feat(workspace): 接入真实Vault数据链路
This commit is contained in:
@@ -118,7 +118,7 @@ cd frontend
|
||||
pnpm test
|
||||
```
|
||||
|
||||
当前回归基线为后端 71 项测试、前端 23 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||
当前回归基线为后端 76 项测试、前端 26 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||
|
||||
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
+55
-1
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -5,7 +5,7 @@
|
||||
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
|
||||
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员
|
||||
|
||||
> 实施状态更新:2026-08-31。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。第二阶段在现有边界上接入真实音频处理、MCP、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Agent Trace、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、真实桌面文件系统和 Sync Server 仍未实现。
|
||||
> 实施状态更新:2026-08-31。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault。第二阶段在现有边界上接入真实音频处理、MCP、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Agent Trace、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
|
||||
|
||||
---
|
||||
|
||||
@@ -2321,7 +2321,7 @@ Markdown Workspace
|
||||
|
||||
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。
|
||||
|
||||
截至 2026-08-31,上述第一阶段后端链路和 Web 联调前端均已完成。当前验证基线为后端 71 项测试、前端 23 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
|
||||
截至 2026-08-31,上述第一阶段后端链路和 Web 联调前端均已完成,第二阶段前置的 Workspace 去 Mock 联调也已完成。当前验证基线为后端 76 项测试、前端 26 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
|
||||
|
||||
第二阶段在既有 Contract 上接入:
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
| Security | 本地主密钥目前保存在数据目录,桌面端接入后迁移到系统凭据库 |
|
||||
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
|
||||
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
|
||||
| Desktop | Workspace 仍使用 Web Mock,后续由 Tauri IPC 文件系统适配器替换 |
|
||||
| Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 |
|
||||
| Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 |
|
||||
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Vue Router
|
||||
- OpenAI、DeepSeek、Ollama 预设、自动模型发现和开发阶段加密 API Key 输入;
|
||||
- 智能体页面、运行状态、事件、工具和权限详情的中文展示。
|
||||
|
||||
原统一占位页已经删除,所有已注册业务路由均指向真实页面。当前 Workspace 文件能力仍使用 Web Mock Adapter;Tauri 文件系统、Stronghold 和桌面窗口能力应在桌面容器阶段接入,不影响页面与 Store 的调用边界。
|
||||
原统一占位页已经删除,所有已注册业务路由均指向真实页面。Web Workspace 已通过 FastAPI 连接后端配置的单一真实 Vault,不再回退 Mock 数据;Tauri 多 Vault、原生目录选择、Stronghold 和桌面窗口能力仍在桌面容器阶段接入,不影响页面与 Store 的调用边界。
|
||||
|
||||
## 2. 目录与职责
|
||||
|
||||
@@ -111,7 +111,9 @@ SecondarySidebar
|
||||
|
||||
文件树把右键目标保存在 `contextTarget`,重命名和删除始终作用于实际被右键的节点,不再依赖当前编辑文件。根目录使用 `/` 表示,新增根级文件时直接写入 Store 顶层数组。
|
||||
|
||||
当前 `workspaceService` 仍是 Web 开发模式下的 Mock Adapter。保存、重命名和删除只保留调用边界,尚未接入 Tauri 文件系统命令。进入桌面端阶段后,应替换 Service 内部实现,不改变 Component 和 Store 的调用方式。
|
||||
当前 `workspaceService` 是 FastAPI Workspace Adapter。打开 Vault 时只允许后端 `APP_VAULT_PATH` 配置的目录,随后通过 Workspace/Note API 读取真实文件树和 Markdown,并完成文件、目录的新建、重命名、移动、保存和删除。接口错误直接进入统一错误链路,不再用 Mock Fallback 掩盖连接或契约失败。
|
||||
|
||||
浏览器不能获得任意本地文件系统权限,因此 Web 模式不提供目录选择和多 Vault 管理。进入桌面端阶段后,由 Tauri Host 实现同一 Service 边界下的原生适配器,组件和 Store 无需感知底层传输变化。
|
||||
|
||||
## 6. HTTP 接口层
|
||||
|
||||
@@ -184,13 +186,13 @@ pnpm build
|
||||
|
||||
```text
|
||||
pnpm build passed
|
||||
pnpm test 23 passed
|
||||
uv run pytest 71 passed
|
||||
pnpm test 26 passed
|
||||
uv run pytest 76 passed
|
||||
preview smoke HTTP 200
|
||||
git diff --check passed
|
||||
```
|
||||
|
||||
当前前端使用 Vitest 执行 Store、Workspace、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 71 项测试结果,也不涉及产品代码。
|
||||
当前前端使用 Vitest 执行 Store、Workspace API Adapter、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 76 项测试结果,也不涉及产品代码。
|
||||
|
||||
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 文档性质:开发需求基线,不是最终视觉规范或产品宣传文档。
|
||||
> 依据:`第一阶段分工表.md`、`AI笔记软件技术栈说明-团队版-v2.3.md`、`后端接口契约-开发版.md`。
|
||||
|
||||
> 实现状态:更新至 2026-08-30。全部已注册业务路由均已有真实页面;Markdown 写作/源码模式、Search、Chat、智能体执行轨迹、扩展管理、设置、Provider 预设、模型发现和开发阶段加密凭据输入均已落地。当前仍以 Web Mock Workspace 代替 Tauri 文件系统。
|
||||
> 实现状态:更新至 2026-08-31。全部已注册业务路由均已有真实页面;Markdown 写作/源码模式、Search、Chat、智能体执行轨迹、扩展管理、设置、Provider 预设、模型发现和开发阶段加密凭据输入均已落地。Web Workspace 已连接 FastAPI 管理的真实单 Vault;Tauri 原生目录选择和多 Vault 尚未接入。
|
||||
|
||||
## 1. 第一阶段目标
|
||||
|
||||
@@ -716,10 +716,10 @@ SecretService
|
||||
|
||||
### 16.3 WorkspaceService
|
||||
|
||||
- 统一封装 Tauri 文件命令;
|
||||
- 统一封装 FastAPI Workspace/Note API,并为 Tauri 文件命令保留适配边界;
|
||||
- 规范化路径;
|
||||
- 处理文件锁、自动保存和冲突;
|
||||
- Web 开发模式提供可替换的 Mock 实现;
|
||||
- Web 开发模式连接后端配置的单一 Vault,禁止失败后回退 Mock;
|
||||
- 不把任意本地路径直接暴露给 Plugin UI。
|
||||
|
||||
## 17. 公共组件
|
||||
|
||||
+16
-1
@@ -30,8 +30,22 @@
|
||||
| PATCH | `/api/notes/{note_id}` | 更新笔记 |
|
||||
| DELETE | `/api/notes/{note_id}` | 删除笔记 |
|
||||
| POST | `/api/notes/{note_id}/move` | 移动笔记 |
|
||||
| POST | `/api/notes/{note_id}/rename` | 重命名笔记文件并保留 Note/Block 身份 |
|
||||
| POST | `/api/search` | FTS、Vector 或 Hybrid 检索 |
|
||||
|
||||
### Workspace
|
||||
|
||||
Web 联调阶段只暴露后端通过 `APP_VAULT_PATH` 配置的单一 Vault,不接受浏览器传入任意本地目录。桌面多 Vault 与目录选择仍由后续 Tauri Host 提供。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/workspace` | 获取当前 Vault、文件数和索引同步状态 |
|
||||
| POST | `/api/workspace/open` | 打开配置的 Vault;磁盘路径集变化时重建索引 |
|
||||
| GET | `/api/workspace/tree` | 获取真实 Markdown 文件和目录树 |
|
||||
| POST | `/api/workspace/folders` | 新建目录 |
|
||||
| POST | `/api/workspace/folders/rename` | 重命名目录并同步 Note 路径 |
|
||||
| POST | `/api/workspace/folders/delete` | 删除目录及其 Note、Block、FTS 和向量记录 |
|
||||
|
||||
### Chat、Agent 与 Tool
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
@@ -159,11 +173,12 @@ RunCancelled
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
更新至 2026-08-31:后端 71 项回归测试通过。
|
||||
更新至 2026-08-31:后端 76 项回归测试通过。
|
||||
|
||||
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
|
||||
- Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。
|
||||
- Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。
|
||||
- Workspace 已接入后端配置的真实 Vault;文件树、笔记读写、文件/目录新建、重命名和删除不再使用前端 Mock Fallback。
|
||||
- Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。
|
||||
- Plugin 启用前必须通过权限接口记录授权,未知权限默认拒绝。
|
||||
- Attachment Tool 读取 Host 管理的 `attachments` 目录;音频接口读取 Host 生成的转写文本,真实本地语音模型在第二阶段接入。
|
||||
|
||||
@@ -68,7 +68,7 @@ uv run pytest -q -p no:cacheprovider
|
||||
当前基线:
|
||||
|
||||
```text
|
||||
71 passed
|
||||
76 passed
|
||||
```
|
||||
|
||||
通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。
|
||||
@@ -83,8 +83,8 @@ pnpm test
|
||||
当前基线:
|
||||
|
||||
```text
|
||||
9 test files passed
|
||||
23 tests passed
|
||||
10 test files passed
|
||||
26 tests passed
|
||||
```
|
||||
|
||||
通过标准:退出码为 0、失败数为 0。测试覆盖 Provider Store、主题偏好、Workspace、文件树、文件切换、可视化编辑器、智能体中文标签、轻量动效性能约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题输出。
|
||||
@@ -266,7 +266,9 @@ Invoke-RestMethod -Uri "$apiBase/providers/mock/models"
|
||||
|
||||
### 6.2 Workspace 与 Markdown
|
||||
|
||||
- 能新建、打开、重命名和删除 Web Mock 文件;
|
||||
- 启动 FastAPI 并配置 `APP_VAULT_PATH` 后,能打开后端真实 Vault;
|
||||
- 能在磁盘和 SQLite/FTS/向量索引之间一致地新建、读取、保存、重命名和删除文件及目录;
|
||||
- 后端不可用时明确报告连接错误,不展示或写入 Mock 文件;
|
||||
- 连续快速点击不同文件时,路径和正文始终一致;
|
||||
- 文件切换前的未保存内容不会被错误写入新文件;
|
||||
- 写作模式不展示 Markdown 源码,源码模式可以精确编辑;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -74,7 +60,7 @@ async function createVault() {
|
||||
|
||||
<div class="vault-card">
|
||||
<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 class="section-label">最近打开</div>
|
||||
@@ -97,11 +83,8 @@ async function createVault() {
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading">
|
||||
<AppIcon :icon="FolderOpened" /> 打开本地 Vault
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="showCreateDialog = true" :disabled="isLoading">
|
||||
<AppIcon :icon="Plus" /> 创建新 Vault
|
||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || !workspaceStore.recentVaults.length">
|
||||
<AppIcon :icon="FolderOpened" /> 打开后端 Vault
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -122,24 +105,6 @@ async function createVault() {
|
||||
</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>
|
||||
</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 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>
|
||||
|
||||
@@ -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))!
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import EditorHeader from '@/features/editor/EditorHeader.vue'
|
||||
import EditorPane from '@/features/editor/EditorPane.vue'
|
||||
import { EditPen } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -37,3 +37,7 @@ export async function deleteNote(noteId: string): Promise<OperationResponse> {
|
||||
export async function moveNote(noteId: string, folder: string): Promise<ApiNote> {
|
||||
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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,258 +1,171 @@
|
||||
import type { FileNode } from '@/contracts'
|
||||
|
||||
// Web 开发模式使用内存实现,服务签名保持与未来桌面文件系统适配器一致。
|
||||
// TODO(desktop): Tauri Host 就绪后通过 IPC 替换 Mock,并保留路径规范化与错误映射。
|
||||
import type {
|
||||
ApiNote,
|
||||
ApiWorkspaceEntry,
|
||||
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 {
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const MOCK_VAULTS: VaultInfo[] = [
|
||||
{ path: '/Users/demo/Documents/MyVault', name: '我的知识库' },
|
||||
{ path: '/Users/demo/Documents/StudyNotes', name: '学习笔记' },
|
||||
]
|
||||
let cachedTree: FileNode[] | null = null
|
||||
const noteIdByPath = new Map<string, string>()
|
||||
const typeByPath = new Map<string, FileNode['type']>()
|
||||
|
||||
const MOCK_FILE_TREE: FileNode[] = [
|
||||
{
|
||||
id: 'f-data',
|
||||
name: '数据结构',
|
||||
path: '/数据结构',
|
||||
type: 'folder',
|
||||
is_open: true,
|
||||
children: [
|
||||
{ id: 'n-rbt', name: '红黑树.md', path: '/数据结构/红黑树.md', type: 'file' },
|
||||
{ id: 'n-bst', name: '二叉搜索树.md', path: '/数据结构/二叉搜索树.md', type: 'file' },
|
||||
{
|
||||
id: 'f-list',
|
||||
name: '链表',
|
||||
path: '/数据结构/链表',
|
||||
type: 'folder',
|
||||
is_open: false,
|
||||
children: [
|
||||
{ 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',
|
||||
function normalizePublicPath(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
||||
return normalized ? `/${normalized}` : '/'
|
||||
}
|
||||
|
||||
function relativePath(path: string): string {
|
||||
return normalizePublicPath(path).replace(/^\//, '')
|
||||
}
|
||||
|
||||
function toFileNode(entry: ApiWorkspaceEntry): FileNode {
|
||||
const path = normalizePublicPath(entry.path)
|
||||
const node: FileNode = {
|
||||
id: entry.entry_id,
|
||||
note_id: entry.note_id ?? undefined,
|
||||
name: entry.name,
|
||||
path,
|
||||
type: entry.type,
|
||||
is_open: false,
|
||||
children: [
|
||||
{ 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
|
||||
- [ ] 性能优化与测试
|
||||
|
||||
---
|
||||
|
||||
祝你写作愉快!
|
||||
`)
|
||||
children: entry.type === 'folder' ? entry.children.map(toFileNode) : undefined,
|
||||
}
|
||||
if (name === '红黑树.md') {
|
||||
return rememberContent(filePath, `# 红黑树
|
||||
typeByPath.set(path, entry.type)
|
||||
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
|
||||
}
|
||||
|
||||
## 性质
|
||||
|
||||
1. 每个节点是红色或黑色
|
||||
2. 根节点是黑色
|
||||
3. 所有叶子节点(NIL)是黑色
|
||||
4. 如果一个节点是红色,则它的两个子节点都是黑色
|
||||
5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点
|
||||
|
||||
这些性质确保了红黑树的关键特性:**从根到叶子的最长可能路径不会超过最短可能路径的两倍长**。
|
||||
|
||||
## 插入操作
|
||||
|
||||
插入后可能破坏红黑性质,需要通过变色和旋转来修复。
|
||||
|
||||
### 情况1:叔叔节点是红色
|
||||
|
||||
将父节点和叔叔节点设为黑色,将祖父节点设为红色,当前节点上移到祖父节点,继续向上调整。
|
||||
|
||||
### 情况2:叔叔节点是黑色,且当前节点是右孩子
|
||||
|
||||
以父节点为支点左旋,将当前节点转换为左孩子,进入情况3。
|
||||
|
||||
### 情况3:叔叔节点是黑色,且当前节点是左孩子
|
||||
|
||||
以祖父节点为支点右旋,将父节点设为黑色,祖父节点设为红色。
|
||||
|
||||
## 与 AVL 树对比
|
||||
|
||||
| 特性 | AVL 树 | 红黑树 |
|
||||
|------|--------|--------|
|
||||
| 平衡严格度 | 高度差 ≤ 1 | 黑色高度相同 |
|
||||
| 查找速度 | 更快 | 略慢 |
|
||||
| 插入删除 | 旋转更多 | 旋转更少 |
|
||||
| 适用场景 | 读多写少 | 读写均衡 |
|
||||
|
||||
## 应用场景
|
||||
|
||||
- C++ STL 的 map/set
|
||||
- Java 的 TreeMap
|
||||
- Linux 内核的完全公平调度器
|
||||
`)
|
||||
function nodeFromNote(note: ApiNote): FileNode {
|
||||
const path = normalizePublicPath(note.file_path)
|
||||
noteIdByPath.set(path, note.note_id)
|
||||
typeByPath.set(path, 'file')
|
||||
return {
|
||||
id: note.note_id,
|
||||
note_id: note.note_id,
|
||||
name: path.split('/').at(-1) || note.title,
|
||||
path,
|
||||
type: 'file',
|
||||
}
|
||||
return rememberContent(filePath, `# ${name.replace('.md', '')}
|
||||
|
||||
这是一篇示例笔记。
|
||||
|
||||
## 第一部分
|
||||
|
||||
这里是笔记的内容。
|
||||
|
||||
## 第二部分
|
||||
|
||||
更多内容...
|
||||
|
||||
> 引用内容示例
|
||||
|
||||
\`\`\`javascript
|
||||
console.log('Hello, NotesAgent!');
|
||||
\`\`\`
|
||||
`)
|
||||
}
|
||||
|
||||
export function saveFileContent(filePath: string, content: string): Promise<void> {
|
||||
console.debug(`[workspaceService] Save ${filePath}, ${content.length} chars`)
|
||||
mockFileContents.set(filePath, content)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
async function requireNoteId(filePath: string): Promise<string> {
|
||||
const path = normalizePublicPath(filePath)
|
||||
let noteId = noteIdByPath.get(path)
|
||||
if (!noteId) {
|
||||
await refreshTree()
|
||||
noteId = noteIdByPath.get(path)
|
||||
}
|
||||
return Promise.resolve()
|
||||
if (!noteId) throw new Error(`笔记尚未建立后端索引:${path}`)
|
||||
return noteId
|
||||
}
|
||||
|
||||
export function deleteFile(path: string): Promise<void> {
|
||||
for (const filePath of [...mockFileContents.keys()]) {
|
||||
if (filePath === path || filePath.startsWith(`${path}/`)) mockFileContents.delete(filePath)
|
||||
export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
|
||||
return apiClient.get('/api/workspace')
|
||||
}
|
||||
|
||||
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> {
|
||||
// Mock 文件树由 Store 同步更新;真实实现必须在 Host 侧执行原子移动。
|
||||
void sourcePath
|
||||
void targetPath
|
||||
return Promise.resolve()
|
||||
export async function deleteFile(pathValue: string): Promise<void> {
|
||||
const path = normalizePublicPath(pathValue)
|
||||
if (typeByPath.get(path) === 'folder') {
|
||||
await apiClient.post<OperationResponse>('/api/workspace/folders/delete', {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user