fix(workspace): background vector indexing and correct diagram previews

This commit is contained in:
2026-09-06 02:57:56 +08:00
parent 9e0715f9db
commit a5c44c4ac0
21 changed files with 561 additions and 75 deletions
+1
View File
@@ -1131,6 +1131,7 @@ class TranscriptNoteRequest(Contract):
class IndexStatus(Contract): class IndexStatus(Contract):
vector_refresh_required: bool = False
total_notes: int = 0 total_notes: int = 0
total_blocks: int = 0 total_blocks: int = 0
status: Literal["idle", "queued", "running", "failed"] = "idle" status: Literal["idle", "queued", "running", "failed"] = "idle"
+2
View File
@@ -25,6 +25,8 @@ async def lifespan(_: FastAPI):
try: try:
yield yield
finally: finally:
from app.services import index_service
await index_service.shutdown()
await transcription_service.shutdown() await transcription_service.shutdown()
from app.local_models import components from app.local_models import components
await components.shutdown() await components.shutdown()
+1 -1
View File
@@ -286,7 +286,7 @@ async def get_note(note_id: str) -> Note:
@router.patch("/notes/{note_id}", response_model=Note, tags=["Notes"]) @router.patch("/notes/{note_id}", response_model=Note, tags=["Notes"])
async def update_note(note_id: str, request: NoteUpdateRequest) -> Note: async def update_note(note_id: str, request: NoteUpdateRequest) -> Note:
return await note_service.update_note( return await note_service.update_note(
note_id, title=request.title, markdown=request.markdown, tags=request.tags note_id, title=request.title, markdown=request.markdown, tags=request.tags, defer_vectors=True
) )
+144 -39
View File
@@ -1,11 +1,10 @@
"""索引服务:扫描 Vault、全量重建索引、查询索引状态。 """索引服务:后台重建、快照校验与原子替换,不在模型计算期间锁住笔记编辑。"""
MVP 阶段重建是同步的(数据量小),完成后直接返回 completed 的 IndexJob。
索引任务暂存内存(_jobs),不持久化到 SQLite;后续接入异步任务队列时再落到 index_jobs 表。
"""
from __future__ import annotations from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@@ -17,7 +16,7 @@ from app.errors import ApiError
from app.knowledge.parser import parse_note from app.knowledge.parser import parse_note
from app.services.note_service import index_note, prepare_note_index from app.services.note_service import index_note, prepare_note_index
from app.database.db import connect, transaction from app.database.db import connect, transaction
from app.services.coordination import serialized_vault_mutation from app.services.coordination import _vault_mutation_lock
from app.retrieval.vectorstore import SqliteVecStore from app.retrieval.vectorstore import SqliteVecStore
from app.local_models.runtime import LocalEmbedding from app.local_models.runtime import LocalEmbedding
from app.services import note_service from app.services import note_service
@@ -29,6 +28,8 @@ _active_job_id: str | None = None
_last_completed_at: datetime | None = None _last_completed_at: datetime | None = None
_last_error: str | None = None _last_error: str | None = None
MAX_JOBS = 100 MAX_JOBS = 100
_background_task: asyncio.Task | None = None
_logger = logging.getLogger(__name__)
def _remember_job(job: IndexJob) -> None: def _remember_job(job: IndexJob) -> None:
@@ -62,9 +63,10 @@ def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
return result return result
@serialized_vault_mutation
async def rebuild(request: IndexRebuildRequest) -> IndexJob: async def rebuild(request: IndexRebuildRequest) -> IndexJob:
global _active_job_id, _last_completed_at, _last_error global _active_job_id, _last_completed_at, _last_error
if _active_job_id is not None:
raise ApiError(409, "INDEX_BUSY", "索引正在后台计算,请稍后重试。")
job_id = "job_" + uuid4().hex[:12] job_id = "job_" + uuid4().hex[:12]
# 增量重建(scope != all 或指定 note_ids)尚未实现,明确拒绝而非静默全量重建 # 增量重建(scope != all 或指定 note_ids)尚未实现,明确拒绝而非静默全量重建
if request.scope != "all" or request.note_ids: if request.scope != "all" or request.note_ids:
@@ -76,6 +78,8 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
) )
docs = _scan_vault() docs = _scan_vault()
saved_records = {key: repository.get_note_record(key) for key in _pending_notes()}
saved_paths = {record.file_path: record for record in saved_records.values() if record is not None}
_active_job_id = job_id _active_job_id = job_id
_last_error = None _last_error = None
@@ -91,6 +95,10 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
markdown=markdown, file_path=rel, folder=folder, tags=None, markdown=markdown, file_path=rel, folder=folder, tags=None,
created_at=created, updated_at=updated, created_at=created, updated_at=updated,
) )
if saved := saved_paths.get(rel):
parsed = parse_note(markdown=markdown, file_path=rel, folder=folder, tags=saved.tags,
created_at=saved.created_at, updated_at=saved.updated_at, note_id=saved.note_id)
parsed.title = saved.title
prepared = await prepare_note_index(parsed, strict=True) if isinstance(note_service.embedding, LocalEmbedding) else await prepare_note_index(parsed) prepared = await prepare_note_index(parsed, strict=True) if isinstance(note_service.embedding, LocalEmbedding) else await prepare_note_index(parsed)
if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks: if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks:
batch = prepared[1] batch = prepared[1]
@@ -104,37 +112,41 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
prepared_notes.append((parsed, prepared)) prepared_notes.append((parsed, prepared))
# All network/model awaits precede the transaction. The concrete SQLite # All network/model awaits precede the transaction. The concrete SQLite
# methods below complete synchronously despite their async interfaces. # methods below complete synchronously despite their async interfaces.
conn = connect() async with _vault_mutation_lock:
try: if _scan_vault() != docs or saved_records != {key: repository.get_note_record(key) for key in _pending_notes()}:
with transaction(conn): raise ApiError(409, "INDEX_SNAPSHOT_CHANGED", "笔记在计算期间发生变化,稍后重新计算。")
task_note_links = dict(conn.execute( conn = connect()
"SELECT task_id, note_id FROM tasks WHERE note_id IS NOT NULL" try:
).fetchall()) with transaction(conn):
media_links = conn.execute("SELECT job_id,revision,options_hash,note_id FROM media_notes").fetchall() task_note_links = dict(conn.execute(
repository.clear_all(conn=conn) "SELECT task_id, note_id FROM tasks WHERE note_id IS NOT NULL"
await vector_store.clear(conn=conn) ).fetchall())
for parsed, prepared in prepared_notes: media_links = conn.execute("SELECT job_id,revision,options_hash,note_id FROM media_notes").fetchall()
await index_note(parsed, prepared=prepared, conn=conn) repository.clear_all(conn=conn)
for policy, space in semantic_spaces.items(): await vector_store.clear(conn=conn)
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='routed_block_vectors'").fetchone() for parsed, prepared in prepared_notes:
missing = not exists or conn.execute( await index_note(parsed, prepared=prepared, conn=conn)
"SELECT 1 FROM blocks b LEFT JOIN routed_block_vectors r " for policy, space in semantic_spaces.items():
"ON r.block_id=b.block_id AND r.space_id=? AND r.dimensions=? " exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='routed_block_vectors'").fetchone()
"WHERE b.embedding_local_only=? AND r.block_id IS NULL LIMIT 1", (*space, int(policy)), missing = not exists or conn.execute(
).fetchone() "SELECT 1 FROM blocks b LEFT JOIN routed_block_vectors r "
if missing: "ON r.block_id=b.block_id AND r.space_id=? AND r.dimensions=? "
raise ApiError(500, "SEMANTIC_INDEX_WRITE_FAILED", "向量索引写入失败,原索引已保留,请检查数据库和磁盘状态。") "WHERE b.embedding_local_only=? AND r.block_id IS NULL LIMIT 1", (*space, int(policy)),
for task_id, note_id in task_note_links.items(): ).fetchone()
conn.execute( if missing:
"UPDATE tasks SET note_id = ? WHERE task_id = ? " raise ApiError(500, "SEMANTIC_INDEX_WRITE_FAILED", "向量索引写入失败,原索引已保留,请检查数据库和磁盘状态。")
"AND EXISTS (SELECT 1 FROM notes WHERE note_id = ?)", for task_id, note_id in task_note_links.items():
(note_id, task_id, note_id), conn.execute(
) "UPDATE tasks SET note_id = ? WHERE task_id = ? "
for link in media_links: "AND EXISTS (SELECT 1 FROM notes WHERE note_id = ?)",
conn.execute("INSERT OR IGNORE INTO media_notes SELECT ?,?,?,? WHERE EXISTS (SELECT 1 FROM notes WHERE note_id=?)", (note_id, task_id, note_id),
(*link, link["note_id"])) )
finally: for link in media_links:
conn.close() conn.execute("INSERT OR IGNORE INTO media_notes SELECT ?,?,?,? WHERE EXISTS (SELECT 1 FROM notes WHERE note_id=?)",
(*link, link["note_id"]))
repository.set_index_meta({"workspace_vectors_pending": "0"}, conn=conn)
finally:
conn.close()
except BaseException as exc: except BaseException as exc:
_remember_job(IndexJob( _remember_job(IndexJob(
job_id=job_id, status="failed", scope=request.scope, job_id=job_id, status="failed", scope=request.scope,
@@ -148,15 +160,19 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
job = IndexJob(job_id=job_id, status="completed", scope=request.scope, created_at=datetime.now(timezone.utc)) job = IndexJob(job_id=job_id, status="completed", scope=request.scope, created_at=datetime.now(timezone.utc))
_remember_job(job) _remember_job(job)
_last_completed_at = job.created_at _last_completed_at = job.created_at
if _pending_notes():
schedule_workspace_rebuild()
return job return job
def get_status() -> IndexStatus: def get_status() -> IndexStatus:
counts = repository.stats() counts = repository.stats()
vector_refresh_required = repository.get_index_meta().get('workspace_vectors_pending') == '1' or bool(_pending_notes())
if _active_job_id is not None: if _active_job_id is not None:
return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id, return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id, vector_refresh_required=vector_refresh_required,
total_notes=counts["notes"], total_blocks=counts["blocks"]) total_notes=counts["notes"], total_blocks=counts["blocks"])
return IndexStatus( return IndexStatus(
vector_refresh_required=vector_refresh_required,
total_notes=counts["notes"], total_blocks=counts["blocks"], total_notes=counts["notes"], total_blocks=counts["blocks"],
status="failed" if _last_error else "idle", status="failed" if _last_error else "idle",
pending_jobs=0, pending_jobs=0,
@@ -167,3 +183,92 @@ def get_status() -> IndexStatus:
def get_job(job_id: str) -> IndexJob | None: def get_job(job_id: str) -> IndexJob | None:
return _jobs.get(job_id) return _jobs.get(job_id)
def schedule_workspace_rebuild() -> None:
"""单进程去重;任务失败保留待重建标记,重新打开 Vault 可重试。"""
global _background_task
if _background_task is not None and not _background_task.done():
return
if _active_job_id is not None:
return
async def run():
while True:
try:
if repository.get_index_meta().get('workspace_vectors_pending') == '1':
await rebuild(IndexRebuildRequest())
elif pending := _pending_notes():
await _refresh_saved_note(pending[0])
else:
return
except ApiError as exc:
if exc.code == 'INDEX_SNAPSHOT_CHANGED':
await asyncio.sleep(1)
continue
_logger.warning('Background index failed: %s', exc.code)
return
except Exception:
_logger.exception('Background index failed')
return
_background_task = asyncio.create_task(run(), name='workspace-vector-index')
async def shutdown() -> None:
global _background_task
if _background_task is not None:
_background_task.cancel()
await asyncio.gather(_background_task, return_exceptions=True)
_background_task = None
def _pending_notes() -> list[str]:
return [key.split(':', 1)[1] for key, value in repository.get_index_meta().items()
if key.startswith('note_vectors_pending:') and value == '1']
async def _refresh_saved_note(note_id: str) -> None:
global _active_job_id, _last_error, _last_completed_at
record = repository.get_note_record(note_id)
key = f'note_vectors_pending:{note_id}'
if record is None:
repository.set_index_meta({key: '0'})
return
markdown = note_service._read_markdown(record.file_path)
parsed = parse_note(markdown=markdown, file_path=record.file_path, folder=record.folder,
tags=record.tags, created_at=record.created_at,
updated_at=record.updated_at, note_id=note_id)
parsed.title = record.title
job_id = 'job_' + uuid4().hex[:12]
_active_job_id = job_id
_last_error = None
_remember_job(IndexJob(job_id=job_id, status='running', scope='all', created_at=datetime.now(timezone.utc)))
try:
prepared = await prepare_note_index(parsed, strict=True)
if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks and prepared[1] is None:
raise ApiError(503, "EMBEDDING_UNAVAILABLE", "笔记已保存,后台向量计算未完成。")
async with _vault_mutation_lock:
current = repository.get_note_record(note_id)
if current != record or note_service._read_markdown(record.file_path) != markdown:
# Another save or rename won the race; leave the durable queue entry intact.
return
conn = connect()
try:
with transaction(conn):
# Write only vectors: metadata and FTS already represent the saved revision.
vectors, remote = prepared
from app.retrieval.vectorstore import VectorRecord
from app.retrieval import routed_vectors
await vector_store.upsert([VectorRecord(id=b.block_id, vector=v)
for b, v in zip(parsed.blocks, vectors)], conn=conn)
routed_vectors.store_remote(conn, [b.block_id for b in parsed.blocks], remote)
repository.set_index_meta({key: '0'}, conn=conn)
finally:
conn.close()
_last_completed_at = datetime.now(timezone.utc)
_remember_job(IndexJob(job_id=job_id, status='completed', scope='all', created_at=_last_completed_at))
except BaseException as exc:
_last_error = str(exc) or '后台向量计算已中断,笔记已保存。'
_remember_job(IndexJob(job_id=job_id, status='failed', scope='all', created_at=datetime.now(timezone.utc)))
raise
finally:
_active_job_id = None
+22 -2
View File
@@ -181,7 +181,7 @@ async def get_note(note_id: str) -> Note | None:
@serialized_vault_mutation @serialized_vault_mutation
async def update_note( async def update_note(
note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None, expected_content_hash: str | None = None note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None, expected_content_hash: str | None = None, defer_vectors: bool = False
) -> Note: ) -> Note:
record = repository.get_note_record(note_id) record = repository.get_note_record(note_id)
if record is None: if record is None:
@@ -207,10 +207,30 @@ async def update_note(
if title is not None: if title is not None:
parsed.title = title # 显式传入的 title 覆盖正文推导结果 parsed.title = title # 显式传入的 title 覆盖正文推导结果
await index_note(parsed) if defer_vectors:
conn = connect()
try:
with transaction(conn):
old_ids = repository.replace_note_metadata(
conn=conn, note_id=parsed.note_id, title=parsed.title,
file_path=parsed.file_path, folder=parsed.folder, tags=parsed.tags,
created_at=parsed.created_at, updated_at=parsed.updated_at, blocks=parsed.blocks,
)
# Saved content is immediately searchable; old vectors must not describe it.
await vector_store.delete(old_ids, conn=conn)
conn.execute('UPDATE blocks SET embedding_local_only=? WHERE note_id=?',
(int(parsed.embedding_local_only), parsed.note_id))
repository.set_index_meta({f'note_vectors_pending:{parsed.note_id}': '1'}, conn=conn)
finally:
conn.close()
else:
await index_note(parsed)
except BaseException: except BaseException:
_write_markdown(record.file_path, old_md) # 索引失败时回滚正文,避免部分提交 _write_markdown(record.file_path, old_md) # 索引失败时回滚正文,避免部分提交
raise raise
if defer_vectors:
from app.services import index_service
index_service.schedule_workspace_rebuild()
return _build_note(parsed.note_id, parsed.title, parsed.file_path, parsed.tags, return _build_note(parsed.note_id, parsed.title, parsed.file_path, parsed.tags,
parsed.created_at, parsed.updated_at, parsed.blocks, new_md) parsed.created_at, parsed.updated_at, parsed.blocks, new_md)
+37 -3
View File
@@ -11,7 +11,6 @@ from uuid import uuid4
from app import repository from app import repository
from app.config import get_settings from app.config import get_settings
from app.contracts import ( from app.contracts import (
IndexRebuildRequest,
OperationResponse, OperationResponse,
WorkspaceEntry, WorkspaceEntry,
WorkspaceInfo, WorkspaceInfo,
@@ -20,6 +19,7 @@ from app.contracts import (
from app.database.db import connect, transaction from app.database.db import connect, transaction
from app.errors import ApiError from app.errors import ApiError
from app.retrieval.vectorstore import SqliteVecStore from app.retrieval.vectorstore import SqliteVecStore
from app.knowledge.parser import parse_note
from app.services import index_service from app.services import index_service
from app.services.coordination import serialized_vault_mutation from app.services.coordination import serialized_vault_mutation
from app.services.vault_paths import normalize_entry_name, normalize_folder, resolve_in_vault from app.services.vault_paths import normalize_entry_name, normalize_folder, resolve_in_vault
@@ -106,7 +106,7 @@ def get_workspace_tree() -> list[WorkspaceEntry]:
async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot: async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot:
"""打开当前配置 Vault;发现未索引文件时先执行一次安全全量刷新""" """打开只登记文件与全文索引,不让 Embedding 或厂商网络阻塞工作区"""
root = get_settings().vault_path.resolve() root = get_settings().vault_path.resolve()
if requested_path and Path(requested_path).resolve() != root: if requested_path and Path(requested_path).resolve() != root:
@@ -119,11 +119,45 @@ async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot:
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
info = get_workspace_info() info = get_workspace_info()
if info.requires_refresh: if info.requires_refresh:
await index_service.rebuild(IndexRebuildRequest()) await _register_workspace_files()
info = get_workspace_info() info = get_workspace_info()
if index_service.get_status().vector_refresh_required:
index_service.schedule_workspace_rebuild()
return WorkspaceSnapshot(workspace=info, items=get_workspace_tree()) return WorkspaceSnapshot(workspace=info, items=get_workspace_tree())
@serialized_vault_mutation
async def _register_workspace_files() -> None:
root = get_settings().vault_path.resolve()
paths = _disk_markdown_paths()
existing = {item.file_path: item for item in repository.list_note_locations()}
prepared = []
for relative in sorted(paths - existing.keys()):
path = resolve_in_vault(relative)
stat = path.stat()
prepared.append(parse_note(
markdown=path.read_text(encoding='utf-8'), file_path=relative,
folder='' if path.parent == root else path.parent.relative_to(root).as_posix(),
tags=None, created_at=datetime.fromtimestamp(stat.st_ctime, timezone.utc),
updated_at=datetime.fromtimestamp(stat.st_mtime, timezone.utc),
))
conn = connect()
try:
with transaction(conn):
for relative in existing.keys() - paths:
block_ids = repository.delete_note(existing[relative].note_id, conn=conn)
await vector_store.delete(block_ids, conn=conn)
for parsed in prepared:
repository.replace_note_metadata(conn=conn, note_id=parsed.note_id, title=parsed.title,
file_path=parsed.file_path, folder=parsed.folder, tags=parsed.tags,
created_at=parsed.created_at, updated_at=parsed.updated_at, blocks=parsed.blocks)
conn.execute('UPDATE blocks SET embedding_local_only=? WHERE note_id=?', (int(parsed.embedding_local_only), parsed.note_id))
if prepared:
repository.set_index_meta({'workspace_vectors_pending': '1'}, conn=conn)
finally:
conn.close()
@serialized_vault_mutation @serialized_vault_mutation
async def create_folder(parent: str, name: str) -> WorkspaceEntry: async def create_folder(parent: str, name: str) -> WorkspaceEntry:
clean_parent = normalize_folder(parent) clean_parent = normalize_folder(parent)
+131
View File
@@ -0,0 +1,131 @@
import asyncio
from app import repository
from app.config import get_settings
from app.services import index_service, workspace_service
def test_open_returns_before_vectors_and_deduplicates_background(monkeypatch):
async def scenario():
started, release = asyncio.Event(), asyncio.Event()
original = index_service.prepare_note_index
calls = 0
async def slow(*args, **kwargs):
nonlocal calls
calls += 1
started.set()
await release.wait()
return await original(*args, **kwargs)
monkeypatch.setattr(index_service, 'prepare_note_index', slow)
vault = get_settings().vault_path
vault.mkdir(parents=True, exist_ok=True)
(vault / 'demo.md').write_text('# Demo\n\nsearchable content', encoding='utf-8')
try:
snapshot = await asyncio.wait_for(workspace_service.open_workspace(None), 1)
assert snapshot.items[0].note_id
await asyncio.wait_for(started.wait(), 1)
task = index_service._background_task
await asyncio.wait_for(workspace_service.open_workspace(None), 1)
assert index_service._background_task is task
assert index_service.get_status().status == 'running'
# A mutation still completes while the model is waiting.
await asyncio.wait_for(workspace_service.create_folder('/', 'new-folder'), 1)
assert repository.list_note_locations()[0].note_id == snapshot.items[0].note_id
release.set()
await asyncio.wait_for(task, 2)
assert calls == 1
assert not index_service.get_status().vector_refresh_required
finally:
release.set()
await index_service.shutdown()
asyncio.run(scenario())
def test_background_retries_changed_snapshot_without_overwriting(monkeypatch):
async def scenario():
started, release = asyncio.Event(), asyncio.Event()
original = index_service.prepare_note_index
calls = 0
async def slow(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
started.set()
await release.wait()
return await original(*args, **kwargs)
monkeypatch.setattr(index_service, 'prepare_note_index', slow)
vault = get_settings().vault_path
vault.mkdir(parents=True, exist_ok=True)
path = vault / 'demo.md'
path.write_text('# Before\n\nold', encoding='utf-8')
try:
await workspace_service.open_workspace(None)
await asyncio.wait_for(started.wait(), 1)
path.write_text('# After\n\nnew', encoding='utf-8')
release.set()
await asyncio.wait_for(index_service._background_task, 4)
assert calls == 2
assert repository.list_note_locations()[0].title == 'After'
assert not index_service.get_status().vector_refresh_required
finally:
release.set()
await index_service.shutdown()
asyncio.run(scenario())
def test_save_returns_while_vectors_wait_and_latest_revision_wins(monkeypatch):
from app.services import note_service
async def scenario():
note = await note_service.create_note(title='Draft', markdown='# Draft\n\ninitial', folder=None, tags=[])
started, release = asyncio.Event(), asyncio.Event()
original = index_service.prepare_note_index
calls = 0
async def slow(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
started.set()
await release.wait()
return await original(*args, **kwargs)
monkeypatch.setattr(index_service, 'prepare_note_index', slow)
try:
await asyncio.wait_for(note_service.update_note(note.note_id, markdown='# First\n\none', defer_vectors=True), 1)
await asyncio.wait_for(started.wait(), 1)
await asyncio.wait_for(note_service.update_note(note.note_id, title='Custom title', tags=['kept'], markdown='# Latest\n\ntwo', defer_vectors=True), 1)
assert (await note_service.get_note(note.note_id)).markdown == '# Latest\n\ntwo'
assert index_service.get_status().vector_refresh_required
release.set()
await asyncio.wait_for(index_service._background_task, 3)
current = repository.get_note_record(note.note_id)
assert current.title == 'Custom title'
assert current.tags == ['kept']
assert calls == 2
assert not index_service.get_status().vector_refresh_required
finally:
release.set()
await index_service.shutdown()
asyncio.run(scenario())
def test_failed_vectors_do_not_undo_save_and_pending_work_can_resume(monkeypatch):
from app.services import note_service
async def scenario():
note = await note_service.create_note(title='Draft', markdown='# Draft', folder=None, tags=[])
original = index_service.prepare_note_index
async def fail(*args, **kwargs):
raise RuntimeError('model unavailable')
monkeypatch.setattr(index_service, 'prepare_note_index', fail)
try:
await note_service.update_note(note.note_id, markdown='# Saved', defer_vectors=True)
await index_service._background_task
assert (await note_service.get_note(note.note_id)).markdown == '# Saved'
assert index_service.get_status().status == 'failed'
assert index_service.get_status().vector_refresh_required
await index_service.shutdown()
monkeypatch.setattr(index_service, 'prepare_note_index', original)
await workspace_service.open_workspace(None)
await index_service._background_task
assert not index_service.get_status().vector_refresh_required
finally:
await index_service.shutdown()
asyncio.run(scenario())
+10 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
@@ -10,6 +10,7 @@ import SecondarySidebar from './SecondarySidebar.vue'
import StatusBar from './StatusBar.vue' import StatusBar from './StatusBar.vue'
import TitleBar from './TitleBar.vue' import TitleBar from './TitleBar.vue'
import CommandPalette from './CommandPalette.vue' import CommandPalette from './CommandPalette.vue'
import { getIndexStatus } from '@/services/indexService'
import { navigateToCitation } from '@/composables/useCitationNavigation' import { navigateToCitation } from '@/composables/useCitationNavigation'
defineProps<{ defineProps<{
@@ -23,7 +24,14 @@ const settingsStore = useSettingsStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
onMounted(() => { void settingsStore.loadDiagnostics() }) let statusTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
async function pollIndex() {
try { settingsStore.indexStatus = await getIndexStatus() } catch { /* retain last status; retry */ }
if (!disposed) statusTimer = setTimeout(pollIndex, 5000)
}
onMounted(() => { void settingsStore.loadDiagnostics(); void pollIndex() })
onUnmounted(() => { disposed = true; clearTimeout(statusTimer) })
watch(() => settingsStore.defaultEditorMode, (mode) => editorStore.setMode(mode), { immediate: true }) watch(() => settingsStore.defaultEditorMode, (mode) => editorStore.setMode(mode), { immediate: true })
watch(() => settingsStore.editorLineWidth, (width) => { watch(() => settingsStore.editorLineWidth, (width) => {
document.documentElement.style.setProperty('--editor-line-width', `${width}ch`) document.documentElement.style.setProperty('--editor-line-width', `${width}ch`)
@@ -82,3 +82,59 @@ it('zooms directly in the viewer with bounded speed even for a large wheel delta
expect(Number(dialog.querySelector('output')!.textContent!.replace('%', ''))).toBeLessThanOrEqual(105) expect(Number(dialog.querySelector('output')!.textContent!.replace('%', ''))).toBeLessThanOrEqual(105)
wrapper.unmount() wrapper.unmount()
}) })
it('starts wheel zoom from the fitted width instead of the intrinsic SVG width', async () => {
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid"><svg viewBox="0 0 4000 2000"></svg></div>' }, attachTo: document.body })
const svg = wrapper.get('svg').element as SVGSVGElement
vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({ width: 400, height: 200, left: 0, top: 0 } as DOMRect)
await wrapper.get('svg').trigger('mousedown', { button: 1 })
svg.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true }))
expect(parseFloat(svg.style.width)).toBeGreaterThan(400)
expect(parseFloat(svg.style.width)).toBeLessThanOrEqual(420)
wrapper.unmount()
})
it('keeps the cursor point fixed by adjusting the scroll container during zoom', async () => {
let frame: FrameRequestCallback | undefined
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frame = callback; return 1 })
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid" style="overflow-x:auto;overflow-y:auto"><svg viewBox="0 0 400 200"></svg></div>' }, attachTo: document.body })
try {
const container = wrapper.get('.markdown-mermaid').element as HTMLElement
const svg = wrapper.get('svg').element as SVGSVGElement
vi.spyOn(svg, 'getBoundingClientRect').mockImplementation(() => {
const width = parseFloat(svg.style.width) || 400
return { width, height: width / 2, left: -container.scrollLeft, top: -container.scrollTop } as DOMRect
})
await wrapper.get('svg').trigger('mousedown', { button: 1, clientX: 100, clientY: 50 })
svg.dispatchEvent(new WheelEvent('wheel', { clientX: 100, clientY: 50, deltaY: -100, bubbles: true, cancelable: true }))
frame?.(performance.now())
const rect = svg.getBoundingClientRect()
expect(rect.left + rect.width * .25).toBeCloseTo(100)
expect(rect.top + rect.height * .25).toBeCloseTo(50)
expect(container.scrollLeft).toBeGreaterThan(0)
} finally {
wrapper.unmount()
raf.mockRestore(); cancel.mockRestore()
}
})
it('fits the full chart on open and clears the previous viewport scroll', async () => {
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid"><svg viewBox="0 0 2400 200"><text>Final task</text></svg><button data-diagram-action="view">View</button></div>' }, attachTo: document.body })
const dialog = document.querySelector('dialog')!
dialog.showModal = vi.fn()
const viewport = dialog.querySelector('.diagram-viewer-scroll') as HTMLElement
Object.defineProperty(viewport, 'clientWidth', { value: 1000 })
Object.defineProperty(viewport, 'clientHeight', { value: 600 })
viewport.scrollLeft = 900
viewport.scrollTop = 30
await wrapper.get('[data-diagram-action="view"]').trigger('click')
await flushPromises()
expect((dialog.querySelector('.diagram-viewer-image') as HTMLElement).style.width).toBe('1000px')
expect(viewport.scrollLeft).toBe(0)
expect(viewport.scrollTop).toBe(0)
expect(dialog.textContent).toContain('Final task')
wrapper.unmount()
})
@@ -13,6 +13,38 @@ let wheelTarget: HTMLElement | null = null
let anchor = { x: 0, y: 0 } let anchor = { x: 0, y: 0 }
const wheelActive = ref(false) const wheelActive = ref(false)
let lastWheel = 0 let lastWheel = 0
const zoomBases = new WeakMap<HTMLElement, number>()
let anchorFrame = 0
let anchorUntil = 0
function stopAnchoring() { cancelAnimationFrame(anchorFrame); anchorFrame = 0 }
function anchorZoom(svg: SVGSVGElement, event: WheelEvent) {
stopAnchoring()
const rect = svg.getBoundingClientRect()
if (!rect.width || !rect.height) return
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))
const screenX = rect.left + x * rect.width
const screenY = rect.top + y * rect.height
const scrollers: HTMLElement[] = []
for (let node = svg.parentElement; node; node = node.parentElement) {
const style = getComputedStyle(node)
if (/(auto|scroll)/.test(`${style.overflowX} ${style.overflowY}`)) scrollers.push(node)
if (node === viewer.value) break
}
anchorUntil = performance.now() + 240
const follow = () => {
if (!svg.isConnected) return
// Inner horizontal overflow and the editor's outer vertical scroll may differ.
// Re-measure after each scroll, letting the outer container take the remainder.
for (const node of scrollers) {
const current = svg.getBoundingClientRect()
node.scrollLeft += current.left + x * current.width - screenX
node.scrollTop += current.top + y * current.height - screenY
}
if (performance.now() < anchorUntil) anchorFrame = requestAnimationFrame(follow)
}
anchorFrame = requestAnimationFrame(follow)
}
function wheelFactor(event: WheelEvent) { function wheelFactor(event: WheelEvent) {
const now = performance.now() const now = performance.now()
const elapsed = lastWheel ? Math.min(100, Math.max(0, now - lastWheel)) : 80 const elapsed = lastWheel ? Math.min(100, Math.max(0, now - lastWheel)) : 80
@@ -22,9 +54,12 @@ function wheelFactor(event: WheelEvent) {
} }
function viewerWheel(event: WheelEvent) { function viewerWheel(event: WheelEvent) {
event.preventDefault(); event.stopPropagation() event.preventDefault(); event.stopPropagation()
const svg = viewer.value?.querySelector<SVGSVGElement>('.diagram-viewer-image svg')
if (svg) anchorZoom(svg, event)
scale.value = Math.max(.2, Math.min(5, scale.value * wheelFactor(event))) scale.value = Math.max(.2, Math.min(5, scale.value * wheelFactor(event)))
} }
function disarm() { function disarm() {
stopAnchoring(); lastWheel = 0
wheelTarget?.removeAttribute('data-wheel-zoom') wheelTarget?.removeAttribute('data-wheel-zoom')
wheelTarget = null; wheelActive.value = false wheelTarget = null; wheelActive.value = false
document.removeEventListener('mousemove', moved, true) document.removeEventListener('mousemove', moved, true)
@@ -46,6 +81,8 @@ function arm(event: MouseEvent) {
function wheel(event: WheelEvent) { function wheel(event: WheelEvent) {
if (!wheelTarget?.isConnected || !(event.target instanceof Node) || !wheelTarget.contains(event.target)) { disarm(); return } if (!wheelTarget?.isConnected || !(event.target instanceof Node) || !wheelTarget.contains(event.target)) { disarm(); return }
event.preventDefault(); event.stopPropagation() event.preventDefault(); event.stopPropagation()
const svg = wheelTarget.querySelector<SVGSVGElement>('svg')
if (svg) anchorZoom(svg, event)
const factor = wheelFactor(event) const factor = wheelFactor(event)
if (wheelTarget.classList.contains('diagram-viewer-image')) scale.value = Math.max(.2, Math.min(5, scale.value * factor)) if (wheelTarget.classList.contains('diagram-viewer-image')) scale.value = Math.max(.2, Math.min(5, scale.value * factor))
else zoom(wheelTarget, Math.max(.2, Math.min(5, Number(wheelTarget.dataset.diagramScale || 1) * factor))) else zoom(wheelTarget, Math.max(.2, Math.min(5, Number(wheelTarget.dataset.diagramScale || 1) * factor)))
@@ -53,10 +90,12 @@ function wheel(event: WheelEvent) {
function zoom(diagram: HTMLElement, next: number) { function zoom(diagram: HTMLElement, next: number) {
const svg = diagram.querySelector<SVGSVGElement>('svg') const svg = diagram.querySelector<SVGSVGElement>('svg')
if (!svg) return if (!svg) return
if (!zoomBases.has(diagram)) zoomBases.set(diagram, svg.getBoundingClientRect().width || widthOf(svg))
diagram.dataset.diagramScale = String(next) diagram.dataset.diagramScale = String(next)
svg.style.width = next === 1 ? '' : `${widthOf(svg) * next}px` svg.style.width = next === 1 ? '' : `${zoomBases.get(diagram)! * next}px`
svg.style.maxWidth = next === 1 ? '' : 'none' svg.style.maxWidth = next === 1 ? '' : 'none'
svg.style.height = 'auto' svg.style.height = 'auto'
if (next === 1) zoomBases.delete(diagram)
} }
onBeforeUnmount(disarm) onBeforeUnmount(disarm)
function widthOf(svg: SVGSVGElement) { function widthOf(svg: SVGSVGElement) {
@@ -72,8 +111,9 @@ async function interact(event: MouseEvent) {
event.stopPropagation() event.stopPropagation()
const action = button.dataset.diagramAction const action = button.dataset.diagramAction
if (action === 'view') { if (action === 'view') {
disarm()
opener = button opener = button
baseWidth.value = widthOf(svg) const intrinsicWidth = widthOf(svg)
// Mermaid HTML labels live in SVG foreignObject nodes. Preserve that // Mermaid HTML labels live in SVG foreignObject nodes. Preserve that
// integration point while still sanitizing the embedded HTML and handlers. // integration point while still sanitizing the embedded HTML and handlers.
const copy = svg.cloneNode(true) as SVGSVGElement const copy = svg.cloneNode(true) as SVGSVGElement
@@ -88,6 +128,15 @@ async function interact(event: MouseEvent) {
scale.value = 1 scale.value = 1
await nextTick() await nextTick()
viewer.value?.showModal() viewer.value?.showModal()
const viewport = viewer.value?.querySelector<HTMLElement>('.diagram-viewer-scroll')
const box = svg.getAttribute('viewBox')?.trim().split(/[ ,]+/).map(Number)
const intrinsicHeight = box?.length === 4 && box[3]! > 0 ? box[3]! : svg.getBoundingClientRect().height
// Opening is independent of the inline preview's zoom and any previous modal scroll.
// Keep native size for small diagrams; fit wide/tall diagrams completely at 100%.
baseWidth.value = Math.min(intrinsicWidth, viewport?.clientWidth || intrinsicWidth,
intrinsicHeight > 0 && viewport?.clientHeight ? viewport.clientHeight * intrinsicWidth / intrinsicHeight : intrinsicWidth)
await nextTick()
if (viewport) { viewport.scrollLeft = 0; viewport.scrollTop = 0 }
return return
} }
const previous = Number(diagram.dataset.diagramScale || 1) const previous = Number(diagram.dataset.diagramScale || 1)
@@ -122,14 +171,15 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
.diagram-controls button { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-primary); background: var(--color-surface-primary); cursor: pointer; font: inherit; font-size: 12px; } .diagram-controls button { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-primary); background: var(--color-surface-primary); cursor: pointer; font: inherit; font-size: 12px; }
.diagram-controls button:hover { border-color: var(--color-accent-primary); } .diagram-controls button:hover { border-color: var(--color-accent-primary); }
.diagram-controls button:focus-visible { outline: 2px solid var(--color-accent-primary); } .diagram-controls button:focus-visible { outline: 2px solid var(--color-accent-primary); }
.diagram-viewer { width: min(1200px, 94vw); max-width: 94vw; height: 85vh; padding: 16px; color: var(--color-text-primary); background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); } .diagram-viewer { margin: auto; width: min(1200px, 94vw); max-width: 94vw; height: 85vh; padding: 16px; color: var(--color-text-primary); background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
.diagram-viewer[open] { display: flex; flex-direction: column; gap: var(--space-md); overflow: hidden; } .diagram-viewer[open] { display: flex; flex-direction: column; gap: var(--space-md); overflow: hidden; }
.diagram-viewer::backdrop { background: var(--color-background-overlay); } .diagram-viewer::backdrop { background: var(--color-background-overlay); }
.diagram-viewer header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); flex-shrink: 0; } .diagram-viewer header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); flex-shrink: 0; }
.diagram-viewer header .diagram-controls { flex-wrap: wrap; } .diagram-viewer header .diagram-controls { flex-wrap: wrap; }
.diagram-viewer-scroll { flex: 1; min-height: 0; overflow: auto; } .diagram-viewer-scroll { display: flex; flex: 1; min-height: 0; overflow: auto; }
.diagram-viewer-image { margin: auto; transition: width 180ms ease-out; } /* Auto margins center small diagrams and become zero on overflow, keeping all edges reachable. */
.diagram-viewer-image svg { width: 100% !important; max-width: none !important; height: auto !important; } .diagram-viewer-image { flex: 0 0 auto; margin: auto; transition: width 180ms ease-out; }
.diagram-viewer-image svg { display: block; width: 100% !important; max-width: none !important; height: auto !important; }
</style> </style>
<style> <style>
+2 -1
View File
@@ -40,7 +40,8 @@ const saveStatusColor = computed(() => {
const indexStatusText = computed(() => { const indexStatusText = computed(() => {
const s = settingsStore.indexStatus.status const s = settingsStore.indexStatus.status
return s === 'unknown' ? t('索引状态未获取', 'Index status unavailable') : s === 'idle' ? t('索引就绪', 'Index ready') : s === 'indexing' ? `${t('索引中', 'Indexing')} (${settingsStore.indexStatus.pending_jobs})` : t('索引错误', 'Index error') if (s === 'idle' && settingsStore.indexStatus.vector_refresh_required) return t('全文可用 · 向量待重建', 'Full text ready · vectors need rebuilding')
return s === 'unknown' ? t('索引状态未获取', 'Index status unavailable') : s === 'idle' ? t('索引就绪', 'Index ready') : s === 'indexing' ? t('后台计算索引', 'Indexing in background') : t('索引错误', 'Index error')
}) })
const aiCoreStatusText = computed(() => { const aiCoreStatusText = computed(() => {
+2
View File
@@ -507,6 +507,7 @@ export interface ThemeConfig {
// ============ Index ============ // ============ Index ============
export interface IndexStatus { export interface IndexStatus {
vector_refresh_required?: boolean
status: 'unknown' | 'idle' | 'indexing' | 'error' status: 'unknown' | 'idle' | 'indexing' | 'error'
pending_jobs: number pending_jobs: number
total_notes: number | null total_notes: number | null
@@ -800,6 +801,7 @@ export interface ApiTask {
} }
export interface ApiIndexStatus { export interface ApiIndexStatus {
vector_refresh_required?: boolean
total_notes: number total_notes: number
total_blocks: number total_blocks: number
status: 'idle' | 'queued' | 'running' | 'failed' status: 'idle' | 'queued' | 'running' | 'failed'
@@ -408,6 +408,9 @@ defineExpose({ getEditor: () => crepe?.editor })
.milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); } .milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); }
.milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); } .milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); }
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; } .milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
/* Mermaid measures HTML labels outside the editor. Crepe's paragraph padding
must not enlarge them after insertion into fixed-size SVG foreignObjects. */
.milkdown-host :deep(.editor-mermaid-preview svg foreignObject p) { margin: 0; padding: 0; line-height: inherit; font-weight: inherit; }
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; } .milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
.milkdown-host :deep(.font-size-marker) { display: none; } .milkdown-host :deep(.font-size-marker) { display: none; }
.milkdown-host :deep(.milkdown-code-block) { overflow: visible; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); } .milkdown-host :deep(.milkdown-code-block) { overflow: visible; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
+22 -14
View File
@@ -7,6 +7,7 @@ import { useSettingsStore } from '@/stores/settings'
import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue' import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n' import { t } from '@/i18n'
import { ApiErrorClass } from '@/services/apiClient'
const router = useRouter() const router = useRouter()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
@@ -15,29 +16,34 @@ const settingsStore = useSettingsStore()
const isLoading = ref(false) const isLoading = ref(false)
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking') const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
const openError = ref('')
onMounted(async () => { onMounted(() => { void initializeVault() })
await Promise.allSettled([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
async function initializeVault() {
openError.value = ''
void settingsStore.loadDiagnostics().then(() => {
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
}).catch(() => { aiCoreStatus.value = 'stopped' })
try { await workspaceStore.loadRecentVaults() }
catch (reason) { openError.value = reason instanceof Error ? reason.message : String(reason); return }
const lastVaultPath = localStorage.getItem('last-vault-path') const lastVaultPath = localStorage.getItem('last-vault-path')
if (settingsStore.restoreLastVault && lastVaultPath) { if (settingsStore.restoreLastVault && lastVaultPath) {
try { await openVault(lastVaultPath)
await openVault(lastVaultPath)
return
} catch {
// Vault
localStorage.removeItem('last-vault-path')
}
} }
{ }
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
}
})
async function openVault(path: string) { async function openVault(path: string) {
if (isLoading.value) return
isLoading.value = true isLoading.value = true
openError.value = ''
try { try {
await workspaceStore.openVault(path) await workspaceStore.openVault(path)
router.push('/workspace') await router.push('/workspace')
void settingsStore.loadDiagnostics()
} catch (reason) {
openError.value = reason instanceof Error ? reason.message : String(reason)
if (reason instanceof ApiErrorClass && reason.code === 'WORKSPACE_PATH_MISMATCH') localStorage.removeItem('last-vault-path')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -60,6 +66,8 @@ async function openFolderPicker() {
</div> </div>
<div class="vault-card"> <div class="vault-card">
<div v-if="openError" class="error-banner" role="alert">{{ openError }} <button class="btn" @click="initializeVault" :disabled="isLoading">{{ t('重试', 'Retry') }}</button></div>
<p v-if="isLoading" role="status">{{ t('正在打开知识库', 'Opening knowledge base') }}</p>
<h2 class="card-title">{{ t('选择知识库', 'Select Knowledge Base') }}</h2> <h2 class="card-title">{{ t('选择知识库', 'Select Knowledge Base') }}</h2>
<p class="card-desc">{{ t('Web 联调模式连接 AI Core 当前配置的 Vault', 'Web development mode connects to the Vault configured in AI Core') }}</p> <p class="card-desc">{{ t('Web 联调模式连接 AI Core 当前配置的 Vault', 'Web development mode connects to the Vault configured in AI Core') }}</p>
+26
View File
@@ -0,0 +1,26 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import apiClient from './apiClient'
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() })
it('aborts a stuck request with an actionable timeout', async () => {
vi.useFakeTimers()
let signal: AbortSignal | undefined
vi.stubGlobal('fetch', vi.fn((_url, options) => new Promise((_resolve, reject) => {
signal = options.signal
signal?.addEventListener('abort', () => reject(new Error('aborted')))
})))
const assertion = expect(apiClient.get('/api/workspace', { timeoutMs: 15000 })).rejects.toMatchObject({ code: 'REQUEST_TIMEOUT' })
await vi.advanceTimersByTimeAsync(15000)
await assertion
expect(signal?.aborted).toBe(true)
expect(vi.getTimerCount()).toBe(0)
})
it('clears the deadline after success', async () => {
vi.useFakeTimers()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"ok":true}', { headers: { 'Content-Type': 'application/json' } })))
expect(await apiClient.get('/health', { timeoutMs: 10000 })).toEqual({ ok: true })
expect(vi.getTimerCount()).toBe(0)
})
+13 -1
View File
@@ -9,6 +9,7 @@ export function resolveApiUrl(path: string): string {
} }
interface RequestOptions extends RequestInit { interface RequestOptions extends RequestInit {
timeoutMs?: number
params?: Record<string, string | number | boolean | undefined> params?: Record<string, string | number | boolean | undefined>
token?: string token?: string
} }
@@ -26,7 +27,13 @@ export class ApiErrorClass extends Error {
} }
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> { async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { params, token, headers, ...rest } = options const { params, token, headers, timeoutMs, ...rest } = options
const controller = timeoutMs ? new AbortController() : null
let timedOut = false
const abort = () => controller?.abort()
if (rest.signal?.aborted) abort()
rest.signal?.addEventListener('abort', abort, { once: true })
const timer = timeoutMs ? setTimeout(() => { timedOut = true; controller?.abort() }, timeoutMs) : undefined
let url = resolveApiUrl(path) let url = resolveApiUrl(path)
@@ -54,6 +61,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
try { try {
const resp = await fetch(url, { const resp = await fetch(url, {
...rest, ...rest,
signal: controller?.signal ?? rest.signal,
headers: reqHeaders, headers: reqHeaders,
}) })
@@ -78,8 +86,12 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
throw new ApiErrorClass(code, message, details) throw new ApiErrorClass(code, message, details)
} catch (e) { } catch (e) {
if (timedOut) throw new ApiErrorClass('REQUEST_TIMEOUT', '请求超时,请检查后端状态后重试。')
if (e instanceof ApiErrorClass) throw e if (e instanceof ApiErrorClass) throw e
throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error') throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error')
} finally {
if (timer) clearTimeout(timer)
rest.signal?.removeEventListener('abort', abort)
} }
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import type { ApiIndexJob, ApiIndexStatus, IndexStatus } from '@/contracts'
function toIndexStatus(status: ApiIndexStatus): IndexStatus { function toIndexStatus(status: ApiIndexStatus): IndexStatus {
return { return {
vector_refresh_required: status.vector_refresh_required ?? false,
status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing', status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
pending_jobs: status.pending_jobs, pending_jobs: status.pending_jobs,
total_notes: status.total_notes ?? null, total_notes: status.total_notes ?? null,
@@ -13,7 +14,7 @@ function toIndexStatus(status: ApiIndexStatus): IndexStatus {
} }
export async function getIndexStatus(): Promise<IndexStatus> { export async function getIndexStatus(): Promise<IndexStatus> {
return toIndexStatus(await apiClient.get<ApiIndexStatus>('/api/index/status')) return toIndexStatus(await apiClient.get<ApiIndexStatus>('/api/index/status', { timeoutMs: 10000 }))
} }
export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<ApiIndexJob> { export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<ApiIndexJob> {
+3 -3
View File
@@ -2,13 +2,13 @@ import apiClient from './apiClient'
import type { SystemStatus } from '@/contracts' import type { SystemStatus } from '@/contracts'
export function healthCheck(): Promise<{ status: string }> { export function healthCheck(): Promise<{ status: string }> {
return apiClient.get('/health') return apiClient.get('/health', { timeoutMs: 10000 })
} }
export function getStatus(): Promise<SystemStatus> { export function getStatus(): Promise<SystemStatus> {
return apiClient.get('/api/status') return apiClient.get('/api/status', { timeoutMs: 10000 })
} }
export function getPermissionPolicy(): Promise<Record<string, 'allow' | 'confirm' | 'deny'>> { export function getPermissionPolicy(): Promise<Record<string, 'allow' | 'confirm' | 'deny'>> {
return apiClient.get('/api/permissions/policy') return apiClient.get('/api/permissions/policy', { timeoutMs: 10000 })
} }
+2 -2
View File
@@ -79,7 +79,7 @@ async function requireNoteId(filePath: string): Promise<string> {
} }
export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> { export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
return apiClient.get('/api/workspace') return apiClient.get('/api/workspace', { timeoutMs: 15000 })
} }
export async function getRecentVaults(): Promise<VaultInfo[]> { export async function getRecentVaults(): Promise<VaultInfo[]> {
@@ -88,7 +88,7 @@ export async function getRecentVaults(): Promise<VaultInfo[]> {
} }
export async function openVault(path: string): Promise<VaultInfo> { export async function openVault(path: string): Promise<VaultInfo> {
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }) const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 })
cacheEntries(snapshot.items) cacheEntries(snapshot.items)
return { return {
vault_id: snapshot.workspace.vault_id, vault_id: snapshot.workspace.vault_id,
+1
View File
@@ -67,6 +67,7 @@ export const useEditorStore = defineStore('editor', () => {
if (currentFilePath.value === targetPath) saveStatus.value = 'save_failed' if (currentFilePath.value === targetPath) saveStatus.value = 'save_failed'
} finally { } finally {
pendingSave = null pendingSave = null
if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave()
} }
})() })()
return pendingSave return pendingSave
+25
View File
@@ -0,0 +1,25 @@
// @vitest-environment happy-dom
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, expect, it, vi } from 'vitest'
import { useEditorStore } from './editor'
import * as workspace from '@/services/workspaceService'
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() })
it('saves text typed while the previous save is still pending', async () => {
vi.useFakeTimers()
setActivePinia(createPinia())
const store = useEditorStore()
store.currentFilePath = '/draft.md'
let release!: () => void
const write = vi.spyOn(workspace, 'saveFileContent').mockImplementationOnce(() => new Promise<void>(resolve => { release = resolve })).mockResolvedValue()
store.updateContent('first')
const saving = store.save()
store.updateContent('latest')
release()
await saving
expect(store.saveStatus).toBe('dirty')
await vi.advanceTimersByTimeAsync(1500)
expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest')
expect(store.saveStatus).toBe('saved')
})