feat(workspace): 接入真实Vault数据链路

This commit is contained in:
2026-08-31 21:38:56 +08:00
parent 2cb47c08f1
commit 8553d6f3c3
17 changed files with 1020 additions and 426 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test pnpm test
``` ```
当前回归基线为后端 71 项测试、前端 23 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 当前回归基线为后端 76 项测试、前端 26 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。 构建产物位于 `frontend/dist`,该目录不提交到 Git。
+46
View File
@@ -31,6 +31,48 @@ class OperationResponse(Contract):
message: str | None = None 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 # Notes and retrieval
class NoteBlock(Contract): class NoteBlock(Contract):
block_id: str block_id: str
@@ -79,6 +121,10 @@ class NoteMoveRequest(Contract):
folder: str folder: str
class NoteRenameRequest(Contract):
file_name: str = Field(min_length=1)
class SearchMode(str, Enum): class SearchMode(str, Enum):
fts = "fts" fts = "fts"
vector = "vector" vector = "vector"
+54
View File
@@ -63,6 +63,14 @@ class FtsHit:
bm25: float bm25: float
@dataclass(frozen=True, slots=True)
class NoteLocation:
note_id: str
title: str
file_path: str
folder: str
def replace_note_metadata( def replace_note_metadata(
*, *,
conn: sqlite3.Connection, conn: sqlite3.Connection,
@@ -219,6 +227,52 @@ def fts_search(match: str, limit: int = 100) -> list[FtsHit]:
conn.close() 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( def fts_search_page(
*, *,
match: str, match: str,
+55 -1
View File
@@ -13,6 +13,9 @@ from app.contracts import (
CredentialStatus, CredentialStatus,
CredentialWriteRequest, CredentialWriteRequest,
ExtensionInstallRequest, ExtensionInstallRequest,
FolderCreateRequest,
FolderDeleteRequest,
FolderRenameRequest,
IndexJob, IndexJob,
IndexRebuildRequest, IndexRebuildRequest,
IndexStatus, IndexStatus,
@@ -22,6 +25,7 @@ from app.contracts import (
NoteCreateRequest, NoteCreateRequest,
NoteListResponse, NoteListResponse,
NoteMoveRequest, NoteMoveRequest,
NoteRenameRequest,
NoteUpdateRequest, NoteUpdateRequest,
OperationResponse, OperationResponse,
PageMeta, PageMeta,
@@ -48,6 +52,10 @@ from app.contracts import (
ToolListResponse, ToolListResponse,
TranscriptionJob, TranscriptionJob,
TranscriptionRequest, TranscriptionRequest,
WorkspaceEntry,
WorkspaceInfo,
WorkspaceOpenRequest,
WorkspaceSnapshot,
) )
from app.agent import AgentCapacityError, AgentRunNotFoundError from app.agent import AgentCapacityError, AgentRunNotFoundError
from app.container import container from app.container import container
@@ -58,7 +66,13 @@ from app.providers.factory import UnsupportedProviderError
from app.providers.base import ProviderError from app.providers.base import ProviderError
from app.providers.credentials import CredentialStoreError from app.providers.credentials import CredentialStoreError
from app.retrieval.engine import engine 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") 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 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 # Notes
@router.get("/notes", response_model=NoteListResponse, tags=["Notes"]) @router.get("/notes", response_model=NoteListResponse, tags=["Notes"])
async def list_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) 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 # Retrieval and chat
@router.post("/search", response_model=SearchResponse, tags=["Search"]) @router.post("/search", response_model=SearchResponse, tags=["Search"])
async def search_notes(request: SearchRequest) -> SearchResponse: async def search_notes(request: SearchRequest) -> SearchResponse:
+72 -52
View File
@@ -6,13 +6,11 @@ Markdown 文件是笔记正文的持久化载体(Vault),SQLite/FTS5/向量
from __future__ import annotations from __future__ import annotations
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from app import repository from app import repository
from app.config import get_settings
from app.contracts import Note, NoteBlock, NoteSummary from app.contracts import Note, NoteBlock, NoteSummary
from app.database.db import connect, transaction from app.database.db import connect, transaction
from app.errors import ApiError 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.embedding import HashEmbeddingProvider
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
from app.services.coordination import serialized_vault_mutation from app.services.coordination import serialized_vault_mutation
from app.services.vault_paths import (
normalize_entry_name,
normalize_folder,
resolve_in_vault,
safe_note_filename,
)
# 轻量实现实例(无状态,可直接复用);接入真实模型后替换为对应 Provider # 轻量实现实例(无状态,可直接复用);接入真实模型后替换为对应 Provider
embedding = HashEmbeddingProvider() embedding = HashEmbeddingProvider()
vector_store = SqliteVecStore() 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]: def _rel_path(folder: str | None, title: str) -> tuple[str, str]:
"""由 folder + title 生成安全的相对路径,返回 (rel_path, 清洗后的 folder)。""" """由 folder + title 生成安全的相对路径,返回 (rel_path, 清洗后的 folder)。"""
clean_folder = _normalize_folder(folder) clean_folder = normalize_folder(folder)
name = _safe_name(title) name = safe_note_filename(title)
if not name.endswith(".md"):
name += ".md"
rel = f"{clean_folder}/{name}" if clean_folder else name rel = f"{clean_folder}/{name}" if clean_folder else name
return rel, clean_folder 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: 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 "" return path.read_text(encoding="utf-8") if path.exists() else ""
def _write_markdown(rel_path: str, markdown: str) -> None: 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.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8") path.write_text(markdown, encoding="utf-8")
def _create_markdown(rel_path: str, markdown: str) -> None: def _create_markdown(rel_path: str, markdown: str) -> None:
"""排他创建 Markdown;目标已存在时返回资源冲突,不覆盖用户文件。""" """排他创建 Markdown;目标已存在时返回资源冲突,不覆盖用户文件。"""
path = _abs_path(rel_path) path = resolve_in_vault(rel_path)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
try: try:
with path.open("x", encoding="utf-8") as handle: 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: def _delete_markdown(rel_path: str) -> None:
path = _abs_path(rel_path) path = resolve_in_vault(rel_path)
if path.exists(): if path.exists():
path.unlink() path.unlink()
@@ -222,7 +186,7 @@ async def move_note(note_id: str, *, folder: str) -> Note:
if record is None: if record is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id}) 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 filename = Path(record.file_path).name
new_rel_path = f"{clean_folder}/{filename}" if clean_folder else filename new_rel_path = f"{clean_folder}/{filename}" if clean_folder else filename
if new_rel_path == record.file_path: 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 assert note is not None
return note return note
source = _abs_path(record.file_path) source = resolve_in_vault(record.file_path)
target = _abs_path(new_rel_path) target = resolve_in_vault(new_rel_path)
if not source.is_file(): if not source.is_file():
raise ApiError( raise ApiError(
409, "NOTE_FILE_MISSING", "note file is missing from the Vault", 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 @serialized_vault_mutation
async def delete_note(note_id: str) -> bool: async def delete_note(note_id: str) -> bool:
record = repository.get_note_record(note_id) record = repository.get_note_record(note_id)
if record is None: if record is None:
return False 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 tombstone = path.with_name(f".{path.name}.{uuid4().hex}.deleting") if path.exists() else None
if tombstone is not None: if tombstone is not None:
path.replace(tombstone) path.replace(tombstone)
+78
View File
@@ -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()
+249
View File
@@ -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",
)
+116
View File
@@ -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()
+24
View File
@@ -24,6 +24,7 @@ export interface NoteBlock {
export interface FileNode { export interface FileNode {
id: string id: string
note_id?: string
name: string name: string
path: string path: string
type: 'file' | 'folder' type: 'file' | 'folder'
@@ -387,6 +388,29 @@ export interface PageMeta {
offset: number 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 { export interface OperationResponse {
status: 'accepted' | 'completed' status: 'accepted' | 'completed'
resource_id?: string | null resource_id?: string | null
@@ -1,10 +1,11 @@
// @vitest-environment happy-dom // @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 { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import EditorPane from './EditorPane.vue' import EditorPane from './EditorPane.vue'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import * as workspaceService from '@/services/workspaceService'
let wrapper: VueWrapper | null = null let wrapper: VueWrapper | null = null
@@ -19,12 +20,20 @@ async function waitForText(text: string) {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
setActivePinia(createPinia()) 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(() => { afterEach(() => {
wrapper?.unmount() wrapper?.unmount()
wrapper = null wrapper = null
document.body.innerHTML = '' document.body.innerHTML = ''
vi.restoreAllMocks()
}) })
describe('EditorPane file switching', () => { describe('EditorPane file switching', () => {
+14 -111
View File
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useSettingsStore } from '@/stores/settings' 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' import AppIcon from '@/components/common/AppIcon.vue'
const router = useRouter() const router = useRouter()
@@ -13,17 +13,19 @@ const themeStore = useThemeStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const isLoading = ref(false) const isLoading = ref(false)
const showCreateDialog = ref(false)
const newVaultName = ref('')
const newVaultPath = ref('')
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking') const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
onMounted(async () => { onMounted(async () => {
await Promise.all([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()]) await Promise.allSettled([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
const lastVaultPath = localStorage.getItem('last-vault-path') const lastVaultPath = localStorage.getItem('last-vault-path')
if (settingsStore.restoreLastVault && lastVaultPath) { if (settingsStore.restoreLastVault && lastVaultPath) {
await openVault(lastVaultPath) try {
return await openVault(lastVaultPath)
return
} catch {
// Mock Vault
localStorage.removeItem('last-vault-path')
}
} }
setTimeout(() => { setTimeout(() => {
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped' aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
@@ -41,24 +43,8 @@ async function openVault(path: string) {
} }
async function openFolderPicker() { async function openFolderPicker() {
// In Tauri this would use the native dialog const configured = workspaceStore.recentVaults[0]
// For web dev, simulate if (configured) await openVault(configured.path)
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
}
} }
</script> </script>
@@ -74,7 +60,7 @@ async function createVault() {
<div class="vault-card"> <div class="vault-card">
<h2 class="card-title">选择知识库</h2> <h2 class="card-title">选择知识库</h2>
<p class="card-desc">选择一个本地 Vault 开始你的知识之旅</p> <p class="card-desc">Web 联调模式连接 AI Core 当前配置的 Vault</p>
<div v-if="workspaceStore.recentVaults.length" class="recent-vaults"> <div v-if="workspaceStore.recentVaults.length" class="recent-vaults">
<div class="section-label">最近打开</div> <div class="section-label">最近打开</div>
@@ -97,11 +83,8 @@ async function createVault() {
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading"> <button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || !workspaceStore.recentVaults.length">
<AppIcon :icon="FolderOpened" /> 打开本地 Vault <AppIcon :icon="FolderOpened" /> 打开后端 Vault
</button>
<button class="btn btn-secondary" @click="showCreateDialog = true" :disabled="isLoading">
<AppIcon :icon="Plus" /> 创建新 Vault
</button> </button>
</div> </div>
@@ -122,24 +105,6 @@ async function createVault() {
</div> </div>
</div> </div>
<!-- Create Vault Dialog -->
<div v-if="showCreateDialog" class="dialog-overlay" @click.self="showCreateDialog = false">
<div class="dialog">
<h3>创建新 Vault</h3>
<div class="form-group">
<label>Vault 名称</label>
<input v-model="newVaultName" type="text" placeholder="我的知识库" />
</div>
<div class="form-group">
<label>存储路径</label>
<input v-model="newVaultPath" type="text" placeholder="/path/to/vault" />
</div>
<div class="dialog-actions">
<button class="btn btn-secondary" @click="showCreateDialog = false">取消</button>
<button class="btn btn-primary" @click="createVault" :disabled="!newVaultName || !newVaultPath">创建</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -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 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);
}
</style> </style>
@@ -1,11 +1,12 @@
// @vitest-environment happy-dom // @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 { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router' import { createMemoryHistory, createRouter } from 'vue-router'
import FileTreePanel from './FileTreePanel.vue' import FileTreePanel from './FileTreePanel.vue'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import * as workspaceService from '@/services/workspaceService'
let wrapper: VueWrapper | null = null let wrapper: VueWrapper | null = null
@@ -21,12 +22,26 @@ async function waitForPath(path: string) {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
setActivePinia(createPinia()) 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(() => { afterEach(() => {
wrapper?.unmount() wrapper?.unmount()
wrapper = null wrapper = null
document.body.innerHTML = '' document.body.innerHTML = ''
vi.restoreAllMocks()
}) })
describe('FileTreePanel file switching', () => { describe('FileTreePanel file switching', () => {
@@ -40,7 +55,7 @@ describe('FileTreePanel file switching', () => {
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore() const editorStore = useEditorStore()
await workspaceStore.openVault('/mock-vault') await workspaceStore.openVault('C:/vault')
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } }) wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
const findNode = (name: string) => wrapper!.findAll('.tree-node').find((node) => node.text().includes(name))! const findNode = (name: string) => wrapper!.findAll('.tree-node').find((node) => node.text().includes(name))!
@@ -17,16 +17,16 @@ afterEach(() => {
wrapper = null wrapper = null
}) })
describe('WorkspaceView initial file', () => { describe('WorkspaceView empty state', () => {
it('does not overwrite a file selected while the welcome note is loading', async () => { it('does not fabricate a Mock welcome note when no backend file is selected', async () => {
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
wrapper = mount(WorkspaceView, { wrapper = mount(WorkspaceView, {
global: { stubs: { EditorHeader: true, EditorPane: true } }, global: { stubs: { EditorHeader: true, EditorPane: true } },
}) })
workspaceStore.openFile('/数据结构/红黑树.md')
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md') expect(workspaceStore.activeFilePath).toBeNull()
expect(wrapper.find('.empty-workspace').exists()).toBe(true)
}) })
}) })
@@ -1,28 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import { useEditorStore } from '@/stores/editor'
import EditorHeader from '@/features/editor/EditorHeader.vue' import EditorHeader from '@/features/editor/EditorHeader.vue'
import EditorPane from '@/features/editor/EditorPane.vue' import EditorPane from '@/features/editor/EditorPane.vue'
import { EditPen } from '@element-plus/icons-vue' import { EditPen } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
onMounted(() => {
if (!workspaceStore.fileTree.length && workspaceStore.hasVault) {
// Already loaded
}
if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) {
void editorStore.loadFile('/欢迎使用 NotesAgent.md').then(() => {
//
if (!workspaceStore.activeFilePath && editorStore.currentFilePath === '/欢迎使用 NotesAgent.md') {
workspaceStore.openFile('/欢迎使用 NotesAgent.md')
}
})
}
})
</script> </script>
<template> <template>
+4
View File
@@ -37,3 +37,7 @@ export async function deleteNote(noteId: string): Promise<OperationResponse> {
export async function moveNote(noteId: string, folder: string): Promise<ApiNote> { export async function moveNote(noteId: string, folder: string): Promise<ApiNote> {
return apiClient.post(`/api/notes/${noteId}/move`, { folder }) return apiClient.post(`/api/notes/${noteId}/move`, { folder })
} }
export async function renameNote(noteId: string, fileName: string): Promise<ApiNote> {
return apiClient.post(`/api/notes/${noteId}/rename`, { file_name: fileName })
}
@@ -0,0 +1,126 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiErrorClass } from './apiClient'
import * as workspaceService from './workspaceService'
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
const workspaceSnapshot = {
workspace: {
vault_id: 'default',
name: 'vault',
path: 'C:\\data\\vault',
file_count: 1,
indexed_note_count: 1,
requires_refresh: false,
},
items: [
{
entry_id: 'folder-course',
name: '课程',
path: '/课程',
type: 'folder',
note_id: null,
children: [
{
entry_id: 'note-os',
note_id: 'note-os',
name: '操作系统.md',
path: '/课程/操作系统.md',
type: 'file',
children: [],
},
],
},
],
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('workspaceService backend adapter', () => {
it('opens the configured Vault and reads/saves Markdown through Note API', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async (input, init) => {
const url = String(input)
if (url === '/api/workspace/open') return jsonResponse(workspaceSnapshot)
if (url === '/api/notes/note-os' && init?.method === 'GET') {
return jsonResponse({
note_id: 'note-os', title: '操作系统', file_path: '课程/操作系统.md', tags: [],
created_at: '2026-08-31T00:00:00Z', updated_at: '2026-08-31T00:00:00Z',
markdown: '# 操作系统\n', blocks: [],
})
}
if (url === '/api/notes/note-os' && init?.method === 'PATCH') {
return jsonResponse({})
}
throw new Error(`Unexpected request: ${init?.method} ${url}`)
})
const vault = await workspaceService.openVault('C:\\data\\vault')
const tree = await workspaceService.getFileTree()
const markdown = await workspaceService.readFileContent('/课程/操作系统.md')
await workspaceService.saveFileContent('/课程/操作系统.md', '# 已更新\n')
expect(vault).toEqual({ path: 'C:\\data\\vault', name: 'vault' })
expect(tree[0].children?.[0]).toMatchObject({
id: 'note-os', note_id: 'note-os', path: '/课程/操作系统.md', type: 'file',
})
expect(markdown).toBe('# 操作系统\n')
const patchCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')
expect(JSON.parse(String(patchCall?.[1]?.body))).toEqual({ markdown: '# 已更新\n' })
})
it('creates notes and folders with Vault-relative paths', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async (input, init) => {
const url = String(input)
if (url === '/api/workspace/open') return jsonResponse(workspaceSnapshot)
if (url === '/api/notes' && init?.method === 'POST') {
return jsonResponse({
note_id: 'note-new', title: '新笔记', file_path: '课程/新笔记.md', tags: [],
created_at: '2026-08-31T00:00:00Z', updated_at: '2026-08-31T00:00:00Z',
markdown: '# 新笔记\n', blocks: [],
})
}
if (url === '/api/workspace/folders' && init?.method === 'POST') {
return jsonResponse({
entry_id: 'folder-child', name: '子目录', path: '/课程/子目录', type: 'folder',
note_id: null, children: [],
})
}
throw new Error(`Unexpected request: ${init?.method} ${url}`)
})
await workspaceService.openVault('C:\\data\\vault')
const note = await workspaceService.createFile('/课程', '新笔记.md', '# 新笔记\n')
const folder = await workspaceService.createFolder('/课程', '子目录')
expect(note).toMatchObject({ id: 'note-new', path: '/课程/新笔记.md' })
expect(folder).toMatchObject({ id: 'folder-child', path: '/课程/子目录' })
const bodies = fetchMock.mock.calls
.filter(([, init]) => init?.method === 'POST')
.map(([, init]) => JSON.parse(String(init?.body)))
expect(bodies).toContainEqual({ title: '新笔记', folder: '课程', markdown: '# 新笔记\n' })
expect(bodies).toContainEqual({ parent: '课程', name: '子目录' })
})
it('reports backend connectivity errors instead of falling back to Mock data', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('offline'))
await expect(workspaceService.getWorkspaceInfo()).rejects.toEqual(
expect.objectContaining<Partial<ApiErrorClass>>({ code: 'NETWORK_ERROR' }),
)
})
})
+150 -237
View File
@@ -1,258 +1,171 @@
import type { FileNode } from '@/contracts' import type {
ApiNote,
// Web 开发模式使用内存实现,服务签名保持与未来桌面文件系统适配器一致。 ApiWorkspaceEntry,
// TODO(desktop): Tauri Host 就绪后通过 IPC 替换 Mock,并保留路径规范化与错误映射。 ApiWorkspaceInfo,
ApiWorkspaceSnapshot,
FileNode,
OperationResponse,
} from '@/contracts'
import apiClient from './apiClient'
import * as noteService from './noteService'
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
export interface VaultInfo { export interface VaultInfo {
path: string path: string
name: string name: string
} }
const MOCK_VAULTS: VaultInfo[] = [ let cachedTree: FileNode[] | null = null
{ path: '/Users/demo/Documents/MyVault', name: '我的知识库' }, const noteIdByPath = new Map<string, string>()
{ path: '/Users/demo/Documents/StudyNotes', name: '学习笔记' }, const typeByPath = new Map<string, FileNode['type']>()
]
const MOCK_FILE_TREE: FileNode[] = [ function normalizePublicPath(path: string): string {
{ const normalized = path.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
id: 'f-data', return normalized ? `/${normalized}` : '/'
name: '数据结构', }
path: '/数据结构',
type: 'folder', function relativePath(path: string): string {
is_open: true, return normalizePublicPath(path).replace(/^\//, '')
children: [ }
{ id: 'n-rbt', name: '红黑树.md', path: '/数据结构/红黑树.md', type: 'file' },
{ id: 'n-bst', name: '二叉搜索树.md', path: '/数据结构/二叉搜索树.md', type: 'file' }, function toFileNode(entry: ApiWorkspaceEntry): FileNode {
{ const path = normalizePublicPath(entry.path)
id: 'f-list', const node: FileNode = {
name: '链表', id: entry.entry_id,
path: '/数据结构/链表', note_id: entry.note_id ?? undefined,
type: 'folder', name: entry.name,
is_open: false, path,
children: [ type: entry.type,
{ id: 'n-slist', name: '单链表.md', path: '/数据结构/链表/单链表.md', type: 'file' },
{ id: 'n-dlist', name: '双向链表.md', path: '/数据结构/链表/双向链表.md', type: 'file' },
],
},
],
},
{
id: 'f-os',
name: '操作系统',
path: '/操作系统',
type: 'folder',
is_open: false, is_open: false,
children: [ children: entry.type === 'folder' ? entry.children.map(toFileNode) : undefined,
{ id: 'n-deadlock', name: '死锁.md', path: '/操作系统/死锁.md', type: 'file' },
{ id: 'n-sched', name: '进程调度.md', path: '/操作系统/进程调度.md', type: 'file' },
],
},
{
id: 'f-net',
name: '计算机网络',
path: '/计算机网络',
type: 'folder',
is_open: false,
children: [
{ id: 'n-tcp', name: 'TCP_IP.md', path: '/计算机网络/TCP_IP.md', type: 'file' },
{ id: 'n-http', name: 'HTTP协议.md', path: '/计算机网络/HTTP协议.md', type: 'file' },
],
},
{ id: 'n-welcome', name: '欢迎使用 NotesAgent.md', path: '/欢迎使用 NotesAgent.md', type: 'file' },
]
const mockFileContents = new Map<string, string>()
function rememberContent(path: string, content: string): Promise<string> {
mockFileContents.set(path, content)
return Promise.resolve(content)
}
export function getRecentVaults(): Promise<VaultInfo[]> {
return Promise.resolve(MOCK_VAULTS)
}
export function openVault(path: string): Promise<VaultInfo> {
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Vault'
return Promise.resolve({ path, name })
}
export function createVault(path: string, name: string): Promise<VaultInfo> {
return Promise.resolve({ path, name })
}
export function getFileTree(): Promise<FileNode[]> {
return Promise.resolve(JSON.parse(JSON.stringify(MOCK_FILE_TREE)))
}
export function readFileContent(filePath: string): Promise<string> {
const saved = mockFileContents.get(filePath)
if (saved !== undefined) return Promise.resolve(saved)
const name = filePath.split('/').pop() || 'Untitled'
if (name === '欢迎使用 NotesAgent.md') {
return rememberContent(filePath, `# 欢迎使用 NotesAgent
AI Markdown RAG Agent
##
- **** Markdown
- ****FTS5 +
- **AI ** RAG AI
- **Agent **AI
- **Skill ** AI Skill
- **** Plugin
##
1.
2. 使 \`Ctrl+P\` 打开命令面板
3. 使
4. AI
> 使 AI
##
- ****使 Milkdown Markdown
- ****使 CodeMirror 6 Markdown
##
\`\`\`python
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
left = [x for x in arr if x < pivot]
middle = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quick_sort(left) + middle + quick_sort(right)
\`\`\`
##
- [x]
- [x]
- [ ]
- [ ] AI Core
- [ ]
---
`)
} }
if (name === '红黑树.md') { typeByPath.set(path, entry.type)
return rememberContent(filePath, `# 红黑树 if (entry.note_id) noteIdByPath.set(path, entry.note_id)
return node
}
Red-Black Tree function cacheEntries(entries: ApiWorkspaceEntry[]): FileNode[] {
noteIdByPath.clear()
typeByPath.clear()
cachedTree = entries.map(toFileNode)
return cachedTree
}
## function nodeFromNote(note: ApiNote): FileNode {
const path = normalizePublicPath(note.file_path)
1. noteIdByPath.set(path, note.note_id)
2. typeByPath.set(path, 'file')
3. NIL return {
4. id: note.note_id,
5. note_id: note.note_id,
name: path.split('/').at(-1) || note.title,
**** path,
type: 'file',
##
### 1
### 2
3
### 3
## AVL
| | AVL | |
|------|--------|--------|
| | 1 | |
| | | |
| | | |
| | | |
##
- C++ STL map/set
- Java TreeMap
- Linux
`)
} }
return rememberContent(filePath, `# ${name.replace('.md', '')}
##
##
...
>
\`\`\`javascript
console.log('Hello, NotesAgent!');
\`\`\`
`)
} }
export function saveFileContent(filePath: string, content: string): Promise<void> { async function requireNoteId(filePath: string): Promise<string> {
console.debug(`[workspaceService] Save ${filePath}, ${content.length} chars`) const path = normalizePublicPath(filePath)
mockFileContents.set(filePath, content) let noteId = noteIdByPath.get(path)
return Promise.resolve() if (!noteId) {
} await refreshTree()
noteId = noteIdByPath.get(path)
export function createFile(folderPath: string, name: string, content = ''): Promise<FileNode> {
const path = `${folderPath === '/' ? '' : folderPath}/${name}`
const id = `n-${Date.now()}`
mockFileContents.set(path, content)
return Promise.resolve({ id, name, path, type: 'file' })
}
export function createFolder(parentPath: string, name: string): Promise<FileNode> {
const path = `${parentPath === '/' ? '' : parentPath}/${name}`
const id = `f-${Date.now()}`
return Promise.resolve({ id, name, path, type: 'folder', is_open: true, children: [] })
}
export function renameFile(oldPath: string, newName: string): Promise<void> {
const separator = oldPath.lastIndexOf('/')
const newPath = `${oldPath.slice(0, separator + 1)}${newName}`
for (const [path, content] of [...mockFileContents]) {
if (path === oldPath || path.startsWith(`${oldPath}/`)) {
mockFileContents.delete(path)
mockFileContents.set(`${newPath}${path.slice(oldPath.length)}`, content)
}
} }
return Promise.resolve() if (!noteId) throw new Error(`笔记尚未建立后端索引:${path}`)
return noteId
} }
export function deleteFile(path: string): Promise<void> { export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
for (const filePath of [...mockFileContents.keys()]) { return apiClient.get('/api/workspace')
if (filePath === path || filePath.startsWith(`${path}/`)) mockFileContents.delete(filePath) }
export async function getRecentVaults(): Promise<VaultInfo[]> {
const workspace = await getWorkspaceInfo()
return [{ path: workspace.path, name: workspace.name }]
}
export async function openVault(path: string): Promise<VaultInfo> {
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path })
cacheEntries(snapshot.items)
return { path: snapshot.workspace.path, name: snapshot.workspace.name }
}
export async function createVault(path: string, name: string): Promise<VaultInfo> {
// Web 模式不能创建任意本地目录;路径匹配时等价于初始化后端配置的 Vault。
void name
return openVault(path)
}
export async function refreshTree(): Promise<FileNode[]> {
const entries = await apiClient.get<ApiWorkspaceEntry[]>('/api/workspace/tree')
return cacheEntries(entries)
}
export async function getFileTree(): Promise<FileNode[]> {
return cachedTree ?? refreshTree()
}
export async function readFileContent(filePath: string): Promise<string> {
const note = await noteService.getNote(await requireNoteId(filePath))
return note.markdown
}
export async function saveFileContent(filePath: string, content: string): Promise<void> {
await noteService.updateNote(await requireNoteId(filePath), { markdown: content })
}
export async function createFile(
folderPath: string,
name: string,
content = '',
): Promise<FileNode> {
const title = name.replace(/\.md$/i, '')
const note = await noteService.createNote({
title,
folder: relativePath(folderPath),
markdown: content,
})
return nodeFromNote(note)
}
export async function createFolder(parentPath: string, name: string): Promise<FileNode> {
const entry = await apiClient.post<ApiWorkspaceEntry>('/api/workspace/folders', {
parent: relativePath(parentPath),
name,
})
return toFileNode(entry)
}
export async function renameFile(oldPath: string, newName: string): Promise<void> {
const path = normalizePublicPath(oldPath)
if (typeByPath.get(path) === 'folder') {
await apiClient.post('/api/workspace/folders/rename', {
path: relativePath(path),
new_name: newName,
})
} else {
await noteService.renameNote(await requireNoteId(path), newName)
} }
return Promise.resolve() await refreshTree()
} }
export function moveFile(sourcePath: string, targetPath: string): Promise<void> { export async function deleteFile(pathValue: string): Promise<void> {
// Mock 文件树由 Store 同步更新;真实实现必须在 Host 侧执行原子移动。 const path = normalizePublicPath(pathValue)
void sourcePath if (typeByPath.get(path) === 'folder') {
void targetPath await apiClient.post<OperationResponse>('/api/workspace/folders/delete', {
return Promise.resolve() path: relativePath(path),
})
} else {
await noteService.deleteNote(await requireNoteId(path))
}
await refreshTree()
}
export async function moveFile(sourcePath: string, targetPath: string): Promise<void> {
const source = normalizePublicPath(sourcePath)
if (typeByPath.get(source) !== 'file') {
throw new Error('当前阶段只支持移动笔记文件。')
}
await noteService.moveNote(await requireNoteId(source), relativePath(targetPath))
await refreshTree()
} }