fix(workspace): background vector indexing and correct diagram previews
This commit is contained in:
@@ -1131,6 +1131,7 @@ class TranscriptNoteRequest(Contract):
|
||||
|
||||
|
||||
class IndexStatus(Contract):
|
||||
vector_refresh_required: bool = False
|
||||
total_notes: int = 0
|
||||
total_blocks: int = 0
|
||||
status: Literal["idle", "queued", "running", "failed"] = "idle"
|
||||
|
||||
@@ -25,6 +25,8 @@ async def lifespan(_: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
from app.services import index_service
|
||||
await index_service.shutdown()
|
||||
await transcription_service.shutdown()
|
||||
from app.local_models import components
|
||||
await components.shutdown()
|
||||
|
||||
@@ -286,7 +286,7 @@ async def get_note(note_id: str) -> Note:
|
||||
@router.patch("/notes/{note_id}", response_model=Note, tags=["Notes"])
|
||||
async def update_note(note_id: str, request: NoteUpdateRequest) -> 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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""索引服务:扫描 Vault、全量重建索引、查询索引状态。
|
||||
|
||||
MVP 阶段重建是同步的(数据量小),完成后直接返回 completed 的 IndexJob。
|
||||
索引任务暂存内存(_jobs),不持久化到 SQLite;后续接入异步任务队列时再落到 index_jobs 表。
|
||||
"""
|
||||
"""索引服务:后台重建、快照校验与原子替换,不在模型计算期间锁住笔记编辑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
@@ -17,7 +16,7 @@ from app.errors import ApiError
|
||||
from app.knowledge.parser import parse_note
|
||||
from app.services.note_service import index_note, prepare_note_index
|
||||
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.local_models.runtime import LocalEmbedding
|
||||
from app.services import note_service
|
||||
@@ -29,6 +28,8 @@ _active_job_id: str | None = None
|
||||
_last_completed_at: datetime | None = None
|
||||
_last_error: str | None = None
|
||||
MAX_JOBS = 100
|
||||
_background_task: asyncio.Task | None = None
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _remember_job(job: IndexJob) -> None:
|
||||
@@ -62,9 +63,10 @@ def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
|
||||
return result
|
||||
|
||||
|
||||
@serialized_vault_mutation
|
||||
async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
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]
|
||||
# 增量重建(scope != all 或指定 note_ids)尚未实现,明确拒绝而非静默全量重建
|
||||
if request.scope != "all" or request.note_ids:
|
||||
@@ -76,6 +78,8 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
)
|
||||
|
||||
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
|
||||
_last_error = None
|
||||
@@ -91,6 +95,10 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
markdown=markdown, file_path=rel, folder=folder, tags=None,
|
||||
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)
|
||||
if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks:
|
||||
batch = prepared[1]
|
||||
@@ -104,37 +112,41 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
prepared_notes.append((parsed, prepared))
|
||||
# All network/model awaits precede the transaction. The concrete SQLite
|
||||
# methods below complete synchronously despite their async interfaces.
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
task_note_links = dict(conn.execute(
|
||||
"SELECT task_id, note_id FROM tasks WHERE note_id IS NOT NULL"
|
||||
).fetchall())
|
||||
media_links = conn.execute("SELECT job_id,revision,options_hash,note_id FROM media_notes").fetchall()
|
||||
repository.clear_all(conn=conn)
|
||||
await vector_store.clear(conn=conn)
|
||||
for parsed, prepared in prepared_notes:
|
||||
await index_note(parsed, prepared=prepared, conn=conn)
|
||||
for policy, space in semantic_spaces.items():
|
||||
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='routed_block_vectors'").fetchone()
|
||||
missing = not exists or conn.execute(
|
||||
"SELECT 1 FROM blocks b LEFT JOIN routed_block_vectors r "
|
||||
"ON r.block_id=b.block_id AND r.space_id=? AND r.dimensions=? "
|
||||
"WHERE b.embedding_local_only=? AND r.block_id IS NULL LIMIT 1", (*space, int(policy)),
|
||||
).fetchone()
|
||||
if missing:
|
||||
raise ApiError(500, "SEMANTIC_INDEX_WRITE_FAILED", "向量索引写入失败,原索引已保留,请检查数据库和磁盘状态。")
|
||||
for task_id, note_id in task_note_links.items():
|
||||
conn.execute(
|
||||
"UPDATE tasks SET note_id = ? WHERE task_id = ? "
|
||||
"AND EXISTS (SELECT 1 FROM notes WHERE note_id = ?)",
|
||||
(note_id, task_id, note_id),
|
||||
)
|
||||
for link in media_links:
|
||||
conn.execute("INSERT OR IGNORE INTO media_notes SELECT ?,?,?,? WHERE EXISTS (SELECT 1 FROM notes WHERE note_id=?)",
|
||||
(*link, link["note_id"]))
|
||||
finally:
|
||||
conn.close()
|
||||
async with _vault_mutation_lock:
|
||||
if _scan_vault() != docs or saved_records != {key: repository.get_note_record(key) for key in _pending_notes()}:
|
||||
raise ApiError(409, "INDEX_SNAPSHOT_CHANGED", "笔记在计算期间发生变化,稍后重新计算。")
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
task_note_links = dict(conn.execute(
|
||||
"SELECT task_id, note_id FROM tasks WHERE note_id IS NOT NULL"
|
||||
).fetchall())
|
||||
media_links = conn.execute("SELECT job_id,revision,options_hash,note_id FROM media_notes").fetchall()
|
||||
repository.clear_all(conn=conn)
|
||||
await vector_store.clear(conn=conn)
|
||||
for parsed, prepared in prepared_notes:
|
||||
await index_note(parsed, prepared=prepared, conn=conn)
|
||||
for policy, space in semantic_spaces.items():
|
||||
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='routed_block_vectors'").fetchone()
|
||||
missing = not exists or conn.execute(
|
||||
"SELECT 1 FROM blocks b LEFT JOIN routed_block_vectors r "
|
||||
"ON r.block_id=b.block_id AND r.space_id=? AND r.dimensions=? "
|
||||
"WHERE b.embedding_local_only=? AND r.block_id IS NULL LIMIT 1", (*space, int(policy)),
|
||||
).fetchone()
|
||||
if missing:
|
||||
raise ApiError(500, "SEMANTIC_INDEX_WRITE_FAILED", "向量索引写入失败,原索引已保留,请检查数据库和磁盘状态。")
|
||||
for task_id, note_id in task_note_links.items():
|
||||
conn.execute(
|
||||
"UPDATE tasks SET note_id = ? WHERE task_id = ? "
|
||||
"AND EXISTS (SELECT 1 FROM notes WHERE note_id = ?)",
|
||||
(note_id, task_id, note_id),
|
||||
)
|
||||
for link in media_links:
|
||||
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:
|
||||
_remember_job(IndexJob(
|
||||
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))
|
||||
_remember_job(job)
|
||||
_last_completed_at = job.created_at
|
||||
if _pending_notes():
|
||||
schedule_workspace_rebuild()
|
||||
return job
|
||||
|
||||
|
||||
def get_status() -> IndexStatus:
|
||||
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:
|
||||
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"])
|
||||
return IndexStatus(
|
||||
vector_refresh_required=vector_refresh_required,
|
||||
total_notes=counts["notes"], total_blocks=counts["blocks"],
|
||||
status="failed" if _last_error else "idle",
|
||||
pending_jobs=0,
|
||||
@@ -167,3 +183,92 @@ def get_status() -> IndexStatus:
|
||||
|
||||
def get_job(job_id: str) -> IndexJob | None:
|
||||
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
|
||||
|
||||
@@ -181,7 +181,7 @@ async def get_note(note_id: str) -> Note | None:
|
||||
|
||||
@serialized_vault_mutation
|
||||
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:
|
||||
record = repository.get_note_record(note_id)
|
||||
if record is None:
|
||||
@@ -207,10 +207,30 @@ async def update_note(
|
||||
if title is not None:
|
||||
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:
|
||||
_write_markdown(record.file_path, old_md) # 索引失败时回滚正文,避免部分提交
|
||||
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,
|
||||
parsed.created_at, parsed.updated_at, parsed.blocks, new_md)
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from uuid import uuid4
|
||||
from app import repository
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
IndexRebuildRequest,
|
||||
OperationResponse,
|
||||
WorkspaceEntry,
|
||||
WorkspaceInfo,
|
||||
@@ -20,6 +19,7 @@ from app.contracts import (
|
||||
from app.database.db import connect, transaction
|
||||
from app.errors import ApiError
|
||||
from app.retrieval.vectorstore import SqliteVecStore
|
||||
from app.knowledge.parser import parse_note
|
||||
from app.services import index_service
|
||||
from app.services.coordination import serialized_vault_mutation
|
||||
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:
|
||||
"""打开当前配置 Vault;发现未索引文件时先执行一次安全全量刷新。"""
|
||||
"""打开只登记文件与全文索引,不让 Embedding 或厂商网络阻塞工作区。"""
|
||||
|
||||
root = get_settings().vault_path.resolve()
|
||||
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)
|
||||
info = get_workspace_info()
|
||||
if info.requires_refresh:
|
||||
await index_service.rebuild(IndexRebuildRequest())
|
||||
await _register_workspace_files()
|
||||
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())
|
||||
|
||||
|
||||
@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
|
||||
async def create_folder(parent: str, name: str) -> WorkspaceEntry:
|
||||
clean_parent = normalize_folder(parent)
|
||||
|
||||
@@ -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())
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -10,6 +10,7 @@ import SecondarySidebar from './SecondarySidebar.vue'
|
||||
import StatusBar from './StatusBar.vue'
|
||||
import TitleBar from './TitleBar.vue'
|
||||
import CommandPalette from './CommandPalette.vue'
|
||||
import { getIndexStatus } from '@/services/indexService'
|
||||
import { navigateToCitation } from '@/composables/useCitationNavigation'
|
||||
|
||||
defineProps<{
|
||||
@@ -23,7 +24,14 @@ const settingsStore = useSettingsStore()
|
||||
const route = useRoute()
|
||||
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.editorLineWidth, (width) => {
|
||||
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)
|
||||
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 }
|
||||
const wheelActive = ref(false)
|
||||
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) {
|
||||
const now = performance.now()
|
||||
const elapsed = lastWheel ? Math.min(100, Math.max(0, now - lastWheel)) : 80
|
||||
@@ -22,9 +54,12 @@ function wheelFactor(event: WheelEvent) {
|
||||
}
|
||||
function viewerWheel(event: WheelEvent) {
|
||||
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)))
|
||||
}
|
||||
function disarm() {
|
||||
stopAnchoring(); lastWheel = 0
|
||||
wheelTarget?.removeAttribute('data-wheel-zoom')
|
||||
wheelTarget = null; wheelActive.value = false
|
||||
document.removeEventListener('mousemove', moved, true)
|
||||
@@ -46,6 +81,8 @@ function arm(event: MouseEvent) {
|
||||
function wheel(event: WheelEvent) {
|
||||
if (!wheelTarget?.isConnected || !(event.target instanceof Node) || !wheelTarget.contains(event.target)) { disarm(); return }
|
||||
event.preventDefault(); event.stopPropagation()
|
||||
const svg = wheelTarget.querySelector<SVGSVGElement>('svg')
|
||||
if (svg) anchorZoom(svg, event)
|
||||
const factor = wheelFactor(event)
|
||||
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)))
|
||||
@@ -53,10 +90,12 @@ function wheel(event: WheelEvent) {
|
||||
function zoom(diagram: HTMLElement, next: number) {
|
||||
const svg = diagram.querySelector<SVGSVGElement>('svg')
|
||||
if (!svg) return
|
||||
if (!zoomBases.has(diagram)) zoomBases.set(diagram, svg.getBoundingClientRect().width || widthOf(svg))
|
||||
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.height = 'auto'
|
||||
if (next === 1) zoomBases.delete(diagram)
|
||||
}
|
||||
onBeforeUnmount(disarm)
|
||||
function widthOf(svg: SVGSVGElement) {
|
||||
@@ -72,8 +111,9 @@ async function interact(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
const action = button.dataset.diagramAction
|
||||
if (action === 'view') {
|
||||
disarm()
|
||||
opener = button
|
||||
baseWidth.value = widthOf(svg)
|
||||
const intrinsicWidth = widthOf(svg)
|
||||
// Mermaid HTML labels live in SVG foreignObject nodes. Preserve that
|
||||
// integration point while still sanitizing the embedded HTML and handlers.
|
||||
const copy = svg.cloneNode(true) as SVGSVGElement
|
||||
@@ -88,6 +128,15 @@ async function interact(event: MouseEvent) {
|
||||
scale.value = 1
|
||||
await nextTick()
|
||||
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
|
||||
}
|
||||
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:hover { border-color: 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::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 .diagram-controls { flex-wrap: wrap; }
|
||||
.diagram-viewer-scroll { flex: 1; min-height: 0; overflow: auto; }
|
||||
.diagram-viewer-image { margin: auto; transition: width 180ms ease-out; }
|
||||
.diagram-viewer-image svg { width: 100% !important; max-width: none !important; height: auto !important; }
|
||||
.diagram-viewer-scroll { display: flex; flex: 1; min-height: 0; overflow: auto; }
|
||||
/* Auto margins center small diagrams and become zero on overflow, keeping all edges reachable. */
|
||||
.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>
|
||||
|
||||
@@ -40,7 +40,8 @@ const saveStatusColor = computed(() => {
|
||||
|
||||
const indexStatusText = computed(() => {
|
||||
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(() => {
|
||||
|
||||
@@ -507,6 +507,7 @@ export interface ThemeConfig {
|
||||
// ============ Index ============
|
||||
|
||||
export interface IndexStatus {
|
||||
vector_refresh_required?: boolean
|
||||
status: 'unknown' | 'idle' | 'indexing' | 'error'
|
||||
pending_jobs: number
|
||||
total_notes: number | null
|
||||
@@ -800,6 +801,7 @@ export interface ApiTask {
|
||||
}
|
||||
|
||||
export interface ApiIndexStatus {
|
||||
vector_refresh_required?: boolean
|
||||
total_notes: number
|
||||
total_blocks: number
|
||||
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-selectednode) { outline-color: var(--color-accent-primary); }
|
||||
.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(.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); }
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSettingsStore } from '@/stores/settings'
|
||||
import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
|
||||
const router = useRouter()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -15,29 +16,34 @@ const settingsStore = useSettingsStore()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
|
||||
const openError = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
|
||||
onMounted(() => { void initializeVault() })
|
||||
|
||||
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')
|
||||
if (settingsStore.restoreLastVault && lastVaultPath) {
|
||||
try {
|
||||
await openVault(lastVaultPath)
|
||||
return
|
||||
} catch {
|
||||
// 历史保存的旧路径可能与当前后端 Vault 不同,清除后让用户重新选择。
|
||||
localStorage.removeItem('last-vault-path')
|
||||
}
|
||||
await openVault(lastVaultPath)
|
||||
}
|
||||
{
|
||||
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openVault(path: string) {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
openError.value = ''
|
||||
try {
|
||||
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 {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -60,6 +66,8 @@ async function openFolderPicker() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p class="card-desc">{{ t('Web 联调模式连接 AI Core 当前配置的 Vault', 'Web development mode connects to the Vault configured in AI Core') }}</p>
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -9,6 +9,7 @@ export function resolveApiUrl(path: string): string {
|
||||
}
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
timeoutMs?: number
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
token?: string
|
||||
}
|
||||
@@ -26,7 +27,13 @@ export class ApiErrorClass extends Error {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -54,6 +61,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
...rest,
|
||||
signal: controller?.signal ?? rest.signal,
|
||||
headers: reqHeaders,
|
||||
})
|
||||
|
||||
@@ -78,8 +86,12 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
|
||||
throw new ApiErrorClass(code, message, details)
|
||||
} catch (e) {
|
||||
if (timedOut) throw new ApiErrorClass('REQUEST_TIMEOUT', '请求超时,请检查后端状态后重试。')
|
||||
if (e instanceof ApiErrorClass) throw e
|
||||
throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error')
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
rest.signal?.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApiIndexJob, ApiIndexStatus, IndexStatus } from '@/contracts'
|
||||
|
||||
function toIndexStatus(status: ApiIndexStatus): IndexStatus {
|
||||
return {
|
||||
vector_refresh_required: status.vector_refresh_required ?? false,
|
||||
status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
|
||||
pending_jobs: status.pending_jobs,
|
||||
total_notes: status.total_notes ?? null,
|
||||
@@ -13,7 +14,7 @@ function toIndexStatus(status: ApiIndexStatus): 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> {
|
||||
|
||||
@@ -2,13 +2,13 @@ import apiClient from './apiClient'
|
||||
import type { SystemStatus } from '@/contracts'
|
||||
|
||||
export function healthCheck(): Promise<{ status: string }> {
|
||||
return apiClient.get('/health')
|
||||
return apiClient.get('/health', { timeoutMs: 10000 })
|
||||
}
|
||||
|
||||
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'>> {
|
||||
return apiClient.get('/api/permissions/policy')
|
||||
return apiClient.get('/api/permissions/policy', { timeoutMs: 10000 })
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ async function requireNoteId(filePath: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
|
||||
return apiClient.get('/api/workspace')
|
||||
return apiClient.get('/api/workspace', { timeoutMs: 15000 })
|
||||
}
|
||||
|
||||
export async function getRecentVaults(): Promise<VaultInfo[]> {
|
||||
@@ -88,7 +88,7 @@ export async function getRecentVaults(): 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)
|
||||
return {
|
||||
vault_id: snapshot.workspace.vault_id,
|
||||
|
||||
@@ -67,6 +67,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
if (currentFilePath.value === targetPath) saveStatus.value = 'save_failed'
|
||||
} finally {
|
||||
pendingSave = null
|
||||
if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave()
|
||||
}
|
||||
})()
|
||||
return pendingSave
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user