Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e8d4b7b9f | ||
|
|
a5b709a46f | ||
|
|
49dbacb296 | ||
|
|
3cb197aafe | ||
|
|
8da75d4420 | ||
|
|
84077feb18 | ||
|
|
bb8091f2e6 | ||
|
|
0b20bad0a8 | ||
|
|
9559fda5f9 | ||
|
|
fb1da4d00a | ||
|
|
8e4f4e6d1a |
@@ -10,7 +10,7 @@
|
||||
NotesAgent/
|
||||
├── frontend/ Vue 3 + TypeScript + Vite 前端
|
||||
├── backend/ FastAPI + Pydantic 后端
|
||||
├── docs/ 分工与技术栈说明
|
||||
├── docs/ 架构、契约、开发说明、协作规范与问题复盘
|
||||
└── server sync/ 云同步服务预留目录,当前未实现
|
||||
```
|
||||
|
||||
@@ -36,7 +36,7 @@ python --version
|
||||
uv --version
|
||||
```
|
||||
|
||||
当前 Web 联调不需要 Rust 和 Tauri。开始桌面端集成后,再按照 `docs/AI笔记软件技术栈说明-团队版-v2.2.md` 安装 Rust Toolchain 与 Tauri CLI。
|
||||
当前 Web 联调不需要 Rust 和 Tauri。开始桌面端集成后,再按照 `docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md` 安装 Rust Toolchain 与 Tauri CLI。
|
||||
|
||||
## 首次初始化
|
||||
|
||||
@@ -118,7 +118,7 @@ cd frontend
|
||||
pnpm test
|
||||
```
|
||||
|
||||
当前回归基线为后端 71 项测试、前端 23 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||
当前回归基线为后端 81 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||
|
||||
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
||||
|
||||
@@ -126,22 +126,15 @@ pnpm test
|
||||
|
||||
| 文档 | 用途 |
|
||||
| --- | --- |
|
||||
| [技术栈说明](docs/AI笔记软件技术栈说明-团队版-v2.2.md) | 目标架构、当前实施边界与模块依赖 |
|
||||
| [第一阶段分工表](docs/第一阶段分工表.md) | 成员职责、协作关系与当前交付状态 |
|
||||
| [第一阶段测试验证操作手册](docs/第一阶段测试验证操作手册.md) | 自动化测试、接口主链路、前端人工验收与记录模板 |
|
||||
| [后端接口契约](docs/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 |
|
||||
| [AI Core 与 Agent Core](docs/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
|
||||
| [Knowledge 与 Retrieval Core](docs/Knowledge与Retrieval-Core开发说明.md) | Block、索引、混合检索和 Citation |
|
||||
| [模型提供商与模型发现](docs/模型提供商与模型发现开发说明.md) | Provider 预设、模型发现和凭据边界 |
|
||||
| [前端页面需求](docs/前端页面需求说明-开发版.md) | 页面、交互、状态与验收基线 |
|
||||
| [前端实现说明](docs/前端壳子与接口层开发说明.md) | 当前前端目录、Service、SSE 和运行边界 |
|
||||
| [前端写作体验](docs/前端写作体验优化开发说明.md) | Milkdown、CodeMirror、格式栏和 Shiki |
|
||||
| [前端视觉与轻量动效](docs/前端视觉与轻量动效优化开发说明.md) | Design Token、页面美化、性能边界与主题注入约定 |
|
||||
| [Git 使用细则](docs/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
|
||||
| [代码注释与 TODO 约定](docs/代码注释与TODO约定.md) | 注释原则、TODO 格式、领域标签与当前待办索引 |
|
||||
| [后端审阅复盘](docs/后端全面审阅问题与修复复盘.md) | 后端问题原因、后果与修复方案 |
|
||||
| [Knowledge/Retrieval 复盘](docs/Knowledge与Retrieval-Core问题与修复复盘.md) | 检索与事务问题复盘 |
|
||||
| [前端审阅复盘](docs/前端合并审阅问题与修复复盘.md) | 前端工程、契约和交互问题复盘 |
|
||||
| [文档总索引](docs/README.md) | 文档分类、阅读顺序和维护规则 |
|
||||
| [技术栈说明](docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md) | 目标架构、第二阶段技术边界与模块依赖 |
|
||||
| [第二阶段分工表](docs/architecture/第二阶段团队分工表.md) | 第二阶段人员职责、任务顺序、协作关系与验收项 |
|
||||
| [后端接口契约](docs/contracts/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 |
|
||||
| [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 |
|
||||
| [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
|
||||
| [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
|
||||
| [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 |
|
||||
| [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 |
|
||||
|
||||
## 日常开发注意事项
|
||||
|
||||
@@ -151,6 +144,7 @@ pnpm test
|
||||
- API 默认监听 `127.0.0.1:8000`,前端默认监听 `127.0.0.1:5173`。
|
||||
- 后端附件目录默认是 `backend/data/attachments`,可通过 `APP_ATTACHMENTS_PATH` 覆盖;该目录由桌面 Host 管理。
|
||||
- 跨模块接口发生变化时,需要同步更新前后端类型和 `docs` 中的接口说明。
|
||||
- 当前前后端接口清单见 `docs/后端接口契约-开发版.md`,OpenAPI 以 `/openapi.json` 为准。
|
||||
- 前端页面、交互、状态管理和第一阶段验收要求见 `docs/前端页面需求说明-开发版.md`。
|
||||
- 分支、提交、Pull Request、Review 和冲突处理规范见 `docs/Git使用细则-团队开发版.md`。
|
||||
- 当前已实现接口见 `docs/contracts/后端接口契约-开发版.md`,第二阶段规划接口见 `docs/contracts/第二阶段接口契约-开发版.md`;已实现能力以 `/openapi.json` 为准。
|
||||
- 前端页面、交互、状态管理和第一阶段验收要求见 `docs/contracts/前端页面需求说明-开发版.md`。
|
||||
- 分支、提交、Pull Request、Review 和冲突处理规范见 `docs/guides/Git使用细则-团队开发版.md`。
|
||||
- CI 检查、产物、发布和回滚规范见 `docs/guides/CI-CD细则-团队开发版.md`。
|
||||
|
||||
+4
-4
@@ -23,10 +23,10 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
当前基线为 71 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
||||
当前基线为 81 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
||||
|
||||
团队接口清单见 `../docs/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
||||
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
||||
|
||||
AI Core 与 Agent Core 的模块边界、Mock Provider 和 Tool Calling 调试方式见 `../docs/AI-Core与Agent-Core开发说明.md`。
|
||||
AI Core 与 Agent Core 的模块边界、Mock Provider 和 Tool Calling 调试方式见 `../docs/development/AI-Core与Agent-Core开发说明.md`。
|
||||
|
||||
Knowledge Core 与 Retrieval Core 的模块边界、数据模型、接口与检索流程见 `../docs/Knowledge与Retrieval-Core开发说明.md`。
|
||||
Knowledge Core 与 Retrieval Core 的模块边界、数据模型、接口与检索流程见 `../docs/development/Knowledge与Retrieval-Core开发说明.md`。
|
||||
|
||||
@@ -104,6 +104,11 @@ class PermissionManager:
|
||||
ticket.future.set_result(decision)
|
||||
return True
|
||||
|
||||
def get_ticket(self, run_id: str, request_id: str) -> PermissionTicket | None:
|
||||
"""只读返回待确认票据,供 Trace 记录权限类型;不暴露 Future 给接口层。"""
|
||||
|
||||
return self._pending.get((run_id, request_id))
|
||||
|
||||
def cancel_run(self, run_id: str) -> None:
|
||||
for key, ticket in list(self._pending.items()):
|
||||
if ticket.run_id == run_id:
|
||||
|
||||
+178
-35
@@ -7,17 +7,20 @@ import json
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from time import perf_counter
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
from app.agent.permissions import PermissionManager, PermissionMode
|
||||
from app.agent.tools import ToolExecutionContext, ToolNotFoundError, ToolRegistry
|
||||
from app.agent.trace_repository import AgentTraceRepository, sanitize_trace_value
|
||||
from app.contracts import (
|
||||
AgentEvent,
|
||||
AgentEventType,
|
||||
AgentRun,
|
||||
AgentRunCreateRequest,
|
||||
AgentRunStatus,
|
||||
AgentTraceResponse,
|
||||
Citation,
|
||||
Message,
|
||||
MessageRole,
|
||||
@@ -61,6 +64,7 @@ class RunRecord:
|
||||
events: list[AgentEvent] = field(default_factory=list)
|
||||
subscribers: set[asyncio.Queue[AgentEvent]] = field(default_factory=set)
|
||||
task: asyncio.Task[None] | None = None
|
||||
next_sequence: int = 0
|
||||
|
||||
|
||||
class AgentRuntime:
|
||||
@@ -72,11 +76,13 @@ class AgentRuntime:
|
||||
tools: ToolRegistry,
|
||||
permissions: PermissionManager,
|
||||
skills: SkillRuntime | None = None,
|
||||
trace_repository: AgentTraceRepository | None = None,
|
||||
) -> None:
|
||||
self.providers = providers
|
||||
self.tools = tools
|
||||
self.permissions = permissions
|
||||
self.skills = skills
|
||||
self.trace_repository = trace_repository or AgentTraceRepository()
|
||||
self._records: dict[str, RunRecord] = {}
|
||||
|
||||
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun:
|
||||
@@ -116,22 +122,38 @@ class AgentRuntime:
|
||||
skill_config=skill_config,
|
||||
allowed_tools=allowed_tools,
|
||||
)
|
||||
self.trace_repository.create_run(
|
||||
run,
|
||||
request,
|
||||
self._config_snapshot(record),
|
||||
)
|
||||
self._records[run.run_id] = record
|
||||
record.task = asyncio.create_task(self._execute(record), name=run.run_id)
|
||||
return run.model_copy(deep=True)
|
||||
|
||||
def get_run(self, run_id: str) -> AgentRun:
|
||||
return self._get_record(run_id).run.model_copy(deep=True)
|
||||
record = self._records.get(run_id)
|
||||
if record is not None:
|
||||
return record.run.model_copy(deep=True)
|
||||
run = self.trace_repository.recover_interrupted(run_id)
|
||||
if run is None:
|
||||
raise AgentRunNotFoundError(run_id)
|
||||
return run.model_copy(deep=True)
|
||||
|
||||
def list_runs(self, limit: int, offset: int) -> tuple[list[AgentRun], int]:
|
||||
records = sorted(
|
||||
self._records.values(), key=lambda item: item.run.created_at, reverse=True
|
||||
)
|
||||
items = [item.run.model_copy(deep=True) for item in records[offset : offset + limit]]
|
||||
return items, len(records)
|
||||
items, total = self.trace_repository.list_runs(limit=limit, offset=offset)
|
||||
recovered = [
|
||||
self.trace_repository.recover_interrupted(item.run_id) or item
|
||||
if item.run_id not in self._records
|
||||
else self._records[item.run_id].run.model_copy(deep=True)
|
||||
for item in items
|
||||
]
|
||||
return recovered, total
|
||||
|
||||
async def cancel(self, run_id: str) -> AgentRun:
|
||||
record = self._get_record(run_id)
|
||||
record = self._records.get(run_id)
|
||||
if record is None:
|
||||
return self.get_run(run_id)
|
||||
if record.run.status in TERMINAL_STATUSES:
|
||||
return record.run.model_copy(deep=True)
|
||||
record.run.cancelled = True
|
||||
@@ -144,23 +166,53 @@ class AgentRuntime:
|
||||
return record.run.model_copy(deep=True)
|
||||
|
||||
def resolve_permission(self, run_id: str, request_id: str, decision: str) -> bool:
|
||||
self._get_record(run_id)
|
||||
return self.permissions.resolve(run_id, request_id, decision)
|
||||
record = self._records.get(run_id)
|
||||
if record is None:
|
||||
return False
|
||||
ticket = self.permissions.get_ticket(run_id, request_id)
|
||||
resolved = self.permissions.resolve(run_id, request_id, decision)
|
||||
if resolved:
|
||||
self._publish(
|
||||
record,
|
||||
AgentEventType.permission_resolved,
|
||||
{
|
||||
"request_id": request_id,
|
||||
"permission": ticket.permission if ticket else None,
|
||||
"decision": decision,
|
||||
},
|
||||
)
|
||||
return resolved
|
||||
|
||||
async def events(self, run_id: str) -> AsyncIterator[AgentEvent]:
|
||||
record = self._get_record(run_id)
|
||||
# 先回放快照再订阅实时事件,使晚加入的 SSE 客户端也能恢复界面状态。
|
||||
# TODO(agent): 持久化事件并支持 Last-Event-ID,进程重启后仍可续传。
|
||||
async def events(
|
||||
self, run_id: str, *, after_sequence: int = -1
|
||||
) -> AsyncIterator[AgentEvent]:
|
||||
record = self._records.get(run_id)
|
||||
run = self.get_run(run_id)
|
||||
if record is None:
|
||||
for event in self.trace_repository.list_events(
|
||||
run_id, after_sequence=after_sequence
|
||||
):
|
||||
yield event
|
||||
return
|
||||
|
||||
# 先注册订阅再读持久化历史;同一事件循环内没有 await,不会丢失交界事件。
|
||||
queue: asyncio.Queue[AgentEvent] = asyncio.Queue()
|
||||
record.subscribers.add(queue)
|
||||
history = [event.model_copy(deep=True) for event in record.events]
|
||||
history = self.trace_repository.list_events(
|
||||
run_id, after_sequence=after_sequence
|
||||
)
|
||||
last_sequence = after_sequence
|
||||
try:
|
||||
for event in history:
|
||||
last_sequence = event.sequence
|
||||
yield event
|
||||
if record.run.status in TERMINAL_STATUSES:
|
||||
if run.status in TERMINAL_STATUSES:
|
||||
return
|
||||
while True:
|
||||
event = await queue.get()
|
||||
if event.sequence <= last_sequence:
|
||||
continue
|
||||
last_sequence = event.sequence
|
||||
yield event.model_copy(deep=True)
|
||||
if event.event in {
|
||||
AgentEventType.run_completed,
|
||||
@@ -172,7 +224,9 @@ class AgentRuntime:
|
||||
record.subscribers.discard(queue)
|
||||
|
||||
async def wait(self, run_id: str) -> AgentRun:
|
||||
record = self._get_record(run_id)
|
||||
record = self._records.get(run_id)
|
||||
if record is None:
|
||||
return self.get_run(run_id)
|
||||
if record.task:
|
||||
try:
|
||||
await asyncio.shield(record.task)
|
||||
@@ -180,6 +234,17 @@ class AgentRuntime:
|
||||
pass
|
||||
return record.run.model_copy(deep=True)
|
||||
|
||||
def get_trace(
|
||||
self, run_id: str, *, after_sequence: int, limit: int
|
||||
) -> AgentTraceResponse:
|
||||
self.get_run(run_id)
|
||||
trace = self.trace_repository.get_trace(
|
||||
run_id, after_sequence=after_sequence, limit=limit
|
||||
)
|
||||
if trace is None:
|
||||
raise AgentRunNotFoundError(run_id)
|
||||
return trace
|
||||
|
||||
async def _execute(self, record: RunRecord) -> None:
|
||||
try:
|
||||
async with asyncio.timeout(record.request.run_timeout_seconds):
|
||||
@@ -210,15 +275,51 @@ class AgentRuntime:
|
||||
for step in range(1, record.request.max_steps + 1):
|
||||
record.run.current_step = step
|
||||
record.run.updated_at = datetime.now(timezone.utc)
|
||||
turn = await provider.complete(
|
||||
ModelRequest(
|
||||
provider_id=record.request.provider_id,
|
||||
model=record.request.model,
|
||||
system=(record.skill_config.system_prompt if record.skill_config else None),
|
||||
messages=messages,
|
||||
tools=allowed_tools,
|
||||
metadata=self._request_metadata(record),
|
||||
model_call_id = f"model_call_{uuid4().hex}"
|
||||
started_at = perf_counter()
|
||||
self._publish(
|
||||
record,
|
||||
AgentEventType.model_call_started,
|
||||
{
|
||||
"model_call_id": model_call_id,
|
||||
"step": step,
|
||||
"provider_id": record.request.provider_id,
|
||||
"model": record.request.model,
|
||||
},
|
||||
)
|
||||
try:
|
||||
turn = await provider.complete(
|
||||
ModelRequest(
|
||||
provider_id=record.request.provider_id,
|
||||
model=record.request.model,
|
||||
system=(record.skill_config.system_prompt if record.skill_config else None),
|
||||
messages=messages,
|
||||
tools=allowed_tools,
|
||||
metadata=self._request_metadata(record),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
self._publish(
|
||||
record,
|
||||
AgentEventType.model_call_failed,
|
||||
{
|
||||
"model_call_id": model_call_id,
|
||||
"duration_ms": int((perf_counter() - started_at) * 1000),
|
||||
"error_code": getattr(exc, "code", type(exc).__name__),
|
||||
},
|
||||
)
|
||||
raise
|
||||
self._publish(
|
||||
record,
|
||||
AgentEventType.model_call_completed,
|
||||
{
|
||||
"model_call_id": model_call_id,
|
||||
"duration_ms": int((perf_counter() - started_at) * 1000),
|
||||
"finish_reason": "tool_calls" if turn.tool_calls else "stop",
|
||||
"input_tokens": turn.input_tokens,
|
||||
"output_tokens": turn.output_tokens,
|
||||
"tool_call_count": len(turn.tool_calls),
|
||||
},
|
||||
)
|
||||
record.run.token_usage += turn.input_tokens + turn.output_tokens
|
||||
self._publish(
|
||||
@@ -257,7 +358,7 @@ class AgentRuntime:
|
||||
|
||||
async def execute(call: ToolCall) -> ToolResult:
|
||||
async with semaphore:
|
||||
return await self._execute_tool(record, call)
|
||||
return await self._execute_tool(record, call, model_call_id)
|
||||
|
||||
results = await asyncio.gather(*(execute(call) for call in calls))
|
||||
for call, result in zip(calls, results):
|
||||
@@ -290,8 +391,13 @@ class AgentRuntime:
|
||||
|
||||
self._fail(record, "MAX_STEPS_EXCEEDED", "Agent reached its maximum step count.")
|
||||
|
||||
async def _execute_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
|
||||
self._publish(record, AgentEventType.tool_call, call.model_dump(mode="json"))
|
||||
async def _execute_tool(
|
||||
self, record: RunRecord, call: ToolCall, parent_model_call_id: str
|
||||
) -> ToolResult:
|
||||
started_at = perf_counter()
|
||||
call_data = call.model_dump(mode="json")
|
||||
call_data["parent_model_call_id"] = parent_model_call_id
|
||||
self._publish(record, AgentEventType.tool_call, call_data)
|
||||
try:
|
||||
registered = self.tools.get(call.name)
|
||||
except ToolNotFoundError:
|
||||
@@ -305,7 +411,9 @@ class AgentRuntime:
|
||||
error_code="TOOL_NOT_ALLOWED",
|
||||
error_message="Tool is not included in allowed_tools.",
|
||||
)
|
||||
self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json"))
|
||||
self._publish_tool_result(
|
||||
record, result, parent_model_call_id, started_at
|
||||
)
|
||||
return result
|
||||
|
||||
permission = registered.definition.permission if registered else None
|
||||
@@ -317,7 +425,9 @@ class AgentRuntime:
|
||||
error_code="NETWORK_NOT_ALLOWED",
|
||||
error_message="Agent run does not allow network tools.",
|
||||
)
|
||||
self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json"))
|
||||
self._publish_tool_result(
|
||||
record, result, parent_model_call_id, started_at
|
||||
)
|
||||
return result
|
||||
mode = self.permissions.mode_for(permission)
|
||||
if mode == PermissionMode.deny:
|
||||
@@ -348,11 +458,13 @@ class AgentRuntime:
|
||||
error_code="PERMISSION_TIMEOUT",
|
||||
error_message="Tool permission confirmation timed out.",
|
||||
)
|
||||
self._publish(
|
||||
record, AgentEventType.tool_result, result.model_dump(mode="json")
|
||||
self._publish_tool_result(
|
||||
record, result, parent_model_call_id, started_at
|
||||
)
|
||||
return result
|
||||
record.run.status = AgentRunStatus.running
|
||||
record.run.updated_at = datetime.now(timezone.utc)
|
||||
self.trace_repository.save_run(record.run)
|
||||
result = (
|
||||
await self._invoke_tool(record, call)
|
||||
if decision in {"allow_once", "allow_session"}
|
||||
@@ -361,9 +473,21 @@ class AgentRuntime:
|
||||
else:
|
||||
result = await self._invoke_tool(record, call)
|
||||
|
||||
self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json"))
|
||||
self._publish_tool_result(record, result, parent_model_call_id, started_at)
|
||||
return result
|
||||
|
||||
def _publish_tool_result(
|
||||
self,
|
||||
record: RunRecord,
|
||||
result: ToolResult,
|
||||
parent_model_call_id: str,
|
||||
started_at: float,
|
||||
) -> None:
|
||||
data = result.model_dump(mode="json")
|
||||
data["parent_model_call_id"] = parent_model_call_id
|
||||
data["duration_ms"] = int((perf_counter() - started_at) * 1000)
|
||||
self._publish(record, AgentEventType.tool_result, data)
|
||||
|
||||
async def _invoke_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
@@ -411,15 +535,19 @@ class AgentRuntime:
|
||||
def _publish(
|
||||
self, record: RunRecord, event_type: AgentEventType, data: dict[str, object]
|
||||
) -> None:
|
||||
sanitized = sanitize_trace_value(data)
|
||||
assert isinstance(sanitized, dict)
|
||||
event = AgentEvent(
|
||||
event=event_type,
|
||||
run_id=record.run.run_id,
|
||||
sequence=len(record.events),
|
||||
data=data,
|
||||
sequence=record.next_sequence,
|
||||
data=sanitized,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
record.next_sequence += 1
|
||||
record.events.append(event)
|
||||
# 内存事件只保留最近窗口;完整审计轨迹应由后续持久化层承担。
|
||||
self.trace_repository.append_event(record.run, event)
|
||||
# 内存只保留实时订阅窗口;完整审计轨迹由 SQLite 保存。
|
||||
if len(record.events) > MAX_EVENTS_PER_RUN:
|
||||
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
|
||||
for queue in record.subscribers:
|
||||
@@ -433,6 +561,21 @@ class AgentRuntime:
|
||||
metadata["retrieval"] = record.skill_config.retrieval.model_dump(mode="json")
|
||||
return metadata
|
||||
|
||||
def _config_snapshot(self, record: RunRecord) -> dict[str, object]:
|
||||
provider = self.providers.get(record.request.provider_id).config
|
||||
return {
|
||||
"provider_id": record.request.provider_id,
|
||||
"provider_type": provider.provider_type.value,
|
||||
"model": record.request.model,
|
||||
"capabilities": [item.value for item in provider.capabilities],
|
||||
"skill_id": record.request.skill_id,
|
||||
"allowed_tools": list(record.allowed_tools),
|
||||
"max_steps": record.request.max_steps,
|
||||
"token_budget": record.request.token_budget,
|
||||
"allow_network": record.request.allow_network,
|
||||
"metadata": record.request.metadata,
|
||||
}
|
||||
|
||||
def _collect_citations(self, record: RunRecord, result: ToolResult) -> None:
|
||||
if not result.success or not isinstance(result.output, dict):
|
||||
return
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Agent Run/Event 持久化与 Trace 查询。
|
||||
|
||||
SQLite 中的事件是 SSE、前端 Trace 和 Benchmark 的共同事实来源。写入前统一脱敏和
|
||||
限长,避免 Secret 或无限大的 Tool Result 进入审计数据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.contracts import (
|
||||
AgentEvent,
|
||||
AgentEventType,
|
||||
AgentRun,
|
||||
AgentRunCreateRequest,
|
||||
AgentRunStatus,
|
||||
AgentTraceResponse,
|
||||
AgentTraceSummary,
|
||||
)
|
||||
from app.database.db import connect, transaction
|
||||
|
||||
MAX_TRACE_STRING = 4_096
|
||||
MAX_TRACE_COLLECTION = 100
|
||||
MAX_TRACE_DEPTH = 8
|
||||
_SECRET_KEYS = {
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"client_secret",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
}
|
||||
_SECRET_KEY_SUFFIXES = ("_api_key", "_password", "_secret")
|
||||
_TERMINAL_VALUES = {
|
||||
AgentRunStatus.completed.value,
|
||||
AgentRunStatus.failed.value,
|
||||
AgentRunStatus.cancelled.value,
|
||||
}
|
||||
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[^\s,;]+")
|
||||
_API_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")
|
||||
|
||||
|
||||
def sanitize_trace_value(
|
||||
value: Any, *, depth: int = 0, apply_limits: bool = True
|
||||
) -> Any:
|
||||
"""递归净化持久化数据;可按审计用途限制体积,Secret 始终脱敏。"""
|
||||
|
||||
if apply_limits and depth >= MAX_TRACE_DEPTH:
|
||||
return "[MAX_DEPTH]"
|
||||
if isinstance(value, dict):
|
||||
sanitized: dict[str, Any] = {}
|
||||
for index, (key, item) in enumerate(value.items()):
|
||||
if apply_limits and index >= MAX_TRACE_COLLECTION:
|
||||
sanitized["__truncated__"] = True
|
||||
break
|
||||
normalized = str(key).casefold().replace("-", "_")
|
||||
sanitized[str(key)] = (
|
||||
"[REDACTED]"
|
||||
if normalized in _SECRET_KEYS
|
||||
or normalized.endswith(_SECRET_KEY_SUFFIXES)
|
||||
else sanitize_trace_value(
|
||||
item, depth=depth + 1, apply_limits=apply_limits
|
||||
)
|
||||
)
|
||||
return sanitized
|
||||
if isinstance(value, (list, tuple)):
|
||||
source_items = value[:MAX_TRACE_COLLECTION] if apply_limits else value
|
||||
items = [
|
||||
sanitize_trace_value(
|
||||
item, depth=depth + 1, apply_limits=apply_limits
|
||||
)
|
||||
for item in source_items
|
||||
]
|
||||
if apply_limits and len(value) > MAX_TRACE_COLLECTION:
|
||||
items.append("[TRUNCATED]")
|
||||
return items
|
||||
if isinstance(value, str):
|
||||
value = _BEARER_PATTERN.sub("Bearer [REDACTED]", value)
|
||||
value = _API_KEY_PATTERN.sub("[REDACTED]", value)
|
||||
if apply_limits and len(value) > MAX_TRACE_STRING:
|
||||
return f"{value[:MAX_TRACE_STRING]}...[TRUNCATED]"
|
||||
return value
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
return sanitize_trace_value(
|
||||
str(value), depth=depth + 1, apply_limits=apply_limits
|
||||
)
|
||||
|
||||
|
||||
class AgentTraceRepository:
|
||||
def create_run(
|
||||
self,
|
||||
run: AgentRun,
|
||||
request: AgentRunCreateRequest,
|
||||
config_snapshot: dict[str, Any],
|
||||
) -> None:
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_runs(
|
||||
run_id, status, run_json, request_json, config_snapshot_json,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
run.run_id,
|
||||
run.status.value,
|
||||
self._serialize_run(run),
|
||||
json.dumps(
|
||||
sanitize_trace_value(request.model_dump(mode="json")),
|
||||
ensure_ascii=False,
|
||||
),
|
||||
json.dumps(
|
||||
sanitize_trace_value(config_snapshot), ensure_ascii=False
|
||||
),
|
||||
run.created_at.isoformat(),
|
||||
run.updated_at.isoformat(),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def save_run(self, run: AgentRun) -> None:
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
self._update_run(conn, run)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def append_event(self, run: AgentRun, event: AgentEvent) -> None:
|
||||
"""在同一事务中保存最新 Run 和事件;复写同一序号时保持幂等。"""
|
||||
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
self._update_run(conn, run)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_events(run_id, sequence, event, data_json, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id, sequence) DO NOTHING
|
||||
""",
|
||||
(
|
||||
event.run_id,
|
||||
event.sequence,
|
||||
event.event.value,
|
||||
json.dumps(event.data, ensure_ascii=False),
|
||||
event.timestamp.isoformat(),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_run(self, run_id: str) -> AgentRun | None:
|
||||
conn = connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT run_json FROM agent_runs WHERE run_id = ?", (run_id,)
|
||||
).fetchone()
|
||||
return AgentRun.model_validate_json(row["run_json"]) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_runs(self, limit: int, offset: int) -> tuple[list[AgentRun], int]:
|
||||
conn = connect()
|
||||
try:
|
||||
total = int(conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0])
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT run_json FROM agent_runs
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
""",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return [AgentRun.model_validate_json(row["run_json"]) for row in rows], total
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_events(
|
||||
self, run_id: str, *, after_sequence: int = -1, limit: int | None = None
|
||||
) -> list[AgentEvent]:
|
||||
conn = connect()
|
||||
try:
|
||||
sql = """
|
||||
SELECT event, sequence, data_json, timestamp
|
||||
FROM agent_events
|
||||
WHERE run_id = ? AND sequence > ?
|
||||
ORDER BY sequence
|
||||
"""
|
||||
params: tuple[Any, ...] = (run_id, after_sequence)
|
||||
if limit is not None:
|
||||
sql += " LIMIT ?"
|
||||
params += (limit,)
|
||||
return [self._event_from_row(run_id, row) for row in conn.execute(sql, params)]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_trace(
|
||||
self, run_id: str, *, after_sequence: int, limit: int
|
||||
) -> AgentTraceResponse | None:
|
||||
conn = connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT run_json, config_snapshot_json
|
||||
FROM agent_runs WHERE run_id = ?
|
||||
""",
|
||||
(run_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
run = AgentRun.model_validate_json(row["run_json"])
|
||||
event_rows = conn.execute(
|
||||
"""
|
||||
SELECT event, sequence, data_json, timestamp
|
||||
FROM agent_events
|
||||
WHERE run_id = ? AND sequence > ?
|
||||
ORDER BY sequence LIMIT ?
|
||||
""",
|
||||
(run_id, after_sequence, limit + 1),
|
||||
).fetchall()
|
||||
has_more = len(event_rows) > limit
|
||||
items = [
|
||||
self._event_from_row(run_id, item) for item in event_rows[:limit]
|
||||
]
|
||||
counts = {
|
||||
item["event"]: int(item["count"])
|
||||
for item in conn.execute(
|
||||
"""
|
||||
SELECT event, COUNT(*) AS count
|
||||
FROM agent_events WHERE run_id = ? GROUP BY event
|
||||
""",
|
||||
(run_id,),
|
||||
)
|
||||
}
|
||||
tool_errors = int(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM agent_events
|
||||
WHERE run_id = ? AND event = 'ToolResult'
|
||||
AND json_extract(data_json, '$.success') = 0
|
||||
""",
|
||||
(run_id,),
|
||||
).fetchone()[0]
|
||||
)
|
||||
errors = (
|
||||
counts.get(AgentEventType.run_failed.value, 0)
|
||||
+ counts.get(AgentEventType.model_call_failed.value, 0)
|
||||
+ tool_errors
|
||||
)
|
||||
duration_ms = max(
|
||||
0, int((run.updated_at - run.created_at).total_seconds() * 1000)
|
||||
)
|
||||
return AgentTraceResponse(
|
||||
run_id=run_id,
|
||||
status=run.status,
|
||||
items=items,
|
||||
next_sequence=items[-1].sequence if items else after_sequence,
|
||||
has_more=has_more,
|
||||
summary=AgentTraceSummary(
|
||||
model_calls=counts.get(AgentEventType.model_call_started.value, 0),
|
||||
tool_calls=counts.get(AgentEventType.tool_call.value, 0),
|
||||
duration_ms=duration_ms,
|
||||
token_usage=run.token_usage,
|
||||
errors=errors,
|
||||
),
|
||||
config_snapshot=json.loads(row["config_snapshot_json"]),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def recover_interrupted(self, run_id: str) -> AgentRun | None:
|
||||
"""把上个进程遗留的非终态 Run 收束为失败,并追加可回放终止事件。"""
|
||||
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
row = conn.execute(
|
||||
"SELECT run_json, status FROM agent_runs WHERE run_id = ?", (run_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
run = AgentRun.model_validate_json(row["run_json"])
|
||||
if row["status"] in _TERMINAL_VALUES:
|
||||
return run
|
||||
run.status = AgentRunStatus.failed
|
||||
run.error_code = "AGENT_PROCESS_RESTARTED"
|
||||
run.error_message = "Agent process restarted before the run completed."
|
||||
run.updated_at = datetime.now(timezone.utc)
|
||||
next_sequence = int(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(MAX(sequence), -1) + 1
|
||||
FROM agent_events WHERE run_id = ?
|
||||
""",
|
||||
(run_id,),
|
||||
).fetchone()[0]
|
||||
)
|
||||
event = AgentEvent(
|
||||
event=AgentEventType.run_failed,
|
||||
run_id=run_id,
|
||||
sequence=next_sequence,
|
||||
data={
|
||||
"code": run.error_code,
|
||||
"message": run.error_message,
|
||||
},
|
||||
timestamp=run.updated_at,
|
||||
)
|
||||
self._update_run(conn, run)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_events(run_id, sequence, event, data_json, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
run_id,
|
||||
next_sequence,
|
||||
event.event.value,
|
||||
json.dumps(event.data, ensure_ascii=False),
|
||||
event.timestamp.isoformat(),
|
||||
),
|
||||
)
|
||||
return run
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _update_run(conn, run: AgentRun) -> None:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE agent_runs
|
||||
SET status = ?, run_json = ?, updated_at = ?
|
||||
WHERE run_id = ?
|
||||
""",
|
||||
(
|
||||
run.status.value,
|
||||
AgentTraceRepository._serialize_run(run),
|
||||
run.updated_at.isoformat(),
|
||||
run.run_id,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise LookupError(run.run_id)
|
||||
|
||||
@staticmethod
|
||||
def _event_from_row(run_id: str, row) -> AgentEvent:
|
||||
return AgentEvent(
|
||||
event=AgentEventType(row["event"]),
|
||||
run_id=run_id,
|
||||
sequence=int(row["sequence"]),
|
||||
data=json.loads(row["data_json"]),
|
||||
timestamp=datetime.fromisoformat(row["timestamp"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_run(run: AgentRun) -> str:
|
||||
# Run 是重启后 GET/list 的完整事实;只做 Secret 脱敏,不套用 Trace 摘要限长。
|
||||
return json.dumps(
|
||||
sanitize_trace_value(
|
||||
run.model_dump(mode="json"), apply_limits=False
|
||||
),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -283,6 +329,10 @@ class AgentEventType(str, Enum):
|
||||
permission_required = "PermissionRequired"
|
||||
usage = "Usage"
|
||||
citation = "Citation"
|
||||
model_call_started = "ModelCallStarted"
|
||||
model_call_completed = "ModelCallCompleted"
|
||||
model_call_failed = "ModelCallFailed"
|
||||
permission_resolved = "PermissionResolved"
|
||||
run_completed = "RunCompleted"
|
||||
run_failed = "RunFailed"
|
||||
run_cancelled = "RunCancelled"
|
||||
@@ -296,6 +346,24 @@ class AgentEvent(Contract):
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
class AgentTraceSummary(Contract):
|
||||
model_calls: int = 0
|
||||
tool_calls: int = 0
|
||||
duration_ms: int = 0
|
||||
token_usage: int = 0
|
||||
errors: int = 0
|
||||
|
||||
|
||||
class AgentTraceResponse(Contract):
|
||||
run_id: str
|
||||
status: AgentRunStatus
|
||||
items: list[AgentEvent] = Field(default_factory=list)
|
||||
next_sequence: int
|
||||
has_more: bool = False
|
||||
summary: AgentTraceSummary = Field(default_factory=AgentTraceSummary)
|
||||
config_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PermissionDecisionRequest(Contract):
|
||||
decision: Literal["allow_once", "allow_session", "deny"]
|
||||
|
||||
|
||||
@@ -69,6 +69,33 @@ MIGRATIONS: list[str] = [
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status_due ON tasks(status, due_at);
|
||||
""",
|
||||
# v3: 第二阶段 Agent Trace;Run 与事件事实持久化,供 SSE 恢复和 Benchmark 复用。
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
run_json TEXT NOT NULL,
|
||||
request_json TEXT NOT NULL,
|
||||
config_snapshot_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_created
|
||||
ON agent_runs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_status
|
||||
ON agent_runs(status, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_events (
|
||||
run_id TEXT NOT NULL REFERENCES agent_runs(run_id) ON DELETE CASCADE,
|
||||
sequence INTEGER NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL DEFAULT '{}',
|
||||
timestamp TEXT NOT NULL,
|
||||
PRIMARY KEY (run_id, sequence)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_events_type
|
||||
ON agent_events(run_id, event, sequence);
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+112
-7
@@ -2,17 +2,21 @@ from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Header, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.contracts import (
|
||||
AgentRun,
|
||||
AgentRunCreateRequest,
|
||||
AgentRunListResponse,
|
||||
AgentTraceResponse,
|
||||
ChatRequest,
|
||||
CredentialStatus,
|
||||
CredentialWriteRequest,
|
||||
ExtensionInstallRequest,
|
||||
FolderCreateRequest,
|
||||
FolderDeleteRequest,
|
||||
FolderRenameRequest,
|
||||
IndexJob,
|
||||
IndexRebuildRequest,
|
||||
IndexStatus,
|
||||
@@ -22,6 +26,7 @@ from app.contracts import (
|
||||
NoteCreateRequest,
|
||||
NoteListResponse,
|
||||
NoteMoveRequest,
|
||||
NoteRenameRequest,
|
||||
NoteUpdateRequest,
|
||||
OperationResponse,
|
||||
PageMeta,
|
||||
@@ -48,6 +53,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 +67,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")
|
||||
|
||||
@@ -67,8 +82,9 @@ def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def as_sse(event: str, payload: str) -> str:
|
||||
return f"event: {event}\ndata: {payload}\n\n"
|
||||
def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str:
|
||||
id_line = f"id: {event_id}\n" if event_id is not None else ""
|
||||
return f"{id_line}event: {event}\ndata: {payload}\n\n"
|
||||
|
||||
|
||||
def provider_or_404(provider_id: str):
|
||||
@@ -114,6 +130,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 +211,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:
|
||||
@@ -260,16 +316,65 @@ async def cancel_agent_run(run_id: str) -> OperationResponse:
|
||||
},
|
||||
tags=["Agent"],
|
||||
)
|
||||
async def agent_events(run_id: str) -> StreamingResponse:
|
||||
async def agent_events(
|
||||
run_id: str,
|
||||
after_sequence: int | None = Query(default=None, ge=-1),
|
||||
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||
) -> StreamingResponse:
|
||||
agent_run_or_404(run_id)
|
||||
cursor = after_sequence
|
||||
if cursor is None and last_event_id is not None:
|
||||
try:
|
||||
cursor = int(last_event_id)
|
||||
except ValueError as exc:
|
||||
raise ApiError(
|
||||
400,
|
||||
"TRACE_CURSOR_INVALID",
|
||||
"Last-Event-ID must be an integer sequence.",
|
||||
{"last_event_id": last_event_id},
|
||||
) from exc
|
||||
if cursor < -1:
|
||||
raise ApiError(
|
||||
400,
|
||||
"TRACE_CURSOR_INVALID",
|
||||
"Last-Event-ID must be greater than or equal to -1.",
|
||||
)
|
||||
cursor = cursor if cursor is not None else -1
|
||||
|
||||
async def stream() -> AsyncIterator[str]:
|
||||
async for event in container.agent.events(run_id):
|
||||
yield as_sse(event.event.value, event.model_dump_json())
|
||||
async for event in container.agent.events(run_id, after_sequence=cursor):
|
||||
yield as_sse(
|
||||
event.event.value,
|
||||
event.model_dump_json(),
|
||||
event_id=event.sequence,
|
||||
)
|
||||
|
||||
return StreamingResponse(stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent/runs/{run_id}/trace",
|
||||
response_model=AgentTraceResponse,
|
||||
tags=["Agent"],
|
||||
)
|
||||
async def get_agent_trace(
|
||||
run_id: str,
|
||||
after_sequence: int = Query(default=-1, ge=-1),
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
) -> AgentTraceResponse:
|
||||
try:
|
||||
return container.agent.get_trace(
|
||||
run_id, after_sequence=after_sequence, limit=limit
|
||||
)
|
||||
except AgentRunNotFoundError as exc:
|
||||
raise ApiError(
|
||||
404,
|
||||
"AGENT_RUN_NOT_FOUND",
|
||||
f"Agent run does not exist: {run_id}",
|
||||
{"run_id": run_id},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agent/runs/{run_id}/permissions/{request_id}",
|
||||
response_model=OperationResponse,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -1,10 +1,18 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.trace_repository import AgentTraceRepository
|
||||
from app.agent.permissions import PermissionMode
|
||||
from app.agent.tools import ToolExecutionContext
|
||||
from app.container import build_container
|
||||
from app.database.db import connect
|
||||
from app.errors import ApiError
|
||||
from app.routes import agent_events
|
||||
from app.contracts import (
|
||||
AgentEventType,
|
||||
AgentRun,
|
||||
AgentRunCreateRequest,
|
||||
AgentRunStatus,
|
||||
ToolCall,
|
||||
@@ -108,8 +116,200 @@ def test_permission_confirmation_resumes_agent() -> None:
|
||||
created.run_id, request_id, "allow_once"
|
||||
)
|
||||
completed = await container.agent.wait(created.run_id)
|
||||
events = [event async for event in container.agent.events(created.run_id)]
|
||||
assert completed.status == AgentRunStatus.completed
|
||||
assert completed.tool_results[0].success is True
|
||||
assert AgentEventType.permission_resolved in {event.event for event in events}
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_agent_trace_persists_and_replays_from_sequence() -> None:
|
||||
async def scenario() -> None:
|
||||
first = build_container()
|
||||
created = await first.agent.create_run(
|
||||
AgentRunCreateRequest(
|
||||
input="persistent trace",
|
||||
provider_id="mock",
|
||||
model="mock-1",
|
||||
metadata={"suite": "agent-benchmark-v1"},
|
||||
)
|
||||
)
|
||||
completed = await first.agent.wait(created.run_id)
|
||||
|
||||
restarted = build_container()
|
||||
restored = restarted.agent.get_run(created.run_id)
|
||||
first_page = restarted.agent.get_trace(
|
||||
created.run_id, after_sequence=-1, limit=2
|
||||
)
|
||||
second_page = restarted.agent.get_trace(
|
||||
created.run_id,
|
||||
after_sequence=first_page.next_sequence,
|
||||
limit=100,
|
||||
)
|
||||
replay = [
|
||||
event
|
||||
async for event in restarted.agent.events(
|
||||
created.run_id, after_sequence=first_page.next_sequence
|
||||
)
|
||||
]
|
||||
|
||||
assert completed.status == restored.status == AgentRunStatus.completed
|
||||
assert first_page.has_more is True
|
||||
assert [item.sequence for item in first_page.items] == [0, 1]
|
||||
assert second_page.items[0].sequence == 2
|
||||
assert replay == second_page.items
|
||||
assert first_page.summary.model_calls == 1
|
||||
assert first_page.summary.token_usage == completed.token_usage
|
||||
assert first_page.config_snapshot["metadata"] == {
|
||||
"suite": "agent-benchmark-v1"
|
||||
}
|
||||
assert second_page.items[-1].event == AgentEventType.run_completed
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_interrupted_persisted_run_is_closed_after_restart() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
request = AgentRunCreateRequest(
|
||||
input="interrupted",
|
||||
provider_id="mock",
|
||||
model="mock-1",
|
||||
)
|
||||
persisted = AgentRun(
|
||||
run_id="run_interrupted",
|
||||
status=AgentRunStatus.running,
|
||||
input=request.input,
|
||||
provider_id=request.provider_id,
|
||||
model=request.model,
|
||||
max_steps=request.max_steps,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
AgentTraceRepository().create_run(persisted, request, {"model": "mock-1"})
|
||||
|
||||
restarted = build_container()
|
||||
recovered = restarted.agent.get_run(persisted.run_id)
|
||||
events = run(
|
||||
_collect_events(restarted.agent.events(persisted.run_id, after_sequence=-1))
|
||||
)
|
||||
|
||||
assert recovered.status == AgentRunStatus.failed
|
||||
assert recovered.error_code == "AGENT_PROCESS_RESTARTED"
|
||||
assert events[-1].event == AgentEventType.run_failed
|
||||
assert events[-1].sequence == 0
|
||||
|
||||
|
||||
def test_trace_redacts_secrets_and_truncates_large_values() -> None:
|
||||
async def scenario() -> None:
|
||||
container = build_container()
|
||||
secret = "sk-should-not-be-stored"
|
||||
created = await container.agent.create_run(
|
||||
AgentRunCreateRequest(
|
||||
input=f'/tool system.echo {{"text":"{"x" * 4200}","api_key":"{secret}"}}',
|
||||
provider_id="mock",
|
||||
model="mock-1",
|
||||
allowed_tools=["system.echo"],
|
||||
metadata={"authorization": secret},
|
||||
)
|
||||
)
|
||||
await container.agent.wait(created.run_id)
|
||||
trace = container.agent.get_trace(
|
||||
created.run_id, after_sequence=-1, limit=100
|
||||
)
|
||||
tool_call = next(
|
||||
item for item in trace.items if item.event == AgentEventType.tool_call
|
||||
)
|
||||
|
||||
assert tool_call.data["arguments"]["api_key"] == "[REDACTED]"
|
||||
assert str(tool_call.data["arguments"]["text"]).endswith("...[TRUNCATED]")
|
||||
assert trace.config_snapshot["metadata"]["authorization"] == "[REDACTED]"
|
||||
assert secret not in trace.model_dump_json()
|
||||
conn = connect()
|
||||
try:
|
||||
stored_row = conn.execute(
|
||||
"""
|
||||
SELECT run_json, request_json, config_snapshot_json
|
||||
FROM agent_runs WHERE run_id = ?
|
||||
""",
|
||||
(created.run_id,),
|
||||
).fetchone()
|
||||
stored = "\n".join(str(value) for value in stored_row)
|
||||
finally:
|
||||
conn.close()
|
||||
assert secret not in stored
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_persisted_agent_run_preserves_long_input_and_output() -> None:
|
||||
"""审计事件可以限长,但重启后读取的 AgentRun 不能丢失正文。"""
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
long_input = "输入" * 2_500
|
||||
long_output = "输出" * 2_500
|
||||
request = AgentRunCreateRequest(
|
||||
input=long_input,
|
||||
provider_id="mock",
|
||||
model="mock-1",
|
||||
)
|
||||
persisted = AgentRun(
|
||||
run_id="run_long_content",
|
||||
status=AgentRunStatus.completed,
|
||||
input=long_input,
|
||||
output=long_output,
|
||||
provider_id=request.provider_id,
|
||||
model=request.model,
|
||||
max_steps=request.max_steps,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
repository = AgentTraceRepository()
|
||||
repository.create_run(persisted, request, {"model": request.model})
|
||||
|
||||
restored = repository.get_run(persisted.run_id)
|
||||
|
||||
assert restored is not None
|
||||
assert restored.input == long_input
|
||||
assert restored.output == long_output
|
||||
|
||||
|
||||
async def _collect_events(iterator):
|
||||
return [event async for event in iterator]
|
||||
|
||||
|
||||
def test_agent_sse_uses_last_event_id_and_emits_event_ids(monkeypatch) -> None:
|
||||
async def scenario() -> None:
|
||||
test_container = build_container()
|
||||
monkeypatch.setattr("app.routes.container", test_container)
|
||||
created = await test_container.agent.create_run(
|
||||
AgentRunCreateRequest(
|
||||
input="resume sse",
|
||||
provider_id="mock",
|
||||
model="mock-1",
|
||||
)
|
||||
)
|
||||
await test_container.agent.wait(created.run_id)
|
||||
|
||||
response = await agent_events(
|
||||
created.run_id, after_sequence=None, last_event_id="1"
|
||||
)
|
||||
chunks = [chunk async for chunk in response.body_iterator]
|
||||
body = "".join(
|
||||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||
for chunk in chunks
|
||||
)
|
||||
|
||||
assert "id: 0\n" not in body
|
||||
assert "id: 1\n" not in body
|
||||
assert "id: 2\n" in body
|
||||
assert "event: RunCompleted" in body
|
||||
|
||||
with pytest.raises(ApiError) as error:
|
||||
await agent_events(
|
||||
created.run_id, after_sequence=None, last_event_id="invalid"
|
||||
)
|
||||
assert error.value.code == "TRACE_CURSOR_INVALID"
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
|
||||
"/api/agent/runs",
|
||||
"/api/agent/runs/{run_id}/cancel",
|
||||
"/api/agent/runs/{run_id}/events",
|
||||
"/api/agent/runs/{run_id}/trace",
|
||||
"/api/skills",
|
||||
"/api/plugins",
|
||||
"/api/plugins/install",
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,69 @@
|
||||
# NotesAgent 文档索引
|
||||
|
||||
本目录集中保存团队开发期间需要长期维护的架构、接口、实现、协作和问题复盘文档。文档按用途分类,避免设计约束、开发记录与故障复盘混放。
|
||||
|
||||
## 目录分类
|
||||
|
||||
| 目录 | 内容 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| [`architecture/`](architecture/) | 技术栈、阶段目标与团队分工 | 确认整体边界、模块依赖和阶段范围 |
|
||||
| [`contracts/`](contracts/) | 前后端接口契约与页面需求 | 开发前对齐 DTO、路由、事件和交互 |
|
||||
| [`development/`](development/) | 各模块的实现说明 | 阅读现有代码、联调和扩展功能 |
|
||||
| [`guides/`](guides/) | Git、测试、注释和 CI/CD 规范 | 日常开发、提交、审阅和发布 |
|
||||
| [`retrospectives/`](retrospectives/) | 审阅发现的问题与修复复盘 | 排查同类问题、撰写总结或博客 |
|
||||
|
||||
## architecture:架构与分工
|
||||
|
||||
- [AI 笔记软件技术栈说明](architecture/AI笔记软件技术栈说明-团队版-v2.3.md)
|
||||
- [第一阶段分工表](architecture/第一阶段分工表.md)
|
||||
- [第二阶段团队分工表](architecture/第二阶段团队分工表.md)
|
||||
|
||||
## contracts:契约与需求
|
||||
|
||||
- [后端接口契约](contracts/后端接口契约-开发版.md)
|
||||
- [第二阶段接口契约](contracts/第二阶段接口契约-开发版.md)
|
||||
- [前端页面需求说明](contracts/前端页面需求说明-开发版.md)
|
||||
|
||||
运行中的后端以 `/openapi.json` 为机器可读事实来源。接口契约用于描述设计意图、联调约束和实现状态;两者不一致时,应先确认代码行为,再在同一个 PR 中同步修正文档或实现。
|
||||
|
||||
## development:开发说明
|
||||
|
||||
- [AI Core 与 Agent Core 开发说明](development/AI-Core与Agent-Core开发说明.md)
|
||||
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
|
||||
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
|
||||
- [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md)
|
||||
- [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md)
|
||||
- [前端视觉与轻量动效优化开发说明](development/前端视觉与轻量动效优化开发说明.md)
|
||||
|
||||
## guides:团队协作规范
|
||||
|
||||
- [Git 使用细则](guides/Git使用细则-团队开发版.md)
|
||||
- [CI/CD 细则](guides/CI-CD细则-团队开发版.md)
|
||||
- [代码注释与 TODO 约定](guides/代码注释与TODO约定.md)
|
||||
- [第一阶段测试验证操作手册](guides/第一阶段测试验证操作手册.md)
|
||||
|
||||
## retrospectives:问题与修复复盘
|
||||
|
||||
- [后端全面审阅问题与修复复盘](retrospectives/后端全面审阅问题与修复复盘.md)
|
||||
- [Agent Core 第二阶段问题与修复复盘](retrospectives/Agent-Core第二阶段问题与修复复盘.md)
|
||||
- [Knowledge 与 Retrieval Core 问题与修复复盘](retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md)
|
||||
- [前端合并审阅问题与修复复盘](retrospectives/前端合并审阅问题与修复复盘.md)
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
新成员或新阶段开始时,建议按以下顺序阅读:
|
||||
|
||||
1. 技术栈说明和当前阶段分工表;
|
||||
2. 所负责功能对应的接口契约;
|
||||
3. 对应模块的开发说明;
|
||||
4. Git、CI/CD、测试及注释规范;
|
||||
5. 与当前任务相关的问题复盘。
|
||||
|
||||
## 维护规则
|
||||
|
||||
- 新文档先判断用途,再放入对应分类目录,不在 `docs/` 根目录继续堆放业务文档。
|
||||
- 移动或重命名文档时,同步修正仓库内全部链接,并执行本地链接检查。
|
||||
- 接口、数据结构或事件格式发生变化时,同一个 PR 内同步更新契约和相关开发说明。
|
||||
- 问题复盘至少写清原因、后果、解决思路、实际方案和验证结果。
|
||||
- `.local-plans/` 只保存个人或阶段性的本地计划,不属于正式团队文档,不应提交到远程仓库。
|
||||
- 文档中的“计划实现”和“已经实现”必须明确区分;实现状态以代码、测试和运行时契约为准。
|
||||
@@ -1,10 +1,11 @@
|
||||
# AI 笔记软件技术栈说明
|
||||
|
||||
> 文档性质:团队技术基线
|
||||
> 基线版本:v2.3
|
||||
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
|
||||
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员
|
||||
|
||||
> 实施状态更新:2026-08-30。本文同时包含目标架构和当前实现。当前已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Tauri/Rust Host、Stronghold、真实桌面文件系统、独立 MCP Host、真实多模态模型和 Sync Server 尚未实现。
|
||||
> 实施状态更新:2026-09-01。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault,第二阶段 Agent Trace 持久化、分页快照和可恢复 SSE 已完成。后续继续接入真实音频处理、MCP、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
|
||||
|
||||
---
|
||||
|
||||
@@ -38,6 +39,8 @@
|
||||
| 状态管理 | Pinia | 管理工作区、编辑器、搜索、会话、Agent、Skill、主题与模型状态 |
|
||||
| UI 基础 | 当前公共 Vue 组件 + Element Plus 图标 + Design Token;目标按需引入 Reka UI | 通用交互组件、无障碍交互和主题化能力 |
|
||||
| Markdown 编辑器 | Milkdown + CodeMirror 6 | 可视化 Markdown 编辑与源码编辑 |
|
||||
| 图表渲染 | Mermaid + 受控 SVG 输出 | Markdown 流程图、时序图等图表的预览与静态导出 |
|
||||
| 函数图像 | `FunctionPlot` 结构化模型 + Renderer Adapter | 二维函数解析、交互预览与导出静态图 |
|
||||
| 本地核心服务 | Python + FastAPI + Pydantic v2 | RAG、Agent、Skill、模型访问、多模态、索引和本地 API |
|
||||
| Python 打包 | PyInstaller / Nuitka | 将 Python AI Core 打包为 Tauri Sidecar |
|
||||
| 笔记存储 | Markdown + Assets | 保存用户正文和附件 |
|
||||
@@ -49,14 +52,16 @@
|
||||
| Agent | 自研 Agent Runtime | 模型推理、工具选择、工具调用、结果回灌、运行控制 |
|
||||
| Skill | 自研声明式 Skill Runtime | 复用提示词、工具集合、权限和检索配置 |
|
||||
| Plugin | 自研 Plugin Runtime + Plugin Manifest + MCP Bridge | 扩展程序能力、Tool、外部服务集成和受控 UI Contribution |
|
||||
| Theme | Theme Manifest + Design Token + 受限 CSS | 本地主题包导入、预览、启停与社区格式兼容 |
|
||||
| LLM | 自研 Provider Adapter | 统一不同模型服务商的输入、输出、Streaming 与 Tool Calling |
|
||||
| 模型协议 | OpenAI Responses / Chat Completions compatible / Anthropic Messages / Ollama | 用户自定义模型接入 |
|
||||
| ASR | faster-whisper | 音频转写 |
|
||||
| 说话人分离 | pyannote.audio | 课堂、会议等多人音频中的说话人区分 |
|
||||
| 情感识别 | emotion2vec | 可选音频分析能力 |
|
||||
| 文档导出 | Document AST + Exporter Adapter | Markdown 到 HTML、PDF、DOCX,并保留图表、公式和代码块 |
|
||||
| 密钥存储 | 当前 Fernet 开发存储;目标 Tauri Stronghold | Web 联调期避免明文落盘,桌面集成后保存模型 API Key 和同步凭证 |
|
||||
| 云同步 | 独立 Sync Server:FastAPI + PostgreSQL + S3/MinIO | 可选自托管,多设备 Vault 同步、版本管理和设备管理 |
|
||||
| 测试 | pytest + Vitest + 自建 RAG / Agent Dataset | 后端、前端组件、接口、检索、Agent 和模型适配器测试 |
|
||||
| 测试 | pytest + Vitest + 版本化 RAG / Agent Dataset | 后端、前端组件、接口、检索质量、Agent 行为和模型适配器测试 |
|
||||
|
||||
表中的技术选型构成当前开发基线。新增依赖时需要明确其所属层、调用方、运行位置和替换成本,避免同一功能出现多套并行实现。
|
||||
|
||||
@@ -392,6 +397,22 @@ Reka UI / Headless Components 提供 Dialog、Popover、Menu、Tabs、Select、T
|
||||
|
||||
业务组件中优先引用 Design Token。主题包负责覆盖 Token 和允许开放的组件样式。主题加载器需要限制资源路径,避免主题 CSS 引用 Vault 外的任意本地文件。
|
||||
|
||||
### 5.5 Theme Package 与社区格式
|
||||
|
||||
第二阶段统一 Theme Package 的本地分发格式:
|
||||
|
||||
```text
|
||||
my-theme/
|
||||
├── theme.yaml
|
||||
├── theme.css
|
||||
├── preview.png
|
||||
└── README.md
|
||||
```
|
||||
|
||||
`theme.yaml` 至少声明 `id`、`name`、`version`、`author` 和 `min_app_version`。导入流程固定为 Manifest 校验、CSS 安全检查、隔离预览、安装、启用;停用或卸载后必须恢复内置 Design Token,不残留全局样式。
|
||||
|
||||
主题 CSS 只能使用开放的 Token 和宿主允许的稳定选择器,不允许脚本、远程资源、任意本地路径、`@import` 或覆盖安全相关界面。第二阶段只定义本地导入和社区包兼容格式,不把联网 Theme Marketplace 作为客户端依赖。
|
||||
|
||||
---
|
||||
|
||||
## 6. Markdown 编辑与知识结构
|
||||
@@ -472,6 +493,32 @@ Save Markdown
|
||||
|
||||
外部编辑器修改文件时也走相同流程。索引任务写入 `index_jobs`,前端可以展示待处理、处理中和失败状态。
|
||||
|
||||
### 6.5 Mermaid Code Block
|
||||
|
||||
Mermaid 使用标准 fenced code block 持久化,Markdown 文件仍是唯一正文来源:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Markdown] --> B[Renderer]
|
||||
```
|
||||
````
|
||||
|
||||
前端识别代码块后调用 Mermaid Renderer 生成 SVG。渲染必须支持亮暗主题、编辑后重新计算、错误占位、缩放查看和销毁旧实例。生成的 SVG 经过净化后才能插入 DOM;导出链路使用同一份 Mermaid 源码生成稳定的 SVG 或图片,不能抓取编辑器界面截图。
|
||||
|
||||
### 6.6 函数图像 Block
|
||||
|
||||
函数图像采用独立 fenced block,并在实现稳定后冻结语法:
|
||||
|
||||
````markdown
|
||||
```function-plot
|
||||
y = x^2
|
||||
y = sin(x)
|
||||
```
|
||||
````
|
||||
|
||||
解析层将文本转换为与渲染库无关的 `FunctionPlot`,至少包含表达式、定义域、显示范围、坐标轴和渲染配置。表达式解析不得使用 `eval` 或执行任意 JavaScript。交互预览和导出共享同一数据模型;HTML 可以保留交互渲染,PDF/DOCX 使用 SVG 或栅格化静态图。
|
||||
|
||||
---
|
||||
|
||||
## 7. SQLite 与数据归属
|
||||
@@ -498,7 +545,7 @@ index_jobs
|
||||
sync_state
|
||||
```
|
||||
|
||||
其中 `notes` 和 `blocks` 保存 Markdown 的结构化投影;FTS5 建立全文索引;sqlite-vec 保存 Block 向量;`agent_runs` 和 `tool_calls` 保存 Agent Trace;Provider 表保存非敏感模型配置。
|
||||
其中 `notes` 和 `blocks` 保存 Markdown 的结构化投影;FTS5 建立全文索引;sqlite-vec 保存 Block 向量;当前实现以 `agent_runs` 和 `agent_events` 保存 Agent Trace;Provider 表保存非敏感模型配置。
|
||||
|
||||
API Key、同步 Token 等机密数据不进入 SQLite,通过 `credential_id` 与 Stronghold 中的实际密钥关联。
|
||||
|
||||
@@ -628,7 +675,9 @@ class EmbeddingProvider(Protocol):
|
||||
async def embed_query(self, query: str) -> list[float]: ...
|
||||
```
|
||||
|
||||
默认配置使用本地 BGE-M3 类模型。模型名称和具体推理实现由配置决定,索引记录需要保存 embedding model id 和向量维度。用户更换模型后,由索引服务识别维度或模型变化并提示重新建立向量索引。
|
||||
目标默认配置使用本地 BGE-M3 类模型。当前第一阶段实现是 128 维 `HashEmbeddingProvider`,只用于离线跑通向量存储、索引更新和 Hybrid 链路,不代表真实语义召回质量。第二阶段接入真实 Embedding 时继续实现相同接口,上层 Retrieval Core 不依赖具体模型运行时。
|
||||
|
||||
索引记录需要保存 embedding model id、模型版本、向量维度和归一化方式。用户更换模型或任一索引兼容字段变化后,索引服务必须将旧向量标记为不可用并要求重建,禁止把不同模型生成的向量写入同一索引空间。
|
||||
|
||||
### 9.5 RRF 与 Reranker
|
||||
|
||||
@@ -724,7 +773,7 @@ Agent Run 至少提供以下限制:
|
||||
- 网络访问权限;
|
||||
- 并发 Tool 数量。
|
||||
|
||||
Agent 运行过程中产生的每一步写入 `agent_runs` 和 `tool_calls`,用户可以在 Agent Trace 中查看工具名称、参数摘要、耗时、执行结果和权限状态。
|
||||
Agent 运行过程中产生的每一步写入 `agent_runs` 和 `agent_events`,用户可以在 Agent Trace 中查看工具名称、参数摘要、耗时、执行结果和权限状态。SSE 与 Benchmark 均从同一事件事实读取,不维护旁路数据。
|
||||
|
||||
### 10.3 Tool Registry
|
||||
|
||||
@@ -771,6 +820,25 @@ audio.transcribe
|
||||
|
||||
Tool Executor 对参数再次进行 Pydantic 校验。文件修改类 Tool 调用 Knowledge Core,不允许 Tool 自行读取或修改 SQLite 表。
|
||||
|
||||
### 10.4 Agent Trace Contract
|
||||
|
||||
第二阶段的 Trace 不从前端临时状态反推,而由 Agent Runtime 产生可回放事件。一个 Trace 至少覆盖:
|
||||
|
||||
```text
|
||||
Run Started / Completed / Failed / Cancelled
|
||||
Model Call Started / Completed
|
||||
Thinking / Text Delta
|
||||
Tool Call / Tool Result
|
||||
Permission Required / Resolved
|
||||
Citation
|
||||
Usage
|
||||
Error
|
||||
```
|
||||
|
||||
每个事件携带 `run_id`、单调递增的 `sequence`、时间戳和结构化 `data`。Tool 事件额外记录调用 ID、参数摘要、耗时、权限结果、输出摘要和错误;Provider 事件记录 Provider、模型、Usage 和耗时,但不得记录 API Key、完整敏感正文或未经净化的第三方响应。
|
||||
|
||||
前端先按 `run_id + sequence` 回放和去重,再构建 Trace Tree。Benchmark 复用同一事件流统计工具选择、步骤、延迟和 Token Usage,不另建一套只供测试使用的 Agent 执行协议。持久化层接入后,SSE 使用 `Last-Event-ID` 或等价游标恢复中断连接。
|
||||
|
||||
---
|
||||
|
||||
## 11. Skill Runtime
|
||||
@@ -916,7 +984,7 @@ Manifest 进入安装流程前使用 Pydantic Schema 校验。Plugin ID、版本
|
||||
|
||||
### 12.3 Plugin Contribution
|
||||
|
||||
第一阶段允许 Plugin 声明以下 Contribution:
|
||||
Plugin Manifest 可以声明以下 Contribution:
|
||||
|
||||
```text
|
||||
Tool
|
||||
@@ -929,6 +997,10 @@ Settings Section
|
||||
|
||||
其中 Tool 面向 Agent;Command 面向命令面板和快捷操作;Importer / Exporter 用于文件格式扩展;Sidebar Panel 和 Settings Section 为前端提供受控扩展位置。
|
||||
|
||||
第一阶段只落地声明式 Tool Contribution。第二阶段新增 Command 和 Settings Contribution:Command 由后端注册为稳定 ID、标题、参数和执行目标,前端只消费 Contribution Contract;Settings Schema 首批只允许 `string`、`number`、`boolean`、`select` 和 `secret reference`,由宿主动态生成表单。
|
||||
|
||||
Secret Setting 只保存 Credential ID,明文通过 Secret API 写入凭据存储,不进入 Manifest、Plugin Storage、Pinia 或 Agent Trace。Importer、Exporter 和 Sidebar Panel 保留现有 Manifest 扩展位,未完成宿主实现前不得标记为可用。
|
||||
|
||||
Contribution 由宿主应用决定挂载位置。Plugin 不直接修改应用路由、Pinia Store 或核心数据库 Schema。
|
||||
|
||||
### 12.4 Plugin Host
|
||||
@@ -990,6 +1062,20 @@ Tool Registry 仍使用项目自己的 `ToolDefinition` 和 `ToolResult`。MCP B
|
||||
|
||||
MCP 能力首先用于 Tool 和 Resource 类扩展。需要复杂 UI 的插件通过 Frontend Extension Slot 单独处理。
|
||||
|
||||
第二阶段 MCP Bridge 至少覆盖以下协议边界:
|
||||
|
||||
```text
|
||||
Server Process / Connection Lifecycle
|
||||
initialize 与 capability negotiation
|
||||
tools/list 与 ToolDefinition 映射
|
||||
tools/call 与 ToolResult 映射
|
||||
超时、取消和进程退出
|
||||
协议错误与业务错误转换
|
||||
健康检查与 Tool 注销
|
||||
```
|
||||
|
||||
首个宿主实现优先支持本地 `stdio` 传输;其他传输在兼容性测试后增加。外部 Server 的 Tool 名称进入项目注册表前添加 Plugin 命名空间,并校验 JSON Schema、权限和重复 ID。MCP 内容不得绕过项目自己的 Permission、超时、日志净化和结果大小限制。
|
||||
|
||||
### 12.6 Frontend Extension Slot
|
||||
|
||||
前端预留受控扩展点:
|
||||
@@ -1066,6 +1152,8 @@ Plugin Storage API 负责访问该目录。插件不能通过自身目录拼接
|
||||
|
||||
需要保存密钥的 Plugin 通过 Secret API 请求独立 Credential ID,由 Stronghold 保存实际值。
|
||||
|
||||
第二阶段 Plugin 安装记录、启停状态、授权、配置 Schema 版本和 Contribution 元数据需要持久化。应用启动时先恢复元数据,再启动已启用的 Host;恢复失败的 Plugin 保持隔离并标记为 `error`,不能留下已注册但没有可用执行后端的 Tool 或 Command。
|
||||
|
||||
---
|
||||
|
||||
## 13. Provider Adapter 与模型接入
|
||||
@@ -1146,6 +1234,8 @@ Done
|
||||
|
||||
前端 Streaming UI 只认识这些事件类型。
|
||||
|
||||
第二阶段 Provider 兼容性不以 Adapter 数量为目标,而以同一组行为测试为准:普通对话、Streaming、Tool Calling、Reasoning Event、取消、Usage 和错误映射。优先验证 OpenAI Responses、OpenAI Chat Completions、OpenAI-Compatible、Anthropic Messages 与 Ollama;不支持的 Capability 必须在请求前拒绝,不能静默丢弃 Tool 或附件。
|
||||
|
||||
### 13.4 Provider 配置与 API Key
|
||||
|
||||
目标桌面架构将普通 Provider 配置保存在 SQLite。当前 Web 联调版由内存 `ProviderRegistry` 持有,AI Core 重启后清空:
|
||||
@@ -1204,6 +1294,23 @@ text
|
||||
|
||||
内容结构化模块将 Transcript 整理为 Markdown,同时保留音频时间信息,后续 RAG 引用可以跳回音频片段。
|
||||
|
||||
音频管线使用后台 Job,不在 HTTP 请求中长时间同步阻塞。转写结果统一为:
|
||||
|
||||
```text
|
||||
job_id
|
||||
attachment_id
|
||||
language
|
||||
segments[]
|
||||
speaker
|
||||
start_time
|
||||
end_time
|
||||
text
|
||||
status
|
||||
error
|
||||
```
|
||||
|
||||
`pyannote.audio` 和 `faster-whisper` 通过独立 Adapter 加载,模型下载、设备选择、精度、批量大小和缓存目录由配置管理。缺少说话人模型时可以只返回时间戳转写,但必须明确标记 diarization 不可用;模型失败不能生成伪造的 completed 结果。
|
||||
|
||||
### 14.2 OCR
|
||||
|
||||
OCR 作为 Media Pipeline 的输入适配能力,用于图片笔记、白板照片、PPT 截图和扫描资料。OCR 输出进入附件文本索引,也可以由用户选择生成 Markdown。
|
||||
@@ -1214,6 +1321,39 @@ OCR 引擎在当前技术栈中尚未固定,调用接口先定义为 `OCRProvi
|
||||
|
||||
emotion2vec 作为音频扩展分析模块。输出可以附加到音频段元数据,不参与核心 RAG 索引和 Agent 启动流程。
|
||||
|
||||
### 14.4 Document AST 与多格式导出
|
||||
|
||||
第二阶段建立统一导出链路:
|
||||
|
||||
```text
|
||||
Markdown
|
||||
→ Markdown AST
|
||||
→ Document AST
|
||||
→ DocumentExporter
|
||||
├── HtmlExporter
|
||||
├── PdfExporter
|
||||
└── DocxExporter
|
||||
```
|
||||
|
||||
`Document AST` 是导出器共享的中间表示,覆盖标题、段落、列表、表格、图片、引用、代码块、数学公式、Mermaid 和函数图像。Exporter 不直接解析编辑器 DOM,避免不同界面状态产生不同输出。
|
||||
|
||||
统一接口返回文件、MIME、警告和失败节点:
|
||||
|
||||
```python
|
||||
class DocumentExporter(Protocol):
|
||||
async def export(
|
||||
self,
|
||||
document: Document,
|
||||
options: ExportOptions,
|
||||
) -> ExportResult: ...
|
||||
```
|
||||
|
||||
HTML 导出保留结构化语义和受控样式;PDF 与 DOCX 在不支持交互内容时使用静态 SVG 或图片。具体底层库在 PoC 后冻结,但必须封装在 Exporter Adapter 内,不允许导出库的数据结构渗透到 Knowledge Core。
|
||||
|
||||
### 14.5 可视化内容的统一静态输出
|
||||
|
||||
Mermaid Renderer 和 Function Plot Renderer 除前端预览外,都必须提供可重复的静态输出接口。导出器只消费 SVG、PNG 或带尺寸信息的资源引用,不调用 Vue 组件。渲染结果按源码 Hash、主题和渲染器版本缓存;源码、主题或版本变化时缓存失效。
|
||||
|
||||
---
|
||||
|
||||
## 15. 安全与权限
|
||||
@@ -1719,6 +1859,8 @@ POST /api/index/rebuild
|
||||
GET /health
|
||||
```
|
||||
|
||||
第一阶段已实现路径和第二阶段冻结草案分别见 `../contracts/后端接口契约-开发版.md` 与 `../contracts/第二阶段接口契约-开发版.md`。规划接口完成前不得在前端假定其可用;完成后以 OpenAPI、Pydantic Contract 和 TypeScript Wire DTO 的一致结果为准。
|
||||
|
||||
HTTP 返回统一错误结构:
|
||||
|
||||
```json
|
||||
@@ -1793,6 +1935,8 @@ ainote/
|
||||
│ │ ├── skills/
|
||||
│ │ ├── providers/
|
||||
│ │ ├── media/
|
||||
│ │ ├── extensions/
|
||||
│ │ ├── export/
|
||||
│ │ └── database/
|
||||
│ └── tests/
|
||||
│
|
||||
@@ -1813,7 +1957,11 @@ ainote/
|
||||
│
|
||||
├── benchmarks/
|
||||
│ ├── rag/
|
||||
│ │ ├── datasets/
|
||||
│ │ └── reports/
|
||||
│ └── agent/
|
||||
│ ├── datasets/
|
||||
│ └── reports/
|
||||
│
|
||||
├── infra/
|
||||
│ └── cloud/
|
||||
@@ -1832,6 +1980,8 @@ ainote/
|
||||
- VectorStore 具体实现只出现在 Retrieval 基础设施层。
|
||||
- 内置 Skill 放在 `skills/builtin`,不硬编码在 Agent Runtime。
|
||||
- Plugin Runtime、Plugin Host 和 MCP Bridge 位于 Extension Core,对第三方插件暴露的稳定接口放在 `packages/plugin-sdk`。
|
||||
- Mermaid 与函数图像的 Markdown 源码解析归编辑/文档模型,交互渲染归前端 Renderer,静态渲染契约由 Export Service 复用。
|
||||
- Document AST 和 Exporter Adapter 位于 Export Service,导出器不得读取 Vue 组件 DOM 或 Provider 内部状态。
|
||||
- 内置 Plugin 放在 `plugins/builtin`,通过与第三方 Plugin 相同的 Contribution 接口注册。
|
||||
- Benchmark 数据和运行脚本放在 `benchmarks`。
|
||||
|
||||
@@ -1902,13 +2052,15 @@ Python 使用 pytest。重点覆盖:
|
||||
- Plugin Manifest;
|
||||
- Plugin Lifecycle;
|
||||
- Plugin Tool Registration;
|
||||
- MCP Bridge;
|
||||
- MCP 生命周期、Tool 映射、取消与异常退出;
|
||||
- Provider Adapter;
|
||||
- 权限判断;
|
||||
- API 错误转换。
|
||||
|
||||
前端测试重点覆盖 Store、Service 和关键交互流程。Rust 侧覆盖路径处理、Sidecar 启停和 Secret 访问封装。
|
||||
|
||||
第二阶段还需要增加 Theme Manifest/CSS 安全校验、Mermaid 错误与主题切换、Function Plot 表达式拒绝策略、Document AST 快照和 HTML/PDF/DOCX 导出回归测试。多模态测试使用小型固定音频 Fixture,模型级测试与不下载模型的快速单元测试分组执行。
|
||||
|
||||
### 20.2 RAG Benchmark
|
||||
|
||||
RAG Dataset 结构:
|
||||
@@ -1933,12 +2085,13 @@ Hybrid + Reranker
|
||||
核心指标:
|
||||
|
||||
```text
|
||||
Hit@1
|
||||
Hit@5
|
||||
Recall@K
|
||||
Hit@K
|
||||
MRR
|
||||
Citation Precision
|
||||
Citation Recall
|
||||
Latency
|
||||
Citation Hit Rate
|
||||
P50 Latency
|
||||
P95 Latency
|
||||
```
|
||||
|
||||
Benchmark 参数、Embedding 模型、Reranker、数据集版本和运行环境需要一起记录,保证不同实验结果可以复现。
|
||||
@@ -1953,12 +2106,13 @@ Agent Dataset 保存任务目标、允许工具、期望工具序列和结果条
|
||||
Task Success Rate
|
||||
Tool Selection Accuracy
|
||||
Tool Argument Accuracy
|
||||
Invalid Tool Call Rate
|
||||
Average Steps
|
||||
Average Latency
|
||||
Token Usage
|
||||
```
|
||||
|
||||
Agent Benchmark 同时记录模型 Provider 和模型版本。
|
||||
Agent Benchmark 同时记录模型 Provider、模型版本、Skill、可用工具集合、运行配置和 Dataset 版本。指标直接从 Agent Trace Contract 计算,测试框架不得绕过权限或使用另一套 Tool 执行器。
|
||||
|
||||
---
|
||||
|
||||
@@ -2077,6 +2231,47 @@ Audio
|
||||
→ RAG Index
|
||||
```
|
||||
|
||||
### 21.8 MCP Tool 接入
|
||||
|
||||
```text
|
||||
Enable Plugin
|
||||
→ Start MCP Server
|
||||
→ MCP initialize / tools/list
|
||||
→ Validate Schema and Permission
|
||||
→ Namespace Tool ID
|
||||
→ Register Tool Registry
|
||||
→ Agent Tool Call
|
||||
→ MCP tools/call
|
||||
→ Normalize ToolResult / Error
|
||||
→ Agent Trace
|
||||
```
|
||||
|
||||
### 21.9 Mermaid 与函数图像
|
||||
|
||||
```text
|
||||
Markdown Fenced Block
|
||||
→ Parse Source
|
||||
→ Mermaid / FunctionPlot Model
|
||||
→ Renderer Adapter
|
||||
├── Interactive Preview
|
||||
└── Sanitized SVG / Image
|
||||
→ Export Service
|
||||
```
|
||||
|
||||
### 21.10 多格式文档导出
|
||||
|
||||
```text
|
||||
Markdown
|
||||
→ Markdown AST
|
||||
→ Document AST
|
||||
→ Resolve Assets / Formula / Diagram / Plot
|
||||
→ Select Exporter
|
||||
├── HTML
|
||||
├── PDF
|
||||
└── DOCX
|
||||
→ ExportResult + Warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 22. 异常处理与恢复
|
||||
@@ -2106,7 +2301,7 @@ SQLite 索引损坏或版本不一致时,应用允许重建 `.ainote/app.db`
|
||||
|
||||
## 23. 当前版本实施边界
|
||||
|
||||
第一阶段开发围绕能够形成完整桌面知识工作流的能力展开:
|
||||
第一阶段围绕可联调的本地知识工作流建立基础接口:
|
||||
|
||||
```text
|
||||
Markdown Workspace
|
||||
@@ -2124,23 +2319,42 @@ Markdown Workspace
|
||||
→ Plugin Manifest / Tool Contribution
|
||||
```
|
||||
|
||||
第一阶段的 Plugin Runtime 需要完成安装、启用、停用、权限、Tool 注册和至少一个示例 Plugin,建立 Skill 调用 Plugin Tool 的完整链路。
|
||||
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。
|
||||
|
||||
截至 2026-08-30,上述第一阶段后端链路和 Web 联调前端均已完成。当前验证基线为后端 71 项测试、前端 14 项测试及生产构建通过。
|
||||
截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成,第二阶段前置的 Workspace 去 Mock 联调及 Agent Trace 持久化/恢复接口也已完成。当前验证基线为后端 81 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
|
||||
|
||||
第二阶段接入:
|
||||
第二阶段在既有 Contract 上接入:
|
||||
|
||||
```text
|
||||
faster-whisper
|
||||
pyannote.audio
|
||||
主题导入与社区格式
|
||||
MCP Bridge
|
||||
Plugin Command / Settings Contribution
|
||||
更多 Provider Adapter
|
||||
高级 Agent Trace 可视化
|
||||
RAG / Agent Benchmark
|
||||
Multimodal
|
||||
├── faster-whisper
|
||||
└── pyannote.audio
|
||||
|
||||
Extension / Model
|
||||
├── MCP Bridge
|
||||
├── Plugin Command Contribution
|
||||
├── Plugin Settings Contribution
|
||||
└── Provider Streaming / Tool Calling / Error Mapping 增强
|
||||
|
||||
Quality
|
||||
├── RAG Benchmark
|
||||
├── Agent Benchmark
|
||||
└── Retrieval 参数调优
|
||||
|
||||
Content Output
|
||||
├── Markdown → HTML / PDF / DOCX
|
||||
├── Mermaid 编辑、预览与静态导出
|
||||
└── Function Plot 解析、预览与静态导出
|
||||
|
||||
Frontend Extension
|
||||
├── Theme Package 导入与社区包格式
|
||||
├── Agent Trace 可视化
|
||||
├── Plugin Command UI
|
||||
└── Plugin Settings UI
|
||||
```
|
||||
|
||||
上述列表描述第二阶段技术范围,不表示能力已经实现。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
|
||||
|
||||
第三阶段处理:
|
||||
|
||||
```text
|
||||
@@ -2154,7 +2368,7 @@ Plugin 安装清单同步
|
||||
Skill 分发
|
||||
Plugin 分发与社区仓库
|
||||
Plugin Sidebar Panel 等前端扩展点
|
||||
Theme 社区
|
||||
联网 Theme Marketplace
|
||||
OCR 深度集成
|
||||
更多多模态能力
|
||||
扩展协议
|
||||
@@ -2162,7 +2376,7 @@ OCR 深度集成
|
||||
|
||||
Sync Server 按独立服务开发和部署,不进入桌面客户端核心启动依赖。第一版同步完成文件级 Revision、多设备增量同步和冲突保留后,再评估端到端加密与实时协同编辑。
|
||||
|
||||
阶段划分用于安排开发顺序。模块接口在第一阶段完成时确定基础版本,后续功能通过现有接口扩展。
|
||||
阶段划分用于限定交付范围。人员分工、任务顺序和协作安排以阶段分工表为准,不在技术栈说明中重复维护;本文只维护技术选型、模块边界和跨模块 Contract。
|
||||
|
||||
---
|
||||
|
||||
@@ -2191,10 +2405,12 @@ Sync Server 按独立服务开发和部署,不进入桌面客户端核心启
|
||||
|
||||
## 25. 当前技术基线摘要
|
||||
|
||||
目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引和任务;Agent Trace 与 Provider/Extension Registry 当前仍为内存实现。
|
||||
目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引、任务及 Agent Trace;Provider/Extension Registry 当前仍为内存实现。
|
||||
|
||||
Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 通过 FTS5、轻量 Embedding、sqlite-vec、RRF 和 Reranker 提供混合检索;Agent Runtime 使用 Tool Registry 操作知识库和任务;Skill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置;当前 Plugin Runtime 支持 Manifest、生命周期和声明式白名单 Tool Contribution,独立 Plugin Host 与 MCP Bridge 留待后续。Provider Adapter 当前实现 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama,OpenAI Responses 和 Anthropic Messages 留待后续。
|
||||
Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 当前通过 FTS5、`HashEmbeddingProvider`、sqlite-vec、RRF 和轻量 Reranker 跑通混合检索,真实 Embedding 与正式 Benchmark 在第二阶段接入;Agent Runtime 使用 Tool Registry 操作知识库和任务,并将扩展 Agent Trace Contract 供可视化和 Benchmark 共用;Skill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置。
|
||||
|
||||
多模态目标方案使用 faster-whisper、pyannote.audio 和可选 emotion2vec;当前只读取 Host 预生成 transcript。API Key 在 Web 联调期由 Fernet 开发存储加密保存,桌面版迁移到 Tauri Stronghold。多设备同步的目标方案为独立、可自托管的 Sync Server,目前尚未实现;本地核心功能不依赖 Sync Server。
|
||||
当前 Plugin Runtime 支持 Manifest、生命周期和声明式白名单 Tool Contribution;第二阶段通过 MCP Bridge 接入隔离 Tool,并增加 Command 与 Settings Contribution。Provider Adapter 当前实现 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama,第二阶段按统一行为测试完善 OpenAI Responses、Anthropic Messages 等协议。多模态目标方案使用 faster-whisper、pyannote.audio 和可选 emotion2vec;当前只读取 Host 预生成 transcript。
|
||||
|
||||
第二阶段内容输出以 Document AST、Exporter Adapter、Mermaid Renderer 和 Function Plot Renderer 为共同边界,支持 HTML、PDF、DOCX 与静态图导出。Theme Package 使用 Manifest、Design Token 和受限 CSS 实现本地导入;联网主题市场不属于本阶段核心依赖。API Key 在 Web 联调期由 Fernet 开发存储加密保存,桌面版迁移到 Tauri Stronghold。多设备同步的目标方案为独立、可自托管的 Sync Server,目前尚未实现;本地核心功能不依赖 Sync Server。
|
||||
|
||||
该技术基线用于指导当前比赛版本的代码组织、接口设计、模块协作、测试和交付。
|
||||
@@ -0,0 +1,821 @@
|
||||
# 第二阶段团队分工表
|
||||
|
||||
## 一、阶段目标
|
||||
|
||||
第二阶段延续第一阶段已经形成的模块边界,重点推进多模态输入、MCP 与 Plugin 扩展、更多 Provider、RAG / Agent Benchmark、多格式导出、主题社区格式、Agent Trace 可视化、Mermaid 渲染和函数图像绘制。
|
||||
|
||||
第二阶段继续保持第一阶段的模块 ownership:
|
||||
|
||||
- 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、整体架构与代码审阅。
|
||||
- 杨星萱:Knowledge Core、Retrieval Core、Benchmark、文档导出、函数图像绘制。
|
||||
- 吉海燕:Frontend、Theme、Agent Trace、Plugin UI Contribution、Mermaid 渲染。
|
||||
|
||||
---
|
||||
|
||||
## 二、总分工表
|
||||
|
||||
| 成员 | 主要负责方向 | 第二阶段任务 | 配合事项 |
|
||||
| --- | --- | --- | --- |
|
||||
| 范涵宇 | Agent Core / Extension Core / Model Core / Multimodal / 总体架构 | faster-whisper、pyannote.audio、MCP Bridge、Plugin Command Contribution 后端、Plugin Settings Contribution 后端、更多 Provider、整体集成、代码审阅与统筹 | 与吉海燕联调 Plugin Command / Settings 前端;为杨星萱的 Agent Benchmark 提供 Agent Trace、Tool Call 等测试接口 |
|
||||
| 杨星萱 | Knowledge Core / Retrieval Core / Benchmark / Export / 数学内容渲染 | RAG Benchmark、Agent Benchmark 基础设施、Markdown → HTML / PDF / DOCX、函数图像绘制与渲染支持、Retrieval 调优 | 与范涵宇确认 Agent Benchmark 事件和测试数据结构;与吉海燕联调函数图像在编辑器和预览区中的展示 |
|
||||
| 吉海燕 | Frontend / Theme / Visualization | Theme Import、Theme Manifest、社区主题格式、Agent Trace 可视化、Plugin Command / Settings 前端、Mermaid 渲染支持 | 与范涵宇联调 Plugin Contribution Contract 与 AgentEvent;与杨星萱联调函数图像及导出预览 |
|
||||
|
||||
---
|
||||
|
||||
## 三、范涵宇
|
||||
|
||||
### 3.1 faster-whisper
|
||||
|
||||
负责接入 faster-whisper,完成真实音频转写。
|
||||
|
||||
目标链路:
|
||||
|
||||
```text
|
||||
Audio
|
||||
↓
|
||||
faster-whisper
|
||||
↓
|
||||
Timestamped Transcript
|
||||
↓
|
||||
Knowledge Core
|
||||
```
|
||||
|
||||
输出至少包含:
|
||||
|
||||
```text
|
||||
text
|
||||
start_time
|
||||
end_time
|
||||
language
|
||||
```
|
||||
|
||||
### 3.2 pyannote.audio
|
||||
|
||||
负责说话人分离,并与 faster-whisper 组合。
|
||||
|
||||
```text
|
||||
Audio
|
||||
↓
|
||||
pyannote.audio
|
||||
↓
|
||||
Speaker Segments
|
||||
↓
|
||||
faster-whisper
|
||||
↓
|
||||
Timestamped + Speaker Transcript
|
||||
```
|
||||
|
||||
输出至少包含:
|
||||
|
||||
```text
|
||||
speaker
|
||||
start_time
|
||||
end_time
|
||||
text
|
||||
```
|
||||
|
||||
### 3.3 MCP Bridge
|
||||
|
||||
负责实现 MCP Bridge,使外部 MCP Server 可以进入现有 Plugin / Tool 体系。
|
||||
|
||||
```text
|
||||
External MCP Server
|
||||
↓
|
||||
MCP Bridge
|
||||
↓
|
||||
Plugin Runtime
|
||||
↓
|
||||
Tool Registry
|
||||
↓
|
||||
Agent Runtime
|
||||
```
|
||||
|
||||
Agent Runtime 继续使用项目内部的:
|
||||
|
||||
```text
|
||||
ToolDefinition
|
||||
ToolCall
|
||||
ToolResult
|
||||
```
|
||||
|
||||
MCP Bridge 负责协议转换。
|
||||
|
||||
### 3.4 Plugin Command Contribution
|
||||
|
||||
负责 Plugin Runtime 中 Command Contribution 的解析、注册和执行接口。
|
||||
|
||||
```text
|
||||
Plugin Manifest
|
||||
↓
|
||||
Plugin Runtime
|
||||
↓
|
||||
Command Registry
|
||||
↓
|
||||
Frontend Contract
|
||||
```
|
||||
|
||||
吉海燕负责 Command Palette 等前端展示。
|
||||
|
||||
### 3.5 Plugin Settings Contribution
|
||||
|
||||
负责 Plugin Manifest 中 Settings Schema 的解析和后端配置接口。
|
||||
|
||||
首批支持:
|
||||
|
||||
```text
|
||||
string
|
||||
number
|
||||
boolean
|
||||
select
|
||||
secret reference
|
||||
```
|
||||
|
||||
范涵宇负责 Manifest Parsing、Settings Schema、Plugin Storage、Secret Reference 和 Runtime Contract。
|
||||
|
||||
### 3.6 更多 Provider
|
||||
|
||||
继续完善 Provider Adapter。
|
||||
|
||||
第二阶段优先验证:
|
||||
|
||||
```text
|
||||
Normal Chat
|
||||
Streaming
|
||||
Tool Calling
|
||||
Reasoning Event
|
||||
Cancellation
|
||||
Usage
|
||||
Error Mapping
|
||||
```
|
||||
|
||||
主要协议目标:
|
||||
|
||||
```text
|
||||
OpenAI Responses
|
||||
OpenAI Chat Completions
|
||||
OpenAI-Compatible
|
||||
Anthropic Messages
|
||||
Ollama
|
||||
```
|
||||
|
||||
Provider 数量不作为主要验收指标,优先保证协议适配稳定。
|
||||
|
||||
### 3.7 审阅与统筹
|
||||
|
||||
持续负责:
|
||||
|
||||
- 公共 Contract 审阅;
|
||||
- Agent / Skill / Plugin / Provider 接口审阅;
|
||||
- 跨模块 PR Review;
|
||||
- 第二阶段整体架构一致性检查;
|
||||
- Demo 链路集成;
|
||||
- 公共错误类型和事件格式统一;
|
||||
- 第二阶段版本合并与发布前检查。
|
||||
|
||||
---
|
||||
|
||||
## 四、杨星萱
|
||||
|
||||
### 4.1 RAG Benchmark
|
||||
|
||||
建立正式 RAG Benchmark。
|
||||
|
||||
比较:
|
||||
|
||||
```text
|
||||
FTS5
|
||||
Vector Search
|
||||
Hybrid Retrieval
|
||||
Hybrid + RRF
|
||||
Hybrid + RRF + Reranker
|
||||
```
|
||||
|
||||
核心指标:
|
||||
|
||||
```text
|
||||
Hit@1
|
||||
Hit@5
|
||||
Recall@K
|
||||
MRR
|
||||
Citation Hit Rate
|
||||
P50 Latency
|
||||
P95 Latency
|
||||
```
|
||||
|
||||
Dataset 至少记录:
|
||||
|
||||
```text
|
||||
query
|
||||
expected_note_id
|
||||
expected_block_id
|
||||
expected_citation
|
||||
tags
|
||||
```
|
||||
|
||||
### 4.2 Agent Benchmark
|
||||
|
||||
负责 Agent Benchmark 测试框架、Dataset、指标统计和报告生成。
|
||||
|
||||
范涵宇提供:
|
||||
|
||||
```text
|
||||
Agent Run
|
||||
Agent Event
|
||||
Tool Call
|
||||
Tool Result
|
||||
Trace
|
||||
```
|
||||
|
||||
主要指标:
|
||||
|
||||
```text
|
||||
Task Success Rate
|
||||
Tool Selection Accuracy
|
||||
Tool Argument Accuracy
|
||||
Invalid Tool Call Rate
|
||||
Average Steps
|
||||
Average Latency
|
||||
Token Usage
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```yaml
|
||||
id: agent-os-review-001
|
||||
|
||||
prompt: >
|
||||
找出操作系统笔记中关于死锁的内容,
|
||||
生成总结并创建三个复习任务。
|
||||
|
||||
expected_tools:
|
||||
- rag.search
|
||||
- notes.read
|
||||
- tasks.create
|
||||
|
||||
expected_conditions:
|
||||
citation_required: true
|
||||
tasks_created: 3
|
||||
```
|
||||
|
||||
### 4.3 多格式文档导出
|
||||
|
||||
负责建立统一 Export Service:
|
||||
|
||||
```text
|
||||
Document AST
|
||||
↓
|
||||
DocumentExporter
|
||||
├── HtmlExporter
|
||||
├── PdfExporter
|
||||
└── DocxExporter
|
||||
```
|
||||
|
||||
统一接口示意:
|
||||
|
||||
```python
|
||||
class DocumentExporter(Protocol):
|
||||
async def export(
|
||||
self,
|
||||
document: Document,
|
||||
options: ExportOptions,
|
||||
) -> ExportResult:
|
||||
...
|
||||
```
|
||||
|
||||
第二阶段完成:
|
||||
|
||||
```text
|
||||
Markdown → HTML
|
||||
Markdown → PDF
|
||||
Markdown → DOCX
|
||||
```
|
||||
|
||||
导出时尽量保持:
|
||||
|
||||
- 标题;
|
||||
- 段落;
|
||||
- 列表;
|
||||
- 表格;
|
||||
- 图片;
|
||||
- 引用;
|
||||
- 代码块;
|
||||
- 数学公式;
|
||||
- Mermaid;
|
||||
- 函数图像。
|
||||
|
||||
Exporter 接口保留未来由 Plugin 增加 EPUB、LaTeX 等格式的扩展空间。
|
||||
|
||||
### 4.4 函数图像绘制与渲染支持
|
||||
|
||||
负责函数图像相关的数据解析、表达、绘制和渲染支持。
|
||||
|
||||
目标示例:
|
||||
|
||||
```text
|
||||
y = x^2
|
||||
y = sin(x)
|
||||
y = 2x + 1
|
||||
```
|
||||
|
||||
内部建议抽象:
|
||||
|
||||
```text
|
||||
FunctionPlot
|
||||
├── expressions
|
||||
├── domain
|
||||
├── range
|
||||
├── axis config
|
||||
└── render config
|
||||
```
|
||||
|
||||
处理链:
|
||||
|
||||
```text
|
||||
Markdown / Structured Block
|
||||
↓
|
||||
Function Expression Parser
|
||||
↓
|
||||
Function Plot Model
|
||||
↓
|
||||
Renderer
|
||||
↓
|
||||
Editor / Preview
|
||||
```
|
||||
|
||||
需要考虑:
|
||||
|
||||
- 二维函数;
|
||||
- 多函数同图;
|
||||
- 定义域;
|
||||
- 坐标轴;
|
||||
- 缩放;
|
||||
- 图像刷新;
|
||||
- Markdown 中的持久化格式;
|
||||
- HTML / PDF / DOCX 导出时的静态渲染。
|
||||
|
||||
可设计独立 fenced block,例如:
|
||||
|
||||
````markdown
|
||||
```function-plot
|
||||
y = x^2
|
||||
y = sin(x)
|
||||
```
|
||||
````
|
||||
|
||||
具体语法在实现阶段冻结。
|
||||
|
||||
函数图像的数据结构、解析和渲染逻辑由杨星萱负责;吉海燕配合完成前端容器、布局和交互。
|
||||
|
||||
### 4.5 Retrieval 调优
|
||||
|
||||
根据 RAG Benchmark 数据继续调整:
|
||||
|
||||
```text
|
||||
Block Chunking
|
||||
FTS Query
|
||||
Vector Top-K
|
||||
RRF Parameters
|
||||
Reranker Candidate Count
|
||||
Score Threshold
|
||||
Metadata Filter
|
||||
```
|
||||
|
||||
所有调优保留 Benchmark 前后对比数据。
|
||||
|
||||
---
|
||||
|
||||
## 五、吉海燕
|
||||
|
||||
### 5.1 Theme Import
|
||||
|
||||
负责 Theme Package 导入。
|
||||
|
||||
建议结构:
|
||||
|
||||
```text
|
||||
my-theme/
|
||||
├── theme.yaml
|
||||
├── theme.css
|
||||
├── preview.png
|
||||
└── README.md
|
||||
```
|
||||
|
||||
流程:
|
||||
|
||||
```text
|
||||
Import
|
||||
↓
|
||||
Manifest Validation
|
||||
↓
|
||||
Preview
|
||||
↓
|
||||
Install
|
||||
↓
|
||||
Enable
|
||||
↓
|
||||
Disable
|
||||
↓
|
||||
Uninstall
|
||||
```
|
||||
|
||||
### 5.2 Theme Community Format
|
||||
|
||||
负责制定和实现社区主题格式。
|
||||
|
||||
示例:
|
||||
|
||||
```yaml
|
||||
id: example-theme
|
||||
name: Example Theme
|
||||
version: 1.0.0
|
||||
author: example
|
||||
min_app_version: 0.2.0
|
||||
```
|
||||
|
||||
主题继续基于:
|
||||
|
||||
```text
|
||||
Design Token
|
||||
CSS Variables
|
||||
Theme Manifest
|
||||
```
|
||||
|
||||
第二阶段优先完成本地导入和社区包格式,不要求立即实现联网 Theme Marketplace。
|
||||
|
||||
### 5.3 Agent Trace 可视化
|
||||
|
||||
负责将 Agent Runtime 执行过程可视化。
|
||||
|
||||
建议结构:
|
||||
|
||||
```text
|
||||
User Request
|
||||
│
|
||||
├── Model Call
|
||||
│
|
||||
├── rag.search
|
||||
│ ├── arguments
|
||||
│ ├── duration
|
||||
│ └── result
|
||||
│
|
||||
├── notes.read
|
||||
│
|
||||
├── Model Call
|
||||
│
|
||||
└── Completed
|
||||
```
|
||||
|
||||
Trace Node 可展开查看:
|
||||
|
||||
```text
|
||||
Arguments
|
||||
Result
|
||||
Duration
|
||||
Permission
|
||||
Usage
|
||||
Error
|
||||
```
|
||||
|
||||
需要支持:
|
||||
|
||||
- Agent Run 状态;
|
||||
- Tool Call 顺序;
|
||||
- Tool 调用耗时;
|
||||
- Tool Result 摘要;
|
||||
- Provider 调用;
|
||||
- Token Usage;
|
||||
- 错误状态;
|
||||
- 用户取消;
|
||||
- Citation 跳转。
|
||||
|
||||
### 5.4 Plugin Command 前端
|
||||
|
||||
负责 Command Contribution 在前端呈现。
|
||||
|
||||
主要挂载:
|
||||
|
||||
```text
|
||||
Command Palette
|
||||
Context Menu
|
||||
Toolbar Action
|
||||
```
|
||||
|
||||
前端使用 Plugin Contribution Contract,不直接解析插件后端 Manifest。
|
||||
|
||||
### 5.5 Plugin Settings 前端
|
||||
|
||||
根据范涵宇提供的 Plugin Settings Schema 动态生成设置表单。
|
||||
|
||||
支持:
|
||||
|
||||
```text
|
||||
Input
|
||||
Number
|
||||
Switch
|
||||
Select
|
||||
Secret Input
|
||||
```
|
||||
|
||||
Secret 类型通过 Secret API 提交,不写入 Pinia 持久化或普通配置文件。
|
||||
|
||||
### 5.6 Mermaid 渲染支持
|
||||
|
||||
负责在 Markdown 编辑器、预览区域和相关展示界面加入 Mermaid 渲染。
|
||||
|
||||
目标 Markdown:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Markdown] --> B[Renderer]
|
||||
B --> C[Diagram]
|
||||
```
|
||||
````
|
||||
|
||||
处理链:
|
||||
|
||||
```text
|
||||
Markdown
|
||||
↓
|
||||
Mermaid Code Block
|
||||
↓
|
||||
Mermaid Renderer
|
||||
↓
|
||||
SVG
|
||||
↓
|
||||
Editor / Preview
|
||||
```
|
||||
|
||||
需要支持:
|
||||
|
||||
- Flowchart;
|
||||
- Sequence Diagram;
|
||||
- Class Diagram;
|
||||
- State Diagram;
|
||||
- ER Diagram;
|
||||
- Gantt 等常用 Mermaid 图;
|
||||
- Light / Dark Theme;
|
||||
- 编辑后重新渲染;
|
||||
- 渲染错误提示;
|
||||
- SVG 缩放与查看;
|
||||
- 导出时的静态图处理。
|
||||
|
||||
Mermaid 渲染需要与 Theme Design Token 联动。
|
||||
|
||||
与杨星萱负责的 Export Service 联调时,前端或渲染层需要提供 Mermaid → SVG / Image 的稳定输出,使 HTML、PDF 和 DOCX 导出能够保留图表。
|
||||
|
||||
---
|
||||
|
||||
## 六、跨成员协作表
|
||||
|
||||
| 协作事项 | 主负责人 | 配合人员 |
|
||||
| --- | --- | --- |
|
||||
| MCP Bridge → Agent Tool | 范涵宇 | 杨星萱 |
|
||||
| Plugin Command Runtime | 范涵宇 | 吉海燕 |
|
||||
| Plugin Command UI | 吉海燕 | 范涵宇 |
|
||||
| Plugin Settings Runtime | 范涵宇 | 吉海燕 |
|
||||
| Plugin Settings UI | 吉海燕 | 范涵宇 |
|
||||
| Agent Benchmark Framework | 杨星萱 | 范涵宇 |
|
||||
| Agent Trace Event Contract | 范涵宇 | 吉海燕、杨星萱 |
|
||||
| Agent Trace Visualization | 吉海燕 | 范涵宇 |
|
||||
| RAG Benchmark | 杨星萱 | 范涵宇 |
|
||||
| Markdown Export | 杨星萱 | 吉海燕 |
|
||||
| Mermaid Editor Rendering | 吉海燕 | 杨星萱 |
|
||||
| Mermaid Export Rendering | 杨星萱 | 吉海燕 |
|
||||
| Function Plot Core / Rendering | 杨星萱 | 吉海燕 |
|
||||
| Function Plot Frontend Integration | 吉海燕 | 杨星萱 |
|
||||
| Theme Community Format | 吉海燕 | 范涵宇 |
|
||||
| Provider Streaming UI | 范涵宇 | 吉海燕 |
|
||||
| Multimodal → Knowledge Core | 范涵宇 | 杨星萱 |
|
||||
| 第二阶段整体 Demo | 范涵宇 | 吉海燕、杨星萱 |
|
||||
| 核心代码 Review | 范涵宇 | 对应模块负责人 |
|
||||
|
||||
---
|
||||
|
||||
## 七、第二阶段优先级
|
||||
|
||||
### P0
|
||||
|
||||
#### 范涵宇
|
||||
|
||||
```text
|
||||
MCP Bridge
|
||||
faster-whisper
|
||||
pyannote.audio
|
||||
```
|
||||
|
||||
#### 杨星萱
|
||||
|
||||
```text
|
||||
RAG Benchmark
|
||||
Agent Benchmark Infrastructure
|
||||
Markdown → HTML
|
||||
函数图像基础渲染
|
||||
```
|
||||
|
||||
#### 吉海燕
|
||||
|
||||
```text
|
||||
Agent Trace Visualization
|
||||
Theme Manifest / Theme Import
|
||||
Mermaid Rendering
|
||||
```
|
||||
|
||||
### P1
|
||||
|
||||
#### 范涵宇
|
||||
|
||||
```text
|
||||
Plugin Command Contribution
|
||||
Plugin Settings Contribution
|
||||
Provider Adapter 完善
|
||||
```
|
||||
|
||||
#### 杨星萱
|
||||
|
||||
```text
|
||||
Markdown → PDF
|
||||
Markdown → DOCX
|
||||
Retrieval 调优
|
||||
函数图像导出适配
|
||||
```
|
||||
|
||||
#### 吉海燕
|
||||
|
||||
```text
|
||||
Theme Community Format
|
||||
Plugin Command UI
|
||||
Plugin Settings UI
|
||||
Mermaid Theme Adaptation
|
||||
```
|
||||
|
||||
### P2
|
||||
|
||||
#### 范涵宇
|
||||
|
||||
```text
|
||||
更多 Provider
|
||||
MCP 兼容性增强
|
||||
Multimodal Pipeline 优化
|
||||
```
|
||||
|
||||
#### 杨星萱
|
||||
|
||||
```text
|
||||
复杂格式导出
|
||||
Benchmark 报告自动生成
|
||||
函数图像高级配置
|
||||
```
|
||||
|
||||
#### 吉海燕
|
||||
|
||||
```text
|
||||
Theme 浏览与管理体验
|
||||
Agent Trace 高级筛选
|
||||
Mermaid 高级交互
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、第二阶段共同验收 Demo
|
||||
|
||||
建议使用“课堂学习全过程”作为第二阶段总 Demo:
|
||||
|
||||
```text
|
||||
导入课堂录音
|
||||
↓
|
||||
pyannote.audio
|
||||
↓
|
||||
说话人分离
|
||||
↓
|
||||
faster-whisper
|
||||
↓
|
||||
带时间戳 Transcript
|
||||
↓
|
||||
生成 Markdown
|
||||
↓
|
||||
Knowledge Core 建立索引
|
||||
↓
|
||||
用户询问课程内容
|
||||
↓
|
||||
Agent 调用 RAG
|
||||
↓
|
||||
返回 Citation
|
||||
↓
|
||||
Agent Trace 可视化
|
||||
```
|
||||
|
||||
随后展示:
|
||||
|
||||
```text
|
||||
选择 Skill
|
||||
↓
|
||||
调用 Agent
|
||||
↓
|
||||
通过 MCP Bridge 使用外部 Tool
|
||||
↓
|
||||
生成整理后的 Markdown
|
||||
```
|
||||
|
||||
Markdown 中展示:
|
||||
|
||||
```text
|
||||
普通文本
|
||||
数学公式
|
||||
Mermaid 图
|
||||
函数图像
|
||||
```
|
||||
|
||||
最后:
|
||||
|
||||
```text
|
||||
Markdown
|
||||
├── Export HTML
|
||||
├── Export PDF
|
||||
└── Export DOCX
|
||||
```
|
||||
|
||||
并切换一个导入的 Community Theme。
|
||||
|
||||
---
|
||||
|
||||
## 九、Definition of Done
|
||||
|
||||
### 范涵宇
|
||||
|
||||
- [ ] faster-whisper 能完成真实音频转写;
|
||||
- [ ] pyannote.audio 能生成说话人分段;
|
||||
- [ ] 两者能组合生成带时间戳和 Speaker 的 Transcript;
|
||||
- [ ] MCP Server 能通过 MCP Bridge 注册 Tool;
|
||||
- [ ] Agent 能调用 MCP Tool;
|
||||
- [ ] Plugin Command Contribution 后端可注册;
|
||||
- [ ] Plugin Settings Contribution 后端可解析;
|
||||
- [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定;
|
||||
- [ ] 完成跨模块接口审阅和第二阶段集成。
|
||||
|
||||
### 杨星萱
|
||||
|
||||
- [ ] RAG Benchmark Dataset 可以稳定运行;
|
||||
- [ ] 能输出 Hit@K、Recall@K、MRR、Latency 等指标;
|
||||
- [ ] Agent Benchmark Framework 可以执行标准 Case;
|
||||
- [ ] Markdown 可以导出 HTML;
|
||||
- [ ] Markdown 可以导出 PDF;
|
||||
- [ ] Markdown 可以导出 DOCX;
|
||||
- [ ] Mermaid 在导出链路中可以保留为静态图;
|
||||
- [ ] 函数图像能够由结构化表达生成;
|
||||
- [ ] 函数图像能够进入预览和导出链路;
|
||||
- [ ] Retrieval 调优结果有 Benchmark 数据支撑。
|
||||
|
||||
### 吉海燕
|
||||
|
||||
- [ ] Theme Package 可以导入;
|
||||
- [ ] Theme Manifest 可以校验;
|
||||
- [ ] Theme 可以启用、停用和卸载;
|
||||
- [ ] Agent Trace 可以展示完整 Tool Call 顺序;
|
||||
- [ ] Trace Node 可以查看参数、结果、耗时和错误;
|
||||
- [ ] Plugin Command 可以显示在前端;
|
||||
- [ ] Plugin Settings 可以动态生成设置项;
|
||||
- [ ] Markdown Mermaid Code Block 可以渲染;
|
||||
- [ ] Mermaid 支持主题切换;
|
||||
- [ ] Mermaid 渲染错误可以明确展示;
|
||||
- [ ] Mermaid 图能够提供给 Export Service。
|
||||
|
||||
---
|
||||
|
||||
## 十、分工摘要
|
||||
|
||||
```text
|
||||
范涵宇
|
||||
├── faster-whisper
|
||||
├── pyannote.audio
|
||||
├── MCP Bridge
|
||||
├── Plugin Command Runtime
|
||||
├── Plugin Settings Runtime
|
||||
├── Provider Adapter
|
||||
├── Code Review
|
||||
└── Integration / Coordination
|
||||
|
||||
杨星萱
|
||||
├── RAG Benchmark
|
||||
├── Agent Benchmark Infrastructure
|
||||
├── Retrieval Optimization
|
||||
├── Export Service
|
||||
│ ├── Markdown → HTML
|
||||
│ ├── Markdown → PDF
|
||||
│ └── Markdown → DOCX
|
||||
└── Function Plot
|
||||
├── Expression / Data Model
|
||||
├── Rendering
|
||||
└── Export Integration
|
||||
|
||||
吉海燕
|
||||
├── Theme Import
|
||||
├── Theme Community Format
|
||||
├── Agent Trace Visualization
|
||||
├── Plugin Command UI
|
||||
├── Plugin Settings UI
|
||||
└── Mermaid
|
||||
├── Markdown Rendering
|
||||
├── Theme Adaptation
|
||||
└── Export Rendering Interface
|
||||
```
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
> 文档用途:供团队在第一阶段进行页面设计、Vue 开发、前后端联调和验收。
|
||||
> 文档性质:开发需求基线,不是最终视觉规范或产品宣传文档。
|
||||
> 依据:`第一阶段分工表.md`、`AI笔记软件技术栈说明-团队版-v2.2.md`、`后端接口契约-开发版.md`。
|
||||
> 依据:`../architecture/第一阶段分工表.md`、`../architecture/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. 公共组件
|
||||
@@ -1,6 +1,6 @@
|
||||
# 后端接口契约(开发版)
|
||||
|
||||
> 更新日期:2026-08-30。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 FastAPI 运行时生成的 OpenAPI 为准。
|
||||
> 更新日期:2026-08-31。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 FastAPI 运行时生成的 OpenAPI 为准。第二阶段尚未实现的规划接口见 `第二阶段接口契约-开发版.md`,不要将规划路径视为当前服务能力。
|
||||
|
||||
## 契约入口
|
||||
|
||||
@@ -30,8 +30,22 @@
|
||||
| PATCH | `/api/notes/{note_id}` | 更新笔记 |
|
||||
| DELETE | `/api/notes/{note_id}` | 删除笔记 |
|
||||
| POST | `/api/notes/{note_id}/move` | 移动笔记 |
|
||||
| POST | `/api/notes/{note_id}/rename` | 重命名笔记文件并保留 Note/Block 身份 |
|
||||
| POST | `/api/search` | FTS、Vector 或 Hybrid 检索 |
|
||||
|
||||
### Workspace
|
||||
|
||||
Web 联调阶段只暴露后端通过 `APP_VAULT_PATH` 配置的单一 Vault,不接受浏览器传入任意本地目录。桌面多 Vault 与目录选择仍由后续 Tauri Host 提供。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/workspace` | 获取当前 Vault、文件数和索引同步状态 |
|
||||
| POST | `/api/workspace/open` | 打开配置的 Vault;磁盘路径集变化时重建索引 |
|
||||
| GET | `/api/workspace/tree` | 获取真实 Markdown 文件和目录树 |
|
||||
| POST | `/api/workspace/folders` | 新建目录 |
|
||||
| POST | `/api/workspace/folders/rename` | 重命名目录并同步 Note 路径 |
|
||||
| POST | `/api/workspace/folders/delete` | 删除目录及其 Note、Block、FTS 和向量记录 |
|
||||
|
||||
### Chat、Agent 与 Tool
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
@@ -41,7 +55,8 @@
|
||||
| POST | `/api/agent/runs` | 创建 Agent Run |
|
||||
| GET | `/api/agent/runs/{run_id}` | 获取 Agent Run 状态与 Trace 摘要 |
|
||||
| POST | `/api/agent/runs/{run_id}/cancel` | 取消 Agent Run |
|
||||
| GET | `/api/agent/runs/{run_id}/events` | 订阅 AgentEvent SSE |
|
||||
| GET | `/api/agent/runs/{run_id}/events` | 订阅 AgentEvent SSE,支持 `Last-Event-ID` / `after_sequence` 恢复 |
|
||||
| GET | `/api/agent/runs/{run_id}/trace` | 分页读取持久化 Trace、摘要和运行配置快照 |
|
||||
| POST | `/api/agent/runs/{run_id}/permissions/{request_id}` | 响应 Tool 权限确认 |
|
||||
| GET | `/api/tools` | 获取已注册 Tool Definition |
|
||||
|
||||
@@ -159,12 +174,16 @@ RunCancelled
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
更新至 2026-08-30:后端 71 项回归测试通过。
|
||||
更新至 2026-09-01:后端 81 项回归测试通过。
|
||||
|
||||
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
|
||||
- Agent Run/Event 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
|
||||
- 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 生成的转写文本,真实本地语音模型在第二阶段接入。
|
||||
- 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。
|
||||
|
||||
第二阶段开发保持本文件中已有路径兼容,并按 `第二阶段接口契约-开发版.md` 增加子资源、可选字段和事件。接口完成后先更新 OpenAPI 与本文件,再将第二阶段文档中的状态改为已实现。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
> 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。
|
||||
|
||||
> 更新日期:2026-08-30。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成,后端当前回归基线为 71 项测试通过。
|
||||
> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化和可恢复 SSE 已落地,后端当前回归基线为 81 项测试通过。
|
||||
|
||||
## 当前实现
|
||||
|
||||
@@ -28,6 +28,7 @@ backend/app/
|
||||
│ └── mock.py 离线开发 Provider
|
||||
├── agent/
|
||||
│ ├── runtime.py Agent Loop、限制、取消、Trace 和 SSE
|
||||
│ ├── trace_repository.py Run/Event SQLite 持久化、分页、摘要与脱敏
|
||||
│ ├── tools.py Tool 注册、参数校验、隔离执行和结果转换
|
||||
│ ├── permissions.py 权限策略、确认请求和会话授权
|
||||
│ └── builtin_tools.py 无副作用的内置开发 Tool
|
||||
@@ -53,7 +54,7 @@ Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider
|
||||
- Permission;
|
||||
- Step、Timeout、Token Budget、取消;
|
||||
- Tool 并发上限与 run 级网络权限;
|
||||
- 内存 Trace 与 SSE;
|
||||
- SQLite Trace、分页快照与可恢复 SSE;
|
||||
- Skill Manifest、Prompt、Tool/Permission/模型能力解析;
|
||||
- Plugin Manifest、生命周期和 Tool Contribution;
|
||||
- Skill 调用内置 Tool 与 Plugin Tool;
|
||||
@@ -171,10 +172,13 @@ POST /api/agent/runs
|
||||
```text
|
||||
GET /api/agent/runs/{run_id}
|
||||
GET /api/agent/runs/{run_id}/events
|
||||
GET /api/agent/runs/{run_id}/trace?after_sequence=-1&limit=200
|
||||
POST /api/agent/runs/{run_id}/cancel
|
||||
```
|
||||
|
||||
当前 Run 与 Trace 保存在内存中,AI Core 重启后清空。Runtime 最多保留 200 个 Run,每个 Run 最多保留 2000 个事件,并限制单轮 Tool Call 数量,避免长时间运行时无界增长。后续数据库层接入时替换 Repository,不改变 API Contract。
|
||||
Run 与 AgentEvent 已写入 SQLite,`run_id + sequence` 是幂等键。SSE 每帧包含 `id: sequence`;客户端可以通过 `Last-Event-ID` 请求头或 `after_sequence` 查询参数恢复缺失事件。Trace API 返回平铺事件、下一游标、分页状态、模型/工具调用统计、耗时、Token Usage 和创建 Run 时的配置快照,不负责生成前端树形布局。
|
||||
|
||||
运行时内存仍只保留最近 2000 个事件用于实时订阅,完整 Trace 以 SQLite 为准。AI Core 重启后,已经终止的 Run 可以继续查询和回放;重启前未终止的 Run 会收束为 `AGENT_PROCESS_RESTARTED`,避免永久停在 `running`。API Key、Authorization、Password、Secret、常见 `sk-`/Bearer 值在入库前脱敏。Event、Request 和配置快照中的超长字符串与集合会截断;作为查询事实来源的 `AgentRun` 只脱敏、不限长,保证重启前后 input/output 内容一致。
|
||||
|
||||
## Tool Calling
|
||||
|
||||
@@ -333,7 +337,7 @@ Skill Manifest
|
||||
|
||||
- 已实现 Mock、OpenAI-Compatible Chat Completions 与 Ollama Adapter;OpenAI Responses 和 Anthropic Messages 尚未实现。
|
||||
- Provider 配置暂存内存,后续通过 Repository 接入 SQLite;PATCH 已支持用显式 `null` 清空 base URL、默认模型和凭据引用。
|
||||
- Run/Trace 暂存内存;下一步抽象 Repository 并接入 SQLite。
|
||||
- Run/Trace 已通过 Repository 接入 SQLite;后续增加按保留策略归档和 Benchmark 引用保护。
|
||||
- Permission 已有核心等待/恢复机制,前端确认 UI 已完成联调和中文展示。
|
||||
- Task 已持久化到 SQLite;Attachment Tool 读取 Host 管理目录中的 UTF-8 文件。
|
||||
- `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍按技术基线在第二阶段接入。
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
> 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的
|
||||
> 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。
|
||||
|
||||
> 更新日期:2026-08-30。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 71 项测试通过。
|
||||
> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 81 项测试通过。
|
||||
|
||||
## 当前实现
|
||||
|
||||
@@ -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 27 passed
|
||||
uv run pytest 81 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、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 81 项测试结果,也不涉及产品代码。
|
||||
|
||||
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
|
||||
|
||||
@@ -104,4 +104,4 @@ pnpm build
|
||||
|
||||
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
|
||||
|
||||
当前完整回归基线:后端 71 项测试、前端 14 项测试通过,前端生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
|
||||
当前完整回归基线:后端 81 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
|
||||
@@ -0,0 +1,166 @@
|
||||
# CI/CD 细则(团队开发版)
|
||||
|
||||
> 本文档规定 NotesAgent 在 Gitea 上的持续集成、构建产物、发布和回滚要求。当前仓库尚未提交 Gitea Actions 工作流,因此本文首先作为落地流水线时的统一规范;流水线启用前,Pull Request 仍须人工执行同等检查。
|
||||
|
||||
> 更新日期:2026-09-01。当前阶段的 CD 指“生成可验证的候选构建与发布产物”,不包含把后端自动部署到公网环境。
|
||||
|
||||
## 1. 目标与原则
|
||||
|
||||
CI/CD 用于尽早发现依赖锁文件失效、类型错误、测试回归、前后端契约不一致和生产构建失败。流水线应遵守以下原则:
|
||||
|
||||
- 以 Gitea 为唯一远程和流水线入口;
|
||||
- `main` 始终保持可安装、可测试、可构建;
|
||||
- 安装依赖时使用锁文件,避免流水线与开发机解析出不同版本;
|
||||
- 未通过必需检查的提交不得合入 `main`;
|
||||
- 外部模型、真实 API Key 和用户本地数据不得成为基础 CI 的前置条件;
|
||||
- 缓存只用于加速,不得影响构建结果;删除缓存后流水线仍应成功;
|
||||
- 测试、构建和发布步骤使用最小权限,敏感信息不得写入日志或产物。
|
||||
|
||||
## 2. 运行环境基线
|
||||
|
||||
| 组件 | CI 要求 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Python | 3.12 | 项目最低支持 3.11,CI 使用团队推荐版本 |
|
||||
| uv | 当前稳定版,并在日志中输出版本 | 按 `backend/uv.lock` 安装后端依赖 |
|
||||
| Node.js | 22 LTS | 满足前端环境要求并保持 Runner 兼容性 |
|
||||
| pnpm | 10 | 按 `frontend/pnpm-lock.yaml` 安装前端依赖 |
|
||||
| 操作系统 | Linux Runner 为基础门禁 | 桌面端启用后再增加 Windows、macOS 构建矩阵 |
|
||||
|
||||
Runner 镜像或 Action 的大版本必须固定。升级 Python、Node.js、uv、pnpm 或基础 Action 时,应使用独立的 `chore/` 分支,并完整运行前后端检查。
|
||||
|
||||
## 3. 触发规则
|
||||
|
||||
| 事件 | 必须执行 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| Pull Request 指向 `main` | 文档检查、后端测试、前端测试、类型检查、生产构建 | 合并门禁 |
|
||||
| 推送到 `main` | 全量检查、集成冒烟、保存候选构建 | 验证合并结果 |
|
||||
| 推送功能分支 | 至少执行受影响模块的检查 | 尽早反馈;不得替代 PR 全量门禁 |
|
||||
| 推送 `v*` 标签 | 全量检查、构建、校验和、发布候选产物 | 正式发布入口 |
|
||||
| 手动触发 | 可选择全量回归或重新生成候选产物 | 发布前复核和故障恢复 |
|
||||
|
||||
纯文档变更可以跳过前后端耗时任务,但必须执行文档链接检查和 `git diff --check`。只有可靠的路径检测结果才能判定为纯文档变更;锁文件、工作流、构建配置和接口契约变更一律按代码变更处理。
|
||||
|
||||
## 4. Pull Request 必需检查
|
||||
|
||||
建议将以下 Job 名称固定为 Gitea 分支保护所要求的状态检查:
|
||||
|
||||
| Job | 必需命令或行为 | 通过标准 |
|
||||
| --- | --- | --- |
|
||||
| `docs-check` | `git diff --check`,检查仓库内 Markdown 相对链接 | 无空白错误、无失效本地链接 |
|
||||
| `backend-test` | `uv sync --frozen`、编译检查、`uv run pytest` | 依赖锁有效且测试全部通过 |
|
||||
| `frontend-test` | `pnpm install --frozen-lockfile`、`pnpm test` | 依赖锁有效且测试全部通过 |
|
||||
| `frontend-typecheck` | `pnpm type-check` | 无 TypeScript/Vue 类型错误 |
|
||||
| `frontend-build` | `pnpm build` | Vite 生产构建成功 |
|
||||
| `integration-smoke` | 启动 FastAPI,验证健康检查和关键本地链路 | 服务可启动,响应与契约符合预期 |
|
||||
|
||||
后端 Job 的基准命令:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv sync --frozen
|
||||
uv run python -m compileall -q app
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
前端 Job 的基准命令:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
`integration-smoke` 应使用 Mock Provider、临时数据库和临时附件目录,不访问 OpenAI、DeepSeek 或其他外部服务。测试结束后必须关闭服务并清理临时数据。
|
||||
|
||||
## 5. 路径与模块检查规则
|
||||
|
||||
- 修改 `backend/**`、`backend/uv.lock` 或后端配置时,必须运行 `backend-test` 和 `integration-smoke`。
|
||||
- 修改 `frontend/**`、`frontend/pnpm-lock.yaml` 或前端配置时,必须运行全部前端 Job。
|
||||
- 修改 `docs/contracts/**`、FastAPI 路由、DTO、SSE 事件或前端 Service 类型时,必须同时运行前后端全量检查。
|
||||
- 修改 `.gitea/**`、根目录工程配置或依赖版本时,必须运行所有 Job。
|
||||
- 修改 `docs/**` 以外且无法明确归类的文件时,默认运行所有 Job。
|
||||
|
||||
路径过滤只用于减少无关重复任务,不得造成关键检查缺失。若无法可靠判断影响范围,应执行全量流水线。
|
||||
|
||||
## 6. 凭据与敏感信息
|
||||
|
||||
- 基础 CI 不配置真实模型 API Key,Provider 相关测试统一使用 Mock 或请求桩。
|
||||
- 确需发布签名或访问受保护服务时,凭据只保存在 Gitea Actions Secrets 中,不写入仓库、工作流参数、缓存或构建产物。
|
||||
- 来自外部分支或不受信任 Pull Request 的任务不得读取发布凭据。
|
||||
- Secret 名称表达用途和环境,例如 `RELEASE_SIGNING_KEY`;禁止使用含义模糊的 `KEY1`、`TOKEN2`。
|
||||
- 日志中禁止输出请求头、完整 Token、API Key、用户笔记内容和本地凭据存储内容。
|
||||
- 生产凭据与测试凭据分离,并遵循最小权限、定期轮换和可撤销原则。
|
||||
|
||||
前端构建时注入的变量会进入静态资源,不能用于保存秘密。只有明确可公开的配置才允许使用 Vite 客户端环境变量。
|
||||
|
||||
## 7. 缓存与产物
|
||||
|
||||
可以缓存 uv 下载缓存和 pnpm Store,缓存键至少包含操作系统、运行时版本和对应锁文件哈希。不得缓存:
|
||||
|
||||
- `backend/.venv/`;
|
||||
- `frontend/node_modules/`;
|
||||
- `backend/data/`、测试数据库和用户附件;
|
||||
- `.env`、API Key、本地凭据库或签名材料。
|
||||
|
||||
普通 PR 不上传可执行发布包,只保留必要的测试报告和前端构建日志。`main` 或版本标签的候选产物应记录提交 SHA,生成 SHA-256 校验和,并设置明确的保留期限;非正式候选产物建议保留 14 天。
|
||||
|
||||
## 8. 分支保护与合并门禁
|
||||
|
||||
Gitea 中的 `main` 应启用以下保护:
|
||||
|
||||
- 禁止普通成员直接推送和强制推送;
|
||||
- 要求 Pull Request 审阅通过;
|
||||
- 要求第 4 节列出的适用状态检查成功;
|
||||
- Head 更新后使旧审阅和旧检查失效,必须针对最新提交重新检查;
|
||||
- 对话和审阅意见处理完成后才允许合并;
|
||||
- 优先使用 squash 或 rebase 保持主线清晰,具体方式遵循 [Git 使用细则](Git使用细则-团队开发版.md)。
|
||||
|
||||
临时绕过门禁只允许用于明确的仓库级故障。绕过者需要记录原因、影响、补验计划,并在恢复后立即补跑全部检查。
|
||||
|
||||
## 9. 发布流程
|
||||
|
||||
当前阶段按以下顺序生成发布候选:
|
||||
|
||||
1. 从已通过全部检查的 `main` 提交确定发布 SHA;
|
||||
2. 更新版本号、变更说明和必要文档;
|
||||
3. 创建形如 `v0.2.0` 的语义化版本标签;
|
||||
4. 标签流水线重新执行全部测试和生产构建;
|
||||
5. 对产物执行本地启动或安装冒烟测试;
|
||||
6. 生成校验和,并把版本、提交 SHA、构建环境和已知限制写入发布说明;
|
||||
7. 人工确认后在 Gitea 发布页面公开产物。
|
||||
|
||||
Tauri 桌面端接入后,发布流水线再增加 Windows、macOS 和 Linux 构建矩阵、平台签名及安装包验证。在签名、更新通道和回滚方案准备完成前,不启用面向用户的自动更新。
|
||||
|
||||
## 10. 回滚与热修复
|
||||
|
||||
- 尚未公开的候选产物直接标记为失败,不覆盖同一版本的已有产物;修复后递增预发布编号或版本号。
|
||||
- 已发布版本出现问题时,优先停止分发并回退到最近一个已验证版本。
|
||||
- 代码修复从 `main` 创建 `hotfix/<模块>-<问题>` 分支,通过完整门禁后合并并发布补丁版本。
|
||||
- 禁止重写已公开版本标签或用新文件替换旧版本同名产物。
|
||||
- 回滚或热修复完成后,在 `docs/retrospectives/` 记录原因、影响、处置过程和防复发措施。
|
||||
|
||||
## 11. 流水线失败处理
|
||||
|
||||
1. 先确认失败是否可在本地使用相同锁文件和命令复现;
|
||||
2. 判断是代码、测试、依赖、Runner 还是外部基础设施问题;
|
||||
3. 代码或测试问题由当前 PR 修复,不通过重跑掩盖不稳定测试;
|
||||
4. Runner 或 Gitea 故障应记录日志和时间,恢复后针对同一 Head 重新执行;
|
||||
5. 连续出现的偶发失败必须作为缺陷处理,明确负责人并增加稳定性修复;
|
||||
6. 修复流水线本身时,不得顺便降低测试范围或绕过既有门禁。
|
||||
|
||||
## 12. 落地清单
|
||||
|
||||
首次创建 `.gitea/workflows/` 时,应逐项确认:
|
||||
|
||||
- [ ] 工作流只使用 Gitea Runner 支持且来源可信的 Action;
|
||||
- [ ] Python、Node.js、uv 和 pnpm 版本符合本规范;
|
||||
- [ ] 后端和前端依赖均以 frozen 模式安装;
|
||||
- [ ] 必需 Job 名称与 `main` 分支保护一致;
|
||||
- [ ] Mock 测试不依赖外部模型服务和真实凭据;
|
||||
- [ ] 缓存键包含锁文件哈希,缓存内容不含用户数据或秘密;
|
||||
- [ ] PR、`main`、版本标签和手动触发行为分别验证;
|
||||
- [ ] 失败任务能返回非零退出码,后续发布步骤不会继续;
|
||||
- [ ] 候选产物包含提交 SHA、校验和和保留期限;
|
||||
- [ ] 团队成员能够按本文档在本地复现全部门禁。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 本文档用于 Notes Agent 团队日常开发。目标是让三名成员可以并行开发、稳定联调,并确保 `main` 始终处于可运行状态。
|
||||
|
||||
> 更新日期:2026-08-30。当前远程只使用 `gitea`,功能分支不添加个人或工具名称前缀;合并门槛为相关测试、前端生产构建、文档同步和 `git diff --check` 全部通过。
|
||||
> 更新日期:2026-09-01。当前远程只使用 `gitea`,功能分支不添加个人或工具名称前缀;合并门槛为相关测试、前端生产构建、文档同步和 `git diff --check` 全部通过。自动化门禁、产物和发布规则见 [CI/CD 细则](CI-CD细则-团队开发版.md)。
|
||||
|
||||
## 1. 仓库与远程
|
||||
|
||||
@@ -622,4 +622,4 @@ git push -u gitea <分支名>
|
||||
git log --oneline --decorate -10
|
||||
```
|
||||
|
||||
本细则的核心要求是:`main` 可运行、改动可 Review、问题可追踪、敏感信息不入库、跨模块变化同步 Contract 与文档。
|
||||
本细则的核心要求是:`main` 可运行、改动可 Review、问题可追踪、敏感信息不入库、跨模块变化同步 Contract 与文档。流水线启用后,合并和发布还必须满足 [CI/CD 细则](CI-CD细则-团队开发版.md) 中的状态检查与产物要求。
|
||||
@@ -36,7 +36,7 @@
|
||||
| Security | 本地主密钥目前保存在数据目录,桌面端接入后迁移到系统凭据库 |
|
||||
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
|
||||
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
|
||||
| Desktop | Workspace 仍使用 Web Mock,后续由 Tauri IPC 文件系统适配器替换 |
|
||||
| Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 |
|
||||
| Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 |
|
||||
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
|
||||
|
||||
@@ -68,7 +68,7 @@ uv run pytest -q -p no:cacheprovider
|
||||
当前基线:
|
||||
|
||||
```text
|
||||
71 passed
|
||||
81 passed
|
||||
```
|
||||
|
||||
通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。
|
||||
@@ -83,8 +83,8 @@ pnpm test
|
||||
当前基线:
|
||||
|
||||
```text
|
||||
9 test files passed
|
||||
23 tests passed
|
||||
11 test files passed
|
||||
27 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 源码,源码模式可以精确编辑;
|
||||
@@ -0,0 +1,491 @@
|
||||
# Agent Core 第二阶段:Trace 持久化与 SSE 恢复问题复盘
|
||||
|
||||
> 审阅与修复日期:2026-09-01
|
||||
> 涉及分支:`feat/agent-trace-persistence`
|
||||
> 功能提交:`3cb197a feat(agent): 持久化Trace并支持SSE恢复`
|
||||
> 文档用途:记录 Agent Run、Trace、SSE 恢复和审计数据安全问题的形成原因、实际后果、解决思路与落地方案,供后续开发文档、比赛材料和技术博客写作使用。
|
||||
|
||||
## 1. 背景与结论
|
||||
|
||||
第一阶段 Agent Runtime 已经能够完成模型调用、Tool Calling、权限确认、取消、Usage 和 Citation,但 Run 与 Event 仍以进程内字典和列表为事实来源。第一阶段审阅加入的 Run/Event 数量上限解决了内存无界增长,却没有解决重启丢失、断线续传、Benchmark 复用和敏感数据审计等第二阶段问题。
|
||||
|
||||
本轮处理了 9 类问题:
|
||||
|
||||
| 编号 | 问题 | 级别 | 处理结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| A-01 | Agent Run 与 Event 只存在于进程内存 | P0 | 增加 SQLite v3 Schema 和 Trace Repository |
|
||||
| A-02 | Event 裁剪后 sequence 可能重复 | P0 | 改为独立单调序号并建立数据库幂等键 |
|
||||
| A-03 | SSE 断线后无法从指定事件恢复 | P1 | 支持 `Last-Event-ID`、`after_sequence` 和 SSE `id` |
|
||||
| A-04 | 缺少可分页 Trace 与 Benchmark 配置快照 | P1 | 增加 Trace API、统计摘要与配置快照 |
|
||||
| A-05 | Trace 缺少模型调用、父子关系和权限结果 | P1 | 增加第二阶段事件及耗时/parent 字段 |
|
||||
| A-06 | Trace 可能保存 Secret 和超大 Tool Result | P0 | 全部持久化副本统一脱敏,审计摘要限长 |
|
||||
| A-07 | 进程重启后未终止 Run 永久显示运行中 | P1 | 自动收束为 `AGENT_PROCESS_RESTARTED` |
|
||||
| A-08 | 前端 DTO 与 SSE Client 无法消费恢复协议 | P1 | 同步 TypeScript Contract、Service、标签和 SSE id |
|
||||
| A-09 | AgentRun 与审计摘要共用限长规则 | P1 | Run 只脱敏不限长,保证重启前后内容一致 |
|
||||
|
||||
修复后的验证基线为:后端 81 项测试、前端 27 项测试、TypeScript 类型检查和生产构建通过。
|
||||
|
||||
## 2. A-01:Agent Run 与 Event 只存在于进程内存
|
||||
|
||||
### 原因
|
||||
|
||||
旧 `AgentRuntime` 使用 `_records: dict[str, RunRecord]` 保存全部运行状态。每个 `RunRecord` 内部再保存 `events`、实时订阅队列和异步任务。创建、查询、列表和 SSE 回放都只读取这个字典,没有 Repository 边界。
|
||||
|
||||
第一阶段为控制内存加入了最多 200 个 Run、每个 Run 2000 个 Event 的限制。这是必要的资源保护,但只是减少进程内数据量,不能替代持久化。
|
||||
|
||||
### 后果
|
||||
|
||||
- AI Core 重启后,历史 Run、Tool Result、Citation 和错误全部丢失;
|
||||
- 前端刷新或重新连接后,只能读取当前进程尚未淘汰的数据;
|
||||
- Agent Benchmark 无法使用稳定的历史事实计算指标;
|
||||
- Run 列表随着进程重启清空,界面记录和实际操作脱节;
|
||||
- 内存裁剪后的旧事件无法再恢复。
|
||||
|
||||
### 解决思路
|
||||
|
||||
将 SQLite 中的 Run/Event 作为唯一持久化事实,把内存降级为执行上下文和实时订阅窗口。Runtime 继续负责状态机,Repository 负责落库、分页、恢复和统计,Router 不直接访问数据库。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新增 SQLite v3 migration:
|
||||
|
||||
```text
|
||||
agent_runs
|
||||
├── run_id
|
||||
├── status
|
||||
├── run_json
|
||||
├── request_json
|
||||
├── config_snapshot_json
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
agent_events
|
||||
├── run_id
|
||||
├── sequence
|
||||
├── event
|
||||
├── data_json
|
||||
└── timestamp
|
||||
```
|
||||
|
||||
新增 `AgentTraceRepository`,负责:
|
||||
|
||||
- 创建 Run 及请求、配置快照;
|
||||
- 在同一事务中更新 Run 并追加 Event;
|
||||
- 按创建时间分页列出 Run;
|
||||
- 按 sequence 读取 Event;
|
||||
- 生成 Trace 分页响应和统计摘要;
|
||||
- 恢复进程中断的非终态 Run。
|
||||
|
||||
内存中的 2000 条 Event 上限继续保留,但只用于实时订阅窗口;完整记录由 SQLite 管理。
|
||||
|
||||
## 3. A-02:Event 裁剪后 sequence 可能重复
|
||||
|
||||
### 原因
|
||||
|
||||
旧 `_publish()` 使用下面的方式生成序号:
|
||||
|
||||
```python
|
||||
sequence = len(record.events)
|
||||
```
|
||||
|
||||
当事件数量超过 2000 后,Runtime 会删除列表头部。列表长度重新回到 2000,后续事件仍会得到 2000,形成重复 sequence。
|
||||
|
||||
### 后果
|
||||
|
||||
- `run_id + sequence` 无法作为幂等键;
|
||||
- 前端按 sequence 去重时会错误丢弃新事件;
|
||||
- 时间线排序出现同序号节点;
|
||||
- 持久化后会触发主键冲突,或者在错误的覆盖策略下破坏旧事件;
|
||||
- Benchmark 无法可靠还原 Tool Call 顺序。
|
||||
|
||||
### 解决思路
|
||||
|
||||
sequence 应属于 Run 的逻辑时钟,不能从当前缓存长度推导。内存是否裁剪不得影响序号。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- `RunRecord` 增加独立的 `next_sequence`;
|
||||
- 每次发布读取当前值,再原子递增;
|
||||
- `agent_events` 使用 `(run_id, sequence)` 复合主键;
|
||||
- Repository 对重复写入使用幂等插入,不覆盖已经存在的事件事实;
|
||||
- Trace 和 SSE 均严格按 sequence 升序返回。
|
||||
|
||||
## 4. A-03:SSE 断线后无法恢复
|
||||
|
||||
### 原因
|
||||
|
||||
旧 SSE 接口只能从内存列表头部重新回放全部历史,再切换到实时队列。协议帧只有 `event:` 和 `data:`,没有 SSE 标准的 `id:`。接口也不读取 `Last-Event-ID` 或查询游标。
|
||||
|
||||
前端即使知道自己最后处理到哪个 sequence,也无法把该位置传回服务端。
|
||||
|
||||
### 后果
|
||||
|
||||
- 短暂断网或页面切换后只能从头回放;
|
||||
- 长 Run 重连会重复传输大量事件;
|
||||
- 前端需要依赖本地去重掩盖服务端缺少恢复能力;
|
||||
- AI Core 重启后无法续传,因为历史事件本身也不存在;
|
||||
- 实时与历史交界处容易漏事件或重复事件。
|
||||
|
||||
### 解决思路
|
||||
|
||||
恢复协议以 sequence 为游标。服务端先注册实时订阅,再读取 `sequence > cursor` 的持久化历史,随后消费实时队列;交界处允许重复,但 Runtime 和前端都按 sequence 去重。
|
||||
|
||||
### 解决方案
|
||||
|
||||
接口支持两种游标输入:
|
||||
|
||||
```http
|
||||
GET /api/agent/runs/{run_id}/events?after_sequence=42
|
||||
Last-Event-ID: 42
|
||||
```
|
||||
|
||||
返回帧包含:
|
||||
|
||||
```text
|
||||
id: 43
|
||||
event: ToolResult
|
||||
data: {"run_id":"...","sequence":43,"data":{...}}
|
||||
```
|
||||
|
||||
具体处理:
|
||||
|
||||
- `after_sequence` 优先于 `Last-Event-ID`;
|
||||
- 默认游标为 `-1`,表示从 sequence 0 开始;
|
||||
- 非整数或小于 `-1` 的 Header 返回 `TRACE_CURSOR_INVALID`;
|
||||
- 历史回放读取 SQLite,不依赖内存窗口;
|
||||
- 历史与实时交界处按最后已发送 sequence 跳过重复项;
|
||||
- 终态 Run 回放完终止事件后关闭连接。
|
||||
|
||||
## 5. A-04:缺少可分页 Trace 与 Benchmark 配置快照
|
||||
|
||||
### 原因
|
||||
|
||||
旧接口只有 Run 状态和 SSE。SSE 适合实时消费,不适合报告页随机访问、大 Trace 分页或 Benchmark 批量计算。Run 也没有保存创建时的 Provider、Model、Skill、允许工具和限制参数快照。
|
||||
|
||||
### 后果
|
||||
|
||||
- Trace 页面只能依赖一次长连接重建全部状态;
|
||||
- Benchmark 需要绕过正式接口读取 Runtime 内部对象;
|
||||
- Provider 或 Skill 配置变化后,旧结果失去可解释性;
|
||||
- 大 Trace 无法受控分页,接口响应体会持续增大;
|
||||
- 前端和 Benchmark 容易各自实现一套不一致的统计逻辑。
|
||||
|
||||
### 解决思路
|
||||
|
||||
提供面向读取的 Trace Snapshot API,但只返回平铺事实,不在后端生成前端树形布局。前端按 parent ID 和 sequence 构造时间线,Benchmark 从同一事件计算指标。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新增接口:
|
||||
|
||||
```http
|
||||
GET /api/agent/runs/{run_id}/trace?after_sequence=-1&limit=200
|
||||
```
|
||||
|
||||
响应包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "run_123",
|
||||
"status": "completed",
|
||||
"items": [],
|
||||
"next_sequence": 199,
|
||||
"has_more": true,
|
||||
"summary": {
|
||||
"model_calls": 2,
|
||||
"tool_calls": 3,
|
||||
"duration_ms": 1530,
|
||||
"token_usage": 2048,
|
||||
"errors": 0
|
||||
},
|
||||
"config_snapshot": {}
|
||||
}
|
||||
```
|
||||
|
||||
配置快照保存:
|
||||
|
||||
- Provider ID 和类型;
|
||||
- Model;
|
||||
- Capability;
|
||||
- Skill ID;
|
||||
- 允许的 Tool;
|
||||
- `max_steps`、Token Budget 和网络权限;
|
||||
- 经过脱敏的 Metadata。
|
||||
|
||||
## 6. A-05:Trace 缺少关键执行事实
|
||||
|
||||
### 原因
|
||||
|
||||
第一阶段事件能够表达 Run、Tool、Permission Request、Usage 和 Citation,但没有明确表示一次模型调用的开始、完成或失败。Tool Call 与模型轮次之间也没有 parent ID,Tool Result 缺少统一耗时。权限接口只唤醒 Future,不记录最终决定。
|
||||
|
||||
### 后果
|
||||
|
||||
- 前端无法展示“模型调用 → 多个 Tool → 下一次模型调用”的完整树;
|
||||
- Agent Benchmark 无法计算模型调用次数和平均步骤耗时;
|
||||
- 并发 Tool Call 时难以判断属于哪个模型轮次;
|
||||
- 权限卡片消失后,Trace 中只保留“请求过权限”,不知道用户允许还是拒绝;
|
||||
- Provider 失败只能看到最终 RunFailed,缺少模型调用级上下文。
|
||||
|
||||
### 解决思路
|
||||
|
||||
保持现有 AgentEvent envelope 不变,只增加事件类型和可选数据字段。调用关系使用稳定 ID 表达,不让前端根据相邻位置猜测父子关系。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新增事件:
|
||||
|
||||
```text
|
||||
ModelCallStarted
|
||||
ModelCallCompleted
|
||||
ModelCallFailed
|
||||
PermissionResolved
|
||||
```
|
||||
|
||||
补充字段:
|
||||
|
||||
- Model Call:`model_call_id`、Provider、Model、Step、Finish Reason、Token、Duration;
|
||||
- Tool Call/Result:`parent_model_call_id`、`duration_ms`;
|
||||
- Permission Resolved:`request_id`、Permission、Decision;
|
||||
- Model Call Failed:`error_code` 和耗时,不写入第三方原始敏感异常。
|
||||
|
||||
模型与 Tool 耗时使用单调时钟计算,避免系统时间调整影响 Duration。
|
||||
|
||||
## 7. A-06:Trace 可能保存 Secret 和超大结果
|
||||
|
||||
### 原因
|
||||
|
||||
Tool 参数和输出来自模型、插件或外部服务,属于不可信数据。旧事件直接保存 `ToolCall.model_dump()` 和 `ToolResult.model_dump()`。`notes.read`、附件或第三方 Tool 可以返回大段正文,参数也可能包含 `api_key`、Authorization 或 Password。
|
||||
|
||||
初版持久化修复只净化了 `agent_events.data_json`。提交前审阅发现,`agent_runs.run_json` 中的 `tool_results`、`output` 和 `input` 仍可能保存同一份敏感值,说明“只在事件层脱敏”并不完整。
|
||||
|
||||
### 后果
|
||||
|
||||
- API Key 或 Bearer Token 可能进入 SQLite、备份和测试产物;
|
||||
- Trace API 不返回 Secret,但数据库中的 Run Snapshot 仍可能泄露;
|
||||
- 单个 Tool Result 可以让 Event 和 Run JSON 快速膨胀;
|
||||
- 前端展开节点时可能因为超大 JSON 卡顿;
|
||||
- Benchmark Dataset 或报告导出可能间接携带密钥。
|
||||
|
||||
### 解决思路
|
||||
|
||||
所有进入持久化边界的数据统一经过同一个净化函数,不能分别在 Router、Runtime 和 Repository 中维护不同脱敏规则。Secret 脱敏适用于全部副本;体积限制只适用于 Trace Event、Request 和 Config 等审计数据,不能改变对外查询所依赖的 AgentRun 事实。
|
||||
|
||||
### 解决方案
|
||||
|
||||
统一处理以下对象:
|
||||
|
||||
```text
|
||||
AgentRun Snapshot
|
||||
AgentRunCreateRequest Snapshot
|
||||
Config Snapshot
|
||||
AgentEvent Data
|
||||
```
|
||||
|
||||
通用脱敏规则:
|
||||
|
||||
- `api_key`、Authorization、Access/Refresh Token、Password、Secret 等键替换为 `[REDACTED]`;
|
||||
- 常见 `sk-...` 和 `Bearer ...` 字符串模式直接替换;
|
||||
- `credential_id` 等非明文引用保留,不误判为 Secret。
|
||||
|
||||
Event、Request 和 Config 审计副本额外执行限长:
|
||||
|
||||
- 单字符串最多保留 4096 个字符;
|
||||
- 单集合最多保留 100 项;
|
||||
- 递归深度最多 8 层;
|
||||
- 超限位置使用明确的 `[TRUNCATED]` 或 `[MAX_DEPTH]` 标记。
|
||||
|
||||
`AgentRun` Snapshot 仍经过同一套 Secret 脱敏,但不执行长度、集合和深度截断。回归测试直接读取 `agent_runs` 原始 SQLite 字段,确认测试密钥没有落盘,避免只验证 API 响应造成假安全。
|
||||
|
||||
## 8. A-07:重启后未终止 Run 永久显示运行中
|
||||
|
||||
### 原因
|
||||
|
||||
Agent 的异步 Task 和 Permission Future 不能跨进程恢复。持久化 Run 后,如果直接返回数据库状态,重启前处于 `queued`、`running` 或 `waiting_permission` 的记录会一直保持非终态,但新进程中没有对应 Task 可以继续执行。
|
||||
|
||||
### 后果
|
||||
|
||||
- 前端长期显示“运行中”或“等待授权”;
|
||||
- SSE 订阅等待一个永远不会到来的终止事件;
|
||||
- Benchmark Runner 无法判断 Case 已中断;
|
||||
- 用户取消该 Run 时,新进程找不到实际 Task;
|
||||
- 统计中的成功率和耗时被悬挂 Run 污染。
|
||||
|
||||
### 解决思路
|
||||
|
||||
本阶段提供“状态恢复”,不伪装成“执行恢复”。没有可重放状态机、Provider 幂等令牌和 Tool 副作用日志之前,自动继续执行会造成重复写入或重复网络请求。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新 Runtime 首次读取不属于当前进程的非终态 Run 时:
|
||||
|
||||
- 状态改为 `failed`;
|
||||
- 错误码设为 `AGENT_PROCESS_RESTARTED`;
|
||||
- 错误信息说明进程在完成前重启;
|
||||
- 使用数据库最大 sequence 加一,追加唯一 `RunFailed` 事件;
|
||||
- 后续 Run 查询、Trace 和 SSE 都返回同一终态事实。
|
||||
|
||||
已经完成、失败或取消的 Run 不修改,可以在重启后继续查询和回放。
|
||||
|
||||
## 9. A-08:前端无法消费第二阶段恢复协议
|
||||
|
||||
### 原因
|
||||
|
||||
后端增加新事件和 SSE `id` 后,前端 `AgentEventType` 仍只包含第一阶段事件。`SseClient` 只解析 `event` 和 `data`,忽略 `id`,也没有发送 `Last-Event-ID`。因此仅完成后端并不能形成可联调的 Contract。
|
||||
|
||||
### 后果
|
||||
|
||||
- `Record<AgentEventType, string>` 中文标签无法通过类型检查;
|
||||
- 前端不知道 Model Call 和 Permission Resolved 的类型;
|
||||
- 断线后无法把最后事件 ID 传回后端;
|
||||
- Trace 可视化负责人需要自行猜测 Wire DTO;
|
||||
- 后端恢复能力只能通过 Curl 使用,页面调用链没有闭环。
|
||||
|
||||
### 解决思路
|
||||
|
||||
范侧只提供稳定的前端接口适配,不越过分工实现 Trace Visualization。Pydantic Contract、TypeScript Wire DTO 和 Service 必须在同一功能提交中同步。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- TypeScript 增加四类第二阶段 Agent Event;
|
||||
- 增加 `AgentTraceSummary` 和 `AgentTraceResponse`;
|
||||
- `agentService.getAgentTrace()` 封装分页查询;
|
||||
- `streamAgentEvents()` 接受 `afterSequence`;
|
||||
- `SseClient` 发送 `Last-Event-ID` 并解析返回帧的 `id`;
|
||||
- 新事件增加中文标签和详情字段名;
|
||||
- 增加 SSE 请求头和 Event ID 单元测试。
|
||||
|
||||
Trace 时间线、树形布局、筛选、节点展开和 Citation 跳转仍由前端负责人实现。
|
||||
|
||||
## 10. A-09:AgentRun 与审计摘要共用限长规则
|
||||
|
||||
### 原因
|
||||
|
||||
初版使用 `sanitize_trace_value()` 同时处理 `run_json`、Request、Config 和 Event。该函数不仅脱敏,还会截断超过 4096 字符的字符串、超过 100 项的集合和超过 8 层的结构。`run_json` 随后又被 `GET /agent/runs/{run_id}` 和 Run 列表当作重启后的事实来源,因此审计数据的防膨胀规则意外改变了业务响应。
|
||||
|
||||
### 后果
|
||||
|
||||
- 常见的长模型回答在 AI Core 重启后只剩前 4096 个字符和截断标记;
|
||||
- 长输入、Tool Result 和嵌套结果也可能丢失;
|
||||
- 同一个 Run 在进程内与重启后的接口响应不一致;
|
||||
- 前端刷新、Benchmark 复核和问题追踪无法取得原始运行结果;
|
||||
- 原有测试只验证了 Trace 参数截断,没有比较重启前后的长正文。
|
||||
|
||||
审阅时使用 5000 字符 output 复现:写入前长度为 5000,重新读取后长度变为 4110,并以 `...[TRUNCATED]` 结尾。
|
||||
|
||||
### 解决思路
|
||||
|
||||
持久化边界包含两类数据:AgentRun 是业务事实,Trace Event、Request 和 Config 是可视化与审计摘要。两类数据必须共享 Secret 脱敏规则,但不能共享有损的体积限制。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- `sanitize_trace_value()` 增加明确的 `apply_limits` 策略参数;
|
||||
- 默认继续限长,保持 Event、Request 和 Config 的安全边界;
|
||||
- `_serialize_run()` 使用 `apply_limits=False`,完整保留 input、output、Tool Result 和 Citation;
|
||||
- Secret 键名及 `sk-`、Bearer 模式在两种策略下始终脱敏;
|
||||
- 增加超过 4096 字符的 input/output 持久化回归测试,直接从新 Repository 读取并逐字比较。
|
||||
|
||||
## 11. 事务、顺序与恢复不变量
|
||||
|
||||
本轮修复明确了以下不变量:
|
||||
|
||||
1. `run_id + sequence` 唯一标识一条 Agent Event。
|
||||
2. sequence 在一个 Run 内只增不减,不受内存裁剪影响。
|
||||
3. 发布事件时,在同一 SQLite 事务中更新 Run Snapshot 并追加 Event。
|
||||
4. SSE、Trace 页面和 Agent Benchmark 读取同一份 `agent_events`,不建立旁路。
|
||||
5. 终止事件为 `RunCompleted`、`RunFailed` 或 `RunCancelled`;终态 Run 不再产生业务事件。
|
||||
6. 重启后不能安全继续执行的 Run 必须明确失败,不能永久悬挂。
|
||||
7. 所有持久化 Trace 数据先脱敏、再写入。
|
||||
8. 前端按 `run_id + sequence` 去重,不能依赖一次网络读取对应一条 SSE Event。
|
||||
9. AgentRun 的持久化副本不执行审计摘要限长,重启前后业务字段必须一致。
|
||||
|
||||
## 12. 验证方法
|
||||
|
||||
后端:
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv run python -m compileall -q app
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
pnpm test
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
仓库检查:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
```
|
||||
|
||||
验证结果:
|
||||
|
||||
```text
|
||||
backend pytest 81 passed
|
||||
backend compileall passed
|
||||
frontend vitest 11 files / 27 tests passed
|
||||
frontend type-check passed
|
||||
frontend build passed
|
||||
git diff --check passed
|
||||
```
|
||||
|
||||
本轮新增回归覆盖:
|
||||
|
||||
- 完成 Run 在新 Runtime 中恢复查询;
|
||||
- Trace 多页读取和游标无重复;
|
||||
- SSE `Last-Event-ID`、`after_sequence` 和 `id:` 帧;
|
||||
- Model Call、Permission Resolved 和 Tool parent ID;
|
||||
- 进程中断 Run 自动生成唯一终止事件;
|
||||
- API Key、Authorization 和超长 Tool 参数净化;
|
||||
- 超长 AgentRun input/output 在持久化和重启读取后保持完整;
|
||||
- 直接检查 SQLite,确认 Secret 未进入 Run/Request/Config Snapshot;
|
||||
- OpenAPI 发布 Trace 路径;
|
||||
- 前端 SSE Client 发送和解析恢复游标。
|
||||
|
||||
测试仍会出现本机 `.pytest_cache` 无写入权限警告,不影响 81 项用例结果,也不涉及产品代码。
|
||||
|
||||
## 13. 当前边界与后续工作
|
||||
|
||||
### 13.1 本阶段明确不做
|
||||
|
||||
- 不在后端生成前端 Trace 树形布局;
|
||||
- 不在进程重启后自动重放未完成 Tool 副作用;
|
||||
- 不把 Secret 明文放入 Trace、日志或 Benchmark;
|
||||
- 不为 Benchmark 建立绕过 Agent Runtime 的专用执行协议。
|
||||
|
||||
### 13.2 后续需要继续处理
|
||||
|
||||
- 增加 Trace 保留、归档和被 Benchmark 引用时的保护策略;
|
||||
- 引入保留窗口后实现 `TRACE_CURSOR_EXPIRED`;
|
||||
- 根据桌面网络策略增加有上限的指数退避自动重连;
|
||||
- 评估高频 Token Event 的批量写入,减少 SQLite 连接与事务开销;
|
||||
- Agent Benchmark 接入正式 Trace 并验证指标字段是否充足;
|
||||
- 前端完成 Trace Timeline/Tree、筛选、节点详情和 Citation 跳转;
|
||||
- 多进程或远程执行出现需求后,再设计带租约和幂等副作用的执行恢复。
|
||||
|
||||
## 14. 可复用经验
|
||||
|
||||
### 14.1 资源上限不等于持久化
|
||||
|
||||
限制内存 Run 和 Event 数量只能防止进程膨胀,不能解决重启、审计和报告复现。临时保护措施应在文档中明确标注,不能被误认为最终架构已经完成。
|
||||
|
||||
### 14.2 游标必须独立于缓存结构
|
||||
|
||||
只要 sequence 来源于 `len(list)`、数组下标或当前页位置,裁剪和分页就可能破坏唯一性。可恢复事件流必须使用独立、单调且可持久化的逻辑序号。
|
||||
|
||||
### 14.3 恢复读取不等于恢复执行
|
||||
|
||||
恢复 Run/Trace 查询相对安全;恢复一个包含 Tool 副作用的执行任务需要额外的幂等、租约和补偿机制。在没有这些机制时,明确失败比重复执行更可靠。
|
||||
|
||||
### 14.4 脱敏要覆盖全部持久化副本
|
||||
|
||||
同一敏感值可能同时出现在 Event、Run Snapshot、Request、Config、日志和报告中。只检查最终 API 响应无法证明数据没有落盘,安全测试应直接验证持久化介质。
|
||||
|
||||
### 14.5 生产者和消费者 Contract 必须同时更新
|
||||
|
||||
后端新增事件类型、字段或 SSE 规则时,至少同步 Pydantic、OpenAPI、TypeScript DTO、Service 和协议测试。可视化页面可以由另一成员开发,但不能让对方从后端实现反推 Contract。
|
||||
+1
-1
@@ -499,5 +499,5 @@ backend/app/retrieval/engine.py FTS/Vector/Hybrid 检索编排
|
||||
backend/app/services/index_service.py 全量重建与失败恢复
|
||||
backend/app/knowledge/parser.py Block ID 与 tags 解析语义
|
||||
backend/tests/test_retrieval.py 审阅回归测试
|
||||
docs/Knowledge与Retrieval-Core开发说明.md 模块开发说明
|
||||
docs/development/Knowledge与Retrieval-Core开发说明.md 模块开发说明
|
||||
```
|
||||
@@ -4,7 +4,7 @@
|
||||
> 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。
|
||||
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
|
||||
|
||||
> 2026-08-30 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析和 Fernet 加密存储,当前完整后端回归基线为 71 项测试通过。
|
||||
> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储和 Agent Trace 持久化,当前完整后端回归基线为 81 项测试通过。
|
||||
|
||||
## 1. 审阅结论
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface NoteBlock {
|
||||
|
||||
export interface FileNode {
|
||||
id: string
|
||||
note_id?: string
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'folder'
|
||||
@@ -142,6 +143,10 @@ export type AgentEventType =
|
||||
| 'PermissionRequired'
|
||||
| 'Usage'
|
||||
| 'Citation'
|
||||
| 'ModelCallStarted'
|
||||
| 'ModelCallCompleted'
|
||||
| 'ModelCallFailed'
|
||||
| 'PermissionResolved'
|
||||
| 'RunCompleted'
|
||||
| 'RunFailed'
|
||||
| 'RunCancelled'
|
||||
@@ -154,6 +159,24 @@ export interface AgentEvent {
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface AgentTraceSummary {
|
||||
model_calls: number
|
||||
tool_calls: number
|
||||
duration_ms: number
|
||||
token_usage: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export interface AgentTraceResponse {
|
||||
run_id: string
|
||||
status: AgentRunStatus
|
||||
items: AgentEvent[]
|
||||
next_sequence: number
|
||||
has_more: boolean
|
||||
summary: AgentTraceSummary
|
||||
config_snapshot: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
tool_call_id: string
|
||||
name: string
|
||||
@@ -387,6 +410,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
|
||||
|
||||
@@ -18,6 +18,10 @@ const eventLabels: Record<AgentEventType, string> = {
|
||||
PermissionRequired: '请求权限',
|
||||
Usage: '用量统计',
|
||||
Citation: '引用来源',
|
||||
ModelCallStarted: '模型调用开始',
|
||||
ModelCallCompleted: '模型调用完成',
|
||||
ModelCallFailed: '模型调用失败',
|
||||
PermissionResolved: '权限已处理',
|
||||
RunCompleted: '运行完成',
|
||||
RunFailed: '运行失败',
|
||||
RunCancelled: '运行取消',
|
||||
@@ -86,6 +90,10 @@ const detailLabels: Record<string, string> = {
|
||||
total_tokens: '令牌总数',
|
||||
status: '状态',
|
||||
duration_ms: '耗时(毫秒)',
|
||||
model_call_id: '模型调用 ID',
|
||||
parent_model_call_id: '上级模型调用 ID',
|
||||
finish_reason: '结束原因',
|
||||
decision: '授权决定',
|
||||
}
|
||||
|
||||
export function runStatusLabel(status?: AgentRunStatus): string {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import apiClient from './apiClient'
|
||||
import { SseClient } from './sseClient'
|
||||
import type { AgentRun, AgentEvent, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
|
||||
import type { AgentRun, AgentEvent, AgentTraceResponse, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
|
||||
|
||||
function toAgentRun(run: ApiAgentRun): AgentRun {
|
||||
// API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。
|
||||
@@ -54,6 +54,13 @@ export async function cancelAgentRun(runId: string): Promise<OperationResponse>
|
||||
return apiClient.post(`/api/agent/runs/${runId}/cancel`)
|
||||
}
|
||||
|
||||
export async function getAgentTrace(
|
||||
runId: string,
|
||||
params?: { after_sequence?: number; limit?: number },
|
||||
): Promise<AgentTraceResponse> {
|
||||
return apiClient.get(`/api/agent/runs/${runId}/trace`, { params })
|
||||
}
|
||||
|
||||
export async function listTools(): Promise<ToolDefinition[]> {
|
||||
const response = await apiClient.get<{ items: ToolDefinition[] }>('/api/tools')
|
||||
return response.items
|
||||
@@ -66,12 +73,14 @@ export function streamAgentEvents(
|
||||
onError?: (error: Error) => void
|
||||
onDone?: () => void
|
||||
onOpen?: () => void
|
||||
}
|
||||
},
|
||||
afterSequence = -1,
|
||||
): SseClient {
|
||||
// 将通用 SSE 包装成领域事件,Store 无需了解传输层 envelope。
|
||||
const client = new SseClient({
|
||||
url: `/api/agent/runs/${runId}/events`,
|
||||
url: `/api/agent/runs/${runId}/events?after_sequence=${afterSequence}`,
|
||||
method: 'GET',
|
||||
lastEventId: afterSequence >= 0 ? String(afterSequence) : undefined,
|
||||
onEvent: (eventName, data) => {
|
||||
handlers.onEvent?.({
|
||||
event: eventName as AgentEvent['event'],
|
||||
|
||||
@@ -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,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SseClient } from './sseClient'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('SseClient resumable event transport', () => {
|
||||
it('sends Last-Event-ID and exposes the returned SSE id', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
'id: 3\nevent: ModelCallCompleted\ndata: {"sequence":3,"data":{"duration_ms":12}}\n\n',
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const received = vi.fn()
|
||||
const client = new SseClient({
|
||||
url: '/api/agent/runs/run-1/events?after_sequence=2',
|
||||
method: 'GET',
|
||||
lastEventId: '2',
|
||||
onEvent: received,
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/agent/runs/run-1/events?after_sequence=2',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({ 'Last-Event-ID': '2' }),
|
||||
}),
|
||||
)
|
||||
expect(received).toHaveBeenCalledWith(
|
||||
'ModelCallCompleted',
|
||||
{ sequence: 3, data: { duration_ms: 12 } },
|
||||
'3',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,17 @@
|
||||
import { resolveApiUrl } from './apiClient'
|
||||
|
||||
export type SseEventHandler = (event: string, data: Record<string, unknown>) => void
|
||||
export type SseEventHandler = (
|
||||
event: string,
|
||||
data: Record<string, unknown>,
|
||||
eventId?: string,
|
||||
) => void
|
||||
|
||||
export interface SseClientOptions {
|
||||
url: string
|
||||
method?: string
|
||||
body?: unknown
|
||||
token?: string
|
||||
lastEventId?: string
|
||||
onEvent?: SseEventHandler
|
||||
onError?: (error: Error) => void
|
||||
onOpen?: () => void
|
||||
@@ -26,7 +31,7 @@ export class SseClient {
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const { url, method = 'POST', body, token, onEvent, onError, onOpen, onDone } = this.options
|
||||
const { url, method = 'POST', body, token, lastEventId, onEvent, onError, onOpen, onDone } = this.options
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -38,6 +43,9 @@ export class SseClient {
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
if (lastEventId !== undefined) {
|
||||
headers['Last-Event-ID'] = lastEventId
|
||||
}
|
||||
|
||||
const resp = await fetch(resolveApiUrl(url), {
|
||||
method,
|
||||
@@ -57,17 +65,19 @@ export class SseClient {
|
||||
// 一个 UTF-8 字符或 SSE 行可能横跨多个网络分片,必须累积后再按空行派发。
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let eventName = 'message'
|
||||
let eventId: string | undefined
|
||||
let dataLines: string[] = []
|
||||
let doneNotified = false
|
||||
|
||||
const dispatchEvent = () => {
|
||||
if (!dataLines.length) {
|
||||
eventName = 'message'
|
||||
eventId = undefined
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(dataLines.join('\n')) as Record<string, unknown>
|
||||
onEvent?.(eventName, data)
|
||||
onEvent?.(eventName, data, eventId)
|
||||
if (!doneNotified && ['Done', 'RunCompleted', 'RunFailed', 'RunCancelled'].includes(eventName)) {
|
||||
doneNotified = true
|
||||
onDone?.()
|
||||
@@ -76,6 +86,7 @@ export class SseClient {
|
||||
onError?.(error instanceof Error ? error : new Error('Malformed SSE data'))
|
||||
}
|
||||
eventName = 'message'
|
||||
eventId = undefined
|
||||
dataLines = []
|
||||
}
|
||||
|
||||
@@ -87,6 +98,7 @@ export class SseClient {
|
||||
let fieldValue = separator === -1 ? '' : line.slice(separator + 1)
|
||||
if (fieldValue.startsWith(' ')) fieldValue = fieldValue.slice(1)
|
||||
if (field === 'event') eventName = fieldValue
|
||||
if (field === 'id') eventId = fieldValue
|
||||
if (field === 'data') dataLines.push(fieldValue)
|
||||
}
|
||||
|
||||
@@ -118,7 +130,7 @@ export class SseClient {
|
||||
this.controller.abort()
|
||||
}
|
||||
|
||||
// TODO(streaming): Agent 事件持久化后,增加 Last-Event-ID 与指数退避重连。
|
||||
// TODO(streaming): 桌面网络策略确定后,在 Store 层增加有上限的指数退避重连。
|
||||
|
||||
isConnected() {
|
||||
return this.connected
|
||||
|
||||
@@ -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