Compare commits

...
Author SHA1 Message Date
admin e37ac7b0a4 fix(extension): 强化 MCP 参数与生产运行门禁 2026-09-01 21:25:05 +08:00
admin 574b113827 fix(extension): 完善 MCP 参数与运行安全边界 2026-09-01 16:06:23 +08:00
admin 1132a4cece fix(extension): 修复 MCP Host 资源与协议边界 2026-09-01 12:11:30 +08:00
admin aedb1c1267 docs(extension): 补充阶段C MCP开发说明 2026-09-01 11:32:47 +08:00
admin fc4b7b9495 feat(extension): 接入 stdio MCP Plugin Host 2026-09-01 11:32:05 +08:00
admin 0e8d4b7b9f fix(agent): 保留持久化Run完整内容 2026-09-01 10:05:58 +08:00
admin a5b709a46f docs: 重组文档目录并补充CI/CD细则 2026-09-01 09:55:40 +08:00
admin 49dbacb296 docs(agent): 记录Trace问题与修复方案 2026-09-01 00:49:30 +08:00
admin 3cb197aafe feat(agent): 持久化Trace并支持SSE恢复 2026-09-01 00:39:37 +08:00
admin 8da75d4420 feat(workspace): 接入真实Vault数据链路 2026-08-31 21:38:56 +08:00
admin 84077feb18 docs: 清理接口文档行尾格式 2026-08-31 20:27:23 +08:00
admin bb8091f2e6 docs(api): 规划第二阶段统一接口契约 2026-08-31 20:26:53 +08:00
admin 0b20bad0a8 docs: 更新第二阶段技术栈基线 2026-08-31 20:08:14 +08:00
admin 9559fda5f9 docx:添加第二阶段团队分工表
添加详细的第二阶段开发计划文档,包括:

