From 8553d6f3c3971f8d79445e801a7ad203c3289318 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 ++++++ 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 +++++++----------- 17 files changed, 1020 insertions(+), 426 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/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 @@