From 8da75d44204df6341675ca0fdc0e0fc1d0f4bc6d Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Mon, 31 Aug 2026 21:38:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20=E6=8E=A5=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9EVault=E6=95=B0=E6=8D=AE=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- backend/app/contracts.py | 46 +++ backend/app/repository.py | 54 +++ backend/app/routes.py | 56 ++- backend/app/services/note_service.py | 124 +++--- backend/app/services/vault_paths.py | 78 ++++ backend/app/services/workspace_service.py | 249 +++++++++++ backend/tests/test_workspace.py | 116 ++++++ docs/AI笔记软件技术栈说明-团队版-v2.3.md | 4 +- docs/代码注释与TODO约定.md | 2 +- docs/前端壳子与接口层开发说明.md | 12 +- docs/前端页面需求说明-开发版.md | 6 +- docs/后端接口契约-开发版.md | 17 +- docs/第一阶段测试验证操作手册.md | 10 +- frontend/src/contracts/index.ts | 24 ++ .../src/features/editor/EditorPane.spec.ts | 11 +- frontend/src/features/vault/VaultEntry.vue | 125 +----- .../features/workspace/FileTreePanel.spec.ts | 19 +- .../features/workspace/WorkspaceView.spec.ts | 8 +- .../src/features/workspace/WorkspaceView.vue | 17 - frontend/src/services/noteService.ts | 4 + .../src/services/workspaceService.spec.ts | 126 ++++++ frontend/src/services/workspaceService.ts | 387 +++++++----------- 23 files changed, 1055 insertions(+), 442 deletions(-) create mode 100644 backend/app/services/vault_paths.py create mode 100644 backend/app/services/workspace_service.py create mode 100644 backend/tests/test_workspace.py create mode 100644 frontend/src/services/workspaceService.spec.ts diff --git a/README.md b/README.md index 30fc74e..45454e6 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 71 项测试、前端 23 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 76 项测试、前端 26 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 diff --git a/backend/app/contracts.py b/backend/app/contracts.py index c0e3f66..e7c9be8 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -31,6 +31,48 @@ class OperationResponse(Contract): message: str | None = None +# Workspace boundary (single configured Vault in Web development mode) +class WorkspaceInfo(Contract): + vault_id: str = "default" + name: str + path: str + file_count: int = 0 + indexed_note_count: int = 0 + requires_refresh: bool = False + + +class WorkspaceEntry(Contract): + entry_id: str + name: str + path: str + type: Literal["file", "folder"] + note_id: str | None = None + children: list["WorkspaceEntry"] = Field(default_factory=list) + + +class WorkspaceSnapshot(Contract): + workspace: WorkspaceInfo + items: list[WorkspaceEntry] = Field(default_factory=list) + + +class WorkspaceOpenRequest(Contract): + path: str | None = None + + +class FolderCreateRequest(Contract): + parent: str = "" + name: str = Field(min_length=1) + + +class FolderRenameRequest(Contract): + path: str + new_name: str = Field(min_length=1) + + +class FolderDeleteRequest(Contract): + path: str + + # Notes and retrieval class NoteBlock(Contract): block_id: str @@ -79,6 +121,10 @@ class NoteMoveRequest(Contract): folder: str +class NoteRenameRequest(Contract): + file_name: str = Field(min_length=1) + + class SearchMode(str, Enum): fts = "fts" vector = "vector" diff --git a/backend/app/repository.py b/backend/app/repository.py index cd3fbdd..29a082c 100644 --- a/backend/app/repository.py +++ b/backend/app/repository.py @@ -63,6 +63,14 @@ class FtsHit: bm25: float +@dataclass(frozen=True, slots=True) +class NoteLocation: + note_id: str + title: str + file_path: str + folder: str + + def replace_note_metadata( *, conn: sqlite3.Connection, @@ -219,6 +227,52 @@ def fts_search(match: str, limit: int = 100) -> list[FtsHit]: conn.close() +def list_note_locations(*, conn: sqlite3.Connection | None = None) -> list[NoteLocation]: + """返回 Workspace 构树和目录事务所需的最小笔记位置集合。""" + + owns = conn is None + conn = conn or connect() + try: + rows = conn.execute( + "SELECT note_id, title, file_path, folder FROM notes ORDER BY file_path" + ).fetchall() + return [ + NoteLocation( + note_id=row["note_id"], + title=row["title"], + file_path=row["file_path"], + folder=row["folder"], + ) + for row in rows + ] + finally: + if owns: + conn.close() + + +def update_note_location( + *, + conn: sqlite3.Connection, + note_id: str, + title: str, + file_path: str, + folder: str, + updated_at: datetime, +) -> None: + """更新文件位置和展示标题;Block/FTS/向量内容不变,无需重新生成。""" + + cursor = conn.execute( + """ + UPDATE notes + SET title = ?, file_path = ?, folder = ?, updated_at = ? + WHERE note_id = ? + """, + (title, file_path, folder, _iso(updated_at), note_id), + ) + if cursor.rowcount != 1: + raise LookupError(note_id) + + def fts_search_page( *, match: str, diff --git a/backend/app/routes.py b/backend/app/routes.py index f6eca48..31ff060 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -13,6 +13,9 @@ from app.contracts import ( CredentialStatus, CredentialWriteRequest, ExtensionInstallRequest, + FolderCreateRequest, + FolderDeleteRequest, + FolderRenameRequest, IndexJob, IndexRebuildRequest, IndexStatus, @@ -22,6 +25,7 @@ from app.contracts import ( NoteCreateRequest, NoteListResponse, NoteMoveRequest, + NoteRenameRequest, NoteUpdateRequest, OperationResponse, PageMeta, @@ -48,6 +52,10 @@ from app.contracts import ( ToolListResponse, TranscriptionJob, TranscriptionRequest, + WorkspaceEntry, + WorkspaceInfo, + WorkspaceOpenRequest, + WorkspaceSnapshot, ) from app.agent import AgentCapacityError, AgentRunNotFoundError from app.container import container @@ -58,7 +66,13 @@ from app.providers.factory import UnsupportedProviderError from app.providers.base import ProviderError from app.providers.credentials import CredentialStoreError from app.retrieval.engine import engine -from app.services import index_service, note_service, task_service, transcription_service +from app.services import ( + index_service, + note_service, + task_service, + transcription_service, + workspace_service, +) router = APIRouter(prefix="/api") @@ -114,6 +128,41 @@ def extension_call(operation): raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc +# Workspace (single configured Vault in Web development mode) +@router.get("/workspace", response_model=WorkspaceInfo, tags=["Workspace"]) +async def get_workspace() -> WorkspaceInfo: + return workspace_service.get_workspace_info() + + +@router.post("/workspace/open", response_model=WorkspaceSnapshot, tags=["Workspace"]) +async def open_workspace(request: WorkspaceOpenRequest) -> WorkspaceSnapshot: + return await workspace_service.open_workspace(request.path) + + +@router.get("/workspace/tree", response_model=list[WorkspaceEntry], tags=["Workspace"]) +async def get_workspace_tree() -> list[WorkspaceEntry]: + return workspace_service.get_workspace_tree() + + +@router.post("/workspace/folders", response_model=WorkspaceEntry, tags=["Workspace"]) +async def create_workspace_folder(request: FolderCreateRequest) -> WorkspaceEntry: + return await workspace_service.create_folder(request.parent, request.name) + + +@router.post( + "/workspace/folders/rename", response_model=WorkspaceEntry, tags=["Workspace"] +) +async def rename_workspace_folder(request: FolderRenameRequest) -> WorkspaceEntry: + return await workspace_service.rename_folder(request.path, request.new_name) + + +@router.post( + "/workspace/folders/delete", response_model=OperationResponse, tags=["Workspace"] +) +async def delete_workspace_folder(request: FolderDeleteRequest) -> OperationResponse: + return await workspace_service.delete_folder(request.path) + + # Notes @router.get("/notes", response_model=NoteListResponse, tags=["Notes"]) async def list_notes( @@ -160,6 +209,11 @@ async def move_note(note_id: str, request: NoteMoveRequest) -> Note: return await note_service.move_note(note_id, folder=request.folder) +@router.post("/notes/{note_id}/rename", response_model=Note, tags=["Notes"]) +async def rename_note(note_id: str, request: NoteRenameRequest) -> Note: + return await note_service.rename_note(note_id, file_name=request.file_name) + + # Retrieval and chat @router.post("/search", response_model=SearchResponse, tags=["Search"]) async def search_notes(request: SearchRequest) -> SearchResponse: diff --git a/backend/app/services/note_service.py b/backend/app/services/note_service.py index c6ab0f8..2d7579a 100644 --- a/backend/app/services/note_service.py +++ b/backend/app/services/note_service.py @@ -6,13 +6,11 @@ Markdown 文件是笔记正文的持久化载体(Vault),SQLite/FTS5/向量 from __future__ import annotations -import re from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 from app import repository -from app.config import get_settings from app.contracts import Note, NoteBlock, NoteSummary from app.database.db import connect, transaction from app.errors import ApiError @@ -20,74 +18,40 @@ from app.knowledge.parser import ParsedNote, parse_note from app.retrieval.embedding import HashEmbeddingProvider from app.retrieval.vectorstore import SqliteVecStore, VectorRecord from app.services.coordination import serialized_vault_mutation +from app.services.vault_paths import ( + normalize_entry_name, + normalize_folder, + resolve_in_vault, + safe_note_filename, +) # 轻量实现实例(无状态,可直接复用);接入真实模型后替换为对应 Provider embedding = HashEmbeddingProvider() vector_store = SqliteVecStore() -def _vault() -> Path: - return get_settings().vault_path - - -def _safe_name(title: str) -> str: - name = re.sub(r'[\\/:*?"<>|]', "_", title).strip() - return name or "untitled" - - -def _normalize_folder(folder: str | None) -> str: - """清洗 folder 为安全的相对目录,拒绝 `..`/`.`/绝对路径/盘符/空字节,防路径逃逸。""" - if not folder: - return "" - if "\x00" in folder: - raise ApiError(400, "INVALID_PATH", "folder must not contain NUL bytes", {"folder": folder}) - segments: list[str] = [] - for part in re.split(r"[\\/]+", folder): - if part == "": - continue - if part in (".", ".."): - raise ApiError(400, "INVALID_PATH", "folder must not contain '.' or '..'", {"folder": folder}) - if ":" in part: - raise ApiError(400, "INVALID_PATH", "folder must be a relative path", {"folder": folder}) - segments.append(part) - return "/".join(segments) - - def _rel_path(folder: str | None, title: str) -> tuple[str, str]: """由 folder + title 生成安全的相对路径,返回 (rel_path, 清洗后的 folder)。""" - clean_folder = _normalize_folder(folder) - name = _safe_name(title) - if not name.endswith(".md"): - name += ".md" + clean_folder = normalize_folder(folder) + name = safe_note_filename(title) rel = f"{clean_folder}/{name}" if clean_folder else name return rel, clean_folder -def _abs_path(rel_path: str) -> Path: - """把相对路径解析为 Vault 内的绝对路径;越界即报 400,杜绝路径逃逸。""" - if not rel_path or "\x00" in rel_path: - raise ApiError(400, "INVALID_PATH", "invalid file path", {"file_path": rel_path}) - root = _vault().resolve() - candidate = (_vault() / rel_path).resolve() - if not candidate.is_relative_to(root): - raise ApiError(400, "INVALID_PATH", "path escapes vault", {"file_path": rel_path}) - return candidate - - def _read_markdown(rel_path: str) -> str: - path = _abs_path(rel_path) + path = resolve_in_vault(rel_path) return path.read_text(encoding="utf-8") if path.exists() else "" def _write_markdown(rel_path: str, markdown: str) -> None: - path = _abs_path(rel_path) + path = resolve_in_vault(rel_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(markdown, encoding="utf-8") def _create_markdown(rel_path: str, markdown: str) -> None: """排他创建 Markdown;目标已存在时返回资源冲突,不覆盖用户文件。""" - path = _abs_path(rel_path) + path = resolve_in_vault(rel_path) path.parent.mkdir(parents=True, exist_ok=True) try: with path.open("x", encoding="utf-8") as handle: @@ -102,7 +66,7 @@ def _create_markdown(rel_path: str, markdown: str) -> None: def _delete_markdown(rel_path: str) -> None: - path = _abs_path(rel_path) + path = resolve_in_vault(rel_path) if path.exists(): path.unlink() @@ -222,7 +186,7 @@ async def move_note(note_id: str, *, folder: str) -> Note: if record is None: raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id}) - clean_folder = _normalize_folder(folder) + clean_folder = normalize_folder(folder) filename = Path(record.file_path).name new_rel_path = f"{clean_folder}/{filename}" if clean_folder else filename if new_rel_path == record.file_path: @@ -230,8 +194,8 @@ async def move_note(note_id: str, *, folder: str) -> Note: assert note is not None return note - source = _abs_path(record.file_path) - target = _abs_path(new_rel_path) + source = resolve_in_vault(record.file_path) + target = resolve_in_vault(new_rel_path) if not source.is_file(): raise ApiError( 409, "NOTE_FILE_MISSING", "note file is missing from the Vault", @@ -267,13 +231,69 @@ async def move_note(note_id: str, *, folder: str) -> Note: ) +@serialized_vault_mutation +async def rename_note(note_id: str, *, file_name: str) -> Note: + """重命名 Markdown 文件并保留 note_id、Block 与向量身份。""" + + record = repository.get_note_record(note_id) + if record is None: + raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id}) + + normalized = normalize_entry_name(file_name, markdown=True) + source = resolve_in_vault(record.file_path) + folder = normalize_folder(record.folder) + new_file_path = f"{folder}/{normalized}" if folder else normalized + target = resolve_in_vault(new_file_path) + if new_file_path == record.file_path: + note = await get_note(note_id) + assert note is not None + return note + if not source.is_file(): + raise ApiError( + 409, + "NOTE_FILE_MISSING", + "note file is missing from the Vault", + {"note_id": note_id, "file_path": record.file_path}, + ) + if target.exists(): + raise ApiError( + 409, + "RESOURCE_CONFLICT", + "a note already exists with the requested file name", + {"note_id": note_id, "file_path": new_file_path}, + ) + + source.replace(target) + now = datetime.now(timezone.utc) + conn = connect() + try: + with transaction(conn): + repository.update_note_location( + conn=conn, + note_id=note_id, + title=Path(normalized).stem, + file_path=new_file_path, + folder=folder, + updated_at=now, + ) + except BaseException: + target.replace(source) + raise + finally: + conn.close() + + note = await get_note(note_id) + assert note is not None + return note + + @serialized_vault_mutation async def delete_note(note_id: str) -> bool: record = repository.get_note_record(note_id) if record is None: return False - path = _abs_path(record.file_path) + path = resolve_in_vault(record.file_path) tombstone = path.with_name(f".{path.name}.{uuid4().hex}.deleting") if path.exists() else None if tombstone is not None: path.replace(tombstone) diff --git a/backend/app/services/vault_paths.py b/backend/app/services/vault_paths.py new file mode 100644 index 0000000..a26ad92 --- /dev/null +++ b/backend/app/services/vault_paths.py @@ -0,0 +1,78 @@ +"""Vault 相对路径校验;所有文件操作必须先经过本模块。""" + +from __future__ import annotations + +import re +from pathlib import Path + +from app.config import get_settings +from app.errors import ApiError + +_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]') + + +def normalize_folder(folder: str | None) -> str: + """返回使用 `/` 的安全相对目录;根目录表示为空字符串。""" + + if not folder or folder in {"/", "\\"}: + return "" + if "\x00" in folder: + raise ApiError(400, "INVALID_PATH", "folder must not contain NUL bytes") + segments: list[str] = [] + for part in re.split(r"[\\/]+", folder): + if not part: + continue + if part in {".", ".."} or ":" in part: + raise ApiError( + 400, + "INVALID_PATH", + "folder must be a relative path without '.' or '..' segments", + {"folder": folder}, + ) + segments.append(part) + return "/".join(segments) + + +def normalize_entry_name(name: str, *, markdown: bool = False) -> str: + """校验单个目录项名称;不静默接受路径分隔符或保留段。""" + + value = name.strip() + if not value or value in {".", ".."} or "\x00" in value: + raise ApiError(400, "INVALID_PATH", "entry name is invalid", {"name": name}) + if _INVALID_FILE_CHARS.search(value): + raise ApiError( + 400, + "INVALID_PATH", + "entry name contains unsupported characters", + {"name": name}, + ) + if markdown and not value.lower().endswith(".md"): + value += ".md" + return value + + +def safe_note_filename(title: str) -> str: + """为创建笔记保留原有的宽松清洗行为。""" + + value = _INVALID_FILE_CHARS.sub("_", title).strip() or "untitled" + return value if value.lower().endswith(".md") else f"{value}.md" + + +def resolve_in_vault(relative_path: str) -> Path: + """把相对路径解析到当前 Vault,并拒绝符号链接/`..` 导致的越界。""" + + if not relative_path or "\x00" in relative_path: + raise ApiError( + 400, "INVALID_PATH", "invalid Vault-relative path", {"path": relative_path} + ) + root = get_settings().vault_path.resolve() + candidate = (root / relative_path.replace("\\", "/").lstrip("/")).resolve() + if not candidate.is_relative_to(root): + raise ApiError( + 400, "INVALID_PATH", "path escapes Vault", {"path": relative_path} + ) + return candidate + + +def relative_to_vault(path: Path) -> str: + return path.resolve().relative_to(get_settings().vault_path.resolve()).as_posix() diff --git a/backend/app/services/workspace_service.py b/backend/app/services/workspace_service.py new file mode 100644 index 0000000..6725a58 --- /dev/null +++ b/backend/app/services/workspace_service.py @@ -0,0 +1,249 @@ +"""Web 联调 Workspace:把单一配置 Vault 映射为前端可用的真实文件树。""" + +from __future__ import annotations + +import hashlib +import shutil +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +from app import repository +from app.config import get_settings +from app.contracts import ( + IndexRebuildRequest, + OperationResponse, + WorkspaceEntry, + WorkspaceInfo, + WorkspaceSnapshot, +) +from app.database.db import connect, transaction +from app.errors import ApiError +from app.retrieval.vectorstore import SqliteVecStore +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 + +vector_store = SqliteVecStore() + + +def _entry_id(kind: str, path: str) -> str: + digest = hashlib.sha256(f"{kind}:{path}".encode("utf-8")).hexdigest()[:16] + return f"{kind}_{digest}" + + +def _disk_markdown_paths() -> set[str]: + root = get_settings().vault_path + if not root.exists(): + return set() + resolved_root = root.resolve() + paths: set[str] = set() + for path in root.rglob("*.md"): + if path.is_symlink(): + continue + resolved = path.resolve() + if resolved.is_file() and resolved.is_relative_to(resolved_root): + paths.add(resolved.relative_to(resolved_root).as_posix()) + return paths + + +def get_workspace_info() -> WorkspaceInfo: + root = get_settings().vault_path.resolve() + disk_paths = _disk_markdown_paths() + indexed_paths = {item.file_path for item in repository.list_note_locations()} + return WorkspaceInfo( + name=root.name or "Vault", + path=str(root), + file_count=len(disk_paths), + indexed_note_count=len(indexed_paths), + requires_refresh=disk_paths != indexed_paths, + ) + + +def _tree(directory: Path, locations: dict[str, repository.NoteLocation]) -> list[WorkspaceEntry]: + if not directory.exists(): + return [] + root = get_settings().vault_path.resolve() + entries: list[WorkspaceEntry] = [] + children = sorted( + directory.iterdir(), key=lambda item: (not item.is_dir(), item.name.casefold()) + ) + for child in children: + if child.name.startswith(".") or child.is_symlink(): + continue + resolved = child.resolve() + if not resolved.is_relative_to(root): + continue + relative = resolved.relative_to(root).as_posix() + public_path = f"/{relative}" + if resolved.is_dir(): + entries.append( + WorkspaceEntry( + entry_id=_entry_id("folder", relative), + name=child.name, + path=public_path, + type="folder", + children=_tree(resolved, locations), + ) + ) + elif resolved.is_file() and child.suffix.lower() == ".md": + location = locations.get(relative) + entries.append( + WorkspaceEntry( + entry_id=location.note_id if location else _entry_id("file", relative), + note_id=location.note_id if location else None, + name=child.name, + path=public_path, + type="file", + ) + ) + return entries + + +def get_workspace_tree() -> list[WorkspaceEntry]: + locations = {item.file_path: item for item in repository.list_note_locations()} + return _tree(get_settings().vault_path.resolve(), locations) + + +async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot: + """打开当前配置 Vault;发现未索引文件时先执行一次安全全量刷新。""" + + root = get_settings().vault_path.resolve() + if requested_path and Path(requested_path).resolve() != root: + raise ApiError( + 409, + "WORKSPACE_PATH_MISMATCH", + "Web development mode can only open the backend configured Vault.", + {"configured_path": str(root)}, + ) + root.mkdir(parents=True, exist_ok=True) + info = get_workspace_info() + if info.requires_refresh: + await index_service.rebuild(IndexRebuildRequest()) + info = get_workspace_info() + return WorkspaceSnapshot(workspace=info, items=get_workspace_tree()) + + +@serialized_vault_mutation +async def create_folder(parent: str, name: str) -> WorkspaceEntry: + clean_parent = normalize_folder(parent) + clean_name = normalize_entry_name(name) + relative = f"{clean_parent}/{clean_name}" if clean_parent else clean_name + target = resolve_in_vault(relative) + if not clean_parent: + get_settings().vault_path.mkdir(parents=True, exist_ok=True) + if target.exists(): + raise ApiError( + 409, "RESOURCE_CONFLICT", "folder already exists", {"path": relative} + ) + if not target.parent.is_dir(): + raise ApiError( + 404, + "RESOURCE_NOT_FOUND", + "parent folder not found", + {"parent": clean_parent}, + ) + target.mkdir(parents=False) + return WorkspaceEntry( + entry_id=_entry_id("folder", relative), + name=clean_name, + path=f"/{relative}", + type="folder", + ) + + +@serialized_vault_mutation +async def rename_folder(path: str, new_name: str) -> WorkspaceEntry: + old_folder = normalize_folder(path) + if not old_folder: + raise ApiError(400, "INVALID_PATH", "the Vault root cannot be renamed") + clean_name = normalize_entry_name(new_name) + parent = Path(old_folder).parent.as_posix() + parent = "" if parent == "." else parent + new_folder = f"{parent}/{clean_name}" if parent else clean_name + source = resolve_in_vault(old_folder) + target = resolve_in_vault(new_folder) + if not source.is_dir() or source.is_symlink(): + raise ApiError(404, "RESOURCE_NOT_FOUND", "folder not found", {"path": path}) + if target.exists(): + raise ApiError( + 409, "RESOURCE_CONFLICT", "target folder already exists", {"path": new_folder} + ) + + affected = [ + item + for item in repository.list_note_locations() + if item.folder == old_folder or item.folder.startswith(f"{old_folder}/") + ] + source.replace(target) + conn = connect() + now = datetime.now(timezone.utc) + try: + with transaction(conn): + for item in affected: + file_suffix = item.file_path[len(old_folder) :].lstrip("/") + folder_suffix = item.folder[len(old_folder) :].lstrip("/") + repository.update_note_location( + conn=conn, + note_id=item.note_id, + title=item.title, + file_path=f"{new_folder}/{file_suffix}", + folder=( + f"{new_folder}/{folder_suffix}" if folder_suffix else new_folder + ), + updated_at=now, + ) + except BaseException: + target.replace(source) + raise + finally: + conn.close() + + return WorkspaceEntry( + entry_id=_entry_id("folder", new_folder), + name=clean_name, + path=f"/{new_folder}", + type="folder", + children=_tree(target, {item.file_path: item for item in repository.list_note_locations()}), + ) + + +@serialized_vault_mutation +async def delete_folder(path: str) -> OperationResponse: + folder = normalize_folder(path) + if not folder: + raise ApiError(400, "INVALID_PATH", "the Vault root cannot be deleted") + source = resolve_in_vault(folder) + if not source.is_dir() or source.is_symlink(): + raise ApiError(404, "RESOURCE_NOT_FOUND", "folder not found", {"path": path}) + + affected = [ + item + for item in repository.list_note_locations() + if item.folder == folder or item.folder.startswith(f"{folder}/") + ] + tombstone = source.with_name(f".{source.name}.{uuid4().hex}.deleting") + source.replace(tombstone) + conn = connect() + try: + with transaction(conn): + block_ids: list[str] = [] + for item in affected: + block_ids.extend(repository.delete_note(item.note_id, conn=conn)) + await vector_store.delete(block_ids, conn=conn) + except BaseException: + tombstone.replace(source) + raise + finally: + conn.close() + + try: + shutil.rmtree(tombstone) + except OSError: + # 已提交的删除不回滚;隐藏 tombstone 可由后续维护任务清理。 + pass + return OperationResponse( + status="completed", + resource_id=_entry_id("folder", folder), + message=f"deleted folder and {len(affected)} indexed notes", + ) diff --git a/backend/tests/test_workspace.py b/backend/tests/test_workspace.py new file mode 100644 index 0000000..d32bea3 --- /dev/null +++ b/backend/tests/test_workspace.py @@ -0,0 +1,116 @@ +import asyncio + +import pytest + +from app.config import get_settings +from app.contracts import ( + FolderCreateRequest, + FolderDeleteRequest, + FolderRenameRequest, + NoteCreateRequest, + NoteRenameRequest, + WorkspaceOpenRequest, +) +from app.errors import ApiError +from app.routes import ( + create_note, + create_workspace_folder, + delete_workspace_folder, + get_note, + get_workspace_tree, + open_workspace, + rename_note, + rename_workspace_folder, +) + + +def test_open_workspace_indexes_real_markdown_and_returns_tree() -> None: + vault = get_settings().vault_path + note_path = vault / "课程" / "操作系统.md" + note_path.parent.mkdir(parents=True) + note_path.write_text("# 操作系统\n\n进程调度。\n", encoding="utf-8") + + snapshot = asyncio.run(open_workspace(WorkspaceOpenRequest())) + + assert snapshot.workspace.path == str(vault.resolve()) + assert snapshot.workspace.requires_refresh is False + assert snapshot.workspace.file_count == snapshot.workspace.indexed_note_count == 1 + folder = snapshot.items[0] + assert folder.path == "/课程" + assert folder.children[0].path == "/课程/操作系统.md" + assert folder.children[0].note_id is not None + + +def test_open_workspace_rejects_unconfigured_path() -> None: + with pytest.raises(ApiError) as error: + asyncio.run(open_workspace(WorkspaceOpenRequest(path="C:/another-vault"))) + + assert error.value.code == "WORKSPACE_PATH_MISMATCH" + + +def test_note_rename_preserves_identity_and_content() -> None: + created = asyncio.run( + create_note( + NoteCreateRequest( + title="旧名称", markdown="# 标题不变\n\n真实正文。\n", folder="课程" + ) + ) + ) + + renamed = asyncio.run( + rename_note(created.note_id, NoteRenameRequest(file_name="新名称.md")) + ) + + assert renamed.note_id == created.note_id + assert renamed.file_path == "课程/新名称.md" + assert renamed.title == "新名称" + assert renamed.markdown == "# 标题不变\n\n真实正文。\n" + assert not (get_settings().vault_path / "课程" / "旧名称.md").exists() + + +def test_folder_lifecycle_updates_database_and_vectors() -> None: + folder = asyncio.run( + create_workspace_folder(FolderCreateRequest(parent="/", name="课程")) + ) + created = asyncio.run( + create_note( + NoteCreateRequest(title="网络", markdown="# 网络\n\nTCP。\n", folder="课程") + ) + ) + + renamed_folder = asyncio.run( + rename_workspace_folder( + FolderRenameRequest(path=folder.path, new_name="计算机课程") + ) + ) + moved_note = asyncio.run(get_note(created.note_id)) + + assert renamed_folder.path == "/计算机课程" + assert moved_note.note_id == created.note_id + assert moved_note.file_path == "计算机课程/网络.md" + assert asyncio.run(get_workspace_tree())[0].children[0].note_id == created.note_id + + response = asyncio.run( + delete_workspace_folder(FolderDeleteRequest(path=renamed_folder.path)) + ) + + assert response.status == "completed" + with pytest.raises(ApiError) as error: + asyncio.run(get_note(created.note_id)) + assert error.value.code == "RESOURCE_NOT_FOUND" + assert asyncio.run(get_workspace_tree()) == [] + + +def test_workspace_openapi_paths_are_published() -> None: + from app.main import app + + paths = app.openapi()["paths"] + assert { + "/api/workspace", + "/api/workspace/open", + "/api/workspace/tree", + "/api/workspace/folders", + "/api/workspace/folders/rename", + "/api/workspace/folders/delete", + "/api/notes/{note_id}/rename", + } <= paths.keys() diff --git a/docs/AI笔记软件技术栈说明-团队版-v2.3.md b/docs/AI笔记软件技术栈说明-团队版-v2.3.md index 9cd8bdb..b0a2c9d 100644 --- a/docs/AI笔记软件技术栈说明-团队版-v2.3.md +++ b/docs/AI笔记软件技术栈说明-团队版-v2.3.md @@ -5,7 +5,7 @@ > 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步 > 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员 -> 实施状态更新:2026-08-31。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。第二阶段在现有边界上接入真实音频处理、MCP、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Agent Trace、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、真实桌面文件系统和 Sync Server 仍未实现。 +> 实施状态更新:2026-08-31。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault。第二阶段在现有边界上接入真实音频处理、MCP、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Agent Trace、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。 --- @@ -2321,7 +2321,7 @@ Markdown Workspace 第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。 -截至 2026-08-31,上述第一阶段后端链路和 Web 联调前端均已完成。当前验证基线为后端 71 项测试、前端 23 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 +截至 2026-08-31,上述第一阶段后端链路和 Web 联调前端均已完成,第二阶段前置的 Workspace 去 Mock 联调也已完成。当前验证基线为后端 76 项测试、前端 26 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 第二阶段在既有 Contract 上接入: diff --git a/docs/代码注释与TODO约定.md b/docs/代码注释与TODO约定.md index 232dcb2..e285ec1 100644 --- a/docs/代码注释与TODO约定.md +++ b/docs/代码注释与TODO约定.md @@ -36,7 +36,7 @@ | Security | 本地主密钥目前保存在数据目录,桌面端接入后迁移到系统凭据库 | | Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 | | AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 | -| Desktop | Workspace 仍使用 Web Mock,后续由 Tauri IPC 文件系统适配器替换 | +| Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 | | Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 | | Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker | diff --git a/docs/前端壳子与接口层开发说明.md b/docs/前端壳子与接口层开发说明.md index 6520e18..c1be39c 100644 --- a/docs/前端壳子与接口层开发说明.md +++ b/docs/前端壳子与接口层开发说明.md @@ -36,7 +36,7 @@ Vue Router - OpenAI、DeepSeek、Ollama 预设、自动模型发现和开发阶段加密 API Key 输入; - 智能体页面、运行状态、事件、工具和权限详情的中文展示。 -原统一占位页已经删除,所有已注册业务路由均指向真实页面。当前 Workspace 文件能力仍使用 Web Mock Adapter;Tauri 文件系统、Stronghold 和桌面窗口能力应在桌面容器阶段接入,不影响页面与 Store 的调用边界。 +原统一占位页已经删除,所有已注册业务路由均指向真实页面。Web Workspace 已通过 FastAPI 连接后端配置的单一真实 Vault,不再回退 Mock 数据;Tauri 多 Vault、原生目录选择、Stronghold 和桌面窗口能力仍在桌面容器阶段接入,不影响页面与 Store 的调用边界。 ## 2. 目录与职责 @@ -111,7 +111,9 @@ SecondarySidebar 文件树把右键目标保存在 `contextTarget`,重命名和删除始终作用于实际被右键的节点,不再依赖当前编辑文件。根目录使用 `/` 表示,新增根级文件时直接写入 Store 顶层数组。 -当前 `workspaceService` 仍是 Web 开发模式下的 Mock Adapter。保存、重命名和删除只保留调用边界,尚未接入 Tauri 文件系统命令。进入桌面端阶段后,应替换 Service 内部实现,不改变 Component 和 Store 的调用方式。 +当前 `workspaceService` 是 FastAPI Workspace Adapter。打开 Vault 时只允许后端 `APP_VAULT_PATH` 配置的目录,随后通过 Workspace/Note API 读取真实文件树和 Markdown,并完成文件、目录的新建、重命名、移动、保存和删除。接口错误直接进入统一错误链路,不再用 Mock Fallback 掩盖连接或契约失败。 + +浏览器不能获得任意本地文件系统权限,因此 Web 模式不提供目录选择和多 Vault 管理。进入桌面端阶段后,由 Tauri Host 实现同一 Service 边界下的原生适配器,组件和 Store 无需感知底层传输变化。 ## 6. HTTP 接口层 @@ -184,13 +186,13 @@ pnpm build ```text pnpm build passed -pnpm test 23 passed -uv run pytest 71 passed +pnpm test 26 passed +uv run pytest 76 passed preview smoke HTTP 200 git diff --check passed ``` -当前前端使用 Vitest 执行 Store、Workspace、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 71 项测试结果,也不涉及产品代码。 +当前前端使用 Vitest 执行 Store、Workspace API Adapter、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 76 项测试结果,也不涉及产品代码。 Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。 diff --git a/docs/前端页面需求说明-开发版.md b/docs/前端页面需求说明-开发版.md index 4f8c750..678fefd 100644 --- a/docs/前端页面需求说明-开发版.md +++ b/docs/前端页面需求说明-开发版.md @@ -4,7 +4,7 @@ > 文档性质:开发需求基线,不是最终视觉规范或产品宣传文档。 > 依据:`第一阶段分工表.md`、`AI笔记软件技术栈说明-团队版-v2.3.md`、`后端接口契约-开发版.md`。 -> 实现状态:更新至 2026-08-30。全部已注册业务路由均已有真实页面;Markdown 写作/源码模式、Search、Chat、智能体执行轨迹、扩展管理、设置、Provider 预设、模型发现和开发阶段加密凭据输入均已落地。当前仍以 Web Mock Workspace 代替 Tauri 文件系统。 +> 实现状态:更新至 2026-08-31。全部已注册业务路由均已有真实页面;Markdown 写作/源码模式、Search、Chat、智能体执行轨迹、扩展管理、设置、Provider 预设、模型发现和开发阶段加密凭据输入均已落地。Web Workspace 已连接 FastAPI 管理的真实单 Vault;Tauri 原生目录选择和多 Vault 尚未接入。 ## 1. 第一阶段目标 @@ -716,10 +716,10 @@ SecretService ### 16.3 WorkspaceService -- 统一封装 Tauri 文件命令; +- 统一封装 FastAPI Workspace/Note API,并为 Tauri 文件命令保留适配边界; - 规范化路径; - 处理文件锁、自动保存和冲突; -- Web 开发模式提供可替换的 Mock 实现; +- Web 开发模式连接后端配置的单一 Vault,禁止失败后回退 Mock; - 不把任意本地路径直接暴露给 Plugin UI。 ## 17. 公共组件 diff --git a/docs/后端接口契约-开发版.md b/docs/后端接口契约-开发版.md index 9c45ded..a12d81a 100644 --- a/docs/后端接口契约-开发版.md +++ b/docs/后端接口契约-开发版.md @@ -30,8 +30,22 @@ | PATCH | `/api/notes/{note_id}` | 更新笔记 | | DELETE | `/api/notes/{note_id}` | 删除笔记 | | POST | `/api/notes/{note_id}/move` | 移动笔记 | +| POST | `/api/notes/{note_id}/rename` | 重命名笔记文件并保留 Note/Block 身份 | | POST | `/api/search` | FTS、Vector 或 Hybrid 检索 | +### Workspace + +Web 联调阶段只暴露后端通过 `APP_VAULT_PATH` 配置的单一 Vault,不接受浏览器传入任意本地目录。桌面多 Vault 与目录选择仍由后续 Tauri Host 提供。 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/api/workspace` | 获取当前 Vault、文件数和索引同步状态 | +| POST | `/api/workspace/open` | 打开配置的 Vault;磁盘路径集变化时重建索引 | +| GET | `/api/workspace/tree` | 获取真实 Markdown 文件和目录树 | +| POST | `/api/workspace/folders` | 新建目录 | +| POST | `/api/workspace/folders/rename` | 重命名目录并同步 Note 路径 | +| POST | `/api/workspace/folders/delete` | 删除目录及其 Note、Block、FTS 和向量记录 | + ### Chat、Agent 与 Tool | 方法 | 路径 | 用途 | @@ -159,11 +173,12 @@ RunCancelled ## 当前实现状态 -更新至 2026-08-31:后端 71 项回归测试通过。 +更新至 2026-08-31:后端 76 项回归测试通过。 - Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。 - Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。 - Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。 +- Workspace 已接入后端配置的真实 Vault;文件树、笔记读写、文件/目录新建、重命名和删除不再使用前端 Mock Fallback。 - Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。 - Plugin 启用前必须通过权限接口记录授权,未知权限默认拒绝。 - Attachment Tool 读取 Host 管理的 `attachments` 目录;音频接口读取 Host 生成的转写文本,真实本地语音模型在第二阶段接入。 diff --git a/docs/第一阶段测试验证操作手册.md b/docs/第一阶段测试验证操作手册.md index d2c1987..684cde9 100644 --- a/docs/第一阶段测试验证操作手册.md +++ b/docs/第一阶段测试验证操作手册.md @@ -68,7 +68,7 @@ uv run pytest -q -p no:cacheprovider 当前基线: ```text -71 passed +76 passed ``` 通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。 @@ -83,8 +83,8 @@ pnpm test 当前基线: ```text -9 test files passed -23 tests passed +10 test files passed +26 tests passed ``` 通过标准:退出码为 0、失败数为 0。测试覆盖 Provider Store、主题偏好、Workspace、文件树、文件切换、可视化编辑器、智能体中文标签、轻量动效性能约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题输出。 @@ -266,7 +266,9 @@ Invoke-RestMethod -Uri "$apiBase/providers/mock/models" ### 6.2 Workspace 与 Markdown -- 能新建、打开、重命名和删除 Web Mock 文件; +- 启动 FastAPI 并配置 `APP_VAULT_PATH` 后,能打开后端真实 Vault; +- 能在磁盘和 SQLite/FTS/向量索引之间一致地新建、读取、保存、重命名和删除文件及目录; +- 后端不可用时明确报告连接错误,不展示或写入 Mock 文件; - 连续快速点击不同文件时,路径和正文始终一致; - 文件切换前的未保存内容不会被错误写入新文件; - 写作模式不展示 Markdown 源码,源码模式可以精确编辑; diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index 237fa4f..afcdcde 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -24,6 +24,7 @@ export interface NoteBlock { export interface FileNode { id: string + note_id?: string name: string path: string type: 'file' | 'folder' @@ -387,6 +388,29 @@ export interface PageMeta { offset: number } +export interface ApiWorkspaceInfo { + vault_id: string + name: string + path: string + file_count: number + indexed_note_count: number + requires_refresh: boolean +} + +export interface ApiWorkspaceEntry { + entry_id: string + name: string + path: string + type: 'file' | 'folder' + note_id?: string | null + children: ApiWorkspaceEntry[] +} + +export interface ApiWorkspaceSnapshot { + workspace: ApiWorkspaceInfo + items: ApiWorkspaceEntry[] +} + export interface OperationResponse { status: 'accepted' | 'completed' resource_id?: string | null diff --git a/frontend/src/features/editor/EditorPane.spec.ts b/frontend/src/features/editor/EditorPane.spec.ts index 55fe909..cb97dea 100644 --- a/frontend/src/features/editor/EditorPane.spec.ts +++ b/frontend/src/features/editor/EditorPane.spec.ts @@ -1,10 +1,11 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' import EditorPane from './EditorPane.vue' import { useEditorStore } from '@/stores/editor' +import * as workspaceService from '@/services/workspaceService' let wrapper: VueWrapper | null = null @@ -19,12 +20,20 @@ async function waitForText(text: string) { beforeEach(() => { localStorage.clear() setActivePinia(createPinia()) + vi.spyOn(workspaceService, 'readFileContent').mockImplementation(async (filePath) => { + if (filePath === '/欢迎使用 NotesAgent.md') { + return '# 欢迎使用 NotesAgent\n\n祝你写作愉快' + } + if (filePath === '/数据结构/红黑树.md') return '# 红黑树\n\n新的文件内容' + throw new Error(`Unexpected file path: ${filePath}`) + }) }) afterEach(() => { wrapper?.unmount() wrapper = null document.body.innerHTML = '' + vi.restoreAllMocks() }) describe('EditorPane file switching', () => { diff --git a/frontend/src/features/vault/VaultEntry.vue b/frontend/src/features/vault/VaultEntry.vue index 4cabfb1..93976fe 100644 --- a/frontend/src/features/vault/VaultEntry.vue +++ b/frontend/src/features/vault/VaultEntry.vue @@ -4,7 +4,7 @@ import { useRouter } from 'vue-router' import { useWorkspaceStore } from '@/stores/workspace' import { useThemeStore } from '@/stores/theme' import { useSettingsStore } from '@/stores/settings' -import { ArrowRight, Document, Folder, FolderOpened, Moon, Plus, 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' const router = useRouter() @@ -13,17 +13,19 @@ const themeStore = useThemeStore() const settingsStore = useSettingsStore() const isLoading = ref(false) -const showCreateDialog = ref(false) -const newVaultName = ref('') -const newVaultPath = ref('') const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking') onMounted(async () => { - await Promise.all([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()]) + await Promise.allSettled([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()]) const lastVaultPath = localStorage.getItem('last-vault-path') if (settingsStore.restoreLastVault && lastVaultPath) { - await openVault(lastVaultPath) - return + try { + await openVault(lastVaultPath) + return + } catch { + // Mock 阶段保存的旧路径可能与当前后端 Vault 不同,清除后让用户重新选择。 + localStorage.removeItem('last-vault-path') + } } setTimeout(() => { aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped' @@ -41,24 +43,8 @@ async function openVault(path: string) { } async function openFolderPicker() { - // In Tauri this would use the native dialog - // For web dev, simulate - const path = prompt('请输入 Vault 路径(开发模式)', '/Users/demo/Documents/MyVault') - if (path) { - await openVault(path) - } -} - -async function createVault() { - if (!newVaultName.value || !newVaultPath.value) return - isLoading.value = true - try { - await workspaceStore.createVault(newVaultPath.value, newVaultName.value) - router.push('/workspace') - } finally { - isLoading.value = false - showCreateDialog.value = false - } + const configured = workspaceStore.recentVaults[0] + if (configured) await openVault(configured.path) } @@ -74,7 +60,7 @@ async function createVault() {