- 阶段目标和总体分工安排
- 各成员具体职责和任务分配(范涵宇、杨星萱、吉海燕)
- 技术实现方案和架构设计
- 跨模块协作关系和接口定义
- 优先级划分(P0/P1/P2)和验收标准
- 项目演示Demo规划和完成定义
2026-08-31 19:53:44 +08:00
admin fb1da4d00a merge: 补充前后端代码注释与TODO约定 2026-08-30 23:03:33 +08:00
admin 2becaf0c65 docs: 建立代码注释与TODO维护约定 2026-08-30 23:00:11 +08:00
admin 49c856a69c chore(frontend): 补充状态与服务边界注释 2026-08-30 22:59:58 +08:00
admin 629a6bda9c chore(backend): 补充核心流程注释与待办 2026-08-30 22:59:45 +08:00
Kronecker 8e4f4e6d1a Merge pull request 'feat(frontend): 统一页面视觉并完善 GitHub 代码主题' (#6) from feat/frontend-visual-polish into main
Reviewed-on: #6
2026-08-30 22:39:15 +08:00
admin df044d7888 docs(frontend): 补充 Shiki 预览与选择器回归说明 2026-08-30 22:31:51 +08:00
admin 9bcb3bf3a6 fix(frontend): 修复 Shiki 主题选择器并添加真实预览 2026-08-30 22:31:21 +08:00
admin 206a8f5b0a docs(frontend): 记录 GitHub 代码主题配置 2026-08-30 20:42:00 +08:00
admin 3595021d80 feat(frontend): 添加 GitHub 代码块主题设置 2026-08-30 20:41:42 +08:00
admin 7803ae5110 chore(frontend): 将暂定品牌名统一为 NotesAgent 2026-08-30 20:25:51 +08:00
admin 668139706b fix(frontend): 提升 Markdown 表格与列表对比度 2026-08-30 20:23:22 +08:00
admin 7fa2e9c404 docs(frontend): 记录视觉优化与动效约束 2026-08-30 20:14:22 +08:00
admin ef03a8f745 feat(frontend): 统一页面视觉与轻量动效 2026-08-30 20:13:29 +08:00
admin 2c84a98474 docs: 添加第一阶段测试验证手册 2026-08-30 15:20:12 +08:00
Kronecker f75d486e00 Merge pull request 'docs: 同步当前工程实现与验证基线' (#5) from fix/frontend-review-findings into main
Reviewed-on: #5
2026-08-30 15:16:17 +08:00
93 changed files with 8456 additions and 763 deletions
+17 -19
View File
@@ -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 项测试、前端 14 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
当前回归基线为后端 92 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。
@@ -126,19 +126,16 @@ pnpm test
| 文档 | 用途 |
| --- | --- |
| [技术栈说明](docs/AI笔记软件技术栈说明-团队版-v2.2.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 |
| [Git 使用细则](docs/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
| [后端审阅复盘](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 |
| [MCP Bridge 与 Plugin Host](docs/development/MCP-Bridge与Plugin-Host开发说明.md) | stdio MCP、隔离进程、Tool 映射、状态与错误边界 |
| [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
| [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 |
| [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 |
## 日常开发注意事项
@@ -148,6 +145,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
View File
@@ -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>` 注入;不要把真实密钥写入仓库。
当前基线为 92 项测试通过。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`
+11
View File
@@ -1,3 +1,5 @@
"""Agent 工具权限策略与一次性确认票据。"""
import asyncio
from dataclasses import dataclass
from enum import Enum
@@ -51,6 +53,7 @@ class PermissionPolicy:
def mode_for(self, permission: str | None) -> PermissionMode:
if permission is None:
return PermissionMode.allow
# 未登记权限一律拒绝,防止扩展通过拼写错误或新权限绕过策略。
return self._rules.get(permission, PermissionMode.deny)
@@ -63,6 +66,8 @@ class PermissionTicket:
class PermissionManager:
"""管理当前进程内的确认请求与会话级授权。"""
def __init__(self, policy: PermissionPolicy) -> None:
self.policy = policy
self._pending: dict[tuple[str, str], PermissionTicket] = {}
@@ -94,10 +99,16 @@ class PermissionManager:
if ticket is None or ticket.future.done():
return False
if decision == "allow_session":
# 会话授权只存在于进程内,应用重启后按默认策略重新确认。
self._session_grants.add(ticket.permission)
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:
+195 -33
View File
@@ -1,3 +1,5 @@
"""Agent 运行时:负责模型轮次、工具调用、权限确认与事件发布。"""
from __future__ import annotations
import asyncio
@@ -5,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,
@@ -50,6 +55,8 @@ MAX_TOOL_CALLS_PER_TURN = 50
@dataclass(slots=True)
class RunRecord:
"""单次运行的可变上下文,仅由 AgentRuntime 持有。"""
run: AgentRun
request: AgentRunCreateRequest
skill_config: AgentConfiguration | None = None
@@ -57,20 +64,25 @@ 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:
"""进程内 Agent 编排器;对外返回深拷贝,避免调用方修改运行状态。"""
def __init__(
self,
providers: ProviderRegistry,
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:
@@ -98,6 +110,7 @@ class AgentRuntime:
)
allowed_tools = list(request.allowed_tools)
if skill_config is not None:
# 同时指定 Skill 与工具白名单时取交集,避免 Skill 扩大调用权限。
allowed_tools = (
[name for name in skill_config.allowed_tools if name in allowed_tools]
if allowed_tools
@@ -109,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
@@ -137,21 +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)
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,
@@ -163,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)
@@ -171,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):
@@ -201,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(
@@ -243,11 +353,12 @@ class AgentRuntime:
messages.append(
Message(role=MessageRole.assistant, content=turn.text or "", tool_calls=calls)
)
# 工具可以并发执行,但结果按模型原始调用顺序写回上下文,保证轮次可复现。
semaphore = asyncio.Semaphore(record.request.max_concurrent_tools)
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):
@@ -280,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:
@@ -295,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
@@ -307,12 +425,15 @@ 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:
result = self._permission_denied(call)
elif mode == PermissionMode.confirm and permission:
# 运行状态必须在等待期间可见,前端才能展示并处理权限确认卡片。
ticket = self.permissions.create_ticket(record.run.run_id, permission)
record.run.status = AgentRunStatus.waiting_permission
self._publish(
@@ -337,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"}
@@ -350,13 +473,31 @@ 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(
self.tools.execute(call, ToolExecutionContext(run_id=record.run.run_id)),
self.tools.execute(
call,
ToolExecutionContext(
run_id=record.run.run_id,
tool_call_id=call.tool_call_id,
),
),
timeout=record.request.tool_timeout_seconds,
)
except TimeoutError:
@@ -400,14 +541,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:
@@ -421,6 +567,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
@@ -448,6 +609,7 @@ class AgentRuntime:
raise AgentRunNotFoundError(run_id) from exc
def _prune_records(self) -> None:
# 只清理终态记录,绝不为了容量取消仍在执行或等待授权的任务。
overflow = len(self._records) - MAX_RUN_RECORDS + 1
if overflow <= 0:
return
+50 -19
View File
@@ -1,4 +1,7 @@
"""Agent 工具注册与执行边界。"""
import inspect
import threading
from dataclasses import dataclass
from time import perf_counter
from typing import Any, Awaitable, Callable
@@ -15,6 +18,7 @@ ToolExecutor = Callable[[BaseModel, "ToolExecutionContext"], Any | Awaitable[Any
@dataclass(frozen=True, slots=True)
class ToolExecutionContext:
run_id: str
tool_call_id: str | None = None
@dataclass(slots=True)
@@ -28,9 +32,21 @@ class ToolNotFoundError(LookupError):
pass
class ToolExecutionError(RuntimeError):
"""Executor 可预期失败,保留领域错误码而不是折叠成通用异常。"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
class ToolRegistry:
"""统一校验工具入参并隔离执行异常,避免单个工具击穿 Agent 主循环。"""
def __init__(self) -> None:
self._tools: dict[str, RegisteredTool] = {}
self._lock = threading.RLock()
def register(
self,
@@ -38,33 +54,38 @@ class ToolRegistry:
arguments_model: type[BaseModel],
executor: ToolExecutor,
) -> None:
if definition.name in self._tools:
raise ValueError(f"Tool already registered: {definition.name}")
self._tools[definition.name] = RegisteredTool(
definition=definition,
arguments_model=arguments_model,
executor=executor,
)
with self._lock:
if definition.name in self._tools:
raise ValueError(f"Tool already registered: {definition.name}")
self._tools[definition.name] = RegisteredTool(
definition=definition,
arguments_model=arguments_model,
executor=executor,
)
def unregister(self, name: str) -> None:
self._tools.pop(name, None)
with self._lock:
self._tools.pop(name, None)
def contains(self, name: str) -> bool:
return name in self._tools
with self._lock:
return name in self._tools
def get(self, name: str) -> RegisteredTool:
try:
return self._tools[name]
except KeyError as exc:
raise ToolNotFoundError(name) from exc
with self._lock:
try:
return self._tools[name]
except KeyError as exc:
raise ToolNotFoundError(name) from exc
def definitions(self, allowed: list[str] | None = None) -> list[ToolDefinition]:
names = set(allowed) if allowed is not None else None
return [
item.definition.model_copy(deep=True)
for name, item in self._tools.items()
if names is None or name in names
]
with self._lock:
return [
item.definition.model_copy(deep=True)
for name, item in self._tools.items()
if names is None or name in names
]
async def execute(self, call: ToolCall, context: ToolExecutionContext) -> ToolResult:
started = perf_counter()
@@ -80,6 +101,7 @@ class ToolRegistry:
)
try:
# JSON Schema 约束模型可见的协议,Pydantic 再完成运行时类型转换。
Draft202012Validator(registered.definition.parameters).validate(call.arguments)
arguments = registered.arguments_model.model_validate(call.arguments)
except (ValidationError, JsonSchemaValidationError) as exc:
@@ -103,7 +125,16 @@ class ToolRegistry:
output=output,
duration_ms=round((perf_counter() - started) * 1000),
)
except Exception as exc: # Tool failures are isolated from the Agent loop.
except ToolExecutionError as exc:
return ToolResult(
tool_call_id=call.tool_call_id,
name=call.name,
success=False,
error_code=exc.code,
error_message=exc.message,
duration_ms=round((perf_counter() - started) * 1000),
)
except Exception as exc: # 工具失败转换成结构化结果,由模型决定是否降级或重试。
return ToolResult(
tool_call_id=call.tool_call_id,
name=call.name,
+372
View File
@@ -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,
)
+8 -2
View File
@@ -3,7 +3,7 @@ from dataclasses import dataclass
from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry
from app.agent.builtin_tools import register_builtin_tools
from app.contracts import ModelCapability, ProviderConfig, ProviderType
from app.config import BACKEND_DIR
from app.config import BACKEND_DIR, get_settings
from app.extensions import PluginRuntime, SkillRuntime
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
from app.providers.credentials import (
@@ -26,6 +26,7 @@ class ApplicationContainer:
def build_container() -> ApplicationContainer:
settings = get_settings()
credentials = EncryptedCredentialStore()
provider_factory = ProviderFactory(
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
@@ -50,7 +51,12 @@ def build_container() -> ApplicationContainer:
tools = ToolRegistry()
register_builtin_tools(tools)
plugins = PluginRuntime(tools)
plugins = PluginRuntime(
tools,
# 当前 Python Host 尚无 OS 沙箱。生产构建必须保持关闭,直到
# Tauri/Rust Host 能签发绑定命令摘要的可信启动许可。
allow_unsandboxed_mcp=settings.environment == "development",
)
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
plugins.enable("text-tools")
+94
View File
@@ -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"]
@@ -349,6 +417,10 @@ class ExtensionInstallRequest(Contract):
class PluginBackend(Contract):
type: Literal["mcp", "internal_rpc", "none"] = "none"
transport: Literal["stdio", "http", "none"] = "none"
command: str | None = None
args: list[str] = Field(default_factory=list)
startup_timeout_seconds: int = Field(default=10, ge=1, le=60)
tool_timeout_seconds: int = Field(default=30, ge=1, le=600)
class PluginContribution(Contract):
@@ -392,6 +464,28 @@ class PluginListResponse(Contract):
items: list[Plugin] = Field(default_factory=list)
class PluginHostState(str, Enum):
stopped = "stopped"
starting = "starting"
ready = "ready"
unhealthy = "unhealthy"
error = "error"
class PluginHostStatus(Contract):
plugin_id: str
backend_type: Literal["mcp", "internal_rpc", "none"]
transport: Literal["stdio", "http", "none"]
status: PluginHostState
tools_count: int = 0
started_at: datetime | None = None
last_seen_at: datetime | None = None
protocol_version: str | None = None
server_name: str | None = None
server_version: str | None = None
error: str | None = None
class PluginPermissionGrantRequest(Contract):
permissions: list[str] = Field(default_factory=list)
+27
View File
@@ -69,6 +69,33 @@ MIGRATIONS: list[str] = [
);
CREATE INDEX IF NOT EXISTS idx_tasks_status_due ON tasks(status, due_at);
""",
# v3: 第二阶段 Agent TraceRun 与事件事实持久化,供 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);
""",
]
+9 -1
View File
@@ -4,5 +4,13 @@ from app.extensions.runtime import (
PluginRuntime,
SkillRuntime,
)
from app.extensions.mcp import McpBridge, McpBridgeError
__all__ = ["AgentConfiguration", "ExtensionError", "PluginRuntime", "SkillRuntime"]
__all__ = [
"AgentConfiguration",
"ExtensionError",
"McpBridge",
"McpBridgeError",
"PluginRuntime",
"SkillRuntime",
]
+785
View File
@@ -0,0 +1,785 @@
"""本地 stdio MCP Bridge。
第三方 Server 始终运行在子进程中。Bridge 只把通过校验的 MCP Tool 转换为项目内部
ToolDefinition/ToolResult,不把 MCP 原始协议泄露给 Agent Runtime 或前端。
"""
from __future__ import annotations
import asyncio
import json
import os
import queue
import subprocess
import threading
from collections import deque
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from app.agent.permissions import KNOWN_PERMISSIONS
from app.agent.tools import ToolExecutionError
from app.contracts import (
PluginBackend,
PluginHostState,
PluginHostStatus,
ToolDefinition,
)
MCP_PROTOCOL_VERSION = "2025-11-25"
SUPPORTED_PROTOCOL_VERSIONS = {
MCP_PROTOCOL_VERSION,
"2025-06-18",
"2025-03-26",
"2024-11-05",
}
MAX_MCP_MESSAGE_BYTES = 2 * 1024 * 1024
MAX_MCP_TOOL_RESULT_BYTES = 256 * 1024
MAX_MCP_TOOLS = 500
MAX_MCP_LIST_PAGES = 100
class McpBridgeError(RuntimeError):
def __init__(self, code: str, message: str, *, status_code: int = 502) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
@dataclass(frozen=True, slots=True)
class McpDiscoveredTool:
remote_name: str
definition: ToolDefinition
@dataclass(slots=True)
class _PendingRequest:
response: queue.Queue[dict[str, Any] | BaseException]
class McpStdioClient:
"""线程驱动的换行分隔 JSON-RPC 客户端,避免阻塞 FastAPI 事件循环。"""
def __init__(
self,
command: list[str],
*,
cwd: Path,
on_seen: Callable[[], None],
on_broken: Callable[[str], None],
on_tools_changed: Callable[[], None],
) -> None:
self.command = command
self.cwd = cwd
self.on_seen = on_seen
self.on_broken = on_broken
self.on_tools_changed = on_tools_changed
self.process: subprocess.Popen[str] | None = None
self._write_lock = threading.Lock()
self._pending_lock = threading.Lock()
self._pending: dict[int, _PendingRequest] = {}
self._next_id = 1
self._stopping = False
# stderr 只在 Host 内部保留有限尾部,不进入 API、Trace 或普通日志。
self._stderr_tail: deque[str] = deque(maxlen=50)
def start(self) -> None:
if self.process is not None and self.process.poll() is None:
return
# TODO(extension-security): 社区 Plugin 开放前迁移到 Tauri/Rust Host 的
# 平台级沙箱启动器;uvx 只隔离 Python 依赖,不能替代系统权限限制。
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
environment = _subprocess_environment()
environment.setdefault("PYTHONUNBUFFERED", "1")
try:
self.process = subprocess.Popen(
self.command,
cwd=self.cwd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
shell=False,
env=environment,
creationflags=creation_flags,
)
except OSError as exc:
raise McpBridgeError(
"PLUGIN_HOST_START_FAILED",
f"Cannot start MCP server process: {exc}",
status_code=503,
) from exc
threading.Thread(target=self._stdout_loop, daemon=True).start()
threading.Thread(target=self._stderr_loop, daemon=True).start()
def request(
self,
method: str,
params: dict[str, Any],
*,
timeout: float,
timeout_code: str,
response_error_code: str = "MCP_TOOL_CALL_FAILED",
) -> dict[str, Any]:
request_id, pending = self.begin_request(method, params)
return self.wait_response(
request_id,
pending,
timeout=timeout,
timeout_code=timeout_code,
response_error_code=response_error_code,
)
def begin_request(
self, method: str, params: dict[str, Any]
) -> tuple[int, _PendingRequest]:
self._ensure_running()
with self._pending_lock:
request_id = self._next_id
self._next_id += 1
pending = _PendingRequest(response=queue.Queue(maxsize=1))
self._pending[request_id] = pending
try:
self._send(
{
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params,
}
)
except BaseException:
with self._pending_lock:
self._pending.pop(request_id, None)
raise
return request_id, pending
def wait_response(
self,
request_id: int,
pending: _PendingRequest,
*,
timeout: float,
timeout_code: str,
response_error_code: str = "MCP_TOOL_CALL_FAILED",
) -> dict[str, Any]:
try:
response = pending.response.get(timeout=timeout)
except queue.Empty as exc:
self.cancel(request_id, "Request timed out.")
self.abandon(request_id)
raise McpBridgeError(timeout_code, "MCP request timed out.", status_code=504) from exc
if isinstance(response, BaseException):
raise response
if "error" in response:
error = response.get("error")
message = (
str(error.get("message", "MCP JSON-RPC error."))
if isinstance(error, dict)
else "MCP JSON-RPC error."
)
raise McpBridgeError(response_error_code, message)
result = response.get("result")
if not isinstance(result, dict):
raise McpBridgeError(
response_error_code, "MCP response result must be an object."
)
return result
def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
if params is not None:
payload["params"] = params
self._send(payload)
def cancel(self, request_id: int, reason: str = "Cancelled by host.") -> None:
try:
self.notify(
"notifications/cancelled",
{"requestId": request_id, "reason": reason},
)
except McpBridgeError:
pass
def abandon(
self, request_id: int, wake_error: BaseException | None = None
) -> None:
with self._pending_lock:
pending = self._pending.pop(request_id, None)
# asyncio.to_thread 被取消时不会停止底层线程;主动唤醒 Queue,避免线程
# 一直占用默认线程池直至远端超时。
if pending is not None and wake_error is not None:
try:
pending.response.put_nowait(wake_error)
except queue.Full:
pass
def stop(self) -> None:
process = self.process
if process is None:
return
self._stopping = True
try:
if process.stdin:
try:
process.stdin.close()
except (BrokenPipeError, OSError, ValueError):
pass
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
finally:
self._fail_pending(
McpBridgeError("PLUGIN_HOST_UNAVAILABLE", "MCP host stopped.", status_code=503)
)
self.process = None
def _send(self, message: dict[str, Any]) -> None:
self._ensure_running()
encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":"))
if len(encoded.encode("utf-8")) > MAX_MCP_MESSAGE_BYTES:
raise McpBridgeError("MCP_TOOL_CALL_FAILED", "MCP request is too large.")
process = self.process
assert process is not None and process.stdin is not None
try:
with self._write_lock:
process.stdin.write(encoded + "\n")
process.stdin.flush()
except (BrokenPipeError, OSError, ValueError) as exc:
raise McpBridgeError(
"PLUGIN_HOST_UNAVAILABLE", "MCP host input is closed.", status_code=503
) from exc
def _stdout_loop(self) -> None:
process = self.process
assert process is not None and process.stdout is not None
failure: str | None = None
try:
while True:
# readline(size) 在换行缺失时仍有硬上限,不能先把任意大的
# 第三方 stdout 行完整读入宿主内存再检查。
raw_line = process.stdout.readline(MAX_MCP_MESSAGE_BYTES + 1)
if raw_line == "":
break
if not raw_line.endswith("\n"):
failure = "MCP server emitted an oversized or unterminated message."
break
if len(raw_line.encode("utf-8")) > MAX_MCP_MESSAGE_BYTES:
failure = "MCP server emitted an oversized protocol message."
break
try:
message = json.loads(raw_line)
except json.JSONDecodeError:
failure = "MCP server emitted invalid JSON on stdout."
break
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
failure = "MCP server emitted an invalid JSON-RPC message."
break
self.on_seen()
if "id" in message and ("result" in message or "error" in message):
request_id = message.get("id")
if isinstance(request_id, int):
with self._pending_lock:
pending = self._pending.pop(request_id, None)
if pending:
pending.response.put(message)
continue
method = message.get("method")
if method == "notifications/tools/list_changed":
self.on_tools_changed()
elif isinstance(method, str) and "id" in message:
self._send(
{
"jsonrpc": "2.0",
"id": message["id"],
"error": {"code": -32601, "message": "Method not supported."},
}
)
except (McpBridgeError, OSError, ValueError) as exc:
failure = f"MCP stdout closed unexpectedly: {type(exc).__name__}."
finally:
if failure and process.poll() is None:
process.terminate()
exit_code = process.poll()
if exit_code is None:
try:
exit_code = process.wait(timeout=1)
except subprocess.TimeoutExpired:
exit_code = None
if not self._stopping:
message = failure or f"MCP host exited unexpectedly with code {exit_code}."
error = McpBridgeError(
"PLUGIN_HOST_UNAVAILABLE", message, status_code=503
)
self._fail_pending(error)
self.on_broken(message)
def _stderr_loop(self) -> None:
process = self.process
assert process is not None and process.stderr is not None
try:
while True:
# stderr 不是协议通道,但同样按块读取,避免无换行日志造成
# 宿主侧的无界字符串分配。
line = process.stderr.readline(1025)
if line == "":
break
self._stderr_tail.append(line.rstrip()[:1024])
except (OSError, ValueError):
return
def _ensure_running(self) -> None:
if self.process is None or self.process.poll() is not None:
raise McpBridgeError(
"PLUGIN_HOST_UNAVAILABLE", "MCP host is not running.", status_code=503
)
def _fail_pending(self, error: BaseException) -> None:
with self._pending_lock:
pending = list(self._pending.values())
self._pending.clear()
for item in pending:
item.response.put(error)
@dataclass(slots=True)
class _McpHost:
backend: PluginBackend
client: McpStdioClient
status: PluginHostStatus
class McpBridge:
"""管理每个 Plugin 的独立 MCP Client,并执行 Contract 转换。"""
def __init__(self) -> None:
self._hosts: dict[str, _McpHost] = {}
self._statuses: dict[str, PluginHostStatus] = {}
self._calls: dict[tuple[str, str], int] = {}
self._lock = threading.RLock()
def start(
self,
plugin_id: str,
backend: PluginBackend,
package_path: Path,
declared_permissions: list[str],
on_unavailable: Callable[[str, str], None],
) -> list[McpDiscoveredTool]:
if backend.transport != "stdio":
raise McpBridgeError(
"MCP_CAPABILITY_UNSUPPORTED",
"Phase C only supports the MCP stdio transport.",
status_code=501,
)
command = self._resolve_command(package_path, backend)
now = datetime.now(timezone.utc)
status = PluginHostStatus(
plugin_id=plugin_id,
backend_type="mcp",
transport="stdio",
status=PluginHostState.starting,
started_at=now,
last_seen_at=now,
)
host_ref: dict[str, _McpHost] = {}
def seen() -> None:
host = host_ref.get("host")
if host:
host.status.last_seen_at = datetime.now(timezone.utc)
def broken(message: str) -> None:
host = host_ref.get("host")
if host:
host.status.status = PluginHostState.unhealthy
host.status.error = message
on_unavailable(plugin_id, message)
def tools_changed() -> None:
broken("MCP tool list changed; restart the Plugin Host to revalidate tools.")
client = McpStdioClient(
command,
cwd=package_path,
on_seen=seen,
on_broken=broken,
on_tools_changed=tools_changed,
)
host = _McpHost(backend=backend, client=client, status=status)
host_ref["host"] = host
with self._lock:
if plugin_id in self._hosts:
raise McpBridgeError(
"PLUGIN_HOST_START_FAILED",
f"MCP host is already running: {plugin_id}",
status_code=409,
)
self._hosts[plugin_id] = host
self._statuses[plugin_id] = status
try:
client.start()
initialize = client.request(
"initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "NotesAgent", "version": "0.1.0"},
},
timeout=backend.startup_timeout_seconds,
timeout_code="MCP_INITIALIZE_FAILED",
response_error_code="MCP_INITIALIZE_FAILED",
)
version = initialize.get("protocolVersion")
if version not in SUPPORTED_PROTOCOL_VERSIONS:
raise McpBridgeError(
"MCP_INITIALIZE_FAILED",
f"Unsupported MCP protocol version: {version}",
)
capabilities = initialize.get("capabilities")
if not isinstance(capabilities, dict) or not isinstance(
capabilities.get("tools"), dict
):
raise McpBridgeError(
"MCP_CAPABILITY_UNSUPPORTED",
"MCP server does not declare the tools capability.",
)
server_info = initialize.get("serverInfo")
if not isinstance(server_info, dict):
server_info = {}
status.protocol_version = str(version)
status.server_name = _optional_string(server_info.get("name"))
status.server_version = _optional_string(server_info.get("version"))
client.notify("notifications/initialized")
discovered = self._discover_tools(
plugin_id, client, backend, declared_permissions
)
status.status = PluginHostState.ready
status.tools_count = len(discovered)
status.last_seen_at = datetime.now(timezone.utc)
status.error = None
return discovered
except McpBridgeError as exc:
status.status = PluginHostState.error
status.error = exc.message
client.stop()
with self._lock:
self._hosts.pop(plugin_id, None)
raise
except Exception as exc:
status.status = PluginHostState.error
status.error = f"MCP initialization failed: {type(exc).__name__}."
client.stop()
with self._lock:
self._hosts.pop(plugin_id, None)
raise McpBridgeError("MCP_INITIALIZE_FAILED", status.error) from exc
async def call_tool(
self,
plugin_id: str,
remote_name: str,
arguments: dict[str, Any],
*,
request_id: str,
) -> Any:
host = self._host(plugin_id)
rpc_id, pending = host.client.begin_request(
"tools/call", {"name": remote_name, "arguments": arguments}
)
call_key = (plugin_id, request_id)
with self._lock:
self._calls[call_key] = rpc_id
try:
result = await asyncio.to_thread(
host.client.wait_response,
rpc_id,
pending,
timeout=host.backend.tool_timeout_seconds,
timeout_code="MCP_TOOL_CALL_FAILED",
)
except asyncio.CancelledError:
host.client.cancel(rpc_id)
host.client.abandon(
rpc_id,
McpBridgeError(
"MCP_TOOL_CALL_FAILED", "MCP request was cancelled."
),
)
raise
except McpBridgeError as exc:
raise ToolExecutionError(exc.code, exc.message) from exc
finally:
with self._lock:
self._calls.pop(call_key, None)
encoded_size = len(
json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
)
if encoded_size > MAX_MCP_TOOL_RESULT_BYTES:
raise ToolExecutionError(
"MCP_TOOL_RESULT_TOO_LARGE",
"MCP tool result exceeds the configured size limit.",
)
if result.get("isError") is True:
raise ToolExecutionError(
"MCP_TOOL_CALL_FAILED", _mcp_error_message(result.get("content"))
)
structured = result.get("structuredContent")
if structured is not None:
if not isinstance(structured, dict):
raise ToolExecutionError(
"MCP_TOOL_CALL_FAILED",
"MCP structuredContent must be an object.",
)
return structured
content = result.get("content", [])
if not isinstance(content, list):
raise ToolExecutionError(
"MCP_TOOL_CALL_FAILED", "MCP tool content must be an array."
)
return {"content": content}
def cancel(self, plugin_id: str, request_id: str) -> None:
with self._lock:
rpc_id = self._calls.get((plugin_id, request_id))
host = self._hosts.get(plugin_id)
if rpc_id is not None and host is not None:
host.client.cancel(rpc_id)
def stop(self, plugin_id: str) -> None:
with self._lock:
host = self._hosts.pop(plugin_id, None)
if host:
host.client.stop()
host.status.status = PluginHostState.stopped
host.status.tools_count = 0
host.status.error = None
def remove(self, plugin_id: str) -> None:
"""停止 Host,并清除卸载后不应跨安装保留的状态与调用索引。"""
self.stop(plugin_id)
with self._lock:
self._statuses.pop(plugin_id, None)
stale_calls = [key for key in self._calls if key[0] == plugin_id]
for key in stale_calls:
self._calls.pop(key, None)
def status(self, plugin_id: str, backend: PluginBackend) -> PluginHostStatus:
with self._lock:
status = self._statuses.get(plugin_id)
if status:
return status.model_copy(deep=True)
return PluginHostStatus(
plugin_id=plugin_id,
backend_type=backend.type,
transport=backend.transport,
status=PluginHostState.stopped,
)
def _discover_tools(
self,
plugin_id: str,
client: McpStdioClient,
backend: PluginBackend,
declared_permissions: list[str],
) -> list[McpDiscoveredTool]:
discovered: list[McpDiscoveredTool] = []
cursor: str | None = None
for _ in range(MAX_MCP_LIST_PAGES):
params = {"cursor": cursor} if cursor else {}
result = client.request(
"tools/list",
params,
timeout=backend.startup_timeout_seconds,
timeout_code="MCP_INITIALIZE_FAILED",
response_error_code="MCP_INITIALIZE_FAILED",
)
raw_tools = result.get("tools")
if not isinstance(raw_tools, list):
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP tools/list must return a tools array."
)
for raw in raw_tools:
discovered.append(
self._map_tool(plugin_id, raw, declared_permissions)
)
if len(discovered) > MAX_MCP_TOOLS:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"MCP server exposes more than {MAX_MCP_TOOLS} tools.",
)
next_cursor = result.get("nextCursor")
if next_cursor is None:
break
if not isinstance(next_cursor, str) or not next_cursor:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP nextCursor must be a non-empty string."
)
cursor = next_cursor
else:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP tools/list exceeded the page limit."
)
names = [item.definition.name for item in discovered]
if len(names) != len(set(names)):
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP server returned duplicate tool names."
)
return discovered
@staticmethod
def _map_tool(
plugin_id: str, raw: Any, declared_permissions: list[str]
) -> McpDiscoveredTool:
if not isinstance(raw, dict):
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP tool definition must be an object."
)
remote_name = raw.get("name")
if not isinstance(remote_name, str) or not remote_name:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP tool name must be a non-empty string."
)
if (
len(remote_name) > 128
or not remote_name[0].isalnum()
or not all(
character.islower()
or character.isdigit()
or character in "._-"
for character in remote_name
)
):
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"MCP tool name is not a valid NotesAgent id: {remote_name}",
)
schema = raw.get("inputSchema", {"type": "object", "properties": {}})
if not isinstance(schema, dict) or schema.get("type", "object") != "object":
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"MCP tool inputSchema must be an object schema: {remote_name}",
)
try:
Draft202012Validator.check_schema(schema)
except SchemaError as exc:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"Invalid MCP tool schema for {remote_name}: {exc.message}",
) from exc
metadata = raw.get("_meta")
permission = (
metadata.get("notesagent/permission") if isinstance(metadata, dict) else None
)
if permission is not None and (
not isinstance(permission, str) or permission not in KNOWN_PERMISSIONS
):
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"MCP tool declares an unknown permission: {remote_name}",
)
if permission and permission not in declared_permissions:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"MCP tool permission is missing from Plugin manifest: {permission}",
)
description = raw.get("description")
return McpDiscoveredTool(
remote_name=remote_name,
definition=ToolDefinition(
name=f"{plugin_id}.{remote_name}",
description=description if isinstance(description, str) else remote_name,
parameters=schema,
permission=permission,
source="plugin",
),
)
def _host(self, plugin_id: str) -> _McpHost:
with self._lock:
host = self._hosts.get(plugin_id)
if host is None or host.status.status != PluginHostState.ready:
raise ToolExecutionError(
"PLUGIN_HOST_UNAVAILABLE", f"MCP Plugin Host is not ready: {plugin_id}"
)
return host
@staticmethod
def _resolve_command(root: Path, backend: PluginBackend) -> list[str]:
if not backend.command or not backend.command.strip():
raise McpBridgeError(
"PLUGIN_HOST_START_FAILED", "MCP stdio backend requires a command."
)
command = backend.command.strip()
if Path(command).is_absolute() or "/" in command or "\\" in command:
executable = (
(root / command).resolve()
if not Path(command).is_absolute()
else Path(command).resolve()
)
try:
executable.relative_to(root)
except ValueError as exc:
raise McpBridgeError(
"PLUGIN_HOST_START_FAILED",
"MCP executable path must stay inside the Plugin package.",
) from exc
command = str(executable)
return [command, *backend.args]
def _mcp_error_message(content: Any) -> str:
if isinstance(content, list):
texts = [
item.get("text")
for item in content
if isinstance(item, dict)
and item.get("type") == "text"
and isinstance(item.get("text"), str)
]
if texts:
return "\n".join(texts)[:4096]
return "MCP tool returned an error result."
def _optional_string(value: Any) -> str | None:
return value if isinstance(value, str) else None
def _subprocess_environment() -> dict[str, str]:
"""只传递启动进程所需的系统变量,隔离 Provider Key、Vault 路径等宿主状态。"""
allowed = {
"PATH",
"PATHEXT",
"SYSTEMROOT",
"WINDIR",
"COMSPEC",
"TEMP",
"TMP",
"TMPDIR",
"LANG",
"LC_ALL",
"VIRTUAL_ENV",
}
environment = {
key: value for key, value in os.environ.items() if key.upper() in allowed
}
environment["PYTHONUNBUFFERED"] = "1"
environment["PYTHONIOENCODING"] = "utf-8"
return environment
+254 -57
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import re
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
@@ -16,6 +17,7 @@ from app.contracts import (
ModelCapability,
Plugin,
PluginManifest,
PluginHostStatus,
PluginStatus,
RetrievalConfig,
Skill,
@@ -23,6 +25,7 @@ from app.contracts import (
SkillStatus,
ToolDefinition,
)
from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool
_EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
@@ -67,6 +70,7 @@ class SkillRuntime:
self._records: dict[str, _SkillRecord] = {}
def install(self, package_path: str | Path) -> Skill:
# TODO(extension): 将安装记录持久化,应用重启后从可信包目录恢复状态。
root = _package_dir(package_path)
raw = _read_yaml(root / "skill.yaml")
if "id" in raw and "skill_id" not in raw:
@@ -241,17 +245,29 @@ class _PluginRecord:
tools: list[DeclarativeToolSpec]
package_path: Path
registered_tools: list[str]
mcp_remote_names: dict[str, str]
class PluginRuntime:
"""Plugin Manifest、生命周期及 Tool Contribution 注册。"""
def __init__(self, tools: ToolRegistry, host: DeclarativePluginHost | None = None) -> None:
def __init__(
self,
tools: ToolRegistry,
host: DeclarativePluginHost | None = None,
mcp_bridge: McpBridge | None = None,
*,
allow_unsandboxed_mcp: bool = False,
) -> None:
self.registry = tools
self.host = host or DeclarativePluginHost()
self.mcp = mcp_bridge or McpBridge()
self.allow_unsandboxed_mcp = allow_unsandboxed_mcp
self._records: dict[str, _PluginRecord] = {}
self._lock = threading.RLock()
def install(self, package_path: str | Path) -> Plugin:
# 安装阶段只读取清单;MCP 子进程必须在权限授予后的 enable 阶段启动。
root = _package_dir(package_path)
raw = _read_yaml(root / "plugin.yaml")
if "id" in raw and "plugin_id" not in raw:
@@ -269,15 +285,17 @@ class PluginRuntime:
status_code=409,
)
specs = self._load_tools(root)
declared = set(manifest.contributes.tools)
actual = {spec.name for spec in specs}
if declared != actual:
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"plugin.yaml tool contributions must exactly match tools.yaml",
details={"declared": sorted(declared), "actual": sorted(actual)},
)
_validate_backend(manifest)
specs = [] if manifest.backend.type == "mcp" else self._load_tools(root)
if manifest.backend.type != "mcp":
declared = set(manifest.contributes.tools)
actual = {spec.name for spec in specs}
if declared != actual:
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"plugin.yaml tool contributions must exactly match tools.yaml",
details={"declared": sorted(declared), "actual": sorted(actual)},
)
for spec in specs:
_validate_id("tool", spec.name)
_validate_tool_schema(spec)
@@ -300,6 +318,7 @@ class PluginRuntime:
tools=specs,
package_path=root,
registered_tools=[],
mcp_remote_names={},
)
self._records[manifest.plugin_id] = record
return record.plugin.model_copy(deep=True)
@@ -311,17 +330,14 @@ class PluginRuntime:
return self._record(plugin_id).plugin.model_copy(deep=True)
def enable(self, plugin_id: str) -> Plugin:
# Host 启动和 Tool 批量注册必须串行,避免并发 enable 产生重复进程或半注册状态。
with self._lock:
return self._enable(plugin_id)
def _enable(self, plugin_id: str) -> Plugin:
record = self._record(plugin_id)
if record.plugin.enabled:
return record.plugin.model_copy(deep=True)
if record.plugin.manifest.backend.type == "mcp":
record.plugin.status = PluginStatus.dependency_missing
raise ExtensionError(
"PLUGIN_HOST_UNAVAILABLE",
"MCP Plugin Host is reserved for the second development phase.",
status_code=501,
details={"plugin_id": plugin_id, "backend": "mcp"},
)
missing_grants = sorted(
set(record.plugin.manifest.permissions) - set(record.plugin.granted_permissions)
)
@@ -333,7 +349,18 @@ class PluginRuntime:
status_code=409,
details={"plugin_id": plugin_id, "permissions": missing_grants},
)
conflicts = [spec.name for spec in record.tools if self.registry.contains(spec.name)]
if (
record.plugin.manifest.backend.type == "mcp"
and not self.allow_unsandboxed_mcp
):
raise ExtensionError(
"MCP_TRUST_APPROVAL_REQUIRED",
"Unsandboxed MCP Hosts are disabled outside development mode.",
status_code=403,
details={"plugin_id": plugin_id},
)
declared_tools = list(record.plugin.manifest.contributes.tools)
conflicts = [name for name in declared_tools if self.registry.contains(name)]
if conflicts:
raise ExtensionError(
"PLUGIN_TOOL_CONFLICT",
@@ -343,41 +370,75 @@ class PluginRuntime:
)
record.plugin.status = PluginStatus.starting
try:
for spec in record.tools:
arguments_model = _arguments_model(spec)
if record.plugin.manifest.backend.type == "mcp":
discovered = self._start_mcp(record)
actual = {item.definition.name for item in discovered}
declared = set(declared_tools)
if actual != declared:
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"Discovered MCP tools must exactly match Plugin contributions.",
details={"declared": sorted(declared), "actual": sorted(actual)},
)
for item in discovered:
self._register_mcp_tool(record, item)
else:
for spec in record.tools:
arguments_model = _arguments_model(spec)
async def executor(
arguments: BaseModel,
context: ToolExecutionContext,
_handler: str = spec.handler,
) -> Any:
return await self.host.execute(_handler, arguments, context)
async def executor(
arguments: BaseModel,
context: ToolExecutionContext,
_handler: str = spec.handler,
) -> Any:
return await self.host.execute(_handler, arguments, context)
self.registry.register(
ToolDefinition(
name=spec.name,
description=spec.description,
parameters=spec.parameters,
permission=spec.permission,
source="plugin",
),
arguments_model,
executor,
)
record.registered_tools.append(spec.name)
self.registry.register(
ToolDefinition(
name=spec.name,
description=spec.description,
parameters=spec.parameters,
permission=spec.permission,
source="plugin",
),
arguments_model,
executor,
)
record.registered_tools.append(spec.name)
except Exception as exc:
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
record.mcp_remote_names.clear()
self.mcp.stop(plugin_id)
record.plugin.status = PluginStatus.error
record.plugin.error_message = str(exc)
raise
record.plugin.error_message = _safe_extension_message(exc)
if isinstance(exc, ExtensionError):
raise
if isinstance(exc, McpBridgeError):
raise ExtensionError(
exc.code,
exc.message,
status_code=exc.status_code,
details={"plugin_id": plugin_id},
) from exc
raise ExtensionError(
"PLUGIN_HOST_START_FAILED",
record.plugin.error_message,
status_code=503,
details={"plugin_id": plugin_id},
) from exc
record.plugin.enabled = True
record.plugin.status = PluginStatus.ready
record.plugin.error_message = None
return record.plugin.model_copy(deep=True)
def set_permissions(self, plugin_id: str, permissions: list[str]) -> Plugin:
with self._lock:
return self._set_permissions(plugin_id, permissions)
def _set_permissions(self, plugin_id: str, permissions: list[str]) -> Plugin:
record = self._record(plugin_id)
requested = set(permissions)
declared = set(record.plugin.manifest.permissions)
@@ -399,15 +460,125 @@ class PluginRuntime:
return record.plugin.model_copy(deep=True)
def disable(self, plugin_id: str) -> Plugin:
with self._lock:
return self._disable(plugin_id)
def _disable(self, plugin_id: str) -> Plugin:
record = self._record(plugin_id)
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
record.mcp_remote_names.clear()
if record.plugin.manifest.backend.type == "mcp":
self.mcp.stop(plugin_id)
record.plugin.enabled = False
record.plugin.status = PluginStatus.disabled
return record.plugin.model_copy(deep=True)
def get_host_status(self, plugin_id: str) -> PluginHostStatus:
record = self._record(plugin_id)
return self.mcp.status(plugin_id, record.plugin.manifest.backend)
def restart_host(self, plugin_id: str) -> PluginHostStatus:
with self._lock:
return self._restart_host(plugin_id)
def _restart_host(self, plugin_id: str) -> PluginHostStatus:
record = self._record(plugin_id)
if record.plugin.manifest.backend.type != "mcp":
raise ExtensionError(
"PLUGIN_HOST_UNAVAILABLE",
"Plugin does not use an MCP Host.",
status_code=409,
details={"plugin_id": plugin_id},
)
if record.plugin.status in {
PluginStatus.installed,
PluginStatus.disabled,
PluginStatus.permission_required,
}:
raise ExtensionError(
"PLUGIN_HOST_UNAVAILABLE",
"Disabled or inactive MCP Plugins must be started with Enable.",
status_code=409,
details={"plugin_id": plugin_id, "status": record.plugin.status.value},
)
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
record.mcp_remote_names.clear()
self.mcp.stop(plugin_id)
record.plugin.enabled = False
record.plugin.status = PluginStatus.installed
record.plugin.error_message = None
self.enable(plugin_id)
return self.get_host_status(plugin_id)
def shutdown(self) -> None:
"""关闭所有隔离 Host;用于 FastAPI lifespan 和测试清理。"""
with self._lock:
for plugin_id, record in list(self._records.items()):
if record.plugin.manifest.backend.type == "mcp":
self.mcp.stop(plugin_id)
def _start_mcp(self, record: _PluginRecord) -> list[McpDiscoveredTool]:
manifest = record.plugin.manifest
return self.mcp.start(
manifest.plugin_id,
manifest.backend,
record.package_path,
manifest.permissions,
self._handle_mcp_unavailable,
)
def _register_mcp_tool(
self, record: _PluginRecord, discovered: McpDiscoveredTool
) -> None:
definition = discovered.definition
arguments_model = _arguments_model_from_schema(
definition.name, definition.parameters
)
plugin_id = record.plugin.manifest.plugin_id
remote_name = discovered.remote_name
async def executor(
arguments: BaseModel,
context: ToolExecutionContext,
) -> Any:
return await self.mcp.call_tool(
plugin_id,
remote_name,
# 省略的可选字段不能被补成 null;显式传入的 null 仍由
# model_fields_set 保留并交给 MCP Server。
arguments.model_dump(exclude_unset=True),
request_id=context.tool_call_id or f"{context.run_id}:{definition.name}",
)
self.registry.register(definition, arguments_model, executor)
record.registered_tools.append(definition.name)
record.mcp_remote_names[definition.name] = remote_name
def _handle_mcp_unavailable(self, plugin_id: str, message: str) -> None:
with self._lock:
record = self._records.get(plugin_id)
if record is None:
return
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
record.mcp_remote_names.clear()
record.plugin.enabled = False
record.plugin.status = PluginStatus.error
record.plugin.error_message = message
def uninstall(self, plugin_id: str, dependent_skills: list[str] | None = None) -> None:
with self._lock:
self._uninstall(plugin_id, dependent_skills)
def _uninstall(
self, plugin_id: str, dependent_skills: list[str] | None = None
) -> None:
record = self._record(plugin_id)
if dependent_skills:
raise ExtensionError(
@@ -416,8 +587,13 @@ class PluginRuntime:
status_code=409,
details={"plugin_id": plugin_id, "skills": dependent_skills},
)
is_mcp = record.plugin.manifest.backend.type == "mcp"
if record.plugin.enabled:
self.disable(plugin_id)
if is_mcp:
# stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同
# 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。
self.mcp.remove(plugin_id)
del self._records[plugin_id]
def _record(self, plugin_id: str) -> _PluginRecord:
@@ -494,24 +670,18 @@ def _manifest_error(kind: str, exc: ValidationError) -> ExtensionError:
def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]:
schema = spec.parameters or {"type": "object", "properties": {}}
return _arguments_model_from_schema(spec.name, schema)
def _arguments_model_from_schema(
tool_name: str, schema: dict[str, Any]
) -> type[BaseModel]:
if schema.get("type", "object") != "object":
raise ExtensionError("PLUGIN_TOOL_SCHEMA_INVALID", "Tool parameters must be an object schema.")
properties = schema.get("properties", {})
required = set(schema.get("required", []))
fields: dict[str, tuple[Any, Any]] = {}
types = {
"string": str,
"number": float,
"integer": int,
"boolean": bool,
"array": list[Any],
"object": dict[str, Any],
}
for name, field_schema in properties.items():
annotation = types.get(field_schema.get("type"), Any)
fields[name] = (annotation, ... if name in required else None)
model_name = "PluginArgs_" + re.sub(r"\W+", "_", spec.name)
return create_model(model_name, __config__=ConfigDict(extra="forbid"), **fields)
model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name)
# 完整 JSON Schema 已在 ToolRegistry 中先行校验。参数载体不重复声明字段,
# 从而完整保留 model_dump、连字符键、联合类型和动态属性等合法 JSON 键值。
return create_model(model_name, __config__=ConfigDict(extra="allow"))
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
@@ -532,3 +702,30 @@ def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
"Tool parameters must be an object schema with object properties.",
details={"tool": spec.name},
)
def _validate_backend(manifest: PluginManifest) -> None:
backend = manifest.backend
if backend.type == "mcp":
if backend.transport != "stdio":
raise ExtensionError(
"MCP_CAPABILITY_UNSUPPORTED",
"Phase C MCP Plugins must use stdio transport.",
status_code=501,
)
if not backend.command or not backend.command.strip():
raise ExtensionError(
"EXTENSION_MANIFEST_INVALID",
"MCP stdio backend requires a command.",
)
elif backend.command is not None or backend.args:
raise ExtensionError(
"EXTENSION_MANIFEST_INVALID",
"Only MCP stdio backends may declare command or args.",
)
def _safe_extension_message(exc: Exception) -> str:
if isinstance(exc, (ExtensionError, McpBridgeError)):
return exc.message
return f"Plugin Host operation failed: {type(exc).__name__}."
+12
View File
@@ -1,19 +1,31 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from starlette.exceptions import HTTPException as StarletteHttpException
from app.config import get_settings
from app.container import container
from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler
from app.routes import router as api_router
from app.schemas import HealthResponse, ServiceStatusResponse
settings = get_settings()
@asynccontextmanager
async def lifespan(_: FastAPI):
yield
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
container.plugins.shutdown()
app = FastAPI(
title=settings.name,
version=settings.version,
description="AI 笔记软件的本地 AI Core 与 Agent Core 服务。",
lifespan=lifespan,
)
app.add_middleware(
+7
View File
@@ -1,3 +1,5 @@
"""Provider 凭据解析及本地加密存储。"""
import json
import os
import re
@@ -43,6 +45,8 @@ class EnvironmentCredentialResolver:
class EncryptedCredentialStore:
"""将本地开发凭据作为 Fernet 密文存储,Provider 使用时按 ID 解密。"""
# TODO(security): 桌面 Host 接入后将主密钥迁移到系统钥匙串/凭据保险库。
def __init__(self) -> None:
self._lock = threading.RLock()
@@ -75,6 +79,7 @@ class EncryptedCredentialStore:
key_path.parent.mkdir(parents=True, exist_ok=True)
self._restrict(key_path.parent, 0o700)
if not key_path.exists():
# 先写临时文件再原子替换,避免异常退出留下半截主密钥。
temporary = key_path.with_suffix(".tmp")
temporary.write_bytes(Fernet.generate_key())
self._restrict(temporary, 0o600)
@@ -112,6 +117,7 @@ class EncryptedCredentialStore:
encoding="utf-8",
)
self._restrict(temporary, 0o600)
# 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。
temporary.replace(store_path)
self._restrict(store_path, 0o600)
@@ -158,6 +164,7 @@ class ChainedCredentialResolver:
self._resolvers = resolvers
def resolve(self, credential_id: str | None) -> str | None:
# 顺序即优先级:调用方可让 Host 注入值覆盖本地开发凭据。
for resolver in self._resolvers:
value = resolver.resolve(credential_id)
if value:
+54
View File
@@ -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,
+155 -11
View File
@@ -1,18 +1,23 @@
import asyncio
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,11 +27,13 @@ from app.contracts import (
NoteCreateRequest,
NoteListResponse,
NoteMoveRequest,
NoteRenameRequest,
NoteUpdateRequest,
OperationResponse,
PageMeta,
PermissionDecisionRequest,
Plugin,
PluginHostStatus,
PluginListResponse,
PluginPermissionGrantRequest,
ProviderConfig,
@@ -48,6 +55,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 +69,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 +84,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 +132,50 @@ def extension_call(operation):
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
async def extension_call_async(operation):
"""进程启动/关闭可能等待 stdio Host,移出 FastAPI 事件循环。"""
try:
return await asyncio.to_thread(operation)
except ExtensionError as exc:
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
# Workspace (single configured Vault in Web development mode)
@router.get("/workspace", response_model=WorkspaceInfo, tags=["Workspace"])
async def get_workspace() -> WorkspaceInfo:
return workspace_service.get_workspace_info()
@router.post("/workspace/open", response_model=WorkspaceSnapshot, tags=["Workspace"])
async def open_workspace(request: WorkspaceOpenRequest) -> WorkspaceSnapshot:
return await workspace_service.open_workspace(request.path)
@router.get("/workspace/tree", response_model=list[WorkspaceEntry], tags=["Workspace"])
async def get_workspace_tree() -> list[WorkspaceEntry]:
return workspace_service.get_workspace_tree()
@router.post("/workspace/folders", response_model=WorkspaceEntry, tags=["Workspace"])
async def create_workspace_folder(request: FolderCreateRequest) -> WorkspaceEntry:
return await workspace_service.create_folder(request.parent, request.name)
@router.post(
"/workspace/folders/rename", response_model=WorkspaceEntry, tags=["Workspace"]
)
async def rename_workspace_folder(request: FolderRenameRequest) -> WorkspaceEntry:
return await workspace_service.rename_folder(request.path, request.new_name)
@router.post(
"/workspace/folders/delete", response_model=OperationResponse, tags=["Workspace"]
)
async def delete_workspace_folder(request: FolderDeleteRequest) -> OperationResponse:
return await workspace_service.delete_folder(request.path)
# Notes
@router.get("/notes", response_model=NoteListResponse, tags=["Notes"])
async def list_notes(
@@ -160,6 +222,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 +327,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,
@@ -378,7 +494,7 @@ async def install_plugin(request: ExtensionInstallRequest) -> Plugin:
tags=["Plugins"],
)
async def enable_plugin(plugin_id: str) -> Plugin:
return extension_call(lambda: container.plugins.enable(plugin_id))
return await extension_call_async(lambda: container.plugins.enable(plugin_id))
@router.post(
@@ -387,7 +503,7 @@ async def enable_plugin(plugin_id: str) -> Plugin:
tags=["Plugins"],
)
async def disable_plugin(plugin_id: str) -> Plugin:
return extension_call(lambda: container.plugins.disable(plugin_id))
return await extension_call_async(lambda: container.plugins.disable(plugin_id))
@router.put(
@@ -398,11 +514,37 @@ async def disable_plugin(plugin_id: str) -> Plugin:
async def set_plugin_permissions(
plugin_id: str, request: PluginPermissionGrantRequest
) -> Plugin:
return extension_call(
return await extension_call_async(
lambda: container.plugins.set_permissions(plugin_id, request.permissions)
)
@router.get(
"/plugins/{plugin_id}/host",
response_model=PluginHostStatus,
tags=["Plugins"],
)
async def get_plugin_host_status(plugin_id: str) -> PluginHostStatus:
return extension_call(lambda: container.plugins.get_host_status(plugin_id))
@router.post(
"/plugins/{plugin_id}/host/restart",
response_model=OperationResponse,
status_code=202,
tags=["Plugins"],
)
async def restart_plugin_host(plugin_id: str) -> OperationResponse:
status = await extension_call_async(
lambda: container.plugins.restart_host(plugin_id)
)
return OperationResponse(
status="accepted",
resource_id=plugin_id,
message=f"Plugin Host status: {status.status.value}",
)
@router.delete(
"/plugins/{plugin_id}",
response_model=OperationResponse,
@@ -411,7 +553,9 @@ async def set_plugin_permissions(
async def uninstall_plugin(plugin_id: str) -> OperationResponse:
plugin = extension_call(lambda: container.plugins.get(plugin_id))
dependent_skills = container.skills.depending_on_tools(plugin.manifest.contributes.tools)
extension_call(lambda: container.plugins.uninstall(plugin_id, dependent_skills))
await extension_call_async(
lambda: container.plugins.uninstall(plugin_id, dependent_skills)
)
return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled")
+72 -52
View File
@@ -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)
@@ -1,3 +1,5 @@
"""转写适配层;第一阶段消费文本附件或桌面 Host 预生成的旁路文本。"""
from __future__ import annotations
from collections import OrderedDict
@@ -13,6 +15,7 @@ MAX_JOBS = 100
def create_transcription(attachment_id: str, language: str | None = None) -> TranscriptionJob:
# TODO(ai-core): 第二阶段接入本地 ASR 队列后,保留相同 Job 契约替换此同步降级实现。
del language # 预生成 transcript 暂不需要语言识别。
source = attachment_path(attachment_id)
transcript = source if source.suffix.lower() in {".txt", ".md"} else Path(f"{source}.txt")
+78
View File
@@ -0,0 +1,78 @@
"""Vault 相对路径校验;所有文件操作必须先经过本模块。"""
from __future__ import annotations
import re
from pathlib import Path
from app.config import get_settings
from app.errors import ApiError
_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]')
def normalize_folder(folder: str | None) -> str:
"""返回使用 `/` 的安全相对目录;根目录表示为空字符串。"""
if not folder or folder in {"/", "\\"}:
return ""
if "\x00" in folder:
raise ApiError(400, "INVALID_PATH", "folder must not contain NUL bytes")
segments: list[str] = []
for part in re.split(r"[\\/]+", folder):
if not part:
continue
if part in {".", ".."} or ":" in part:
raise ApiError(
400,
"INVALID_PATH",
"folder must be a relative path without '.' or '..' segments",
{"folder": folder},
)
segments.append(part)
return "/".join(segments)
def normalize_entry_name(name: str, *, markdown: bool = False) -> str:
"""校验单个目录项名称;不静默接受路径分隔符或保留段。"""
value = name.strip()
if not value or value in {".", ".."} or "\x00" in value:
raise ApiError(400, "INVALID_PATH", "entry name is invalid", {"name": name})
if _INVALID_FILE_CHARS.search(value):
raise ApiError(
400,
"INVALID_PATH",
"entry name contains unsupported characters",
{"name": name},
)
if markdown and not value.lower().endswith(".md"):
value += ".md"
return value
def safe_note_filename(title: str) -> str:
"""为创建笔记保留原有的宽松清洗行为。"""
value = _INVALID_FILE_CHARS.sub("_", title).strip() or "untitled"
return value if value.lower().endswith(".md") else f"{value}.md"
def resolve_in_vault(relative_path: str) -> Path:
"""把相对路径解析到当前 Vault,并拒绝符号链接/`..` 导致的越界。"""
if not relative_path or "\x00" in relative_path:
raise ApiError(
400, "INVALID_PATH", "invalid Vault-relative path", {"path": relative_path}
)
root = get_settings().vault_path.resolve()
candidate = (root / relative_path.replace("\\", "/").lstrip("/")).resolve()
if not candidate.is_relative_to(root):
raise ApiError(
400, "INVALID_PATH", "path escapes Vault", {"path": relative_path}
)
return candidate
def relative_to_vault(path: Path) -> str:
return path.resolve().relative_to(get_settings().vault_path.resolve()).as_posix()
+249
View File
@@ -0,0 +1,249 @@
"""Web 联调 Workspace:把单一配置 Vault 映射为前端可用的真实文件树。"""
from __future__ import annotations
import hashlib
import shutil
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from app import repository
from app.config import get_settings
from app.contracts import (
IndexRebuildRequest,
OperationResponse,
WorkspaceEntry,
WorkspaceInfo,
WorkspaceSnapshot,
)
from app.database.db import connect, transaction
from app.errors import ApiError
from app.retrieval.vectorstore import SqliteVecStore
from app.services import index_service
from app.services.coordination import serialized_vault_mutation
from app.services.vault_paths import normalize_entry_name, normalize_folder, resolve_in_vault
vector_store = SqliteVecStore()
def _entry_id(kind: str, path: str) -> str:
digest = hashlib.sha256(f"{kind}:{path}".encode("utf-8")).hexdigest()[:16]
return f"{kind}_{digest}"
def _disk_markdown_paths() -> set[str]:
root = get_settings().vault_path
if not root.exists():
return set()
resolved_root = root.resolve()
paths: set[str] = set()
for path in root.rglob("*.md"):
if path.is_symlink():
continue
resolved = path.resolve()
if resolved.is_file() and resolved.is_relative_to(resolved_root):
paths.add(resolved.relative_to(resolved_root).as_posix())
return paths
def get_workspace_info() -> WorkspaceInfo:
root = get_settings().vault_path.resolve()
disk_paths = _disk_markdown_paths()
indexed_paths = {item.file_path for item in repository.list_note_locations()}
return WorkspaceInfo(
name=root.name or "Vault",
path=str(root),
file_count=len(disk_paths),
indexed_note_count=len(indexed_paths),
requires_refresh=disk_paths != indexed_paths,
)
def _tree(directory: Path, locations: dict[str, repository.NoteLocation]) -> list[WorkspaceEntry]:
if not directory.exists():
return []
root = get_settings().vault_path.resolve()
entries: list[WorkspaceEntry] = []
children = sorted(
directory.iterdir(), key=lambda item: (not item.is_dir(), item.name.casefold())
)
for child in children:
if child.name.startswith(".") or child.is_symlink():
continue
resolved = child.resolve()
if not resolved.is_relative_to(root):
continue
relative = resolved.relative_to(root).as_posix()
public_path = f"/{relative}"
if resolved.is_dir():
entries.append(
WorkspaceEntry(
entry_id=_entry_id("folder", relative),
name=child.name,
path=public_path,
type="folder",
children=_tree(resolved, locations),
)
)
elif resolved.is_file() and child.suffix.lower() == ".md":
location = locations.get(relative)
entries.append(
WorkspaceEntry(
entry_id=location.note_id if location else _entry_id("file", relative),
note_id=location.note_id if location else None,
name=child.name,
path=public_path,
type="file",
)
)
return entries
def get_workspace_tree() -> list[WorkspaceEntry]:
locations = {item.file_path: item for item in repository.list_note_locations()}
return _tree(get_settings().vault_path.resolve(), locations)
async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot:
"""打开当前配置 Vault;发现未索引文件时先执行一次安全全量刷新。"""
root = get_settings().vault_path.resolve()
if requested_path and Path(requested_path).resolve() != root:
raise ApiError(
409,
"WORKSPACE_PATH_MISMATCH",
"Web development mode can only open the backend configured Vault.",
{"configured_path": str(root)},
)
root.mkdir(parents=True, exist_ok=True)
info = get_workspace_info()
if info.requires_refresh:
await index_service.rebuild(IndexRebuildRequest())
info = get_workspace_info()
return WorkspaceSnapshot(workspace=info, items=get_workspace_tree())
@serialized_vault_mutation
async def create_folder(parent: str, name: str) -> WorkspaceEntry:
clean_parent = normalize_folder(parent)
clean_name = normalize_entry_name(name)
relative = f"{clean_parent}/{clean_name}" if clean_parent else clean_name
target = resolve_in_vault(relative)
if not clean_parent:
get_settings().vault_path.mkdir(parents=True, exist_ok=True)
if target.exists():
raise ApiError(
409, "RESOURCE_CONFLICT", "folder already exists", {"path": relative}
)
if not target.parent.is_dir():
raise ApiError(
404,
"RESOURCE_NOT_FOUND",
"parent folder not found",
{"parent": clean_parent},
)
target.mkdir(parents=False)
return WorkspaceEntry(
entry_id=_entry_id("folder", relative),
name=clean_name,
path=f"/{relative}",
type="folder",
)
@serialized_vault_mutation
async def rename_folder(path: str, new_name: str) -> WorkspaceEntry:
old_folder = normalize_folder(path)
if not old_folder:
raise ApiError(400, "INVALID_PATH", "the Vault root cannot be renamed")
clean_name = normalize_entry_name(new_name)
parent = Path(old_folder).parent.as_posix()
parent = "" if parent == "." else parent
new_folder = f"{parent}/{clean_name}" if parent else clean_name
source = resolve_in_vault(old_folder)
target = resolve_in_vault(new_folder)
if not source.is_dir() or source.is_symlink():
raise ApiError(404, "RESOURCE_NOT_FOUND", "folder not found", {"path": path})
if target.exists():
raise ApiError(
409, "RESOURCE_CONFLICT", "target folder already exists", {"path": new_folder}
)
affected = [
item
for item in repository.list_note_locations()
if item.folder == old_folder or item.folder.startswith(f"{old_folder}/")
]
source.replace(target)
conn = connect()
now = datetime.now(timezone.utc)
try:
with transaction(conn):
for item in affected:
file_suffix = item.file_path[len(old_folder) :].lstrip("/")
folder_suffix = item.folder[len(old_folder) :].lstrip("/")
repository.update_note_location(
conn=conn,
note_id=item.note_id,
title=item.title,
file_path=f"{new_folder}/{file_suffix}",
folder=(
f"{new_folder}/{folder_suffix}" if folder_suffix else new_folder
),
updated_at=now,
)
except BaseException:
target.replace(source)
raise
finally:
conn.close()
return WorkspaceEntry(
entry_id=_entry_id("folder", new_folder),
name=clean_name,
path=f"/{new_folder}",
type="folder",
children=_tree(target, {item.file_path: item for item in repository.list_note_locations()}),
)
@serialized_vault_mutation
async def delete_folder(path: str) -> OperationResponse:
folder = normalize_folder(path)
if not folder:
raise ApiError(400, "INVALID_PATH", "the Vault root cannot be deleted")
source = resolve_in_vault(folder)
if not source.is_dir() or source.is_symlink():
raise ApiError(404, "RESOURCE_NOT_FOUND", "folder not found", {"path": path})
affected = [
item
for item in repository.list_note_locations()
if item.folder == folder or item.folder.startswith(f"{folder}/")
]
tombstone = source.with_name(f".{source.name}.{uuid4().hex}.deleting")
source.replace(tombstone)
conn = connect()
try:
with transaction(conn):
block_ids: list[str] = []
for item in affected:
block_ids.extend(repository.delete_note(item.note_id, conn=conn))
await vector_store.delete(block_ids, conn=conn)
except BaseException:
tombstone.replace(source)
raise
finally:
conn.close()
try:
shutil.rmtree(tombstone)
except OSError:
# 已提交的删除不回滚;隐藏 tombstone 可由后续维护任务清理。
pass
return OperationResponse(
status="completed",
resource_id=_entry_id("folder", folder),
message=f"deleted folder and {len(affected)} indexed notes",
)
@@ -0,0 +1,21 @@
id: mcp-fixture
name: MCP Fixture
version: 1.0.0
description: 阶段 C 离线联调 Fixture,覆盖 MCP Tool 生命周期与错误边界。
permissions:
- notes.read
contributes:
tools:
- mcp-fixture.echo
- mcp-fixture.fail
- mcp-fixture.sleep
- mcp-fixture.large
- mcp-fixture.environment
- mcp-fixture.exit
backend:
type: mcp
transport: stdio
command: python
args: [server.py]
startup_timeout_seconds: 5
tool_timeout_seconds: 1
@@ -0,0 +1,204 @@
"""确定性的 MCP stdio 测试 Server;仅使用标准库,不依赖产品代码。"""
from __future__ import annotations
import json
import os
import sys
import threading
import time
from typing import Any
WRITE_LOCK = threading.Lock()
CANCELLED: dict[int, threading.Event] = {}
MODE = sys.argv[1] if len(sys.argv) > 1 else "normal"
def send(message: dict[str, Any]) -> None:
with WRITE_LOCK:
sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
sys.stdout.flush()
def respond(request_id: int, result: dict[str, Any]) -> None:
send({"jsonrpc": "2.0", "id": request_id, "result": result})
def tool(name: str, description: str, properties: dict[str, Any] | None = None) -> dict[str, Any]:
return {
"name": name,
"description": description,
"inputSchema": {
"type": "object",
"properties": properties or {},
"required": list(properties or {}),
"additionalProperties": False,
},
}
TOOLS = {
"echo": {
**tool(
"echo",
"Return the provided text.",
{
"text": {"type": "string"},
"suffix": {"type": ["string", "null"]},
},
),
"_meta": {"notesagent/permission": "notes.read"},
},
"fail": tool("fail", "Return an MCP business error."),
"sleep": tool("sleep", "Wait until completed or cancelled.", {"seconds": {"type": "number"}}),
"large": tool("large", "Return a result larger than the host limit."),
"environment": tool("environment", "Report whether host secrets leaked into the process."),
"exit": tool("exit", "Terminate the fixture process."),
}
# suffix 是可选字段,用于验证 Host 不会把缺省值擅自补成 null。
TOOLS["echo"]["inputSchema"]["required"] = ["text"]
def call_tool(request_id: int, params: dict[str, Any]) -> None:
name = params.get("name")
arguments = params.get("arguments") or {}
if name == "echo":
text = str(arguments.get("text", ""))
structured_content = {"echo": text}
if "suffix" in arguments:
structured_content["suffix"] = arguments["suffix"]
respond(
request_id,
{
"content": [{"type": "text", "text": text}],
"structuredContent": structured_content,
"isError": False,
},
)
return
if name == "fail":
respond(
request_id,
{
"content": [{"type": "text", "text": "fixture failure"}],
"isError": True,
},
)
return
if name == "large":
respond(
request_id,
{
"content": [{"type": "text", "text": "x" * 300_000}],
"isError": False,
},
)
return
if name == "environment":
respond(
request_id,
{
"content": [{"type": "text", "text": "environment checked"}],
"structuredContent": {
"has_openai_key": "OPENAI_API_KEY" in os.environ,
"has_app_db_path": "APP_DB_PATH" in os.environ,
},
"isError": False,
},
)
return
if name == "exit":
os._exit(17)
if name == "sleep":
cancelled = CANCELLED.setdefault(request_id, threading.Event())
seconds = max(0.0, min(float(arguments.get("seconds", 0)), 30.0))
if cancelled.wait(seconds):
respond(
request_id,
{
"content": [{"type": "text", "text": "cancelled"}],
"isError": True,
},
)
else:
respond(
request_id,
{
"content": [{"type": "text", "text": "completed"}],
"structuredContent": {"slept": seconds},
"isError": False,
},
)
CANCELLED.pop(request_id, None)
return
send(
{
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32602, "message": f"Unknown tool: {name}"},
}
)
def main() -> None:
for line in sys.stdin:
message = json.loads(line)
method = message.get("method")
request_id = message.get("id")
params = message.get("params") or {}
if method == "initialize" and isinstance(request_id, int):
if MODE == "invalid-result":
send({"jsonrpc": "2.0", "id": request_id, "result": None})
continue
if MODE == "oversized-stdout":
# 不带换行,验证 Host 在读取完整内容前执行硬上限。
sys.stdout.write("x" * (2 * 1024 * 1024 + 1))
sys.stdout.flush()
time.sleep(10)
return
respond(
request_id,
{
"protocolVersion": params.get("protocolVersion"),
"capabilities": (
{} if MODE == "no-tools" else {"tools": {"listChanged": False}}
),
"serverInfo": {"name": "notesagent-mcp-fixture", "version": "1.0.0"},
},
)
elif method == "tools/list" and isinstance(request_id, int):
if MODE == "invalid-schema":
respond(
request_id,
{
"tools": [
{
"name": "broken",
"description": "invalid schema",
"inputSchema": {"type": "string"},
}
]
},
)
elif params.get("cursor") == "page-2":
respond(
request_id,
{"tools": [TOOLS["large"], TOOLS["environment"], TOOLS["exit"]]},
)
else:
respond(
request_id,
{"tools": [TOOLS["echo"], TOOLS["fail"], TOOLS["sleep"]], "nextCursor": "page-2"},
)
elif method == "tools/call" and isinstance(request_id, int):
threading.Thread(target=call_tool, args=(request_id, params), daemon=True).start()
elif method == "notifications/cancelled":
cancelled_id = params.get("requestId")
if isinstance(cancelled_id, int):
CANCELLED.setdefault(cancelled_id, threading.Event()).set()
elif method == "ping" and isinstance(request_id, int):
respond(request_id, {})
if __name__ == "__main__":
main()
+200
View File
@@ -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())
+3
View File
@@ -89,9 +89,12 @@ 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",
"/api/plugins/{plugin_id}/host",
"/api/plugins/{plugin_id}/host/restart",
"/api/plugins/{plugin_id}/enable",
"/api/plugins/{plugin_id}/disable",
"/api/providers/test",
+318 -1
View File
@@ -1,4 +1,7 @@
import asyncio
import shutil
import threading
import time
import pytest
@@ -12,14 +15,31 @@ from app.contracts import (
ToolCall,
)
from app.extensions import ExtensionError
from app.extensions.mcp import McpStdioClient
from app.extensions.runtime import _arguments_model_from_schema
from app.services import note_service
from app.config import get_settings
from app.config import BACKEND_DIR, get_settings
MCP_FIXTURE = BACKEND_DIR / "extensions" / "fixtures" / "mcp-echo"
def run(coroutine):
return asyncio.run(coroutine)
@pytest.fixture
def mcp_container():
container = build_container()
installed = container.plugins.install(MCP_FIXTURE)
assert installed.status == "permission_required"
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
try:
yield container
finally:
container.plugins.shutdown()
def test_bundled_plugin_registers_tool_and_skill_is_ready() -> None:
async def scenario() -> None:
container = build_container()
@@ -297,3 +317,300 @@ def test_attachment_and_transcription_tools_use_host_storage() -> None:
assert transcription.output["text"] == "会议转写内容"
run(scenario())
def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
mcp_container, monkeypatch
) -> None:
async def scenario() -> None:
monkeypatch.setenv("OPENAI_API_KEY", "must-not-enter-plugin-host")
enabled = mcp_container.plugins.enable("mcp-fixture")
status = mcp_container.plugins.get_host_status("mcp-fixture")
definition = mcp_container.tools.get("mcp-fixture.echo").definition
result = await mcp_container.tools.execute(
ToolCall(
tool_call_id="call_mcp_echo",
name="mcp-fixture.echo",
arguments={"text": "hello mcp"},
),
ToolExecutionContext(
run_id="run_mcp_fixture", tool_call_id="call_mcp_echo"
),
)
assert enabled.status == "ready" and enabled.enabled is True
assert status.status == "ready"
environment = await mcp_container.tools.execute(
ToolCall(
tool_call_id="call_mcp_environment",
name="mcp-fixture.environment",
arguments={},
),
ToolExecutionContext(run_id="run_mcp_fixture"),
)
assert status.tools_count == 6
assert status.protocol_version == "2025-11-25"
assert status.server_name == "notesagent-mcp-fixture"
assert definition.permission == "notes.read"
assert result.success is True
assert result.output == {"echo": "hello mcp"}
explicit_null = await mcp_container.tools.execute(
ToolCall(
tool_call_id="call_mcp_explicit_null",
name="mcp-fixture.echo",
arguments={"text": "null stays explicit", "suffix": None},
),
ToolExecutionContext(run_id="run_mcp_fixture"),
)
assert explicit_null.success is True
assert explicit_null.output == {
"echo": "null stays explicit",
"suffix": None,
}
assert environment.success is True
assert environment.output == {
"has_openai_key": False,
"has_app_db_path": False,
}
disabled = mcp_container.plugins.disable("mcp-fixture")
assert disabled.status == "disabled"
assert mcp_container.plugins.get_host_status("mcp-fixture").status == "stopped"
assert not mcp_container.tools.contains("mcp-fixture.echo")
with pytest.raises(ExtensionError) as exc:
mcp_container.plugins.restart_host("mcp-fixture")
assert exc.value.code == "PLUGIN_HOST_UNAVAILABLE"
assert mcp_container.plugins.get("mcp-fixture").status == "disabled"
assert not mcp_container.tools.contains("mcp-fixture.echo")
mcp_container.plugins.uninstall("mcp-fixture")
reinstalled = mcp_container.plugins.install(MCP_FIXTURE)
fresh_status = mcp_container.plugins.get_host_status("mcp-fixture")
assert reinstalled.status == "permission_required"
assert fresh_status.status == "stopped"
assert fresh_status.started_at is None
assert fresh_status.protocol_version is None
assert fresh_status.server_name is None
run(scenario())
def test_agent_calls_mcp_tool_through_registry_and_writes_trace(mcp_container) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
created = await mcp_container.agent.create_run(
AgentRunCreateRequest(
input='/tool mcp-fixture.echo {"text":"agent mcp"}',
provider_id="mock",
model="mock-1",
allowed_tools=["mcp-fixture.echo"],
)
)
completed = await mcp_container.agent.wait(created.run_id)
trace = mcp_container.agent.get_trace(
created.run_id, after_sequence=-1, limit=100
)
assert completed.status == AgentRunStatus.completed
assert completed.tool_results[0].success is True
assert completed.tool_results[0].output == {"echo": "agent mcp"}
assert any(
item.event == "ToolCall" and item.data.get("name") == "mcp-fixture.echo"
for item in trace.items
)
run(scenario())
def test_mcp_business_error_size_limit_and_timeout_are_structured(mcp_container) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
context = ToolExecutionContext(run_id="run_mcp_errors")
failed = await mcp_container.tools.execute(
ToolCall(tool_call_id="call_fail", name="mcp-fixture.fail", arguments={}),
context,
)
oversized = await mcp_container.tools.execute(
ToolCall(tool_call_id="call_large", name="mcp-fixture.large", arguments={}),
context,
)
timed_out = await mcp_container.tools.execute(
ToolCall(
tool_call_id="call_sleep",
name="mcp-fixture.sleep",
arguments={"seconds": 5},
),
ToolExecutionContext(
run_id="run_mcp_errors", tool_call_id="call_sleep"
),
)
recovered = await mcp_container.tools.execute(
ToolCall(
tool_call_id="call_after_timeout",
name="mcp-fixture.echo",
arguments={"text": "still ready"},
),
context,
)
assert failed.success is False
assert failed.error_code == "MCP_TOOL_CALL_FAILED"
assert failed.error_message == "fixture failure"
assert oversized.success is False
assert oversized.error_code == "MCP_TOOL_RESULT_TOO_LARGE"
assert timed_out.success is False
assert timed_out.error_code == "MCP_TOOL_CALL_FAILED"
assert recovered.success is True
assert mcp_container.plugins.get_host_status("mcp-fixture").status == "ready"
run(scenario())
def test_mcp_cancel_releases_blocking_response_thread(
mcp_container, monkeypatch
) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
released = threading.Event()
original_wait = McpStdioClient.wait_response
def tracked_wait(self, *args, **kwargs):
try:
return original_wait(self, *args, **kwargs)
finally:
released.set()
monkeypatch.setattr(McpStdioClient, "wait_response", tracked_wait)
task = asyncio.create_task(
mcp_container.plugins.mcp.call_tool(
"mcp-fixture",
"sleep",
{"seconds": 5},
request_id="call_cancel_release",
)
)
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
deadline = time.monotonic() + 0.5
while not released.is_set() and time.monotonic() < deadline:
await asyncio.sleep(0.01)
assert released.is_set(), "cancelled MCP wait must not occupy a worker until timeout"
run(scenario())
def test_mcp_argument_model_preserves_json_schema_additional_properties() -> None:
arguments_model = _arguments_model_from_schema(
"mcp-fixture.dynamic",
{
"type": "object",
"properties": {"model_dump": {"type": "string"}},
"required": ["model_dump"],
"additionalProperties": {"type": "string"},
},
)
arguments = arguments_model.model_validate(
{"model_dump": "method name remains data", "dynamic-key": "value"}
)
assert arguments.model_dump() == {
"model_dump": "method name remains data",
"dynamic-key": "value",
}
def test_production_rejects_unsandboxed_mcp_host(monkeypatch) -> None:
monkeypatch.setenv("APP_ENVIRONMENT", "production")
get_settings.cache_clear()
container = build_container()
installed = container.plugins.install(MCP_FIXTURE)
assert installed.status == "permission_required"
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
try:
with pytest.raises(ExtensionError) as exc:
container.plugins.enable("mcp-fixture")
assert exc.value.code == "MCP_TRUST_APPROVAL_REQUIRED"
assert container.plugins.get_host_status("mcp-fixture").status == "stopped"
assert not container.tools.contains("mcp-fixture.echo")
finally:
container.plugins.shutdown()
get_settings.cache_clear()
def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
crashed = await mcp_container.tools.execute(
ToolCall(tool_call_id="call_exit", name="mcp-fixture.exit", arguments={}),
ToolExecutionContext(run_id="run_mcp_exit", tool_call_id="call_exit"),
)
deadline = time.monotonic() + 2
while mcp_container.tools.contains("mcp-fixture.echo") and time.monotonic() < deadline:
await asyncio.sleep(0.02)
plugin = mcp_container.plugins.get("mcp-fixture")
status = mcp_container.plugins.get_host_status("mcp-fixture")
assert crashed.success is False
assert crashed.error_code == "PLUGIN_HOST_UNAVAILABLE"
assert plugin.status == "error" and plugin.enabled is False
assert status.status == "unhealthy"
assert not mcp_container.tools.contains("mcp-fixture.echo")
restarted = mcp_container.plugins.restart_host("mcp-fixture")
assert restarted.status == "ready"
assert restarted.tools_count == 6
assert mcp_container.tools.contains("mcp-fixture.echo")
run(scenario())
@pytest.mark.parametrize(
("mode", "contributions", "expected_code"),
[
("no-tools", "[]", "MCP_CAPABILITY_UNSUPPORTED"),
("invalid-schema", "[mcp-invalid.broken]", "MCP_TOOL_SCHEMA_INVALID"),
("invalid-result", "[]", "MCP_INITIALIZE_FAILED"),
("oversized-stdout", "[]", "PLUGIN_HOST_UNAVAILABLE"),
],
)
def test_mcp_rejects_invalid_initialization_and_discovery(
tmp_path, mode, contributions, expected_code
) -> None:
package = tmp_path / f"mcp-{mode}"
package.mkdir()
shutil.copyfile(MCP_FIXTURE / "server.py", package / "server.py")
(package / "plugin.yaml").write_text(
f"""
id: mcp-invalid
name: Invalid MCP Fixture
version: 1.0.0
contributes:
tools: {contributions}
backend:
type: mcp
transport: stdio
command: python
args: [server.py, {mode}]
startup_timeout_seconds: 5
tool_timeout_seconds: 1
""".strip(),
encoding="utf-8",
)
container = build_container()
container.plugins.install(package)
try:
with pytest.raises(ExtensionError) as exc:
container.plugins.enable("mcp-invalid")
assert exc.value.code == expected_code
assert container.plugins.get("mcp-invalid").status == "error"
assert container.plugins.get_host_status("mcp-invalid").status == "error"
assert not container.tools.contains("mcp-invalid.broken")
finally:
container.plugins.shutdown()
+116
View File
@@ -0,0 +1,116 @@
import asyncio
import pytest
from app.config import get_settings
from app.contracts import (
FolderCreateRequest,
FolderDeleteRequest,
FolderRenameRequest,
NoteCreateRequest,
NoteRenameRequest,
WorkspaceOpenRequest,
)
from app.errors import ApiError
from app.routes import (
create_note,
create_workspace_folder,
delete_workspace_folder,
get_note,
get_workspace_tree,
open_workspace,
rename_note,
rename_workspace_folder,
)
def test_open_workspace_indexes_real_markdown_and_returns_tree() -> None:
vault = get_settings().vault_path
note_path = vault / "课程" / "操作系统.md"
note_path.parent.mkdir(parents=True)
note_path.write_text("# 操作系统\n\n进程调度。\n", encoding="utf-8")
snapshot = asyncio.run(open_workspace(WorkspaceOpenRequest()))
assert snapshot.workspace.path == str(vault.resolve())
assert snapshot.workspace.requires_refresh is False
assert snapshot.workspace.file_count == snapshot.workspace.indexed_note_count == 1
folder = snapshot.items[0]
assert folder.path == "/课程"
assert folder.children[0].path == "/课程/操作系统.md"
assert folder.children[0].note_id is not None
def test_open_workspace_rejects_unconfigured_path() -> None:
with pytest.raises(ApiError) as error:
asyncio.run(open_workspace(WorkspaceOpenRequest(path="C:/another-vault")))
assert error.value.code == "WORKSPACE_PATH_MISMATCH"
def test_note_rename_preserves_identity_and_content() -> None:
created = asyncio.run(
create_note(
NoteCreateRequest(
title="旧名称", markdown="# 标题不变\n\n真实正文。\n", folder="课程"
)
)
)
renamed = asyncio.run(
rename_note(created.note_id, NoteRenameRequest(file_name="新名称.md"))
)
assert renamed.note_id == created.note_id
assert renamed.file_path == "课程/新名称.md"
assert renamed.title == "新名称"
assert renamed.markdown == "# 标题不变\n\n真实正文。\n"
assert not (get_settings().vault_path / "课程" / "旧名称.md").exists()
def test_folder_lifecycle_updates_database_and_vectors() -> None:
folder = asyncio.run(
create_workspace_folder(FolderCreateRequest(parent="/", name="课程"))
)
created = asyncio.run(
create_note(
NoteCreateRequest(title="网络", markdown="# 网络\n\nTCP。\n", folder="课程")
)
)
renamed_folder = asyncio.run(
rename_workspace_folder(
FolderRenameRequest(path=folder.path, new_name="计算机课程")
)
)
moved_note = asyncio.run(get_note(created.note_id))
assert renamed_folder.path == "/计算机课程"
assert moved_note.note_id == created.note_id
assert moved_note.file_path == "计算机课程/网络.md"
assert asyncio.run(get_workspace_tree())[0].children[0].note_id == created.note_id
response = asyncio.run(
delete_workspace_folder(FolderDeleteRequest(path=renamed_folder.path))
)
assert response.status == "completed"
with pytest.raises(ApiError) as error:
asyncio.run(get_note(created.note_id))
assert error.value.code == "RESOURCE_NOT_FOUND"
assert asyncio.run(get_workspace_tree()) == []
def test_workspace_openapi_paths_are_published() -> None:
from app.main import app
paths = app.openapi()["paths"]
assert {
"/api/workspace",
"/api/workspace/open",
"/api/workspace/tree",
"/api/workspace/folders",
"/api/workspace/folders/rename",
"/api/workspace/folders/delete",
"/api/notes/{note_id}/rename",
} <= paths.keys()
+70
View File
@@ -0,0 +1,70 @@
# 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)
- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.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、stdio MCP Bridge 与隔离 Plugin Host 已完成。后续继续接入真实音频处理、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 ServerFastAPI + 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 TraceProvider 表保存非敏感模型配置。
其中 `notes``blocks` 保存 Markdown 的结构化投影;FTS5 建立全文索引;sqlite-vec 保存 Block 向量;当前实现以 `agent_runs``agent_events` 保存 Agent TraceProvider 表保存非敏感模型配置。
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 ContributionCommand 由后端注册为稳定 ID、标题、参数和执行目标,前端只消费 Contribution ContractSettings 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
@@ -968,10 +1040,18 @@ Plugin Host 负责:
内置 Plugin 可以使用相同的 Plugin Interface 注册能力,减少内置功能和社区扩展之间的接口差异。
Python 包形式的 MCP Server 推荐使用固定版本的 `uvx --isolated --from <package>==<version> <command>` 启动,以隔离依赖并避免污染 AI Core 环境;包内脚本和非 Python Server 仍可使用受控 `command + args``uvx` 的虚拟环境不是安全沙箱,不能限制文件、网络、子进程或系统调用。
面向社区或不可信 Plugin 开放前,Tauri/Rust Host 必须增加平台级沙箱、完整进程树回收、包来源/签名校验,并在首次安装或命令变化时向用户完整展示 executable 和参数、要求明确同意。当前 Python Host 的独立进程、环境裁剪和 Permission 只用于可信开发联调,不能替代这些生产安全门槛。
在该门槛完成前,后端仅允许 `APP_ENVIRONMENT=development` 启动未沙箱化 MCP Host;生产环境统一返回 `MCP_TRUST_APPROVAL_REQUIRED`。Python `uvx` Server 在开发模式首次运行可能联网解析依赖,生产版本必须在安装/更新阶段预取并验证固定版本,正常运行阶段只使用已经准备好的环境。
### 12.5 MCP Bridge
MCP Bridge 用于接入具有 MCP Server 接口的插件或外部工具服务。
当前已实现本地 stdio 首版:Plugin Runtime 在授权后的启用阶段启动独立 Server 进程,完成 `initialize`、capability negotiation、分页 `tools/list``tools/call`、取消、超时、异常退出和 Host Restart。实现接受 `2025-11-25``2025-06-18``2025-03-26``2024-11-05` 协议版本;Streamable HTTP、Resource、Prompt、Sampling 与操作系统级沙箱仍属于后续范围。
MCP Tool 进入系统后的调用路径为:
```text
@@ -990,6 +1070,20 @@ Tool Registry 仍使用项目自己的 `ToolDefinition` 和 `ToolResult`。MCP B
MCP 能力首先用于 Tool 和 Resource 类扩展。需要复杂 UI 的插件通过 Frontend Extension Slot 单独处理。
当前 stdio MCP Bridge 已覆盖以下协议边界:
```text
Server Process / Connection Lifecycle
initialize 与 capability negotiation
tools/list 与 ToolDefinition 映射
tools/call 与 ToolResult 映射
超时、取消和进程退出
协议错误与业务错误转换
健康检查与 Tool 注销
```
首个宿主实现支持本地 `stdio` 传输;其他传输在兼容性测试后增加。外部 Server 的 Tool 名称进入项目注册表前添加 Plugin 命名空间,并校验 JSON Schema、权限、重复 ID 及其与 Manifest Contribution 的一致性。MCP 调用复用项目自己的 Permission、超时、Agent Trace、日志净化和结果大小限制;子进程环境按白名单裁剪,不传入 Provider Key、Vault 或数据库路径。
### 12.6 Frontend Extension Slot
前端预留受控扩展点:
@@ -1066,6 +1160,8 @@ Plugin Storage API 负责访问该目录。插件不能通过自身目录拼接
需要保存密钥的 Plugin 通过 Secret API 请求独立 Credential ID,由 Stronghold 保存实际值。
第二阶段 Plugin 安装记录、启停状态、授权、配置 Schema 版本和 Contribution 元数据需要持久化。应用启动时先恢复元数据,再启动已启用的 Host;恢复失败的 Plugin 保持隔离并标记为 `error`,不能留下已注册但没有可用执行后端的 Tool 或 Command。
---
## 13. Provider Adapter 与模型接入
@@ -1146,6 +1242,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 +1302,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 +1329,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 +1867,8 @@ POST /api/index/rebuild
GET /health
```
第一阶段已实现路径和第二阶段冻结草案分别见 `../contracts/后端接口契约-开发版.md``../contracts/第二阶段接口契约-开发版.md`。规划接口完成前不得在前端假定其可用;完成后以 OpenAPI、Pydantic Contract 和 TypeScript Wire DTO 的一致结果为准。
HTTP 返回统一错误结构:
```json
@@ -1793,6 +1943,8 @@ ainote/
│ │ ├── skills/
│ │ ├── providers/
│ │ ├── media/
│ │ ├── extensions/
│ │ ├── export/
│ │ └── database/
│ └── tests/
@@ -1813,7 +1965,11 @@ ainote/
├── benchmarks/
│ ├── rag/
│ │ ├── datasets/
│ │ └── reports/
│ └── agent/
│ ├── datasets/
│ └── reports/
├── infra/
│ └── cloud/
@@ -1832,6 +1988,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 +2060,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 +2093,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 +2114,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 +2239,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 +2309,7 @@ SQLite 索引损坏或版本不一致时,应用允许重建 `.ainote/app.db`
## 23. 当前版本实施边界
第一阶段开发围绕能够形成完整桌面知识工作流的能力展开
第一阶段围绕可联调的本地知识工作流建立基础接口
```text
Markdown Workspace
@@ -2124,23 +2327,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 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 92 项测试、前端 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 Bridgestdio 首版已实现)
├── 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
```
上述列表描述第二阶段技术范围,其中 stdio MCP Bridge 已实现,其余能力以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
第三阶段处理:
```text
@@ -2154,7 +2376,7 @@ Plugin 安装清单同步
Skill 分发
Plugin 分发与社区仓库
Plugin Sidebar Panel 等前端扩展点
Theme 社区
联网 Theme Marketplace
OCR 深度集成
更多多模态能力
扩展协议
@@ -2162,7 +2384,7 @@ OCR 深度集成
Sync Server 按独立服务开发和部署,不进入桌面客户端核心启动依赖。第一版同步完成文件级 Revision、多设备增量同步和冲突保留后,再评估端到端加密与实时协同编辑。
阶段划分用于安排开发顺序。模块接口在第一阶段完成时确定基础版本,后续功能通过现有接口扩展
阶段划分用于限定交付范围。人员分工、任务顺序和协作安排以阶段分工表为准,不在技术栈说明中重复维护;本文只维护技术选型、模块边界和跨模块 Contract
---
@@ -2191,10 +2413,12 @@ Sync Server 按独立服务开发和部署,不进入桌面客户端核心启
## 25. 当前技术基线摘要
目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引任务Agent TraceProvider/Extension Registry 当前仍为内存实现。
目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引任务Agent TraceProvider/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 与 OllamaOpenAI 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,并已通过 stdio MCP Bridge 接入独立进程 Tool、Host 状态与重启接口;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
- [x] MCP Server 能通过 MCP Bridge 注册 Tool
- [x] 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 管理的真实单 VaultTauri 原生目录选择和多 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-09-01。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 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 |
@@ -61,6 +76,8 @@
| POST | `/api/plugins/{plugin_id}/enable` | 启用 Plugin |
| POST | `/api/plugins/{plugin_id}/disable` | 停用 Plugin |
| PUT | `/api/plugins/{plugin_id}/permissions` | 设置 Plugin 已授权权限 |
| GET | `/api/plugins/{plugin_id}/host` | 获取隔离 MCP Host 状态、工具数和协商信息 |
| POST | `/api/plugins/{plugin_id}/host/restart` | 重启 MCP Host 并重新发现、校验和注册 Tool |
| DELETE | `/api/plugins/{plugin_id}` | 卸载 Plugin |
### Provider
@@ -159,12 +176,17 @@ RunCancelled
## 当前实现状态
更新至 2026-08-30:后端 71 项回归测试通过。
更新至 2026-09-01:后端 92 项回归测试通过。
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
- Agent Run/Event 已持久化到 SQLiteSSE 帧携带 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 启用前必须通过权限接口记录授权,未知权限默认拒绝。
- 本地 stdio MCP Server 已通过独立子进程接入 Plugin RuntimeAgent 只消费内部 Tool Contract。Host 支持 initialize、分页发现、调用、超时取消、状态查询、重启和异常退出后的 Tool 注销。
- 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、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 92 项测试通过。
## 当前实现
@@ -28,16 +28,19 @@ 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
├── extensions/
── runtime.py Skill/Plugin Manifest、生命周期、依赖与 Tool Contribution
── runtime.py Skill/Plugin Manifest、生命周期、依赖与 Tool Contribution
│ └── mcp.py stdio JSON-RPC、MCP 生命周期、发现、调用与 Host 隔离
└── container.py AI Core 依赖组装
backend/extensions/
├── skills/knowledge-assistant/ 内置知识库 Skill
── plugins/text-tools/ 内置示例 Plugin
── plugins/text-tools/ 内置声明式 Plugin
└── fixtures/mcp-echo/ 离线 MCP Server 联调 Fixture
```
Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider 业务逻辑。
@@ -53,9 +56,10 @@ 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
- stdio MCP Bridge、隔离进程生命周期、Tool 映射与 Host 健康状态;
- Skill 调用内置 Tool 与 Plugin Tool
- 公共 Contract 和 API 接入。
@@ -64,7 +68,7 @@ Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider
- Note、NoteBlock、Markdown Parser:由 Knowledge Core 提供;
- FTS5、Vector、RRF、Reranker、Citation:由 Retrieval Core 提供;
- 文件系统和 API Key 明文读取:由 Rust Host 提供;
- MCP Plugin Host、Frontend Extension Slot:按技术基线放在第二阶段实现。
- Frontend Extension Slot 与 Plugin Command/Settings:按第二阶段后续阶段实现。
## Provider
@@ -171,10 +175,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
@@ -298,7 +305,7 @@ DELETE /api/skills/{skill_id}
### Plugin Runtime
第一阶段 Plugin Runtime 完成 Manifest 校验、安装、启用、停用、卸载和 Tool Contribution。第三方代码不会直接 import 到 AI Core;当前 Declarative Plugin Host 只执行宿主实现的白名单 handler,MCP Host 留到第二阶段
第一阶段 Plugin Runtime 完成 Manifest 校验、安装、启用、停用、卸载和声明式 Tool Contribution。阶段 C 增加 stdio MCP Bridge第三方代码不会直接 import 到 AI Core,而由独立子进程运行,通过换行分隔 JSON-RPC 完成 initialize、Tool 发现和调用
启用 Plugin 时将 Tool 注册到统一 Tool Registry,并标记 `source=plugin`;停用或异常时注销 Tool。启用中的 Skill 依赖某 Plugin Tool 时,Plugin 不能直接卸载。
@@ -311,11 +318,15 @@ GET /api/plugins/{plugin_id}
POST /api/plugins/{plugin_id}/enable
POST /api/plugins/{plugin_id}/disable
PUT /api/plugins/{plugin_id}/permissions
GET /api/plugins/{plugin_id}/host
POST /api/plugins/{plugin_id}/host/restart
DELETE /api/plugins/{plugin_id}
```
Plugin Manifest 中的权限只是声明,不代表已经授权。带权限的 Plugin 安装后进入 `permission_required`,Host 必须通过权限接口记录用户授权,之后才能启用。JSON Schema 在安装阶段校验,Tool 调用时再次校验实际参数。
MCP Tool 进入 Registry 前统一增加 `<plugin_id>.<remote_name>` 命名空间。Server 声明的 `notesagent/permission` 必须属于已知权限并出现在 Plugin Manifest;发现集合还必须与 Manifest Contribution 完全一致。启用失败会回滚全部 Tool 并关闭子进程,异常退出会把 Plugin 标记为 `error` 并立即注销对应 Tool。详细实现和 Fixture 操作见 [MCP Bridge 与 Plugin Host 开发说明](MCP-Bridge与Plugin-Host开发说明.md)。
内置示例 `text-tools` 注册 `text.uppercase`。内置 `knowledge-assistant` Skill 同时声明 `notes.search``text.uppercase`,用于验证完整链路:
```text
@@ -333,9 +344,9 @@ Skill Manifest
- 已实现 Mock、OpenAI-Compatible Chat Completions 与 Ollama AdapterOpenAI Responses 和 Anthropic Messages 尚未实现。
- Provider 配置暂存内存,后续通过 Repository 接入 SQLitePATCH 已支持用显式 `null` 清空 base URL、默认模型和凭据引用。
- Run/Trace 暂存内存;下一步抽象 Repository 接入 SQLite。
- Run/Trace 已通过 Repository 接入 SQLite;后续增加按保留策略归档和 Benchmark 引用保护
- Permission 已有核心等待/恢复机制,前端确认 UI 已完成联调和中文展示。
- Task 已持久化到 SQLiteAttachment Tool 读取 Host 管理目录中的 UTF-8 文件。
- `audio.transcribe` 当前消费 Host 预生成的 transcriptfaster-whisper 与说话人分离仍按技术基线在第二阶段接入。
- Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。
- 当前 Plugin Host 支持内置声明式白名单 handler;MCP Bridge、独立进程健康检查与 UI Contribution 在第二阶段实现
- 当前 Plugin Host 支持内置声明式 handler 和本地 stdio MCP ServerStreamable HTTP、OS 级沙箱、Plugin Command/Settings 与 UI Contribution 留在后续阶段
@@ -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;完整后端回归基线为 92 项测试通过。
## 当前实现
@@ -0,0 +1,282 @@
# MCP Bridge 与 Plugin Host 开发说明
> 更新日期:2026-09-01。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。Plugin Command 与 Settings 属于阶段 D,不在本文实现范围内。
## 1. 目标与实现状态
阶段 C 的目标是让外部 MCP Server 进入既有 Plugin、Tool、Permission、Agent 和 Trace 链路,同时避免 Agent Runtime、前端或 Benchmark 直接依赖 MCP 原始消息。
当前链路:
```text
Plugin Manifest
→ Plugin Runtime
→ 独立 stdio MCP Server 进程
→ initialize / capability negotiation
→ tools/list 分页发现与校验
→ NotesAgent ToolDefinition
→ Tool Registry / Permission Manager
→ Agent Runtime / Agent Trace
```
已经实现:
- 本地 stdio 子进程启动、关闭和异常退出检测;
- UTF-8、换行分隔的 JSON-RPC 2.0 消息;
- initialize、协议版本与 tools capability 协商;
- `notifications/initialized`
- 分页 `tools/list`
- `tools/call`、业务错误与 JSON-RPC 错误转换;
- 超时和 `notifications/cancelled`
- Tool 命名空间、JSON Schema、权限和 Manifest 集合校验;
- Host 状态查询、重启和异常后的 Tool 自动注销;
- stderr 隔离、环境变量裁剪、消息及结果大小限制;
- 无网络、无密钥的确定性 MCP Fixture。
实现依据为 MCP 官方 [Lifecycle 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)、[Transports 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) 和 [Tools 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)。
## 2. 代码位置
```text
backend/app/extensions/mcp.py
stdio 进程、JSON-RPC、MCP 生命周期、发现、调用和 Host 状态
backend/app/extensions/runtime.py
Plugin Manifest、权限、MCP Tool 批量注册/回滚和生命周期集成
backend/app/agent/tools.py
内部 Tool 参数校验、结构化执行错误和线程安全 Registry
backend/extensions/fixtures/mcp-echo/
确定性 stdio MCP Server 与 Plugin Manifest
```
## 3. Plugin Manifest
MCP Plugin 的后端配置示例:
```yaml
id: example-mcp
name: Example MCP
version: 1.0.0
permissions:
- notes.read
contributes:
tools:
- example-mcp.search
backend:
type: mcp
transport: stdio
command: uvx
args: [--isolated, --from, example-mcp==1.2.3, example-mcp]
startup_timeout_seconds: 60
tool_timeout_seconds: 30
```
约束:
- 阶段 C 只接受 `type: mcp``transport: stdio`
- 命令和参数通过数组直接传给 `subprocess.Popen`,不经过 Shell
- Python 包形式的 MCP Server 推荐使用 `uvx --isolated --from <package>==<version> <command>`,固定版本并与 NotesAgent 项目环境隔离;
- Plugin 包内自带且不需要第三方依赖的 Python 脚本可以使用 `python server.py`Node、Rust 等 Server 继续使用各自受控启动器,因此 Host 不强制所有 MCP 都经过 `uvx`
- PATH 中的 executable 使用名称,例如 `uvx``python``node`
- manifest 中带目录的 executable 必须解析到 Plugin 包内部;
- `contributes.tools` 使用 `<plugin_id>.<remote_name>`
- 安装阶段只读 Manifest,不启动第三方进程;
- 完成用户授权后,`enable` 才启动 Host。
## 4. 生命周期
### 4.1 启动
启用 MCP Plugin 时依次执行:
1. 检查 Plugin 声明权限是否全部获得授权;
2. 检查 Manifest 声明的 Tool ID 是否与现有 Registry 冲突;
3. 启动独立 stdio Server
4. 发送 `initialize`
5. 校验协商版本和 `tools` capability
6. 发送 `notifications/initialized`
7. 分页读取 `tools/list`
8. 校验全部 Tool
9. 确认发现集合与 Manifest 完全一致;
10. 将完整集合注册到 Tool Registry
11. Plugin 和 Host 进入 `ready`
任何步骤失败都会注销本轮已注册 Tool、关闭子进程并把 Plugin 标记为 `error`,不会留下半启用状态。
### 4.2 停止与异常退出
停用、卸载或应用关闭时,先注销 Tool,再关闭 stdin,等待 Server 正常退出。超时后依次 terminate 和 kill。
Server 异常退出、stdout 出现非 JSON-RPC 内容或发送超大协议消息时:
- 未完成请求返回 `PLUGIN_HOST_UNAVAILABLE`
- Host 进入 `unhealthy`
- Plugin 进入 `error`
- 对应 Tool 从 Registry 中立即注销;
- 用户可以调用 Host Restart 接口重新协商和发现。
Server 发送 `notifications/tools/list_changed` 时不会直接信任新集合。当前实现先把 Host 标记为不健康并注销旧 Tool,要求通过 Restart 重新执行完整发现与校验。
## 5. Tool Contract 转换
MCP Tool
```json
{
"name": "search",
"description": "Search notes",
"inputSchema": { "type": "object", "properties": {} },
"_meta": { "notesagent/permission": "notes.read" }
}
```
进入系统后转换为:
```json
{
"name": "example-mcp.search",
"description": "Search notes",
"parameters": { "type": "object", "properties": {} },
"permission": "notes.read",
"source": "plugin"
}
```
转换规则:
- 远端名称必须能转换为合法且稳定的项目 Tool ID;
- `inputSchema` 必须是有效的 object JSON Schema
- `additionalProperties``patternProperties` 等动态字段先由完整 JSON Schema 校验,Pydantic 参数载体不会再次误拒绝合法字段;
- `_meta.notesagent/permission` 必须属于项目已知权限;
- Tool 权限必须同时出现在 Plugin Manifest 中;
- Agent 仍通过 Tool Registry 执行参数校验、Permission、超时和 Trace
- MCP `structuredContent` 存在时映射为内部 output;否则保留为受控 `content` 数组;
- MCP `isError: true` 映射为 `MCP_TOOL_CALL_FAILED`
- 结果超过 256 KiB 映射为 `MCP_TOOL_RESULT_TOO_LARGE`
## 6. 隔离与安全边界
当前隔离是“独立进程 + 协议边界”,不是完整的操作系统沙箱。
`uvx` 解决的是 Python 工具依赖隔离:它等价于 `uv tool run`,在 uv 缓存中使用可丢弃的独立虚拟环境。它不会限制 Server 读取用户文件、访问网络、创建子进程或调用系统 API,因此不能代替安全沙箱。当前开发模式下,首次 `enable` 尚未缓存的包可能访问包索引,因此示例使用 60 秒启动上限;生产实现不得依赖该行为,必须在用户确认后的安装/更新阶段预取和验证固定版本,运行阶段只启动已准备好的环境。
已经执行的保护:
- 第三方模块不 import 到 AI Core
- 子进程 `cwd` 固定为 Plugin 包目录;
- 不使用 Shell 拼接命令;
- 不把 Provider API Key、`APP_DB_PATH`、Vault 路径和其他宿主环境变量传入子进程;
- stderr 与 JSON-RPC stdout 分离,stderr 不进入 API 和 Agent Trace
- stdout 只能发送合法 MCP JSON-RPC
- stdout 在读取完整行前即应用有界读取,单条协议消息上限 2 MiB;stderr 也按固定大小分块读取;
- 单次 Tool Result 上限 256 KiB
- MCP Tool 不绕过 Permission Manager 和 Agent Tool Timeout。
- 调用被 Agent 取消时,同时通知 Server 并唤醒本地 pending Queue,阻塞线程不会继续占用线程池直至远端超时。
当前尚未提供容器、受限系统账户、seccomp、Windows AppContainer 或 macOS Sandbox,因此 Plugin 进程仍具有当前操作系统用户授予的一般文件访问能力。正式社区插件分发或“一键安装”前必须完成以下安全门槛:
- 由 Tauri/Rust Host 统一启动进程并提供平台级文件、网络、子进程和资源配额限制;
- 安装/更新时完整展示 executable 与全部参数,明确警告并要求用户主动确认;
- 固定包来源和版本,增加包哈希/签名与可信发布者校验;
- 默认禁止访问 Vault、凭据和宿主环境,只通过声明 Permission 与受控 Host API 授权;
- 关闭 Host 时终止完整进程树,不只结束直接子进程。
在这些门槛完成前,当前 MCP Host 只适用于内置 Fixture、团队可信插件和开发联调;不得把它描述为可以安全执行任意社区代码。上述安装确认要求遵循 MCP [SEP-1024](https://modelcontextprotocol.io/seps/1024-mcp-client-security-requirements-for-local-server-)`uvx` 行为依据 uv 官方 [Using tools](https://docs.astral.sh/uv/guides/tools/) 文档。
后端通过 `APP_ENVIRONMENT` 强制该边界:只有 `development` 可以启动当前未沙箱化的 MCP Host;其他环境返回 `403 MCP_TRUST_APPROVAL_REQUIRED`,且不会创建进程或注册 Tool。后续 Tauri/Rust Host 提供沙箱与绑定完整命令摘要的可信许可后,再替换此临时门禁。
## 7. Host API
```http
GET /api/plugins/{plugin_id}/host
POST /api/plugins/{plugin_id}/host/restart
```
状态响应包含:
```text
plugin_id
backend_type / transport
status
tools_count
started_at / last_seen_at
protocol_version
server_name / server_version
error
```
状态值:
```text
stopped
starting
ready
unhealthy
error
```
Restart 返回 `202 OperationResponse`。接口返回前已完成本地 Host 重启和 Tool 重新发现;`message` 中给出最终 Host 状态。Restart 只用于运行中或异常 Host;用户主动停用、尚未启用或等待授权的 Plugin 返回 `409 PLUGIN_HOST_UNAVAILABLE`,必须通过 Enable 明确启动。
## 8. 离线 Fixture
Fixture 位于:
```text
backend/extensions/fixtures/mcp-echo
```
它提供:
- `mcp-fixture.echo`:返回 structuredContent
- `mcp-fixture.fail`:返回 `isError: true`
- `mcp-fixture.sleep`:验证超时和取消;
- `mcp-fixture.large`:验证结果大小上限;
- `mcp-fixture.environment`:验证宿主 Secret/路径没有进入子进程;
- `mcp-fixture.exit`:验证异常退出、Tool 注销和 Restart。
Fixture 的 `tools/list` 使用两页响应,用于覆盖分页发现。测试还会启动缺少 tools capability、返回无效 Schema/initialize result,以及输出超长无换行 stdout 的变体。
## 9. 验证
```powershell
cd backend
uv run python -m compileall -q app
uv run pytest
cd ../frontend
pnpm test
pnpm type-check
pnpm build
```
阶段 C 新增测试覆盖:
- initialize、版本和 capability negotiation
- 分页 `tools/list` 与命名空间映射;
- Permission、JSON Schema 与 Contribution 集合;
- Tool 成功、业务错误、结果过大和超时;
- Agent 取消后 pending 等待线程及时释放;
- `additionalProperties` 动态参数保持 JSON Schema 语义;
- Agent Runtime 调用 MCP Tool 并写入正式 Trace
- Secret/Vault 环境隔离;
- Server 异常退出、Tool 注销和 Host Restart
- 缺少 capability、无效 initialize result、无效 MCP Schema 和超长无换行 stdout
- disabled Plugin 不会被 Host Restart 隐式重新启用;
- OpenAPI 发布 Host 状态和重启路径。
## 10. 当前边界与后续阶段
阶段 C 不包含:
- Streamable HTTP MCP transport
- Resources、Prompts、Sampling、Elicitation 和 MCP Tasks
- Plugin Command 与 Settings Contribution
- Secret Reference 注入;
- Plugin Registry 持久化、签名与社区来源校验;
- 操作系统级沙箱;
- 一键安装前的完整命令展示与确认 UI;
- Tool 列表热更新的无中断替换。
阶段 D 将在当前 Plugin Runtime 上继续增加 Command、Settings、Secret Contract 和命名空间 Storage,不修改 Agent 使用内部 Tool Contract 的原则。
@@ -71,6 +71,12 @@ Milkdown 自定义插件在写作模式中隐藏 HTML 标记,并通过 ProseMi
代码高亮使用 Shiki 的 JavaScript 正则引擎,并只注册第一阶段常用语言:Markdown、HTML、CSS、JavaScript、TypeScript、JSON、Python、Shell 和 SQL。未知语言回退为 Markdown 语法展示,不阻塞整篇内容渲染。
Shiki 同时生成 `github-light``github-dark` 两套 CSS 变量。主题页提供“跟随主题 / GitHub Light / GitHub Dark”选项,通过根节点 `data-code-theme` 切换对应变量,无需重新执行高亮。偏好写入 `editor-appearance`,内置主题和后续主题包也可通过 `ThemeConfig.code_theme` 指定默认代码主题。
代码主题选择器下方使用真实的 `MarkdownContent` 和 Shiki 渲染 TypeScript 示例,选项变化后立即展示对应 GitHub 高亮效果。该预览只存在于主题设置页,不会恢复写作页代码块的额外预览面板。
代码块容器使用 GitHub 风格的背景、边框、6px 圆角、16px 内边距和等宽字体;相关颜色由 `--color-code-*` Token 控制,方便主题商店覆盖。
高亮结果同时生成 `github-light``github-dark` 颜色变量。根节点的 `data-theme` 变化后由 CSS 选择对应颜色,因此切换主题无需重新解析整篇 Markdown。
写作编辑器中的普通代码块进入文档后直接展开 CodeMirror 编辑区,不再先显示 Shiki 预览,也不再提供“编辑代码/查看高亮”切换,减少一次多余操作。公式块仍由 Milkdown 的 LaTeX 功能负责编辑和渲染。
@@ -109,7 +115,7 @@ pnpm test
文件切换失效包含两层原因。第一层是旧实现先更新 `currentFilePath`、后等待文件内容,导致编辑器使用新路径和旧内容提前重建;现在改为文件读取成功后一次性提交路径和内容。第二层是工作区欢迎笔记的异步初始化结束后会无条件设为活动文件,可能覆盖用户在此期间的真实点击;现在点击文件时立即同步工作区活动路径,默认初始化仅在用户尚未选择文件且欢迎笔记确实加载成功时提交。文件读取失败时则恢复点击前的活动文件。
本地内置浏览器测试运行时因环境资源路径缺失未能启动,因此本次没有把自动化交互测试列为已通过项。合并前建议人工检查一次工具栏选区操作、文件切换同步和亮暗主题下的代码块显示
本地内置浏览器测试运行时因环境资源路径缺失未能启动,因此本次没有把自动化交互测试列为已通过项。合并前建议人工检查一次工具栏选区操作、文件切换同步,以及跟随主题、GitHub Light、GitHub Dark 三种代码块设置下的显示效果
## 5. 后续建议
@@ -36,7 +36,7 @@ Vue Router
- OpenAI、DeepSeek、Ollama 预设、自动模型发现和开发阶段加密 API Key 输入;
- 智能体页面、运行状态、事件、工具和权限详情的中文展示。
原统一占位页已经删除,所有已注册业务路由均指向真实页面。当前 Workspace 文件能力仍使用 Web Mock AdapterTauri 文件系统、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 14 passed
uv run pytest 71 passed
pnpm test 27 passed
uv run pytest 92 passed
preview smoke HTTP 200
git diff --check passed
```
当前前端使用 Vitest 执行 Store、Workspace、文件树编辑器组件测试;`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 权限警告,不影响 92 项测试结果,也不涉及产品代码。
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
@@ -0,0 +1,113 @@
# 前端视觉与轻量动效优化开发说明
> 更新日期:2026-08-30
> 适用范围:全局 Design Token、App Shell、功能页、卡片、表单、弹窗和轻量交互动效
## 1. 目标
本轮优化不改变页面功能和前后端契约,主要解决原界面层级偏平、组件间距不统一、交互反馈不足的问题,同时为后续主题商店 CSS 注入保留稳定边界。
设计原则:
- 颜色、圆角、阴影、间距和速度继续使用 CSS 变量;
- 页面与弹窗动画只改变 `opacity``transform`
- 不使用背景模糊、连续粒子、视差、复杂 SVG 或大范围布局动画;
- 不使用 `transition: all`,只声明需要变化的属性;
- 尊重系统 `prefers-reduced-motion` 设置;
- 主题只需覆盖现有 Token,不需要了解组件内部动画实现。
## 2. 全局视觉基线
更新 `tokens.css`
- 调整四级圆角和阴影,使卡片、弹窗与导航层次更清楚;
- 调整标题栏、状态栏和双侧栏尺寸;
- 统一更短的运动时间与缓动曲线;
- 增加键盘 `focus-visible` 焦点环;
- 为 checkbox、radio 和 range 使用主题强调色;
- 窄窗口下收缩辅助侧栏与展开导航宽度;
- 在减少动态效果模式下,把动画和过渡缩短到近似即时完成。
更新 `features.css`
- 功能页增加受控内容宽度、响应式留白和低强度主题渐变;
- 卡片增加边框、阴影与最多 2px 的悬浮位移;
- 按钮、输入框、Badge、空状态和通知统一交互反馈;
- 设置页导航改为分段式卡片导航;
- 弹窗与页面增加一次性淡入和轻微位移动画。
## 3. 页面与布局优化
### 3.1 App Shell
- 主侧栏增加明确的 Active 标记、悬浮反馈和宽度过渡;
- 辅助侧栏统一为次级 Surface,并改善标题与标签层级;
- 标题栏应用名改为轻量胶囊标识;
- 状态栏强化状态点和窄窗口降级;
- 命令面板增加轻量入场、圆角、阴影和列表反馈。
### 3.2 业务页面
- Search 结果卡片增加左侧强调线和统一内容宽度;
- Chat 增加消息容器、头像层级、Citation 悬浮反馈和 Composer 顶部阴影;
- Agent Tool 选择卡增加选中状态,Trace 使用轻量时间线;
- Task 完成按钮增加主题化状态反馈;
- Settings 行在悬浮时提供背景提示;
- Workspace 空状态与 Vault 入口增加清晰的层级和一次性入场动画。
## 4. 动效性能边界
允许的常规动效:
```text
opacity
transform: translate / scale / rotate
background-color
border-color
color
box-shadow
```
默认禁止:
```text
transition: all
backdrop-filter / filter 模糊
持续改变 width / height / margin / padding 的动画
无限循环的装饰动画
全屏高频渐变或粒子动画
```
状态栏 Spinner 和 AI Core 检查状态点属于有明确状态含义的循环动画,并会被 `prefers-reduced-motion` 全局规则降级。
## 5. 主题商店接入约定
自定义主题优先覆盖以下 Token
- `--color-background-*`
- `--color-surface-*`
- `--color-text-*`
- `--color-accent-*`
- `--color-border-*`
- `--color-code-*`
- `--shadow-*`
- `--radius-*`
- `--motion-*`
主题 CSS 不应给通配选择器增加动画,不应重新启用高成本滤镜,也不应覆盖 `prefers-reduced-motion` 的降级规则。若主题需要完全静态的界面,可把三个 `--motion-*` Token 设置为接近 0ms。
## 6. 验证
新增 `styles/motion.spec.ts`,防止全局样式重新引入 `transition: all`、高成本模糊滤镜或布局型页面入场动画,并约束 Markdown 表格与列表使用独立的高对比度主题变量。
当前验证结果:
```text
pnpm test 9 files / 23 tests passed
pnpm build passed
git diff --check passed
```
生产构建仍有已有的大 Chunk 警告,主要来自 Milkdown、CodeMirror、KaTeX 和 Shiki;本轮样式及动效未增加 JavaScript 动画库或运行时依赖。
本轮已完成样式静态检查、自动化测试和生产构建。由于本机内置浏览器运行资源路径缺失,亮色/暗色主题的人工页面巡检需在 PR 验收环境补做。
@@ -104,4 +104,4 @@ pnpm build
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
当前完整回归基线:后端 71 项测试、前端 14 项测试通过,前端生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
当前完整回归基线:后端 92 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
+166
View File
@@ -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) 中的状态检查与产物要求。
+43
View File
@@ -0,0 +1,43 @@
# 代码注释与 TODO 约定
本文用于统一团队在前后端代码中编写注释和待办项的方式。注释应解释设计意图、边界条件和不明显的取舍,不重复代码本身已经清楚表达的内容。
## 注释原则
- 模块或核心类说明其职责和边界,例如 Agent 编排器、工具执行边界、凭据存储边界。
- 异步流程说明顺序、快照、去重、回滚和竞态处理原因。
- 安全相关流程说明默认拒绝、权限收敛、输入净化和凭据优先级。
- 简单赋值、显然的条件判断、类型定义和展示模板不添加翻译式注释。
- 注释随实现一并维护;实现变化后已经失真的注释应在同一提交中修改或删除。
## TODO 格式
前端使用:
```ts
// TODO(editor): 描述尚未完成的能力、完成条件或替换目标。
```
后端使用:
```python
# TODO(agent): 描述尚未完成的能力、完成条件或替换目标。
```
领域标签使用小写英文,当前约定包括 `agent``ai-core``chat``desktop``editor``extension``performance``security``streaming`。一个 TODO 应对应真实存在的工程缺口;小型清理工作直接完成,不长期保留无负责人、无目标的占位待办。
## 当前待办索引
以下内容可通过 `rg "TODO\\(" backend/app frontend/src` 定位,代码中的注释是最新状态:
| 领域 | 当前边界 |
| --- | --- |
| Agent / Streaming | 运行事件仍在进程内保存,后续需要持久化、`Last-Event-ID` 和断线重连 |
| Security | 本地主密钥目前保存在数据目录,桌面端接入后迁移到系统凭据库 |
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
| Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 |
| Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 |
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
TODO 完成后应删除对应代码注释并同步更新本索引;若工作超过一个提交,应建立 Issue,并在 Issue 中引用代码位置,而不是在源码中记录长篇设计讨论。
@@ -0,0 +1,378 @@
# 第一阶段测试验证操作手册
> 适用基线:2026-08-30 `main`
> 适用对象:开发、自测、代码审阅、合并验收和 Demo 前检查
> 验证范围:Vue Web 前端、FastAPI、Knowledge/Retrieval Core、AI/Agent Core、Extension Core、Provider 与开发阶段凭据链路
## 1. 验证目标
本手册用于确认第一阶段已经形成可运行的本地知识工作流:
```text
启动前后端
→ 编辑 Markdown
→ 建立或更新索引
→ Search / RAG 返回 Citation
→ Chat 或 Agent 调用统一 Provider
→ Agent 展示 Trace、Tool 和 Permission
→ Skill / Plugin 完成生命周期与 Tool 注册
```
当前不作为第一阶段通过条件的内容:Tauri/Rust Host、Stronghold、真实桌面文件系统、独立 MCP Plugin Host、真实音频模型和 Sync Server。
## 2. 环境准备
最低环境:
| 工具 | 要求 |
| --- | --- |
| Git | 较新稳定版 |
| Node.js | 22 或更高版本 |
| pnpm | 10 或更高版本 |
| Python | 3.11 或更高版本 |
| uv | 较新稳定版 |
在仓库根目录检查版本:
```powershell
git --version
node --version
pnpm --version
python --version
uv --version
```
同步依赖:
```powershell
cd backend
uv sync --frozen
cd ../frontend
pnpm install --frozen-lockfile
cd ..
```
`uv sync` 会自动创建和管理 `backend/.venv`,不需要手动创建或激活虚拟环境。
## 3. 自动化验收
### 3.1 后端测试
```powershell
cd backend
uv run pytest -q -p no:cacheprovider
```
当前基线:
```text
81 passed
```
通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。
### 3.2 前端测试
```powershell
cd frontend
pnpm test
```
当前基线:
```text
11 test files passed
27 tests passed
```
通过标准:退出码为 0、失败数为 0。测试覆盖 Provider Store、主题偏好、Workspace、文件树、文件切换、可视化编辑器、智能体中文标签、轻量动效性能约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题输出。
### 3.3 类型检查与生产构建
```powershell
cd frontend
pnpm build
```
通过标准:`vue-tsc -b``vite build` 均成功,生成 `frontend/dist`。当前较大的编辑器与 Markdown Chunk 会产生体积警告,该警告不等于构建失败,但应记录在验收结果中。
### 3.4 Git 与文档检查
```powershell
cd ..
git diff --check
git status --short
```
通过标准:`git diff --check` 没有错误。测试产生的 `.venv``node_modules``dist`、凭据和运行数据不得进入提交。
## 4. 启动联调环境
打开两个 PowerShell 终端。
终端 A
```powershell
cd backend
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
终端 B
```powershell
cd frontend
pnpm dev
```
访问:
- 前端:<http://127.0.0.1:5173>
- 健康检查:<http://127.0.0.1:8000/health>
- 服务状态:<http://127.0.0.1:8000/api/status>
- Swagger UI<http://127.0.0.1:8000/docs>
- OpenAPI<http://127.0.0.1:8000/openapi.json>
快速检查:
```powershell
Invoke-RestMethod http://127.0.0.1:8000/health
Invoke-RestMethod http://127.0.0.1:8000/api/status
```
如果 `/docs` 返回 `RESOURCE_NOT_FOUND`,检查启动命令是否在 `backend` 目录执行、端口 8000 是否被其他程序占用,以及浏览器地址是否确实为 `http://127.0.0.1:8000/docs`
## 5. 后端主链路验证
以下命令在第三个 PowerShell 终端执行,保持后端运行。
### 5.1 Note 创建、读取与检索
```powershell
$apiBase = 'http://127.0.0.1:8000/api'
$noteBody = @{
title = '第一阶段验收笔记'
markdown = "# 第一阶段验收`n`nNotes Agent 支持混合检索和可定位引用。"
folder = '验收'
tags = @('phase-1', 'verification')
} | ConvertTo-Json
$note = Invoke-RestMethod -Method Post -Uri "$apiBase/notes" -ContentType 'application/json' -Body $noteBody
$noteId = $note.note_id
Invoke-RestMethod -Uri "$apiBase/notes/$noteId"
```
执行搜索:
```powershell
$searchBody = @{
query = '混合检索'
mode = 'hybrid'
note_ids = @($noteId)
limit = 10
offset = 0
include_snippet = $true
} | ConvertTo-Json
$search = Invoke-RestMethod -Method Post -Uri "$apiBase/search" -ContentType 'application/json' -Body $searchBody
$search.items | Format-Table title, file_path, snippet
```
通过标准:创建响应包含稳定 `note_id`;读取内容一致;搜索至少返回一项,并包含 `note_id``block_id`、文件路径、Snippet 和 Citation 定位信息。
### 5.2 Index 状态与重建
```powershell
Invoke-RestMethod -Uri "$apiBase/index/status"
$indexJob = Invoke-RestMethod -Method Post -Uri "$apiBase/index/rebuild" -ContentType 'application/json' -Body '{"scope":"all","force":false}'
$indexJob
Invoke-RestMethod -Uri "$apiBase/index/jobs/$($indexJob.job_id)"
```
通过标准:状态接口可访问;重建任务最终为 `completed`。重建失败时应返回统一错误并保留可恢复状态,不应留下半成品索引。
### 5.3 Chat SSE
使用内置 Mock Provider,不需要外部 API Key
```powershell
curl.exe --no-buffer -X POST "http://127.0.0.1:8000/api/chat" -H "Content-Type: application/json" --data-raw '{"provider_id":"mock","model":"mock-1","messages":[{"role":"user","content":"请回复第一阶段 Chat 验收成功"}],"use_rag":false}'
```
通过标准:响应类型为 `text/event-stream`,能看到递增 `sequence``TextDelta`,并以 `Done` 终止;不得一次性伪装为流式结果。
### 5.4 Agent Run 与 Trace
```powershell
$runBody = @{
input = '执行第一阶段 Agent 基础验证'
provider_id = 'mock'
model = 'mock-1'
allowed_tools = @('system.echo', 'math.add')
max_steps = 10
tool_timeout_seconds = 30
run_timeout_seconds = 300
max_concurrent_tools = 1
allow_network = $false
} | ConvertTo-Json
$run = Invoke-RestMethod -Method Post -Uri "$apiBase/agent/runs" -ContentType 'application/json' -Body $runBody
Start-Sleep -Milliseconds 300
$runResult = Invoke-RestMethod -Uri "$apiBase/agent/runs/$($run.run_id)"
$runResult
```
订阅事件也可以使用:
```powershell
curl.exe --no-buffer "http://127.0.0.1:8000/api/agent/runs/$($run.run_id)/events"
```
通过标准:Run 最终为 `completed`,Trace 至少包含开始、文本或工具事件和完成事件;事件序号递增。需要权限的 Tool 应进入 `waiting_permission`,用户允许、会话允许或拒绝后能正确恢复或终止。
### 5.5 Tool、Skill 与 Plugin
```powershell
Invoke-RestMethod -Uri "$apiBase/tools"
Invoke-RestMethod -Uri "$apiBase/skills"
Invoke-RestMethod -Uri "$apiBase/plugins"
```
通过标准:内置 Tool Definition 能被列出;内置知识助手 Skill 和示例 Plugin 状态可读取;未知权限、缺失依赖和畸形 Manifest 必须被拒绝,不能静默启用。
### 5.6 Provider 与模型发现
```powershell
Invoke-RestMethod -Uri "$apiBase/providers"
Invoke-RestMethod -Uri "$apiBase/providers/presets"
Invoke-RestMethod -Uri "$apiBase/providers/mock/models"
```
通过标准:预设至少包含 OpenAI、DeepSeek 和 OllamaMock Provider 能返回模型列表。OpenAI/DeepSeek 属于选测项,需要测试人员自己的有效 API Key,真实密钥不得写入命令历史、文档、Issue、截图或提交。
如需验证外部模型,优先在“设置 → 模型提供商”中选择预设并填写 API Key。页面不得回显明文;后端 `GET /api/credentials/{credential_id}` 只返回 `configured` 状态。测试完成后可在 Swagger 中调用对应 DELETE 接口删除测试凭据。
## 6. 前端人工验收
### 6.1 App Shell 与主题
- 主导航、辅助侧栏、标题栏和状态栏正常显示;
- `Ctrl+P` 能打开命令面板并跳转页面;
- 亮色、暗色和护眼主题切换后文字、表格线、列表序号和浮动工具栏均清晰;
- 导航使用统一图标,不出现无意义 Emoji;
- 窄窗口下主要操作仍可访问。
### 6.2 Workspace 与 Markdown
- 启动 FastAPI 并配置 `APP_VAULT_PATH` 后,能打开后端真实 Vault
- 能在磁盘和 SQLite/FTS/向量索引之间一致地新建、读取、保存、重命名和删除文件及目录;
- 后端不可用时明确报告连接错误,不展示或写入 Mock 文件;
- 连续快速点击不同文件时,路径和正文始终一致;
- 文件切换前的未保存内容不会被错误写入新文件;
- 写作模式不展示 Markdown 源码,源码模式可以精确编辑;
- H1–H6、正文、粗体、斜体、有序/无序列表、行内代码、代码块、行内/块公式、链接和字号输入均能修改 Markdown;
- 选择文本后,顶部工具栏和浮动工具栏都对当前选区生效;
- 正文不默认加粗,标题默认加粗;
- 代码块默认展开编辑,不显示额外 Shiki 预览;
- 主题页可在跟随主题、GitHub Light 和 GitHub Dark 间切换代码块样式,刷新后偏好仍保留;
- Chat 等只读 Markdown 区域的代码高亮能跟随亮暗主题;
- 表格、公式和 Markdown HTML 渲染正常,危险 HTML 被 DOMPurify 清理。
### 6.3 Search、Chat 与 Citation
- FTS、Vector 和 Hybrid 查询可切换;
- 向量服务不可用时能降级到 FTS 并显示说明;
- Chat 能展示 Streaming、Thinking、Tool Call、Usage、错误与 Citation
- 点击 Citation 后能打开对应笔记并定位内容;
- 取消生成后页面状态恢复,不继续追加旧请求内容。
### 6.4 智能体页面
- 能新建、查看、切换和取消智能体运行;
- Provider、模型、Skill、Tool 和运行限制可配置;
- 运行状态、事件类型、工具说明、权限弹窗和常用详情字段显示中文;
- `notes.search` 等技术 ID 保留显示,便于与日志对应;
- Permission Request 不会跨 Run 残留;
- Completed、Failed、Cancelled 和连接中断状态均有明确反馈。
### 6.5 设置与扩展管理
- Provider 预设可选择,保存后自动获取模型,也能手动刷新和选择默认模型;
- 凭据缺失和 HTTP 401 会显示可理解的错误,不只显示笼统网络失败;
- Skill、Plugin 的安装、启用、停用、权限和删除操作状态一致;
- AI Core 诊断页能显示健康状态和开发 API 地址;
- 前端不会在 Store、Local Storage 或页面中保存、回显 API Key 明文。
## 7. 清理测试数据
删除本手册创建的验收笔记:
```powershell
Invoke-RestMethod -Method Delete -Uri "$apiBase/notes/$noteId"
```
如果测试了外部 Provider,还应删除临时 Provider 和不再使用的测试凭据。不要直接递归删除整个 `backend/data`,其中可能包含其他成员的本地 Vault、索引和任务数据。
## 8. 通过判定
第一阶段可以标记为“验证通过”需要同时满足:
- 后端测试零失败;
- 前端测试零失败;
- TypeScript 检查和生产构建成功;
- 健康检查、OpenAPI 和主要接口可访问;
- Note → Index/Search → Citation 主链路通过;
- Mock Chat 和 Agent Trace 主链路通过;
- Tool、Skill、Plugin 和 Provider 基础接口通过;
- 前端人工验收没有 P0/P1 缺陷;
- 没有真实密钥、生成目录或运行数据进入 Git;
- 已记录测试环境、提交、结果、警告和遗留问题。
外部 OpenAI/DeepSeek、Tauri、Stronghold、真实文件系统、真实音频模型与 Sync Server 失败或未测,不阻止当前第一阶段 Web 联调基线通过,但必须在验收记录中注明“未纳入本阶段”或“选测未执行”。
## 9. 验收记录模板
```markdown
# 第一阶段验收记录
- 日期:
- 验收人:
- 分支:main
- 提交:
- 操作系统:
- Node / pnpm
- Python / uv
## 自动化结果
- 后端 pytest:通过 / 失败,数量:
- 前端 Vitest:通过 / 失败,数量:
- 前端 build:通过 / 失败:
- git diff --check:通过 / 失败:
## 主链路
- Health / OpenAPI
- Note / Search / Citation
- Chat SSE
- Agent Run / Trace / Permission
- Tool / Skill / Plugin
- Provider / Models
- 前端页面人工验收:
## 选测项
- OpenAI:未测 / 通过 / 失败
- DeepSeek:未测 / 通过 / 失败
- Ollama:未测 / 通过 / 失败
## 警告与遗留问题
-
## 最终结论
- 通过 / 有条件通过 / 不通过
```
@@ -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-01Agent 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-02Event 裁剪后 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-03SSE 断线后无法恢复
### 原因
旧 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-05Trace 缺少关键执行事实
### 原因
第一阶段事件能够表达 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-06Trace 可能保存 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-09AgentRun 与审计摘要共用限长规则
### 原因
初版使用 `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。
@@ -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 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 92 项测试通过。
## 1. 审阅结论
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#171717" />
<title>Notes Agent</title>
<title>NotesAgent</title>
</head>
<body>
<div id="app"></div>
+2 -1
View File
@@ -73,7 +73,7 @@ defineExpose({ openCitation })
flex-direction: column;
height: 100vh;
width: 100vw;
background: var(--color-background-primary);
background: var(--color-background-secondary);
color: var(--color-text-primary);
}
@@ -89,5 +89,6 @@ defineExpose({ openCitation })
min-width: 0;
overflow: hidden;
background: var(--color-background-primary);
isolation: isolate;
}
</style>
@@ -89,13 +89,17 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</template>
<style scoped>
.command-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: var(--color-background-overlay); }
.command-palette { width: min(600px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); }
.command-input { width: 100%; padding: var(--space-lg); border: 0; border-bottom: 1px solid var(--color-border-default); outline: 0; background: transparent; font-size: var(--font-size-xl); }
.command-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: var(--color-background-overlay); animation: command-backdrop-in var(--motion-fast) both; }
.command-palette { width: min(620px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: command-palette-in var(--motion-normal) both; }
.command-input { width: 100%; padding: var(--space-xl); border: 0; border-bottom: 1px solid var(--color-border-default); outline: 0; background: transparent; color: var(--color-text-primary); font-size: var(--font-size-xl); }
.command-list { max-height: 360px; overflow: auto; padding: var(--space-sm); }
.command-list button { display: flex; justify-content: space-between; width: 100%; padding: var(--space-md); border-radius: var(--radius-md); text-align: left; }
.command-list button { display: flex; justify-content: space-between; width: 100%; padding: var(--space-md) var(--space-lg); border-radius: var(--radius-md); text-align: left; transition: color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast); }
.command-list button:hover, .command-list button:focus { outline: 0; background: var(--color-accent-soft); color: var(--color-accent-primary); }
.command-list button:hover { transform: translateX(2px); }
.command-list small, .command-list p, footer { color: var(--color-text-tertiary); }
.command-list p { padding: var(--space-xl); text-align: center; }
footer { display: flex; gap: var(--space-lg); padding: var(--space-sm) var(--space-lg); border-top: 1px solid var(--color-border-subtle); font-size: var(--font-size-xs); }
@keyframes command-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes command-palette-in { from { opacity: 0; transform: translateY(-8px) scale(.99); } to { opacity: 1; transform: translateY(0) scale(1); } }
</style>
@@ -17,24 +17,33 @@ watch(() => props.source, async (source) => {
<div class="markdown-content" v-html="html" />
</template>
<style scoped>
<style>
.markdown-content { white-space: normal; user-select: text; }
.markdown-content :deep(p), .markdown-content :deep(ul), .markdown-content :deep(ol), .markdown-content :deep(pre), .markdown-content :deep(blockquote) { margin: .65em 0; }
.markdown-content :deep(h1), .markdown-content :deep(h2), .markdown-content :deep(h3) { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
.markdown-content :deep(ul) { padding-left: 1.5em; list-style: disc; }
.markdown-content :deep(ol) { padding-left: 1.5em; list-style: decimal; }
.markdown-content :deep(.shiki) { overflow: auto; padding: var(--space-md); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); }
.markdown-content :deep(code) { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }
.markdown-content :deep(pre code) { padding: 0; background: transparent; }
.markdown-content :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
.markdown-content :deep(table) { width: 100%; margin: .65em 0; border-collapse: collapse; }
.markdown-content :deep(th), .markdown-content :deep(td) { padding: .45em .65em; border: 1px solid var(--color-border-default); text-align: left; }
.markdown-content :deep(img) { max-width: 100%; }
.markdown-content :deep(hr) { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
:global([data-theme='dark']) .markdown-content :deep(.shiki),
:global([data-theme='dark']) .markdown-content :deep(.shiki span) {
.markdown-content p, .markdown-content ul, .markdown-content ol, .markdown-content pre, .markdown-content blockquote { margin: .65em 0; }
.markdown-content h1, .markdown-content h2, .markdown-content h3 { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
.markdown-content ul { padding-left: 1.5em; list-style: disc; }
.markdown-content ol { padding-left: 1.5em; list-style: decimal; }
.markdown-content li::marker { color: var(--color-markdown-marker); font-weight: 700; }
.markdown-content .shiki { overflow: auto; margin: .85em 0; padding: 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background) !important; color: var(--color-code-text); font-family: var(--font-ui-mono); font-size: .875em; line-height: 1.45; tab-size: 4; }
.markdown-content code { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }
.markdown-content .shiki code { display: block; min-width: max-content; padding: 0; background: transparent; font: inherit; }
.markdown-content .shiki .line { display: block; min-height: 1.45em; }
.markdown-content blockquote { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
.markdown-content table { width: 100%; margin: .65em 0; border-collapse: collapse; }
.markdown-content th, .markdown-content td { padding: .45em .65em; border: 1px solid var(--color-markdown-grid); text-align: left; }
.markdown-content th { background: var(--color-markdown-table-header); font-weight: 700; }
.markdown-content img { max-width: 100%; }
.markdown-content hr { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
[data-code-theme='github-light'] .markdown-content .shiki,
[data-code-theme='github-light'] .markdown-content .shiki span {
color: var(--shiki-light) !important;
font-style: var(--shiki-light-font-style) !important;
font-weight: var(--shiki-light-font-weight) !important;
text-decoration: var(--shiki-light-text-decoration) !important;
}
[data-code-theme='github-dark'] .markdown-content .shiki,
[data-code-theme='github-dark'] .markdown-content .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
@@ -60,13 +60,14 @@ function toggleExpanded() {
<style scoped>
.primary-sidebar {
width: 56px;
width: var(--sidebar-primary-width);
background: var(--color-background-secondary);
border-right: 1px solid var(--color-border-subtle);
display: flex;
flex-direction: column;
flex-shrink: 0;
z-index: var(--z-sidebar);
transition: width var(--motion-normal), background-color var(--motion-normal);
}
.primary-sidebar.expanded { width: var(--sidebar-primary-width-expanded); }
@@ -76,7 +77,7 @@ function toggleExpanded() {
.nav-list {
flex: 1;
padding: var(--space-sm) 0;
padding: var(--space-md) 0;
display: flex;
flex-direction: column;
gap: 2px;
@@ -87,31 +88,34 @@ function toggleExpanded() {
flex-direction: column;
align-items: center;
justify-content: center;
height: 50px;
margin: 0 4px;
height: 48px;
margin: 0 6px;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text-secondary);
transition: all var(--motion-fast);
border: 1px solid transparent;
transition: color var(--motion-fast), background-color var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast);
position: relative;
&:hover {
background: var(--color-background-hover);
color: var(--color-text-primary);
transform: translateX(2px);
}
&.active {
background: var(--color-accent-soft);
color: var(--color-accent-primary);
border-color: color-mix(in srgb, var(--color-accent-primary) 16%, transparent);
&::before {
content: '';
position: absolute;
left: -4px;
left: -7px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 24px;
width: 4px;
height: 22px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: var(--color-accent-primary);
}
@@ -127,6 +131,7 @@ function toggleExpanded() {
.nav-label {
font-size: 10px;
line-height: 1.2;
font-weight: 550;
}
.sidebar-footer {
@@ -134,5 +139,5 @@ function toggleExpanded() {
border-top: 1px solid var(--color-border-subtle);
}
.collapse-button { width: calc(100% - 8px); }
.collapse-button { width: calc(100% - 12px); }
</style>
@@ -53,7 +53,7 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
<style scoped>
.secondary-sidebar {
width: var(--sidebar-secondary-width);
background: var(--color-background-primary);
background: var(--color-surface-secondary);
border-right: 1px solid var(--color-border-default);
display: flex;
flex-direction: column;
@@ -62,36 +62,36 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
}
.sidebar-header {
padding: var(--space-md) var(--space-lg);
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border-subtle);
flex-shrink: 0;
}
.sidebar-title {
font-size: var(--font-size-sm);
font-weight: 600;
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 var(--space-sm) 0;
margin: 0 0 var(--space-md) 0;
}
.sidebar-tabs {
display: flex;
gap: 2px;
background: var(--color-background-secondary);
padding: 2px;
padding: 3px;
border-radius: var(--radius-md);
}
.tab {
flex: 1;
text-align: center;
padding: 4px 8px;
padding: 6px 8px;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: none;
transition: all var(--motion-fast);
transition: color var(--motion-fast), background-color var(--motion-fast), box-shadow var(--motion-fast);
&.active {
background: var(--color-surface-primary);
@@ -108,6 +108,7 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
flex: 1;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
</style>
+9 -2
View File
@@ -107,8 +107,8 @@ const showEditorInfo = computed(() => route.name === 'workspace')
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-md);
background: var(--color-background-secondary);
padding: 0 var(--space-lg);
background: var(--color-surface-secondary);
border-top: 1px solid var(--color-border-subtle);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
@@ -129,6 +129,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
gap: 6px;
white-space: nowrap;
cursor: default;
transition: color var(--motion-fast);
&:hover {
color: var(--color-text-primary);
@@ -140,6 +141,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
height: 6px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 2px var(--color-surface-secondary);
}
.agent-status {
@@ -162,4 +164,9 @@ const showEditorInfo = computed(() => route.name === 'workspace')
.provider-info {
color: var(--color-text-tertiary);
}
@media (max-width: 760px) {
.statusbar-left, .statusbar-right { gap: var(--space-sm); }
.provider-info { display: none; }
}
</style>
+22 -9
View File
@@ -25,7 +25,7 @@ const pageTitle = computed(() => {
themes: '主题管理',
settings: '设置',
}
return titles[name] || '知笔知己'
return titles[name] || 'NotesAgent'
})
const currentFileName = computed(() => {
@@ -50,7 +50,7 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
</span>
</div>
<div class="titlebar-center">
<span class="app-name">知笔知己</span>
<span class="app-name">NotesAgent</span>
</div>
<div class="titlebar-right">
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? '切换浅色主题' : '切换深色主题'">
@@ -71,8 +71,8 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-md);
background: var(--color-background-secondary);
padding: 0 var(--space-lg);
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-subtle);
font-size: var(--font-size-sm);
flex-shrink: 0;
@@ -129,7 +129,13 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
}
.app-name {
font-weight: 500;
padding: 3px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-weight: 650;
letter-spacing: .04em;
}
.titlebar-right {
@@ -141,8 +147,8 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
}
.icon-btn {
width: 28px;
height: 28px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
@@ -150,11 +156,12 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
color: var(--color-text-secondary);
font-size: 14px;
-webkit-app-region: no-drag;
transition: background var(--motion-fast);
transition: background-color var(--motion-fast), color var(--motion-fast), transform var(--motion-fast);
&:hover {
background: var(--color-background-hover);
color: var(--color-text-primary);
transform: rotate(8deg);
}
}
@@ -179,7 +186,7 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--motion-fast);
transition: background-color var(--motion-fast), color var(--motion-fast);
&:hover {
background: var(--color-background-hover);
@@ -190,4 +197,10 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
color: white;
}
}
@media (max-width: 760px) {
.titlebar-left, .titlebar-right { min-width: 0; }
.titlebar-center, .window-controls { display: none; }
.file-name { max-width: 42vw; }
}
</style>
+71 -1
View File
@@ -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
@@ -232,6 +255,22 @@ export type PluginStatus =
| 'dependency_missing'
| 'permission_required'
export type PluginHostState = 'stopped' | 'starting' | 'ready' | 'unhealthy' | 'error'
export interface PluginHostStatus {
plugin_id: string
backend_type: 'mcp' | 'internal_rpc' | 'none'
transport: 'stdio' | 'http' | 'none'
status: PluginHostState
tools_count: number
started_at?: string | null
last_seen_at?: string | null
protocol_version?: string | null
server_name?: string | null
server_version?: string | null
error?: string | null
}
export interface PluginContribution {
type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section'
id: string
@@ -329,6 +368,7 @@ export interface ThemeConfig {
is_dark: boolean
author?: string
builtin: boolean
code_theme?: 'github-light' | 'github-dark'
}
// ============ Index ============
@@ -386,6 +426,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
@@ -492,7 +555,14 @@ export interface ApiPlugin {
panels: string[]
settings_sections: string[]
}
backend: { type: 'mcp' | 'internal_rpc' | 'none'; transport: 'stdio' | 'http' | 'none' }
backend: {
type: 'mcp' | 'internal_rpc' | 'none'
transport: 'stdio' | 'http' | 'none'
command?: string | null
args?: string[]
startup_timeout_seconds?: number
tool_timeout_seconds?: number
}
}
status: PluginStatus
enabled: boolean
+9 -3
View File
@@ -108,16 +108,22 @@ function eventText(event: AgentEvent) {
</template>
<style scoped>
.run-form { display: grid; gap: var(--space-xl); max-width: 980px; }
.agent-page > * { width: min(100%, 1080px); margin-inline: auto; }
.run-form { display: grid; gap: var(--space-xl); }
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
.tool-option { display: flex; gap: var(--space-sm); padding: var(--space-sm); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
.tool-option { display: flex; gap: var(--space-sm); padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); cursor: pointer; transition: border-color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast), box-shadow var(--motion-fast); }
.tool-option:hover { border-color: var(--color-accent-secondary); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
.tool-option:has(input:checked) { border-color: var(--color-accent-primary); background: var(--color-accent-soft); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-primary) 10%, transparent); }
.tool-option small { display: block; color: var(--color-text-secondary); }
.tool-option code { display: block; margin: 2px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.network { display: flex; gap: var(--space-sm); }
.trace-layout { display: grid; gap: var(--space-lg); }
.run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.run-summary h2 { margin-top: var(--space-sm); font-family: var(--font-ui-mono); font-size: var(--font-size-lg); }
.timeline { display: grid; gap: var(--space-md); }
.timeline { position: relative; display: grid; gap: var(--space-md); padding-left: var(--space-md); }
.timeline::before { content: ''; position: absolute; top: 10px; bottom: 10px; left: 1px; width: 2px; border-radius: var(--radius-full); background: var(--color-border-default); }
.event-card { position: relative; }
.event-card::before { content: ''; position: absolute; top: 20px; left: calc(-1 * var(--space-md) - 5px); width: 8px; height: 8px; border: 2px solid var(--color-surface-primary); border-radius: var(--radius-full); background: var(--color-accent-primary); box-shadow: 0 0 0 1px var(--color-accent-secondary); }
.event-head { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.event-text { margin-top: var(--space-md); white-space: pre-wrap; line-height: var(--line-height-relaxed); }
pre { margin-top: var(--space-md); max-height: 260px; overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); font-family: var(--font-ui-mono); font-size: var(--font-size-xs); white-space: pre-wrap; user-select: text; }
+8
View File
@@ -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 {
+11 -6
View File
@@ -94,24 +94,29 @@ async function openCitation(citation: Citation) {
</template>
<style scoped>
.chat-page { display: grid; grid-template-rows: auto auto 1fr auto; height: 100%; min-height: 0; background: var(--color-background-primary); }
.chat-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: var(--space-md); padding: var(--space-md) var(--space-xl); border-bottom: 1px solid var(--color-border-default); }
.chat-page { display: grid; grid-template-rows: auto auto 1fr auto; height: 100%; min-height: 0; background: radial-gradient(circle at 85% -10%, var(--color-accent-soft), transparent 30%), var(--color-background-primary); }
.chat-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: var(--space-md); padding: var(--space-md) var(--space-xl); border-bottom: 1px solid var(--color-border-default); background: var(--color-surface-secondary); box-shadow: var(--shadow-sm); z-index: 1; }
.compact { min-width: 160px; }
.rag-toggle { display: flex; align-items: center; gap: var(--space-xs); min-height: 36px; color: var(--color-text-secondary); }
.chat-error { margin: var(--space-md) var(--space-xl) 0; }
.message-timeline { min-height: 0; overflow: auto; padding: var(--space-xl) max(var(--space-xl), calc((100% - 820px) / 2)); user-select: text; }
.message { display: grid; grid-template-columns: 36px 1fr; gap: var(--space-md); margin-bottom: var(--space-xl); }
.avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: var(--radius-full); background: var(--color-background-tertiary); font-weight: 700; }
.message { display: grid; grid-template-columns: 36px 1fr; gap: var(--space-md); margin-bottom: var(--space-xl); animation: message-in var(--motion-normal) both; }
.avatar { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--color-border-default); border-radius: var(--radius-full); background: var(--color-background-tertiary); box-shadow: var(--shadow-sm); font-weight: 700; }
.assistant .avatar { background: var(--color-accent-soft); color: var(--color-accent-primary); }
.message-body { min-width: 0; padding: var(--space-md) var(--space-lg); border: 1px solid var(--color-border-subtle); border-radius: 4px var(--radius-lg) var(--radius-lg) var(--radius-lg); background: color-mix(in srgb, var(--color-surface-primary) 88%, transparent); box-shadow: var(--shadow-sm); }
.user .message-body { background: var(--color-accent-soft); border-color: color-mix(in srgb, var(--color-accent-primary) 14%, transparent); }
.message-content { white-space: pre-wrap; line-height: var(--line-height-relaxed); }
.thinking { margin-bottom: var(--space-sm); color: var(--color-text-secondary); }.thinking p { margin-top: var(--space-sm); white-space: pre-wrap; }
.tool-calls { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }.tool-calls .item-card { display: grid; gap: var(--space-xs); }.tool-calls pre { overflow: auto; font-size: var(--font-size-xs); }
.usage { display: block; margin-top: var(--space-xs); color: var(--color-text-tertiary); }
.message time { display: block; margin-top: var(--space-sm); color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.citations { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }
.citation-card { display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-sm); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); text-align: left; }
.citation-card { display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); text-align: left; transition: border-color var(--motion-fast), transform var(--motion-fast), box-shadow var(--motion-fast); }
.citation-card:hover { border-color: var(--color-accent-secondary); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
.citation-card small { display: block; margin-top: 2px; color: var(--color-text-secondary); }
.composer { padding: var(--space-md) max(var(--space-xl), calc((100% - 820px) / 2)); border-top: 1px solid var(--color-border-default); background: var(--color-surface-primary); }
.composer { padding: var(--space-md) max(var(--space-xl), calc((100% - 820px) / 2)); border-top: 1px solid var(--color-border-default); background: var(--color-surface-secondary); box-shadow: 0 -8px 24px color-mix(in srgb, var(--color-text-primary) 5%, transparent); }
.composer .textarea { min-height: 72px; }
.composer-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); margin-top: var(--space-sm); }
@keyframes message-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
</style>
@@ -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,20 +20,28 @@ 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', () => {
it('recreates the visual editor with the newly loaded file content', async () => {
const store = useEditorStore()
await store.loadFile('/欢迎使用知笔知己.md')
await store.loadFile('/欢迎使用 NotesAgent.md')
wrapper = mount(EditorPane, { attachTo: document.body })
await waitForText('欢迎使用知笔知己')
await waitForText('欢迎使用 NotesAgent')
await store.loadFile('/数据结构/红黑树.md')
await nextTick()
+3 -1
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
function updateContent(event: Event) {
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
@@ -12,7 +14,7 @@ function updateContent(event: Event) {
</script>
<template>
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="editorStore.currentFilePath ?? 'empty'"
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}`"
:initial-content="editorStore.content" />
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="false"
aria-label="Markdown 源码编辑器" @input="updateContent" />
@@ -2,6 +2,7 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Link } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe'
import { oneDark } from '@codemirror/theme-one-dark'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
@@ -19,6 +20,7 @@ import { callCommand } from '@milkdown/kit/utils'
import AppIcon from '@/components/common/AppIcon.vue'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
@@ -26,6 +28,7 @@ import '@milkdown/crepe/theme/frame.css'
const props = defineProps<{ initialContent: string }>()
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true)
const fontSizeInput = ref(16)
@@ -36,6 +39,7 @@ type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inli
function runCommand(command: ToolbarCommand) {
const editor = crepe?.editor
if (!editor) return
// Milkdown
const actions = {
bold: callCommand(toggleStrongCommand.key),
italic: callCommand(toggleEmphasisCommand.key),
@@ -52,6 +56,7 @@ function runCommand(command: ToolbarCommand) {
function applyLink() {
if (!crepe) return
// TODO(editor): Element Plus prompt URL
const href = window.prompt('请输入链接地址', 'https://')?.trim()
if (!href) return
@@ -104,6 +109,7 @@ onMounted(async () => {
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: '开始记录你的想法…' },
[Crepe.Feature.CodeMirror]: {
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
previewOnlyByDefault: false,
searchPlaceholder: '搜索语言',
noResultText: '没有匹配的语言',
@@ -158,6 +164,7 @@ onMounted(async () => {
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// /
if (markdown === previousMarkdown || markdown === editorStore.content) return
editorStore.updateContent(markdown)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
@@ -250,7 +257,7 @@ defineExpose({ getEditor: () => crepe?.editor })
--crepe-color-surface-low: var(--color-background-secondary);
--crepe-color-on-surface: var(--color-text-primary);
--crepe-color-on-surface-variant: var(--color-text-secondary);
--crepe-color-outline: var(--color-border-default);
--crepe-color-outline: var(--color-markdown-grid);
--crepe-color-primary: var(--color-accent-primary);
--crepe-color-secondary: var(--color-accent-soft);
--crepe-color-on-secondary: var(--color-text-primary);
@@ -269,14 +276,21 @@ defineExpose({ getEditor: () => crepe?.editor })
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
.milkdown-host :deep(.font-size-marker) { display: none; }
.milkdown-host :deep(.milkdown-code-block) { overflow: hidden; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.milkdown-host :deep(.milkdown-code-block .cm-editor),
.milkdown-host :deep(.milkdown-code-block .cm-gutters),
.milkdown-host :deep(.milkdown-code-block .cm-panel) { background: var(--color-code-background); }
.milkdown-host :deep(.milkdown-code-block .cm-content) { caret-color: var(--color-code-text); font-family: var(--font-editor-mono); }
.milkdown-host :deep(.milkdown-code-block .language-button) { color: var(--color-code-muted); }
:global(.milkdown-toolbar) { border: 1px solid var(--color-border-default) !important; background: var(--color-surface-elevated) !important; box-shadow: var(--shadow-md) !important; }
:global(.milkdown-toolbar .toolbar-item svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-text-primary) !important; fill: var(--color-text-primary) !important; opacity: 1 !important; }
:global(.milkdown-toolbar .toolbar-item:hover svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-accent-primary) !important; fill: var(--color-accent-primary) !important; }
:global([data-theme='light']) .milkdown-host :deep(.milkdown-table-block th),
:global([data-theme='light']) .milkdown-host :deep(.milkdown-table-block td) { border-color: var(--color-text-tertiary); }
:global([data-theme='light']) .milkdown-host :deep(.milkdown-list-item-block li .label-wrapper) { color: var(--color-text-secondary); font-weight: 600; }
:global([data-theme='light']) .milkdown-host :deep(.milkdown-list-item-block li .label-wrapper svg) { fill: var(--color-text-secondary); }
.milkdown-host :deep(.milkdown-table-block th),
.milkdown-host :deep(.milkdown-table-block td) { border-color: var(--color-markdown-grid); }
.milkdown-host :deep(.milkdown-table-block th) { background: var(--color-markdown-table-header); font-weight: 700; }
.milkdown-host :deep(.milkdown-list-item-block li .label-wrapper) { color: var(--color-markdown-marker); font-weight: 700; }
.milkdown-host :deep(.milkdown-list-item-block li .label-wrapper svg) { fill: var(--color-markdown-marker); }
.milkdown-host :deep(code) { font-family: var(--font-editor-mono); }
:global([data-theme='dark']) .milkdown-host :deep(.milkdown) { color-scheme: dark; }
:global([data-theme='dark'] .milkdown-host .milkdown) { color-scheme: dark; }
@media (max-width: 680px) { .toolbar-select select { min-width: 46px; width: 46px; } }
</style>
+4 -1
View File
@@ -66,13 +66,16 @@ async function openResult(result: SearchResult) {
</template>
<style scoped>
.search-page > * { width: min(100%, 1040px); margin-inline: auto; }
.search-form { display: grid; grid-template-columns: 1fr auto; gap: var(--space-md); margin-bottom: var(--space-lg); }
.search-input { height: 44px; font-size: var(--font-size-lg); }
.advanced { grid-column: 1 / -1; }
.results-header, .result-title, .result-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.results-header { margin: var(--space-xl) 0 var(--space-md); color: var(--color-text-secondary); }
.result-list { display: grid; gap: var(--space-md); }
.result-card { cursor: pointer; }
.result-card { position: relative; cursor: pointer; overflow: hidden; }
.result-card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--color-accent-primary); opacity: 0; transform: scaleY(.45); transition: opacity var(--motion-fast), transform var(--motion-fast); }
.result-card:hover::before { opacity: 1; transform: scaleY(1); }
.snippet { margin: var(--space-md) 0; line-height: var(--line-height-relaxed); }
.result-meta { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
@media (max-width: 700px) { .search-form { grid-template-columns: 1fr; } .advanced { grid-column: auto; } }
@@ -181,11 +181,12 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
.settings-page { max-width: 1120px; margin: 0 auto; }
.settings-section { display: grid; gap: var(--space-md); }
.settings-section h2 { margin-bottom: var(--space-sm); }
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 54px; padding: var(--space-sm) 0; border-bottom: 1px solid var(--color-border-subtle); }
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 58px; padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); transition: background-color var(--motion-fast); }
.setting-row:hover { background: var(--color-background-secondary); }
.setting-row small { display: block; color: var(--color-text-tertiary); }.short { width: min(220px, 45%); }
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-lg); }
.provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-main { min-width: 0; flex: 1; }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); }
.model-picker { display: flex; align-items: center; gap: var(--space-sm); margin-top: var(--space-md); }.model-picker label { white-space: nowrap; font-weight: 600; }.model-picker .select { width: min(360px, 100%); }.provider-actions { flex-wrap: wrap; justify-content: flex-end; }.error-text { color: var(--color-danger, #d33); }
.model-picker { display: flex; align-items: center; gap: var(--space-sm); margin-top: var(--space-md); }.model-picker label { white-space: nowrap; font-weight: 600; }.model-picker .select { width: min(360px, 100%); }.provider-actions { flex-wrap: wrap; justify-content: flex-end; }.error-text { color: var(--color-error); }
.test-result { color: var(--color-info); }.index-summary, .diagnostic-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }.index-summary > div { padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }.index-summary strong, .index-summary small { display: block; }.index-summary strong { font-size: var(--font-size-3xl); }
.section-description { margin-top: calc(-1 * var(--space-md)); }.diagnostic-grid { grid-template-columns: repeat(2, 1fr); }.diagnostic-grid h3 { margin: var(--space-md) 0 var(--space-xs); }.diagnostic-actions { margin-top: var(--space-md); }
@media (max-width: 700px) { .provider-card, .setting-row, .model-picker { align-items: flex-start; flex-direction: column; }.short, .model-picker .select { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; }.provider-actions { justify-content: flex-start; } }
+4 -3
View File
@@ -50,10 +50,11 @@ async function remove(task: TaskItem) {
</template>
<style scoped>
.task-list { display: grid; gap: var(--space-md); }
.task-list { display: grid; gap: var(--space-md); width: min(100%, 980px); margin-inline: auto; }
.task-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--space-md); }
.status-check { width: 26px; height: 26px; border: 2px solid var(--color-border-default); border-radius: var(--radius-full); }
.status-check.done { border-color: var(--color-success); background: var(--color-success); color: white; }
.status-check { width: 28px; height: 28px; border: 2px solid var(--color-border-default); border-radius: var(--radius-full); transition: border-color var(--motion-fast), background-color var(--motion-fast), color var(--motion-fast), transform var(--motion-fast); }
.status-check:hover { border-color: var(--color-success); transform: scale(1.06); }
.status-check.done { border-color: var(--color-success); background: var(--color-success); color: white; box-shadow: 0 3px 10px color-mix(in srgb, var(--color-success) 24%, transparent); }
.task-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
.task-content p { margin: var(--space-xs) 0; }
.task-content .subtle { display: flex; flex-wrap: wrap; gap: var(--space-md); }
+27 -2
View File
@@ -1,6 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const shikiPreview = `\`\`\`typescript
const notes = await search('本地优先')
\`\`\``
const codeThemeLabel = computed(() => themeStore.resolvedCodeBlockTheme === 'github-dark'
? 'Shiki · GitHub Dark'
: 'Shiki · GitHub Light')
</script>
<template>
@@ -13,7 +22,20 @@ const themeStore = useThemeStore()
<p class="subtle">v{{ theme.version }} · {{ theme.builtin ? '内置主题' : theme.author }}</p>
</button>
</div>
<div class="panel preference-panel"><h2 class="panel-title">编辑器外观</h2><div class="form-grid"><div class="field"><label>字号{{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div><div class="field"><label>行高{{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div><div class="field"><label>字体</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">系统字体</option><option value="serif">衬线字体</option><option value="var(--font-ui-mono)">等宽字体</option></select></div></div><div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }"><h3>主题预览</h3><p>知识的价值不只在于保存更在于被重新发现和使用</p><code>const notes = await search('本地优先')</code></div></div>
<div class="panel preference-panel">
<h2 class="panel-title">编辑器外观</h2>
<div class="form-grid">
<div class="field"><label>字号{{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div>
<div class="field"><label>行高{{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div>
<div class="field"><label>字体</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">系统字体</option><option value="serif">衬线字体</option><option value="var(--font-ui-mono)">等宽字体</option></select></div>
<div class="field"><label>代码块样式</label><select v-model="themeStore.codeBlockTheme" class="select"><option value="auto">跟随主题</option><option value="github-light">GitHub Light</option><option value="github-dark">GitHub Dark</option></select><small>Markdown 渲染使用对应的 Shiki GitHub 主题</small></div>
</div>
<div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }">
<div class="preview-heading"><h3>主题预览</h3><span class="badge info">{{ codeThemeLabel }}</span></div>
<p>知识的价值不只在于保存更在于被重新发现和使用</p>
<MarkdownContent class="code-theme-preview" :source="shikiPreview" />
</div>
</div>
</section>
</template>
@@ -28,5 +50,8 @@ const themeStore = useThemeStore()
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); }
.preference-panel { display: grid; gap: var(--space-xl); }
.editor-preview { padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.editor-preview p { margin: var(--space-sm) 0; }.editor-preview code { color: var(--color-accent-primary); }
.editor-preview p { margin: var(--space-sm) 0; }
.preview-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.field small { color: var(--color-text-tertiary); }
.code-theme-preview { margin-top: var(--space-md); }
</style>
+38 -113
View File
@@ -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>
@@ -68,13 +54,13 @@ async function createVault() {
<div class="entry-container">
<div class="brand-section">
<div class="logo"><AppIcon :icon="Document" :size="56" /></div>
<h1 class="app-title">知笔知己</h1>
<h1 class="app-title">NotesAgent</h1>
<p class="app-subtitle">本地优先的 AI 笔记软件</p>
</div>
<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>
@@ -161,7 +126,7 @@ async function createVault() {
background:
radial-gradient(circle at 20% 30%, var(--color-accent-soft) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, var(--color-info-soft) 0%, transparent 50%);
opacity: 0.5;
opacity: 0.62;
}
.entry-container {
@@ -173,6 +138,7 @@ async function createVault() {
gap: 32px;
max-width: 480px;
width: 90%;
animation: entry-in var(--motion-slow) both;
}
.brand-section {
@@ -180,8 +146,16 @@ async function createVault() {
}
.logo {
font-size: 64px;
margin-bottom: 12px;
display: inline-grid;
place-items: center;
width: 84px;
height: 84px;
margin-bottom: 14px;
border: 1px solid color-mix(in srgb, var(--color-accent-primary) 18%, transparent);
border-radius: 24px;
background: var(--color-surface-primary);
color: var(--color-accent-primary);
box-shadow: var(--shadow-lg);
}
.app-title {
@@ -206,7 +180,7 @@ async function createVault() {
border: 1px solid var(--color-border-default);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
box-shadow: var(--shadow-lg);
box-shadow: var(--shadow-xl);
}
.card-title {
@@ -247,11 +221,13 @@ async function createVault() {
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
transition: all var(--motion-fast);
transition: background-color var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
&:hover {
background: var(--color-accent-soft);
border-color: var(--color-accent-secondary);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
&:disabled {
@@ -288,8 +264,11 @@ async function createVault() {
.vault-arrow {
color: var(--color-text-tertiary);
font-size: 20px;
transition: color var(--motion-fast), transform var(--motion-fast);
}
.vault-item:hover .vault-arrow { color: var(--color-accent-primary); transform: translateX(3px); }
.actions {
display: flex;
flex-direction: column;
@@ -307,7 +286,7 @@ async function createVault() {
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--motion-fast);
transition: background-color var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
border: 1px solid transparent;
&:disabled {
@@ -321,6 +300,8 @@ async function createVault() {
&:hover:not(:disabled) {
background: var(--color-accent-primary-hover);
transform: translateY(-1px);
box-shadow: 0 7px 18px color-mix(in srgb, var(--color-accent-primary) 25%, transparent);
}
}
@@ -392,61 +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);
}
.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);
}
.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);
}
@keyframes entry-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
</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))!
@@ -133,5 +133,5 @@ button:hover { background: var(--color-background-secondary); }
.tree { padding: var(--space-xs); }
.context-menu { position: fixed; z-index: 1000; display: grid; min-width: 130px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-primary); box-shadow: var(--shadow-md); }
.context-menu button { text-align: left; }
.context-menu .danger { color: var(--color-danger, #d33); }
.context-menu .danger { color: var(--color-error); }
</style>
@@ -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('/欢迎使用知笔知己.md').then(() => {
//
if (!workspaceStore.activeFilePath && editorStore.currentFilePath === '/欢迎使用知笔知己.md') {
workspaceStore.openFile('/欢迎使用知笔知己.md')
}
})
}
})
</script>
<template>
@@ -59,6 +42,11 @@ onMounted(() => {
.empty-content {
text-align: center;
padding: var(--space-3xl);
border: 1px dashed var(--color-border-default);
border-radius: var(--radius-xl);
background: var(--color-background-secondary);
animation: workspace-empty-in var(--motion-normal) both;
h2 {
font-size: 18px;
@@ -75,4 +63,6 @@ onMounted(() => {
font-size: 48px;
opacity: 0.5;
}
@keyframes workspace-empty-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
</style>
+1 -1
View File
@@ -83,7 +83,7 @@ router.beforeEach((to, _from, next) => {
})
router.afterEach((to) => {
const baseTitle = '知笔知己'
const baseTitle = 'NotesAgent'
const title = to.meta.title as string | undefined
document.title = title ? `${title} · ${baseTitle}` : baseTitle
})
+14 -3
View File
@@ -1,8 +1,9 @@
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 模型预留了输入/输出拆分字段。
return {
run_id: run.run_id,
status: run.status,
@@ -53,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
@@ -65,11 +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'],
+2
View File
@@ -1,5 +1,6 @@
import type { ApiError, ErrorResponse } from '@/contracts'
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
export function resolveApiUrl(path: string): string {
@@ -63,6 +64,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
return resp as unknown as T
}
// 后端约定返回 ErrorResponse;代理或网关的非 JSON 错误仍降级为 HTTP 状态码。
let errBody: ErrorResponse | null = null
try {
errBody = (await resp.json()) as ErrorResponse
+4
View File
@@ -37,3 +37,7 @@ export async function deleteNote(noteId: string): Promise<OperationResponse> {
export async function moveNote(noteId: string, folder: string): Promise<ApiNote> {
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 })
}
+11 -3
View File
@@ -1,5 +1,5 @@
import apiClient from './apiClient'
import type { ApiPlugin, OperationResponse, Plugin, PluginContribution } from '@/contracts'
import type { ApiPlugin, OperationResponse, Plugin, PluginContribution, PluginHostStatus } from '@/contracts'
function toPlugin(plugin: ApiPlugin): Plugin {
const { manifest } = plugin
@@ -54,6 +54,14 @@ export async function grantPluginPermissions(pluginId: string, permissions: stri
return toPlugin(await apiClient.put<ApiPlugin>(`/api/plugins/${pluginId}/permissions`, { permissions }))
}
export async function getPluginHostStatus(pluginId: string): Promise<PluginHostStatus> {
return apiClient.get(`/api/plugins/${pluginId}/host`)
}
export async function restartPluginHost(pluginId: string): Promise<OperationResponse> {
return apiClient.post(`/api/plugins/${pluginId}/host/restart`)
}
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/plugins/${pluginId}`)
}
@@ -65,7 +73,7 @@ export const mockPlugins: Plugin[] = [
version: '1.3.2',
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
icon: '',
author: '知笔知己团队',
author: 'NotesAgent 团队',
status: 'ready',
enabled: true,
permissions: ['notes.read', 'network.request'],
@@ -117,7 +125,7 @@ export const mockPlugins: Plugin[] = [
version: '2.1.0',
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
icon: '',
author: '知笔知己团队',
author: 'NotesAgent 团队',
status: 'error',
enabled: false,
permissions: ['notes.write', 'attachments.read'],
+2 -2
View File
@@ -50,7 +50,7 @@ export const mockSkills: Skill[] = [
version: '1.0.0',
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
icon: '',
author: '知笔知己团队',
author: 'NotesAgent 团队',
permissions: ['notes.search', 'notes.read', 'tasks.create'],
tools: ['notes.search', 'notes.read', 'tasks.create'],
retrieval_config: { top_k: 10, rerank: true, citation: true },
@@ -64,7 +64,7 @@ export const mockSkills: Skill[] = [
version: '1.1.0',
description: '从音频或文本中提取会议要点、行动项和待办任务',
icon: '',
author: '知笔知己团队',
author: 'NotesAgent 团队',
permissions: ['notes.search', 'notes.write', 'tasks.write', 'attachments.read'],
tools: ['notes.search', 'notes.create', 'tasks.create', 'attachments.read'],
retrieval_config: { top_k: 5, rerank: false, citation: true },
+42
View File
@@ -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',
)
})
})
+18 -3
View File
@@ -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,
@@ -54,19 +62,22 @@ export class SseClient {
this.connected = true
onOpen?.()
// 一个 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?.()
@@ -75,6 +86,7 @@ export class SseClient {
onError?.(error instanceof Error ? error : new Error('Malformed SSE data'))
}
eventName = 'message'
eventId = undefined
dataLines = []
}
@@ -86,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)
}
@@ -117,6 +130,8 @@ export class SseClient {
this.controller.abort()
}
// 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' }),
)
})
})
+150 -234
View File
@@ -1,255 +1,171 @@
import type { FileNode } from '@/contracts'
// Mock workspace service for web dev mode
// In Tauri environment this will use Tauri IPC commands
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: '欢迎使用知笔知己.md', path: '/欢迎使用知笔知己.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 === '欢迎使用知笔知己.md') {
return rememberContent(filePath, `# 欢迎使用知笔知己
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, Notes Agent!');
\`\`\`
`)
}
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> {
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()
}
+2
View File
@@ -53,6 +53,7 @@ export const useAgentStore = defineStore('agent', () => {
}
function processEvent(event: AgentEvent) {
// 服务端会先回放历史再发送实时事件,以 run_id + sequence 去重保证幂等。
if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return
events.value.push(event)
events.value.sort((a, b) => a.sequence - b.sequence)
@@ -100,6 +101,7 @@ export const useAgentStore = defineStore('agent', () => {
}
function subscribe(runId: string) {
// 任一时刻只保留当前运行的事件流,防止切换详情后旧事件污染新页面。
eventStream?.cancel()
isRunning.value = true
error.value = null
+3
View File
@@ -16,6 +16,8 @@ export const useChatStore = defineStore('chat', () => {
const selectedModel = ref('mock-1')
let sseClient: SseClient | null = null
// TODO(chat): 会话持久化接口完成后移除 mockConversations/mockMessages 数据源。
const activeConversation = computed(() =>
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
)
@@ -56,6 +58,7 @@ export const useChatStore = defineStore('chat', () => {
inputText.value = ''
isStreaming.value = true
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
const aiMsg: ChatMessage = {
message_id: `msg-${Date.now() + 1}`,
conversation_id: conversationId,
+4
View File
@@ -47,6 +47,7 @@ export const useEditorStore = defineStore('editor', () => {
async function save() {
if (!currentFilePath.value) return
if (pendingSave) return pendingSave
// 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。
const targetPath = currentFilePath.value
const snapshot = content.value
saveStatus.value = 'saving'
@@ -82,6 +83,7 @@ export const useEditorStore = defineStore('editor', () => {
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。')
}
// 版本号使较慢的旧读取不能覆盖用户后选择的新文件。
const version = ++loadVersion
const previousStatus = saveStatus.value
saveStatus.value = 'saving'
@@ -117,6 +119,8 @@ export const useEditorStore = defineStore('editor', () => {
}
}
// TODO(editor): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。
function closeFile() {
loadVersion++
if (saveTimer) clearTimeout(saveTimer)
+47
View File
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { useThemeStore } from './theme'
beforeEach(() => {
localStorage.clear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.removeAttribute('data-code-theme')
setActivePinia(createPinia())
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: () => ({ matches: false }),
})
})
describe('代码块主题偏好', () => {
it('跟随应用主题选择对应的 GitHub 代码主题', async () => {
const store = useThemeStore()
store.applyTheme('dark')
await nextTick()
expect(store.resolvedCodeBlockTheme).toBe('github-dark')
expect(document.documentElement.dataset.codeTheme).toBe('github-dark')
})
it('允许代码块主题独立于应用主题', async () => {
const store = useThemeStore()
store.applyTheme('dark')
store.codeBlockTheme = 'github-light'
await nextTick()
expect(store.resolvedCodeBlockTheme).toBe('github-light')
expect(document.documentElement.dataset.codeTheme).toBe('github-light')
})
it('恢复持久化的代码块主题偏好', async () => {
localStorage.setItem('editor-appearance', JSON.stringify({ codeBlockTheme: 'github-dark' }))
const store = useThemeStore()
store.initTheme()
await nextTick()
expect(store.codeBlockTheme).toBe('github-dark')
expect(document.documentElement.dataset.codeTheme).toBe('github-dark')
})
})
+33 -5
View File
@@ -3,17 +3,24 @@ import { ref, computed, watch } from 'vue'
import type { ThemeConfig } from '@/contracts'
const builtinThemes: ThemeConfig[] = [
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true },
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true },
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true },
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true, code_theme: 'github-light' },
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true, code_theme: 'github-dark' },
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true, code_theme: 'github-light' },
]
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
function isCodeBlockThemePreference(value: unknown): value is CodeBlockThemePreference {
return value === 'auto' || value === 'github-light' || value === 'github-dark'
}
export const useThemeStore = defineStore('theme', () => {
const themes = ref<ThemeConfig[]>(builtinThemes)
const currentThemeId = ref<string>('light')
const fontEditorSize = ref(15)
const fontEditorFamily = ref('system-ui')
const lineHeight = ref(1.7)
const codeBlockTheme = ref<CodeBlockThemePreference>('auto')
let appearanceHydrated = false
const currentTheme = computed(() =>
@@ -21,6 +28,10 @@ export const useThemeStore = defineStore('theme', () => {
)
const isDark = computed(() => currentTheme.value?.is_dark || false)
const resolvedCodeBlockTheme = computed<'github-light' | 'github-dark'>(() => {
if (codeBlockTheme.value !== 'auto') return codeBlockTheme.value
return currentTheme.value?.code_theme ?? (isDark.value ? 'github-dark' : 'github-light')
})
function applyTheme(themeId: string) {
const theme = themes.value.find((t) => t.theme_id === themeId)
@@ -38,13 +49,15 @@ export const useThemeStore = defineStore('theme', () => {
}
function initTheme() {
// 先恢复外观再开放 watch 持久化,避免 immediate watcher 覆盖本地设置。
const savedAppearance = localStorage.getItem('editor-appearance')
if (savedAppearance) {
try {
const value = JSON.parse(savedAppearance) as { size?: number; family?: string; lineHeight?: number }
const value = JSON.parse(savedAppearance) as { size?: number; family?: string; lineHeight?: number; codeBlockTheme?: unknown }
if (value.size) fontEditorSize.value = value.size
if (value.family) fontEditorFamily.value = value.family
if (value.lineHeight) lineHeight.value = value.lineHeight
if (isCodeBlockThemePreference(value.codeBlockTheme)) codeBlockTheme.value = value.codeBlockTheme
} catch { localStorage.removeItem('editor-appearance') }
}
const saved = localStorage.getItem('theme')
@@ -67,12 +80,21 @@ export const useThemeStore = defineStore('theme', () => {
fontEditorSize.value = 15
fontEditorFamily.value = 'system-ui'
lineHeight.value = 1.7
codeBlockTheme.value = 'auto'
}
const persistAppearance = () => localStorage.setItem('editor-appearance', JSON.stringify({
size: fontEditorSize.value, family: fontEditorFamily.value, lineHeight: lineHeight.value,
size: fontEditorSize.value,
family: fontEditorFamily.value,
lineHeight: lineHeight.value,
codeBlockTheme: codeBlockTheme.value,
}))
watch(resolvedCodeBlockTheme, (theme) => {
// CSS 与 Shiki 共用该属性,确保代码块背景和 token 配色始终成套切换。
document.documentElement.setAttribute('data-code-theme', theme)
}, { immediate: true })
watch(fontEditorSize, (v) => {
document.documentElement.style.setProperty('--font-editor-size', `${v}px`)
if (appearanceHydrated) persistAppearance()
@@ -88,6 +110,10 @@ export const useThemeStore = defineStore('theme', () => {
if (appearanceHydrated) persistAppearance()
}, { immediate: true })
watch(codeBlockTheme, () => {
if (appearanceHydrated) persistAppearance()
})
return {
themes,
currentThemeId,
@@ -96,6 +122,8 @@ export const useThemeStore = defineStore('theme', () => {
fontEditorSize,
fontEditorFamily,
lineHeight,
codeBlockTheme,
resolvedCodeBlockTheme,
applyTheme,
initTheme,
toggleTheme,
+1
View File
@@ -118,6 +118,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
function renamePath(oldPath: string, newPath: string, newName: string) {
const node = findNodeByPath(fileTree.value, oldPath)
if (!node) return
// 文件夹重命名必须同步改写所有后代、标签页和当前文件路径。
const updateNodePath = (current: FileNode) => {
if (current.path === oldPath) current.name = newName
if (current.path === oldPath || current.path.startsWith(`${oldPath}/`)) {
+67 -26
View File
@@ -1,9 +1,18 @@
.feature-page {
height: 100%;
overflow: auto;
padding: var(--space-2xl);
background: var(--color-background-primary);
padding: clamp(var(--space-lg), 3vw, var(--space-3xl));
background:
radial-gradient(circle at 92% -8%, var(--color-accent-soft) 0, transparent 28%),
var(--color-background-primary);
user-select: text;
scrollbar-gutter: stable;
animation: page-in var(--motion-normal) both;
}
.chat-page,
.workspace-view {
animation: page-in var(--motion-normal) both;
}
.feature-header {
@@ -11,13 +20,15 @@
align-items: flex-start;
justify-content: space-between;
gap: var(--space-lg);
margin-bottom: var(--space-xl);
margin: 0 auto var(--space-2xl);
max-width: 1180px;
}
.feature-header h1 { font-size: var(--font-size-3xl); line-height: 1.2; }
.feature-header p { margin-top: var(--space-xs); color: var(--color-text-secondary); }
.feature-header h1 { font-size: clamp(var(--font-size-2xl), 2.3vw, 30px); line-height: 1.15; letter-spacing: -.025em; }
.feature-header p { max-width: 680px; margin-top: var(--space-sm); color: var(--color-text-secondary); }
.feature-actions, .inline-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-lg); }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-lg); max-width: 1180px; margin-inline: auto; }
.feature-page > .panel { max-width: 1180px; margin-inline: auto; }
.split-view { display: grid; grid-template-columns: minmax(260px, .8fr) minmax(360px, 1.7fr); gap: var(--space-lg); min-height: 0; }
.panel, .item-card {
@@ -27,10 +38,13 @@
box-shadow: var(--shadow-sm);
}
.panel { padding: var(--space-xl); }
.item-card { padding: var(--space-lg); transition: border-color var(--motion-fast), transform var(--motion-fast); }
.item-card:hover { border-color: var(--color-accent-secondary); }
.item-card.selected { border-color: var(--color-accent-primary); box-shadow: 0 0 0 2px var(--color-accent-soft); }
.panel { padding: clamp(var(--space-lg), 2.2vw, var(--space-2xl)); }
.item-card {
padding: var(--space-lg);
transition: border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast), background-color var(--motion-fast);
}
.item-card:hover { border-color: var(--color-accent-secondary); box-shadow: var(--shadow-md); transform: translateY(-2px); }
.item-card.selected { border-color: var(--color-accent-primary); box-shadow: 0 0 0 3px var(--color-accent-soft), var(--shadow-md); }
.panel-title { margin-bottom: var(--space-md); font-size: var(--font-size-xl); }
.muted { color: var(--color-text-secondary); }
.subtle { color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
@@ -41,14 +55,18 @@
border: 1px solid transparent;
border-radius: var(--radius-md);
font-weight: 600;
line-height: 1;
transition: background-color var(--motion-fast), border-color var(--motion-fast), color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
}
.button-primary { background: var(--color-accent-primary); color: var(--color-text-inverse); }
.button-primary:hover { background: var(--color-accent-primary-hover); }
.button-primary { background: var(--color-accent-primary); color: var(--color-text-inverse); box-shadow: 0 4px 12px color-mix(in srgb, var(--color-accent-primary) 24%, transparent); }
.button-primary:hover { background: var(--color-accent-primary-hover); transform: translateY(-1px); box-shadow: 0 7px 18px color-mix(in srgb, var(--color-accent-primary) 28%, transparent); }
.button-secondary { border-color: var(--color-border-default); background: var(--color-surface-primary); }
.button-secondary:hover, .icon-button:hover { background: var(--color-background-hover); }
.button-secondary:hover, .icon-button:hover { border-color: var(--color-accent-secondary); background: var(--color-background-hover); color: var(--color-accent-primary); transform: translateY(-1px); }
.button-danger { background: var(--color-error-soft); color: var(--color-error); }
button:disabled { cursor: not-allowed; opacity: .55; }
.button-danger:hover { transform: translateY(-1px); box-shadow: 0 5px 14px color-mix(in srgb, var(--color-error) 18%, transparent); }
button:active:not(:disabled) { transform: translateY(0); }
button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform: none; }
.field { display: grid; gap: var(--space-xs); }
.field label { color: var(--color-text-secondary); font-size: var(--font-size-sm); font-weight: 600; }
@@ -59,10 +77,12 @@ button:disabled { cursor: not-allowed; opacity: .55; }
outline: none;
background: var(--color-background-primary);
color: var(--color-text-primary);
transition: border-color var(--motion-fast), box-shadow var(--motion-fast), background-color var(--motion-fast);
}
.input, .select { height: 36px; padding: 0 var(--space-md); }
.input, .select { height: 38px; padding: 0 var(--space-md); }
.textarea { min-height: 100px; padding: var(--space-md); resize: vertical; }
.input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 2px var(--color-accent-soft); }
.input:hover, .select:hover, .textarea:hover { border-color: var(--color-text-tertiary); }
.input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 3px var(--color-accent-soft); background: var(--color-surface-primary); }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); }
.badge {
@@ -75,6 +95,7 @@ button:disabled { cursor: not-allowed; opacity: .55; }
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: .01em;
}
.badge.success { background: var(--color-success-soft); color: var(--color-success); }
.badge.warning { background: var(--color-warning-soft); color: var(--color-warning); }
@@ -82,25 +103,45 @@ button:disabled { cursor: not-allowed; opacity: .55; }
.badge.info { background: var(--color-info-soft); color: var(--color-info); }
.tag-list { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
.empty-state { display: grid; place-items: center; min-height: 220px; padding: var(--space-2xl); text-align: center; color: var(--color-text-secondary); }
.empty-state { display: grid; place-items: center; min-height: 220px; padding: var(--space-3xl); border: 1px dashed var(--color-border-default); border-radius: var(--radius-xl); background: color-mix(in srgb, var(--color-background-secondary) 74%, transparent); text-align: center; color: var(--color-text-secondary); }
.empty-state strong { display: block; margin-bottom: var(--space-xs); color: var(--color-text-primary); font-size: var(--font-size-xl); }
.error-banner { margin-bottom: var(--space-lg); padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-error-soft); color: var(--color-error); }
.notice-banner { margin-bottom: var(--space-lg); padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-info-soft); color: var(--color-info); }
.error-banner, .notice-banner { border: 1px solid transparent; animation: notice-in var(--motion-normal) both; }
.error-banner { margin-bottom: var(--space-lg); padding: var(--space-md) var(--space-lg); border-color: color-mix(in srgb, var(--color-error) 22%, transparent); border-radius: var(--radius-md); background: var(--color-error-soft); color: var(--color-error); }
.notice-banner { margin-bottom: var(--space-lg); padding: var(--space-md) var(--space-lg); border-color: color-mix(in srgb, var(--color-info) 22%, transparent); border-radius: var(--radius-md); background: var(--color-info-soft); color: var(--color-info); }
.sidebar-panel { padding: var(--space-md); }
.sidebar-panel .input, .sidebar-panel .select { margin-bottom: var(--space-sm); }
.sidebar-list { display: grid; gap: var(--space-xs); }
.sidebar-list-item { padding: var(--space-sm); border-radius: var(--radius-md); cursor: pointer; }
.sidebar-list-item:hover, .sidebar-list-item.active { background: var(--color-background-hover); }
.sidebar-list-item { padding: 9px var(--space-sm); border: 1px solid transparent; border-radius: var(--radius-md); cursor: pointer; transition: color var(--motion-fast), background-color var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast); }
.sidebar-list-item:hover { background: var(--color-background-hover); transform: translateX(2px); }
.sidebar-list-item.active { border-color: color-mix(in srgb, var(--color-accent-primary) 18%, transparent); background: var(--color-accent-soft); color: var(--color-accent-primary); }
.modal-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: var(--space-xl); background: var(--color-background-overlay); }
.modal { width: min(560px, 100%); max-height: 85vh; overflow: auto; padding: var(--space-xl); border-radius: var(--radius-lg); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); }
.modal-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: var(--space-xl); background: var(--color-background-overlay); animation: backdrop-in var(--motion-fast) both; }
.modal { width: min(560px, 100%); max-height: 85vh; overflow: auto; padding: var(--space-2xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: modal-in var(--motion-normal) both; }
.modal h2 { margin-bottom: var(--space-lg); }
.modal form { display: grid; gap: var(--space-md); }
.settings-nav { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-bottom: var(--space-xl); border-bottom: 1px solid var(--color-border-default); }
.settings-nav button { padding: var(--space-sm) var(--space-md); border-bottom: 2px solid transparent; color: var(--color-text-secondary); }
.settings-nav button.active { border-color: var(--color-accent-primary); color: var(--color-accent-primary); }
.settings-nav { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-bottom: var(--space-xl); padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-background-secondary); }
.settings-nav button { padding: 8px var(--space-md); border-radius: var(--radius-md); color: var(--color-text-secondary); transition: color var(--motion-fast), background-color var(--motion-fast), box-shadow var(--motion-fast); }
.settings-nav button:hover { color: var(--color-text-primary); background: var(--color-background-hover); }
.settings-nav button.active { background: var(--color-surface-primary); color: var(--color-accent-primary); box-shadow: var(--shadow-sm); }
@keyframes page-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes notice-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-in {
from { opacity: 0; transform: translateY(8px) scale(.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (max-width: 900px) {
.feature-page { padding: var(--space-lg); }
+44
View File
@@ -0,0 +1,44 @@
/// <reference types="node" />
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const globalStyles = [
new URL('./tokens.css', import.meta.url),
new URL('./features.css', import.meta.url),
].map((path) => readFileSync(path, 'utf8')).join('\n')
const markdownStyles = [
new URL('../features/editor/VisualMarkdownEditor.vue', import.meta.url),
new URL('../features/themes/ThemesView.vue', import.meta.url),
new URL('../components/common/MarkdownContent.vue', import.meta.url),
].map((path) => readFileSync(path, 'utf8')).join('\n')
describe('轻量动效基线', () => {
it('为减少动态效果偏好提供全局回退', () => {
expect(globalStyles).toContain('@media (prefers-reduced-motion: reduce)')
})
it('不使用全属性过渡或高成本模糊滤镜', () => {
expect(globalStyles).not.toMatch(/transition:\s*all\b/)
expect(globalStyles).not.toMatch(/(?:backdrop-)?filter\s*:/)
})
it('页面入场只改变透明度和变换', () => {
const pageAnimation = globalStyles.match(/@keyframes page-in\s*{[\s\S]*?\n}/)?.[0] ?? ''
expect(pageAnimation).toContain('opacity')
expect(pageAnimation).toContain('transform')
expect(pageAnimation).not.toMatch(/(?:width|height|margin|padding|top|left)\s*:/)
})
it('Markdown 序号和表格使用独立的高对比度主题变量', () => {
expect(globalStyles).toContain('--color-markdown-grid:')
expect(globalStyles).toContain('--color-markdown-marker:')
expect(markdownStyles).toContain('var(--color-markdown-grid)')
expect(markdownStyles).toContain('var(--color-markdown-marker)')
})
it('不混用可能丢失后代选择器的 scoped global 写法', () => {
expect(markdownStyles).not.toMatch(/:global\([^\n]+\)\s+\./)
})
})
+78 -15
View File
@@ -44,17 +44,22 @@
--color-border-focus: #5b67f1;
--color-border-disabled: #eef0f3;
/* Markdown */
--color-markdown-grid: #8b949e;
--color-markdown-marker: #343b44;
--color-markdown-table-header: #eef0f3;
/* Shadow */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.16);
--shadow-sm: 0 1px 2px rgba(31, 35, 40, 0.05), 0 1px 5px rgba(31, 35, 40, 0.03);
--shadow-md: 0 8px 22px rgba(31, 35, 40, 0.08), 0 2px 6px rgba(31, 35, 40, 0.04);
--shadow-lg: 0 16px 36px rgba(31, 35, 40, 0.11), 0 4px 12px rgba(31, 35, 40, 0.05);
--shadow-xl: 0 24px 64px rgba(31, 35, 40, 0.18), 0 8px 20px rgba(31, 35, 40, 0.08);
/* Radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-sm: 6px;
--radius-md: 9px;
--radius-lg: 13px;
--radius-xl: 18px;
--radius-full: 9999px;
/* Spacing */
@@ -100,16 +105,16 @@
--z-titlebar: 500;
/* Motion */
--motion-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1);
--motion-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--motion-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--motion-fast: 120ms cubic-bezier(0.2, 0, 0, 1);
--motion-normal: 190ms cubic-bezier(0.2, 0, 0, 1);
--motion-slow: 260ms cubic-bezier(0.2, 0, 0, 1);
/* Layout */
--titlebar-height: 38px;
--sidebar-primary-width: 52px;
--titlebar-height: 42px;
--sidebar-primary-width: 58px;
--sidebar-primary-width-expanded: 180px;
--sidebar-secondary-width: 260px;
--statusbar-height: 26px;
--sidebar-secondary-width: 272px;
--statusbar-height: 28px;
}
[data-theme='dark'] {
@@ -152,6 +157,10 @@
--color-border-focus: #7d8bff;
--color-border-disabled: #21262d;
--color-markdown-grid: #6e7681;
--color-markdown-marker: #c9d1d9;
--color-markdown-table-header: #21262d;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
@@ -172,12 +181,29 @@
--color-text-tertiary: #9a8a72;
--color-border-default: #ddcfad;
--color-border-subtle: #eadfc4;
--color-markdown-grid: #9c8353;
--color-markdown-marker: #554735;
--color-markdown-table-header: #eadbb8;
--color-accent-primary: #8a5b32;
--color-accent-primary-hover: #704724;
--color-accent-soft: #edddbd;
--color-text-link: #7b512e;
}
[data-code-theme='github-light'] {
--color-code-background: #f6f8fa;
--color-code-border: #d0d7de;
--color-code-text: #24292f;
--color-code-muted: #57606a;
}
[data-code-theme='github-dark'] {
--color-code-background: #161b22;
--color-code-border: #30363d;
--color-code-text: #c9d1d9;
--color-code-muted: #8b949e;
}
* {
box-sizing: border-box;
margin: 0;
@@ -221,6 +247,25 @@ button {
color: inherit;
}
button,
a,
input,
textarea,
select {
-webkit-tap-highlight-color: transparent;
}
input[type='checkbox'],
input[type='radio'],
input[type='range'] {
accent-color: var(--color-accent-primary);
}
:focus-visible {
outline: 2px solid var(--color-border-focus);
outline-offset: 2px;
}
input,
textarea,
select {
@@ -261,7 +306,25 @@ ol {
color: var(--color-accent-primary);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
[data-theme='dark'] ::selection {
background: var(--color-accent-primary);
color: var(--color-text-inverse);
}
@media (max-width: 900px) {
:root {
--sidebar-primary-width-expanded: 160px;
--sidebar-secondary-width: 224px;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { highlightCode } from './markdown'
describe('Shiki GitHub 双主题', () => {
it('一次渲染同时生成 GitHub Light 和 GitHub Dark 颜色变量', async () => {
const html = await highlightCode('const answer = 42', 'typescript')
expect(html).toContain('github-light')
expect(html).toContain('github-dark')
expect(html).toContain('--shiki-light')
expect(html).toContain('--shiki-dark')
})
})
+4
View File
@@ -16,6 +16,7 @@ import githubLight from '@shikijs/themes/github-light'
marked.setOptions({ gfm: true, breaks: true })
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
const highlighter = createHighlighterCore({
themes: [githubLight, githubDark],
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
@@ -47,5 +48,8 @@ export async function renderMarkdown(source: string): Promise<string> {
code.parentElement?.replaceWith(fragment)
}
// Markdown 可能来自模型或外部笔记,高亮完成后仍必须在最终出口统一净化。
return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } })
}
// TODO(performance): 编辑器首屏稳定后评估将 Shiki 延迟加载或迁移到 Web Worker。