Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e37ac7b0a4 | ||
|
|
574b113827 | ||
|
|
1132a4cece | ||
|
|
aedb1c1267 | ||
|
|
fc4b7b9495 | ||
|
|
0e8d4b7b9f | ||
|
|
a5b709a46f | ||
|
|
49dbacb296 | ||
|
|
3cb197aafe | ||
|
|
8da75d4420 | ||
|
|
84077feb18 | ||
|
|
bb8091f2e6 | ||
|
|
0b20bad0a8 | ||
|
|
9559fda5f9 | ||
|
|
fb1da4d00a | ||
|
|
2becaf0c65 | ||
|
|
49c856a69c | ||
|
|
629a6bda9c | ||
|
|
8e4f4e6d1a | ||
|
|
df044d7888 | ||
|
|
9bcb3bf3a6 | ||
|
|
206a8f5b0a | ||
|
|
3595021d80 | ||
|
|
7803ae5110 | ||
|
|
668139706b | ||
|
|
7fa2e9c404 | ||
|
|
ef03a8f745 | ||
|
|
2c84a98474 | ||
|
|
f75d486e00 |
@@ -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
@@ -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`。
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -69,6 +69,33 @@ MIGRATIONS: list[str] = [
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status_due ON tasks(status, due_at);
|
||||
""",
|
||||
# v3: 第二阶段 Agent Trace;Run 与事件事实持久化,供 SSE 恢复和 Benchmark 复用。
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
run_json TEXT NOT NULL,
|
||||
request_json TEXT NOT NULL,
|
||||
config_snapshot_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_created
|
||||
ON agent_runs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_status
|
||||
ON agent_runs(status, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_events (
|
||||
run_id TEXT NOT NULL REFERENCES agent_runs(run_id) ON DELETE CASCADE,
|
||||
sequence INTEGER NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL DEFAULT '{}',
|
||||
timestamp TEXT NOT NULL,
|
||||
PRIMARY KEY (run_id, sequence)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_events_type
|
||||
ON agent_events(run_id, event, sequence);
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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__}."
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Vault 相对路径校验;所有文件操作必须先经过本模块。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
from app.errors import ApiError
|
||||
|
||||
_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
|
||||
def normalize_folder(folder: str | None) -> str:
|
||||
"""返回使用 `/` 的安全相对目录;根目录表示为空字符串。"""
|
||||
|
||||
if not folder or folder in {"/", "\\"}:
|
||||
return ""
|
||||
if "\x00" in folder:
|
||||
raise ApiError(400, "INVALID_PATH", "folder must not contain NUL bytes")
|
||||
segments: list[str] = []
|
||||
for part in re.split(r"[\\/]+", folder):
|
||||
if not part:
|
||||
continue
|
||||
if part in {".", ".."} or ":" in part:
|
||||
raise ApiError(
|
||||
400,
|
||||
"INVALID_PATH",
|
||||
"folder must be a relative path without '.' or '..' segments",
|
||||
{"folder": folder},
|
||||
)
|
||||
segments.append(part)
|
||||
return "/".join(segments)
|
||||
|
||||
|
||||
def normalize_entry_name(name: str, *, markdown: bool = False) -> str:
|
||||
"""校验单个目录项名称;不静默接受路径分隔符或保留段。"""
|
||||
|
||||
value = name.strip()
|
||||
if not value or value in {".", ".."} or "\x00" in value:
|
||||
raise ApiError(400, "INVALID_PATH", "entry name is invalid", {"name": name})
|
||||
if _INVALID_FILE_CHARS.search(value):
|
||||
raise ApiError(
|
||||
400,
|
||||
"INVALID_PATH",
|
||||
"entry name contains unsupported characters",
|
||||
{"name": name},
|
||||
)
|
||||
if markdown and not value.lower().endswith(".md"):
|
||||
value += ".md"
|
||||
return value
|
||||
|
||||
|
||||
def safe_note_filename(title: str) -> str:
|
||||
"""为创建笔记保留原有的宽松清洗行为。"""
|
||||
|
||||
value = _INVALID_FILE_CHARS.sub("_", title).strip() or "untitled"
|
||||
return value if value.lower().endswith(".md") else f"{value}.md"
|
||||
|
||||
|
||||
def resolve_in_vault(relative_path: str) -> Path:
|
||||
"""把相对路径解析到当前 Vault,并拒绝符号链接/`..` 导致的越界。"""
|
||||
|
||||
if not relative_path or "\x00" in relative_path:
|
||||
raise ApiError(
|
||||
400, "INVALID_PATH", "invalid Vault-relative path", {"path": relative_path}
|
||||
)
|
||||
root = get_settings().vault_path.resolve()
|
||||
candidate = (root / relative_path.replace("\\", "/").lstrip("/")).resolve()
|
||||
if not candidate.is_relative_to(root):
|
||||
raise ApiError(
|
||||
400, "INVALID_PATH", "path escapes Vault", {"path": relative_path}
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
||||
def relative_to_vault(path: Path) -> str:
|
||||
return path.resolve().relative_to(get_settings().vault_path.resolve()).as_posix()
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Web 联调 Workspace:把单一配置 Vault 映射为前端可用的真实文件树。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app import repository
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
IndexRebuildRequest,
|
||||
OperationResponse,
|
||||
WorkspaceEntry,
|
||||
WorkspaceInfo,
|
||||
WorkspaceSnapshot,
|
||||
)
|
||||
from app.database.db import connect, transaction
|
||||
from app.errors import ApiError
|
||||
from app.retrieval.vectorstore import SqliteVecStore
|
||||
from app.services import index_service
|
||||
from app.services.coordination import serialized_vault_mutation
|
||||
from app.services.vault_paths import normalize_entry_name, normalize_folder, resolve_in_vault
|
||||
|
||||
vector_store = SqliteVecStore()
|
||||
|
||||
|
||||
def _entry_id(kind: str, path: str) -> str:
|
||||
digest = hashlib.sha256(f"{kind}:{path}".encode("utf-8")).hexdigest()[:16]
|
||||
return f"{kind}_{digest}"
|
||||
|
||||
|
||||
def _disk_markdown_paths() -> set[str]:
|
||||
root = get_settings().vault_path
|
||||
if not root.exists():
|
||||
return set()
|
||||
resolved_root = root.resolve()
|
||||
paths: set[str] = set()
|
||||
for path in root.rglob("*.md"):
|
||||
if path.is_symlink():
|
||||
continue
|
||||
resolved = path.resolve()
|
||||
if resolved.is_file() and resolved.is_relative_to(resolved_root):
|
||||
paths.add(resolved.relative_to(resolved_root).as_posix())
|
||||
return paths
|
||||
|
||||
|
||||
def get_workspace_info() -> WorkspaceInfo:
|
||||
root = get_settings().vault_path.resolve()
|
||||
disk_paths = _disk_markdown_paths()
|
||||
indexed_paths = {item.file_path for item in repository.list_note_locations()}
|
||||
return WorkspaceInfo(
|
||||
name=root.name or "Vault",
|
||||
path=str(root),
|
||||
file_count=len(disk_paths),
|
||||
indexed_note_count=len(indexed_paths),
|
||||
requires_refresh=disk_paths != indexed_paths,
|
||||
)
|
||||
|
||||
|
||||
def _tree(directory: Path, locations: dict[str, repository.NoteLocation]) -> list[WorkspaceEntry]:
|
||||
if not directory.exists():
|
||||
return []
|
||||
root = get_settings().vault_path.resolve()
|
||||
entries: list[WorkspaceEntry] = []
|
||||
children = sorted(
|
||||
directory.iterdir(), key=lambda item: (not item.is_dir(), item.name.casefold())
|
||||
)
|
||||
for child in children:
|
||||
if child.name.startswith(".") or child.is_symlink():
|
||||
continue
|
||||
resolved = child.resolve()
|
||||
if not resolved.is_relative_to(root):
|
||||
continue
|
||||
relative = resolved.relative_to(root).as_posix()
|
||||
public_path = f"/{relative}"
|
||||
if resolved.is_dir():
|
||||
entries.append(
|
||||
WorkspaceEntry(
|
||||
entry_id=_entry_id("folder", relative),
|
||||
name=child.name,
|
||||
path=public_path,
|
||||
type="folder",
|
||||
children=_tree(resolved, locations),
|
||||
)
|
||||
)
|
||||
elif resolved.is_file() and child.suffix.lower() == ".md":
|
||||
location = locations.get(relative)
|
||||
entries.append(
|
||||
WorkspaceEntry(
|
||||
entry_id=location.note_id if location else _entry_id("file", relative),
|
||||
note_id=location.note_id if location else None,
|
||||
name=child.name,
|
||||
path=public_path,
|
||||
type="file",
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def get_workspace_tree() -> list[WorkspaceEntry]:
|
||||
locations = {item.file_path: item for item in repository.list_note_locations()}
|
||||
return _tree(get_settings().vault_path.resolve(), locations)
|
||||
|
||||
|
||||
async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot:
|
||||
"""打开当前配置 Vault;发现未索引文件时先执行一次安全全量刷新。"""
|
||||
|
||||
root = get_settings().vault_path.resolve()
|
||||
if requested_path and Path(requested_path).resolve() != root:
|
||||
raise ApiError(
|
||||
409,
|
||||
"WORKSPACE_PATH_MISMATCH",
|
||||
"Web development mode can only open the backend configured Vault.",
|
||||
{"configured_path": str(root)},
|
||||
)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
info = get_workspace_info()
|
||||
if info.requires_refresh:
|
||||
await index_service.rebuild(IndexRebuildRequest())
|
||||
info = get_workspace_info()
|
||||
return WorkspaceSnapshot(workspace=info, items=get_workspace_tree())
|
||||
|
||||
|
||||
@serialized_vault_mutation
|
||||
async def create_folder(parent: str, name: str) -> WorkspaceEntry:
|
||||
clean_parent = normalize_folder(parent)
|
||||
clean_name = normalize_entry_name(name)
|
||||
relative = f"{clean_parent}/{clean_name}" if clean_parent else clean_name
|
||||
target = resolve_in_vault(relative)
|
||||
if not clean_parent:
|
||||
get_settings().vault_path.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
raise ApiError(
|
||||
409, "RESOURCE_CONFLICT", "folder already exists", {"path": relative}
|
||||
)
|
||||
if not target.parent.is_dir():
|
||||
raise ApiError(
|
||||
404,
|
||||
"RESOURCE_NOT_FOUND",
|
||||
"parent folder not found",
|
||||
{"parent": clean_parent},
|
||||
)
|
||||
target.mkdir(parents=False)
|
||||
return WorkspaceEntry(
|
||||
entry_id=_entry_id("folder", relative),
|
||||
name=clean_name,
|
||||
path=f"/{relative}",
|
||||
type="folder",
|
||||
)
|
||||
|
||||
|
||||
@serialized_vault_mutation
|
||||
async def rename_folder(path: str, new_name: str) -> WorkspaceEntry:
|
||||
old_folder = normalize_folder(path)
|
||||
if not old_folder:
|
||||
raise ApiError(400, "INVALID_PATH", "the Vault root cannot be renamed")
|
||||
clean_name = normalize_entry_name(new_name)
|
||||
parent = Path(old_folder).parent.as_posix()
|
||||
parent = "" if parent == "." else parent
|
||||
new_folder = f"{parent}/{clean_name}" if parent else clean_name
|
||||
source = resolve_in_vault(old_folder)
|
||||
target = resolve_in_vault(new_folder)
|
||||
if not source.is_dir() or source.is_symlink():
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "folder not found", {"path": path})
|
||||
if target.exists():
|
||||
raise ApiError(
|
||||
409, "RESOURCE_CONFLICT", "target folder already exists", {"path": new_folder}
|
||||
)
|
||||
|
||||
affected = [
|
||||
item
|
||||
for item in repository.list_note_locations()
|
||||
if item.folder == old_folder or item.folder.startswith(f"{old_folder}/")
|
||||
]
|
||||
source.replace(target)
|
||||
conn = connect()
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
with transaction(conn):
|
||||
for item in affected:
|
||||
file_suffix = item.file_path[len(old_folder) :].lstrip("/")
|
||||
folder_suffix = item.folder[len(old_folder) :].lstrip("/")
|
||||
repository.update_note_location(
|
||||
conn=conn,
|
||||
note_id=item.note_id,
|
||||
title=item.title,
|
||||
file_path=f"{new_folder}/{file_suffix}",
|
||||
folder=(
|
||||
f"{new_folder}/{folder_suffix}" if folder_suffix else new_folder
|
||||
),
|
||||
updated_at=now,
|
||||
)
|
||||
except BaseException:
|
||||
target.replace(source)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return WorkspaceEntry(
|
||||
entry_id=_entry_id("folder", new_folder),
|
||||
name=clean_name,
|
||||
path=f"/{new_folder}",
|
||||
type="folder",
|
||||
children=_tree(target, {item.file_path: item for item in repository.list_note_locations()}),
|
||||
)
|
||||
|
||||
|
||||
@serialized_vault_mutation
|
||||
async def delete_folder(path: str) -> OperationResponse:
|
||||
folder = normalize_folder(path)
|
||||
if not folder:
|
||||
raise ApiError(400, "INVALID_PATH", "the Vault root cannot be deleted")
|
||||
source = resolve_in_vault(folder)
|
||||
if not source.is_dir() or source.is_symlink():
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "folder not found", {"path": path})
|
||||
|
||||
affected = [
|
||||
item
|
||||
for item in repository.list_note_locations()
|
||||
if item.folder == folder or item.folder.startswith(f"{folder}/")
|
||||
]
|
||||
tombstone = source.with_name(f".{source.name}.{uuid4().hex}.deleting")
|
||||
source.replace(tombstone)
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
block_ids: list[str] = []
|
||||
for item in affected:
|
||||
block_ids.extend(repository.delete_note(item.note_id, conn=conn))
|
||||
await vector_store.delete(block_ids, conn=conn)
|
||||
except BaseException:
|
||||
tombstone.replace(source)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
shutil.rmtree(tombstone)
|
||||
except OSError:
|
||||
# 已提交的删除不回滚;隐藏 tombstone 可由后续维护任务清理。
|
||||
pass
|
||||
return OperationResponse(
|
||||
status="completed",
|
||||
resource_id=_entry_id("folder", folder),
|
||||
message=f"deleted folder and {len(affected)} indexed notes",
|
||||
)
|
||||
@@ -0,0 +1,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()
|
||||
@@ -1,10 +1,18 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.trace_repository import AgentTraceRepository
|
||||
from app.agent.permissions import PermissionMode
|
||||
from app.agent.tools import ToolExecutionContext
|
||||
from app.container import build_container
|
||||
from app.database.db import connect
|
||||
from app.errors import ApiError
|
||||
from app.routes import agent_events
|
||||
from app.contracts import (
|
||||
AgentEventType,
|
||||
AgentRun,
|
||||
AgentRunCreateRequest,
|
||||
AgentRunStatus,
|
||||
ToolCall,
|
||||
@@ -108,8 +116,200 @@ def test_permission_confirmation_resumes_agent() -> None:
|
||||
created.run_id, request_id, "allow_once"
|
||||
)
|
||||
completed = await container.agent.wait(created.run_id)
|
||||
events = [event async for event in container.agent.events(created.run_id)]
|
||||
assert completed.status == AgentRunStatus.completed
|
||||
assert completed.tool_results[0].success is True
|
||||
assert AgentEventType.permission_resolved in {event.event for event in events}
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_agent_trace_persists_and_replays_from_sequence() -> None:
|
||||
async def scenario() -> None:
|
||||
first = build_container()
|
||||
created = await first.agent.create_run(
|
||||
AgentRunCreateRequest(
|
||||
input="persistent trace",
|
||||
provider_id="mock",
|
||||
model="mock-1",
|
||||
metadata={"suite": "agent-benchmark-v1"},
|
||||
)
|
||||
)
|
||||
completed = await first.agent.wait(created.run_id)
|
||||
|
||||
restarted = build_container()
|
||||
restored = restarted.agent.get_run(created.run_id)
|
||||
first_page = restarted.agent.get_trace(
|
||||
created.run_id, after_sequence=-1, limit=2
|
||||
)
|
||||
second_page = restarted.agent.get_trace(
|
||||
created.run_id,
|
||||
after_sequence=first_page.next_sequence,
|
||||
limit=100,
|
||||
)
|
||||
replay = [
|
||||
event
|
||||
async for event in restarted.agent.events(
|
||||
created.run_id, after_sequence=first_page.next_sequence
|
||||
)
|
||||
]
|
||||
|
||||
assert completed.status == restored.status == AgentRunStatus.completed
|
||||
assert first_page.has_more is True
|
||||
assert [item.sequence for item in first_page.items] == [0, 1]
|
||||
assert second_page.items[0].sequence == 2
|
||||
assert replay == second_page.items
|
||||
assert first_page.summary.model_calls == 1
|
||||
assert first_page.summary.token_usage == completed.token_usage
|
||||
assert first_page.config_snapshot["metadata"] == {
|
||||
"suite": "agent-benchmark-v1"
|
||||
}
|
||||
assert second_page.items[-1].event == AgentEventType.run_completed
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_interrupted_persisted_run_is_closed_after_restart() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
request = AgentRunCreateRequest(
|
||||
input="interrupted",
|
||||
provider_id="mock",
|
||||
model="mock-1",
|
||||
)
|
||||
persisted = AgentRun(
|
||||
run_id="run_interrupted",
|
||||
status=AgentRunStatus.running,
|
||||
input=request.input,
|
||||
provider_id=request.provider_id,
|
||||
model=request.model,
|
||||
max_steps=request.max_steps,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
AgentTraceRepository().create_run(persisted, request, {"model": "mock-1"})
|
||||
|
||||
restarted = build_container()
|
||||
recovered = restarted.agent.get_run(persisted.run_id)
|
||||
events = run(
|
||||
_collect_events(restarted.agent.events(persisted.run_id, after_sequence=-1))
|
||||
)
|
||||
|
||||
assert recovered.status == AgentRunStatus.failed
|
||||
assert recovered.error_code == "AGENT_PROCESS_RESTARTED"
|
||||
assert events[-1].event == AgentEventType.run_failed
|
||||
assert events[-1].sequence == 0
|
||||
|
||||
|
||||
def test_trace_redacts_secrets_and_truncates_large_values() -> None:
|
||||
async def scenario() -> None:
|
||||
container = build_container()
|
||||
secret = "sk-should-not-be-stored"
|
||||
created = await container.agent.create_run(
|
||||
AgentRunCreateRequest(
|
||||
input=f'/tool system.echo {{"text":"{"x" * 4200}","api_key":"{secret}"}}',
|
||||
provider_id="mock",
|
||||
model="mock-1",
|
||||
allowed_tools=["system.echo"],
|
||||
metadata={"authorization": secret},
|
||||
)
|
||||
)
|
||||
await container.agent.wait(created.run_id)
|
||||
trace = container.agent.get_trace(
|
||||
created.run_id, after_sequence=-1, limit=100
|
||||
)
|
||||
tool_call = next(
|
||||
item for item in trace.items if item.event == AgentEventType.tool_call
|
||||
)
|
||||
|
||||
assert tool_call.data["arguments"]["api_key"] == "[REDACTED]"
|
||||
assert str(tool_call.data["arguments"]["text"]).endswith("...[TRUNCATED]")
|
||||
assert trace.config_snapshot["metadata"]["authorization"] == "[REDACTED]"
|
||||
assert secret not in trace.model_dump_json()
|
||||
conn = connect()
|
||||
try:
|
||||
stored_row = conn.execute(
|
||||
"""
|
||||
SELECT run_json, request_json, config_snapshot_json
|
||||
FROM agent_runs WHERE run_id = ?
|
||||
""",
|
||||
(created.run_id,),
|
||||
).fetchone()
|
||||
stored = "\n".join(str(value) for value in stored_row)
|
||||
finally:
|
||||
conn.close()
|
||||
assert secret not in stored
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_persisted_agent_run_preserves_long_input_and_output() -> None:
|
||||
"""审计事件可以限长,但重启后读取的 AgentRun 不能丢失正文。"""
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
long_input = "输入" * 2_500
|
||||
long_output = "输出" * 2_500
|
||||
request = AgentRunCreateRequest(
|
||||
input=long_input,
|
||||
provider_id="mock",
|
||||
model="mock-1",
|
||||
)
|
||||
persisted = AgentRun(
|
||||
run_id="run_long_content",
|
||||
status=AgentRunStatus.completed,
|
||||
input=long_input,
|
||||
output=long_output,
|
||||
provider_id=request.provider_id,
|
||||
model=request.model,
|
||||
max_steps=request.max_steps,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
repository = AgentTraceRepository()
|
||||
repository.create_run(persisted, request, {"model": request.model})
|
||||
|
||||
restored = repository.get_run(persisted.run_id)
|
||||
|
||||
assert restored is not None
|
||||
assert restored.input == long_input
|
||||
assert restored.output == long_output
|
||||
|
||||
|
||||
async def _collect_events(iterator):
|
||||
return [event async for event in iterator]
|
||||
|
||||
|
||||
def test_agent_sse_uses_last_event_id_and_emits_event_ids(monkeypatch) -> None:
|
||||
async def scenario() -> None:
|
||||
test_container = build_container()
|
||||
monkeypatch.setattr("app.routes.container", test_container)
|
||||
created = await test_container.agent.create_run(
|
||||
AgentRunCreateRequest(
|
||||
input="resume sse",
|
||||
provider_id="mock",
|
||||
model="mock-1",
|
||||
)
|
||||
)
|
||||
await test_container.agent.wait(created.run_id)
|
||||
|
||||
response = await agent_events(
|
||||
created.run_id, after_sequence=None, last_event_id="1"
|
||||
)
|
||||
chunks = [chunk async for chunk in response.body_iterator]
|
||||
body = "".join(
|
||||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||
for chunk in chunks
|
||||
)
|
||||
|
||||
assert "id: 0\n" not in body
|
||||
assert "id: 1\n" not in body
|
||||
assert "id: 2\n" in body
|
||||
assert "event: RunCompleted" in body
|
||||
|
||||
with pytest.raises(ApiError) as error:
|
||||
await agent_events(
|
||||
created.run_id, after_sequence=None, last_event_id="invalid"
|
||||
)
|
||||
assert error.value.code == "TRACE_CURSOR_INVALID"
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
@@ -89,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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
FolderCreateRequest,
|
||||
FolderDeleteRequest,
|
||||
FolderRenameRequest,
|
||||
NoteCreateRequest,
|
||||
NoteRenameRequest,
|
||||
WorkspaceOpenRequest,
|
||||
)
|
||||
from app.errors import ApiError
|
||||
from app.routes import (
|
||||
create_note,
|
||||
create_workspace_folder,
|
||||
delete_workspace_folder,
|
||||
get_note,
|
||||
get_workspace_tree,
|
||||
open_workspace,
|
||||
rename_note,
|
||||
rename_workspace_folder,
|
||||
)
|
||||
|
||||
|
||||
def test_open_workspace_indexes_real_markdown_and_returns_tree() -> None:
|
||||
vault = get_settings().vault_path
|
||||
note_path = vault / "课程" / "操作系统.md"
|
||||
note_path.parent.mkdir(parents=True)
|
||||
note_path.write_text("# 操作系统\n\n进程调度。\n", encoding="utf-8")
|
||||
|
||||
snapshot = asyncio.run(open_workspace(WorkspaceOpenRequest()))
|
||||
|
||||
assert snapshot.workspace.path == str(vault.resolve())
|
||||
assert snapshot.workspace.requires_refresh is False
|
||||
assert snapshot.workspace.file_count == snapshot.workspace.indexed_note_count == 1
|
||||
folder = snapshot.items[0]
|
||||
assert folder.path == "/课程"
|
||||
assert folder.children[0].path == "/课程/操作系统.md"
|
||||
assert folder.children[0].note_id is not None
|
||||
|
||||
|
||||
def test_open_workspace_rejects_unconfigured_path() -> None:
|
||||
with pytest.raises(ApiError) as error:
|
||||
asyncio.run(open_workspace(WorkspaceOpenRequest(path="C:/another-vault")))
|
||||
|
||||
assert error.value.code == "WORKSPACE_PATH_MISMATCH"
|
||||
|
||||
|
||||
def test_note_rename_preserves_identity_and_content() -> None:
|
||||
created = asyncio.run(
|
||||
create_note(
|
||||
NoteCreateRequest(
|
||||
title="旧名称", markdown="# 标题不变\n\n真实正文。\n", folder="课程"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
renamed = asyncio.run(
|
||||
rename_note(created.note_id, NoteRenameRequest(file_name="新名称.md"))
|
||||
)
|
||||
|
||||
assert renamed.note_id == created.note_id
|
||||
assert renamed.file_path == "课程/新名称.md"
|
||||
assert renamed.title == "新名称"
|
||||
assert renamed.markdown == "# 标题不变\n\n真实正文。\n"
|
||||
assert not (get_settings().vault_path / "课程" / "旧名称.md").exists()
|
||||
|
||||
|
||||
def test_folder_lifecycle_updates_database_and_vectors() -> None:
|
||||
folder = asyncio.run(
|
||||
create_workspace_folder(FolderCreateRequest(parent="/", name="课程"))
|
||||
)
|
||||
created = asyncio.run(
|
||||
create_note(
|
||||
NoteCreateRequest(title="网络", markdown="# 网络\n\nTCP。\n", folder="课程")
|
||||
)
|
||||
)
|
||||
|
||||
renamed_folder = asyncio.run(
|
||||
rename_workspace_folder(
|
||||
FolderRenameRequest(path=folder.path, new_name="计算机课程")
|
||||
)
|
||||
)
|
||||
moved_note = asyncio.run(get_note(created.note_id))
|
||||
|
||||
assert renamed_folder.path == "/计算机课程"
|
||||
assert moved_note.note_id == created.note_id
|
||||
assert moved_note.file_path == "计算机课程/网络.md"
|
||||
assert asyncio.run(get_workspace_tree())[0].children[0].note_id == created.note_id
|
||||
|
||||
response = asyncio.run(
|
||||
delete_workspace_folder(FolderDeleteRequest(path=renamed_folder.path))
|
||||
)
|
||||
|
||||
assert response.status == "completed"
|
||||
with pytest.raises(ApiError) as error:
|
||||
asyncio.run(get_note(created.note_id))
|
||||
assert error.value.code == "RESOURCE_NOT_FOUND"
|
||||
assert asyncio.run(get_workspace_tree()) == []
|
||||
|
||||
|
||||
def test_workspace_openapi_paths_are_published() -> None:
|
||||
from app.main import app
|
||||
|
||||
paths = app.openapi()["paths"]
|
||||
assert {
|
||||
"/api/workspace",
|
||||
"/api/workspace/open",
|
||||
"/api/workspace/tree",
|
||||
"/api/workspace/folders",
|
||||
"/api/workspace/folders/rename",
|
||||
"/api/workspace/folders/delete",
|
||||
"/api/notes/{note_id}/rename",
|
||||
} <= paths.keys()
|
||||
@@ -0,0 +1,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 Server:FastAPI + PostgreSQL + S3/MinIO | 可选自托管,多设备 Vault 同步、版本管理和设备管理 |
|
||||
| 测试 | pytest + Vitest + 自建 RAG / Agent Dataset | 后端、前端组件、接口、检索、Agent 和模型适配器测试 |
|
||||
| 测试 | pytest + Vitest + 版本化 RAG / Agent Dataset | 后端、前端组件、接口、检索质量、Agent 行为和模型适配器测试 |
|
||||
|
||||
表中的技术选型构成当前开发基线。新增依赖时需要明确其所属层、调用方、运行位置和替换成本,避免同一功能出现多套并行实现。
|
||||
|
||||
@@ -392,6 +397,22 @@ Reka UI / Headless Components 提供 Dialog、Popover、Menu、Tabs、Select、T
|
||||
|
||||
业务组件中优先引用 Design Token。主题包负责覆盖 Token 和允许开放的组件样式。主题加载器需要限制资源路径,避免主题 CSS 引用 Vault 外的任意本地文件。
|
||||
|
||||
### 5.5 Theme Package 与社区格式
|
||||
|
||||
第二阶段统一 Theme Package 的本地分发格式:
|
||||
|
||||
```text
|
||||
my-theme/
|
||||
├── theme.yaml
|
||||
├── theme.css
|
||||
├── preview.png
|
||||
└── README.md
|
||||
```
|
||||
|
||||
`theme.yaml` 至少声明 `id`、`name`、`version`、`author` 和 `min_app_version`。导入流程固定为 Manifest 校验、CSS 安全检查、隔离预览、安装、启用;停用或卸载后必须恢复内置 Design Token,不残留全局样式。
|
||||
|
||||
主题 CSS 只能使用开放的 Token 和宿主允许的稳定选择器,不允许脚本、远程资源、任意本地路径、`@import` 或覆盖安全相关界面。第二阶段只定义本地导入和社区包兼容格式,不把联网 Theme Marketplace 作为客户端依赖。
|
||||
|
||||
---
|
||||
|
||||
## 6. Markdown 编辑与知识结构
|
||||
@@ -472,6 +493,32 @@ Save Markdown
|
||||
|
||||
外部编辑器修改文件时也走相同流程。索引任务写入 `index_jobs`,前端可以展示待处理、处理中和失败状态。
|
||||
|
||||
### 6.5 Mermaid Code Block
|
||||
|
||||
Mermaid 使用标准 fenced code block 持久化,Markdown 文件仍是唯一正文来源:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Markdown] --> B[Renderer]
|
||||
```
|
||||
````
|
||||
|
||||
前端识别代码块后调用 Mermaid Renderer 生成 SVG。渲染必须支持亮暗主题、编辑后重新计算、错误占位、缩放查看和销毁旧实例。生成的 SVG 经过净化后才能插入 DOM;导出链路使用同一份 Mermaid 源码生成稳定的 SVG 或图片,不能抓取编辑器界面截图。
|
||||
|
||||
### 6.6 函数图像 Block
|
||||
|
||||
函数图像采用独立 fenced block,并在实现稳定后冻结语法:
|
||||
|
||||
````markdown
|
||||
```function-plot
|
||||
y = x^2
|
||||
y = sin(x)
|
||||
```
|
||||
````
|
||||
|
||||
解析层将文本转换为与渲染库无关的 `FunctionPlot`,至少包含表达式、定义域、显示范围、坐标轴和渲染配置。表达式解析不得使用 `eval` 或执行任意 JavaScript。交互预览和导出共享同一数据模型;HTML 可以保留交互渲染,PDF/DOCX 使用 SVG 或栅格化静态图。
|
||||
|
||||
---
|
||||
|
||||
## 7. SQLite 与数据归属
|
||||
@@ -498,7 +545,7 @@ index_jobs
|
||||
sync_state
|
||||
```
|
||||
|
||||
其中 `notes` 和 `blocks` 保存 Markdown 的结构化投影;FTS5 建立全文索引;sqlite-vec 保存 Block 向量;`agent_runs` 和 `tool_calls` 保存 Agent Trace;Provider 表保存非敏感模型配置。
|
||||
其中 `notes` 和 `blocks` 保存 Markdown 的结构化投影;FTS5 建立全文索引;sqlite-vec 保存 Block 向量;当前实现以 `agent_runs` 和 `agent_events` 保存 Agent Trace;Provider 表保存非敏感模型配置。
|
||||
|
||||
API Key、同步 Token 等机密数据不进入 SQLite,通过 `credential_id` 与 Stronghold 中的实际密钥关联。
|
||||
|
||||
@@ -628,7 +675,9 @@ class EmbeddingProvider(Protocol):
|
||||
async def embed_query(self, query: str) -> list[float]: ...
|
||||
```
|
||||
|
||||
默认配置使用本地 BGE-M3 类模型。模型名称和具体推理实现由配置决定,索引记录需要保存 embedding model id 和向量维度。用户更换模型后,由索引服务识别维度或模型变化并提示重新建立向量索引。
|
||||
目标默认配置使用本地 BGE-M3 类模型。当前第一阶段实现是 128 维 `HashEmbeddingProvider`,只用于离线跑通向量存储、索引更新和 Hybrid 链路,不代表真实语义召回质量。第二阶段接入真实 Embedding 时继续实现相同接口,上层 Retrieval Core 不依赖具体模型运行时。
|
||||
|
||||
索引记录需要保存 embedding model id、模型版本、向量维度和归一化方式。用户更换模型或任一索引兼容字段变化后,索引服务必须将旧向量标记为不可用并要求重建,禁止把不同模型生成的向量写入同一索引空间。
|
||||
|
||||
### 9.5 RRF 与 Reranker
|
||||
|
||||
@@ -724,7 +773,7 @@ Agent Run 至少提供以下限制:
|
||||
- 网络访问权限;
|
||||
- 并发 Tool 数量。
|
||||
|
||||
Agent 运行过程中产生的每一步写入 `agent_runs` 和 `tool_calls`,用户可以在 Agent Trace 中查看工具名称、参数摘要、耗时、执行结果和权限状态。
|
||||
Agent 运行过程中产生的每一步写入 `agent_runs` 和 `agent_events`,用户可以在 Agent Trace 中查看工具名称、参数摘要、耗时、执行结果和权限状态。SSE 与 Benchmark 均从同一事件事实读取,不维护旁路数据。
|
||||
|
||||
### 10.3 Tool Registry
|
||||
|
||||
@@ -771,6 +820,25 @@ audio.transcribe
|
||||
|
||||
Tool Executor 对参数再次进行 Pydantic 校验。文件修改类 Tool 调用 Knowledge Core,不允许 Tool 自行读取或修改 SQLite 表。
|
||||
|
||||
### 10.4 Agent Trace Contract
|
||||
|
||||
第二阶段的 Trace 不从前端临时状态反推,而由 Agent Runtime 产生可回放事件。一个 Trace 至少覆盖:
|
||||
|
||||
```text
|
||||
Run Started / Completed / Failed / Cancelled
|
||||
Model Call Started / Completed
|
||||
Thinking / Text Delta
|
||||
Tool Call / Tool Result
|
||||
Permission Required / Resolved
|
||||
Citation
|
||||
Usage
|
||||
Error
|
||||
```
|
||||
|
||||
每个事件携带 `run_id`、单调递增的 `sequence`、时间戳和结构化 `data`。Tool 事件额外记录调用 ID、参数摘要、耗时、权限结果、输出摘要和错误;Provider 事件记录 Provider、模型、Usage 和耗时,但不得记录 API Key、完整敏感正文或未经净化的第三方响应。
|
||||
|
||||
前端先按 `run_id + sequence` 回放和去重,再构建 Trace Tree。Benchmark 复用同一事件流统计工具选择、步骤、延迟和 Token Usage,不另建一套只供测试使用的 Agent 执行协议。持久化层接入后,SSE 使用 `Last-Event-ID` 或等价游标恢复中断连接。
|
||||
|
||||
---
|
||||
|
||||
## 11. Skill Runtime
|
||||
@@ -916,7 +984,7 @@ Manifest 进入安装流程前使用 Pydantic Schema 校验。Plugin ID、版本
|
||||
|
||||
### 12.3 Plugin Contribution
|
||||
|
||||
第一阶段允许 Plugin 声明以下 Contribution:
|
||||
Plugin Manifest 可以声明以下 Contribution:
|
||||
|
||||
```text
|
||||
Tool
|
||||
@@ -929,6 +997,10 @@ Settings Section
|
||||
|
||||
其中 Tool 面向 Agent;Command 面向命令面板和快捷操作;Importer / Exporter 用于文件格式扩展;Sidebar Panel 和 Settings Section 为前端提供受控扩展位置。
|
||||
|
||||
第一阶段只落地声明式 Tool Contribution。第二阶段新增 Command 和 Settings Contribution:Command 由后端注册为稳定 ID、标题、参数和执行目标,前端只消费 Contribution Contract;Settings Schema 首批只允许 `string`、`number`、`boolean`、`select` 和 `secret reference`,由宿主动态生成表单。
|
||||
|
||||
Secret Setting 只保存 Credential ID,明文通过 Secret API 写入凭据存储,不进入 Manifest、Plugin Storage、Pinia 或 Agent Trace。Importer、Exporter 和 Sidebar Panel 保留现有 Manifest 扩展位,未完成宿主实现前不得标记为可用。
|
||||
|
||||
Contribution 由宿主应用决定挂载位置。Plugin 不直接修改应用路由、Pinia Store 或核心数据库 Schema。
|
||||
|
||||
### 12.4 Plugin Host
|
||||
@@ -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 Bridge(stdio 首版已实现)
|
||||
├── 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 Trace 与 Provider/Extension Registry 当前仍为内存实现。
|
||||
目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引、任务及 Agent Trace;Provider/Extension Registry 当前仍为内存实现。
|
||||
|
||||
Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 通过 FTS5、轻量 Embedding、sqlite-vec、RRF 和 Reranker 提供混合检索;Agent Runtime 使用 Tool Registry 操作知识库和任务;Skill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置;当前 Plugin Runtime 支持 Manifest、生命周期和声明式白名单 Tool Contribution,独立 Plugin Host 与 MCP Bridge 留待后续。Provider Adapter 当前实现 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama,OpenAI Responses 和 Anthropic Messages 留待后续。
|
||||
Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 当前通过 FTS5、`HashEmbeddingProvider`、sqlite-vec、RRF 和轻量 Reranker 跑通混合检索,真实 Embedding 与正式 Benchmark 在第二阶段接入;Agent Runtime 使用 Tool Registry 操作知识库和任务,并将扩展 Agent Trace Contract 供可视化和 Benchmark 共用;Skill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置。
|
||||
|
||||
多模态目标方案使用 faster-whisper、pyannote.audio 和可选 emotion2vec;当前只读取 Host 预生成 transcript。API Key 在 Web 联调期由 Fernet 开发存储加密保存,桌面版迁移到 Tauri Stronghold。多设备同步的目标方案为独立、可自托管的 Sync Server,目前尚未实现;本地核心功能不依赖 Sync Server。
|
||||
当前 Plugin Runtime 支持 Manifest、生命周期和声明式白名单 Tool Contribution,并已通过 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 管理的真实单 Vault;Tauri 原生目录选择和多 Vault 尚未接入。
|
||||
|
||||
## 1. 第一阶段目标
|
||||
|
||||
@@ -716,10 +716,10 @@ SecretService
|
||||
|
||||
### 16.3 WorkspaceService
|
||||
|
||||
- 统一封装 Tauri 文件命令;
|
||||
- 统一封装 FastAPI Workspace/Note API,并为 Tauri 文件命令保留适配边界;
|
||||
- 规范化路径;
|
||||
- 处理文件锁、自动保存和冲突;
|
||||
- Web 开发模式提供可替换的 Mock 实现;
|
||||
- Web 开发模式连接后端配置的单一 Vault,禁止失败后回退 Mock;
|
||||
- 不把任意本地路径直接暴露给 Plugin UI。
|
||||
|
||||
## 17. 公共组件
|
||||
@@ -1,6 +1,6 @@
|
||||
# 后端接口契约(开发版)
|
||||
|
||||
> 更新日期:2026-08-30。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 FastAPI 运行时生成的 OpenAPI 为准。
|
||||
> 更新日期:2026-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 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
|
||||
- Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。
|
||||
- Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。
|
||||
- Workspace 已接入后端配置的真实 Vault;文件树、笔记读写、文件/目录新建、重命名和删除不再使用前端 Mock Fallback。
|
||||
- Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。
|
||||
- Plugin 启用前必须通过权限接口记录授权,未知权限默认拒绝。
|
||||
- 本地 stdio MCP Server 已通过独立子进程接入 Plugin Runtime;Agent 只消费内部 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 Adapter;OpenAI Responses 和 Anthropic Messages 尚未实现。
|
||||
- Provider 配置暂存内存,后续通过 Repository 接入 SQLite;PATCH 已支持用显式 `null` 清空 base URL、默认模型和凭据引用。
|
||||
- Run/Trace 暂存内存;下一步抽象 Repository 并接入 SQLite。
|
||||
- Run/Trace 已通过 Repository 接入 SQLite;后续增加按保留策略归档和 Benchmark 引用保护。
|
||||
- Permission 已有核心等待/恢复机制,前端确认 UI 已完成联调和中文展示。
|
||||
- Task 已持久化到 SQLite;Attachment Tool 读取 Host 管理目录中的 UTF-8 文件。
|
||||
- `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍按技术基线在第二阶段接入。
|
||||
- Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。
|
||||
- 当前 Plugin Host 只支持内置声明式白名单 handler;MCP Bridge、独立进程健康检查与 UI Contribution 在第二阶段实现。
|
||||
- 当前 Plugin Host 支持内置声明式 handler 和本地 stdio MCP Server;Streamable HTTP、OS 级沙箱、Plugin Command/Settings 与 UI Contribution 留在后续阶段。
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
> 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的
|
||||
> 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。
|
||||
|
||||
> 更新日期:2026-08-30。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 71 项测试通过。
|
||||
> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 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 Adapter;Tauri 文件系统、Stronghold 和桌面窗口能力应在桌面容器阶段接入,不影响页面与 Store 的调用边界。
|
||||
原统一占位页已经删除,所有已注册业务路由均指向真实页面。Web Workspace 已通过 FastAPI 连接后端配置的单一真实 Vault,不再回退 Mock 数据;Tauri 多 Vault、原生目录选择、Stronghold 和桌面窗口能力仍在桌面容器阶段接入,不影响页面与 Store 的调用边界。
|
||||
|
||||
## 2. 目录与职责
|
||||
|
||||
@@ -111,7 +111,9 @@ SecondarySidebar
|
||||
|
||||
文件树把右键目标保存在 `contextTarget`,重命名和删除始终作用于实际被右键的节点,不再依赖当前编辑文件。根目录使用 `/` 表示,新增根级文件时直接写入 Store 顶层数组。
|
||||
|
||||
当前 `workspaceService` 仍是 Web 开发模式下的 Mock Adapter。保存、重命名和删除只保留调用边界,尚未接入 Tauri 文件系统命令。进入桌面端阶段后,应替换 Service 内部实现,不改变 Component 和 Store 的调用方式。
|
||||
当前 `workspaceService` 是 FastAPI Workspace Adapter。打开 Vault 时只允许后端 `APP_VAULT_PATH` 配置的目录,随后通过 Workspace/Note API 读取真实文件树和 Markdown,并完成文件、目录的新建、重命名、移动、保存和删除。接口错误直接进入统一错误链路,不再用 Mock Fallback 掩盖连接或契约失败。
|
||||
|
||||
浏览器不能获得任意本地文件系统权限,因此 Web 模式不提供目录选择和多 Vault 管理。进入桌面端阶段后,由 Tauri Host 实现同一 Service 边界下的原生适配器,组件和 Store 无需感知底层传输变化。
|
||||
|
||||
## 6. HTTP 接口层
|
||||
|
||||
@@ -184,13 +186,13 @@ pnpm build
|
||||
|
||||
```text
|
||||
pnpm build passed
|
||||
pnpm test 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 尚未实现,设置页正式预设不会使用这两种协议。
|
||||
@@ -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) 中的状态检查与产物要求。
|
||||
@@ -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 和 Ollama;Mock 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-01:Agent Run 与 Event 只存在于进程内存
|
||||
|
||||
### 原因
|
||||
|
||||
旧 `AgentRuntime` 使用 `_records: dict[str, RunRecord]` 保存全部运行状态。每个 `RunRecord` 内部再保存 `events`、实时订阅队列和异步任务。创建、查询、列表和 SSE 回放都只读取这个字典,没有 Repository 边界。
|
||||
|
||||
第一阶段为控制内存加入了最多 200 个 Run、每个 Run 2000 个 Event 的限制。这是必要的资源保护,但只是减少进程内数据量,不能替代持久化。
|
||||
|
||||
### 后果
|
||||
|
||||
- AI Core 重启后,历史 Run、Tool Result、Citation 和错误全部丢失;
|
||||
- 前端刷新或重新连接后,只能读取当前进程尚未淘汰的数据;
|
||||
- Agent Benchmark 无法使用稳定的历史事实计算指标;
|
||||
- Run 列表随着进程重启清空,界面记录和实际操作脱节;
|
||||
- 内存裁剪后的旧事件无法再恢复。
|
||||
|
||||
### 解决思路
|
||||
|
||||
将 SQLite 中的 Run/Event 作为唯一持久化事实,把内存降级为执行上下文和实时订阅窗口。Runtime 继续负责状态机,Repository 负责落库、分页、恢复和统计,Router 不直接访问数据库。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新增 SQLite v3 migration:
|
||||
|
||||
```text
|
||||
agent_runs
|
||||
├── run_id
|
||||
├── status
|
||||
├── run_json
|
||||
├── request_json
|
||||
├── config_snapshot_json
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
agent_events
|
||||
├── run_id
|
||||
├── sequence
|
||||
├── event
|
||||
├── data_json
|
||||
└── timestamp
|
||||
```
|
||||
|
||||
新增 `AgentTraceRepository`,负责:
|
||||
|
||||
- 创建 Run 及请求、配置快照;
|
||||
- 在同一事务中更新 Run 并追加 Event;
|
||||
- 按创建时间分页列出 Run;
|
||||
- 按 sequence 读取 Event;
|
||||
- 生成 Trace 分页响应和统计摘要;
|
||||
- 恢复进程中断的非终态 Run。
|
||||
|
||||
内存中的 2000 条 Event 上限继续保留,但只用于实时订阅窗口;完整记录由 SQLite 管理。
|
||||
|
||||
## 3. A-02:Event 裁剪后 sequence 可能重复
|
||||
|
||||
### 原因
|
||||
|
||||
旧 `_publish()` 使用下面的方式生成序号:
|
||||
|
||||
```python
|
||||
sequence = len(record.events)
|
||||
```
|
||||
|
||||
当事件数量超过 2000 后,Runtime 会删除列表头部。列表长度重新回到 2000,后续事件仍会得到 2000,形成重复 sequence。
|
||||
|
||||
### 后果
|
||||
|
||||
- `run_id + sequence` 无法作为幂等键;
|
||||
- 前端按 sequence 去重时会错误丢弃新事件;
|
||||
- 时间线排序出现同序号节点;
|
||||
- 持久化后会触发主键冲突,或者在错误的覆盖策略下破坏旧事件;
|
||||
- Benchmark 无法可靠还原 Tool Call 顺序。
|
||||
|
||||
### 解决思路
|
||||
|
||||
sequence 应属于 Run 的逻辑时钟,不能从当前缓存长度推导。内存是否裁剪不得影响序号。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- `RunRecord` 增加独立的 `next_sequence`;
|
||||
- 每次发布读取当前值,再原子递增;
|
||||
- `agent_events` 使用 `(run_id, sequence)` 复合主键;
|
||||
- Repository 对重复写入使用幂等插入,不覆盖已经存在的事件事实;
|
||||
- Trace 和 SSE 均严格按 sequence 升序返回。
|
||||
|
||||
## 4. A-03:SSE 断线后无法恢复
|
||||
|
||||
### 原因
|
||||
|
||||
旧 SSE 接口只能从内存列表头部重新回放全部历史,再切换到实时队列。协议帧只有 `event:` 和 `data:`,没有 SSE 标准的 `id:`。接口也不读取 `Last-Event-ID` 或查询游标。
|
||||
|
||||
前端即使知道自己最后处理到哪个 sequence,也无法把该位置传回服务端。
|
||||
|
||||
### 后果
|
||||
|
||||
- 短暂断网或页面切换后只能从头回放;
|
||||
- 长 Run 重连会重复传输大量事件;
|
||||
- 前端需要依赖本地去重掩盖服务端缺少恢复能力;
|
||||
- AI Core 重启后无法续传,因为历史事件本身也不存在;
|
||||
- 实时与历史交界处容易漏事件或重复事件。
|
||||
|
||||
### 解决思路
|
||||
|
||||
恢复协议以 sequence 为游标。服务端先注册实时订阅,再读取 `sequence > cursor` 的持久化历史,随后消费实时队列;交界处允许重复,但 Runtime 和前端都按 sequence 去重。
|
||||
|
||||
### 解决方案
|
||||
|
||||
接口支持两种游标输入:
|
||||
|
||||
```http
|
||||
GET /api/agent/runs/{run_id}/events?after_sequence=42
|
||||
Last-Event-ID: 42
|
||||
```
|
||||
|
||||
返回帧包含:
|
||||
|
||||
```text
|
||||
id: 43
|
||||
event: ToolResult
|
||||
data: {"run_id":"...","sequence":43,"data":{...}}
|
||||
```
|
||||
|
||||
具体处理:
|
||||
|
||||
- `after_sequence` 优先于 `Last-Event-ID`;
|
||||
- 默认游标为 `-1`,表示从 sequence 0 开始;
|
||||
- 非整数或小于 `-1` 的 Header 返回 `TRACE_CURSOR_INVALID`;
|
||||
- 历史回放读取 SQLite,不依赖内存窗口;
|
||||
- 历史与实时交界处按最后已发送 sequence 跳过重复项;
|
||||
- 终态 Run 回放完终止事件后关闭连接。
|
||||
|
||||
## 5. A-04:缺少可分页 Trace 与 Benchmark 配置快照
|
||||
|
||||
### 原因
|
||||
|
||||
旧接口只有 Run 状态和 SSE。SSE 适合实时消费,不适合报告页随机访问、大 Trace 分页或 Benchmark 批量计算。Run 也没有保存创建时的 Provider、Model、Skill、允许工具和限制参数快照。
|
||||
|
||||
### 后果
|
||||
|
||||
- Trace 页面只能依赖一次长连接重建全部状态;
|
||||
- Benchmark 需要绕过正式接口读取 Runtime 内部对象;
|
||||
- Provider 或 Skill 配置变化后,旧结果失去可解释性;
|
||||
- 大 Trace 无法受控分页,接口响应体会持续增大;
|
||||
- 前端和 Benchmark 容易各自实现一套不一致的统计逻辑。
|
||||
|
||||
### 解决思路
|
||||
|
||||
提供面向读取的 Trace Snapshot API,但只返回平铺事实,不在后端生成前端树形布局。前端按 parent ID 和 sequence 构造时间线,Benchmark 从同一事件计算指标。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新增接口:
|
||||
|
||||
```http
|
||||
GET /api/agent/runs/{run_id}/trace?after_sequence=-1&limit=200
|
||||
```
|
||||
|
||||
响应包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "run_123",
|
||||
"status": "completed",
|
||||
"items": [],
|
||||
"next_sequence": 199,
|
||||
"has_more": true,
|
||||
"summary": {
|
||||
"model_calls": 2,
|
||||
"tool_calls": 3,
|
||||
"duration_ms": 1530,
|
||||
"token_usage": 2048,
|
||||
"errors": 0
|
||||
},
|
||||
"config_snapshot": {}
|
||||
}
|
||||
```
|
||||
|
||||
配置快照保存:
|
||||
|
||||
- Provider ID 和类型;
|
||||
- Model;
|
||||
- Capability;
|
||||
- Skill ID;
|
||||
- 允许的 Tool;
|
||||
- `max_steps`、Token Budget 和网络权限;
|
||||
- 经过脱敏的 Metadata。
|
||||
|
||||
## 6. A-05:Trace 缺少关键执行事实
|
||||
|
||||
### 原因
|
||||
|
||||
第一阶段事件能够表达 Run、Tool、Permission Request、Usage 和 Citation,但没有明确表示一次模型调用的开始、完成或失败。Tool Call 与模型轮次之间也没有 parent ID,Tool Result 缺少统一耗时。权限接口只唤醒 Future,不记录最终决定。
|
||||
|
||||
### 后果
|
||||
|
||||
- 前端无法展示“模型调用 → 多个 Tool → 下一次模型调用”的完整树;
|
||||
- Agent Benchmark 无法计算模型调用次数和平均步骤耗时;
|
||||
- 并发 Tool Call 时难以判断属于哪个模型轮次;
|
||||
- 权限卡片消失后,Trace 中只保留“请求过权限”,不知道用户允许还是拒绝;
|
||||
- Provider 失败只能看到最终 RunFailed,缺少模型调用级上下文。
|
||||
|
||||
### 解决思路
|
||||
|
||||
保持现有 AgentEvent envelope 不变,只增加事件类型和可选数据字段。调用关系使用稳定 ID 表达,不让前端根据相邻位置猜测父子关系。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新增事件:
|
||||
|
||||
```text
|
||||
ModelCallStarted
|
||||
ModelCallCompleted
|
||||
ModelCallFailed
|
||||
PermissionResolved
|
||||
```
|
||||
|
||||
补充字段:
|
||||
|
||||
- Model Call:`model_call_id`、Provider、Model、Step、Finish Reason、Token、Duration;
|
||||
- Tool Call/Result:`parent_model_call_id`、`duration_ms`;
|
||||
- Permission Resolved:`request_id`、Permission、Decision;
|
||||
- Model Call Failed:`error_code` 和耗时,不写入第三方原始敏感异常。
|
||||
|
||||
模型与 Tool 耗时使用单调时钟计算,避免系统时间调整影响 Duration。
|
||||
|
||||
## 7. A-06:Trace 可能保存 Secret 和超大结果
|
||||
|
||||
### 原因
|
||||
|
||||
Tool 参数和输出来自模型、插件或外部服务,属于不可信数据。旧事件直接保存 `ToolCall.model_dump()` 和 `ToolResult.model_dump()`。`notes.read`、附件或第三方 Tool 可以返回大段正文,参数也可能包含 `api_key`、Authorization 或 Password。
|
||||
|
||||
初版持久化修复只净化了 `agent_events.data_json`。提交前审阅发现,`agent_runs.run_json` 中的 `tool_results`、`output` 和 `input` 仍可能保存同一份敏感值,说明“只在事件层脱敏”并不完整。
|
||||
|
||||
### 后果
|
||||
|
||||
- API Key 或 Bearer Token 可能进入 SQLite、备份和测试产物;
|
||||
- Trace API 不返回 Secret,但数据库中的 Run Snapshot 仍可能泄露;
|
||||
- 单个 Tool Result 可以让 Event 和 Run JSON 快速膨胀;
|
||||
- 前端展开节点时可能因为超大 JSON 卡顿;
|
||||
- Benchmark Dataset 或报告导出可能间接携带密钥。
|
||||
|
||||
### 解决思路
|
||||
|
||||
所有进入持久化边界的数据统一经过同一个净化函数,不能分别在 Router、Runtime 和 Repository 中维护不同脱敏规则。Secret 脱敏适用于全部副本;体积限制只适用于 Trace Event、Request 和 Config 等审计数据,不能改变对外查询所依赖的 AgentRun 事实。
|
||||
|
||||
### 解决方案
|
||||
|
||||
统一处理以下对象:
|
||||
|
||||
```text
|
||||
AgentRun Snapshot
|
||||
AgentRunCreateRequest Snapshot
|
||||
Config Snapshot
|
||||
AgentEvent Data
|
||||
```
|
||||
|
||||
通用脱敏规则:
|
||||
|
||||
- `api_key`、Authorization、Access/Refresh Token、Password、Secret 等键替换为 `[REDACTED]`;
|
||||
- 常见 `sk-...` 和 `Bearer ...` 字符串模式直接替换;
|
||||
- `credential_id` 等非明文引用保留,不误判为 Secret。
|
||||
|
||||
Event、Request 和 Config 审计副本额外执行限长:
|
||||
|
||||
- 单字符串最多保留 4096 个字符;
|
||||
- 单集合最多保留 100 项;
|
||||
- 递归深度最多 8 层;
|
||||
- 超限位置使用明确的 `[TRUNCATED]` 或 `[MAX_DEPTH]` 标记。
|
||||
|
||||
`AgentRun` Snapshot 仍经过同一套 Secret 脱敏,但不执行长度、集合和深度截断。回归测试直接读取 `agent_runs` 原始 SQLite 字段,确认测试密钥没有落盘,避免只验证 API 响应造成假安全。
|
||||
|
||||
## 8. A-07:重启后未终止 Run 永久显示运行中
|
||||
|
||||
### 原因
|
||||
|
||||
Agent 的异步 Task 和 Permission Future 不能跨进程恢复。持久化 Run 后,如果直接返回数据库状态,重启前处于 `queued`、`running` 或 `waiting_permission` 的记录会一直保持非终态,但新进程中没有对应 Task 可以继续执行。
|
||||
|
||||
### 后果
|
||||
|
||||
- 前端长期显示“运行中”或“等待授权”;
|
||||
- SSE 订阅等待一个永远不会到来的终止事件;
|
||||
- Benchmark Runner 无法判断 Case 已中断;
|
||||
- 用户取消该 Run 时,新进程找不到实际 Task;
|
||||
- 统计中的成功率和耗时被悬挂 Run 污染。
|
||||
|
||||
### 解决思路
|
||||
|
||||
本阶段提供“状态恢复”,不伪装成“执行恢复”。没有可重放状态机、Provider 幂等令牌和 Tool 副作用日志之前,自动继续执行会造成重复写入或重复网络请求。
|
||||
|
||||
### 解决方案
|
||||
|
||||
新 Runtime 首次读取不属于当前进程的非终态 Run 时:
|
||||
|
||||
- 状态改为 `failed`;
|
||||
- 错误码设为 `AGENT_PROCESS_RESTARTED`;
|
||||
- 错误信息说明进程在完成前重启;
|
||||
- 使用数据库最大 sequence 加一,追加唯一 `RunFailed` 事件;
|
||||
- 后续 Run 查询、Trace 和 SSE 都返回同一终态事实。
|
||||
|
||||
已经完成、失败或取消的 Run 不修改,可以在重启后继续查询和回放。
|
||||
|
||||
## 9. A-08:前端无法消费第二阶段恢复协议
|
||||
|
||||
### 原因
|
||||
|
||||
后端增加新事件和 SSE `id` 后,前端 `AgentEventType` 仍只包含第一阶段事件。`SseClient` 只解析 `event` 和 `data`,忽略 `id`,也没有发送 `Last-Event-ID`。因此仅完成后端并不能形成可联调的 Contract。
|
||||
|
||||
### 后果
|
||||
|
||||
- `Record<AgentEventType, string>` 中文标签无法通过类型检查;
|
||||
- 前端不知道 Model Call 和 Permission Resolved 的类型;
|
||||
- 断线后无法把最后事件 ID 传回后端;
|
||||
- Trace 可视化负责人需要自行猜测 Wire DTO;
|
||||
- 后端恢复能力只能通过 Curl 使用,页面调用链没有闭环。
|
||||
|
||||
### 解决思路
|
||||
|
||||
范侧只提供稳定的前端接口适配,不越过分工实现 Trace Visualization。Pydantic Contract、TypeScript Wire DTO 和 Service 必须在同一功能提交中同步。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- TypeScript 增加四类第二阶段 Agent Event;
|
||||
- 增加 `AgentTraceSummary` 和 `AgentTraceResponse`;
|
||||
- `agentService.getAgentTrace()` 封装分页查询;
|
||||
- `streamAgentEvents()` 接受 `afterSequence`;
|
||||
- `SseClient` 发送 `Last-Event-ID` 并解析返回帧的 `id`;
|
||||
- 新事件增加中文标签和详情字段名;
|
||||
- 增加 SSE 请求头和 Event ID 单元测试。
|
||||
|
||||
Trace 时间线、树形布局、筛选、节点展开和 Citation 跳转仍由前端负责人实现。
|
||||
|
||||
## 10. A-09:AgentRun 与审计摘要共用限长规则
|
||||
|
||||
### 原因
|
||||
|
||||
初版使用 `sanitize_trace_value()` 同时处理 `run_json`、Request、Config 和 Event。该函数不仅脱敏,还会截断超过 4096 字符的字符串、超过 100 项的集合和超过 8 层的结构。`run_json` 随后又被 `GET /agent/runs/{run_id}` 和 Run 列表当作重启后的事实来源,因此审计数据的防膨胀规则意外改变了业务响应。
|
||||
|
||||
### 后果
|
||||
|
||||
- 常见的长模型回答在 AI Core 重启后只剩前 4096 个字符和截断标记;
|
||||
- 长输入、Tool Result 和嵌套结果也可能丢失;
|
||||
- 同一个 Run 在进程内与重启后的接口响应不一致;
|
||||
- 前端刷新、Benchmark 复核和问题追踪无法取得原始运行结果;
|
||||
- 原有测试只验证了 Trace 参数截断,没有比较重启前后的长正文。
|
||||
|
||||
审阅时使用 5000 字符 output 复现:写入前长度为 5000,重新读取后长度变为 4110,并以 `...[TRUNCATED]` 结尾。
|
||||
|
||||
### 解决思路
|
||||
|
||||
持久化边界包含两类数据:AgentRun 是业务事实,Trace Event、Request 和 Config 是可视化与审计摘要。两类数据必须共享 Secret 脱敏规则,但不能共享有损的体积限制。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- `sanitize_trace_value()` 增加明确的 `apply_limits` 策略参数;
|
||||
- 默认继续限长,保持 Event、Request 和 Config 的安全边界;
|
||||
- `_serialize_run()` 使用 `apply_limits=False`,完整保留 input、output、Tool Result 和 Citation;
|
||||
- Secret 键名及 `sk-`、Bearer 模式在两种策略下始终脱敏;
|
||||
- 增加超过 4096 字符的 input/output 持久化回归测试,直接从新 Repository 读取并逐字比较。
|
||||
|
||||
## 11. 事务、顺序与恢复不变量
|
||||
|
||||
本轮修复明确了以下不变量:
|
||||
|
||||
1. `run_id + sequence` 唯一标识一条 Agent Event。
|
||||
2. sequence 在一个 Run 内只增不减,不受内存裁剪影响。
|
||||
3. 发布事件时,在同一 SQLite 事务中更新 Run Snapshot 并追加 Event。
|
||||
4. SSE、Trace 页面和 Agent Benchmark 读取同一份 `agent_events`,不建立旁路。
|
||||
5. 终止事件为 `RunCompleted`、`RunFailed` 或 `RunCancelled`;终态 Run 不再产生业务事件。
|
||||
6. 重启后不能安全继续执行的 Run 必须明确失败,不能永久悬挂。
|
||||
7. 所有持久化 Trace 数据先脱敏、再写入。
|
||||
8. 前端按 `run_id + sequence` 去重,不能依赖一次网络读取对应一条 SSE Event。
|
||||
9. AgentRun 的持久化副本不执行审计摘要限长,重启前后业务字段必须一致。
|
||||
|
||||
## 12. 验证方法
|
||||
|
||||
后端:
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv run python -m compileall -q app
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
pnpm test
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
仓库检查:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
```
|
||||
|
||||
验证结果:
|
||||
|
||||
```text
|
||||
backend pytest 81 passed
|
||||
backend compileall passed
|
||||
frontend vitest 11 files / 27 tests passed
|
||||
frontend type-check passed
|
||||
frontend build passed
|
||||
git diff --check passed
|
||||
```
|
||||
|
||||
本轮新增回归覆盖:
|
||||
|
||||
- 完成 Run 在新 Runtime 中恢复查询;
|
||||
- Trace 多页读取和游标无重复;
|
||||
- SSE `Last-Event-ID`、`after_sequence` 和 `id:` 帧;
|
||||
- Model Call、Permission Resolved 和 Tool parent ID;
|
||||
- 进程中断 Run 自动生成唯一终止事件;
|
||||
- API Key、Authorization 和超长 Tool 参数净化;
|
||||
- 超长 AgentRun input/output 在持久化和重启读取后保持完整;
|
||||
- 直接检查 SQLite,确认 Secret 未进入 Run/Request/Config Snapshot;
|
||||
- OpenAPI 发布 Trace 路径;
|
||||
- 前端 SSE Client 发送和解析恢复游标。
|
||||
|
||||
测试仍会出现本机 `.pytest_cache` 无写入权限警告,不影响 81 项用例结果,也不涉及产品代码。
|
||||
|
||||
## 13. 当前边界与后续工作
|
||||
|
||||
### 13.1 本阶段明确不做
|
||||
|
||||
- 不在后端生成前端 Trace 树形布局;
|
||||
- 不在进程重启后自动重放未完成 Tool 副作用;
|
||||
- 不把 Secret 明文放入 Trace、日志或 Benchmark;
|
||||
- 不为 Benchmark 建立绕过 Agent Runtime 的专用执行协议。
|
||||
|
||||
### 13.2 后续需要继续处理
|
||||
|
||||
- 增加 Trace 保留、归档和被 Benchmark 引用时的保护策略;
|
||||
- 引入保留窗口后实现 `TRACE_CURSOR_EXPIRED`;
|
||||
- 根据桌面网络策略增加有上限的指数退避自动重连;
|
||||
- 评估高频 Token Event 的批量写入,减少 SQLite 连接与事务开销;
|
||||
- Agent Benchmark 接入正式 Trace 并验证指标字段是否充足;
|
||||
- 前端完成 Trace Timeline/Tree、筛选、节点详情和 Citation 跳转;
|
||||
- 多进程或远程执行出现需求后,再设计带租约和幂等副作用的执行恢复。
|
||||
|
||||
## 14. 可复用经验
|
||||
|
||||
### 14.1 资源上限不等于持久化
|
||||
|
||||
限制内存 Run 和 Event 数量只能防止进程膨胀,不能解决重启、审计和报告复现。临时保护措施应在文档中明确标注,不能被误认为最终架构已经完成。
|
||||
|
||||
### 14.2 游标必须独立于缓存结构
|
||||
|
||||
只要 sequence 来源于 `len(list)`、数组下标或当前页位置,裁剪和分页就可能破坏唯一性。可恢复事件流必须使用独立、单调且可持久化的逻辑序号。
|
||||
|
||||
### 14.3 恢复读取不等于恢复执行
|
||||
|
||||
恢复 Run/Trace 查询相对安全;恢复一个包含 Tool 副作用的执行任务需要额外的幂等、租约和补偿机制。在没有这些机制时,明确失败比重复执行更可靠。
|
||||
|
||||
### 14.4 脱敏要覆盖全部持久化副本
|
||||
|
||||
同一敏感值可能同时出现在 Event、Run Snapshot、Request、Config、日志和报告中。只检查最终 API 响应无法证明数据没有落盘,安全测试应直接验证持久化介质。
|
||||
|
||||
### 14.5 生产者和消费者 Contract 必须同时更新
|
||||
|
||||
后端新增事件类型、字段或 SSE 规则时,至少同步 Pydantic、OpenAPI、TypeScript DTO、Service 和协议测试。可视化页面可以由另一成员开发,但不能让对方从后端实现反推 Contract。
|
||||
+1
-1
@@ -499,5 +499,5 @@ backend/app/retrieval/engine.py FTS/Vector/Hybrid 检索编排
|
||||
backend/app/services/index_service.py 全量重建与失败恢复
|
||||
backend/app/knowledge/parser.py Block ID 与 tags 解析语义
|
||||
backend/tests/test_retrieval.py 审阅回归测试
|
||||
docs/Knowledge与Retrieval-Core开发说明.md 模块开发说明
|
||||
docs/development/Knowledge与Retrieval-Core开发说明.md 模块开发说明
|
||||
```
|
||||
@@ -4,7 +4,7 @@
|
||||
> 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。
|
||||
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
|
||||
|
||||
> 2026-08-30 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析和 Fernet 加密存储,当前完整后端回归基线为 71 项测试通过。
|
||||
> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 92 项测试通过。
|
||||
|
||||
## 1. 审阅结论
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SseClient } from './sseClient'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('SseClient resumable event transport', () => {
|
||||
it('sends Last-Event-ID and exposes the returned SSE id', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
'id: 3\nevent: ModelCallCompleted\ndata: {"sequence":3,"data":{"duration_ms":12}}\n\n',
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const received = vi.fn()
|
||||
const client = new SseClient({
|
||||
url: '/api/agent/runs/run-1/events?after_sequence=2',
|
||||
method: 'GET',
|
||||
lastEventId: '2',
|
||||
onEvent: received,
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/agent/runs/run-1/events?after_sequence=2',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({ 'Last-Event-ID': '2' }),
|
||||
}),
|
||||
)
|
||||
expect(received).toHaveBeenCalledWith(
|
||||
'ModelCallCompleted',
|
||||
{ sequence: 3, data: { duration_ms: 12 } },
|
||||
'3',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,17 @@
|
||||
import { resolveApiUrl } from './apiClient'
|
||||
|
||||
export type SseEventHandler = (event: string, data: Record<string, unknown>) => void
|
||||
export type SseEventHandler = (
|
||||
event: string,
|
||||
data: Record<string, unknown>,
|
||||
eventId?: string,
|
||||
) => void
|
||||
|
||||
export interface SseClientOptions {
|
||||
url: string
|
||||
method?: string
|
||||
body?: unknown
|
||||
token?: string
|
||||
lastEventId?: string
|
||||
onEvent?: SseEventHandler
|
||||
onError?: (error: Error) => void
|
||||
onOpen?: () => void
|
||||
@@ -26,7 +31,7 @@ export class SseClient {
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const { url, method = 'POST', body, token, onEvent, onError, onOpen, onDone } = this.options
|
||||
const { url, method = 'POST', body, token, lastEventId, onEvent, onError, onOpen, onDone } = this.options
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -38,6 +43,9 @@ export class SseClient {
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
if (lastEventId !== undefined) {
|
||||
headers['Last-Event-ID'] = lastEventId
|
||||
}
|
||||
|
||||
const resp = await fetch(resolveApiUrl(url), {
|
||||
method,
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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}/`)) {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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+\./)
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user