选择知识库

-

选择一个本地 Vault 开始你的知识之旅

+

Web 联调模式连接 AI Core 当前配置的 Vault

@@ -97,11 +83,8 @@ async function createVault() {
- -
@@ -122,24 +105,6 @@ async function createVault() {
- -
-
-

创建新 Vault

-
- - -
-
- - -
-
- - -
-
-
@@ -408,67 +373,5 @@ async function createVault() { } } -.dialog-overlay { - position: fixed; - inset: 0; - background: var(--color-background-overlay); - display: flex; - align-items: center; - justify-content: center; - z-index: var(--z-modal); - animation: dialog-backdrop-in var(--motion-fast) both; -} - -.dialog { - background: var(--color-surface-primary); - border-radius: var(--radius-lg); - padding: var(--space-xl); - width: 90%; - max-width: 400px; - box-shadow: var(--shadow-xl); - animation: dialog-in var(--motion-normal) both; -} - @keyframes entry-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } -@keyframes dialog-backdrop-in { from { opacity: 0; } to { opacity: 1; } } -@keyframes dialog-in { from { opacity: 0; transform: translateY(8px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } } - -.dialog h3 { - margin: 0 0 var(--space-lg) 0; - font-size: 18px; -} - -.form-group { - margin-bottom: var(--space-md); - - label { - display: block; - font-size: 13px; - color: var(--color-text-secondary); - margin-bottom: var(--space-xs); - } - - input { - width: 100%; - padding: 8px 12px; - background: var(--color-background-secondary); - border: 1px solid var(--color-border-default); - border-radius: var(--radius-md); - font-size: 14px; - color: var(--color-text-primary); - outline: none; - transition: border-color var(--motion-fast); - - &:focus { - border-color: var(--color-border-focus); - } - } -} - -.dialog-actions { - display: flex; - justify-content: flex-end; - gap: var(--space-sm); - margin-top: var(--space-lg); -} diff --git a/frontend/src/features/workspace/FileTreePanel.spec.ts b/frontend/src/features/workspace/FileTreePanel.spec.ts index dfbd8d0..8dbdc99 100644 --- a/frontend/src/features/workspace/FileTreePanel.spec.ts +++ b/frontend/src/features/workspace/FileTreePanel.spec.ts @@ -1,11 +1,12 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { createMemoryHistory, createRouter } from 'vue-router' import FileTreePanel from './FileTreePanel.vue' import { useEditorStore } from '@/stores/editor' import { useWorkspaceStore } from '@/stores/workspace' +import * as workspaceService from '@/services/workspaceService' let wrapper: VueWrapper | null = null @@ -21,12 +22,26 @@ async function waitForPath(path: string) { beforeEach(() => { localStorage.clear() setActivePinia(createPinia()) + vi.spyOn(workspaceService, 'openVault').mockResolvedValue({ path: 'C:/vault', name: 'vault' }) + vi.spyOn(workspaceService, 'getFileTree').mockResolvedValue([ + { + id: 'folder-data', name: '数据结构', path: '/数据结构', type: 'folder', is_open: true, + children: [ + { id: 'note-rbt', note_id: 'note-rbt', name: '红黑树.md', path: '/数据结构/红黑树.md', type: 'file' }, + { id: 'note-bst', note_id: 'note-bst', name: '二叉搜索树.md', path: '/数据结构/二叉搜索树.md', type: 'file' }, + ], + }, + ]) + vi.spyOn(workspaceService, 'readFileContent').mockImplementation(async (path) => + path.includes('红黑树') ? '# 红黑树\n' : '# 二叉搜索树\n' + ) }) afterEach(() => { wrapper?.unmount() wrapper = null document.body.innerHTML = '' + vi.restoreAllMocks() }) describe('FileTreePanel file switching', () => { @@ -40,7 +55,7 @@ describe('FileTreePanel file switching', () => { const workspaceStore = useWorkspaceStore() const editorStore = useEditorStore() - await workspaceStore.openVault('/mock-vault') + await workspaceStore.openVault('C:/vault') wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } }) const findNode = (name: string) => wrapper!.findAll('.tree-node').find((node) => node.text().includes(name))! diff --git a/frontend/src/features/workspace/WorkspaceView.spec.ts b/frontend/src/features/workspace/WorkspaceView.spec.ts index 15fdb86..f1c8f17 100644 --- a/frontend/src/features/workspace/WorkspaceView.spec.ts +++ b/frontend/src/features/workspace/WorkspaceView.spec.ts @@ -17,16 +17,16 @@ afterEach(() => { wrapper = null }) -describe('WorkspaceView initial file', () => { - it('does not overwrite a file selected while the welcome note is loading', async () => { +describe('WorkspaceView empty state', () => { + it('does not fabricate a Mock welcome note when no backend file is selected', async () => { const workspaceStore = useWorkspaceStore() wrapper = mount(WorkspaceView, { global: { stubs: { EditorHeader: true, EditorPane: true } }, }) - workspaceStore.openFile('/数据结构/红黑树.md') await new Promise((resolve) => setTimeout(resolve, 0)) - expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md') + expect(workspaceStore.activeFilePath).toBeNull() + expect(wrapper.find('.empty-workspace').exists()).toBe(true) }) }) diff --git a/frontend/src/features/workspace/WorkspaceView.vue b/frontend/src/features/workspace/WorkspaceView.vue index 61d9013..423f2d9 100644 --- a/frontend/src/features/workspace/WorkspaceView.vue +++ b/frontend/src/features/workspace/WorkspaceView.vue @@ -1,28 +1,11 @@