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

This commit is contained in:
2026-08-31 21:38:56 +08:00
parent 84077feb18
commit 8da75d4420
23 changed files with 1055 additions and 442 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()
@@ -5,7 +5,7 @@
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步 > 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员 > 目标读者:前端、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 执行不计入第一阶段完成项。 第一阶段 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 上接入: 第二阶段在既有 Contract 上接入:
+1 -1
View File
@@ -36,7 +36,7 @@
| Security | 本地主密钥目前保存在数据目录,桌面端接入后迁移到系统凭据库 | | Security | 本地主密钥目前保存在数据目录,桌面端接入后迁移到系统凭据库 |
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 | | Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 | | AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
| Desktop | Workspace 仍使用 Web Mock,后续由 Tauri IPC 文件系统适配器替换 | | Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 |
| Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 | | Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 |
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker | | Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
+7 -5
View File
@@ -36,7 +36,7 @@ Vue Router
- OpenAI、DeepSeek、Ollama 预设、自动模型发现和开发阶段加密 API Key 输入; - OpenAI、DeepSeek、Ollama 预设、自动模型发现和开发阶段加密 API Key 输入;
- 智能体页面、运行状态、事件、工具和权限详情的中文展示。 - 智能体页面、运行状态、事件、工具和权限详情的中文展示。
原统一占位页已经删除,所有已注册业务路由均指向真实页面。当前 Workspace 文件能力仍使用 Web Mock AdapterTauri 文件系统、Stronghold 和桌面窗口能力在桌面容器阶段接入,不影响页面与 Store 的调用边界。 原统一占位页已经删除,所有已注册业务路由均指向真实页面。Web Workspace 已通过 FastAPI 连接后端配置的单一真实 Vault,不再回退 Mock 数据;Tauri 多 Vault、原生目录选择、Stronghold 和桌面窗口能力在桌面容器阶段接入,不影响页面与 Store 的调用边界。
## 2. 目录与职责 ## 2. 目录与职责
@@ -111,7 +111,9 @@ SecondarySidebar
文件树把右键目标保存在 `contextTarget`,重命名和删除始终作用于实际被右键的节点,不再依赖当前编辑文件。根目录使用 `/` 表示,新增根级文件时直接写入 Store 顶层数组。 文件树把右键目标保存在 `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 接口层 ## 6. HTTP 接口层
@@ -184,13 +186,13 @@ pnpm build
```text ```text
pnpm build passed pnpm build passed
pnpm test 23 passed pnpm test 26 passed
uv run pytest 71 passed uv run pytest 76 passed
preview smoke HTTP 200 preview smoke HTTP 200
git diff --check passed 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 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。 Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
+3 -3
View File
@@ -4,7 +4,7 @@
> 文档性质:开发需求基线,不是最终视觉规范或产品宣传文档。 > 文档性质:开发需求基线,不是最终视觉规范或产品宣传文档。
> 依据:`第一阶段分工表.md``AI笔记软件技术栈说明-团队版-v2.3.md``后端接口契约-开发版.md` > 依据:`第一阶段分工表.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 管理的真实单 VaultTauri 原生目录选择和多 Vault 尚未接入
## 1. 第一阶段目标 ## 1. 第一阶段目标
@@ -716,10 +716,10 @@ SecretService
### 16.3 WorkspaceService ### 16.3 WorkspaceService
- 统一封装 Tauri 文件命令; - 统一封装 FastAPI Workspace/Note API,并为 Tauri 文件命令保留适配边界
- 规范化路径; - 规范化路径;
- 处理文件锁、自动保存和冲突; - 处理文件锁、自动保存和冲突;
- Web 开发模式提供可替换的 Mock 实现 - Web 开发模式连接后端配置的单一 Vault,禁止失败后回退 Mock
- 不把任意本地路径直接暴露给 Plugin UI。 - 不把任意本地路径直接暴露给 Plugin UI。
## 17. 公共组件 ## 17. 公共组件
+16 -1
View File
@@ -30,8 +30,22 @@
| PATCH | `/api/notes/{note_id}` | 更新笔记 | | PATCH | `/api/notes/{note_id}` | 更新笔记 |
| DELETE | `/api/notes/{note_id}` | 删除笔记 | | DELETE | `/api/notes/{note_id}` | 删除笔记 |
| POST | `/api/notes/{note_id}/move` | 移动笔记 | | POST | `/api/notes/{note_id}/move` | 移动笔记 |
| POST | `/api/notes/{note_id}/rename` | 重命名笔记文件并保留 Note/Block 身份 |
| POST | `/api/search` | FTS、Vector 或 Hybrid 检索 | | 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 ### 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。 - Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
- Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。 - Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。
- Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。 - Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。
- Workspace 已接入后端配置的真实 Vault;文件树、笔记读写、文件/目录新建、重命名和删除不再使用前端 Mock Fallback。
- Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。 - Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。
- Plugin 启用前必须通过权限接口记录授权,未知权限默认拒绝。 - Plugin 启用前必须通过权限接口记录授权,未知权限默认拒绝。
- Attachment Tool 读取 Host 管理的 `attachments` 目录;音频接口读取 Host 生成的转写文本,真实本地语音模型在第二阶段接入。 - Attachment Tool 读取 Host 管理的 `attachments` 目录;音频接口读取 Host 生成的转写文本,真实本地语音模型在第二阶段接入。
+6 -4
View File
@@ -68,7 +68,7 @@ uv run pytest -q -p no:cacheprovider
当前基线: 当前基线:
```text ```text
71 passed 76 passed
``` ```
通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。 通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。
@@ -83,8 +83,8 @@ pnpm test
当前基线: 当前基线:
```text ```text
9 test files passed 10 test files passed
23 tests passed 26 tests passed
``` ```
通过标准:退出码为 0、失败数为 0。测试覆盖 Provider Store、主题偏好、Workspace、文件树、文件切换、可视化编辑器、智能体中文标签、轻量动效性能约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题输出。 通过标准:退出码为 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 ### 6.2 Workspace 与 Markdown
- 能新建、打开、重命名和删除 Web Mock 文件 - 启动 FastAPI 并配置 `APP_VAULT_PATH` 后,能打开后端真实 Vault
- 能在磁盘和 SQLite/FTS/向量索引之间一致地新建、读取、保存、重命名和删除文件及目录;
- 后端不可用时明确报告连接错误,不展示或写入 Mock 文件;
- 连续快速点击不同文件时,路径和正文始终一致; - 连续快速点击不同文件时,路径和正文始终一致;
- 文件切换前的未保存内容不会被错误写入新文件; - 文件切换前的未保存内容不会被错误写入新文件;
- 写作模式不展示 Markdown 源码,源码模式可以精确编辑; - 写作模式不展示 Markdown 源码,源码模式可以精确编辑;
+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()
} }