Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e8d4b7b9f | ||
|
|
a5b709a46f | ||
|
|
49dbacb296 | ||
|
|
3cb197aafe | ||
|
|
8da75d4420 | ||
|
|
84077feb18 | ||
|
|
bb8091f2e6 | ||
|
|
0b20bad0a8 | ||
|
|
9559fda5f9 | ||
|
|
fb1da4d00a | ||
|
|
2becaf0c65 | ||
|
|
49c856a69c | ||
|
|
629a6bda9c | ||
|
|
8e4f4e6d1a | ||
|
|
df044d7888 | ||
|
|
9bcb3bf3a6 | ||
|
|
206a8f5b0a | ||
|
|
3595021d80 | ||
|
|
7803ae5110 | ||
|
|
668139706b | ||
|
|
7fa2e9c404 | ||
|
|
ef03a8f745 | ||
|
|
2c84a98474 | ||
|
|
f75d486e00 | ||
|
|
82cb396685 | ||
|
|
3872ef3304 | ||
|
|
b70aac1934 | ||
|
|
bec308b5d1 | ||
|
|
0bb78e004b | ||
|
|
352975d753 | ||
|
|
0836807aa2 | ||
|
|
0842413d29 | ||
|
|
31e29f25bb | ||
|
|
dc5ee76bed | ||
|
|
340bfbbd07 | ||
|
|
0f6938c6a2 | ||
|
|
f7d864bc4d | ||
|
|
11cb384115 | ||
|
|
f993ee9657 | ||
|
|
1564df434d | ||
|
|
99a0595dbc | ||
|
|
5a4084de1e | ||
|
|
693c86c24d | ||
|
|
9bbe4d3c86 | ||
|
|
d7a640a147 | ||
|
|
9b55ccb02a | ||
|
|
8ee3adae6a | ||
|
|
9d223be5ca | ||
|
|
c5193626a5 | ||
|
|
c33610a295 | ||
|
|
32f6bd6468 | ||
|
|
bba3041867 | ||
|
|
0dbd32a757 | ||
|
|
a7772430d5 | ||
|
|
cd405da21c | ||
|
|
d6f6b7e6e2 | ||
|
|
4f9ebc55ec | ||
|
|
afd49ba00f | ||
|
|
5aa0026bb9 | ||
|
|
e5f803c364 | ||
|
|
5192a8b4e8 | ||
|
|
3239bab696 | ||
|
|
3c9142533e | ||
|
|
6b362ccb6f | ||
|
|
48dc6ce75b | ||
|
|
02e19be8bb | ||
|
|
610fc77b0f | ||
|
|
c6c28e4ebe |
@@ -13,6 +13,7 @@ backend/**/__pycache__/
|
||||
backend/.env
|
||||
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
|
||||
backend/data/*.db*
|
||||
backend/data/credentials/
|
||||
|
||||
# Editors and operating systems
|
||||
.idea/
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
|
||||
> 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 README。
|
||||
|
||||
> 当前基线:2026-08-30。第一阶段 Web 联调版的前端页面、Knowledge/Retrieval Core、AI/Agent Core、Extension Core、Provider 预设与本地加密凭据链路均已实现;Tauri Host、Stronghold、真实桌面文件系统和 Sync Server 尚未接入。
|
||||
|
||||
## 当前目录
|
||||
|
||||
```text
|
||||
NotesAgent/
|
||||
├── frontend/ Vue 3 + TypeScript + Vite 前端
|
||||
├── backend/ FastAPI + Pydantic 后端
|
||||
├── docs/ 分工与技术栈说明
|
||||
├── docs/ 架构、契约、开发说明、协作规范与问题复盘
|
||||
└── server sync/ 云同步服务预留目录,当前未实现
|
||||
```
|
||||
|
||||
## 开发环境
|
||||
|
||||
当前前后端壳子需要:
|
||||
当前开发版需要:
|
||||
|
||||
| 环境 | 要求 | 说明 |
|
||||
| --- | --- | --- |
|
||||
@@ -34,7 +36,7 @@ python --version
|
||||
uv --version
|
||||
```
|
||||
|
||||
当前壳子暂不需要 Rust 和 Tauri。开始桌面端集成后,再按照 `docs/AI笔记软件技术栈说明-团队版-v2.2.md` 安装 Rust Toolchain 与 Tauri CLI。
|
||||
当前 Web 联调不需要 Rust 和 Tauri。开始桌面端集成后,再按照 `docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md` 安装 Rust Toolchain 与 Tauri CLI。
|
||||
|
||||
## 首次初始化
|
||||
|
||||
@@ -74,6 +76,14 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||
- API 文档:<http://127.0.0.1:8000/docs>
|
||||
- OpenAPI JSON:<http://127.0.0.1:8000/openapi.json>
|
||||
|
||||
#### 开发环境使用外部模型
|
||||
|
||||
在“设置 → 模型提供商”中选择 DeepSeek 或 OpenAI 预设后,直接在密码输入框填写 API Key。前端只在提交期间持有该值,不写入 Pinia 或 localStorage;AI Core 将其加密保存到本机 `backend/data/credentials/`,Provider 配置只保留内部 Credential ID。
|
||||
|
||||
该目录同时包含本地开发用主密钥和密文,并已加入 `.gitignore`。这提供本地静态加密和完整性校验,但不能替代操作系统凭据库。开始 Tauri 桌面集成后,应将存储实现迁移到 Stronghold,保留现有 Credential API 与 Provider 接口边界。
|
||||
|
||||
无界面或自动化环境仍可使用 `DEEPSEEK_API_KEY`、`OPENAI_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;设置页保存的本地密钥优先,环境变量仅在本地未保存对应 Credential ID 时作为回退。密钥不得写入仓库文件、README、Issue、提交信息或聊天记录。
|
||||
|
||||
### 终端二:启动前端
|
||||
|
||||
```powershell
|
||||
@@ -101,8 +111,31 @@ cd frontend
|
||||
pnpm build
|
||||
```
|
||||
|
||||
前端单元与组件测试:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
pnpm test
|
||||
```
|
||||
|
||||
当前回归基线为后端 81 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||
|
||||
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 用途 |
|
||||
| --- | --- |
|
||||
| [文档总索引](docs/README.md) | 文档分类、阅读顺序和维护规则 |
|
||||
| [技术栈说明](docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md) | 目标架构、第二阶段技术边界与模块依赖 |
|
||||
| [第二阶段分工表](docs/architecture/第二阶段团队分工表.md) | 第二阶段人员职责、任务顺序、协作关系与验收项 |
|
||||
| [后端接口契约](docs/contracts/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 |
|
||||
| [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 |
|
||||
| [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
|
||||
| [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
|
||||
| [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 |
|
||||
| [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 |
|
||||
|
||||
## 日常开发注意事项
|
||||
|
||||
- Python 依赖统一修改 `backend/pyproject.toml`,修改后执行 `uv sync`。
|
||||
@@ -111,6 +144,7 @@ pnpm build
|
||||
- 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`。
|
||||
|
||||
+15
-4
@@ -1,7 +1,9 @@
|
||||
# Backend
|
||||
# Notes Agent Backend
|
||||
|
||||
FastAPI + Pydantic 的本地 AI Core / Agent Core。项目使用 uv 管理依赖和虚拟环境。
|
||||
|
||||
当前实现包含 Knowledge/Retrieval、Chat、Agent Runtime、Tool/Permission、Skill/Plugin、Provider Adapter、任务、索引和开发阶段凭据加密存储。Provider 支持 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama;OpenAI Responses、Anthropic Messages、MCP 独立 Host 和真实语音模型仍属于后续阶段。
|
||||
|
||||
```powershell
|
||||
uv sync
|
||||
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||
@@ -13,9 +15,18 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||
|
||||
- 健康检查:<http://127.0.0.1:8000/health>
|
||||
- API 文档:<http://127.0.0.1:8000/docs>
|
||||
- OpenAPI:<http://127.0.0.1:8000/openapi.json>
|
||||
|
||||
团队接口清单见 `../docs/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
||||
运行回归测试:
|
||||
|
||||
AI Core 与 Agent Core 的模块边界、Mock Provider 和 Tool Calling 调试方式见 `../docs/AI-Core与Agent-Core开发说明.md`。
|
||||
```powershell
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Knowledge Core 与 Retrieval Core 的模块边界、数据模型、接口与检索流程见 `../docs/Knowledge与Retrieval-Core开发说明.md`。
|
||||
当前基线为 81 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
||||
|
||||
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
||||
|
||||
AI Core 与 Agent Core 的模块边界、Mock Provider 和 Tool Calling 调试方式见 `../docs/development/AI-Core与Agent-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:
|
||||
|
||||
+188
-32
@@ -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,9 +473,21 @@ class AgentRuntime:
|
||||
else:
|
||||
result = await self._invoke_tool(record, call)
|
||||
|
||||
self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json"))
|
||||
self._publish_tool_result(record, result, parent_model_call_id, started_at)
|
||||
return result
|
||||
|
||||
def _publish_tool_result(
|
||||
self,
|
||||
record: RunRecord,
|
||||
result: ToolResult,
|
||||
parent_model_call_id: str,
|
||||
started_at: float,
|
||||
) -> None:
|
||||
data = result.model_dump(mode="json")
|
||||
data["parent_model_call_id"] = parent_model_call_id
|
||||
data["duration_ms"] = int((perf_counter() - started_at) * 1000)
|
||||
self._publish(record, AgentEventType.tool_result, data)
|
||||
|
||||
async def _invoke_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
@@ -400,14 +535,19 @@ class AgentRuntime:
|
||||
def _publish(
|
||||
self, record: RunRecord, event_type: AgentEventType, data: dict[str, object]
|
||||
) -> None:
|
||||
sanitized = sanitize_trace_value(data)
|
||||
assert isinstance(sanitized, dict)
|
||||
event = AgentEvent(
|
||||
event=event_type,
|
||||
run_id=record.run.run_id,
|
||||
sequence=len(record.events),
|
||||
data=data,
|
||||
sequence=record.next_sequence,
|
||||
data=sanitized,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
record.next_sequence += 1
|
||||
record.events.append(event)
|
||||
self.trace_repository.append_event(record.run, event)
|
||||
# 内存只保留实时订阅窗口;完整审计轨迹由 SQLite 保存。
|
||||
if len(record.events) > MAX_EVENTS_PER_RUN:
|
||||
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
|
||||
for queue in record.subscribers:
|
||||
@@ -421,6 +561,21 @@ class AgentRuntime:
|
||||
metadata["retrieval"] = record.skill_config.retrieval.model_dump(mode="json")
|
||||
return metadata
|
||||
|
||||
def _config_snapshot(self, record: RunRecord) -> dict[str, object]:
|
||||
provider = self.providers.get(record.request.provider_id).config
|
||||
return {
|
||||
"provider_id": record.request.provider_id,
|
||||
"provider_type": provider.provider_type.value,
|
||||
"model": record.request.model,
|
||||
"capabilities": [item.value for item in provider.capabilities],
|
||||
"skill_id": record.request.skill_id,
|
||||
"allowed_tools": list(record.allowed_tools),
|
||||
"max_steps": record.request.max_steps,
|
||||
"token_budget": record.request.token_budget,
|
||||
"allow_network": record.request.allow_network,
|
||||
"metadata": record.request.metadata,
|
||||
}
|
||||
|
||||
def _collect_citations(self, record: RunRecord, result: ToolResult) -> None:
|
||||
if not result.success or not isinstance(result.output, dict):
|
||||
return
|
||||
@@ -448,6 +603,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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Agent 工具注册与执行边界。"""
|
||||
|
||||
import inspect
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter
|
||||
@@ -29,6 +31,8 @@ class ToolNotFoundError(LookupError):
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""统一校验工具入参并隔离执行异常,避免单个工具击穿 Agent 主循环。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tools: dict[str, RegisteredTool] = {}
|
||||
|
||||
@@ -80,6 +84,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 +108,7 @@ class ToolRegistry:
|
||||
output=output,
|
||||
duration_ms=round((perf_counter() - started) * 1000),
|
||||
)
|
||||
except Exception as exc: # Tool failures are isolated from the Agent loop.
|
||||
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,
|
||||
)
|
||||
@@ -6,13 +6,18 @@ from app.contracts import ModelCapability, ProviderConfig, ProviderType
|
||||
from app.config import BACKEND_DIR
|
||||
from app.extensions import PluginRuntime, SkillRuntime
|
||||
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
||||
from app.providers.credentials import EnvironmentCredentialResolver
|
||||
from app.providers.credentials import (
|
||||
ChainedCredentialResolver,
|
||||
EncryptedCredentialStore,
|
||||
EnvironmentCredentialResolver,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplicationContainer:
|
||||
providers: ProviderRegistry
|
||||
provider_factory: ProviderFactory
|
||||
credentials: EncryptedCredentialStore
|
||||
tools: ToolRegistry
|
||||
permissions: PermissionManager
|
||||
skills: SkillRuntime
|
||||
@@ -21,7 +26,10 @@ class ApplicationContainer:
|
||||
|
||||
|
||||
def build_container() -> ApplicationContainer:
|
||||
provider_factory = ProviderFactory(EnvironmentCredentialResolver())
|
||||
credentials = EncryptedCredentialStore()
|
||||
provider_factory = ProviderFactory(
|
||||
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
|
||||
)
|
||||
providers = ProviderRegistry()
|
||||
providers.register(
|
||||
ProviderConfig(
|
||||
@@ -61,6 +69,7 @@ def build_container() -> ApplicationContainer:
|
||||
return ApplicationContainer(
|
||||
providers=providers,
|
||||
provider_factory=provider_factory,
|
||||
credentials=credentials,
|
||||
tools=tools,
|
||||
permissions=permissions,
|
||||
skills=skills,
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
|
||||
|
||||
class Contract(BaseModel):
|
||||
@@ -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"]
|
||||
|
||||
@@ -438,6 +506,28 @@ class ProviderListResponse(Contract):
|
||||
items: list[ProviderConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProviderPreset(Contract):
|
||||
preset_id: str
|
||||
name: str
|
||||
provider_type: ProviderType
|
||||
base_url: str
|
||||
default_credential_id: str | None = None
|
||||
requires_credential: bool = True
|
||||
|
||||
|
||||
class ProviderPresetListResponse(Contract):
|
||||
items: list[ProviderPreset] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CredentialWriteRequest(Contract):
|
||||
api_key: SecretStr = Field(min_length=1, max_length=8192)
|
||||
|
||||
|
||||
class CredentialStatus(Contract):
|
||||
credential_id: str
|
||||
configured: bool
|
||||
|
||||
|
||||
class ModelInfo(Contract):
|
||||
model: str
|
||||
display_name: str
|
||||
|
||||
@@ -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);
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,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:
|
||||
@@ -252,6 +253,7 @@ class PluginRuntime:
|
||||
self._records: dict[str, _PluginRecord] = {}
|
||||
|
||||
def install(self, package_path: str | Path) -> Plugin:
|
||||
# 当前只加载声明式清单,不导入或执行插件包中的任意 Python 代码。
|
||||
root = _package_dir(package_path)
|
||||
raw = _read_yaml(root / "plugin.yaml")
|
||||
if "id" in raw and "plugin_id" not in raw:
|
||||
@@ -315,6 +317,7 @@ class PluginRuntime:
|
||||
if record.plugin.enabled:
|
||||
return record.plugin.model_copy(deep=True)
|
||||
if record.plugin.manifest.backend.type == "mcp":
|
||||
# TODO(extension): 第二阶段以隔离进程实现 MCP Host,并补充签名与来源校验。
|
||||
record.plugin.status = PluginStatus.dependency_missing
|
||||
raise ExtensionError(
|
||||
"PLUGIN_HOST_UNAVAILABLE",
|
||||
@@ -366,6 +369,7 @@ class PluginRuntime:
|
||||
)
|
||||
record.registered_tools.append(spec.name)
|
||||
except Exception as exc:
|
||||
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
|
||||
for name in record.registered_tools:
|
||||
self.registry.unregister(name)
|
||||
record.registered_tools.clear()
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
"""Provider 凭据解析及本地加密存储。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
_CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
|
||||
|
||||
class CredentialStoreError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CredentialResolver(Protocol):
|
||||
def resolve(self, credential_id: str | None) -> str | None: ...
|
||||
@@ -10,8 +26,147 @@ class CredentialResolver(Protocol):
|
||||
class EnvironmentCredentialResolver:
|
||||
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
|
||||
|
||||
_development_aliases = {
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
}
|
||||
|
||||
def resolve(self, credential_id: str | None) -> str | None:
|
||||
if not credential_id:
|
||||
return None
|
||||
normalized = re.sub(r"[^A-Za-z0-9]", "_", credential_id).upper()
|
||||
return os.getenv(f"AINOTE_CREDENTIAL_{normalized}")
|
||||
injected = os.getenv(f"AINOTE_CREDENTIAL_{normalized}")
|
||||
if injected:
|
||||
return injected
|
||||
alias = self._development_aliases.get(credential_id.lower())
|
||||
return os.getenv(alias) if alias else None
|
||||
|
||||
|
||||
class EncryptedCredentialStore:
|
||||
"""将本地开发凭据作为 Fernet 密文存储,Provider 使用时按 ID 解密。"""
|
||||
|
||||
# TODO(security): 桌面 Host 接入后将主密钥迁移到系统钥匙串/凭据保险库。
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _validate_id(credential_id: str) -> None:
|
||||
if not _CREDENTIAL_ID.fullmatch(credential_id):
|
||||
raise CredentialStoreError("Credential ID contains unsupported characters.")
|
||||
|
||||
@staticmethod
|
||||
def _paths() -> tuple[Path, Path]:
|
||||
directory = get_settings().data_dir / "credentials"
|
||||
return directory / "master.key", directory / "credentials.json"
|
||||
|
||||
@staticmethod
|
||||
def _restrict(path: Path, mode: int) -> None:
|
||||
try:
|
||||
path.chmod(mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _fernet(self) -> Fernet:
|
||||
key_path, _ = self._paths()
|
||||
environment_key = os.getenv("APP_CREDENTIAL_MASTER_KEY")
|
||||
if environment_key:
|
||||
try:
|
||||
return Fernet(environment_key.encode("ascii"))
|
||||
except (ValueError, UnicodeEncodeError) as exc:
|
||||
raise CredentialStoreError("APP_CREDENTIAL_MASTER_KEY is invalid.") from exc
|
||||
|
||||
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)
|
||||
try:
|
||||
temporary.replace(key_path)
|
||||
except FileExistsError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
self._restrict(key_path, 0o600)
|
||||
try:
|
||||
return Fernet(key_path.read_bytes().strip())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise CredentialStoreError("Credential master key cannot be loaded.") from exc
|
||||
|
||||
def _read_tokens(self) -> dict[str, str]:
|
||||
_, store_path = self._paths()
|
||||
if not store_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise CredentialStoreError("Encrypted credential store cannot be loaded.") from exc
|
||||
if not isinstance(data, dict) or not all(
|
||||
isinstance(key, str) and isinstance(value, str) for key, value in data.items()
|
||||
):
|
||||
raise CredentialStoreError("Encrypted credential store has an invalid format.")
|
||||
return data
|
||||
|
||||
def _write_tokens(self, tokens: dict[str, str]) -> None:
|
||||
_, store_path = self._paths()
|
||||
store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._restrict(store_path.parent, 0o700)
|
||||
temporary = store_path.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(tokens, ensure_ascii=True, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._restrict(temporary, 0o600)
|
||||
# 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。
|
||||
temporary.replace(store_path)
|
||||
self._restrict(store_path, 0o600)
|
||||
|
||||
def put(self, credential_id: str, secret: str) -> None:
|
||||
self._validate_id(credential_id)
|
||||
if not secret:
|
||||
raise CredentialStoreError("Credential secret cannot be empty.")
|
||||
with self._lock:
|
||||
tokens = self._read_tokens()
|
||||
token = self._fernet().encrypt(secret.encode("utf-8")).decode("ascii")
|
||||
tokens[credential_id] = token
|
||||
self._write_tokens(tokens)
|
||||
|
||||
def resolve(self, credential_id: str | None) -> str | None:
|
||||
if not credential_id:
|
||||
return None
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
token = self._read_tokens().get(credential_id)
|
||||
if token is None:
|
||||
return None
|
||||
try:
|
||||
return self._fernet().decrypt(token.encode("ascii")).decode("utf-8")
|
||||
except (InvalidToken, UnicodeDecodeError) as exc:
|
||||
raise CredentialStoreError("Credential cannot be decrypted.") from exc
|
||||
|
||||
def has(self, credential_id: str) -> bool:
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
return credential_id in self._read_tokens()
|
||||
|
||||
def delete(self, credential_id: str) -> bool:
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
tokens = self._read_tokens()
|
||||
removed = tokens.pop(credential_id, None) is not None
|
||||
if removed:
|
||||
self._write_tokens(tokens)
|
||||
return removed
|
||||
|
||||
|
||||
class ChainedCredentialResolver:
|
||||
def __init__(self, *resolvers: CredentialResolver) -> None:
|
||||
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:
|
||||
return value
|
||||
return None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from app.contracts import ModelCapability, ProviderConfig, ProviderType
|
||||
from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType
|
||||
from app.providers.base import ModelProvider
|
||||
from app.providers.credentials import CredentialResolver
|
||||
from app.providers.ollama import OllamaProvider
|
||||
@@ -27,6 +27,32 @@ class ProviderFactory:
|
||||
return OllamaProvider(config.base_url or "http://127.0.0.1:11434")
|
||||
raise UnsupportedProviderError(config.provider_type.value)
|
||||
|
||||
@staticmethod
|
||||
def presets() -> list[ProviderPreset]:
|
||||
return [
|
||||
ProviderPreset(
|
||||
preset_id="openai",
|
||||
name="OpenAI",
|
||||
provider_type=ProviderType.openai_chat,
|
||||
base_url="https://api.openai.com/v1",
|
||||
default_credential_id="openai",
|
||||
),
|
||||
ProviderPreset(
|
||||
preset_id="deepseek",
|
||||
name="DeepSeek",
|
||||
provider_type=ProviderType.openai_compatible,
|
||||
base_url="https://api.deepseek.com",
|
||||
default_credential_id="deepseek",
|
||||
),
|
||||
ProviderPreset(
|
||||
preset_id="ollama",
|
||||
name="Ollama",
|
||||
provider_type=ProviderType.ollama,
|
||||
base_url="http://127.0.0.1:11434",
|
||||
requires_credential=False,
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def capabilities(provider_type: ProviderType) -> list[ModelCapability]:
|
||||
if provider_type in {
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.contracts import (
|
||||
ModelRequest,
|
||||
)
|
||||
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||
from app.providers.credentials import CredentialResolver
|
||||
from app.providers.credentials import CredentialResolver, CredentialStoreError
|
||||
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
||||
|
||||
|
||||
@@ -263,7 +263,18 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = self.credentials.resolve(self.credential_id)
|
||||
try:
|
||||
api_key = self.credentials.resolve(self.credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
raise ProviderError(
|
||||
"PROVIDER_CREDENTIAL_UNAVAILABLE",
|
||||
"Credential could not be decrypted by the AI Core.",
|
||||
) from exc
|
||||
if self.credential_id and not api_key:
|
||||
raise ProviderError(
|
||||
"PROVIDER_CREDENTIAL_MISSING",
|
||||
f'Credential "{self.credential_id}" is not available in the AI Core process.',
|
||||
)
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
@@ -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,
|
||||
|
||||
+185
-8
@@ -2,15 +2,21 @@ from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Header, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.contracts import (
|
||||
AgentRun,
|
||||
AgentRunCreateRequest,
|
||||
AgentRunListResponse,
|
||||
AgentTraceResponse,
|
||||
ChatRequest,
|
||||
CredentialStatus,
|
||||
CredentialWriteRequest,
|
||||
ExtensionInstallRequest,
|
||||
FolderCreateRequest,
|
||||
FolderDeleteRequest,
|
||||
FolderRenameRequest,
|
||||
IndexJob,
|
||||
IndexRebuildRequest,
|
||||
IndexStatus,
|
||||
@@ -20,6 +26,7 @@ from app.contracts import (
|
||||
NoteCreateRequest,
|
||||
NoteListResponse,
|
||||
NoteMoveRequest,
|
||||
NoteRenameRequest,
|
||||
NoteUpdateRequest,
|
||||
OperationResponse,
|
||||
PageMeta,
|
||||
@@ -31,6 +38,7 @@ from app.contracts import (
|
||||
ProviderCreateRequest,
|
||||
ProviderListResponse,
|
||||
ProviderModelsResponse,
|
||||
ProviderPresetListResponse,
|
||||
ProviderTestRequest,
|
||||
ProviderTestResponse,
|
||||
ProviderUpdateRequest,
|
||||
@@ -45,6 +53,10 @@ from app.contracts import (
|
||||
ToolListResponse,
|
||||
TranscriptionJob,
|
||||
TranscriptionRequest,
|
||||
WorkspaceEntry,
|
||||
WorkspaceInfo,
|
||||
WorkspaceOpenRequest,
|
||||
WorkspaceSnapshot,
|
||||
)
|
||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
from app.container import container
|
||||
@@ -52,8 +64,16 @@ from app.errors import ApiError
|
||||
from app.extensions import ExtensionError
|
||||
from app.providers.registry import ProviderNotFoundError
|
||||
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")
|
||||
|
||||
@@ -62,8 +82,9 @@ def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def as_sse(event: str, payload: str) -> str:
|
||||
return f"event: {event}\ndata: {payload}\n\n"
|
||||
def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str:
|
||||
id_line = f"id: {event_id}\n" if event_id is not None else ""
|
||||
return f"{id_line}event: {event}\ndata: {payload}\n\n"
|
||||
|
||||
|
||||
def provider_or_404(provider_id: str):
|
||||
@@ -109,6 +130,41 @@ def extension_call(operation):
|
||||
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
|
||||
|
||||
|
||||
# Workspace (single configured Vault in Web development mode)
|
||||
@router.get("/workspace", response_model=WorkspaceInfo, tags=["Workspace"])
|
||||
async def get_workspace() -> WorkspaceInfo:
|
||||
return workspace_service.get_workspace_info()
|
||||
|
||||
|
||||
@router.post("/workspace/open", response_model=WorkspaceSnapshot, tags=["Workspace"])
|
||||
async def open_workspace(request: WorkspaceOpenRequest) -> WorkspaceSnapshot:
|
||||
return await workspace_service.open_workspace(request.path)
|
||||
|
||||
|
||||
@router.get("/workspace/tree", response_model=list[WorkspaceEntry], tags=["Workspace"])
|
||||
async def get_workspace_tree() -> list[WorkspaceEntry]:
|
||||
return workspace_service.get_workspace_tree()
|
||||
|
||||
|
||||
@router.post("/workspace/folders", response_model=WorkspaceEntry, tags=["Workspace"])
|
||||
async def create_workspace_folder(request: FolderCreateRequest) -> WorkspaceEntry:
|
||||
return await workspace_service.create_folder(request.parent, request.name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/workspace/folders/rename", response_model=WorkspaceEntry, tags=["Workspace"]
|
||||
)
|
||||
async def rename_workspace_folder(request: FolderRenameRequest) -> WorkspaceEntry:
|
||||
return await workspace_service.rename_folder(request.path, request.new_name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/workspace/folders/delete", response_model=OperationResponse, tags=["Workspace"]
|
||||
)
|
||||
async def delete_workspace_folder(request: FolderDeleteRequest) -> OperationResponse:
|
||||
return await workspace_service.delete_folder(request.path)
|
||||
|
||||
|
||||
# Notes
|
||||
@router.get("/notes", response_model=NoteListResponse, tags=["Notes"])
|
||||
async def list_notes(
|
||||
@@ -155,6 +211,11 @@ async def move_note(note_id: str, request: NoteMoveRequest) -> Note:
|
||||
return await note_service.move_note(note_id, folder=request.folder)
|
||||
|
||||
|
||||
@router.post("/notes/{note_id}/rename", response_model=Note, tags=["Notes"])
|
||||
async def rename_note(note_id: str, request: NoteRenameRequest) -> Note:
|
||||
return await note_service.rename_note(note_id, file_name=request.file_name)
|
||||
|
||||
|
||||
# Retrieval and chat
|
||||
@router.post("/search", response_model=SearchResponse, tags=["Search"])
|
||||
async def search_notes(request: SearchRequest) -> SearchResponse:
|
||||
@@ -255,16 +316,65 @@ async def cancel_agent_run(run_id: str) -> OperationResponse:
|
||||
},
|
||||
tags=["Agent"],
|
||||
)
|
||||
async def agent_events(run_id: str) -> StreamingResponse:
|
||||
async def agent_events(
|
||||
run_id: str,
|
||||
after_sequence: int | None = Query(default=None, ge=-1),
|
||||
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||
) -> StreamingResponse:
|
||||
agent_run_or_404(run_id)
|
||||
cursor = after_sequence
|
||||
if cursor is None and last_event_id is not None:
|
||||
try:
|
||||
cursor = int(last_event_id)
|
||||
except ValueError as exc:
|
||||
raise ApiError(
|
||||
400,
|
||||
"TRACE_CURSOR_INVALID",
|
||||
"Last-Event-ID must be an integer sequence.",
|
||||
{"last_event_id": last_event_id},
|
||||
) from exc
|
||||
if cursor < -1:
|
||||
raise ApiError(
|
||||
400,
|
||||
"TRACE_CURSOR_INVALID",
|
||||
"Last-Event-ID must be greater than or equal to -1.",
|
||||
)
|
||||
cursor = cursor if cursor is not None else -1
|
||||
|
||||
async def stream() -> AsyncIterator[str]:
|
||||
async for event in container.agent.events(run_id):
|
||||
yield as_sse(event.event.value, event.model_dump_json())
|
||||
async for event in container.agent.events(run_id, after_sequence=cursor):
|
||||
yield as_sse(
|
||||
event.event.value,
|
||||
event.model_dump_json(),
|
||||
event_id=event.sequence,
|
||||
)
|
||||
|
||||
return StreamingResponse(stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent/runs/{run_id}/trace",
|
||||
response_model=AgentTraceResponse,
|
||||
tags=["Agent"],
|
||||
)
|
||||
async def get_agent_trace(
|
||||
run_id: str,
|
||||
after_sequence: int = Query(default=-1, ge=-1),
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
) -> AgentTraceResponse:
|
||||
try:
|
||||
return container.agent.get_trace(
|
||||
run_id, after_sequence=after_sequence, limit=limit
|
||||
)
|
||||
except AgentRunNotFoundError as exc:
|
||||
raise ApiError(
|
||||
404,
|
||||
"AGENT_RUN_NOT_FOUND",
|
||||
f"Agent run does not exist: {run_id}",
|
||||
{"run_id": run_id},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agent/runs/{run_id}/permissions/{request_id}",
|
||||
response_model=OperationResponse,
|
||||
@@ -411,11 +521,61 @@ async def uninstall_plugin(plugin_id: str) -> OperationResponse:
|
||||
|
||||
|
||||
# Providers
|
||||
@router.get(
|
||||
"/credentials/{credential_id}",
|
||||
response_model=CredentialStatus,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def get_credential_status(credential_id: str) -> CredentialStatus:
|
||||
try:
|
||||
configured = container.credentials.has(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
raise ApiError(422, "CREDENTIAL_INVALID", str(exc)) from exc
|
||||
return CredentialStatus(credential_id=credential_id, configured=configured)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/credentials/{credential_id}",
|
||||
response_model=CredentialStatus,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def put_credential(
|
||||
credential_id: str, request: CredentialWriteRequest
|
||||
) -> CredentialStatus:
|
||||
try:
|
||||
container.credentials.put(credential_id, request.api_key.get_secret_value())
|
||||
except CredentialStoreError as exc:
|
||||
raise ApiError(422, "CREDENTIAL_STORE_ERROR", str(exc)) from exc
|
||||
return CredentialStatus(credential_id=credential_id, configured=True)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/credentials/{credential_id}",
|
||||
response_model=CredentialStatus,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def delete_credential(credential_id: str) -> CredentialStatus:
|
||||
try:
|
||||
container.credentials.delete(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
raise ApiError(422, "CREDENTIAL_STORE_ERROR", str(exc)) from exc
|
||||
return CredentialStatus(credential_id=credential_id, configured=False)
|
||||
|
||||
|
||||
@router.get("/providers", response_model=ProviderListResponse, tags=["Providers"])
|
||||
async def list_providers() -> ProviderListResponse:
|
||||
return ProviderListResponse(items=container.providers.list_configs())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/providers/presets",
|
||||
response_model=ProviderPresetListResponse,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def list_provider_presets() -> ProviderPresetListResponse:
|
||||
return ProviderPresetListResponse(items=container.provider_factory.presets())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/providers/{provider_id}",
|
||||
response_model=ProviderConfig,
|
||||
@@ -502,9 +662,26 @@ async def delete_provider(provider_id: str) -> OperationResponse:
|
||||
)
|
||||
async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
|
||||
provider_or_404(provider_id)
|
||||
try:
|
||||
models = await container.providers.list_models(provider_id)
|
||||
except ProviderError as exc:
|
||||
status_code = {
|
||||
"PROVIDER_CREDENTIAL_MISSING": 422,
|
||||
"PROVIDER_CREDENTIAL_UNAVAILABLE": 500,
|
||||
"PROVIDER_AUTH_FAILED": 401,
|
||||
"MODEL_NOT_FOUND": 404,
|
||||
"PROVIDER_RATE_LIMITED": 429,
|
||||
"PROVIDER_TIMEOUT": 504,
|
||||
}.get(exc.code, 502)
|
||||
raise ApiError(
|
||||
status_code,
|
||||
exc.code,
|
||||
exc.message,
|
||||
{"provider_id": provider_id},
|
||||
) from exc
|
||||
return ProviderModelsResponse(
|
||||
provider_id=provider_id,
|
||||
items=await container.providers.list_models(provider_id),
|
||||
items=models,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -5,6 +5,7 @@ description = "Notes Agent 的 FastAPI 基础壳子"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"cryptography>=45,<52",
|
||||
"fastapi>=0.116,<1.0",
|
||||
"httpx>=0.28,<1.0",
|
||||
"jsonschema>=4.25,<5.0",
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import asyncio
|
||||
|
||||
from app.main import health, service_status
|
||||
from app.routes import get_index_status, list_notes, list_plugins, list_providers, list_skills
|
||||
from app.routes import (
|
||||
get_index_status,
|
||||
list_notes,
|
||||
list_plugins,
|
||||
list_provider_presets,
|
||||
list_providers,
|
||||
list_skills,
|
||||
)
|
||||
from app.routes import (
|
||||
create_provider,
|
||||
create_task,
|
||||
@@ -53,6 +60,24 @@ def test_core_collections_are_typed() -> None:
|
||||
assert index.status == "idle"
|
||||
|
||||
|
||||
def test_provider_presets_include_openai_and_deepseek() -> None:
|
||||
presets = asyncio.run(list_provider_presets())
|
||||
by_id = {item.preset_id: item for item in presets.items}
|
||||
|
||||
assert by_id["openai"].base_url == "https://api.openai.com/v1"
|
||||
assert by_id["deepseek"].base_url == "https://api.deepseek.com"
|
||||
assert by_id["deepseek"].provider_type == ProviderType.openai_compatible
|
||||
assert by_id["deepseek"].default_credential_id == "deepseek"
|
||||
|
||||
|
||||
def test_provider_presets_static_route_precedes_provider_id_route() -> None:
|
||||
from app.routes import router
|
||||
|
||||
get_paths = [route.path for route in router.routes if "GET" in getattr(route, "methods", set())]
|
||||
|
||||
assert get_paths.index("/api/providers/presets") < get_paths.index("/api/providers/{provider_id}")
|
||||
|
||||
|
||||
def test_openapi_contains_documented_frontend_interfaces() -> None:
|
||||
from app.main import app
|
||||
|
||||
@@ -64,12 +89,15 @@ 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}/enable",
|
||||
"/api/plugins/{plugin_id}/disable",
|
||||
"/api/providers/test",
|
||||
"/api/providers/presets",
|
||||
"/api/credentials/{credential_id}",
|
||||
"/api/index/rebuild",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import CredentialWriteRequest
|
||||
from app.providers.credentials import (
|
||||
ChainedCredentialResolver,
|
||||
EncryptedCredentialStore,
|
||||
EnvironmentCredentialResolver,
|
||||
)
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
from app.routes import get_credential_status, put_credential
|
||||
|
||||
|
||||
def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> None:
|
||||
store = EncryptedCredentialStore()
|
||||
secret = "sk-test-sensitive-value"
|
||||
|
||||
store.put("deepseek", secret)
|
||||
|
||||
store_path = get_settings().data_dir / "credentials" / "credentials.json"
|
||||
key_path = get_settings().data_dir / "credentials" / "master.key"
|
||||
assert store_path.exists()
|
||||
assert key_path.exists()
|
||||
assert secret not in store_path.read_text(encoding="utf-8")
|
||||
assert secret not in key_path.read_text(encoding="ascii")
|
||||
assert store.resolve("deepseek") == secret
|
||||
assert store.has("deepseek") is True
|
||||
assert store.delete("deepseek") is True
|
||||
assert store.resolve("deepseek") is None
|
||||
|
||||
|
||||
def test_credential_api_never_returns_secret() -> None:
|
||||
written = asyncio.run(
|
||||
put_credential(
|
||||
"deepseek",
|
||||
CredentialWriteRequest(api_key="sk-test-sensitive-value"),
|
||||
)
|
||||
)
|
||||
status = asyncio.run(get_credential_status("deepseek"))
|
||||
|
||||
assert written.model_dump() == {"credential_id": "deepseek", "configured": True}
|
||||
assert status.configured is True
|
||||
assert "sk-test-sensitive-value" not in written.model_dump_json()
|
||||
|
||||
|
||||
def test_provider_reads_decrypted_api_key_from_encrypted_store() -> None:
|
||||
store = EncryptedCredentialStore()
|
||||
store.put("deepseek", "sk-test-sensitive-value")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["Authorization"] == "Bearer sk-test-sensitive-value"
|
||||
return httpx.Response(200, json={"data": [{"id": "deepseek-chat"}]})
|
||||
|
||||
provider = OpenAICompatibleProvider(
|
||||
base_url="https://api.deepseek.test",
|
||||
credential_id="deepseek",
|
||||
credentials=store,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
models = asyncio.run(provider.list_models())
|
||||
|
||||
assert [model.model for model in models] == ["deepseek-chat"]
|
||||
|
||||
|
||||
def test_saved_credential_takes_precedence_over_environment_fallback(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "environment-key")
|
||||
store = EncryptedCredentialStore()
|
||||
store.put("deepseek", "saved-key")
|
||||
resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver())
|
||||
|
||||
assert resolver.resolve("deepseek") == "saved-key"
|
||||
@@ -12,6 +12,8 @@ from app.contracts import (
|
||||
ToolDefinition,
|
||||
)
|
||||
from app.providers.ollama import OllamaProvider
|
||||
from app.providers.base import ProviderError
|
||||
from app.providers.credentials import EnvironmentCredentialResolver
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
|
||||
|
||||
@@ -134,6 +136,53 @@ def test_openai_compatible_preserves_tool_call_context() -> None:
|
||||
assert turn.text == "done"
|
||||
|
||||
|
||||
def test_openai_compatible_fetches_and_maps_model_list() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "GET"
|
||||
assert request.url.path == "/v1/models"
|
||||
assert request.headers["Authorization"] == "Bearer secret-test-key"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": [{"id": "model-b"}, {"id": "model-a"}]},
|
||||
)
|
||||
|
||||
provider = OpenAICompatibleProvider(
|
||||
base_url="https://provider.test/v1",
|
||||
credential_id="provider-test",
|
||||
credentials=StaticCredentials(),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
models = run(provider.list_models())
|
||||
|
||||
assert [item.model for item in models] == ["model-b", "model-a"]
|
||||
|
||||
|
||||
def test_environment_credentials_support_deepseek_development_alias(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "secret-test-key")
|
||||
|
||||
assert EnvironmentCredentialResolver().resolve("deepseek") == "secret-test-key"
|
||||
|
||||
|
||||
def test_openai_compatible_rejects_missing_named_credential_before_request() -> None:
|
||||
class EmptyCredentials:
|
||||
def resolve(self, credential_id: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
provider = OpenAICompatibleProvider(
|
||||
base_url="https://provider.test/v1",
|
||||
credential_id="deepseek",
|
||||
credentials=EmptyCredentials(),
|
||||
)
|
||||
|
||||
try:
|
||||
run(provider.list_models())
|
||||
except ProviderError as error:
|
||||
assert error.code == "PROVIDER_CREDENTIAL_MISSING"
|
||||
else:
|
||||
raise AssertionError("Missing credential should fail before the provider request")
|
||||
|
||||
|
||||
def test_ollama_maps_models_and_completion() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/api/tags":
|
||||
|
||||
@@ -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()
|
||||
Generated
+165
@@ -51,6 +51,104 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
@@ -69,6 +167,62 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
@@ -215,6 +369,7 @@ name = "notes-agent-backend"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonschema" },
|
||||
@@ -230,6 +385,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cryptography", specifier = ">=45,<52" },
|
||||
{ name = "fastapi", specifier = ">=0.116,<1.0" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1.0" },
|
||||
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
|
||||
@@ -259,6 +415,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# NotesAgent 文档索引
|
||||
|
||||
本目录集中保存团队开发期间需要长期维护的架构、接口、实现、协作和问题复盘文档。文档按用途分类,避免设计约束、开发记录与故障复盘混放。
|
||||
|
||||
## 目录分类
|
||||
|
||||
| 目录 | 内容 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| [`architecture/`](architecture/) | 技术栈、阶段目标与团队分工 | 确认整体边界、模块依赖和阶段范围 |
|
||||
| [`contracts/`](contracts/) | 前后端接口契约与页面需求 | 开发前对齐 DTO、路由、事件和交互 |
|
||||
| [`development/`](development/) | 各模块的实现说明 | 阅读现有代码、联调和扩展功能 |
|
||||
| [`guides/`](guides/) | Git、测试、注释和 CI/CD 规范 | 日常开发、提交、审阅和发布 |
|
||||
| [`retrospectives/`](retrospectives/) | 审阅发现的问题与修复复盘 | 排查同类问题、撰写总结或博客 |
|
||||
|
||||
## architecture:架构与分工
|
||||
|
||||
- [AI 笔记软件技术栈说明](architecture/AI笔记软件技术栈说明-团队版-v2.3.md)
|
||||
- [第一阶段分工表](architecture/第一阶段分工表.md)
|
||||
- [第二阶段团队分工表](architecture/第二阶段团队分工表.md)
|
||||
|
||||
## contracts:契约与需求
|
||||
|
||||
- [后端接口契约](contracts/后端接口契约-开发版.md)
|
||||
- [第二阶段接口契约](contracts/第二阶段接口契约-开发版.md)
|
||||
- [前端页面需求说明](contracts/前端页面需求说明-开发版.md)
|
||||
|
||||
运行中的后端以 `/openapi.json` 为机器可读事实来源。接口契约用于描述设计意图、联调约束和实现状态;两者不一致时,应先确认代码行为,再在同一个 PR 中同步修正文档或实现。
|
||||
|
||||
## development:开发说明
|
||||
|
||||
- [AI Core 与 Agent Core 开发说明](development/AI-Core与Agent-Core开发说明.md)
|
||||
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
|
||||
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
|
||||
- [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md)
|
||||
- [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md)
|
||||
- [前端视觉与轻量动效优化开发说明](development/前端视觉与轻量动效优化开发说明.md)
|
||||
|
||||
## guides:团队协作规范
|
||||
|
||||
- [Git 使用细则](guides/Git使用细则-团队开发版.md)
|
||||
- [CI/CD 细则](guides/CI-CD细则-团队开发版.md)
|
||||
- [代码注释与 TODO 约定](guides/代码注释与TODO约定.md)
|
||||
- [第一阶段测试验证操作手册](guides/第一阶段测试验证操作手册.md)
|
||||
|
||||
## retrospectives:问题与修复复盘
|
||||
|
||||
- [后端全面审阅问题与修复复盘](retrospectives/后端全面审阅问题与修复复盘.md)
|
||||
- [Agent Core 第二阶段问题与修复复盘](retrospectives/Agent-Core第二阶段问题与修复复盘.md)
|
||||
- [Knowledge 与 Retrieval Core 问题与修复复盘](retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md)
|
||||
- [前端合并审阅问题与修复复盘](retrospectives/前端合并审阅问题与修复复盘.md)
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
新成员或新阶段开始时,建议按以下顺序阅读:
|
||||
|
||||
1. 技术栈说明和当前阶段分工表;
|
||||
2. 所负责功能对应的接口契约;
|
||||
3. 对应模块的开发说明;
|
||||
4. Git、CI/CD、测试及注释规范;
|
||||
5. 与当前任务相关的问题复盘。
|
||||
|
||||
## 维护规则
|
||||
|
||||
- 新文档先判断用途,再放入对应分类目录,不在 `docs/` 根目录继续堆放业务文档。
|
||||
- 移动或重命名文档时,同步修正仓库内全部链接,并执行本地链接检查。
|
||||
- 接口、数据结构或事件格式发生变化时,同一个 PR 内同步更新契约和相关开发说明。
|
||||
- 问题复盘至少写清原因、后果、解决思路、实际方案和验证结果。
|
||||
- `.local-plans/` 只保存个人或阶段性的本地计划,不属于正式团队文档,不应提交到远程仓库。
|
||||
- 文档中的“计划实现”和“已经实现”必须明确区分;实现状态以代码、测试和运行时契约为准。
|
||||
@@ -1,9 +1,12 @@
|
||||
# AI 笔记软件技术栈说明
|
||||
|
||||
> 文档性质:团队技术基线
|
||||
> 基线版本:v2.3
|
||||
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
|
||||
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员
|
||||
|
||||
> 实施状态更新:2026-09-01。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault,第二阶段 Agent Trace 持久化、分页快照和可恢复 SSE 已完成。后续继续接入真实音频处理、MCP、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档目的
|
||||
@@ -34,8 +37,10 @@
|
||||
| 桌面容器 | Tauri 2 + Rust | 桌面窗口、系统 API、本地文件访问、Sidecar 管理、安全边界 |
|
||||
| 前端 | Vue 3 + TypeScript + Vite | 工作区、编辑器、AI 对话、搜索、设置、扩展管理等用户界面 |
|
||||
| 状态管理 | Pinia | 管理工作区、编辑器、搜索、会话、Agent、Skill、主题与模型状态 |
|
||||
| UI 基础 | Reka UI / Headless Components + Design Token | 通用交互组件和主题化能力 |
|
||||
| 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 | 保存用户正文和附件 |
|
||||
@@ -47,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 | 可选音频分析能力 |
|
||||
| 密钥存储 | Tauri Stronghold | 保存模型 API Key 和同步凭证 |
|
||||
| 文档导出 | Document AST + Exporter Adapter | Markdown 到 HTML、PDF、DOCX,并保留图表、公式和代码块 |
|
||||
| 密钥存储 | 当前 Fernet 开发存储;目标 Tauri Stronghold | Web 联调期避免明文落盘,桌面集成后保存模型 API Key 和同步凭证 |
|
||||
| 云同步 | 独立 Sync Server:FastAPI + PostgreSQL + S3/MinIO | 可选自托管,多设备 Vault 同步、版本管理和设备管理 |
|
||||
| 测试 | pytest + 自建 RAG / Agent Dataset | 单元、接口、检索、Agent 和模型适配器测试 |
|
||||
| 测试 | pytest + Vitest + 版本化 RAG / Agent Dataset | 后端、前端组件、接口、检索质量、Agent 行为和模型适配器测试 |
|
||||
|
||||
表中的技术选型构成当前开发基线。新增依赖时需要明确其所属层、调用方、运行位置和替换成本,避免同一功能出现多套并行实现。
|
||||
|
||||
@@ -390,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 编辑与知识结构
|
||||
@@ -470,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 与数据归属
|
||||
@@ -496,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 中的实际密钥关联。
|
||||
|
||||
@@ -626,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
|
||||
|
||||
@@ -722,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
|
||||
|
||||
@@ -769,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
|
||||
@@ -914,7 +984,7 @@ Manifest 进入安装流程前使用 Pydantic Schema 校验。Plugin ID、版本
|
||||
|
||||
### 12.3 Plugin Contribution
|
||||
|
||||
第一阶段允许 Plugin 声明以下 Contribution:
|
||||
Plugin Manifest 可以声明以下 Contribution:
|
||||
|
||||
```text
|
||||
Tool
|
||||
@@ -927,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
|
||||
@@ -988,6 +1062,20 @@ Tool Registry 仍使用项目自己的 `ToolDefinition` 和 `ToolResult`。MCP B
|
||||
|
||||
MCP 能力首先用于 Tool 和 Resource 类扩展。需要复杂 UI 的插件通过 Frontend Extension Slot 单独处理。
|
||||
|
||||
第二阶段 MCP Bridge 至少覆盖以下协议边界:
|
||||
|
||||
```text
|
||||
Server Process / Connection Lifecycle
|
||||
initialize 与 capability negotiation
|
||||
tools/list 与 ToolDefinition 映射
|
||||
tools/call 与 ToolResult 映射
|
||||
超时、取消和进程退出
|
||||
协议错误与业务错误转换
|
||||
健康检查与 Tool 注销
|
||||
```
|
||||
|
||||
首个宿主实现优先支持本地 `stdio` 传输;其他传输在兼容性测试后增加。外部 Server 的 Tool 名称进入项目注册表前添加 Plugin 命名空间,并校验 JSON Schema、权限和重复 ID。MCP 内容不得绕过项目自己的 Permission、超时、日志净化和结果大小限制。
|
||||
|
||||
### 12.6 Frontend Extension Slot
|
||||
|
||||
前端预留受控扩展点:
|
||||
@@ -1064,6 +1152,8 @@ Plugin Storage API 负责访问该目录。插件不能通过自身目录拼接
|
||||
|
||||
需要保存密钥的 Plugin 通过 Secret API 请求独立 Credential ID,由 Stronghold 保存实际值。
|
||||
|
||||
第二阶段 Plugin 安装记录、启停状态、授权、配置 Schema 版本和 Contribution 元数据需要持久化。应用启动时先恢复元数据,再启动已启用的 Host;恢复失败的 Plugin 保持隔离并标记为 `error`,不能留下已注册但没有可用执行后端的 Tool 或 Command。
|
||||
|
||||
---
|
||||
|
||||
## 13. Provider Adapter 与模型接入
|
||||
@@ -1144,9 +1234,11 @@ 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:
|
||||
目标桌面架构将普通 Provider 配置保存在 SQLite。当前 Web 联调版由内存 `ProviderRegistry` 持有,AI Core 重启后清空:
|
||||
|
||||
```text
|
||||
provider_id
|
||||
@@ -1157,18 +1249,19 @@ credential_id
|
||||
enabled
|
||||
```
|
||||
|
||||
Stronghold 保存 `credential_id` 对应的实际 API Key。
|
||||
当前开发版使用 Fernet 加密文件保存 `credential_id` 对应的实际 API Key,并允许环境变量回退;Tauri 集成后由 Stronghold 替换该存储实现。
|
||||
|
||||
设置页面执行“测试连接”时:
|
||||
|
||||
```text
|
||||
读取 Provider Config
|
||||
→ Rust Host 读取 Secret
|
||||
→ 构造临时 Credential Context
|
||||
→ Credential Resolver 按 ID 读取开发密文或环境变量
|
||||
→ AI Core 调用 Provider
|
||||
→ 返回连接测试结果
|
||||
```
|
||||
|
||||
当前设置页已经提供 OpenAI、DeepSeek 与 Ollama 预设,保存后通过 `/api/providers/{provider_id}/models` 自动发现模型。Credential API 只返回配置状态,不提供任何明文读取接口。
|
||||
|
||||
日志中不记录完整 API Key。请求异常信息在进入前端前过滤 Authorization Header 和密钥片段。
|
||||
|
||||
---
|
||||
@@ -1201,6 +1294,23 @@ text
|
||||
|
||||
内容结构化模块将 Transcript 整理为 Markdown,同时保留音频时间信息,后续 RAG 引用可以跳回音频片段。
|
||||
|
||||
音频管线使用后台 Job,不在 HTTP 请求中长时间同步阻塞。转写结果统一为:
|
||||
|
||||
```text
|
||||
job_id
|
||||
attachment_id
|
||||
language
|
||||
segments[]
|
||||
speaker
|
||||
start_time
|
||||
end_time
|
||||
text
|
||||
status
|
||||
error
|
||||
```
|
||||
|
||||
`pyannote.audio` 和 `faster-whisper` 通过独立 Adapter 加载,模型下载、设备选择、精度、批量大小和缓存目录由配置管理。缺少说话人模型时可以只返回时间戳转写,但必须明确标记 diarization 不可用;模型失败不能生成伪造的 completed 结果。
|
||||
|
||||
### 14.2 OCR
|
||||
|
||||
OCR 作为 Media Pipeline 的输入适配能力,用于图片笔记、白板照片、PPT 截图和扫描资料。OCR 输出进入附件文本索引,也可以由用户选择生成 Markdown。
|
||||
@@ -1211,6 +1321,39 @@ OCR 引擎在当前技术栈中尚未固定,调用接口先定义为 `OCRProvi
|
||||
|
||||
emotion2vec 作为音频扩展分析模块。输出可以附加到音频段元数据,不参与核心 RAG 索引和 Agent 启动流程。
|
||||
|
||||
### 14.4 Document AST 与多格式导出
|
||||
|
||||
第二阶段建立统一导出链路:
|
||||
|
||||
```text
|
||||
Markdown
|
||||
→ Markdown AST
|
||||
→ Document AST
|
||||
→ DocumentExporter
|
||||
├── HtmlExporter
|
||||
├── PdfExporter
|
||||
└── DocxExporter
|
||||
```
|
||||
|
||||
`Document AST` 是导出器共享的中间表示,覆盖标题、段落、列表、表格、图片、引用、代码块、数学公式、Mermaid 和函数图像。Exporter 不直接解析编辑器 DOM,避免不同界面状态产生不同输出。
|
||||
|
||||
统一接口返回文件、MIME、警告和失败节点:
|
||||
|
||||
```python
|
||||
class DocumentExporter(Protocol):
|
||||
async def export(
|
||||
self,
|
||||
document: Document,
|
||||
options: ExportOptions,
|
||||
) -> ExportResult: ...
|
||||
```
|
||||
|
||||
HTML 导出保留结构化语义和受控样式;PDF 与 DOCX 在不支持交互内容时使用静态 SVG 或图片。具体底层库在 PoC 后冻结,但必须封装在 Exporter Adapter 内,不允许导出库的数据结构渗透到 Knowledge Core。
|
||||
|
||||
### 14.5 可视化内容的统一静态输出
|
||||
|
||||
Mermaid Renderer 和 Function Plot Renderer 除前端预览外,都必须提供可重复的静态输出接口。导出器只消费 SVG、PNG 或带尺寸信息的资源引用,不调用 Vue 组件。渲染结果按源码 Hash、主题和渲染器版本缓存;源码、主题或版本变化时缓存失效。
|
||||
|
||||
---
|
||||
|
||||
## 15. 安全与权限
|
||||
@@ -1716,6 +1859,8 @@ POST /api/index/rebuild
|
||||
GET /health
|
||||
```
|
||||
|
||||
第一阶段已实现路径和第二阶段冻结草案分别见 `../contracts/后端接口契约-开发版.md` 与 `../contracts/第二阶段接口契约-开发版.md`。规划接口完成前不得在前端假定其可用;完成后以 OpenAPI、Pydantic Contract 和 TypeScript Wire DTO 的一致结果为准。
|
||||
|
||||
HTTP 返回统一错误结构:
|
||||
|
||||
```json
|
||||
@@ -1790,6 +1935,8 @@ ainote/
|
||||
│ │ ├── skills/
|
||||
│ │ ├── providers/
|
||||
│ │ ├── media/
|
||||
│ │ ├── extensions/
|
||||
│ │ ├── export/
|
||||
│ │ └── database/
|
||||
│ └── tests/
|
||||
│
|
||||
@@ -1810,7 +1957,11 @@ ainote/
|
||||
│
|
||||
├── benchmarks/
|
||||
│ ├── rag/
|
||||
│ │ ├── datasets/
|
||||
│ │ └── reports/
|
||||
│ └── agent/
|
||||
│ ├── datasets/
|
||||
│ └── reports/
|
||||
│
|
||||
├── infra/
|
||||
│ └── cloud/
|
||||
@@ -1829,6 +1980,8 @@ ainote/
|
||||
- VectorStore 具体实现只出现在 Retrieval 基础设施层。
|
||||
- 内置 Skill 放在 `skills/builtin`,不硬编码在 Agent Runtime。
|
||||
- Plugin Runtime、Plugin Host 和 MCP Bridge 位于 Extension Core,对第三方插件暴露的稳定接口放在 `packages/plugin-sdk`。
|
||||
- Mermaid 与函数图像的 Markdown 源码解析归编辑/文档模型,交互渲染归前端 Renderer,静态渲染契约由 Export Service 复用。
|
||||
- Document AST 和 Exporter Adapter 位于 Export Service,导出器不得读取 Vue 组件 DOM 或 Provider 内部状态。
|
||||
- 内置 Plugin 放在 `plugins/builtin`,通过与第三方 Plugin 相同的 Contribution 接口注册。
|
||||
- Benchmark 数据和运行脚本放在 `benchmarks`。
|
||||
|
||||
@@ -1838,36 +1991,34 @@ ainote/
|
||||
|
||||
### 19.1 基础环境
|
||||
|
||||
团队开发机需要准备:
|
||||
当前 Web 联调开发机需要准备:
|
||||
|
||||
```text
|
||||
Node.js
|
||||
pnpm
|
||||
Rust toolchain
|
||||
Tauri CLI
|
||||
Python 3.x
|
||||
uv / Poetry(团队确定一种)
|
||||
Node.js 22+
|
||||
pnpm 10+
|
||||
Python 3.11+
|
||||
uv
|
||||
SQLite
|
||||
Git
|
||||
```
|
||||
|
||||
Python 环境需要支持 faster-whisper、pyannote.audio、Embedding 和 Reranker 所需依赖。涉及 CUDA 的开发成员可以安装 GPU 版本,基础功能仍需提供 CPU 可运行路径。
|
||||
Rust Toolchain 与 Tauri CLI 只在桌面容器阶段安装。当前轻量 Embedding/Reranker 不要求 CUDA;接入 faster-whisper、pyannote.audio 或真实本地模型时再按所选运行时增加 CPU/GPU 依赖。
|
||||
|
||||
### 19.2 本地开发
|
||||
|
||||
开发模式下分别启动 AI Core 和 Tauri:
|
||||
当前开发模式下分别启动 FastAPI 和 Vite:
|
||||
|
||||
```text
|
||||
Terminal A
|
||||
services/ai-core
|
||||
→ start FastAPI dev server
|
||||
cd backend
|
||||
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||
|
||||
Terminal B
|
||||
apps/desktop
|
||||
→ pnpm tauri dev
|
||||
cd frontend
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
开发配置允许桌面端连接固定开发端口。正式构建时改为 Sidecar 随机端口模式。
|
||||
Vite 将 `/api` 与 `/health` 代理到固定开发端口。正式桌面构建时改为 Tauri Sidecar 随机端口和临时访问令牌模式。
|
||||
|
||||
### 19.3 配置
|
||||
|
||||
@@ -1901,13 +2052,15 @@ Python 使用 pytest。重点覆盖:
|
||||
- Plugin Manifest;
|
||||
- Plugin Lifecycle;
|
||||
- Plugin Tool Registration;
|
||||
- MCP Bridge;
|
||||
- MCP 生命周期、Tool 映射、取消与异常退出;
|
||||
- Provider Adapter;
|
||||
- 权限判断;
|
||||
- API 错误转换。
|
||||
|
||||
前端测试重点覆盖 Store、Service 和关键交互流程。Rust 侧覆盖路径处理、Sidecar 启停和 Secret 访问封装。
|
||||
|
||||
第二阶段还需要增加 Theme Manifest/CSS 安全校验、Mermaid 错误与主题切换、Function Plot 表达式拒绝策略、Document AST 快照和 HTML/PDF/DOCX 导出回归测试。多模态测试使用小型固定音频 Fixture,模型级测试与不下载模型的快速单元测试分组执行。
|
||||
|
||||
### 20.2 RAG Benchmark
|
||||
|
||||
RAG Dataset 结构:
|
||||
@@ -1932,12 +2085,13 @@ Hybrid + Reranker
|
||||
核心指标:
|
||||
|
||||
```text
|
||||
Hit@1
|
||||
Hit@5
|
||||
Recall@K
|
||||
Hit@K
|
||||
MRR
|
||||
Citation Precision
|
||||
Citation Recall
|
||||
Latency
|
||||
Citation Hit Rate
|
||||
P50 Latency
|
||||
P95 Latency
|
||||
```
|
||||
|
||||
Benchmark 参数、Embedding 模型、Reranker、数据集版本和运行环境需要一起记录,保证不同实验结果可以复现。
|
||||
@@ -1952,12 +2106,13 @@ Agent Dataset 保存任务目标、允许工具、期望工具序列和结果条
|
||||
Task Success Rate
|
||||
Tool Selection Accuracy
|
||||
Tool Argument Accuracy
|
||||
Invalid Tool Call Rate
|
||||
Average Steps
|
||||
Average Latency
|
||||
Token Usage
|
||||
```
|
||||
|
||||
Agent Benchmark 同时记录模型 Provider 和模型版本。
|
||||
Agent Benchmark 同时记录模型 Provider、模型版本、Skill、可用工具集合、运行配置和 Dataset 版本。指标直接从 Agent Trace Contract 计算,测试框架不得绕过权限或使用另一套 Tool 执行器。
|
||||
|
||||
---
|
||||
|
||||
@@ -2076,6 +2231,47 @@ Audio
|
||||
→ RAG Index
|
||||
```
|
||||
|
||||
### 21.8 MCP Tool 接入
|
||||
|
||||
```text
|
||||
Enable Plugin
|
||||
→ Start MCP Server
|
||||
→ MCP initialize / tools/list
|
||||
→ Validate Schema and Permission
|
||||
→ Namespace Tool ID
|
||||
→ Register Tool Registry
|
||||
→ Agent Tool Call
|
||||
→ MCP tools/call
|
||||
→ Normalize ToolResult / Error
|
||||
→ Agent Trace
|
||||
```
|
||||
|
||||
### 21.9 Mermaid 与函数图像
|
||||
|
||||
```text
|
||||
Markdown Fenced Block
|
||||
→ Parse Source
|
||||
→ Mermaid / FunctionPlot Model
|
||||
→ Renderer Adapter
|
||||
├── Interactive Preview
|
||||
└── Sanitized SVG / Image
|
||||
→ Export Service
|
||||
```
|
||||
|
||||
### 21.10 多格式文档导出
|
||||
|
||||
```text
|
||||
Markdown
|
||||
→ Markdown AST
|
||||
→ Document AST
|
||||
→ Resolve Assets / Formula / Diagram / Plot
|
||||
→ Select Exporter
|
||||
├── HTML
|
||||
├── PDF
|
||||
└── DOCX
|
||||
→ ExportResult + Warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 22. 异常处理与恢复
|
||||
@@ -2105,7 +2301,7 @@ SQLite 索引损坏或版本不一致时,应用允许重建 `.ainote/app.db`
|
||||
|
||||
## 23. 当前版本实施边界
|
||||
|
||||
第一阶段开发围绕能够形成完整桌面知识工作流的能力展开:
|
||||
第一阶段围绕可联调的本地知识工作流建立基础接口:
|
||||
|
||||
```text
|
||||
Markdown Workspace
|
||||
@@ -2123,21 +2319,42 @@ Markdown Workspace
|
||||
→ Plugin Manifest / Tool Contribution
|
||||
```
|
||||
|
||||
第一阶段的 Plugin Runtime 需要完成安装、启用、停用、权限、Tool 注册和至少一个示例 Plugin,建立 Skill 调用 Plugin Tool 的完整链路。
|
||||
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。
|
||||
|
||||
第二阶段接入:
|
||||
截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成,第二阶段前置的 Workspace 去 Mock 联调及 Agent Trace 持久化/恢复接口也已完成。当前验证基线为后端 81 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
|
||||
|
||||
第二阶段在既有 Contract 上接入:
|
||||
|
||||
```text
|
||||
faster-whisper
|
||||
pyannote.audio
|
||||
主题导入与社区格式
|
||||
MCP Bridge
|
||||
Plugin Command / Settings Contribution
|
||||
更多 Provider
|
||||
Agent Trace 可视化
|
||||
RAG / Agent Benchmark
|
||||
Multimodal
|
||||
├── faster-whisper
|
||||
└── pyannote.audio
|
||||
|
||||
Extension / Model
|
||||
├── MCP Bridge
|
||||
├── Plugin Command Contribution
|
||||
├── Plugin Settings Contribution
|
||||
└── Provider Streaming / Tool Calling / Error Mapping 增强
|
||||
|
||||
Quality
|
||||
├── RAG Benchmark
|
||||
├── Agent Benchmark
|
||||
└── Retrieval 参数调优
|
||||
|
||||
Content Output
|
||||
├── Markdown → HTML / PDF / DOCX
|
||||
├── Mermaid 编辑、预览与静态导出
|
||||
└── Function Plot 解析、预览与静态导出
|
||||
|
||||
Frontend Extension
|
||||
├── Theme Package 导入与社区包格式
|
||||
├── Agent Trace 可视化
|
||||
├── Plugin Command UI
|
||||
└── Plugin Settings UI
|
||||
```
|
||||
|
||||
上述列表描述第二阶段技术范围,不表示能力已经实现。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
|
||||
|
||||
第三阶段处理:
|
||||
|
||||
```text
|
||||
@@ -2151,7 +2368,7 @@ Plugin 安装清单同步
|
||||
Skill 分发
|
||||
Plugin 分发与社区仓库
|
||||
Plugin Sidebar Panel 等前端扩展点
|
||||
Theme 社区
|
||||
联网 Theme Marketplace
|
||||
OCR 深度集成
|
||||
更多多模态能力
|
||||
扩展协议
|
||||
@@ -2159,7 +2376,7 @@ OCR 深度集成
|
||||
|
||||
Sync Server 按独立服务开发和部署,不进入桌面客户端核心启动依赖。第一版同步完成文件级 Revision、多设备增量同步和冲突保留后,再评估端到端加密与实时协同编辑。
|
||||
|
||||
阶段划分用于安排开发顺序。模块接口在第一阶段完成时确定基础版本,后续功能通过现有接口扩展。
|
||||
阶段划分用于限定交付范围。人员分工、任务顺序和协作安排以阶段分工表为准,不在技术栈说明中重复维护;本文只维护技术选型、模块边界和跨模块 Contract。
|
||||
|
||||
---
|
||||
|
||||
@@ -2188,10 +2405,12 @@ Sync Server 按独立服务开发和部署,不进入桌面客户端核心启
|
||||
|
||||
## 25. 当前技术基线摘要
|
||||
|
||||
项目桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 管理元数据、全文索引、向量索引和 Agent Trace。
|
||||
目标桌面端采用 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 通过 Plugin Manifest、Plugin Host 和 MCP Bridge 扩展 Tool、Command、导入导出和受控 UI Contribution,Plugin 注册的 Tool 可以被 Agent 与 Skill 共同使用;Provider Adapter 对接 OpenAI、OpenAI-Compatible、Anthropic 和 Ollama 等模型服务。
|
||||
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 作为扩展分析能力。API Key 和同步凭证存放在 Tauri Stronghold。多设备同步由独立 Sync Server 提供,采用 FastAPI、PostgreSQL 和 S3/MinIO,可由用户自托管。客户端在没有 Sync Server 时保持完整本地功能;连接服务器后同步 Markdown、Assets 和必要配置,各设备自行维护 FTS5、Embedding 和 Vector Index。
|
||||
当前 Plugin Runtime 支持 Manifest、生命周期和声明式白名单 Tool Contribution;第二阶段通过 MCP Bridge 接入隔离 Tool,并增加 Command 与 Settings Contribution。Provider Adapter 当前实现 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama,第二阶段按统一行为测试完善 OpenAI Responses、Anthropic Messages 等协议。多模态目标方案使用 faster-whisper、pyannote.audio 和可选 emotion2vec;当前只读取 Host 预生成 transcript。
|
||||
|
||||
第二阶段内容输出以 Document AST、Exporter Adapter、Mermaid Renderer 和 Function Plot Renderer 为共同边界,支持 HTML、PDF、DOCX 与静态图导出。Theme Package 使用 Manifest、Design Token 和受限 CSS 实现本地导入;联网主题市场不属于本阶段核心依赖。API Key 在 Web 联调期由 Fernet 开发存储加密保存,桌面版迁移到 Tauri Stronghold。多设备同步的目标方案为独立、可自托管的 Sync Server,目前尚未实现;本地核心功能不依赖 Sync Server。
|
||||
|
||||
该技术基线用于指导当前比赛版本的代码组织、接口设计、模块协作、测试和交付。
|
||||
@@ -1,5 +1,17 @@
|
||||
# 第一阶段分工表
|
||||
|
||||
> 状态更新:2026-08-30。本文保留原始职责划分,同时记录当前交付状态。第一阶段后端目标已完成,前端 Web 联调页面已完成;尚未纳入本阶段完成项的是 Tauri Host、Stronghold、真实桌面文件系统、独立 MCP Plugin Host、真实音频模型和 Sync Server。
|
||||
|
||||
## 当前交付状态
|
||||
|
||||
| 领域 | 当前状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Desktop Frontend / UI | Web 联调版已完成 | 全部业务路由、Markdown 写作/源码编辑、Chat、Search、Agent、扩展管理、设置页和 Provider 配置已落地 |
|
||||
| Knowledge / Retrieval Core | 第一阶段已完成 | Markdown Block、SQLite/FTS5、sqlite-vec、Hybrid/RRF/Reranker、Citation 与事务修复均已覆盖测试 |
|
||||
| AI / Agent Core | 第一阶段已完成 | Streaming、Agent Loop、Tool、Permission、Trace、Skill/Plugin 与 Knowledge/Retrieval 调用链已落地 |
|
||||
| Model Core | 第一阶段已完成 | Mock、OpenAI Chat/OpenAI-Compatible、DeepSeek 预设、Ollama、模型发现和加密凭据链路可用 |
|
||||
| Desktop Host / Sync | 后续阶段 | Tauri、Stronghold、系统文件访问、Sidecar 生命周期和云同步尚未实现 |
|
||||
|
||||
## 总分工表
|
||||
|
||||
| 成员 | 主要职责 | 第一阶段负责模块 | 具体工作内容 | 主要交付物 |
|
||||
@@ -0,0 +1,821 @@
|
||||
# 第二阶段团队分工表
|
||||
|
||||
## 一、阶段目标
|
||||
|
||||
第二阶段延续第一阶段已经形成的模块边界,重点推进多模态输入、MCP 与 Plugin 扩展、更多 Provider、RAG / Agent Benchmark、多格式导出、主题社区格式、Agent Trace 可视化、Mermaid 渲染和函数图像绘制。
|
||||
|
||||
第二阶段继续保持第一阶段的模块 ownership:
|
||||
|
||||
- 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、整体架构与代码审阅。
|
||||
- 杨星萱:Knowledge Core、Retrieval Core、Benchmark、文档导出、函数图像绘制。
|
||||
- 吉海燕:Frontend、Theme、Agent Trace、Plugin UI Contribution、Mermaid 渲染。
|
||||
|
||||
---
|
||||
|
||||
## 二、总分工表
|
||||
|
||||
| 成员 | 主要负责方向 | 第二阶段任务 | 配合事项 |
|
||||
| --- | --- | --- | --- |
|
||||
| 范涵宇 | Agent Core / Extension Core / Model Core / Multimodal / 总体架构 | faster-whisper、pyannote.audio、MCP Bridge、Plugin Command Contribution 后端、Plugin Settings Contribution 后端、更多 Provider、整体集成、代码审阅与统筹 | 与吉海燕联调 Plugin Command / Settings 前端;为杨星萱的 Agent Benchmark 提供 Agent Trace、Tool Call 等测试接口 |
|
||||
| 杨星萱 | Knowledge Core / Retrieval Core / Benchmark / Export / 数学内容渲染 | RAG Benchmark、Agent Benchmark 基础设施、Markdown → HTML / PDF / DOCX、函数图像绘制与渲染支持、Retrieval 调优 | 与范涵宇确认 Agent Benchmark 事件和测试数据结构;与吉海燕联调函数图像在编辑器和预览区中的展示 |
|
||||
| 吉海燕 | Frontend / Theme / Visualization | Theme Import、Theme Manifest、社区主题格式、Agent Trace 可视化、Plugin Command / Settings 前端、Mermaid 渲染支持 | 与范涵宇联调 Plugin Contribution Contract 与 AgentEvent;与杨星萱联调函数图像及导出预览 |
|
||||
|
||||
---
|
||||
|
||||
## 三、范涵宇
|
||||
|
||||
### 3.1 faster-whisper
|
||||
|
||||
负责接入 faster-whisper,完成真实音频转写。
|
||||
|
||||
目标链路:
|
||||
|
||||
```text
|
||||
Audio
|
||||
↓
|
||||
faster-whisper
|
||||
↓
|
||||
Timestamped Transcript
|
||||
↓
|
||||
Knowledge Core
|
||||
```
|
||||
|
||||
输出至少包含:
|
||||
|
||||
```text
|
||||
text
|
||||
start_time
|
||||
end_time
|
||||
language
|
||||
```
|
||||
|
||||
### 3.2 pyannote.audio
|
||||
|
||||
负责说话人分离,并与 faster-whisper 组合。
|
||||
|
||||
```text
|
||||
Audio
|
||||
↓
|
||||
pyannote.audio
|
||||
↓
|
||||
Speaker Segments
|
||||
↓
|
||||
faster-whisper
|
||||
↓
|
||||
Timestamped + Speaker Transcript
|
||||
```
|
||||
|
||||
输出至少包含:
|
||||
|
||||
```text
|
||||
speaker
|
||||
start_time
|
||||
end_time
|
||||
text
|
||||
```
|
||||
|
||||
### 3.3 MCP Bridge
|
||||
|
||||
负责实现 MCP Bridge,使外部 MCP Server 可以进入现有 Plugin / Tool 体系。
|
||||
|
||||
```text
|
||||
External MCP Server
|
||||
↓
|
||||
MCP Bridge
|
||||
↓
|
||||
Plugin Runtime
|
||||
↓
|
||||
Tool Registry
|
||||
↓
|
||||
Agent Runtime
|
||||
```
|
||||
|
||||
Agent Runtime 继续使用项目内部的:
|
||||
|
||||
```text
|
||||
ToolDefinition
|
||||
ToolCall
|
||||
ToolResult
|
||||
```
|
||||
|
||||
MCP Bridge 负责协议转换。
|
||||
|
||||
### 3.4 Plugin Command Contribution
|
||||
|
||||
负责 Plugin Runtime 中 Command Contribution 的解析、注册和执行接口。
|
||||
|
||||
```text
|
||||
Plugin Manifest
|
||||
↓
|
||||
Plugin Runtime
|
||||
↓
|
||||
Command Registry
|
||||
↓
|
||||
Frontend Contract
|
||||
```
|
||||
|
||||
吉海燕负责 Command Palette 等前端展示。
|
||||
|
||||
### 3.5 Plugin Settings Contribution
|
||||
|
||||
负责 Plugin Manifest 中 Settings Schema 的解析和后端配置接口。
|
||||
|
||||
首批支持:
|
||||
|
||||
```text
|
||||
string
|
||||
number
|
||||
boolean
|
||||
select
|
||||
secret reference
|
||||
```
|
||||
|
||||
范涵宇负责 Manifest Parsing、Settings Schema、Plugin Storage、Secret Reference 和 Runtime Contract。
|
||||
|
||||
### 3.6 更多 Provider
|
||||
|
||||
继续完善 Provider Adapter。
|
||||
|
||||
第二阶段优先验证:
|
||||
|
||||
```text
|
||||
Normal Chat
|
||||
Streaming
|
||||
Tool Calling
|
||||
Reasoning Event
|
||||
Cancellation
|
||||
Usage
|
||||
Error Mapping
|
||||
```
|
||||
|
||||
主要协议目标:
|
||||
|
||||
```text
|
||||
OpenAI Responses
|
||||
OpenAI Chat Completions
|
||||
OpenAI-Compatible
|
||||
Anthropic Messages
|
||||
Ollama
|
||||
```
|
||||
|
||||
Provider 数量不作为主要验收指标,优先保证协议适配稳定。
|
||||
|
||||
### 3.7 审阅与统筹
|
||||
|
||||
持续负责:
|
||||
|
||||
- 公共 Contract 审阅;
|
||||
- Agent / Skill / Plugin / Provider 接口审阅;
|
||||
- 跨模块 PR Review;
|
||||
- 第二阶段整体架构一致性检查;
|
||||
- Demo 链路集成;
|
||||
- 公共错误类型和事件格式统一;
|
||||
- 第二阶段版本合并与发布前检查。
|
||||
|
||||
---
|
||||
|
||||
## 四、杨星萱
|
||||
|
||||
### 4.1 RAG Benchmark
|
||||
|
||||
建立正式 RAG Benchmark。
|
||||
|
||||
比较:
|
||||
|
||||
```text
|
||||
FTS5
|
||||
Vector Search
|
||||
Hybrid Retrieval
|
||||
Hybrid + RRF
|
||||
Hybrid + RRF + Reranker
|
||||
```
|
||||
|
||||
核心指标:
|
||||
|
||||
```text
|
||||
Hit@1
|
||||
Hit@5
|
||||
Recall@K
|
||||
MRR
|
||||
Citation Hit Rate
|
||||
P50 Latency
|
||||
P95 Latency
|
||||
```
|
||||
|
||||
Dataset 至少记录:
|
||||
|
||||
```text
|
||||
query
|
||||
expected_note_id
|
||||
expected_block_id
|
||||
expected_citation
|
||||
tags
|
||||
```
|
||||
|
||||
### 4.2 Agent Benchmark
|
||||
|
||||
负责 Agent Benchmark 测试框架、Dataset、指标统计和报告生成。
|
||||
|
||||
范涵宇提供:
|
||||
|
||||
```text
|
||||
Agent Run
|
||||
Agent Event
|
||||
Tool Call
|
||||
Tool Result
|
||||
Trace
|
||||
```
|
||||
|
||||
主要指标:
|
||||
|
||||
```text
|
||||
Task Success Rate
|
||||
Tool Selection Accuracy
|
||||
Tool Argument Accuracy
|
||||
Invalid Tool Call Rate
|
||||
Average Steps
|
||||
Average Latency
|
||||
Token Usage
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```yaml
|
||||
id: agent-os-review-001
|
||||
|
||||
prompt: >
|
||||
找出操作系统笔记中关于死锁的内容,
|
||||
生成总结并创建三个复习任务。
|
||||
|
||||
expected_tools:
|
||||
- rag.search
|
||||
- notes.read
|
||||
- tasks.create
|
||||
|
||||
expected_conditions:
|
||||
citation_required: true
|
||||
tasks_created: 3
|
||||
```
|
||||
|
||||
### 4.3 多格式文档导出
|
||||
|
||||
负责建立统一 Export Service:
|
||||
|
||||
```text
|
||||
Document AST
|
||||
↓
|
||||
DocumentExporter
|
||||
├── HtmlExporter
|
||||
├── PdfExporter
|
||||
└── DocxExporter
|
||||
```
|
||||
|
||||
统一接口示意:
|
||||
|
||||
```python
|
||||
class DocumentExporter(Protocol):
|
||||
async def export(
|
||||
self,
|
||||
document: Document,
|
||||
options: ExportOptions,
|
||||
) -> ExportResult:
|
||||
...
|
||||
```
|
||||
|
||||
第二阶段完成:
|
||||
|
||||
```text
|
||||
Markdown → HTML
|
||||
Markdown → PDF
|
||||
Markdown → DOCX
|
||||
```
|
||||
|
||||
导出时尽量保持:
|
||||
|
||||
- 标题;
|
||||
- 段落;
|
||||
- 列表;
|
||||
- 表格;
|
||||
- 图片;
|
||||
- 引用;
|
||||
- 代码块;
|
||||
- 数学公式;
|
||||
- Mermaid;
|
||||
- 函数图像。
|
||||
|
||||
Exporter 接口保留未来由 Plugin 增加 EPUB、LaTeX 等格式的扩展空间。
|
||||
|
||||
### 4.4 函数图像绘制与渲染支持
|
||||
|
||||
负责函数图像相关的数据解析、表达、绘制和渲染支持。
|
||||
|
||||
目标示例:
|
||||
|
||||
```text
|
||||
y = x^2
|
||||
y = sin(x)
|
||||
y = 2x + 1
|
||||
```
|
||||
|
||||
内部建议抽象:
|
||||
|
||||
```text
|
||||
FunctionPlot
|
||||
├── expressions
|
||||
├── domain
|
||||
├── range
|
||||
├── axis config
|
||||
└── render config
|
||||
```
|
||||
|
||||
处理链:
|
||||
|
||||
```text
|
||||
Markdown / Structured Block
|
||||
↓
|
||||
Function Expression Parser
|
||||
↓
|
||||
Function Plot Model
|
||||
↓
|
||||
Renderer
|
||||
↓
|
||||
Editor / Preview
|
||||
```
|
||||
|
||||
需要考虑:
|
||||
|
||||
- 二维函数;
|
||||
- 多函数同图;
|
||||
- 定义域;
|
||||
- 坐标轴;
|
||||
- 缩放;
|
||||
- 图像刷新;
|
||||
- Markdown 中的持久化格式;
|
||||
- HTML / PDF / DOCX 导出时的静态渲染。
|
||||
|
||||
可设计独立 fenced block,例如:
|
||||
|
||||
````markdown
|
||||
```function-plot
|
||||
y = x^2
|
||||
y = sin(x)
|
||||
```
|
||||
````
|
||||
|
||||
具体语法在实现阶段冻结。
|
||||
|
||||
函数图像的数据结构、解析和渲染逻辑由杨星萱负责;吉海燕配合完成前端容器、布局和交互。
|
||||
|
||||
### 4.5 Retrieval 调优
|
||||
|
||||
根据 RAG Benchmark 数据继续调整:
|
||||
|
||||
```text
|
||||
Block Chunking
|
||||
FTS Query
|
||||
Vector Top-K
|
||||
RRF Parameters
|
||||
Reranker Candidate Count
|
||||
Score Threshold
|
||||
Metadata Filter
|
||||
```
|
||||
|
||||
所有调优保留 Benchmark 前后对比数据。
|
||||
|
||||
---
|
||||
|
||||
## 五、吉海燕
|
||||
|
||||
### 5.1 Theme Import
|
||||
|
||||
负责 Theme Package 导入。
|
||||
|
||||
建议结构:
|
||||
|
||||
```text
|
||||
my-theme/
|
||||
├── theme.yaml
|
||||
├── theme.css
|
||||
├── preview.png
|
||||
└── README.md
|
||||
```
|
||||
|
||||
流程:
|
||||
|
||||
```text
|
||||
Import
|
||||
↓
|
||||
Manifest Validation
|
||||
↓
|
||||
Preview
|
||||
↓
|
||||
Install
|
||||
↓
|
||||
Enable
|
||||
↓
|
||||
Disable
|
||||
↓
|
||||
Uninstall
|
||||
```
|
||||
|
||||
### 5.2 Theme Community Format
|
||||
|
||||
负责制定和实现社区主题格式。
|
||||
|
||||
示例:
|
||||
|
||||
```yaml
|
||||
id: example-theme
|
||||
name: Example Theme
|
||||
version: 1.0.0
|
||||
author: example
|
||||
min_app_version: 0.2.0
|
||||
```
|
||||
|
||||
主题继续基于:
|
||||
|
||||
```text
|
||||
Design Token
|
||||
CSS Variables
|
||||
Theme Manifest
|
||||
```
|
||||
|
||||
第二阶段优先完成本地导入和社区包格式,不要求立即实现联网 Theme Marketplace。
|
||||
|
||||
### 5.3 Agent Trace 可视化
|
||||
|
||||
负责将 Agent Runtime 执行过程可视化。
|
||||
|
||||
建议结构:
|
||||
|
||||
```text
|
||||
User Request
|
||||
│
|
||||
├── Model Call
|
||||
│
|
||||
├── rag.search
|
||||
│ ├── arguments
|
||||
│ ├── duration
|
||||
│ └── result
|
||||
│
|
||||
├── notes.read
|
||||
│
|
||||
├── Model Call
|
||||
│
|
||||
└── Completed
|
||||
```
|
||||
|
||||
Trace Node 可展开查看:
|
||||
|
||||
```text
|
||||
Arguments
|
||||
Result
|
||||
Duration
|
||||
Permission
|
||||
Usage
|
||||
Error
|
||||
```
|
||||
|
||||
需要支持:
|
||||
|
||||
- Agent Run 状态;
|
||||
- Tool Call 顺序;
|
||||
- Tool 调用耗时;
|
||||
- Tool Result 摘要;
|
||||
- Provider 调用;
|
||||
- Token Usage;
|
||||
- 错误状态;
|
||||
- 用户取消;
|
||||
- Citation 跳转。
|
||||
|
||||
### 5.4 Plugin Command 前端
|
||||
|
||||
负责 Command Contribution 在前端呈现。
|
||||
|
||||
主要挂载:
|
||||
|
||||
```text
|
||||
Command Palette
|
||||
Context Menu
|
||||
Toolbar Action
|
||||
```
|
||||
|
||||
前端使用 Plugin Contribution Contract,不直接解析插件后端 Manifest。
|
||||
|
||||
### 5.5 Plugin Settings 前端
|
||||
|
||||
根据范涵宇提供的 Plugin Settings Schema 动态生成设置表单。
|
||||
|
||||
支持:
|
||||
|
||||
```text
|
||||
Input
|
||||
Number
|
||||
Switch
|
||||
Select
|
||||
Secret Input
|
||||
```
|
||||
|
||||
Secret 类型通过 Secret API 提交,不写入 Pinia 持久化或普通配置文件。
|
||||
|
||||
### 5.6 Mermaid 渲染支持
|
||||
|
||||
负责在 Markdown 编辑器、预览区域和相关展示界面加入 Mermaid 渲染。
|
||||
|
||||
目标 Markdown:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Markdown] --> B[Renderer]
|
||||
B --> C[Diagram]
|
||||
```
|
||||
````
|
||||
|
||||
处理链:
|
||||
|
||||
```text
|
||||
Markdown
|
||||
↓
|
||||
Mermaid Code Block
|
||||
↓
|
||||
Mermaid Renderer
|
||||
↓
|
||||
SVG
|
||||
↓
|
||||
Editor / Preview
|
||||
```
|
||||
|
||||
需要支持:
|
||||
|
||||
- Flowchart;
|
||||
- Sequence Diagram;
|
||||
- Class Diagram;
|
||||
- State Diagram;
|
||||
- ER Diagram;
|
||||
- Gantt 等常用 Mermaid 图;
|
||||
- Light / Dark Theme;
|
||||
- 编辑后重新渲染;
|
||||
- 渲染错误提示;
|
||||
- SVG 缩放与查看;
|
||||
- 导出时的静态图处理。
|
||||
|
||||
Mermaid 渲染需要与 Theme Design Token 联动。
|
||||
|
||||
与杨星萱负责的 Export Service 联调时,前端或渲染层需要提供 Mermaid → SVG / Image 的稳定输出,使 HTML、PDF 和 DOCX 导出能够保留图表。
|
||||
|
||||
---
|
||||
|
||||
## 六、跨成员协作表
|
||||
|
||||
| 协作事项 | 主负责人 | 配合人员 |
|
||||
| --- | --- | --- |
|
||||
| MCP Bridge → Agent Tool | 范涵宇 | 杨星萱 |
|
||||
| Plugin Command Runtime | 范涵宇 | 吉海燕 |
|
||||
| Plugin Command UI | 吉海燕 | 范涵宇 |
|
||||
| Plugin Settings Runtime | 范涵宇 | 吉海燕 |
|
||||
| Plugin Settings UI | 吉海燕 | 范涵宇 |
|
||||
| Agent Benchmark Framework | 杨星萱 | 范涵宇 |
|
||||
| Agent Trace Event Contract | 范涵宇 | 吉海燕、杨星萱 |
|
||||
| Agent Trace Visualization | 吉海燕 | 范涵宇 |
|
||||
| RAG Benchmark | 杨星萱 | 范涵宇 |
|
||||
| Markdown Export | 杨星萱 | 吉海燕 |
|
||||
| Mermaid Editor Rendering | 吉海燕 | 杨星萱 |
|
||||
| Mermaid Export Rendering | 杨星萱 | 吉海燕 |
|
||||
| Function Plot Core / Rendering | 杨星萱 | 吉海燕 |
|
||||
| Function Plot Frontend Integration | 吉海燕 | 杨星萱 |
|
||||
| Theme Community Format | 吉海燕 | 范涵宇 |
|
||||
| Provider Streaming UI | 范涵宇 | 吉海燕 |
|
||||
| Multimodal → Knowledge Core | 范涵宇 | 杨星萱 |
|
||||
| 第二阶段整体 Demo | 范涵宇 | 吉海燕、杨星萱 |
|
||||
| 核心代码 Review | 范涵宇 | 对应模块负责人 |
|
||||
|
||||
---
|
||||
|
||||
## 七、第二阶段优先级
|
||||
|
||||
### P0
|
||||
|
||||
#### 范涵宇
|
||||
|
||||
```text
|
||||
MCP Bridge
|
||||
faster-whisper
|
||||
pyannote.audio
|
||||
```
|
||||
|
||||
#### 杨星萱
|
||||
|
||||
```text
|
||||
RAG Benchmark
|
||||
Agent Benchmark Infrastructure
|
||||
Markdown → HTML
|
||||
函数图像基础渲染
|
||||
```
|
||||
|
||||
#### 吉海燕
|
||||
|
||||
```text
|
||||
Agent Trace Visualization
|
||||
Theme Manifest / Theme Import
|
||||
Mermaid Rendering
|
||||
```
|
||||
|
||||
### P1
|
||||
|
||||
#### 范涵宇
|
||||
|
||||
```text
|
||||
Plugin Command Contribution
|
||||
Plugin Settings Contribution
|
||||
Provider Adapter 完善
|
||||
```
|
||||
|
||||
#### 杨星萱
|
||||
|
||||
```text
|
||||
Markdown → PDF
|
||||
Markdown → DOCX
|
||||
Retrieval 调优
|
||||
函数图像导出适配
|
||||
```
|
||||
|
||||
#### 吉海燕
|
||||
|
||||
```text
|
||||
Theme Community Format
|
||||
Plugin Command UI
|
||||
Plugin Settings UI
|
||||
Mermaid Theme Adaptation
|
||||
```
|
||||
|
||||
### P2
|
||||
|
||||
#### 范涵宇
|
||||
|
||||
```text
|
||||
更多 Provider
|
||||
MCP 兼容性增强
|
||||
Multimodal Pipeline 优化
|
||||
```
|
||||
|
||||
#### 杨星萱
|
||||
|
||||
```text
|
||||
复杂格式导出
|
||||
Benchmark 报告自动生成
|
||||
函数图像高级配置
|
||||
```
|
||||
|
||||
#### 吉海燕
|
||||
|
||||
```text
|
||||
Theme 浏览与管理体验
|
||||
Agent Trace 高级筛选
|
||||
Mermaid 高级交互
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、第二阶段共同验收 Demo
|
||||
|
||||
建议使用“课堂学习全过程”作为第二阶段总 Demo:
|
||||
|
||||
```text
|
||||
导入课堂录音
|
||||
↓
|
||||
pyannote.audio
|
||||
↓
|
||||
说话人分离
|
||||
↓
|
||||
faster-whisper
|
||||
↓
|
||||
带时间戳 Transcript
|
||||
↓
|
||||
生成 Markdown
|
||||
↓
|
||||
Knowledge Core 建立索引
|
||||
↓
|
||||
用户询问课程内容
|
||||
↓
|
||||
Agent 调用 RAG
|
||||
↓
|
||||
返回 Citation
|
||||
↓
|
||||
Agent Trace 可视化
|
||||
```
|
||||
|
||||
随后展示:
|
||||
|
||||
```text
|
||||
选择 Skill
|
||||
↓
|
||||
调用 Agent
|
||||
↓
|
||||
通过 MCP Bridge 使用外部 Tool
|
||||
↓
|
||||
生成整理后的 Markdown
|
||||
```
|
||||
|
||||
Markdown 中展示:
|
||||
|
||||
```text
|
||||
普通文本
|
||||
数学公式
|
||||
Mermaid 图
|
||||
函数图像
|
||||
```
|
||||
|
||||
最后:
|
||||
|
||||
```text
|
||||
Markdown
|
||||
├── Export HTML
|
||||
├── Export PDF
|
||||
└── Export DOCX
|
||||
```
|
||||
|
||||
并切换一个导入的 Community Theme。
|
||||
|
||||
---
|
||||
|
||||
## 九、Definition of Done
|
||||
|
||||
### 范涵宇
|
||||
|
||||
- [ ] faster-whisper 能完成真实音频转写;
|
||||
- [ ] pyannote.audio 能生成说话人分段;
|
||||
- [ ] 两者能组合生成带时间戳和 Speaker 的 Transcript;
|
||||
- [ ] MCP Server 能通过 MCP Bridge 注册 Tool;
|
||||
- [ ] Agent 能调用 MCP Tool;
|
||||
- [ ] Plugin Command Contribution 后端可注册;
|
||||
- [ ] Plugin Settings Contribution 后端可解析;
|
||||
- [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定;
|
||||
- [ ] 完成跨模块接口审阅和第二阶段集成。
|
||||
|
||||
### 杨星萱
|
||||
|
||||
- [ ] RAG Benchmark Dataset 可以稳定运行;
|
||||
- [ ] 能输出 Hit@K、Recall@K、MRR、Latency 等指标;
|
||||
- [ ] Agent Benchmark Framework 可以执行标准 Case;
|
||||
- [ ] Markdown 可以导出 HTML;
|
||||
- [ ] Markdown 可以导出 PDF;
|
||||
- [ ] Markdown 可以导出 DOCX;
|
||||
- [ ] Mermaid 在导出链路中可以保留为静态图;
|
||||
- [ ] 函数图像能够由结构化表达生成;
|
||||
- [ ] 函数图像能够进入预览和导出链路;
|
||||
- [ ] Retrieval 调优结果有 Benchmark 数据支撑。
|
||||
|
||||
### 吉海燕
|
||||
|
||||
- [ ] Theme Package 可以导入;
|
||||
- [ ] Theme Manifest 可以校验;
|
||||
- [ ] Theme 可以启用、停用和卸载;
|
||||
- [ ] Agent Trace 可以展示完整 Tool Call 顺序;
|
||||
- [ ] Trace Node 可以查看参数、结果、耗时和错误;
|
||||
- [ ] Plugin Command 可以显示在前端;
|
||||
- [ ] Plugin Settings 可以动态生成设置项;
|
||||
- [ ] Markdown Mermaid Code Block 可以渲染;
|
||||
- [ ] Mermaid 支持主题切换;
|
||||
- [ ] Mermaid 渲染错误可以明确展示;
|
||||
- [ ] Mermaid 图能够提供给 Export Service。
|
||||
|
||||
---
|
||||
|
||||
## 十、分工摘要
|
||||
|
||||
```text
|
||||
范涵宇
|
||||
├── faster-whisper
|
||||
├── pyannote.audio
|
||||
├── MCP Bridge
|
||||
├── Plugin Command Runtime
|
||||
├── Plugin Settings Runtime
|
||||
├── Provider Adapter
|
||||
├── Code Review
|
||||
└── Integration / Coordination
|
||||
|
||||
杨星萱
|
||||
├── RAG Benchmark
|
||||
├── Agent Benchmark Infrastructure
|
||||
├── Retrieval Optimization
|
||||
├── Export Service
|
||||
│ ├── Markdown → HTML
|
||||
│ ├── Markdown → PDF
|
||||
│ └── Markdown → DOCX
|
||||
└── Function Plot
|
||||
├── Expression / Data Model
|
||||
├── Rendering
|
||||
└── Export Integration
|
||||
|
||||
吉海燕
|
||||
├── Theme Import
|
||||
├── Theme Community Format
|
||||
├── Agent Trace Visualization
|
||||
├── Plugin Command UI
|
||||
├── Plugin Settings UI
|
||||
└── Mermaid
|
||||
├── Markdown Rendering
|
||||
├── Theme Adaptation
|
||||
└── Export Rendering Interface
|
||||
```
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
> 文档用途:供团队在第一阶段进行页面设计、Vue 开发、前后端联调和验收。
|
||||
> 文档性质:开发需求基线,不是最终视觉规范或产品宣传文档。
|
||||
> 依据:`第一阶段分工表.md`、`AI笔记软件技术栈说明-团队版-v2.2.md`、`后端接口契约-开发版.md`。
|
||||
> 依据:`../architecture/第一阶段分工表.md`、`../architecture/AI笔记软件技术栈说明-团队版-v2.3.md`、`后端接口契约-开发版.md`。
|
||||
|
||||
> 实现状态:更新至 2026-08-31。全部已注册业务路由均已有真实页面;Markdown 写作/源码模式、Search、Chat、智能体执行轨迹、扩展管理、设置、Provider 预设、模型发现和开发阶段加密凭据输入均已落地。Web Workspace 已连接 FastAPI 管理的真实单 Vault;Tauri 原生目录选择和多 Vault 尚未接入。
|
||||
|
||||
## 1. 第一阶段目标
|
||||
|
||||
@@ -34,7 +36,7 @@
|
||||
| --- | --- |
|
||||
| 框架 | Vue 3、Composition API、TypeScript、Vite |
|
||||
| 状态管理 | Pinia,只保存跨组件或跨页面状态 |
|
||||
| UI 基础 | Reka UI / Headless Components |
|
||||
| UI 基础 | 当前为项目公共组件、CSS Design Token 与 Element Plus 图标;复杂无障碍 Headless 组件后续按需引入 Reka UI |
|
||||
| 编辑器 | Milkdown 为默认编辑模式,CodeMirror 6 为源码模式 |
|
||||
| 桌面容器 | Tauri 2;文件、密钥、Sidecar 和系统能力通过 Rust Command |
|
||||
| 本地 AI API | FastAPI;普通请求使用 HTTP JSON,流式数据使用 SSE |
|
||||
@@ -611,18 +613,22 @@ Settings 使用分区导航,不把全部配置堆在一个表单中。
|
||||
- 测试连接并显示耗时;
|
||||
- 根据 Capability 标记是否支持 Chat、Tool Calling、Vision、Streaming 等。
|
||||
|
||||
API Key 输入后立即交给 Rust Stronghold,前端仅保存 `credential_id`。页面回显只能显示“已配置/未配置”,不得回显完整密钥。
|
||||
当前 Web 联调阶段,API Key 通过 Credential API 交给 FastAPI,由 Fernet 加密保存;前端仅在提交期间持有明文,Provider 和 Store 只保留 `credential_id`。页面只能回显“已配置/未配置”,不得回显完整密钥。Tauri 集成后由 Stronghold 替换后端开发存储实现,接口边界保持不变。
|
||||
|
||||
接口:
|
||||
|
||||
```text
|
||||
GET /api/providers
|
||||
GET /api/providers/presets
|
||||
POST /api/providers
|
||||
GET /api/providers/{provider_id}
|
||||
PATCH /api/providers/{provider_id}
|
||||
DELETE /api/providers/{provider_id}
|
||||
GET /api/providers/{provider_id}/models
|
||||
POST /api/providers/test
|
||||
GET /api/credentials/{credential_id}
|
||||
PUT /api/credentials/{credential_id}
|
||||
DELETE /api/credentials/{credential_id}
|
||||
```
|
||||
|
||||
### 14.4 Index 与 Models
|
||||
@@ -710,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 @@
|
||||
# 后端接口契约(开发版)
|
||||
|
||||
> 本文档记录当前前后端联调使用的接口壳子。业务服务尚未实现,最终字段以 FastAPI 运行时生成的 OpenAPI 为准。
|
||||
> 更新日期:2026-08-31。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 FastAPI 运行时生成的 OpenAPI 为准。第二阶段尚未实现的规划接口见 `第二阶段接口契约-开发版.md`,不要将规划路径视为当前服务能力。
|
||||
|
||||
## 契约入口
|
||||
|
||||
@@ -30,8 +30,22 @@
|
||||
| PATCH | `/api/notes/{note_id}` | 更新笔记 |
|
||||
| DELETE | `/api/notes/{note_id}` | 删除笔记 |
|
||||
| POST | `/api/notes/{note_id}/move` | 移动笔记 |
|
||||
| POST | `/api/notes/{note_id}/rename` | 重命名笔记文件并保留 Note/Block 身份 |
|
||||
| POST | `/api/search` | FTS、Vector 或 Hybrid 检索 |
|
||||
|
||||
### Workspace
|
||||
|
||||
Web 联调阶段只暴露后端通过 `APP_VAULT_PATH` 配置的单一 Vault,不接受浏览器传入任意本地目录。桌面多 Vault 与目录选择仍由后续 Tauri Host 提供。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/workspace` | 获取当前 Vault、文件数和索引同步状态 |
|
||||
| POST | `/api/workspace/open` | 打开配置的 Vault;磁盘路径集变化时重建索引 |
|
||||
| GET | `/api/workspace/tree` | 获取真实 Markdown 文件和目录树 |
|
||||
| POST | `/api/workspace/folders` | 新建目录 |
|
||||
| POST | `/api/workspace/folders/rename` | 重命名目录并同步 Note 路径 |
|
||||
| POST | `/api/workspace/folders/delete` | 删除目录及其 Note、Block、FTS 和向量记录 |
|
||||
|
||||
### Chat、Agent 与 Tool
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
@@ -41,7 +55,8 @@
|
||||
| POST | `/api/agent/runs` | 创建 Agent Run |
|
||||
| GET | `/api/agent/runs/{run_id}` | 获取 Agent Run 状态与 Trace 摘要 |
|
||||
| POST | `/api/agent/runs/{run_id}/cancel` | 取消 Agent Run |
|
||||
| GET | `/api/agent/runs/{run_id}/events` | 订阅 AgentEvent SSE |
|
||||
| GET | `/api/agent/runs/{run_id}/events` | 订阅 AgentEvent SSE,支持 `Last-Event-ID` / `after_sequence` 恢复 |
|
||||
| GET | `/api/agent/runs/{run_id}/trace` | 分页读取持久化 Trace、摘要和运行配置快照 |
|
||||
| POST | `/api/agent/runs/{run_id}/permissions/{request_id}` | 响应 Tool 权限确认 |
|
||||
| GET | `/api/tools` | 获取已注册 Tool Definition |
|
||||
|
||||
@@ -68,14 +83,18 @@
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/providers` | 获取 Provider 配置列表 |
|
||||
| GET | `/api/providers/presets` | 获取 OpenAI、DeepSeek 与 Ollama 配置预设 |
|
||||
| POST | `/api/providers` | 新建 Provider 配置 |
|
||||
| GET | `/api/providers/{provider_id}` | 获取 Provider 配置 |
|
||||
| PATCH | `/api/providers/{provider_id}` | 更新 Provider 配置 |
|
||||
| DELETE | `/api/providers/{provider_id}` | 删除 Provider 配置 |
|
||||
| GET | `/api/providers/{provider_id}/models` | 获取模型及 Capability 列表 |
|
||||
| POST | `/api/providers/test` | 测试 Provider 连接 |
|
||||
| GET | `/api/credentials/{credential_id}` | 查询凭据是否已配置,不返回明文 |
|
||||
| PUT | `/api/credentials/{credential_id}` | 加密保存开发阶段 API Key |
|
||||
| DELETE | `/api/credentials/{credential_id}` | 删除已保存凭据 |
|
||||
|
||||
Provider Contract 只传递 `credential_id` 或临时 `credential_context_id`,不通过普通 JSON 接口传递明文 API Key。
|
||||
Provider Contract 只传递 `credential_id` 或临时 `credential_context_id`。当前前后端开发阶段通过独立的 `PUT /api/credentials/{credential_id}` 接收 API Key,并立即加密落盘;该接口只返回配置状态,不返回密钥。Provider CRUD、模型列表和测试接口均不携带明文 API Key。Tauri 集成后由 Stronghold 接管存储实现。
|
||||
|
||||
### Tasks、Media 与 Index
|
||||
|
||||
@@ -99,8 +118,8 @@ Provider Contract 只传递 `credential_id` 或临时 `credential_context_id`,
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "NOT_IMPLEMENTED",
|
||||
"message": "The contract is available, but its business service is not implemented.",
|
||||
"code": "RESOURCE_NOT_FOUND",
|
||||
"message": "Resource was not found.",
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
@@ -155,10 +174,16 @@ RunCancelled
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
更新至 2026-09-01:后端 81 项回归测试通过。
|
||||
|
||||
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
|
||||
- Agent Run/Event 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
|
||||
- Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。
|
||||
- Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。
|
||||
- Workspace 已接入后端配置的真实 Vault;文件树、笔记读写、文件/目录新建、重命名和删除不再使用前端 Mock Fallback。
|
||||
- Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。
|
||||
- Plugin 启用前必须通过权限接口记录授权,未知权限默认拒绝。
|
||||
- Attachment Tool 读取 Host 管理的 `attachments` 目录;音频接口读取 Host 生成的转写文本,真实本地语音模型在第二阶段接入。
|
||||
- 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。
|
||||
|
||||
第二阶段开发保持本文件中已有路径兼容,并按 `第二阶段接口契约-开发版.md` 增加子资源、可选字段和事件。接口完成后先更新 OpenAPI 与本文件,再将第二阶段文档中的状态改为已实现。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
|
||||
> 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。
|
||||
|
||||
> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化和可恢复 SSE 已落地,后端当前回归基线为 81 项测试通过。
|
||||
|
||||
## 当前实现
|
||||
|
||||
当前已经建立第一条可运行链路:
|
||||
@@ -26,6 +28,7 @@ backend/app/
|
||||
│ └── mock.py 离线开发 Provider
|
||||
├── agent/
|
||||
│ ├── runtime.py Agent Loop、限制、取消、Trace 和 SSE
|
||||
│ ├── trace_repository.py Run/Event SQLite 持久化、分页、摘要与脱敏
|
||||
│ ├── tools.py Tool 注册、参数校验、隔离执行和结果转换
|
||||
│ ├── permissions.py 权限策略、确认请求和会话授权
|
||||
│ └── builtin_tools.py 无副作用的内置开发 Tool
|
||||
@@ -51,7 +54,7 @@ Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider
|
||||
- Permission;
|
||||
- Step、Timeout、Token Budget、取消;
|
||||
- Tool 并发上限与 run 级网络权限;
|
||||
- 内存 Trace 与 SSE;
|
||||
- SQLite Trace、分页快照与可恢复 SSE;
|
||||
- Skill Manifest、Prompt、Tool/Permission/模型能力解析;
|
||||
- Plugin Manifest、生命周期和 Tool Contribution;
|
||||
- Skill 调用内置 Tool 与 Plugin Tool;
|
||||
@@ -125,20 +128,26 @@ ollama
|
||||
}
|
||||
```
|
||||
|
||||
凭证 ID `openai-main` 对应 Sidecar 进程中的临时环境变量 `AINOTE_CREDENTIAL_OPENAI_MAIN`。环境变量由 Rust Host 从 Stronghold 读取后注入,不写入 Provider Config、日志或前端 Store。
|
||||
凭证 ID `openai-main` 可以对应开发环境变量 `AINOTE_CREDENTIAL_OPENAI_MAIN`。当前 Web 联调版也允许设置页通过 Credential API 提交 API Key,由 `EncryptedCredentialStore` 使用 Fernet 加密保存;前端 Store、Provider Config、日志和读取响应都不保存或返回明文。未来接入 Tauri 后,由 Rust Host 从 Stronghold 注入或替换存储实现。
|
||||
|
||||
Provider 配置生命周期接口已经可用:
|
||||
|
||||
```text
|
||||
GET /api/providers
|
||||
GET /api/providers/presets
|
||||
POST /api/providers
|
||||
GET /api/providers/{provider_id}
|
||||
PATCH /api/providers/{provider_id}
|
||||
DELETE /api/providers/{provider_id}
|
||||
GET /api/providers/{provider_id}/models
|
||||
POST /api/providers/test
|
||||
GET /api/credentials/{credential_id}
|
||||
PUT /api/credentials/{credential_id}
|
||||
DELETE /api/credentials/{credential_id}
|
||||
```
|
||||
|
||||
设置页现已提供 OpenAI、DeepSeek 和 Ollama 预设,并在保存后自动获取、排序和去重模型列表。模型发现会区分凭据缺失、鉴权失败、限流、超时和上游不可用等错误。
|
||||
|
||||
## Agent Run
|
||||
|
||||
创建普通 Agent Run:
|
||||
@@ -163,10 +172,13 @@ POST /api/agent/runs
|
||||
```text
|
||||
GET /api/agent/runs/{run_id}
|
||||
GET /api/agent/runs/{run_id}/events
|
||||
GET /api/agent/runs/{run_id}/trace?after_sequence=-1&limit=200
|
||||
POST /api/agent/runs/{run_id}/cancel
|
||||
```
|
||||
|
||||
当前 Run 与 Trace 保存在内存中,AI Core 重启后清空。Runtime 最多保留 200 个 Run,每个 Run 最多保留 2000 个事件,并限制单轮 Tool Call 数量,避免长时间运行时无界增长。后续数据库层接入时替换 Repository,不改变 API Contract。
|
||||
Run 与 AgentEvent 已写入 SQLite,`run_id + sequence` 是幂等键。SSE 每帧包含 `id: sequence`;客户端可以通过 `Last-Event-ID` 请求头或 `after_sequence` 查询参数恢复缺失事件。Trace API 返回平铺事件、下一游标、分页状态、模型/工具调用统计、耗时、Token Usage 和创建 Run 时的配置快照,不负责生成前端树形布局。
|
||||
|
||||
运行时内存仍只保留最近 2000 个事件用于实时订阅,完整 Trace 以 SQLite 为准。AI Core 重启后,已经终止的 Run 可以继续查询和回放;重启前未终止的 Run 会收束为 `AGENT_PROCESS_RESTARTED`,避免永久停在 `running`。API Key、Authorization、Password、Secret、常见 `sk-`/Bearer 值在入库前脱敏。Event、Request 和配置快照中的超长字符串与集合会截断;作为查询事实来源的 `AgentRun` 只脱敏、不限长,保证重启前后 input/output 内容一致。
|
||||
|
||||
## Tool Calling
|
||||
|
||||
@@ -321,10 +333,12 @@ Skill Manifest
|
||||
|
||||
## 当前限制与下一步
|
||||
|
||||
前端智能体页面已经完成中文联调:运行状态、Agent Event、内置 Tool、Permission 和常用事件详情字段均通过集中标签映射展示中文;`notes.search` 等技术 ID 继续保留,便于与后端 Trace、日志和接口契约对应。
|
||||
|
||||
- 已实现 Mock、OpenAI-Compatible Chat Completions 与 Ollama Adapter;OpenAI Responses 和 Anthropic Messages 尚未实现。
|
||||
- Provider 配置暂存内存,后续通过 Repository 接入 SQLite;PATCH 已支持用显式 `null` 清空 base URL、默认模型和凭据引用。
|
||||
- Run/Trace 暂存内存;下一步抽象 Repository 并接入 SQLite。
|
||||
- Permission 已有核心等待/恢复机制,前端确认 UI 尚未联调。
|
||||
- Run/Trace 已通过 Repository 接入 SQLite;后续增加按保留策略归档和 Benchmark 引用保护。
|
||||
- Permission 已有核心等待/恢复机制,前端确认 UI 已完成联调和中文展示。
|
||||
- Task 已持久化到 SQLite;Attachment Tool 读取 Host 管理目录中的 UTF-8 文件。
|
||||
- `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍按技术基线在第二阶段接入。
|
||||
- Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。
|
||||
+5
-3
@@ -3,6 +3,8 @@
|
||||
> 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的
|
||||
> 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。
|
||||
|
||||
> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 81 项测试通过。
|
||||
|
||||
## 当前实现
|
||||
|
||||
当前已经建立第一条可运行的检索链路:
|
||||
@@ -72,7 +74,7 @@ block_id = "blk_" + sha256(note_id | heading_path | content)[:16]
|
||||
citation_id = "cit_" + block_id
|
||||
```
|
||||
|
||||
> 注意:MVP 阶段 `note_id` 由相对路径派生,移动文件会改变 ID;后续 `move` 流程会保留原 ID。
|
||||
> 注意:`note_id` 是稳定业务 ID;移动接口会保留原 ID,文件路径不能代替业务实体 ID。
|
||||
|
||||
每个 Block 记录 `heading_path`(章节路径)、`start_offset` / `end_offset`(相对原文的
|
||||
字符偏移,用于前端跳转高亮)、`content_hash`、`token_count`。
|
||||
@@ -130,7 +132,7 @@ POST /api/notes
|
||||
GET /api/notes/{note_id}
|
||||
PATCH /api/notes/{note_id}
|
||||
DELETE /api/notes/{note_id}
|
||||
POST /api/notes/{note_id}/move (501,待定语义)
|
||||
POST /api/notes/{note_id}/move (已实现,保留 note_id)
|
||||
```
|
||||
|
||||
创建笔记:
|
||||
@@ -196,7 +198,7 @@ cd backend
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
当前后端完整测试共 62 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把
|
||||
当前后端完整测试共 71 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把
|
||||
数据目录/DB/Vault 重定向到临时目录,不读写真实 `backend/data`,任何本机状态下结果确定。
|
||||
|
||||
## 配置
|
||||
@@ -0,0 +1,124 @@
|
||||
# 前端写作体验优化开发说明
|
||||
|
||||
> 更新日期:2026-08-30。本文所述优化均已进入当前分支;前端完整回归基线为 14 项测试通过,TypeScript 检查和 Vite 生产构建通过。
|
||||
|
||||
## 1. 本次目标
|
||||
|
||||
本次优化聚焦笔记写作主流程,不调整后端接口:
|
||||
|
||||
- 将界面中的装饰性 Emoji 统一替换为 Element Plus 图标;
|
||||
- 将“写作”模式由 Markdown 源码与预览双栏改为单一可视化编辑区;
|
||||
- 为写作区增加标题、加粗、斜体、有序列表、无序列表工具栏;
|
||||
- 写作页代码块默认展开为可编辑状态;只读 Markdown 区域使用 Shiki 提供亮暗主题高亮。
|
||||
|
||||
## 2. 实现说明
|
||||
|
||||
### 2.1 图标体系
|
||||
|
||||
新增 `AppIcon.vue` 作为轻量图标出口,页面直接传入 `@element-plus/icons-vue` 组件。侧边栏、文件树、Vault 入口、主题按钮、空状态及扩展列表不再使用 Emoji 表达操作含义。
|
||||
|
||||
这样处理后,图标尺寸、颜色和主题状态都由 CSS 统一控制,也避免不同系统 Emoji 字体造成的显示差异。
|
||||
|
||||
### 2.2 可视化 Markdown 编辑器
|
||||
|
||||
写作模式使用 Milkdown Crepe 渲染 Markdown 文档,磁盘中仍保存标准 Markdown 文本。编辑器监听 Markdown 更新并写回 Pinia 状态,继续复用原有自动保存逻辑。
|
||||
|
||||
“源码”模式保留为独立模式,便于需要精确编辑 Markdown 的用户使用;写作模式中不再同时展示 Markdown 源码。
|
||||
|
||||
编辑器按当前文件路径重新挂载,保证切换文件、切换源码模式后,展示内容与 Store 中的最新 Markdown 一致。
|
||||
|
||||
### 2.3 Markdown 工具栏
|
||||
|
||||
写作区顶部提供以下基础格式操作:
|
||||
|
||||
- H1 至 H6 标题下拉选择,标题默认使用粗体显示;
|
||||
- 加粗;
|
||||
- 斜体;
|
||||
- 有序列表;
|
||||
- 无序列表;
|
||||
- 12 px 至 32 px 字号选择;
|
||||
- 行内代码与代码块;
|
||||
- 行内公式与公式块;
|
||||
- 链接插入。
|
||||
|
||||
标题、加粗、斜体和列表工具调用 Milkdown Command 修改当前选区或块级结构,因此能正确处理光标、选区和嵌套列表。工具栏使用常见的 `H`、`B`、`I`、`1.` 和 `•` 排版符号,减少图标语义歧义。
|
||||
|
||||
标准 Markdown 没有字号语法。字号功能仅在用户已选择文本时生效,并将内容写为兼容 Markdown 的内联 HTML:
|
||||
|
||||
```markdown
|
||||
<span style="font-size: 18px">选中的文本</span>
|
||||
```
|
||||
|
||||
Milkdown 自定义插件在写作模式中隐藏 HTML 标记,并通过 ProseMirror Decoration 显示实际字号;切换到源码模式时可以直接看到并修改上述 Markdown 内容。
|
||||
|
||||
字号栏同时提供预设下拉框和 `8–96 px` 数值输入框。输入数值后按 Enter 或点击“应用”即可写入当前选区。标题下拉框提供“正文”选项,用于将标题恢复为普通段落;正文显式使用正常字重,只有 H1 至 H6 默认加粗。
|
||||
|
||||
选中文本后出现的 Crepe 浮动格式栏使用应用正文前景色、实色描边和悬浮强调色,避免亮暗主题下图标对比度不足。
|
||||
|
||||
浮动栏由 Crepe Tooltip Provider 挂载,不保证位于 Vue scoped 样式容器内部,因此对比度规则使用全局 `.milkdown-toolbar` 选择器,并通过主题变量适配亮暗模式。顶部格式按钮统一在 `pointerdown` 阶段阻止默认焦点迁移并执行命令,确保点击工具栏时不会丢失编辑器选区。
|
||||
|
||||
有序列表与无序列表使用相同尺寸、相同线条结构的经典列表符号,仅通过左侧的数字或圆点区分类型。亮色主题下,表格边框使用更高对比度的文本辅助色,列表序号、圆点及任务图标也改用辅助文本色并增加字重。
|
||||
|
||||
编辑器左侧加号打开的块菜单已完成中文本地化:
|
||||
|
||||
- “文本”分组包含正文、H1 至 H6、引用和分割线;
|
||||
- “列表”分组包含无序列表、有序列表和任务列表;
|
||||
- “插入”分组包含图片、代码块、表格和公式块。
|
||||
|
||||
代码语言搜索、复制操作、链接编辑及公式确认浮层也统一使用中文文案。
|
||||
|
||||
### 2.4 代码块编辑与 Shiki 高亮
|
||||
|
||||
代码高亮使用 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 功能负责编辑和渲染。
|
||||
|
||||
Shiki 仅应用于:
|
||||
|
||||
- AI 对话中的 Markdown 代码块。
|
||||
- Search、智能体等复用 `MarkdownContent` 的只读 Markdown 代码块。
|
||||
|
||||
Markdown HTML 仍在写入 DOM 前经过 DOMPurify 清理。
|
||||
|
||||
## 3. 新增依赖
|
||||
|
||||
- `@element-plus/icons-vue`:统一界面图标;
|
||||
- `@milkdown/crepe`、`@milkdown/kit`:可视化 Markdown 编辑器及命令;
|
||||
- `shiki`、`@shikijs/langs`、`@shikijs/themes`、`@shikijs/engine-javascript`:代码高亮和按需语言注册。
|
||||
|
||||
## 4. 验证记录
|
||||
|
||||
在 `frontend` 目录执行:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
验证结果:TypeScript 类型检查与 Vite 生产构建均通过。当前前端完整回归测试共 14 项;其中写作与文件切换相关回归覆盖:
|
||||
|
||||
- 顶部工具栏对选区应用加粗;
|
||||
- 浮动工具栏对选区应用斜体;
|
||||
- 自定义字号输入写入 Markdown;
|
||||
- 标题恢复为普通正文;
|
||||
- 连续切换文件后渲染新文件内容;
|
||||
- 从文件树连续点击时,活动路径与编辑器内容同步切换;
|
||||
- 欢迎笔记的异步初始化不会覆盖用户刚点击的文件。
|
||||
|
||||
文件切换失效包含两层原因。第一层是旧实现先更新 `currentFilePath`、后等待文件内容,导致编辑器使用新路径和旧内容提前重建;现在改为文件读取成功后一次性提交路径和内容。第二层是工作区欢迎笔记的异步初始化结束后会无条件设为活动文件,可能覆盖用户在此期间的真实点击;现在点击文件时立即同步工作区活动路径,默认初始化仅在用户尚未选择文件且欢迎笔记确实加载成功时提交。文件读取失败时则恢复点击前的活动文件。
|
||||
|
||||
本地内置浏览器测试运行时因环境资源路径缺失未能启动,因此本次没有把自动化交互测试列为已通过项。合并前建议人工检查一次工具栏选区操作、文件切换同步,以及跟随主题、GitHub Light、GitHub Dark 三种代码块设置下的显示效果。
|
||||
|
||||
## 5. 后续建议
|
||||
|
||||
- 根据真实文档规模评估 Milkdown 与只读 Markdown 高亮模块的懒加载拆包;
|
||||
- 为工具栏补充撤销、重做、引用、行内代码和链接;
|
||||
- 增加编辑器选区命令与文件切换的组件测试。
|
||||
@@ -0,0 +1,209 @@
|
||||
# 前端壳子与接口层开发说明
|
||||
|
||||
> 更新日期:2026-08-30
|
||||
> 适用范围:Vue 3 + TypeScript 页面、Workspace、公共 Service、FastAPI 接口适配和 SSE。
|
||||
> 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。
|
||||
|
||||
## 1. 当前实现状态
|
||||
|
||||
当前前端已经形成一条可安装、可类型检查、可生产构建和可联调的基础链路:
|
||||
|
||||
```text
|
||||
Vue Router
|
||||
→ App Shell
|
||||
→ Pinia Store
|
||||
→ Service / FastAPI DTO Adapter
|
||||
→ HTTP 或 SSE
|
||||
→ FastAPI
|
||||
```
|
||||
|
||||
当前已经落地的页面和公共界面包括:
|
||||
|
||||
- Vault 入口页;
|
||||
- 应用标题栏、主侧边栏、辅助侧边栏和状态栏;
|
||||
- Workspace 文件树;
|
||||
- Markdown 写作/源码模式、手动保存和自动保存状态;
|
||||
- 文件打开、新建、删除和重命名交互壳子;
|
||||
- Search 查询、筛选、结果列表和 Citation 定位;
|
||||
- Chat 会话、Provider/Model/Skill 选择和 SSE 输出;
|
||||
- Agent Run 创建、Trace、取消和权限确认;
|
||||
- Task 筛选、创建、编辑、状态切换和删除;
|
||||
- Skill、Plugin 生命周期管理;
|
||||
- Theme 预览、切换和编辑器 Token 覆盖;
|
||||
- Settings 的通用、编辑器、Provider、索引、权限和 AI Core 诊断分区;
|
||||
- 可收起主导航、功能型二级侧栏、状态栏和 `Ctrl+P` 命令面板。
|
||||
- Milkdown 可视化写作、CodeMirror 源码/代码块编辑、Markdown 格式栏和 Shiki 只读代码高亮;
|
||||
- OpenAI、DeepSeek、Ollama 预设、自动模型发现和开发阶段加密 API Key 输入;
|
||||
- 智能体页面、运行状态、事件、工具和权限详情的中文展示。
|
||||
|
||||
原统一占位页已经删除,所有已注册业务路由均指向真实页面。Web Workspace 已通过 FastAPI 连接后端配置的单一真实 Vault,不再回退 Mock 数据;Tauri 多 Vault、原生目录选择、Stronghold 和桌面窗口能力仍在桌面容器阶段接入,不影响页面与 Store 的调用边界。
|
||||
|
||||
## 2. 目录与职责
|
||||
|
||||
```text
|
||||
frontend/src/
|
||||
├── components/common/ App Shell、导航、命令面板与扩展公共组件
|
||||
├── contracts/index.ts UI View Model 与 FastAPI Wire DTO
|
||||
├── features/ 按页面领域拆分的业务组件
|
||||
├── features/editor/ 编辑器头部与写作/源码编辑区
|
||||
├── features/vault/ Vault 入口
|
||||
├── features/workspace/ Workspace 与递归文件树
|
||||
├── router/index.ts 页面路由和 Vault Guard
|
||||
├── services/ HTTP、SSE、DTO 映射和模块 API
|
||||
├── stores/ Pinia 状态
|
||||
└── styles/tokens.css Design Token
|
||||
```
|
||||
|
||||
职责约定:
|
||||
|
||||
- Component 不直接拼接后端 URL;
|
||||
- Store 负责页面状态和业务操作编排;
|
||||
- Service 负责 HTTP/SSE 调用以及 Wire DTO 到 View Model 的转换;
|
||||
- `contracts/index.ts` 同时保留界面模型和以 `Api` 开头的 FastAPI DTO,两者不能混用;
|
||||
- OpenAPI `/openapi.json` 是后端 Wire Contract 的最终依据。
|
||||
|
||||
## 3. 路由与页面壳子
|
||||
|
||||
已注册路由:
|
||||
|
||||
```text
|
||||
/
|
||||
/workspace
|
||||
/search
|
||||
/chat
|
||||
/agent/runs/:runId?
|
||||
/tasks
|
||||
/extensions/skills
|
||||
/extensions/plugins
|
||||
/themes
|
||||
/settings
|
||||
```
|
||||
|
||||
除 Vault 入口外,其余路由需要先打开 Vault。全部路由均使用懒加载真实页面组件,既保持首屏包体可控,也避免占位页面掩盖缺失实现。
|
||||
|
||||
## 4. 页面实现边界
|
||||
|
||||
| 页面 | 当前可用能力 | 主要 Store / Service |
|
||||
| --- | --- | --- |
|
||||
| Workspace | 文件树、新建、重命名、删除、打开、编辑、保存、模式切换 | `workspaceStore`、`editorStore`、`workspaceService` |
|
||||
| Search | FTS/Vector/Hybrid、文件夹与标签筛选、结果定位 | `searchStore`、`searchService` |
|
||||
| Chat | 会话选择、模型配置、RAG、Skill、SSE、Citation | `chatStore`、`providerStore`、`chatService` |
|
||||
| Agent | Run 配置、Tool 选择、Trace SSE、权限确认、取消 | `agentStore`、`agentService` |
|
||||
| Tasks | 状态筛选、CRUD、完成与恢复 | `taskStore`、`taskService` |
|
||||
| Skills | 列表、详情、安装、启停、卸载 | `skillStore`、`skillService` |
|
||||
| Plugins | 列表、权限确认、安装、启停、卸载 | `pluginStore`、`pluginService` |
|
||||
| Themes | 主题预览、应用、字体与行高覆盖、恢复默认 | `themeStore` |
|
||||
| Settings | 通用、编辑器、Provider、索引、权限、诊断 | `settingsStore`、`providerStore`、相关 Service |
|
||||
|
||||
## 5. Workspace 与编辑器
|
||||
|
||||
Workspace 当前由以下组件构成:
|
||||
|
||||
```text
|
||||
WorkspaceView
|
||||
├── EditorHeader
|
||||
└── EditorPane
|
||||
|
||||
SecondarySidebar
|
||||
└── FileTreePanel
|
||||
└── FileTreeNode(递归)
|
||||
```
|
||||
|
||||
文件树把右键目标保存在 `contextTarget`,重命名和删除始终作用于实际被右键的节点,不再依赖当前编辑文件。根目录使用 `/` 表示,新增根级文件时直接写入 Store 顶层数组。
|
||||
|
||||
当前 `workspaceService` 是 FastAPI Workspace Adapter。打开 Vault 时只允许后端 `APP_VAULT_PATH` 配置的目录,随后通过 Workspace/Note API 读取真实文件树和 Markdown,并完成文件、目录的新建、重命名、移动、保存和删除。接口错误直接进入统一错误链路,不再用 Mock Fallback 掩盖连接或契约失败。
|
||||
|
||||
浏览器不能获得任意本地文件系统权限,因此 Web 模式不提供目录选择和多 Vault 管理。进入桌面端阶段后,由 Tauri Host 实现同一 Service 边界下的原生适配器,组件和 Store 无需感知底层传输变化。
|
||||
|
||||
## 6. HTTP 接口层
|
||||
|
||||
公共请求由 `apiClient.ts` 处理:
|
||||
|
||||
- 支持 GET、POST、PUT、PATCH 和 DELETE;
|
||||
- 使用 `VITE_API_BASE_URL`,并兼容旧的 `VITE_API_BASE`;
|
||||
- 自动附加 `X-Request-Id`;
|
||||
- 将后端统一错误体转换为 `ApiErrorClass`;
|
||||
- 204 响应返回 `undefined`。
|
||||
|
||||
Service 已适配当前 FastAPI Contract:
|
||||
|
||||
| 模块 | 主要适配内容 |
|
||||
| --- | --- |
|
||||
| Notes | `folder`、`markdown`、直接 Note 响应和 `{items, page}` |
|
||||
| Search | 数组筛选字段、`items/page` 响应和 Search View Model 映射 |
|
||||
| Chat | `provider_id`、`model`、`messages` 和 ModelEvent SSE |
|
||||
| Agent | `input`、秒级 Timeout 字段、Run DTO 和 Permission Decision |
|
||||
| Skill / Plugin | 嵌套 `manifest`、安装 `package_path` 和 Plugin Permission PUT |
|
||||
| Provider | Provider Type、Capability 数组、模型列表包装和 Test 响应 |
|
||||
| Task | `due_at`、分页响应和当前后端支持字段 |
|
||||
| Index | `all/notes/vectors` Scope、Job 与状态 DTO |
|
||||
| System | `/health` 和 `/api/status` 的真实响应字段 |
|
||||
|
||||
界面模型中存在的展示字段不能直接发送给后端。例如 Task View Model 的 `priority` 和 `source` 当前只是界面层字段,Service 创建与更新请求不会把它们发送给不支持这些字段的 FastAPI Contract。
|
||||
|
||||
## 7. SSE
|
||||
|
||||
`SseClient` 同时服务于 Chat 和 Agent Event:
|
||||
|
||||
- 使用与普通 HTTP 相同的 API Base URL;
|
||||
- 支持 POST Chat Stream 和 GET Agent Event Stream;
|
||||
- 使用 `TextDecoder` 处理 UTF-8 增量字节;
|
||||
- 在网络分片之间保留 `event` 和多行 `data` 状态;
|
||||
- 以空行作为单个 SSE Event 的结束标志;
|
||||
- 识别 `Done`、`RunCompleted`、`RunFailed` 和 `RunCancelled`;
|
||||
- 支持 AbortController 主动取消。
|
||||
|
||||
Chat Store 已从定时器模拟输出切换为真实 `/api/chat` SSE。默认离线联调配置为:
|
||||
|
||||
```text
|
||||
provider_id = mock
|
||||
model = mock-1
|
||||
```
|
||||
|
||||
## 8. 环境和启动
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
联调前在另一个终端启动后端:
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
生产构建:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 9. 当前验证基线
|
||||
|
||||
```text
|
||||
pnpm build passed
|
||||
pnpm test 27 passed
|
||||
uv run pytest 81 passed
|
||||
preview smoke HTTP 200
|
||||
git diff --check passed
|
||||
```
|
||||
|
||||
当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 81 项测试结果,也不涉及产品代码。
|
||||
|
||||
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
|
||||
|
||||
浏览器可视化冒烟在本次执行环境中因浏览器运行资源缺失未能启动;HTTP 冒烟已确认前端入口、后端健康检查与 OpenAPI 均能访问。进入合并验收前,仍建议团队在本机打开各路由完成一次人工视觉检查。
|
||||
|
||||
## 10. 后续开发要求
|
||||
|
||||
- 新页面文件与路由修改必须在同一提交中出现;
|
||||
- 新增或修改接口时同步更新 FastAPI DTO、Service 映射和接口文档;
|
||||
- 不允许用 `as any` 或错误返回类型掩盖 Contract 差异;
|
||||
- SSE 相关变更需要覆盖跨 Chunk、CRLF、多行 data、终态事件和取消;
|
||||
- Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试;
|
||||
- 页面新增交互必须经过键盘、空状态、加载状态、错误状态和窄窗口检查;
|
||||
- Workspace 的 Milkdown 写作模式与 CodeMirror 源码模式共享同一 Markdown 数据源;后续修改编辑器时不得改变 Store/Service 边界,并必须保留文件切换、自动保存和选区格式化回归测试。
|
||||
@@ -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 验收环境补做。
|
||||
@@ -0,0 +1,107 @@
|
||||
# 模型提供商与模型发现开发说明
|
||||
|
||||
> 更新日期:2026-08-30。OpenAI、DeepSeek、Ollama 预设、模型自动发现、默认模型选择和开发阶段加密凭据存储均已实现并接入设置页。
|
||||
|
||||
## 1. 本次目标
|
||||
|
||||
本次完善设置页的模型提供商配置,不改变 Agent、Chat 和 Skill 对统一 Model Core 接口的依赖:
|
||||
|
||||
- 提供 OpenAI、DeepSeek 和 Ollama 配置预设;
|
||||
- 保存 Provider 后自动获取该账号或服务当前可用的模型列表;
|
||||
- 支持手动刷新模型列表和选择默认模型;
|
||||
- 保留自定义 OpenAI-Compatible 服务入口;
|
||||
- 不在 Vue、FastAPI 配置或仓库文件中保存、回显 API Key 明文。
|
||||
|
||||
## 2. 接口与实现
|
||||
|
||||
### 2.1 Provider 预设
|
||||
|
||||
新增接口:
|
||||
|
||||
```http
|
||||
GET /api/providers/presets
|
||||
```
|
||||
|
||||
预设由后端 `ProviderFactory` 提供,前端只消费名称、协议类型、Base URL 和是否需要凭据等配置元数据,不直接实现厂商协议。
|
||||
|
||||
当前预设:
|
||||
|
||||
| 提供商 | Provider Type | Base URL | 默认 Credential ID |
|
||||
| --- | --- | --- | --- |
|
||||
| OpenAI | `openai_chat` | `https://api.openai.com/v1` | `openai` |
|
||||
| DeepSeek | `openai_compatible` | `https://api.deepseek.com` | `deepseek` |
|
||||
| Ollama | `ollama` | `http://127.0.0.1:11434` | 无 |
|
||||
|
||||
OpenAI 和 DeepSeek 都通过项目已有的 `OpenAICompatibleProvider` 访问。模型发现分别请求 Base URL 下的 `/models`,不引入厂商 SDK。
|
||||
|
||||
### 2.2 自动获取模型
|
||||
|
||||
模型列表继续使用既有接口:
|
||||
|
||||
```http
|
||||
GET /api/providers/{provider_id}/models
|
||||
```
|
||||
|
||||
设置页在以下时机调用该接口:
|
||||
|
||||
- Provider 列表加载完成后,为所有已启用 Provider 自动刷新;
|
||||
- 新增或编辑 Provider 保存成功后自动刷新;
|
||||
- 用户点击“刷新模型”时手动刷新;
|
||||
- 打开已有 Provider 的编辑窗口时刷新可选模型。
|
||||
|
||||
前端按模型名称排序并按 `model_id` 去重。获取结果保存在 `providerStore.modelsByProvider`,加载状态和错误按 Provider 隔离,单个外部服务失败不会阻止其他服务展示。
|
||||
|
||||
获取成功后,Provider 卡片展示模型数量和默认模型下拉框。更换默认模型会调用 Provider PATCH 接口写回配置;编辑窗口仍允许手动输入模型 ID,以兼容未出现在列表中的代理模型或部署别名。
|
||||
|
||||
### 2.3 错误处理
|
||||
|
||||
Provider Adapter 的错误在 FastAPI 路由转换为统一 API Error:
|
||||
|
||||
| Provider Error | HTTP 状态 |
|
||||
| --- | --- |
|
||||
| `PROVIDER_AUTH_FAILED` | 401 |
|
||||
| `MODEL_NOT_FOUND` | 404 |
|
||||
| `PROVIDER_RATE_LIMITED` | 429 |
|
||||
| `PROVIDER_TIMEOUT` | 504 |
|
||||
| 其他 Provider 可用性错误 | 502 |
|
||||
|
||||
前端在对应 Provider 卡片内展示失败原因,并允许用户修正 Credential ID、Base URL 后重新获取。
|
||||
|
||||
## 3. 凭据边界
|
||||
|
||||
设置页选择 OpenAI 或 DeepSeek 预设后展示密码类型的 API Key 输入框,不再要求用户理解 Credential ID。输入值只存在于表单的临时 `ref`,不会写入 Pinia 或 localStorage;请求完成、取消表单或失败后都会清空。
|
||||
|
||||
API Key 通过独立接口写入:
|
||||
|
||||
```http
|
||||
GET /api/credentials/{credential_id}
|
||||
PUT /api/credentials/{credential_id}
|
||||
DELETE /api/credentials/{credential_id}
|
||||
```
|
||||
|
||||
PUT 请求使用 Pydantic `SecretStr` 接收密钥,响应仅包含 Credential ID 和 `configured` 状态。后端使用 Fernet 认证加密,将密文保存到 `data/credentials/credentials.json`,主密钥保存到 `data/credentials/master.key`;目录和文件尽可能设置为仅当前用户可访问并整体排除版本控制。写入采用临时文件替换,避免进程中断留下半写文件。Provider 发起请求时按 Credential ID 解密,解密失败转换为统一 Provider Error,任何读取接口均不返回明文。
|
||||
|
||||
本地开发存储的主密钥与密文仍位于同一用户数据目录,因此它解决的是仓库泄漏、普通配置误提交和静态明文暴露,不等同于操作系统安全硬件或 Stronghold。Tauri 集成后应以 Stronghold 实现替换 `EncryptedCredentialStore`。无界面环境仍兼容 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 和 Host 注入的 `AINOTE_CREDENTIAL_<ID>`;设置页保存的本地密钥优先,环境变量仅作为回退。
|
||||
|
||||
自动化测试仅使用虚构测试值,验证磁盘文件不包含明文、加解密往返、API 响应不泄密,以及 Provider 能用解密后的值构造 Authorization Header。本次没有使用真实 OpenAI 或 DeepSeek Key,也没有向厂商发起真实请求。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
后端:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run pytest -q -p no:cacheprovider
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
|
||||
|
||||
当前完整回归基线:后端 81 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
|
||||
@@ -0,0 +1,166 @@
|
||||
# CI/CD 细则(团队开发版)
|
||||
|
||||
> 本文档规定 NotesAgent 在 Gitea 上的持续集成、构建产物、发布和回滚要求。当前仓库尚未提交 Gitea Actions 工作流,因此本文首先作为落地流水线时的统一规范;流水线启用前,Pull Request 仍须人工执行同等检查。
|
||||
|
||||
> 更新日期:2026-09-01。当前阶段的 CD 指“生成可验证的候选构建与发布产物”,不包含把后端自动部署到公网环境。
|
||||
|
||||
## 1. 目标与原则
|
||||
|
||||
CI/CD 用于尽早发现依赖锁文件失效、类型错误、测试回归、前后端契约不一致和生产构建失败。流水线应遵守以下原则:
|
||||
|
||||
- 以 Gitea 为唯一远程和流水线入口;
|
||||
- `main` 始终保持可安装、可测试、可构建;
|
||||
- 安装依赖时使用锁文件,避免流水线与开发机解析出不同版本;
|
||||
- 未通过必需检查的提交不得合入 `main`;
|
||||
- 外部模型、真实 API Key 和用户本地数据不得成为基础 CI 的前置条件;
|
||||
- 缓存只用于加速,不得影响构建结果;删除缓存后流水线仍应成功;
|
||||
- 测试、构建和发布步骤使用最小权限,敏感信息不得写入日志或产物。
|
||||
|
||||
## 2. 运行环境基线
|
||||
|
||||
| 组件 | CI 要求 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Python | 3.12 | 项目最低支持 3.11,CI 使用团队推荐版本 |
|
||||
| uv | 当前稳定版,并在日志中输出版本 | 按 `backend/uv.lock` 安装后端依赖 |
|
||||
| Node.js | 22 LTS | 满足前端环境要求并保持 Runner 兼容性 |
|
||||
| pnpm | 10 | 按 `frontend/pnpm-lock.yaml` 安装前端依赖 |
|
||||
| 操作系统 | Linux Runner 为基础门禁 | 桌面端启用后再增加 Windows、macOS 构建矩阵 |
|
||||
|
||||
Runner 镜像或 Action 的大版本必须固定。升级 Python、Node.js、uv、pnpm 或基础 Action 时,应使用独立的 `chore/` 分支,并完整运行前后端检查。
|
||||
|
||||
## 3. 触发规则
|
||||
|
||||
| 事件 | 必须执行 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| Pull Request 指向 `main` | 文档检查、后端测试、前端测试、类型检查、生产构建 | 合并门禁 |
|
||||
| 推送到 `main` | 全量检查、集成冒烟、保存候选构建 | 验证合并结果 |
|
||||
| 推送功能分支 | 至少执行受影响模块的检查 | 尽早反馈;不得替代 PR 全量门禁 |
|
||||
| 推送 `v*` 标签 | 全量检查、构建、校验和、发布候选产物 | 正式发布入口 |
|
||||
| 手动触发 | 可选择全量回归或重新生成候选产物 | 发布前复核和故障恢复 |
|
||||
|
||||
纯文档变更可以跳过前后端耗时任务,但必须执行文档链接检查和 `git diff --check`。只有可靠的路径检测结果才能判定为纯文档变更;锁文件、工作流、构建配置和接口契约变更一律按代码变更处理。
|
||||
|
||||
## 4. Pull Request 必需检查
|
||||
|
||||
建议将以下 Job 名称固定为 Gitea 分支保护所要求的状态检查:
|
||||
|
||||
| Job | 必需命令或行为 | 通过标准 |
|
||||
| --- | --- | --- |
|
||||
| `docs-check` | `git diff --check`,检查仓库内 Markdown 相对链接 | 无空白错误、无失效本地链接 |
|
||||
| `backend-test` | `uv sync --frozen`、编译检查、`uv run pytest` | 依赖锁有效且测试全部通过 |
|
||||
| `frontend-test` | `pnpm install --frozen-lockfile`、`pnpm test` | 依赖锁有效且测试全部通过 |
|
||||
| `frontend-typecheck` | `pnpm type-check` | 无 TypeScript/Vue 类型错误 |
|
||||
| `frontend-build` | `pnpm build` | Vite 生产构建成功 |
|
||||
| `integration-smoke` | 启动 FastAPI,验证健康检查和关键本地链路 | 服务可启动,响应与契约符合预期 |
|
||||
|
||||
后端 Job 的基准命令:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv sync --frozen
|
||||
uv run python -m compileall -q app
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
前端 Job 的基准命令:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
`integration-smoke` 应使用 Mock Provider、临时数据库和临时附件目录,不访问 OpenAI、DeepSeek 或其他外部服务。测试结束后必须关闭服务并清理临时数据。
|
||||
|
||||
## 5. 路径与模块检查规则
|
||||
|
||||
- 修改 `backend/**`、`backend/uv.lock` 或后端配置时,必须运行 `backend-test` 和 `integration-smoke`。
|
||||
- 修改 `frontend/**`、`frontend/pnpm-lock.yaml` 或前端配置时,必须运行全部前端 Job。
|
||||
- 修改 `docs/contracts/**`、FastAPI 路由、DTO、SSE 事件或前端 Service 类型时,必须同时运行前后端全量检查。
|
||||
- 修改 `.gitea/**`、根目录工程配置或依赖版本时,必须运行所有 Job。
|
||||
- 修改 `docs/**` 以外且无法明确归类的文件时,默认运行所有 Job。
|
||||
|
||||
路径过滤只用于减少无关重复任务,不得造成关键检查缺失。若无法可靠判断影响范围,应执行全量流水线。
|
||||
|
||||
## 6. 凭据与敏感信息
|
||||
|
||||
- 基础 CI 不配置真实模型 API Key,Provider 相关测试统一使用 Mock 或请求桩。
|
||||
- 确需发布签名或访问受保护服务时,凭据只保存在 Gitea Actions Secrets 中,不写入仓库、工作流参数、缓存或构建产物。
|
||||
- 来自外部分支或不受信任 Pull Request 的任务不得读取发布凭据。
|
||||
- Secret 名称表达用途和环境,例如 `RELEASE_SIGNING_KEY`;禁止使用含义模糊的 `KEY1`、`TOKEN2`。
|
||||
- 日志中禁止输出请求头、完整 Token、API Key、用户笔记内容和本地凭据存储内容。
|
||||
- 生产凭据与测试凭据分离,并遵循最小权限、定期轮换和可撤销原则。
|
||||
|
||||
前端构建时注入的变量会进入静态资源,不能用于保存秘密。只有明确可公开的配置才允许使用 Vite 客户端环境变量。
|
||||
|
||||
## 7. 缓存与产物
|
||||
|
||||
可以缓存 uv 下载缓存和 pnpm Store,缓存键至少包含操作系统、运行时版本和对应锁文件哈希。不得缓存:
|
||||
|
||||
- `backend/.venv/`;
|
||||
- `frontend/node_modules/`;
|
||||
- `backend/data/`、测试数据库和用户附件;
|
||||
- `.env`、API Key、本地凭据库或签名材料。
|
||||
|
||||
普通 PR 不上传可执行发布包,只保留必要的测试报告和前端构建日志。`main` 或版本标签的候选产物应记录提交 SHA,生成 SHA-256 校验和,并设置明确的保留期限;非正式候选产物建议保留 14 天。
|
||||
|
||||
## 8. 分支保护与合并门禁
|
||||
|
||||
Gitea 中的 `main` 应启用以下保护:
|
||||
|
||||
- 禁止普通成员直接推送和强制推送;
|
||||
- 要求 Pull Request 审阅通过;
|
||||
- 要求第 4 节列出的适用状态检查成功;
|
||||
- Head 更新后使旧审阅和旧检查失效,必须针对最新提交重新检查;
|
||||
- 对话和审阅意见处理完成后才允许合并;
|
||||
- 优先使用 squash 或 rebase 保持主线清晰,具体方式遵循 [Git 使用细则](Git使用细则-团队开发版.md)。
|
||||
|
||||
临时绕过门禁只允许用于明确的仓库级故障。绕过者需要记录原因、影响、补验计划,并在恢复后立即补跑全部检查。
|
||||
|
||||
## 9. 发布流程
|
||||
|
||||
当前阶段按以下顺序生成发布候选:
|
||||
|
||||
1. 从已通过全部检查的 `main` 提交确定发布 SHA;
|
||||
2. 更新版本号、变更说明和必要文档;
|
||||
3. 创建形如 `v0.2.0` 的语义化版本标签;
|
||||
4. 标签流水线重新执行全部测试和生产构建;
|
||||
5. 对产物执行本地启动或安装冒烟测试;
|
||||
6. 生成校验和,并把版本、提交 SHA、构建环境和已知限制写入发布说明;
|
||||
7. 人工确认后在 Gitea 发布页面公开产物。
|
||||
|
||||
Tauri 桌面端接入后,发布流水线再增加 Windows、macOS 和 Linux 构建矩阵、平台签名及安装包验证。在签名、更新通道和回滚方案准备完成前,不启用面向用户的自动更新。
|
||||
|
||||
## 10. 回滚与热修复
|
||||
|
||||
- 尚未公开的候选产物直接标记为失败,不覆盖同一版本的已有产物;修复后递增预发布编号或版本号。
|
||||
- 已发布版本出现问题时,优先停止分发并回退到最近一个已验证版本。
|
||||
- 代码修复从 `main` 创建 `hotfix/<模块>-<问题>` 分支,通过完整门禁后合并并发布补丁版本。
|
||||
- 禁止重写已公开版本标签或用新文件替换旧版本同名产物。
|
||||
- 回滚或热修复完成后,在 `docs/retrospectives/` 记录原因、影响、处置过程和防复发措施。
|
||||
|
||||
## 11. 流水线失败处理
|
||||
|
||||
1. 先确认失败是否可在本地使用相同锁文件和命令复现;
|
||||
2. 判断是代码、测试、依赖、Runner 还是外部基础设施问题;
|
||||
3. 代码或测试问题由当前 PR 修复,不通过重跑掩盖不稳定测试;
|
||||
4. Runner 或 Gitea 故障应记录日志和时间,恢复后针对同一 Head 重新执行;
|
||||
5. 连续出现的偶发失败必须作为缺陷处理,明确负责人并增加稳定性修复;
|
||||
6. 修复流水线本身时,不得顺便降低测试范围或绕过既有门禁。
|
||||
|
||||
## 12. 落地清单
|
||||
|
||||
首次创建 `.gitea/workflows/` 时,应逐项确认:
|
||||
|
||||
- [ ] 工作流只使用 Gitea Runner 支持且来源可信的 Action;
|
||||
- [ ] Python、Node.js、uv 和 pnpm 版本符合本规范;
|
||||
- [ ] 后端和前端依赖均以 frozen 模式安装;
|
||||
- [ ] 必需 Job 名称与 `main` 分支保护一致;
|
||||
- [ ] Mock 测试不依赖外部模型服务和真实凭据;
|
||||
- [ ] 缓存键包含锁文件哈希,缓存内容不含用户数据或秘密;
|
||||
- [ ] PR、`main`、版本标签和手动触发行为分别验证;
|
||||
- [ ] 失败任务能返回非零退出码,后续发布步骤不会继续;
|
||||
- [ ] 候选产物包含提交 SHA、校验和和保留期限;
|
||||
- [ ] 团队成员能够按本文档在本地复现全部门禁。
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> 本文档用于 Notes Agent 团队日常开发。目标是让三名成员可以并行开发、稳定联调,并确保 `main` 始终处于可运行状态。
|
||||
|
||||
> 更新日期:2026-09-01。当前远程只使用 `gitea`,功能分支不添加个人或工具名称前缀;合并门槛为相关测试、前端生产构建、文档同步和 `git diff --check` 全部通过。自动化门禁、产物和发布规则见 [CI/CD 细则](CI-CD细则-团队开发版.md)。
|
||||
|
||||
## 1. 仓库与远程
|
||||
|
||||
团队代码统一使用 Gitea:
|
||||
@@ -68,6 +70,7 @@ chore/backend-dependencies
|
||||
- 一个分支只处理一个主要问题;
|
||||
- 不使用 `test1`、`new`、`final`、`xxx-dev` 等无法识别用途的名称;
|
||||
- 功能合入后删除远程分支,避免长期堆积。
|
||||
- 不在分支名前添加 `codex/`、成员姓名或设备名;归属由提交作者、Issue 和 PR Reviewer 表达。
|
||||
|
||||
## 3. 模块分工与改动边界
|
||||
|
||||
@@ -619,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。
|
||||
+3
-2
@@ -3,6 +3,8 @@
|
||||
> 本文记录 `feat/knowledge-retrieval-core` 合并前后的两轮代码审阅、问题复现、修复过程与工程经验。
|
||||
> 它既是团队内部的问题档案,也可作为后续技术文档、课程报告和博客文章的素材底稿。
|
||||
|
||||
> 2026-08-30 状态补充:本文中的 43 项测试是当时该模块的历史基线,不应替换为当前全仓测试数。相关修复仍有效,当前完整后端回归为 71 项通过,Knowledge/Retrieval 已通过 `notes.*` 与 `rag.search` Tool 接入 Agent Runtime。
|
||||
|
||||
## 1. 背景
|
||||
|
||||
Knowledge Core 与 Retrieval Core 建立了项目第一条完整的本地知识检索链路:
|
||||
@@ -497,6 +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 模块开发说明
|
||||
```
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# 前端合并审阅问题与修复复盘
|
||||
|
||||
> 审阅与修复日期:2026-08-29
|
||||
> 涉及提交:`f9efc4f`,合并提交 `c6c28e4`。
|
||||
> 文档用途:记录前端分支合并后暴露的问题域、形成原因、实际后果、修复思路和落地方案,供后续技术文档、比赛材料与博客写作使用。
|
||||
|
||||
> 2026-08-30 状态补充:在本文两轮修复之后,项目又完成 Milkdown 写作工具栏、文件切换二次竞态修复、Shiki 只读高亮、Provider 预设/模型发现/加密 API Key 输入,以及智能体页面汉化。当前前端回归基线为 14 项测试和生产构建通过。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
原前端提交一次增加了 42 个文件和约 8000 行内容,但没有在提交前执行成功的生产构建。合并后同时存在工程配置、组件完整性、接口契约、流式协议和文件树状态五个问题域。
|
||||
|
||||
本轮处理结果:
|
||||
|
||||
| 编号 | 问题域 | 原级别 | 处理结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-01 | TypeScript 与生产构建不可用 | P0 | 已修复,`pnpm build` 通过 |
|
||||
| F-02 | 路由引用未提交页面 | P0 | 已改为统一占位页,并补充基础编辑器组件 |
|
||||
| F-03 | 前后端 Contract 系统性漂移 | P1 | 已增加 Wire DTO 和显式 Service 映射 |
|
||||
| F-04 | SSE 跨网络分片丢失事件 | P1 | 已重写增量解析状态机 |
|
||||
| F-05 | 文件树右键操作目标错误 | P1 | 已改为保存实际右键节点 |
|
||||
| F-06 | 根目录新增文件不可见且路径异常 | P2 | 已处理顶层插入与路径拼接 |
|
||||
| F-07 | Chat 仍使用模拟流式输出 | P2 | 已接入真实 `/api/chat` SSE |
|
||||
|
||||
## 2. F-01:TypeScript 与生产构建不可用
|
||||
|
||||
### 原因
|
||||
|
||||
Vite 配置了 `@` 指向 `src`,但 `tsconfig.app.json` 没有配置 `baseUrl` 和 `paths`。Vite 和 TypeScript 使用不同的模块解析配置,只配置其中一侧后,开发服务器可能暂时工作,`vue-tsc` 仍无法解析全部别名。
|
||||
|
||||
提交中还存在多个独立错误:
|
||||
|
||||
- `ComputedRef` 与字符串直接比较,缺少 `.value`;
|
||||
- `FileTreePanel.vue` 在两个 Script 中重复导入 `FileNode`;
|
||||
- 浏览器 ESM 代码调用 CommonJS `require()`;
|
||||
- Chat Store 使用不存在的 `conversation_id` 变量;
|
||||
- Service 聚合文件导出不存在的 `ApiError`;
|
||||
- StatusBar 使用没有表达式的 `@click`。
|
||||
|
||||
### 后果
|
||||
|
||||
- `pnpm build` 无法生成生产包;
|
||||
- CI 无法验证前端;
|
||||
- 别名错误产生的大量隐式 `any` 干扰真正错误定位;
|
||||
- `main` 不再满足“可构建”要求。
|
||||
|
||||
### 解决思路
|
||||
|
||||
先恢复唯一可信的构建基线,再处理运行时问题。路径别名同时配置给 Vite 和 TypeScript,其余错误按 Vue 3 Composition API 和浏览器 ESM 规则逐项修复。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- 在 `tsconfig.app.json` 增加 `baseUrl` 和 `@/*` 映射;
|
||||
- 在 Script 中通过 `.value` 读取 ComputedRef;
|
||||
- 拆分递归文件树组件,删除第二个 Script 和 `require()`;
|
||||
- 修正 Chat 变量名和类型导出;
|
||||
- 删除无意义的空事件绑定;
|
||||
- 将 `pnpm build` 作为提交前强制检查。
|
||||
|
||||
## 3. F-02:路由和公共组件引用未提交文件
|
||||
|
||||
### 原因
|
||||
|
||||
路由表和 Secondary Sidebar 按最终页面结构一次性写完,但对应页面没有随提交进入仓库。Workspace 也引用了不存在的 `EditorHeader.vue` 和 `EditorPane.vue`。缺失项覆盖 Search、Chat、Agent、Task、Skill、Plugin、Theme、Settings 和多个 Sidebar Panel,共 21 个 Vue 文件。
|
||||
|
||||
### 后果
|
||||
|
||||
- 修复路径别名后,TypeScript 和 Vite 仍因模块不存在而失败;
|
||||
- 开发者无法判断页面是遗漏提交,还是尚未实现;
|
||||
- 后续成员可能分别创建同名但职责不同的组件。
|
||||
|
||||
### 解决思路
|
||||
|
||||
路由只能引用当前提交真实存在的组件。为保留产品信息架构,使用一个明确标注“功能开发中”的公共占位页,避免建立一批内容为空的伪页面。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- 新增统一 `PlaceholderView.vue`;
|
||||
- 未实现功能路由暂时指向占位页;
|
||||
- Secondary Sidebar 对未实现 Panel 显示说明文本;
|
||||
- 增加可运行的基础 Editor Header 和 Textarea Pane;
|
||||
- 文档明确占位路由不代表业务页面完成。
|
||||
|
||||
## 4. F-03:前后端 Contract 系统性漂移
|
||||
|
||||
### 原因
|
||||
|
||||
前端先按页面需要定义了扁平 View Model,并直接把它们作为 HTTP 请求和响应类型。后端已经形成明确的 Pydantic Contract,包括分页包装、嵌套 Manifest、枚举和值对象,两边没有通过 OpenAPI 或人工核对完成同步。
|
||||
|
||||
典型差异:
|
||||
|
||||
| 模块 | 原前端假设 | FastAPI 实际 Contract |
|
||||
| --- | --- | --- |
|
||||
| Notes | `folder_path/content` | `folder/markdown` |
|
||||
| Search | 单值筛选、`results/total` | 数组筛选、`items/page` |
|
||||
| Agent | `task`、可选 Provider | `input`、Provider 与 Model 必填 |
|
||||
| Permission | `allow + scope` | `allow_once/allow_session/deny` |
|
||||
| Skill / Plugin | 扁平对象 | `manifest + runtime status` |
|
||||
| Provider | Capability 对象 | Capability 数组 |
|
||||
| Task | `due_date`、priority、source | `due_at`,后两项尚未进入后端 |
|
||||
| Index | `full/fts/vector` | `all/notes/vectors` |
|
||||
|
||||
### 后果
|
||||
|
||||
- Agent 创建、Permission 响应等请求稳定返回 422;
|
||||
- 列表接口拿到对象后被当成数组使用;
|
||||
- Skill、Plugin 和 Provider 页面读取不到标识和能力;
|
||||
- TypeScript 声称调用安全,但运行时结构完全不同;
|
||||
- 捕获异常后回退 Mock 会掩盖真实联调失败。
|
||||
|
||||
### 解决思路
|
||||
|
||||
区分 Wire DTO 和 View Model。HTTP 边界严格使用与 FastAPI 一致的 `Api*` 类型,Service 显式完成转换,页面展示字段不反向污染后端请求。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- 增加 `ApiNote`、`ApiAgentRun`、`ApiSkill`、`ApiPlugin`、`ApiProviderConfig`、`ApiTask`、`ApiIndexStatus` 等 Wire DTO;
|
||||
- Notes Service 改用 `folder`、`markdown` 和真实分页结构;
|
||||
- Search Service 将单值 UI Filter 转换为后端数组,并映射 `items/page`;
|
||||
- Agent Service 使用 `input`、`tool_timeout_seconds` 和 `run_timeout_seconds`;
|
||||
- Permission Store 将 `allow + once/session` 转换为后端枚举;
|
||||
- Skill 和 Plugin Service 展开嵌套 Manifest;
|
||||
- 增加 Plugin Permission PUT;
|
||||
- Provider Capability 数组转换为界面布尔 Map;
|
||||
- Task Service 只发送后端支持字段,并转换 `due_date/due_at`;
|
||||
- Index Service 显式转换 Scope;
|
||||
- 默认离线 Provider ID 统一为后端的 `mock`。
|
||||
|
||||
## 5. F-04:SSE 跨网络分片丢失事件
|
||||
|
||||
### 原因
|
||||
|
||||
旧解析器把 `eventName` 和 `dataStr` 声明在每次 `reader.read()` 的循环内部。网络 Chunk 与 SSE Event 没有一一对应关系,一个事件的 `event:`、`data:` 和结尾空行可以分别落在多个 Chunk 中。
|
||||
|
||||
```text
|
||||
Chunk 1: event: TextDelta\n
|
||||
Chunk 2: data: {"event":"TextDelta", ...}\n\n
|
||||
```
|
||||
|
||||
读取 Chunk 2 时事件名已被重置成 `message`。如果 data 与空行分开,data 内容也会丢失。
|
||||
|
||||
### 后果
|
||||
|
||||
- Chat 增量文本偶发不显示;
|
||||
- Agent 终态事件无法触发完成回调;
|
||||
- 问题受网络分片影响,开发机难以稳定复现;
|
||||
- 长回答和远程 Provider 更容易出现错误。
|
||||
|
||||
### 解决思路
|
||||
|
||||
SSE 解析状态必须跨 Chunk 保存,以完整行和空行结束事件为边界,不能以单次网络读取为边界。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- 将 Event Name 和 Data Lines 移到读取循环外;
|
||||
- Buffer 只移除已经形成完整行的内容;
|
||||
- 支持 LF、CRLF、多行 data 和注释行;
|
||||
- 流结束时 flush TextDecoder 和剩余 Event;
|
||||
- 终态回调增加去重;
|
||||
- SSE URL 复用普通 HTTP 的 Base URL 解析。
|
||||
|
||||
## 6. F-05:文件树右键操作目标错误
|
||||
|
||||
### 原因
|
||||
|
||||
右键菜单打开时保存了 `contextMenuPath`,但执行删除和重命名时读取的是 `workspaceStore.activeFile`。右键节点与当前编辑节点是两个独立状态。
|
||||
|
||||
### 后果
|
||||
|
||||
用户右键未激活文件并点击删除时,可能关闭或修改正在编辑的另一个文件。这属于潜在数据破坏问题。
|
||||
|
||||
### 解决思路
|
||||
|
||||
菜单操作必须绑定菜单打开时的目标对象,不能在点击命令时从无关的 Active State 推断。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- 使用 `contextTarget: Ref<FileNode | null>` 保存右键节点;
|
||||
- Rename 和 Delete 只消费 `contextTarget`;
|
||||
- 菜单关闭后清空目标;
|
||||
- 将递归 Node 独立为 `FileTreeNode.vue`,通过类型化 Emit 向上传递节点。
|
||||
|
||||
## 7. F-06:根目录新增文件不可见且路径异常
|
||||
|
||||
### 原因
|
||||
|
||||
Store 把 `/` 当作普通父节点查找,但文件树没有代表根目录的虚拟节点。Service 直接使用 `folderPath + '/' + name` 拼接路径,根目录会得到 `//name.md`。
|
||||
|
||||
### 后果
|
||||
|
||||
- Service 返回成功,但新建项目没有加入界面文件树;
|
||||
- 打开的文件路径带双斜杠;
|
||||
- 接入真实文件系统后可能产生平台间路径差异。
|
||||
|
||||
### 解决思路与方案
|
||||
|
||||
顶层数组本身就是根节点的 children。当 `parentPath` 为 `/` 或空字符串时直接写入 `fileTree.value`,Mock Service 拼接根目录路径时只保留一个 `/`。
|
||||
|
||||
## 8. F-07:Chat 使用模拟流式输出
|
||||
|
||||
### 原因
|
||||
|
||||
Chat Store 已经存在 SSE Service,但发送消息后仍通过 `setInterval` 拼接固定文本,没有调用后端。
|
||||
|
||||
### 后果
|
||||
|
||||
- 后端 Provider、RAG、错误事件和取消无法通过前端验证;
|
||||
- 页面看似工作,实际没有形成前后端链路;
|
||||
- SSE 解析缺陷长期被 Mock 掩盖。
|
||||
|
||||
### 解决思路与方案
|
||||
|
||||
保留初始展示数据,但用户主动发送消息时调用真实 `/api/chat`。请求使用当前 Provider、Model、RAG 开关和消息历史;TextDelta 追加到 Assistant Message;Error、网络失败、Done 和主动取消同步更新 Streaming State。默认使用离线 `mock / mock-1`,无需外部 API Key。
|
||||
|
||||
## 9. 验证
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
cd ../backend
|
||||
uv run pytest
|
||||
|
||||
cd ..
|
||||
git diff --check
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
frontend production build passed
|
||||
73 frontend modules transformed
|
||||
71 backend tests passed
|
||||
preview returned HTTP 200
|
||||
git diff --check passed
|
||||
```
|
||||
|
||||
## 10. 预防措施
|
||||
|
||||
- PR 创建前必须执行与 CI 相同的 `pnpm build`;
|
||||
- 路由只引用当前提交存在的文件;
|
||||
- FastAPI `/openapi.json` 是 Wire Contract 的唯一事实来源;
|
||||
- View Model 与 API DTO 分层,Service 必须显式转换;
|
||||
- 不用 Mock Fallback 掩盖 4xx、5xx 和契约错误;
|
||||
- SSE 测试按任意 Chunk 边界构造数据,不能假定一次 read 等于一次 Event;
|
||||
- 删除、移动和覆盖等高影响操作必须携带明确目标 ID 或对象;
|
||||
- 合并后如果发现 P0,先恢复主分支构建,再继续业务页面开发。
|
||||
|
||||
## 11. 当前边界与后续事项
|
||||
|
||||
首次修复解决了前端壳子的工程正确性和接口边界。之后已继续补齐全部业务路由页面;Tauri Host、Stronghold 和真实文件系统仍属于桌面集成阶段。后续仍需要:
|
||||
|
||||
- 为 Service DTO 映射增加自动化契约测试;
|
||||
- 为 SSE Parser 增加跨 Chunk 单元测试;
|
||||
- 用 Tauri Command 替换 Mock Workspace Service;
|
||||
- 在 CI 中加入前端构建和后端测试两个必需检查。
|
||||
|
||||
## 12. 全页面完成后的第二轮审阅与修复
|
||||
|
||||
全部页面接通后再次审阅,发现编译通过并不等于交互状态正确。本轮问题与处理如下:
|
||||
|
||||
| 编号 | 问题 | 原因与后果 | 解决方案 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-08 | Markdown 没有真实渲染 | 写作与源码模式共用同一个 `textarea`,Chat 也把 Markdown 当纯文本显示 | 使用 `marked` 解析 GFM,使用 DOMPurify 清洗 HTML;编辑页提供源码输入和实时预览,Chat 回答复用安全渲染器 |
|
||||
| F-09 | 重命名后路径仍是旧值 | 只修改节点名称,没有同步节点、子节点、打开文件和编辑器路径,后续保存或删除会作用于旧路径 | 新增递归路径迁移,同时更新 `openFiles`、活动路径和 Editor 当前路径;Mock 内容缓存也随路径迁移 |
|
||||
| F-10 | 删除活动文件后正文错位 | Workspace 切换了活动文件,但 Editor 仍保留已删除文件正文 | 删除文件或文件夹时统一清理其所有打开路径;若存在下一个文件则加载,否则关闭编辑器 |
|
||||
| F-11 | 异步读取和保存存在竞态 | 快速切换文件可能让较早请求覆盖较新文件;保存过程中继续编辑会被错误标记为已保存 | 使用读取版本号丢弃过期结果;保存使用路径和正文快照,只有快照仍是最新内容时才标记 `saved` |
|
||||
| F-12 | Agent 权限弹窗跨 Run 残留 | 切换 Run、ToolResult 和终态事件没有释放 PermissionRequest | 加载 Run 前清空请求,并在 ToolResult、Completed、Failed、Cancelled 时同步清理;同时更新本地 Run 状态 |
|
||||
| F-13 | Chat 丢弃非文本 SSE 事件 | Store 只处理 TextDelta 和 Error,Tool Call 请求会留下空消息 | 增加 Thinking、ToolCall Start/Delta/End、Usage 和 Citation 状态处理及页面卡片展示 |
|
||||
| F-14 | Task 字段表现为保存但实际丢失 | 前端展示后端不支持的 Priority/Source,更新请求又漏掉后端已支持的 `note_id` | 暂时移除不可持久化字段的编辑与筛选;补齐 `note_id` 更新与解除关联的 `null` 语义 |
|
||||
| F-15 | 编辑器设置不生效 | Settings Store 与 Editor/Theme 没有联动,自动保存固定为 1500ms | 自动保存、默认模式、拼写检查、字号、行高和行宽改为实际驱动编辑器,并保存到 Local Storage |
|
||||
| F-16 | Vector 降级提示永远不可达 | `vectorUnavailable` 只声明不赋值,向量错误直接清空结果 | 对明确的向量、Embedding、模型和 Provider 不可用错误自动重试 FTS,并显示降级状态 |
|
||||
| F-17 | 护眼主题与 Plugin 导航状态异常 | Sepia 只有预览卡没有 Token;Plugin 路由被错误映射为 Skill 激活状态 | 增加 Sepia Design Token,并按真实路由名计算主导航选中项 |
|
||||
| F-18 | 文件切换仍可能丢失未保存内容 | `loadFile` 直接替换路径和正文,且旧的自动保存定时器会在切换后保存错误文件 | 切换前取消定时器,等待正在执行的保存并保存最新快照;保存失败或存在冲突时阻止切换;各入口仅在加载成功后更新 Workspace 活动路径 |
|
||||
| F-19 | 编辑器外观启动时被默认值覆盖 | Theme Store 的立即监听早于 `initTheme` 执行,先把默认值写进 Local Storage | 增加 Hydration 状态,初始化前监听只更新 CSS,不持久化;读取本地配置完成后再允许写入 |
|
||||
|
||||
安全边界:Markdown 解析结果不得直接使用未经清洗的 `v-html`。DOMPurify 是渲染链路的必需依赖,后续升级 `marked` 或允许扩展 Markdown 时也必须保留清洗步骤。
|
||||
|
||||
## 13. 写作、Provider 与智能体页面的后续修复
|
||||
|
||||
第三轮交互完善继续处理了文件切换、Markdown 选区格式、代码块默认状态、亮暗主题对比度和浮动工具栏失效问题。Provider 设置页增加 OpenAI、DeepSeek、Ollama 预设与模型自动发现,API Key 改为提交给后端加密保存,不进入 Pinia 或 Local Storage。智能体页面的运行状态、事件、工具、权限及导航文案已完成中文化,同时保留技术 ID 便于排障。
|
||||
|
||||
该轮新增 Store、Workspace、文件树、编辑器和中文标签回归测试;当前结果为前端 14 项、后端 71 项测试通过,生产构建通过。
|
||||
@@ -4,6 +4,8 @@
|
||||
> 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。
|
||||
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
|
||||
|
||||
> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储和 Agent Trace 持久化,当前完整后端回归基线为 81 项测试通过。
|
||||
|
||||
## 1. 审阅结论
|
||||
|
||||
审阅前的主链路已经能够运行,原有 50 项测试全部通过,但测试没有覆盖首次失败、畸形扩展包、代码型 Markdown、浏览器字符偏移和真实 Provider Streaming 等边界。
|
||||
@@ -303,7 +305,7 @@ git diff --check
|
||||
验证结果:
|
||||
|
||||
```text
|
||||
62 passed
|
||||
71 passed
|
||||
compileall passed
|
||||
uv lock --check passed
|
||||
git diff --check passed
|
||||
@@ -321,6 +323,8 @@ git diff --check passed
|
||||
- Attachment 与 transcript Tool;
|
||||
- Provider PATCH 显式 null;
|
||||
- OpenAI SSE 与 Ollama JSONL 增量事件。
|
||||
- Provider 预设、模型发现、凭据缺失/鉴权错误映射;
|
||||
- 凭据密文落盘、API 不回显明文及 Provider 解密读取。
|
||||
|
||||
## 14. 后续工作
|
||||
|
||||
+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>
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@milkdown/crepe": "7.22.1",
|
||||
"@milkdown/kit": "7.22.1",
|
||||
"@milkdown/plugin-block": "^7.22.0",
|
||||
"@milkdown/plugin-cursor": "^7.22.0",
|
||||
"@milkdown/plugin-history": "^7.22.0",
|
||||
@@ -21,18 +25,26 @@
|
||||
"@milkdown/plugin-trailing": "^7.22.0",
|
||||
"@milkdown/theme-nord": "^7.22.0",
|
||||
"@milkdown/vue": "^7.22.0",
|
||||
"@shikijs/engine-javascript": "4.4.3",
|
||||
"@shikijs/langs": "4.4.3",
|
||||
"@shikijs/themes": "4.4.3",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"codemirror": "^6.0.0",
|
||||
"dompurify": "^3.4.14",
|
||||
"marked": "^15.0.0",
|
||||
"pinia": "^4.0.0",
|
||||
"shiki": "^4.4.3",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vue/test-utils": "^2.5.0",
|
||||
"happy-dom": "^20.11.15",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.11",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+712
-9
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
withDefaults(defineProps<{ icon: Component; size?: number }>(), { size: 18 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="icon" class="app-icon" :style="{ width: `${size}px`, height: `${size}px` }" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-icon { display: inline-block; flex: 0 0 auto; color: currentColor; }
|
||||
</style>
|
||||
@@ -1,15 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import PrimarySidebar from './PrimarySidebar.vue'
|
||||
import SecondarySidebar from './SecondarySidebar.vue'
|
||||
import StatusBar from './StatusBar.vue'
|
||||
import TitleBar from './TitleBar.vue'
|
||||
import CommandPalette from './CommandPalette.vue'
|
||||
|
||||
defineProps<{
|
||||
showSecondarySidebar?: boolean
|
||||
@@ -19,14 +19,19 @@ const workspaceStore = useWorkspaceStore()
|
||||
const themeStore = useThemeStore()
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const agentStore = useAgentStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => { void settingsStore.loadDiagnostics() })
|
||||
watch(() => settingsStore.defaultEditorMode, (mode) => editorStore.setMode(mode), { immediate: true })
|
||||
watch(() => settingsStore.editorLineWidth, (width) => {
|
||||
document.documentElement.style.setProperty('--editor-line-width', `${width}ch`)
|
||||
}, { immediate: true })
|
||||
|
||||
const routeName = computed(() => route.name as string)
|
||||
|
||||
const secondaryComponent = computed(() => {
|
||||
switch (routeName) {
|
||||
switch (routeName.value) {
|
||||
case 'workspace': return 'file-tree'
|
||||
case 'search': return 'search-filters'
|
||||
case 'chat': return 'conversation-list'
|
||||
@@ -58,6 +63,7 @@ defineExpose({ openCitation })
|
||||
</main>
|
||||
</div>
|
||||
<StatusBar />
|
||||
<CommandPalette />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,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);
|
||||
}
|
||||
|
||||
@@ -83,5 +89,6 @@ defineExpose({ openCitation })
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--color-background-primary);
|
||||
isolation: isolate;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
|
||||
const router = useRouter()
|
||||
const editorStore = useEditorStore()
|
||||
const themeStore = useThemeStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
|
||||
interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> }
|
||||
|
||||
const commands = computed<Command[]>(() => [
|
||||
{ id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') },
|
||||
{ id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') },
|
||||
{ id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') },
|
||||
{ id: 'agent', label: '创建智能体运行', hint: '导航', run: () => router.push('/agent/runs') },
|
||||
{ id: 'settings', label: '打开设置', hint: '导航', run: () => router.push('/settings') },
|
||||
{ id: 'mode', label: `切换为${editorStore.mode === 'source' ? '写作' : '源码'}模式`, hint: '编辑器', run: () => editorStore.toggleMode() },
|
||||
{ id: 'save', label: '保存当前笔记', hint: '编辑器', run: () => editorStore.save() },
|
||||
{ id: 'theme', label: `切换为${themeStore.isDark ? '浅色' : '深色'}主题`, hint: '外观', run: () => themeStore.toggleTheme() },
|
||||
{ id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote },
|
||||
])
|
||||
|
||||
const filteredCommands = computed(() => {
|
||||
const value = query.value.trim().toLocaleLowerCase()
|
||||
return value ? commands.value.filter((command) => `${command.label} ${command.hint}`.toLocaleLowerCase().includes(value)) : commands.value
|
||||
})
|
||||
|
||||
function show() {
|
||||
open.value = true
|
||||
query.value = ''
|
||||
void nextTick(() => input.value?.focus())
|
||||
}
|
||||
|
||||
function hide() { open.value = false }
|
||||
|
||||
async function execute(command: Command | undefined) {
|
||||
if (!command) return
|
||||
hide()
|
||||
await command.run()
|
||||
}
|
||||
|
||||
async function createNote() {
|
||||
const rawName = window.prompt('笔记名称')?.trim()
|
||||
if (!rawName) return
|
||||
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
|
||||
const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`)
|
||||
workspaceStore.addFileToTree('/', file)
|
||||
await editorStore.loadFile(file.path)
|
||||
workspaceStore.openFile(file.path)
|
||||
await router.push('/workspace')
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') {
|
||||
event.preventDefault()
|
||||
open.value ? hide() : show()
|
||||
} else if (event.key === 'Escape' && open.value) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', handleKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="command-backdrop" @click.self="hide">
|
||||
<section class="command-palette" role="dialog" aria-modal="true" aria-label="命令面板">
|
||||
<input ref="input" v-model="query" class="command-input" placeholder="输入命令…" @keydown.enter.prevent="execute(filteredCommands[0])" />
|
||||
<div class="command-list">
|
||||
<button v-for="command in filteredCommands" :key="command.id" type="button" @click="execute(command)">
|
||||
<span>{{ command.label }}</span><small>{{ command.hint }}</small>
|
||||
</button>
|
||||
<p v-if="!filteredCommands.length">没有匹配的命令</p>
|
||||
</div>
|
||||
<footer><span>Enter 执行</span><span>Esc 关闭</span></footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</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); 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) 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>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { Connection, Lightning } from '@element-plus/icons-vue'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
|
||||
const route = useRoute()
|
||||
const skillStore = useSkillStore()
|
||||
const pluginStore = usePluginStore()
|
||||
const isPlugin = computed(() => route.name === 'plugins')
|
||||
|
||||
onMounted(() => { if (isPlugin.value) void pluginStore.loadPlugins(); else void skillStore.loadSkills() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-panel">
|
||||
<div v-if="isPlugin" class="sidebar-list">
|
||||
<button v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="sidebar-list-item extension-item"
|
||||
:class="{ active: pluginStore.selectedPluginId === plugin.plugin_id }" @click="pluginStore.selectPlugin(plugin.plugin_id)">
|
||||
<AppIcon :icon="Connection" /><span><strong>{{ plugin.name }}</strong><small>{{ plugin.status }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="sidebar-list">
|
||||
<button v-for="skill in skillStore.skills" :key="skill.skill_id" class="sidebar-list-item extension-item"
|
||||
:class="{ active: skillStore.selectedSkillId === skill.skill_id }" @click="skillStore.selectSkill(skill.skill_id)">
|
||||
<AppIcon :icon="Lightning" /><span><strong>{{ skill.name }}</strong><small>{{ skill.status }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.extension-item { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: var(--space-sm); width: 100%; text-align: left; }
|
||||
.extension-item strong, .extension-item small { display: block; }
|
||||
.extension-item small { color: var(--color-text-tertiary); }
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
|
||||
const props = defineProps<{ source: string }>()
|
||||
const html = ref('')
|
||||
let renderVersion = 0
|
||||
|
||||
watch(() => props.source, async (source) => {
|
||||
const version = ++renderVersion
|
||||
const result = await renderMarkdown(source)
|
||||
if (version === renderVersion) html.value = result
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-content" v-html="html" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.markdown-content { white-space: normal; user-select: text; }
|
||||
.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;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Search, Setting } from '@element-plus/icons-vue'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const expanded = ref(localStorage.getItem('primary-sidebar-expanded') === 'true')
|
||||
|
||||
const navItems = [
|
||||
{ name: 'workspace', icon: '📁', label: '工作区' },
|
||||
{ name: 'search', icon: '🔍', label: '搜索' },
|
||||
{ name: 'chat', icon: '💬', label: 'AI 对话' },
|
||||
{ name: 'agent', icon: '🤖', label: 'Agent' },
|
||||
{ name: 'tasks', icon: '✅', label: '任务' },
|
||||
{ name: 'skills', icon: '⚡', label: 'Skill' },
|
||||
{ name: 'plugins', icon: '🧩', label: 'Plugin' },
|
||||
{ name: 'themes', icon: '🎨', label: '主题' },
|
||||
{ name: 'settings', icon: '⚙️', label: '设置' },
|
||||
{ name: 'workspace', icon: FolderOpened, label: '工作区' },
|
||||
{ name: 'search', icon: Search, label: '搜索' },
|
||||
{ name: 'chat', icon: ChatDotRound, label: 'AI 对话' },
|
||||
{ name: 'agent', icon: Cpu, label: '智能体' },
|
||||
{ name: 'tasks', icon: CircleCheck, label: '任务' },
|
||||
{ name: 'skills', icon: Lightning, label: 'Skill' },
|
||||
{ name: 'plugins', icon: Connection, label: 'Plugin' },
|
||||
{ name: 'themes', icon: Brush, label: '主题' },
|
||||
{ name: 'settings', icon: Setting, label: '设置' },
|
||||
]
|
||||
|
||||
const currentName = computed(() => {
|
||||
const name = route.name as string
|
||||
if (name === 'skills' || name === 'plugins') return 'skills'
|
||||
return name
|
||||
return route.name as string
|
||||
})
|
||||
|
||||
function navigate(name: string) {
|
||||
router.push({ name })
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded.value = !expanded.value
|
||||
localStorage.setItem('primary-sidebar-expanded', String(expanded.value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="primary-sidebar">
|
||||
<aside class="primary-sidebar" :class="{ expanded }">
|
||||
<nav class="nav-list">
|
||||
<div
|
||||
v-for="item in navItems"
|
||||
@@ -39,32 +45,39 @@ function navigate(name: string) {
|
||||
@click="navigate(item.name)"
|
||||
:title="item.label"
|
||||
>
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
<AppIcon class="nav-icon" :icon="item.icon" :size="20" />
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="nav-item" @click="navigate('settings')" title="设置">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
</div>
|
||||
<button class="nav-item collapse-button" type="button" :title="expanded ? '收起导航' : '展开导航'" @click="toggleExpanded">
|
||||
<AppIcon class="nav-icon" :icon="expanded ? ArrowLeftBold : ArrowRightBold" />
|
||||
<span class="nav-label">{{ expanded ? '收起' : '展开' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<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); }
|
||||
.primary-sidebar.expanded .nav-item { flex-direction: row; justify-content: flex-start; gap: var(--space-md); padding: 0 var(--space-lg); }
|
||||
.primary-sidebar.expanded .nav-icon { margin-bottom: 0; }
|
||||
.primary-sidebar.expanded .nav-label { font-size: var(--font-size-sm); }
|
||||
|
||||
.nav-list {
|
||||
flex: 1;
|
||||
padding: var(--space-sm) 0;
|
||||
padding: var(--space-md) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
@@ -75,31 +88,34 @@ function navigate(name: string) {
|
||||
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);
|
||||
}
|
||||
@@ -115,10 +131,13 @@ function navigate(name: string) {
|
||||
.nav-label {
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: var(--space-sm) 0;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.collapse-button { width: calc(100% - 12px); }
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ const sidebarTitle = computed(() => {
|
||||
const titles: Record<string, string> = {
|
||||
'file-tree': '文件',
|
||||
'conversation-list': '对话',
|
||||
'run-list': 'Agent Run',
|
||||
'run-list': '智能体运行',
|
||||
'search-filters': '搜索筛选',
|
||||
'task-filters': '任务筛选',
|
||||
'extension-list': '扩展',
|
||||
@@ -27,7 +27,7 @@ const sidebarTitle = computed(() => {
|
||||
return titles[props.component || ''] || ''
|
||||
})
|
||||
|
||||
const showSkillToggle = computed(() => routeName === 'skills' || routeName === 'plugins')
|
||||
const showSkillToggle = computed(() => routeName.value === 'skills' || routeName.value === 'plugins')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,7 +53,7 @@ const showSkillToggle = computed(() => routeName === '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 === '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,5 +108,7 @@ const showSkillToggle = computed(() => routeName === 'skills' || routeName === '
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -75,16 +75,16 @@ const showEditorInfo = computed(() => route.name === 'workspace')
|
||||
{{ saveStatusText }}
|
||||
</span>
|
||||
<span class="status-item" :title="indexStatusText">
|
||||
<span class="status-dot" style="background: var(--color-success)" />
|
||||
索引就绪
|
||||
<span class="status-dot" :style="{ background: settingsStore.indexStatus.status === 'error' ? 'var(--color-error)' : settingsStore.indexStatus.status === 'indexing' ? 'var(--color-warning)' : 'var(--color-success)' }" />
|
||||
{{ indexStatusText }}
|
||||
</span>
|
||||
<span class="status-item" :style="{ color: aiCoreColor }" @click>
|
||||
<span class="status-item" :style="{ color: aiCoreColor }">
|
||||
<span class="status-dot" :style="{ background: aiCoreColor }" />
|
||||
{{ aiCoreStatusText }}
|
||||
</span>
|
||||
<span v-if="agentStore.isRunning" class="status-item agent-status">
|
||||
<span class="spinner" />
|
||||
Agent 运行中
|
||||
智能体运行中
|
||||
</span>
|
||||
</div>
|
||||
<div class="statusbar-right">
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRoute } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { Moon, Sunny } from '@element-plus/icons-vue'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -16,14 +18,14 @@ const pageTitle = computed(() => {
|
||||
workspace: '工作区',
|
||||
search: '搜索',
|
||||
chat: 'AI 对话',
|
||||
agent: 'Agent Trace',
|
||||
agent: '智能体执行轨迹',
|
||||
tasks: '任务',
|
||||
skills: 'Skill 管理',
|
||||
plugins: 'Plugin 管理',
|
||||
themes: '主题管理',
|
||||
settings: '设置',
|
||||
}
|
||||
return titles[name] || '知笔知己'
|
||||
return titles[name] || 'NotesAgent'
|
||||
})
|
||||
|
||||
const currentFileName = computed(() => {
|
||||
@@ -48,11 +50,11 @@ 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 ? '切换浅色主题' : '切换深色主题'">
|
||||
<span class="icon">{{ themeStore.isDark ? '☀️' : '🌙' }}</span>
|
||||
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="16" />
|
||||
</button>
|
||||
<div class="window-controls">
|
||||
<span class="win-btn minimize">—</span>
|
||||
@@ -69,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;
|
||||
@@ -127,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 {
|
||||
@@ -139,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;
|
||||
@@ -148,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,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);
|
||||
@@ -188,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'
|
||||
@@ -75,6 +76,8 @@ export interface ChatMessage {
|
||||
created_at: string
|
||||
citations?: Citation[]
|
||||
tool_calls?: ToolCall[]
|
||||
thinking?: string
|
||||
usage?: TokenUsage
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
@@ -99,6 +102,7 @@ export type ModelEventType =
|
||||
| 'ToolCallDelta'
|
||||
| 'ToolCallEnd'
|
||||
| 'Usage'
|
||||
| 'Citation'
|
||||
| 'Error'
|
||||
| 'Done'
|
||||
|
||||
@@ -139,6 +143,10 @@ export type AgentEventType =
|
||||
| 'PermissionRequired'
|
||||
| 'Usage'
|
||||
| 'Citation'
|
||||
| 'ModelCallStarted'
|
||||
| 'ModelCallCompleted'
|
||||
| 'ModelCallFailed'
|
||||
| 'PermissionResolved'
|
||||
| 'RunCompleted'
|
||||
| 'RunFailed'
|
||||
| 'RunCancelled'
|
||||
@@ -151,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
|
||||
@@ -246,16 +272,17 @@ export interface Plugin {
|
||||
status: PluginStatus
|
||||
enabled: boolean
|
||||
permissions: string[]
|
||||
granted_permissions?: string[]
|
||||
contributions: PluginContribution[]
|
||||
backend_type?: 'mcp' | 'internal'
|
||||
transport?: 'stdio' | 'websocket'
|
||||
backend_type?: 'mcp' | 'internal_rpc' | 'none'
|
||||
transport?: 'stdio' | 'http' | 'none'
|
||||
last_error?: string
|
||||
dependent_skills?: string[]
|
||||
}
|
||||
|
||||
// ============ Provider ============
|
||||
|
||||
export type ProviderType = 'openai' | 'anthropic' | 'ollama' | 'openai-compatible' | 'mock'
|
||||
export type ProviderType = ApiProviderType
|
||||
|
||||
export interface ModelCapability {
|
||||
chat: boolean
|
||||
@@ -286,6 +313,15 @@ export interface ProviderConfig {
|
||||
has_credential: boolean
|
||||
}
|
||||
|
||||
export interface ProviderPreset {
|
||||
preset_id: string
|
||||
name: string
|
||||
provider_type: ProviderType
|
||||
base_url: string
|
||||
default_credential_id?: string | null
|
||||
requires_credential: boolean
|
||||
}
|
||||
|
||||
// ============ Tasks ============
|
||||
|
||||
export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled'
|
||||
@@ -316,6 +352,7 @@ export interface ThemeConfig {
|
||||
is_dark: boolean
|
||||
author?: string
|
||||
builtin: boolean
|
||||
code_theme?: 'github-light' | 'github-dark'
|
||||
}
|
||||
|
||||
// ============ Index ============
|
||||
@@ -346,10 +383,10 @@ export interface ErrorResponse {
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
status: 'ok'
|
||||
name: string
|
||||
version: string
|
||||
environment: 'development' | 'production' | 'test'
|
||||
ai_core_available: boolean
|
||||
environment: string
|
||||
}
|
||||
|
||||
export type SaveStatus =
|
||||
@@ -362,3 +399,210 @@ export type SaveStatus =
|
||||
| 'conflict'
|
||||
|
||||
export type AiCoreStatus = 'starting' | 'running' | 'stopped' | 'error'
|
||||
|
||||
// ============ FastAPI wire contracts ============
|
||||
// UI view models above may contain presentation-only fields. Services must use
|
||||
// these DTOs at the HTTP boundary and explicitly map them to view models.
|
||||
|
||||
export interface PageMeta {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface ApiWorkspaceInfo {
|
||||
vault_id: string
|
||||
name: string
|
||||
path: string
|
||||
file_count: number
|
||||
indexed_note_count: number
|
||||
requires_refresh: boolean
|
||||
}
|
||||
|
||||
export interface ApiWorkspaceEntry {
|
||||
entry_id: string
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'folder'
|
||||
note_id?: string | null
|
||||
children: ApiWorkspaceEntry[]
|
||||
}
|
||||
|
||||
export interface ApiWorkspaceSnapshot {
|
||||
workspace: ApiWorkspaceInfo
|
||||
items: ApiWorkspaceEntry[]
|
||||
}
|
||||
|
||||
export interface OperationResponse {
|
||||
status: 'accepted' | 'completed'
|
||||
resource_id?: string | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
export interface ApiNoteBlock {
|
||||
block_id: string
|
||||
note_id: string
|
||||
heading_path: string[]
|
||||
start_offset: number
|
||||
end_offset: number
|
||||
content: string
|
||||
content_hash: string
|
||||
token_count: number
|
||||
}
|
||||
|
||||
export interface ApiNoteSummary {
|
||||
note_id: string
|
||||
title: string
|
||||
file_path: string
|
||||
tags: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ApiNote extends ApiNoteSummary {
|
||||
markdown: string
|
||||
blocks: ApiNoteBlock[]
|
||||
}
|
||||
|
||||
export interface ApiSearchResult {
|
||||
note_id: string
|
||||
block_id: string
|
||||
title: string
|
||||
file_path: string
|
||||
heading_path: string[]
|
||||
snippet?: string | null
|
||||
score: number
|
||||
citation: ApiCitation
|
||||
}
|
||||
|
||||
export interface ApiCitation {
|
||||
citation_id: string
|
||||
note_id: string
|
||||
block_id: string
|
||||
file_path: string
|
||||
heading_path: string[]
|
||||
start_offset?: number | null
|
||||
end_offset?: number | null
|
||||
source_audio?: string | null
|
||||
start_time?: number | null
|
||||
end_time?: number | null
|
||||
speaker?: string | null
|
||||
}
|
||||
|
||||
export interface ApiAgentRun {
|
||||
run_id: string
|
||||
status: AgentRunStatus
|
||||
input: string
|
||||
provider_id: string
|
||||
model: string
|
||||
skill_id?: string | null
|
||||
current_step: number
|
||||
max_steps: number
|
||||
token_budget?: number | null
|
||||
cancelled: boolean
|
||||
output?: string | null
|
||||
error_code?: string | null
|
||||
error_message?: string | null
|
||||
token_usage: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ApiSkill {
|
||||
manifest: {
|
||||
skill_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
permissions: string[]
|
||||
tools: string[]
|
||||
retrieval: { top_k: number; rerank: boolean; citation: boolean }
|
||||
model: { required_capabilities: string[] }
|
||||
}
|
||||
status: SkillStatus
|
||||
enabled: boolean
|
||||
missing_dependencies: string[]
|
||||
}
|
||||
|
||||
export interface ApiPlugin {
|
||||
manifest: {
|
||||
plugin_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
permissions: string[]
|
||||
contributes: {
|
||||
tools: string[]
|
||||
commands: string[]
|
||||
importers: string[]
|
||||
exporters: string[]
|
||||
panels: string[]
|
||||
settings_sections: string[]
|
||||
}
|
||||
backend: { type: 'mcp' | 'internal_rpc' | 'none'; transport: 'stdio' | 'http' | 'none' }
|
||||
}
|
||||
status: PluginStatus
|
||||
enabled: boolean
|
||||
granted_permissions: string[]
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
export type ApiProviderType =
|
||||
| 'mock'
|
||||
| 'openai_responses'
|
||||
| 'openai_chat'
|
||||
| 'openai_compatible'
|
||||
| 'anthropic_messages'
|
||||
| 'ollama'
|
||||
|
||||
export interface ApiProviderConfig {
|
||||
provider_id: string
|
||||
provider_type: ApiProviderType
|
||||
name: string
|
||||
base_url?: string | null
|
||||
default_model?: string | null
|
||||
credential_id?: string | null
|
||||
enabled: boolean
|
||||
capabilities: string[]
|
||||
}
|
||||
|
||||
export interface ApiProviderPreset {
|
||||
preset_id: string
|
||||
name: string
|
||||
provider_type: ApiProviderType
|
||||
base_url: string
|
||||
default_credential_id?: string | null
|
||||
requires_credential: boolean
|
||||
}
|
||||
|
||||
export interface ApiModelInfo {
|
||||
model: string
|
||||
display_name: string
|
||||
capabilities: string[]
|
||||
}
|
||||
|
||||
export interface ApiTask {
|
||||
task_id: string
|
||||
title: string
|
||||
description: string
|
||||
status: TaskStatus
|
||||
note_id?: string | null
|
||||
due_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ApiIndexStatus {
|
||||
status: 'idle' | 'queued' | 'running' | 'failed'
|
||||
pending_jobs: number
|
||||
active_job_id?: string | null
|
||||
last_completed_at?: string | null
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
export interface ApiIndexJob {
|
||||
job_id: string
|
||||
status: 'queued' | 'running' | 'completed' | 'failed'
|
||||
scope: 'all' | 'notes' | 'vectors'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
import type { AgentEvent } from '@/contracts'
|
||||
import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolDescription, toolLabel } from './labels'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const agentStore = useAgentStore()
|
||||
const providerStore = useProviderStore()
|
||||
const skillStore = useSkillStore()
|
||||
const pageError = ref('')
|
||||
const form = reactive({
|
||||
input: '', provider_id: 'mock', model: 'mock-1', skill_id: '', max_steps: 10,
|
||||
tool_timeout_seconds: 30, run_timeout_seconds: 300, token_budget: 8000,
|
||||
allow_network: false, max_concurrent_tools: 1, allowed_tools: [] as string[],
|
||||
})
|
||||
|
||||
const models = computed(() => providerStore.modelsByProvider[form.provider_id] ?? [])
|
||||
const isNewRun = computed(() => !route.params.runId)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()])
|
||||
await providerStore.loadModels(form.provider_id)
|
||||
} catch (error) { pageError.value = error instanceof Error ? error.message : '智能体配置加载失败' }
|
||||
})
|
||||
|
||||
watch(() => route.params.runId, async (runId) => {
|
||||
if (typeof runId !== 'string') return
|
||||
try { await agentStore.loadRun(runId) } catch (error) { pageError.value = error instanceof Error ? error.message : '运行记录加载失败' }
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => form.provider_id, async (providerId) => {
|
||||
try { await providerStore.loadModels(providerId); form.model = models.value[0]?.model_id ?? '' } catch { /* page keeps current selection */ }
|
||||
})
|
||||
|
||||
function toggleTool(name: string) {
|
||||
const index = form.allowed_tools.indexOf(name)
|
||||
if (index >= 0) form.allowed_tools.splice(index, 1)
|
||||
else form.allowed_tools.push(name)
|
||||
}
|
||||
|
||||
async function createRun() {
|
||||
pageError.value = ''
|
||||
try {
|
||||
const run = await agentStore.createRun({
|
||||
input: form.input, provider_id: form.provider_id, model: form.model,
|
||||
skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools,
|
||||
max_steps: form.max_steps, tool_timeout_seconds: form.tool_timeout_seconds,
|
||||
run_timeout_seconds: form.run_timeout_seconds, token_budget: form.token_budget,
|
||||
allow_network: form.allow_network, max_concurrent_tools: form.max_concurrent_tools,
|
||||
})
|
||||
await router.replace({ name: 'agent', params: { runId: run.run_id } })
|
||||
} catch (error) { pageError.value = error instanceof Error ? error.message : '运行创建失败' }
|
||||
}
|
||||
|
||||
function eventText(event: AgentEvent) {
|
||||
if (event.event === 'RunCompleted') return '任务已成功完成。'
|
||||
if (event.event === 'RunCancelled') return '任务已取消。'
|
||||
const text = event.data.text ?? event.data.message ?? event.data.code
|
||||
if (text) return String(text)
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feature-page agent-page">
|
||||
<header class="feature-header"><div><h1>{{ isNewRun ? '创建智能体运行' : '智能体执行轨迹' }}</h1><p>配置执行边界,并实时查看模型、工具和权限事件。</p></div>
|
||||
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建运行</button></header>
|
||||
<div v-if="pageError || agentStore.error" class="error-banner">{{ pageError || agentStore.error }}</div>
|
||||
<form v-if="isNewRun" class="panel run-form" @submit.prevent="createRun">
|
||||
<div class="field"><label>任务</label><textarea v-model="form.input" class="textarea" required placeholder="描述希望智能体完成的任务" /></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>模型提供商</label><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
|
||||
<div class="field"><label>模型</label><select v-model="form.model" class="select"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></select></div>
|
||||
<div class="field"><label>技能</label><select v-model="form.skill_id" class="select"><option value="">不使用技能</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div>
|
||||
<div class="field"><label>最大步骤</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
|
||||
<div class="field"><label>工具超时(秒)</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
|
||||
<div class="field"><label>运行超时(秒)</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div>
|
||||
<div class="field"><label>令牌预算</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div>
|
||||
<div class="field"><label>最大并发工具</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
|
||||
</div>
|
||||
<div class="field"><label>允许使用的工具</label><div class="tool-grid"><label v-for="tool in agentStore.tools" :key="tool.name" class="tool-option"><input type="checkbox" :checked="form.allowed_tools.includes(tool.name)" @change="toggleTool(tool.name)" /><span><strong>{{ toolLabel(tool.name) }}</strong><code>{{ tool.name }}</code><small>{{ toolDescription(tool.name, tool.description) }}</small></span></label></div></div>
|
||||
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次运行调用网络工具</label>
|
||||
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div>
|
||||
</form>
|
||||
|
||||
<div v-else class="trace-layout">
|
||||
<div class="panel run-summary"><div><span class="badge info">{{ runStatusLabel(agentStore.activeRun?.status) }}</span><h2>{{ agentStore.activeRunId }}</h2></div><div class="inline-actions"><span>步骤 {{ agentStore.currentStep }} / {{ agentStore.activeRun?.max_steps }}</span><button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">取消运行</button></div></div>
|
||||
<div class="timeline">
|
||||
<article v-for="event in agentStore.events" :key="event.sequence" class="event-card item-card">
|
||||
<div class="event-head"><span class="badge" :class="{ success: event.event === 'RunCompleted', error: event.event === 'RunFailed', warning: event.event === 'PermissionRequired' }">{{ eventLabel(event.event) }}</span><span>第 {{ event.sequence }} 条 · {{ new Date(event.timestamp).toLocaleTimeString() }}</span></div>
|
||||
<p v-if="eventText(event)" class="event-text">{{ eventText(event) }}</p>
|
||||
<pre v-if="['ToolCall', 'ToolResult', 'Citation', 'Usage'].includes(event.event)">{{ JSON.stringify(localizeDetails(event.data), null, 2) }}</pre>
|
||||
</article>
|
||||
<div v-if="!agentStore.events.length" class="empty-state"><div><strong>等待执行轨迹</strong><p>事件连接建立后将在这里实时显示。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="agentStore.permissionRequest" class="modal-backdrop">
|
||||
<div class="modal"><span class="badge warning">权限确认</span><h2>{{ toolLabel(agentStore.permissionRequest.tool_name) }}</h2><p>{{ agentStore.permissionRequest.impact }}</p><p class="subtle">所需权限:{{ permissionLabel(agentStore.permissionRequest.permission) }}({{ agentStore.permissionRequest.permission }})</p><pre>{{ JSON.stringify(localizeDetails(agentStore.permissionRequest.parameters), null, 2) }}</pre><div class="inline-actions permission-actions"><button class="button-primary" @click="agentStore.respondPermission('allow', 'once')">仅本次允许</button><button class="button-secondary" @click="agentStore.respondPermission('allow', 'session')">本次会话允许</button><button class="button-danger" @click="agentStore.respondPermission('deny')">拒绝</button></div></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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-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 { 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; }
|
||||
.permission-actions { margin-top: var(--space-lg); }
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { runStatusLabel } from './labels'
|
||||
|
||||
const agentStore = useAgentStore()
|
||||
const router = useRouter()
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : '运行记录加载失败' }
|
||||
})
|
||||
|
||||
function selectRun(runId: string) { void router.push({ name: 'agent', params: { runId } }) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-panel">
|
||||
<button class="button-primary new-button" @click="router.push({ name: 'agent' })">+ 新建运行</button>
|
||||
<p v-if="error" class="subtle error-text">{{ error }}</p>
|
||||
<div class="sidebar-list">
|
||||
<button v-for="run in agentStore.sortedRuns" :key="run.run_id" class="sidebar-list-item run-item"
|
||||
:class="{ active: agentStore.activeRunId === run.run_id }" @click="selectRun(run.run_id)">
|
||||
<span class="badge" :class="{ success: run.status === 'completed', error: run.status === 'failed', warning: run.status === 'waiting_permission' }">{{ runStatusLabel(run.status) }}</span>
|
||||
<strong>{{ run.run_id.slice(0, 12) }}</strong><small>{{ run.started_at ? new Date(run.started_at).toLocaleString() : '等待开始' }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.new-button { width: 100%; margin-bottom: var(--space-md); }
|
||||
.run-item { display: grid; gap: 3px; width: 100%; text-align: left; }
|
||||
.run-item .badge { justify-self: start; }
|
||||
.run-item small { color: var(--color-text-tertiary); }
|
||||
.error-text { margin-bottom: var(--space-sm); color: var(--color-error); }
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
eventLabel,
|
||||
localizeDetails,
|
||||
permissionLabel,
|
||||
runStatusLabel,
|
||||
toolDescription,
|
||||
toolLabel,
|
||||
} from './labels'
|
||||
|
||||
describe('智能体页面中文标签', () => {
|
||||
it('转换运行状态和事件名称', () => {
|
||||
expect(runStatusLabel('waiting_permission')).toBe('等待授权')
|
||||
expect(eventLabel('ToolCall')).toBe('调用工具')
|
||||
})
|
||||
|
||||
it('转换工具、权限和工具说明', () => {
|
||||
expect(toolLabel('notes.search')).toBe('搜索笔记')
|
||||
expect(permissionLabel('notes.write')).toBe('修改笔记')
|
||||
expect(toolDescription('math.add', 'fallback')).toContain('两个数')
|
||||
expect(toolLabel('custom.tool')).toBe('custom.tool')
|
||||
})
|
||||
|
||||
it('递归转换事件详情中的键名、状态和布尔值', () => {
|
||||
expect(localizeDetails({
|
||||
tool_call_id: 'call-1',
|
||||
success: true,
|
||||
result: { status: 'completed' },
|
||||
})).toEqual({
|
||||
'工具调用 ID': 'call-1',
|
||||
'是否成功': '是',
|
||||
'结果': { '状态': '已完成' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { AgentEventType, AgentRunStatus } from '@/contracts'
|
||||
|
||||
const runStatusLabels: Record<AgentRunStatus, string> = {
|
||||
queued: '排队中',
|
||||
running: '运行中',
|
||||
waiting_permission: '等待授权',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
|
||||
const eventLabels: Record<AgentEventType, string> = {
|
||||
RunStarted: '运行开始',
|
||||
TextDelta: '回复内容',
|
||||
ThinkingDelta: '思考过程',
|
||||
ToolCall: '调用工具',
|
||||
ToolResult: '工具结果',
|
||||
PermissionRequired: '请求权限',
|
||||
Usage: '用量统计',
|
||||
Citation: '引用来源',
|
||||
ModelCallStarted: '模型调用开始',
|
||||
ModelCallCompleted: '模型调用完成',
|
||||
ModelCallFailed: '模型调用失败',
|
||||
PermissionResolved: '权限已处理',
|
||||
RunCompleted: '运行完成',
|
||||
RunFailed: '运行失败',
|
||||
RunCancelled: '运行取消',
|
||||
}
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
'system.echo': '回显测试',
|
||||
'math.add': '数值相加',
|
||||
'notes.search': '搜索笔记',
|
||||
'rag.search': '知识检索',
|
||||
'notes.read': '读取笔记',
|
||||
'notes.create': '创建笔记',
|
||||
'notes.update': '更新笔记',
|
||||
'notes.list': '列出笔记',
|
||||
'notes.move': '移动笔记',
|
||||
'tasks.create': '创建任务',
|
||||
'tasks.update': '更新任务',
|
||||
'tasks.list': '列出任务',
|
||||
'attachments.read': '读取附件',
|
||||
'audio.transcribe': '音频转写',
|
||||
}
|
||||
|
||||
const toolDescriptions: Record<string, string> = {
|
||||
'system.echo': '回显文本,用于本地智能体集成测试。',
|
||||
'math.add': '计算两个数的和,不产生外部副作用。',
|
||||
'notes.search': '搜索已建立索引的笔记,并返回摘要和引用。',
|
||||
'rag.search': '检索与当前任务相关的笔记内容块和引用。',
|
||||
'notes.read': '根据笔记 ID 读取笔记及其内容块。',
|
||||
'notes.create': '在当前知识库中创建 Markdown 笔记。',
|
||||
'notes.update': '更新已有 Markdown 笔记。',
|
||||
'notes.list': '按文件夹和标签筛选并列出笔记摘要。',
|
||||
'notes.move': '移动笔记到其他文件夹并保留笔记 ID。',
|
||||
'tasks.create': '创建并持久化任务。',
|
||||
'tasks.update': '更新已有任务。',
|
||||
'tasks.list': '列出已持久化的任务。',
|
||||
'attachments.read': '读取由宿主管理的 UTF-8 附件。',
|
||||
'audio.transcribe': '读取音频附件已有的宿主转写结果。',
|
||||
}
|
||||
|
||||
const permissionLabels: Record<string, string> = {
|
||||
'notes.search': '搜索笔记',
|
||||
'notes.read': '读取笔记',
|
||||
'notes.write': '修改笔记',
|
||||
'tasks.read': '读取任务',
|
||||
'tasks.write': '修改任务',
|
||||
'attachments.read': '读取附件',
|
||||
'network.request': '访问网络',
|
||||
'secrets.use': '使用密钥',
|
||||
}
|
||||
|
||||
const detailLabels: Record<string, string> = {
|
||||
tool_call_id: '工具调用 ID',
|
||||
name: '工具名称',
|
||||
arguments: '参数',
|
||||
parameters: '参数',
|
||||
success: '是否成功',
|
||||
output: '输出',
|
||||
result: '结果',
|
||||
error_code: '错误代码',
|
||||
error_message: '错误信息',
|
||||
note_id: '笔记 ID',
|
||||
block_id: '内容块 ID',
|
||||
heading_path: '标题路径',
|
||||
input_tokens: '输入令牌',
|
||||
output_tokens: '输出令牌',
|
||||
total_tokens: '令牌总数',
|
||||
status: '状态',
|
||||
duration_ms: '耗时(毫秒)',
|
||||
model_call_id: '模型调用 ID',
|
||||
parent_model_call_id: '上级模型调用 ID',
|
||||
finish_reason: '结束原因',
|
||||
decision: '授权决定',
|
||||
}
|
||||
|
||||
export function runStatusLabel(status?: AgentRunStatus): string {
|
||||
return status ? runStatusLabels[status] : '未知状态'
|
||||
}
|
||||
|
||||
export function eventLabel(event: AgentEventType): string {
|
||||
return eventLabels[event]
|
||||
}
|
||||
|
||||
export function toolLabel(name: string): string {
|
||||
return toolLabels[name] ?? name
|
||||
}
|
||||
|
||||
export function toolDescription(name: string, fallback: string): string {
|
||||
return toolDescriptions[name] ?? fallback
|
||||
}
|
||||
|
||||
export function permissionLabel(permission: string): string {
|
||||
return permissionLabels[permission] ?? permission
|
||||
}
|
||||
|
||||
function localizeValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(localizeValue)
|
||||
if (value && typeof value === 'object') return localizeDetails(value as Record<string, unknown>)
|
||||
if (value === true) return '是'
|
||||
if (value === false) return '否'
|
||||
if (typeof value === 'string' && value in runStatusLabels) {
|
||||
return runStatusLabels[value as AgentRunStatus]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function localizeDetails(data: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).map(([key, value]) => [detailLabels[key] ?? key, localizeValue(value)])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Citation } from '@/contracts'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const providerStore = useProviderStore()
|
||||
const skillStore = useSkillStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
const router = useRouter()
|
||||
const loadError = ref('')
|
||||
|
||||
const availableModels = computed(() => providerStore.modelsByProvider[chatStore.selectedProviderId] ?? [])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()])
|
||||
await providerStore.loadModels(chatStore.selectedProviderId)
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,当前展示本地数据。'
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => chatStore.selectedProviderId, async (providerId) => {
|
||||
try {
|
||||
await providerStore.loadModels(providerId)
|
||||
const firstModel = providerStore.modelsByProvider[providerId]?.[0]
|
||||
if (firstModel) chatStore.selectedModel = firstModel.model_id
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '模型列表加载失败'
|
||||
}
|
||||
})
|
||||
|
||||
function send() { void chatStore.sendMessage(chatStore.inputText) }
|
||||
|
||||
async function openCitation(citation: Citation) {
|
||||
await editorStore.loadFile(citation.file_path)
|
||||
workspaceStore.openFile(citation.file_path)
|
||||
editorStore.highlightBlock(citation.block_id)
|
||||
await router.push('/workspace')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="chat-page">
|
||||
<header class="chat-toolbar">
|
||||
<div class="field compact"><label>Provider</label><select v-model="chatStore.selectedProviderId" class="select">
|
||||
<option v-for="provider in providerStore.enabledProviders" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }}</option>
|
||||
</select></div>
|
||||
<div class="field compact"><label>Model</label><select v-model="chatStore.selectedModel" class="select">
|
||||
<option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option>
|
||||
</select></div>
|
||||
<div class="field compact"><label>Skill</label><select v-model="chatStore.selectedSkillId" class="select">
|
||||
<option :value="null">不使用 Skill</option><option v-for="skill in skillStore.enabledSkills" :key="skill.skill_id" :value="skill.skill_id">{{ skill.name }}</option>
|
||||
</select></div>
|
||||
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" /> 使用知识库</label>
|
||||
</header>
|
||||
<div v-if="loadError" class="error-banner chat-error">{{ loadError }}</div>
|
||||
<main class="message-timeline">
|
||||
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>开始一段知识对话</strong><p>可以直接提问,也可以打开 RAG 让模型基于当前 Vault 回答。</p></div></div>
|
||||
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
|
||||
<div class="avatar">{{ message.role === 'user' ? '你' : 'AI' }}</div>
|
||||
<div class="message-body">
|
||||
<details v-if="message.thinking" class="thinking"><summary>思考过程</summary><p>{{ message.thinking }}</p></details>
|
||||
<MarkdownContent v-if="message.content" class="message-content" :source="message.content" />
|
||||
<div v-else-if="chatStore.isStreaming" class="message-content">正在思考…</div>
|
||||
<div v-if="message.tool_calls?.length" class="tool-calls"><div v-for="call in message.tool_calls" :key="call.tool_call_id" class="item-card"><span class="badge info">{{ call.status }}</span><strong>{{ call.name }}</strong><pre>{{ JSON.stringify(call.parameters, null, 2) }}</pre></div></div>
|
||||
<div v-if="message.citations?.length" class="citations">
|
||||
<button v-for="(citation, index) in message.citations" :key="citation.block_id" class="citation-card" @click="openCitation(citation)">
|
||||
<span class="badge info">{{ index + 1 }}</span><span><strong>{{ citation.heading_path || citation.file_path }}</strong><small>{{ citation.content }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
<time>{{ new Date(message.created_at).toLocaleTimeString() }}</time>
|
||||
<small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}(输入 {{ message.usage.input_tokens }} / 输出 {{ message.usage.output_tokens }})</small>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<footer class="composer">
|
||||
<textarea v-model="chatStore.inputText" class="textarea" placeholder="输入问题,Ctrl + Enter 发送"
|
||||
@keydown.ctrl.enter.prevent="send" />
|
||||
<div class="composer-actions"><span class="subtle">回答可能包含错误,请核对 Citation。</span>
|
||||
<button v-if="chatStore.isStreaming" class="button-danger" @click="chatStore.stopGeneration">停止</button>
|
||||
<button v-else class="button-primary" :disabled="!chatStore.inputText.trim()" @click="send">发送</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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); 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-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-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>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-panel">
|
||||
<button class="button-primary new-button" @click="chatStore.createNewConversation">+ 新对话</button>
|
||||
<div class="sidebar-list conversation-list">
|
||||
<div v-for="conversation in chatStore.sortedConversations" :key="conversation.conversation_id"
|
||||
class="sidebar-list-item conversation" :class="{ active: chatStore.activeConversationId === conversation.conversation_id }"
|
||||
@click="chatStore.setActiveConversation(conversation.conversation_id)">
|
||||
<div><strong>{{ conversation.title }}</strong><p>{{ conversation.message_count }} 条消息</p></div>
|
||||
<button class="delete" title="删除会话" @click.stop="chatStore.deleteConversation(conversation.conversation_id)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.new-button { width: 100%; margin-bottom: var(--space-md); }
|
||||
.conversation-list { gap: var(--space-xs); }
|
||||
.conversation { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); }
|
||||
.conversation strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-sm); }
|
||||
.conversation p { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
.delete { padding: var(--space-xs); color: var(--color-text-tertiary); }
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
idle: '空闲', dirty: '未保存', saving: '保存中…', saved: '已保存', save_failed: '保存失败',
|
||||
external_changed: '外部文件已变化', conflict: '存在编辑冲突',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="editor-header">
|
||||
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? '未命名笔记' }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
|
||||
<div class="editor-actions">
|
||||
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
|
||||
<div class="mode-switch" aria-label="编辑模式">
|
||||
<button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">写作</button>
|
||||
<button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">源码</button>
|
||||
</div>
|
||||
<button type="button" class="save-button" :disabled="editorStore.saveStatus === 'saving'" @click="editorStore.save">保存</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-lg);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.file-identity { display: grid; min-width: 0; }
|
||||
.file-identity strong, .file-identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-identity small { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
.editor-actions, .mode-switch { display: flex; align-items: center; gap: var(--space-sm); }
|
||||
.save-status { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
.save-status.dirty, .save-status.external_changed { color: var(--color-warning); }
|
||||
.save-status.save_failed, .save-status.conflict { color: var(--color-error); }
|
||||
.save-status.saved { color: var(--color-success); }
|
||||
.mode-switch { gap: 2px; padding: 2px; border-radius: var(--radius-md); background: var(--color-background-secondary); }
|
||||
.mode-switch button, .save-button { padding: 5px 9px; border-radius: var(--radius-sm); }
|
||||
.mode-switch button.active { background: var(--color-surface-primary); color: var(--color-accent-primary); box-shadow: var(--shadow-sm); }
|
||||
.save-button { background: var(--color-accent-primary); color: var(--color-text-inverse); }
|
||||
.save-button:disabled { opacity: .55; }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment happy-dom
|
||||
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
|
||||
|
||||
async function waitForText(text: string) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (wrapper?.text().includes(text)) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error(`Editor did not render ${text}`)
|
||||
}
|
||||
|
||||
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('/欢迎使用 NotesAgent.md')
|
||||
wrapper = mount(EditorPane, { attachTo: document.body })
|
||||
await waitForText('欢迎使用 NotesAgent')
|
||||
|
||||
await store.loadFile('/数据结构/红黑树.md')
|
||||
await nextTick()
|
||||
await waitForText('红黑树')
|
||||
|
||||
expect(store.currentFilePath).toBe('/数据结构/红黑树.md')
|
||||
expect(wrapper.text()).not.toContain('祝你写作愉快')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
<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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-pane {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: var(--space-xl);
|
||||
background: var(--color-background-primary);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
line-height: 1.7;
|
||||
user-select: text;
|
||||
}
|
||||
.editor-pane.source { font-family: var(--font-editor-mono); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { editorViewCtx, type Editor } from '@milkdown/kit/core'
|
||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { getMarkdown } from '@milkdown/kit/utils'
|
||||
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
||||
|
||||
type EditorComponent = { getEditor: () => Editor | undefined }
|
||||
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
async function waitForEditor(wrapper: VueWrapper): Promise<Editor> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const editor = (wrapper.vm as unknown as EditorComponent).getEditor()
|
||||
if (editor) {
|
||||
try {
|
||||
editor.action(getMarkdown())
|
||||
return editor
|
||||
} catch { /* editor is still creating */ }
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error('Milkdown editor did not become ready')
|
||||
}
|
||||
|
||||
function selectText(editor: Editor, from: number, to: number) {
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, from, to)))
|
||||
view.focus()
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mounted.splice(0).forEach((wrapper) => wrapper.unmount())
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
it('applies bold from the top toolbar to the selected text', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
selectText(editor, 1, 6)
|
||||
|
||||
await wrapper.get('[aria-label="加粗"]').trigger('pointerdown')
|
||||
|
||||
expect(editor.action(getMarkdown())).toContain('**alpha** beta')
|
||||
})
|
||||
|
||||
it('applies italic from the floating toolbar to the selected text', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
selectText(editor, 1, 6)
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
const floatingItalic = document.querySelector<HTMLButtonElement>('.milkdown-toolbar [data-toolbar-item="italic"]')
|
||||
expect(floatingItalic).not.toBeNull()
|
||||
floatingItalic?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(editor.action(getMarkdown())).toContain('*alpha* beta')
|
||||
})
|
||||
|
||||
it('writes a custom input font size into markdown for the selected text', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
selectText(editor, 1, 6)
|
||||
|
||||
await wrapper.get('[aria-label="自定义字号"]').setValue(22)
|
||||
await wrapper.get('[aria-label="应用自定义字号"]').trigger('pointerdown')
|
||||
|
||||
expect(editor.action(getMarkdown())).toContain('<span style="font-size: 22px">alpha</span> beta')
|
||||
})
|
||||
|
||||
it('turns a heading back into a normal paragraph', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# alpha' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
|
||||
await wrapper.get('[aria-label="标题级别"]').setValue('paragraph')
|
||||
|
||||
expect(editor.action(getMarkdown()).trim()).toBe('alpha')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
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,
|
||||
toggleInlineCodeCommand,
|
||||
toggleLinkCommand,
|
||||
toggleStrongCommand,
|
||||
turnIntoTextCommand,
|
||||
wrapInBulletListCommand,
|
||||
wrapInHeadingCommand,
|
||||
wrapInOrderedListCommand,
|
||||
} from '@milkdown/kit/preset/commonmark'
|
||||
import { commandsCtx, editorViewCtx } from '@milkdown/kit/core'
|
||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||
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'
|
||||
|
||||
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)
|
||||
let crepe: Crepe | null = null
|
||||
|
||||
type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inline-code' | 'code-block' | 'inline-math' | 'math-block'
|
||||
|
||||
function runCommand(command: ToolbarCommand) {
|
||||
const editor = crepe?.editor
|
||||
if (!editor) return
|
||||
// 顶部工具栏复用 Milkdown 命令,因此选区与浮动工具栏共享同一文档事务。
|
||||
const actions = {
|
||||
bold: callCommand(toggleStrongCommand.key),
|
||||
italic: callCommand(toggleEmphasisCommand.key),
|
||||
'ordered-list': callCommand(wrapInOrderedListCommand.key),
|
||||
'bullet-list': callCommand(wrapInBulletListCommand.key),
|
||||
'inline-code': callCommand(toggleInlineCodeCommand.key),
|
||||
'code-block': callCommand(createCodeBlockCommand.key, ''),
|
||||
'inline-math': callCommand('ToggleLatex'),
|
||||
'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'),
|
||||
}
|
||||
editor.action(actions[command])
|
||||
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
|
||||
}
|
||||
|
||||
function applyLink() {
|
||||
if (!crepe) return
|
||||
// TODO(editor): 用受控 Element Plus 对话框替换 prompt,补充 URL 校验和键盘焦点管理。
|
||||
const href = window.prompt('请输入链接地址', 'https://')?.trim()
|
||||
if (!href) return
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const commands = ctx.get(commandsCtx)
|
||||
if (view.state.selection.empty) {
|
||||
const label = window.prompt('请输入链接文字', href)?.trim() || href
|
||||
const from = view.state.selection.from
|
||||
const transaction = view.state.tr.insertText(label, from)
|
||||
transaction.setSelection(TextSelection.create(transaction.doc, from, from + label.length))
|
||||
view.dispatch(transaction)
|
||||
}
|
||||
return commands.call(toggleLinkCommand.key, { href })
|
||||
})
|
||||
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
|
||||
}
|
||||
|
||||
function applyHeading(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
if (!value || !crepe) return
|
||||
crepe.editor.action(value === 'paragraph'
|
||||
? callCommand(turnIntoTextCommand.key)
|
||||
: callCommand(wrapInHeadingCommand.key, Number(value)))
|
||||
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
|
||||
;(event.target as HTMLSelectElement).value = ''
|
||||
}
|
||||
|
||||
function applyFontSize(event: Event) {
|
||||
const size = Number((event.target as HTMLSelectElement).value)
|
||||
if (!size || !crepe) return
|
||||
fontSizeInput.value = size
|
||||
applyFontSizeValue()
|
||||
;(event.target as HTMLSelectElement).value = ''
|
||||
}
|
||||
|
||||
function applyFontSizeValue() {
|
||||
if (!crepe) return
|
||||
const size = Math.min(96, Math.max(8, Math.round(Number(fontSizeInput.value))))
|
||||
if (!Number.isFinite(size)) return
|
||||
fontSizeInput.value = size
|
||||
applyMarkdownFontSize(crepe.editor, size)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
crepe = new Crepe({
|
||||
root: editorRoot.value,
|
||||
defaultValue: props.initialContent,
|
||||
features: { [Crepe.Feature.TopBar]: false },
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Placeholder]: { text: '开始记录你的想法…' },
|
||||
[Crepe.Feature.CodeMirror]: {
|
||||
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
|
||||
previewOnlyByDefault: false,
|
||||
searchPlaceholder: '搜索语言',
|
||||
noResultText: '没有匹配的语言',
|
||||
copyText: '复制',
|
||||
},
|
||||
[Crepe.Feature.Latex]: {
|
||||
inlineEditConfirm: '确认',
|
||||
},
|
||||
[Crepe.Feature.LinkTooltip]: {
|
||||
editButton: '编辑',
|
||||
removeButton: '移除',
|
||||
confirmButton: '确认',
|
||||
inputPlaceholder: '粘贴链接地址…',
|
||||
},
|
||||
[Crepe.Feature.Toolbar]: {
|
||||
boldLabel: '加粗',
|
||||
italicLabel: '斜体',
|
||||
strikethroughLabel: '删除线',
|
||||
codeLabel: '行内代码',
|
||||
latexLabel: '行内公式',
|
||||
linkLabel: '链接',
|
||||
},
|
||||
[Crepe.Feature.BlockEdit]: {
|
||||
textGroup: {
|
||||
label: '文本',
|
||||
text: { label: '正文' },
|
||||
h1: { label: '一级标题' },
|
||||
h2: { label: '二级标题' },
|
||||
h3: { label: '三级标题' },
|
||||
h4: { label: '四级标题' },
|
||||
h5: { label: '五级标题' },
|
||||
h6: { label: '六级标题' },
|
||||
quote: { label: '引用' },
|
||||
divider: { label: '分割线' },
|
||||
},
|
||||
listGroup: {
|
||||
label: '列表',
|
||||
bulletList: { label: '无序列表' },
|
||||
orderedList: { label: '有序列表' },
|
||||
taskList: { label: '任务列表' },
|
||||
},
|
||||
advancedGroup: {
|
||||
label: '插入',
|
||||
image: { label: '图片' },
|
||||
codeBlock: { label: '代码块' },
|
||||
table: { label: '表格' },
|
||||
math: { label: '公式块' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
await crepe.create()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { void crepe?.destroy() })
|
||||
|
||||
defineExpose({ getEditor: () => crepe?.editor })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="visual-editor">
|
||||
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式工具栏">
|
||||
<label class="toolbar-select heading-select" title="设置标题级别">
|
||||
<span class="format-glyph heading-glyph">H</span>
|
||||
<select aria-label="标题级别" @change="applyHeading">
|
||||
<option value="" selected>标题</option>
|
||||
<option value="paragraph">正文</option>
|
||||
<option v-for="level in 6" :key="level" :value="level">H{{ level }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" title="加粗 (Ctrl+B)" aria-label="加粗" @pointerdown.prevent="runCommand('bold')"><strong class="format-glyph">B</strong></button>
|
||||
<button type="button" title="斜体 (Ctrl+I)" aria-label="斜体" @pointerdown.prevent="runCommand('italic')"><em class="format-glyph">I</em></button>
|
||||
<span class="toolbar-divider" />
|
||||
<button type="button" class="list-glyph" title="有序列表" aria-label="有序列表" @pointerdown.prevent="runCommand('ordered-list')"><span class="list-marker">1</span><span class="list-lines">☰</span></button>
|
||||
<button type="button" class="list-glyph" title="无序列表" aria-label="无序列表" @pointerdown.prevent="runCommand('bullet-list')"><span class="list-marker">•</span><span class="list-lines">☰</span></button>
|
||||
<span class="toolbar-divider" />
|
||||
<label class="toolbar-select font-size-select" title="选择预设字号">
|
||||
<span class="format-glyph font-size-glyph">A</span>
|
||||
<select aria-label="文字字号" @change="applyFontSize">
|
||||
<option value="" selected>字号</option>
|
||||
<option v-for="size in [12, 14, 16, 18, 20, 24, 28, 32]" :key="size" :value="size">{{ size }} px</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="font-size-input" title="输入字号后按 Enter 或点击应用">
|
||||
<input v-model.number="fontSizeInput" type="number" min="8" max="96" step="1" aria-label="自定义字号"
|
||||
@keydown.enter.prevent="applyFontSizeValue" />
|
||||
<span>px</span>
|
||||
<button type="button" aria-label="应用自定义字号" @pointerdown.prevent="applyFontSizeValue">应用</button>
|
||||
</div>
|
||||
<span class="toolbar-divider" />
|
||||
<button type="button" title="行内代码" aria-label="行内代码" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph"></></code></button>
|
||||
<button type="button" title="代码块" aria-label="代码块" @pointerdown.prevent="runCommand('code-block')"><span class="block-glyph">{ }</span></button>
|
||||
<button type="button" title="行内公式" aria-label="行内公式" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
|
||||
<button type="button" title="公式块" aria-label="公式块" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph">∑</span></button>
|
||||
<button type="button" title="插入链接" aria-label="插入链接" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
|
||||
</div>
|
||||
<div v-if="loading" class="editor-loading">正在加载编辑器…</div>
|
||||
<div ref="editorRoot" class="milkdown-host" :class="{ loading }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); }
|
||||
.markdown-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-height: 42px; padding: 5px var(--space-lg); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-primary); }
|
||||
.markdown-toolbar button { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
|
||||
.markdown-toolbar button:hover, .toolbar-select:hover { background: var(--color-background-hover); color: var(--color-text-primary); }
|
||||
.markdown-toolbar button:focus-visible, .toolbar-select:focus-within { outline: 2px solid var(--color-border-focus); outline-offset: 1px; }
|
||||
.format-glyph { font-family: Georgia, 'Times New Roman', serif; font-size: 17px; line-height: 1; }
|
||||
.heading-glyph { font-weight: 800; }
|
||||
.font-size-glyph { font-size: 18px; }
|
||||
.list-glyph { grid-template-columns: 8px 14px; column-gap: 2px; font-weight: 700; }
|
||||
.list-marker { font: 700 12px/1 var(--font-ui-sans); }
|
||||
.list-lines { overflow: hidden; width: 14px; font-size: 15px; line-height: 1; transform: scaleX(1.2); }
|
||||
.code-glyph, .block-glyph { padding: 0; background: transparent; color: inherit; font: 700 13px/1 var(--font-editor-mono); }
|
||||
.math-glyph { font: italic 700 16px/1 Georgia, 'Times New Roman', serif; }
|
||||
.toolbar-select { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 3px 5px 3px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
|
||||
.toolbar-select select { min-width: 58px; border: 0; outline: 0; background: transparent; color: inherit; cursor: pointer; font-size: var(--font-size-sm); }
|
||||
.font-size-select select { min-width: 62px; }
|
||||
.font-size-input { display: inline-flex; align-items: center; height: 30px; margin-left: 2px; overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-secondary); background: var(--color-background-primary); }
|
||||
.font-size-input:focus-within { border-color: var(--color-border-focus); box-shadow: 0 0 0 1px var(--color-border-focus); }
|
||||
.font-size-input input { width: 42px; height: 100%; padding-left: 7px; border: 0; outline: 0; background: transparent; color: var(--color-text-primary); }
|
||||
.font-size-input span { font-size: var(--font-size-xs); }
|
||||
.font-size-input button { min-width: auto; min-height: 100%; margin-left: 4px; padding: 3px 7px; border-left: 1px solid var(--color-border-default); border-radius: 0; font-size: var(--font-size-xs); }
|
||||
.toolbar-divider { width: 1px; height: 20px; margin: 0 var(--space-xs); background: var(--color-border-default); }
|
||||
.milkdown-host { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); }
|
||||
.milkdown-host.loading { visibility: hidden; }
|
||||
.editor-loading { padding: var(--space-xl); color: var(--color-text-tertiary); }
|
||||
.milkdown-host :deep(.milkdown) {
|
||||
min-height: 100%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
--crepe-color-background: var(--color-background-primary);
|
||||
--crepe-color-on-background: var(--color-text-primary);
|
||||
--crepe-color-surface: var(--color-surface-primary);
|
||||
--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-markdown-grid);
|
||||
--crepe-color-primary: var(--color-accent-primary);
|
||||
--crepe-color-secondary: var(--color-accent-soft);
|
||||
--crepe-color-on-secondary: var(--color-text-primary);
|
||||
--crepe-color-inverse: var(--color-text-primary);
|
||||
--crepe-color-on-inverse: var(--color-background-primary);
|
||||
--crepe-color-inline-code: var(--color-error);
|
||||
--crepe-color-error: var(--color-error);
|
||||
--crepe-color-hover: var(--color-background-hover);
|
||||
--crepe-color-selected: var(--color-accent-soft);
|
||||
--crepe-color-inline-area: var(--color-background-tertiary);
|
||||
--crepe-font-default: var(--font-editor-sans);
|
||||
--crepe-font-code: var(--font-editor-mono);
|
||||
}
|
||||
.milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); }
|
||||
.milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); }
|
||||
.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; }
|
||||
.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 .milkdown) { color-scheme: dark; }
|
||||
@media (max-width: 680px) { .toolbar-select select { min-width: 46px; width: 46px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { $prose } from '@milkdown/kit/utils'
|
||||
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
|
||||
const openingTag = /^<span style="font-size:\s*(\d+(?:\.\d+)?)px">$/i
|
||||
const closingTag = /^<\/span>$/i
|
||||
|
||||
export const fontSizeMarkdownPlugin = $prose(() => new Plugin({
|
||||
props: {
|
||||
decorations(state) {
|
||||
const decorations: Decoration[] = []
|
||||
const stack: Array<{ from: number; size: string }> = []
|
||||
|
||||
state.doc.descendants((node, position) => {
|
||||
if (node.type.name !== 'html') return
|
||||
const value = String(node.attrs.value ?? '')
|
||||
const match = value.match(openingTag)
|
||||
|
||||
if (match) {
|
||||
stack.push({ from: position + node.nodeSize, size: match[1] })
|
||||
decorations.push(Decoration.node(position, position + node.nodeSize, { class: 'font-size-marker' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (closingTag.test(value)) {
|
||||
const opening = stack.pop()
|
||||
decorations.push(Decoration.node(position, position + node.nodeSize, { class: 'font-size-marker' }))
|
||||
if (opening && opening.from < position) {
|
||||
decorations.push(Decoration.inline(opening.from, position, {
|
||||
style: `font-size: ${opening.size}px`,
|
||||
'data-font-size': opening.size,
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
export function applyMarkdownFontSize(editor: Editor, size: number): boolean {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const { from, to, empty } = view.state.selection
|
||||
if (empty) return false
|
||||
|
||||
const htmlNode = view.state.schema.nodes.html
|
||||
if (!htmlNode) return false
|
||||
|
||||
const opening = htmlNode.create({ value: `<span style="font-size: ${size}px">` })
|
||||
const closing = htmlNode.create({ value: '</span>' })
|
||||
const transaction = view.state.tr
|
||||
.insert(to, closing)
|
||||
.insert(from, opening)
|
||||
transaction.setSelection(TextSelection.create(transaction.doc, from + 1, to + 1))
|
||||
|
||||
view.dispatch(transaction)
|
||||
view.focus()
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { Connection } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
|
||||
const pluginStore = usePluginStore()
|
||||
const actionError = ref('')
|
||||
onMounted(() => { void pluginStore.loadPlugins() })
|
||||
|
||||
async function install() { const path = prompt('请输入 Plugin Package 路径')?.trim(); if (!path) return; try { await pluginStore.installPlugin(path) } catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' } }
|
||||
async function toggle(id: string, enabled: boolean) { try { enabled ? await pluginStore.disablePlugin(id) : await pluginStore.enablePlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' } }
|
||||
async function grant(id: string, permissions: string[]) { if (!confirm(`将授权:${permissions.join('、')}。是否继续?`)) return; try { await pluginStore.grantPermissions(id, permissions) } catch (error) { actionError.value = error instanceof Error ? error.message : '授权失败' } }
|
||||
async function uninstall(id: string, name: string) { if (!confirm(`卸载“${name}”将移除其全部 Contribution,是否继续?`)) return; try { await pluginStore.uninstallPlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<header class="feature-header"><div><h1>Plugin 管理</h1><p>管理插件生命周期、权限和受控 Contribution。</p></div><button class="button-primary" @click="install">安装 Plugin</button></header>
|
||||
<div v-if="pluginStore.error || actionError" class="error-banner">{{ pluginStore.error || actionError }}</div>
|
||||
<div v-if="pluginStore.selectedPlugin" class="panel">
|
||||
<div class="detail-head"><div><span class="badge" :class="{ success: pluginStore.selectedPlugin.status === 'ready', error: pluginStore.selectedPlugin.status === 'error', warning: pluginStore.selectedPlugin.status === 'permission_required' }">{{ pluginStore.selectedPlugin.status }}</span><h2>{{ pluginStore.selectedPlugin.icon }} {{ pluginStore.selectedPlugin.name }}</h2><p class="muted">v{{ pluginStore.selectedPlugin.version }} · {{ pluginStore.selectedPlugin.backend_type || 'none' }}/{{ pluginStore.selectedPlugin.transport || 'none' }}</p></div><div class="inline-actions"><button v-if="pluginStore.selectedPlugin.status === 'permission_required'" class="button-primary" @click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)">授权权限</button><button class="button-secondary" @click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)">{{ pluginStore.selectedPlugin.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)">卸载</button></div></div>
|
||||
<p class="description">{{ pluginStore.selectedPlugin.description }}</p>
|
||||
<div class="detail-grid"><div><h3>权限</h3><div class="tag-list"><span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>Contribution</h3><div class="contribution-list"><div v-for="item in pluginStore.selectedPlugin.contributions" :key="item.id" class="item-card"><span class="badge info">{{ item.type }}</span><strong>{{ item.name }}</strong><p class="subtle">{{ item.description || item.id }}</p></div></div></div></div>
|
||||
<div v-if="pluginStore.selectedPlugin.last_error" class="error-banner last-error">{{ pluginStore.selectedPlugin.last_error }}</div>
|
||||
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">依赖此插件的 Skill:{{ pluginStore.selectedPlugin.dependent_skills.join('、') }}</div>
|
||||
</div>
|
||||
<div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><AppIcon :icon="Connection" :size="22" /><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} 项 Contribution</p></article></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-head, .extension-title { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-md); }
|
||||
.detail-head h2 { margin-top: var(--space-sm); }
|
||||
.description { margin: var(--space-xl) 0; line-height: var(--line-height-relaxed); }
|
||||
.detail-grid { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(320px, 1.3fr); gap: var(--space-xl); }
|
||||
.detail-grid h3 { margin-bottom: var(--space-sm); }
|
||||
.contribution-list { display: grid; gap: var(--space-sm); }
|
||||
.contribution-list .item-card { display: grid; gap: var(--space-xs); }
|
||||
.last-error { margin: var(--space-xl) 0 0; }
|
||||
.extension-card { cursor: pointer; }
|
||||
.extension-card > p { margin-top: var(--space-md); }
|
||||
.extension-title { align-items: center; }
|
||||
.extension-title .icon { font-size: 28px; }
|
||||
.extension-title p { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
@media (max-width: 800px) { .detail-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { useSearchStore } from '@/stores/search'
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
const modes = [
|
||||
{ value: 'hybrid', label: '混合检索' },
|
||||
{ value: 'fts', label: '全文检索' },
|
||||
{ value: 'vector', label: '向量检索' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-panel">
|
||||
<p class="subtle">检索模式</p>
|
||||
<div class="sidebar-list mode-list">
|
||||
<button v-for="item in modes" :key="item.value" class="sidebar-list-item"
|
||||
:class="{ active: searchStore.mode === item.value }" @click="searchStore.setMode(item.value)">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="subtle section-title">最近搜索</p>
|
||||
<div class="sidebar-list">
|
||||
<button v-for="query in searchStore.recentQueries" :key="query" class="sidebar-list-item recent"
|
||||
@click="searchStore.doSearch({ query, mode: searchStore.mode })">{{ query }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mode-list { margin-top: var(--space-sm); }
|
||||
.section-title { margin-top: var(--space-xl); }
|
||||
.sidebar-list-item { width: 100%; text-align: left; }
|
||||
.recent { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { SearchResult } from '@/contracts'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSearchStore } from '@/stores/search'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
const router = useRouter()
|
||||
const folder = ref('')
|
||||
const tag = ref('')
|
||||
|
||||
function submitSearch() {
|
||||
void searchStore.doSearch({
|
||||
query: searchStore.query,
|
||||
mode: searchStore.mode,
|
||||
folder: folder.value || undefined,
|
||||
tag: tag.value || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function openResult(result: SearchResult) {
|
||||
await editorStore.loadFile(result.file_path)
|
||||
workspaceStore.openFile(result.file_path)
|
||||
editorStore.highlightBlock(result.block_id)
|
||||
await router.push('/workspace')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feature-page search-page">
|
||||
<header class="feature-header">
|
||||
<div><h1>搜索知识库</h1><p>在当前 Vault 中进行全文、向量或混合检索。</p></div>
|
||||
</header>
|
||||
<form class="search-form panel" @submit.prevent="submitSearch">
|
||||
<input v-model="searchStore.query" class="input search-input" placeholder="搜索笔记内容、标题或标签" autofocus />
|
||||
<button class="button-primary" :disabled="!searchStore.query.trim() || searchStore.isSearching">
|
||||
{{ searchStore.isSearching ? '搜索中…' : '搜索' }}
|
||||
</button>
|
||||
<div class="form-grid advanced">
|
||||
<div class="field"><label>文件夹范围</label><input v-model="folder" class="input" placeholder="例如 /数据结构" /></div>
|
||||
<div class="field"><label>标签</label><input v-model="tag" class="input" placeholder="例如 算法" /></div>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="searchStore.error" class="error-banner">{{ searchStore.error }}</div>
|
||||
<div v-if="searchStore.vectorUnavailable" class="notice-banner">向量索引不可用,已保留全文检索能力。</div>
|
||||
<div v-if="searchStore.results.length" class="results-header">
|
||||
<span>找到 {{ searchStore.total }} 条结果</span><span class="badge info">{{ searchStore.mode }}</span>
|
||||
</div>
|
||||
<div v-if="searchStore.results.length" class="result-list">
|
||||
<article v-for="result in searchStore.results" :key="`${result.note_id}:${result.block_id}`"
|
||||
class="item-card result-card" @click="openResult(result)">
|
||||
<div class="result-title"><strong>{{ result.note_title }}</strong><span class="badge">{{ result.match_type }}</span></div>
|
||||
<p class="subtle">{{ result.file_path }} · {{ result.heading_path }}</p>
|
||||
<p class="snippet">{{ result.snippet }}</p>
|
||||
<div class="result-meta"><span>相关度 {{ Math.round(result.score * 100) }}%</span><span>点击定位原文 →</span></div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else-if="!searchStore.isSearching" class="empty-state">
|
||||
<div><strong>{{ searchStore.query ? '没有找到匹配内容' : '从你的知识库开始搜索' }}</strong><p>可切换检索模式或缩小文件夹、标签范围。</p></div>
|
||||
</div>
|
||||
</section>
|
||||
</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 { 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; } }
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import type { ProviderConfig, ProviderType } from '@/contracts'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
type Section = 'general' | 'editor' | 'providers' | 'index' | 'permissions' | 'ai-core'
|
||||
const sections: Array<{ id: Section; label: string }> = [
|
||||
{ id: 'general', label: '通用' }, { id: 'editor', label: '编辑器' }, { id: 'providers', label: '模型提供商' },
|
||||
{ id: 'index', label: '索引与模型' }, { id: 'permissions', label: '权限' }, { id: 'ai-core', label: 'AI Core 诊断' },
|
||||
]
|
||||
const activeSection = ref<Section>('general')
|
||||
const settingsStore = useSettingsStore()
|
||||
const providerStore = useProviderStore()
|
||||
const themeStore = useThemeStore()
|
||||
const showProviderForm = ref(false)
|
||||
const editingProviderId = ref<string | null>(null)
|
||||
const providerAction = ref('')
|
||||
const testResults = ref<Record<string, string>>({})
|
||||
const providerApiKey = ref('')
|
||||
const providerForm = reactive({ preset_id: '', provider_type: 'openai_compatible' as ProviderType, name: '', base_url: '', default_model: '', credential_id: '', enabled: true })
|
||||
const formModels = computed(() => editingProviderId.value ? providerStore.modelsByProvider[editingProviderId.value] ?? [] : [])
|
||||
const selectedPreset = computed(() => providerStore.presets.find((item) => item.preset_id === providerForm.preset_id) ?? null)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
|
||||
await providerStore.refreshEnabledModels()
|
||||
})
|
||||
|
||||
function presetIdFor(provider?: ProviderConfig) {
|
||||
if (!provider) return ''
|
||||
return providerStore.presets.find((preset) =>
|
||||
preset.provider_type === provider.provider_type && preset.base_url === provider.base_url
|
||||
)?.preset_id ?? ''
|
||||
}
|
||||
|
||||
function openProvider(provider?: ProviderConfig) {
|
||||
editingProviderId.value = provider?.provider_id ?? null
|
||||
const presetId = presetIdFor(provider)
|
||||
const preset = providerStore.presets.find((item) => item.preset_id === presetId)
|
||||
Object.assign(providerForm, { preset_id: presetId, provider_type: provider?.provider_type ?? 'openai_compatible', name: provider?.name ?? '', base_url: provider?.base_url ?? '', default_model: provider?.default_model ?? '', credential_id: provider?.credential_id ?? preset?.default_credential_id ?? '', enabled: provider?.enabled ?? true })
|
||||
providerApiKey.value = ''
|
||||
showProviderForm.value = true
|
||||
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
|
||||
if (provider) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
|
||||
}
|
||||
|
||||
function applyProviderPreset() {
|
||||
const preset = providerStore.presets.find((item) => item.preset_id === providerForm.preset_id)
|
||||
if (!preset) return
|
||||
Object.assign(providerForm, {
|
||||
provider_type: preset.provider_type,
|
||||
name: preset.name,
|
||||
base_url: preset.base_url,
|
||||
credential_id: preset.default_credential_id ?? '',
|
||||
})
|
||||
providerApiKey.value = ''
|
||||
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
|
||||
}
|
||||
|
||||
function closeProvider() {
|
||||
providerApiKey.value = ''
|
||||
showProviderForm.value = false
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
providerAction.value = ''
|
||||
const credentialId = providerForm.credential_id.trim()
|
||||
const requiresApiKey = Boolean(selectedPreset.value?.requires_credential)
|
||||
if (requiresApiKey && !providerApiKey.value && !providerStore.credentialConfiguredById[credentialId]) {
|
||||
providerAction.value = '请输入 API Key。密钥将由后端加密保存。'
|
||||
return
|
||||
}
|
||||
const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) }
|
||||
try {
|
||||
if (providerApiKey.value) await providerStore.saveCredential(credentialId, providerApiKey.value)
|
||||
const saved = editingProviderId.value
|
||||
? await providerStore.updateProvider(editingProviderId.value, data)
|
||||
: await providerStore.addProvider(data)
|
||||
closeProvider()
|
||||
if (saved.enabled) void providerStore.loadModels(saved.provider_id).catch(() => undefined)
|
||||
} catch (error) {
|
||||
providerApiKey.value = ''
|
||||
providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProvider(provider: ProviderConfig) { if (!confirm(`确定删除 Provider“${provider.name}”吗?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : '删除失败' } }
|
||||
async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = '测试中…'; const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `连接成功${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `连接失败:${result.error}` }
|
||||
async function refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) }
|
||||
async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
const defaultModel = (event.target as HTMLSelectElement).value
|
||||
try { await providerStore.updateProvider(provider.provider_id, { default_model: defaultModel }) }
|
||||
catch (error) { providerAction.value = error instanceof Error ? error.message : '默认模型更新失败' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feature-page settings-page">
|
||||
<header class="feature-header"><div><h1>设置</h1><p>管理应用偏好、模型、索引、权限和本地 AI Core。</p></div></header>
|
||||
<nav class="settings-nav"><button v-for="section in sections" :key="section.id" :class="{ active: activeSection === section.id }" @click="activeSection = section.id">{{ section.label }}</button></nav>
|
||||
|
||||
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>通用</h2><label class="setting-row"><span><strong>恢复上次 Vault</strong><small>启动后自动打开最近使用的知识库</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>自动保存间隔</strong><small>编辑停止后等待多久写入文件</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 秒</option><option :value="1500">1.5 秒</option><option :value="3000">3 秒</option></select></div><div class="setting-row"><span><strong>界面语言</strong><small>当前阶段支持中文和英文入口</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>版本</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
|
||||
|
||||
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>编辑器</h2><div class="setting-row"><span><strong>默认模式</strong><small>新打开文件使用的编辑器模式</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">写作与预览</option><option value="source">Markdown 源码</option></select></div><div class="setting-row"><span><strong>字号</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>行高</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>行宽</strong><small>Markdown 预览最大字符宽度</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>拼写检查</strong></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
|
||||
|
||||
<div v-else-if="activeSection === 'providers'" class="settings-section">
|
||||
<div class="section-head">
|
||||
<div><h2>模型提供商</h2><p class="subtle">支持 OpenAI、DeepSeek、Ollama 和自定义兼容服务。</p></div>
|
||||
<button class="button-primary" @click="openProvider()">新增 Provider</button>
|
||||
</div>
|
||||
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
|
||||
<div class="provider-list">
|
||||
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
|
||||
<div class="provider-main">
|
||||
<div class="inline-actions"><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
|
||||
<p class="subtle">{{ provider.base_url || '本地内置' }} · 默认模型 {{ provider.default_model || '未设置' }}</p>
|
||||
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
|
||||
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
|
||||
<label :for="`default-model-${provider.provider_id}`">默认模型</label>
|
||||
<select :id="`default-model-${provider.provider_id}`" class="select" :value="provider.default_model" @change="chooseDefaultModel(provider, $event)">
|
||||
<option value="">未设置</option>
|
||||
<option v-for="model in providerStore.modelsByProvider[provider.provider_id]" :key="model.model_id" :value="model.model_id">{{ model.name }}</option>
|
||||
</select>
|
||||
<span class="subtle">已获取 {{ providerStore.modelsByProvider[provider.provider_id].length }} 个模型</span>
|
||||
</div>
|
||||
<p v-if="providerStore.modelErrorsByProvider[provider.provider_id]" class="error-text">模型获取失败:{{ providerStore.modelErrorsByProvider[provider.provider_id] }}</p>
|
||||
<p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p>
|
||||
</div>
|
||||
<div class="inline-actions provider-actions">
|
||||
<button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? '获取中…' : '刷新模型' }}</button>
|
||||
<button class="button-secondary" @click="testProvider(provider)">测试</button>
|
||||
<button class="button-secondary" @click="openProvider(provider)">编辑</button>
|
||||
<button class="button-danger" :disabled="provider.provider_id === 'mock'" @click="removeProvider(provider)">删除</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div></div>
|
||||
|
||||
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">高影响能力默认需要确认。未知权限由后端拒绝。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><select :value="policy" class="select short" @change="settingsStore.setPermission(String(permission), ($event.target as HTMLSelectElement).value as 'allow' | 'confirm' | 'deny')"><option value="allow">允许</option><option value="confirm">每次确认</option><option value="deny">拒绝</option></select></div></div></div>
|
||||
|
||||
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>Sidecar 状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><button class="button-secondary" @click="settingsStore.restartAiCore">重启 AI Core</button></div></div>
|
||||
|
||||
<div v-if="showProviderForm" class="modal-backdrop" @click.self="closeProvider">
|
||||
<div class="modal">
|
||||
<h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2>
|
||||
<form @submit.prevent="saveProvider">
|
||||
<div class="field">
|
||||
<label>提供商预设</label>
|
||||
<select v-model="providerForm.preset_id" class="select" @change="applyProviderPreset">
|
||||
<option value="">自定义</option>
|
||||
<option v-for="preset in providerStore.presets" :key="preset.preset_id" :value="preset.preset_id">{{ preset.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>接入协议</label><select v-model="providerForm.provider_type" class="select"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></div>
|
||||
<div class="field"><label>名称</label><input v-model="providerForm.name" class="input" required /></div>
|
||||
<div class="field"><label>Base URL</label><input v-model="providerForm.base_url" class="input" placeholder="https://api.example.com/v1" required /></div>
|
||||
<div class="field">
|
||||
<label>默认模型</label>
|
||||
<input v-model="providerForm.default_model" class="input" :list="editingProviderId ? 'provider-model-options' : undefined" placeholder="保存后自动获取,也可以手动输入" />
|
||||
<datalist id="provider-model-options"><option v-for="model in formModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist>
|
||||
</div>
|
||||
<div v-if="selectedPreset?.requires_credential" class="field">
|
||||
<label>API Key</label>
|
||||
<input v-model="providerApiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="providerStore.credentialConfiguredById[providerForm.credential_id] ? '已配置,留空表示不修改' : '请输入 API Key'" />
|
||||
<small class="subtle">提交后由本地 AI Core 加密保存,页面不会回显已保存的密钥。</small>
|
||||
</div>
|
||||
<div v-else-if="!selectedPreset" class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="自定义凭据标识" /><small class="subtle">自定义服务可以引用 Host 注入或后端已保存的凭据。</small></div>
|
||||
<label class="inline-actions"><input v-model="providerForm.enabled" type="checkbox" /> 启用</label>
|
||||
<div class="inline-actions"><button class="button-primary">保存并获取模型</button><button type="button" class="button-secondary" @click="closeProvider">取消</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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: 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-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; } }
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { Lightning } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
|
||||
const skillStore = useSkillStore()
|
||||
const actionError = ref('')
|
||||
onMounted(() => { void skillStore.loadSkills() })
|
||||
|
||||
async function install() {
|
||||
const path = prompt('请输入 Skill Package 路径')?.trim()
|
||||
if (!path) return
|
||||
try { await skillStore.installSkill(path) } catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' }
|
||||
}
|
||||
async function toggle(skillId: string, enabled: boolean) {
|
||||
try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' }
|
||||
}
|
||||
async function uninstall(skillId: string, name: string) {
|
||||
if (!confirm(`确定卸载 Skill“${name}”吗?`)) return
|
||||
try { await skillStore.uninstallSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<header class="feature-header"><div><h1>Skill 管理</h1><p>查看工作流使用的 Tool、权限、检索配置和模型要求。</p></div><button class="button-primary" @click="install">安装 Skill</button></header>
|
||||
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>
|
||||
<div v-if="skillStore.selectedSkill" class="panel detail-panel">
|
||||
<div class="detail-head"><div><span class="badge" :class="{ success: skillStore.selectedSkill.status === 'ready', error: skillStore.selectedSkill.status === 'error', warning: skillStore.selectedSkill.status.includes('missing') }">{{ skillStore.selectedSkill.status }}</span><h2>{{ skillStore.selectedSkill.icon }} {{ skillStore.selectedSkill.name }}</h2><p class="muted">v{{ skillStore.selectedSkill.version }} · {{ skillStore.selectedSkill.author || '未知作者' }}</p></div><div class="inline-actions"><button class="button-secondary" @click="toggle(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.enabled)">{{ skillStore.selectedSkill.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.name)">卸载</button></div></div>
|
||||
<p class="description">{{ skillStore.selectedSkill.description }}</p>
|
||||
<div class="detail-grid"><div><h3>工具</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>权限</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>检索配置</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>模型能力</h3><div class="tag-list"><span v-for="cap in skillStore.selectedSkill.model_requirements?.capabilities" :key="cap" class="badge">{{ cap }}</span></div></div></div>
|
||||
<div v-if="skillStore.selectedSkill.missing_dependencies?.length" class="error-banner dependencies">缺失依赖:{{ skillStore.selectedSkill.missing_dependencies.join('、') }}</div>
|
||||
</div>
|
||||
<div v-else class="feature-grid"><article v-for="skill in skillStore.skills" :key="skill.skill_id" class="item-card extension-card" @click="skillStore.selectSkill(skill.skill_id)"><div class="extension-title"><AppIcon :icon="Lightning" :size="22" /><div><strong>{{ skill.name }}</strong><p>v{{ skill.version }}</p></div><span class="badge" :class="{ success: skill.status === 'ready', warning: skill.status === 'dependency_missing' }">{{ skill.status }}</span></div><p class="muted">{{ skill.description }}</p><div class="tag-list"><span v-for="permission in skill.permissions.slice(0, 3)" :key="permission" class="badge">{{ permission }}</span></div></article></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-head, .extension-title { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-md); }
|
||||
.detail-head h2 { margin-top: var(--space-sm); }
|
||||
.description { margin: var(--space-xl) 0; line-height: var(--line-height-relaxed); }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-xl); }
|
||||
.detail-grid h3 { margin-bottom: var(--space-sm); }
|
||||
pre { padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); }
|
||||
.dependencies { margin: var(--space-xl) 0 0; }
|
||||
.extension-card { cursor: pointer; }
|
||||
.extension-card > p { margin: var(--space-md) 0; }
|
||||
.extension-title { align-items: center; }
|
||||
.extension-title .icon { font-size: 28px; }
|
||||
.extension-title p { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { useTaskStore } from '@/stores/task'
|
||||
const taskStore = useTaskStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-panel filters">
|
||||
<div class="field"><label>状态</label><select v-model="taskStore.filterStatus" class="select"><option value="all">全部</option><option value="todo">待办</option><option value="in_progress">进行中</option><option value="done">已完成</option><option value="cancelled">已取消</option></select></div>
|
||||
<div class="task-counts"><p><span>待办</span><strong>{{ taskStore.todoTasks.length }}</strong></p><p><span>进行中</span><strong>{{ taskStore.inProgressTasks.length }}</strong></p><p><span>已完成</span><strong>{{ taskStore.doneTasks.length }}</strong></p></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters { display: grid; gap: var(--space-md); }
|
||||
.task-counts { display: grid; gap: var(--space-xs); padding-top: var(--space-md); border-top: 1px solid var(--color-border-subtle); }
|
||||
.task-counts p { display: flex; justify-content: space-between; color: var(--color-text-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import type { TaskItem, TaskStatus } from '@/contracts'
|
||||
import { useTaskStore } from '@/stores/task'
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const showForm = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const actionError = ref('')
|
||||
const form = reactive({ title: '', description: '', due_date: '', note_id: '' })
|
||||
|
||||
onMounted(() => { void taskStore.loadTasks() })
|
||||
|
||||
function resetForm() { editingId.value = null; form.title = ''; form.description = ''; form.due_date = ''; form.note_id = '' }
|
||||
function editTask(task: TaskItem) { editingId.value = task.task_id; Object.assign(form, { title: task.title, description: task.description ?? '', due_date: task.due_date?.slice(0, 16) ?? '', note_id: task.note_id ?? '' }); showForm.value = true }
|
||||
|
||||
async function saveTask() {
|
||||
actionError.value = ''
|
||||
try {
|
||||
if (editingId.value) await taskStore.updateTask(editingId.value, { ...form, due_date: form.due_date || undefined, note_id: form.note_id || null })
|
||||
else await taskStore.createTask({ ...form, due_date: form.due_date || undefined, note_id: form.note_id || undefined })
|
||||
showForm.value = false; resetForm()
|
||||
} catch (error) { actionError.value = error instanceof Error ? error.message : '任务保存失败' }
|
||||
}
|
||||
|
||||
async function setStatus(task: TaskItem, status: TaskStatus) {
|
||||
try { await taskStore.updateTask(task.task_id, { status }) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' }
|
||||
}
|
||||
|
||||
async function remove(task: TaskItem) {
|
||||
if (!confirm(`确定删除任务“${task.title}”吗?`)) return
|
||||
try { await taskStore.deleteTask(task.task_id) } catch (error) { actionError.value = error instanceof Error ? error.message : '任务删除失败' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<header class="feature-header"><div><h1>任务</h1><p>管理用户、笔记和 Agent 产生的行动项。</p></div><button class="button-primary" @click="resetForm(); showForm = true">+ 新建任务</button></header>
|
||||
<div v-if="taskStore.error || actionError" class="error-banner">{{ taskStore.error || actionError }}</div>
|
||||
<div v-if="taskStore.filteredTasks.length" class="task-list">
|
||||
<article v-for="task in taskStore.filteredTasks" :key="task.task_id" class="item-card task-card">
|
||||
<button class="status-check" :class="{ done: task.status === 'done' }" title="切换完成状态" @click="setStatus(task, task.status === 'done' ? 'todo' : 'done')">{{ task.status === 'done' ? '✓' : '' }}</button>
|
||||
<div class="task-content"><div class="task-title"><strong :class="{ completed: task.status === 'done' }">{{ task.title }}</strong></div><p v-if="task.description" class="muted">{{ task.description }}</p><div class="subtle"><span>{{ task.status }}</span><span v-if="task.due_date">截止 {{ new Date(task.due_date).toLocaleString() }}</span><span v-if="task.note_id">关联 Note:{{ task.note_id }}</span></div></div>
|
||||
<div class="inline-actions"><button class="icon-button" @click="editTask(task)">编辑</button><button class="button-danger" @click="remove(task)">删除</button></div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="empty-state"><div><strong>{{ taskStore.isLoading ? '正在加载任务…' : '没有符合条件的任务' }}</strong><p>创建一项任务,或调整左侧筛选条件。</p></div></div>
|
||||
<div v-if="showForm" class="modal-backdrop" @click.self="showForm = false"><div class="modal"><h2>{{ editingId ? '编辑任务' : '新建任务' }}</h2><form @submit.prevent="saveTask"><div class="field"><label>标题</label><input v-model="form.title" class="input" required /></div><div class="field"><label>描述</label><textarea v-model="form.description" class="textarea" /></div><div class="field"><label>截止时间</label><input v-model="form.due_date" class="input" type="datetime-local" /></div><div class="field"><label>关联 Note ID</label><input v-model="form.note_id" class="input" /></div><div class="inline-actions"><button class="button-primary">保存</button><button type="button" class="button-secondary" @click="showForm = false">取消</button></div></form></div></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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: 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); }
|
||||
.completed { text-decoration: line-through; color: var(--color-text-tertiary); }
|
||||
@media (max-width: 700px) { .task-card { grid-template-columns: auto 1fr; } .task-card > .inline-actions { grid-column: 2; } }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<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>
|
||||
<section class="feature-page">
|
||||
<header class="feature-header"><div><h1>主题</h1><p>预览并切换 Design Token,编辑器偏好会即时生效。</p></div><button class="button-secondary" @click="themeStore.resetToDefault">恢复默认</button></header>
|
||||
<div class="feature-grid themes">
|
||||
<button v-for="theme in themeStore.themes" :key="theme.theme_id" class="item-card theme-card" :class="{ selected: themeStore.currentThemeId === theme.theme_id }" @click="themeStore.applyTheme(theme.theme_id)">
|
||||
<div class="theme-preview" :class="`preview-${theme.theme_id}`"><span></span><span></span><span></span><div></div></div>
|
||||
<div class="theme-info"><div><strong>{{ theme.name }}</strong><p class="subtle">{{ theme.description }}</p></div><span v-if="themeStore.currentThemeId === theme.theme_id" class="badge success">使用中</span></div>
|
||||
<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 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>
|
||||
|
||||
<style scoped>
|
||||
.themes { margin-bottom: var(--space-xl); }
|
||||
.theme-card { display: grid; gap: var(--space-md); text-align: left; }
|
||||
.theme-preview { display: grid; grid-template-columns: 30px 1fr; grid-template-rows: repeat(3, 18px); gap: 6px; height: 120px; padding: var(--space-md); border-radius: var(--radius-md); background: #fff; border: 1px solid #ddd; }
|
||||
.theme-preview span { grid-column: 1; border-radius: 4px; background: #dfe3eb; }
|
||||
.theme-preview div { grid-column: 2; grid-row: 1 / 4; border-radius: 6px; background: #f4f5f7; }
|
||||
.preview-dark { background: #0d1117; border-color: #30363d; }.preview-dark span { background: #30363d; }.preview-dark div { background: #161b22; }
|
||||
.preview-sepia { background: #fbf3df; border-color: #ddcfad; }.preview-sepia span { background: #d8c69c; }.preview-sepia div { background: #f4e8ca; }
|
||||
.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; }
|
||||
.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,6 +4,8 @@ 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, Sunny } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -11,13 +13,20 @@ 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 workspaceStore.loadRecentVaults()
|
||||
await Promise.allSettled([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
|
||||
const lastVaultPath = localStorage.getItem('last-vault-path')
|
||||
if (settingsStore.restoreLastVault && lastVaultPath) {
|
||||
try {
|
||||
await openVault(lastVaultPath)
|
||||
return
|
||||
} catch {
|
||||
// Mock 阶段保存的旧路径可能与当前后端 Vault 不同,清除后让用户重新选择。
|
||||
localStorage.removeItem('last-vault-path')
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
|
||||
}, 800)
|
||||
@@ -34,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>
|
||||
|
||||
@@ -60,14 +53,14 @@ async function createVault() {
|
||||
<div class="bg-decoration" />
|
||||
<div class="entry-container">
|
||||
<div class="brand-section">
|
||||
<div class="logo">📝</div>
|
||||
<h1 class="app-title">知笔知己</h1>
|
||||
<div class="logo"><AppIcon :icon="Document" :size="56" /></div>
|
||||
<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>
|
||||
@@ -79,22 +72,19 @@ async function createVault() {
|
||||
@click="openVault(vault.path)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="vault-icon">📁</span>
|
||||
<AppIcon class="vault-icon" :icon="Folder" :size="20" />
|
||||
<div class="vault-info">
|
||||
<div class="vault-name">{{ vault.name }}</div>
|
||||
<div class="vault-path">{{ vault.path }}</div>
|
||||
</div>
|
||||
<span class="vault-arrow">›</span>
|
||||
<AppIcon class="vault-arrow" :icon="ArrowRight" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading">
|
||||
<span>📂</span> 打开本地 Vault
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="showCreateDialog = true" :disabled="isLoading">
|
||||
<span>➕</span> 创建新 Vault
|
||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || !workspaceStore.recentVaults.length">
|
||||
<AppIcon :icon="FolderOpened" /> 打开后端 Vault
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -109,29 +99,12 @@ async function createVault() {
|
||||
<div class="footer-info">
|
||||
<span>v0.1.0</span>
|
||||
<button class="theme-toggle" @click="themeStore.toggleTheme()">
|
||||
{{ themeStore.isDark ? '☀️ 浅色' : '🌙 深色' }}
|
||||
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="15" />
|
||||
{{ themeStore.isDark ? '浅色' : '深色' }}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@@ -153,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 {
|
||||
@@ -165,6 +138,7 @@ async function createVault() {
|
||||
gap: 32px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
animation: entry-in var(--motion-slow) both;
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
@@ -172,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 {
|
||||
@@ -198,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 {
|
||||
@@ -239,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 {
|
||||
@@ -280,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;
|
||||
@@ -299,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 {
|
||||
@@ -313,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +358,9 @@ async function createVault() {
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--color-text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -381,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>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileNode } from '@/contracts'
|
||||
import { Document, Folder, FolderOpened } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
defineProps<{ node: FileNode; activePath: string | null }>()
|
||||
const emit = defineEmits<{
|
||||
open: [node: FileNode]
|
||||
contextMenu: [event: MouseEvent, node: FileNode]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="tree-node" :class="{ active: node.path === activePath }"
|
||||
@click="emit('open', node)" @contextmenu="emit('contextMenu', $event, node)">
|
||||
<AppIcon :icon="node.type === 'folder' ? (node.is_open ? FolderOpened : Folder) : Document" :size="16" />
|
||||
<span class="name">{{ node.name }}</span>
|
||||
<span v-if="node.is_dirty">●</span>
|
||||
</div>
|
||||
<div v-if="node.type === 'folder' && node.is_open" class="children">
|
||||
<FileTreeNode v-for="child in node.children ?? []" :key="child.id" :node="child" :active-path="activePath"
|
||||
@open="emit('open', $event)" @context-menu="(event, target) => emit('contextMenu', event, target)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree-node { display: flex; align-items: center; gap: var(--space-xs); min-height: 28px; padding: 0 var(--space-sm); border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.tree-node:hover, .tree-node.active { background: var(--color-background-secondary); }
|
||||
.name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.children { padding-left: var(--space-md); }
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment happy-dom
|
||||
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
|
||||
|
||||
async function waitForPath(path: string) {
|
||||
const editorStore = useEditorStore()
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (editorStore.currentFilePath === path) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error(`Editor did not open ${path}`)
|
||||
}
|
||||
|
||||
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', () => {
|
||||
it('switches both workspace selection and editor content on consecutive clicks', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/workspace', component: { template: '<div />' } }],
|
||||
})
|
||||
await router.push('/workspace')
|
||||
await router.isReady()
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
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))!
|
||||
await findNode('红黑树.md').trigger('click')
|
||||
await waitForPath('/数据结构/红黑树.md')
|
||||
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md')
|
||||
expect(editorStore.content).toContain('# 红黑树')
|
||||
|
||||
await findNode('二叉搜索树.md').trigger('click')
|
||||
await waitForPath('/数据结构/二叉搜索树.md')
|
||||
expect(workspaceStore.activeFilePath).toBe('/数据结构/二叉搜索树.md')
|
||||
expect(editorStore.content).toContain('# 二叉搜索树')
|
||||
})
|
||||
})
|
||||
@@ -1,406 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { FileNode } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import FileTreeNode from './FileTreeNode.vue'
|
||||
import { DocumentAdd, FolderAdd } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
const router = useRouter()
|
||||
const newItemType = ref<'file' | 'folder' | null>(null)
|
||||
const newItemName = ref('')
|
||||
const parentPath = ref('/')
|
||||
const contextTarget = ref<FileNode | null>(null)
|
||||
const contextMenuPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const showNewMenu = ref(false)
|
||||
const newFileName = ref('')
|
||||
const newFolderName = ref('')
|
||||
const newFileParentPath = ref('')
|
||||
const showNewFileInput = ref(false)
|
||||
const showNewFolderInput = ref(false)
|
||||
const contextMenuPath = ref<string | null>(null)
|
||||
const showContextMenu = ref(false)
|
||||
const contextMenuPos = ref({ x: 0, y: 0 })
|
||||
const renamingPath = ref<string | null>(null)
|
||||
const renameValue = ref('')
|
||||
|
||||
function toggleFolder(node: FileNode) {
|
||||
workspaceStore.toggleFolder(node.path)
|
||||
function beginCreate(type: 'file' | 'folder', parent = '/') {
|
||||
newItemType.value = type
|
||||
newItemName.value = ''
|
||||
parentPath.value = parent
|
||||
}
|
||||
|
||||
async function openFile(node: FileNode) {
|
||||
if (node.type === 'folder') {
|
||||
toggleFolder(node)
|
||||
return
|
||||
async function createItem() {
|
||||
const rawName = newItemName.value.trim()
|
||||
if (!rawName || !newItemType.value) return
|
||||
if (newItemType.value === 'file') {
|
||||
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
|
||||
const file = await workspaceService.createFile(parentPath.value, name, `# ${rawName}\n\n`)
|
||||
workspaceStore.addFileToTree(parentPath.value, file)
|
||||
await editorStore.loadFile(file.path)
|
||||
workspaceStore.openFile(file.path)
|
||||
await router.push('/workspace')
|
||||
} else {
|
||||
const folder = await workspaceService.createFolder(parentPath.value, rawName)
|
||||
workspaceStore.addFileToTree(parentPath.value, folder)
|
||||
}
|
||||
newItemType.value = null
|
||||
newItemName.value = ''
|
||||
}
|
||||
|
||||
async function openNode(node: FileNode) {
|
||||
if (node.type === 'folder') return workspaceStore.toggleFolder(node.path)
|
||||
// 先同步活动文件,让真实点击立即生效;内容加载失败时再恢复原状态。
|
||||
const previousPath = workspaceStore.activeFilePath
|
||||
const wasOpen = workspaceStore.openFiles.includes(node.path)
|
||||
workspaceStore.openFile(node.path)
|
||||
await editorStore.loadFile(node.path)
|
||||
router.push('/workspace')
|
||||
}
|
||||
|
||||
function startNewFile(parentPath = '') {
|
||||
newFileParentPath.value = parentPath
|
||||
showNewFileInput.value = true
|
||||
showNewMenu.value = false
|
||||
newFileName.value = ''
|
||||
}
|
||||
|
||||
function startNewFolder(parentPath = '') {
|
||||
newFileParentPath.value = parentPath
|
||||
showNewFolderInput.value = true
|
||||
showNewMenu.value = false
|
||||
newFolderName.value = ''
|
||||
}
|
||||
|
||||
async function createFile() {
|
||||
if (!newFileName.value.trim()) return
|
||||
const name = newFileName.value.endsWith('.md') ? newFileName.value : `${newFileName.value}.md`
|
||||
const file = await workspaceService.createFile(newFileParentPath.value || '/', name, '# ' + newFileName.value + '\n\n')
|
||||
workspaceStore.addFileToTree(newFileParentPath.value || '/', file)
|
||||
workspaceStore.openFile(file.path)
|
||||
await editorStore.loadFile(file.path)
|
||||
showNewFileInput.value = false
|
||||
newFileName.value = ''
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
const folder = await workspaceService.createFolder(newFileParentPath.value || '/', newFolderName.value)
|
||||
workspaceStore.addFileToTree(newFileParentPath.value || '/', folder)
|
||||
showNewFolderInput.value = false
|
||||
newFolderName.value = ''
|
||||
}
|
||||
|
||||
function onContextMenu(e: MouseEvent, node: FileNode) {
|
||||
e.preventDefault()
|
||||
contextMenuPath.value = node.path
|
||||
contextMenuPos.value = { x: e.clientX, y: e.clientY }
|
||||
showContextMenu.value = true
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
showContextMenu.value = false
|
||||
contextMenuPath.value = null
|
||||
}
|
||||
|
||||
function startRename(node: FileNode) {
|
||||
renamingPath.value = node.path
|
||||
renameValue.value = node.name
|
||||
showContextMenu.value = false
|
||||
}
|
||||
|
||||
async function finishRename(node: FileNode) {
|
||||
if (renameValue.value && renameValue.value !== node.name) {
|
||||
await workspaceService.renameFile(node.path, renameValue.value)
|
||||
node.name = renameValue.value
|
||||
try {
|
||||
await editorStore.loadFile(node.path)
|
||||
await router.push('/workspace')
|
||||
} catch (error) {
|
||||
if (!wasOpen) workspaceStore.closeFile(node.path)
|
||||
workspaceStore.setActiveFile(previousPath)
|
||||
console.error(`打开文件失败:${node.path}`, error)
|
||||
}
|
||||
renamingPath.value = null
|
||||
}
|
||||
|
||||
async function deleteNode(node: FileNode) {
|
||||
const confirmMsg = node.type === 'folder' ? `确定要删除文件夹 "${node.name}" 吗?` : `确定要删除笔记 "${node.name}" 吗?`
|
||||
if (confirm(confirmMsg)) {
|
||||
await workspaceService.deleteFile(node.path)
|
||||
workspaceStore.removeFromTree(node.path)
|
||||
if (node.type === 'file') {
|
||||
workspaceStore.closeFile(node.path)
|
||||
}
|
||||
function openContextMenu(event: MouseEvent, node: FileNode) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
contextTarget.value = node
|
||||
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
function closeContextMenu() { contextTarget.value = null }
|
||||
|
||||
async function renameTarget() {
|
||||
const node = contextTarget.value
|
||||
if (!node) return
|
||||
const newName = window.prompt('新名称', node.name)?.trim()
|
||||
if (newName && newName !== node.name) {
|
||||
const normalizedName = node.type === 'file' && !newName.toLowerCase().endsWith('.md') ? `${newName}.md` : newName
|
||||
const oldPath = node.path
|
||||
const separator = oldPath.lastIndexOf('/')
|
||||
const newPath = `${oldPath.slice(0, separator + 1)}${normalizedName}`
|
||||
await workspaceService.renameFile(oldPath, normalizedName)
|
||||
workspaceStore.renamePath(oldPath, newPath, normalizedName)
|
||||
editorStore.renameFilePath(oldPath, newPath)
|
||||
}
|
||||
showContextMenu.value = false
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
function getFileIcon(name: string) {
|
||||
if (name.endsWith('.md')) return '📄'
|
||||
return '📄'
|
||||
async function deleteTarget() {
|
||||
const node = contextTarget.value
|
||||
if (!node) return
|
||||
if (!window.confirm(`确定要删除“${node.name}”吗?`)) return closeContextMenu()
|
||||
await workspaceService.deleteFile(node.path)
|
||||
const activeWasRemoved = workspaceStore.closePath(node.path)
|
||||
workspaceStore.removeFromTree(node.path)
|
||||
if (activeWasRemoved) {
|
||||
editorStore.closeFile()
|
||||
if (workspaceStore.activeFilePath) await editorStore.loadFile(workspaceStore.activeFilePath)
|
||||
}
|
||||
closeContextMenu()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-tree-panel" @click="closeContextMenu">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="tool-btn" @click="startNewFile" title="新建笔记">
|
||||
<span>➕</span>
|
||||
</button>
|
||||
<button class="tool-btn" @click="startNewFolder" title="新建文件夹">
|
||||
<span>📁</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="tool-btn" title="刷新">
|
||||
<span>🔄</span>
|
||||
</button>
|
||||
<section class="file-tree-panel" @click="closeContextMenu">
|
||||
<div class="toolbar">
|
||||
<button type="button" title="新建笔记" aria-label="新建笔记" @click.stop="beginCreate('file')"><AppIcon :icon="DocumentAdd" /></button>
|
||||
<button type="button" title="新建文件夹" aria-label="新建文件夹" @click.stop="beginCreate('folder')"><AppIcon :icon="FolderAdd" /></button>
|
||||
</div>
|
||||
|
||||
<div class="new-input" v-if="showNewFileInput">
|
||||
<input
|
||||
v-model="newFileName"
|
||||
type="text"
|
||||
placeholder="笔记名称"
|
||||
@keyup.enter="createFile"
|
||||
@keyup.esc="showNewFileInput = false"
|
||||
autofocus
|
||||
/>
|
||||
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
|
||||
<input v-model="newItemName" :placeholder="newItemType === 'file' ? '笔记名称' : '文件夹名称'" autofocus />
|
||||
<button type="submit">创建</button>
|
||||
<button type="button" @click="newItemType = null">取消</button>
|
||||
</form>
|
||||
<div class="tree">
|
||||
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
|
||||
:active-path="workspaceStore.activeFilePath" @open="openNode" @context-menu="openContextMenu" />
|
||||
</div>
|
||||
<div class="new-input" v-if="showNewFolderInput">
|
||||
<input
|
||||
v-model="newFolderName"
|
||||
type="text"
|
||||
placeholder="文件夹名称"
|
||||
@keyup.enter="createFolder"
|
||||
@keyup.esc="showNewFolderInput = false"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tree-container">
|
||||
<template v-for="node in workspaceStore.fileTree" :key="node.id">
|
||||
<div class="tree-node-wrapper">
|
||||
<TreeNode :node="node" :depth="0" @open="openFile" @toggle="toggleFolder" @context-menu="onContextMenu"
|
||||
:renaming-path="renamingPath" :rename-value="renameValue"
|
||||
@rename-start="startRename" @rename-finish="finishRename"
|
||||
@delete-node="deleteNode"
|
||||
@new-file="startNewFile" @new-folder="startNewFolder" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showContextMenu" class="context-menu"
|
||||
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
|
||||
@click.stop>
|
||||
<button @click="() => { const n = workspaceStore.activeFile; if (n) startRename(n) }">✏️ 重命名</button>
|
||||
<button @click="() => { const n = workspaceStore.activeFile; if (n) deleteNode(n) }" class="danger">🗑️ 删除</button>
|
||||
<div v-if="contextTarget" class="context-menu"
|
||||
:style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
|
||||
<button @click="renameTarget">重命名</button>
|
||||
<button class="danger" @click="deleteTarget">删除</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, h } from 'vue'
|
||||
import type { FileNode } from '@/contracts'
|
||||
|
||||
const TreeNode = defineComponent({
|
||||
name: 'TreeNode',
|
||||
props: {
|
||||
node: { type: Object as () => FileNode, required: true },
|
||||
depth: { type: Number, default: 0 },
|
||||
renamingPath: { type: String, default: null },
|
||||
renameValue: { type: String, default: '' },
|
||||
},
|
||||
emits: ['open', 'toggle', 'context-menu', 'rename-start', 'rename-finish', 'delete-node', 'new-file', 'new-folder'],
|
||||
setup(props, { emit }) {
|
||||
const isActive = (path: string) => {
|
||||
const { useWorkspaceStore } = require('@/stores/workspace')
|
||||
return useWorkspaceStore().activeFilePath === path
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
emit('open', props.node)
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
emit('context-menu', e, props.node)
|
||||
}
|
||||
|
||||
const finishRename = () => {
|
||||
emit('rename-finish', props.node)
|
||||
}
|
||||
|
||||
return () => {
|
||||
const isFolder = props.node.type === 'folder'
|
||||
const isOpen = props.node.is_open
|
||||
const isRenaming = props.renamingPath === props.node.path
|
||||
const active = isActive(props.node.path)
|
||||
|
||||
return h('div', { class: 'tree-node' }, [
|
||||
h('div', {
|
||||
class: ['node-row', { active, folder: isFolder, open: isOpen }],
|
||||
style: { paddingLeft: `${props.depth * 16 + 8}px` },
|
||||
onClick: handleClick,
|
||||
onContextmenu: handleContextMenu,
|
||||
}, [
|
||||
h('span', { class: 'chevron' }, isFolder ? (isOpen ? '▼' : '▶') : ''),
|
||||
h('span', { class: 'node-icon' }, isFolder ? (isOpen ? '📂' : '📁') : '📄'),
|
||||
isRenaming
|
||||
? h('input', {
|
||||
class: 'rename-input',
|
||||
value: props.renameValue,
|
||||
autofocus: true,
|
||||
onBlur: finishRename,
|
||||
onKeyup: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') finishRename()
|
||||
if (e.key === 'Escape') emit('rename-finish', props.node)
|
||||
},
|
||||
})
|
||||
: h('span', { class: 'node-name' }, props.node.name),
|
||||
]),
|
||||
isFolder && isOpen && props.node.children && props.node.children.length
|
||||
? h('div', { class: 'node-children' },
|
||||
props.node.children.map((child) =>
|
||||
h(TreeNode, {
|
||||
key: child.id,
|
||||
node: child,
|
||||
depth: props.depth + 1,
|
||||
renamingPath: props.renamingPath,
|
||||
renameValue: props.renameValue,
|
||||
onOpen: (n: FileNode) => emit('open', n),
|
||||
onToggle: (n: FileNode) => emit('toggle', n.path),
|
||||
onContextmenu: (e: MouseEvent, n: FileNode) => emit('context-menu', e, n),
|
||||
onRenameStart: (n: FileNode) => emit('rename-start', n),
|
||||
onRenameFinish: (n: FileNode) => emit('rename-finish', n),
|
||||
onDeleteNode: (n: FileNode) => emit('delete-node', n),
|
||||
onNewFile: (p: string) => emit('new-file', p),
|
||||
onNewFolder: (p: string) => emit('new-folder', p),
|
||||
})
|
||||
)
|
||||
)
|
||||
: null,
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-tree-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
transition: all var(--motion-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.new-input {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border-focus);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding-right: var(--space-md);
|
||||
cursor: pointer;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
margin-right: 4px;
|
||||
transition: background var(--motion-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 14px;
|
||||
font-size: 9px;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.rename-input {
|
||||
flex: 1;
|
||||
padding: 2px 4px;
|
||||
font-size: 13px;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-focus);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: var(--z-dropdown);
|
||||
background: var(--color-surface-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
.file-tree-panel { height: 100%; }
|
||||
.toolbar { display: flex; gap: var(--space-xs); padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
|
||||
button { border: 0; border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); background: transparent; color: inherit; cursor: pointer; }
|
||||
button:hover { background: var(--color-background-secondary); }
|
||||
.new-item { display: flex; gap: var(--space-xs); padding: var(--space-sm); }
|
||||
.new-item input { min-width: 0; flex: 1; }
|
||||
.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-error); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import WorkspaceView from './WorkspaceView.vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
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 } },
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(workspaceStore.activeFilePath).toBeNull()
|
||||
expect(wrapper.find('.empty-workspace').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,22 +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) {
|
||||
workspaceStore.openFile('/欢迎使用知笔知己.md')
|
||||
editorStore.loadFile('/欢迎使用知笔知己.md')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,7 +16,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<div v-else class="empty-workspace">
|
||||
<div class="empty-content">
|
||||
<div class="empty-icon">📝</div>
|
||||
<AppIcon class="empty-icon" :icon="EditPen" :size="48" />
|
||||
<h2>开始写作</h2>
|
||||
<p>从左侧文件树选择笔记,或创建新的笔记</p>
|
||||
</div>
|
||||
@@ -53,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;
|
||||
@@ -69,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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/features.css'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -61,15 +61,6 @@ const routes = [
|
||||
name: 'settings',
|
||||
component: () => import('@/features/settings/SettingsView.vue'),
|
||||
meta: { title: '设置', requiresVault: true },
|
||||
children: [
|
||||
{ path: '', redirect: '/settings/general' },
|
||||
{ path: 'general', component: () => import('@/features/settings/sections/GeneralSection.vue') },
|
||||
{ path: 'editor', component: () => import('@/features/settings/sections/EditorSection.vue') },
|
||||
{ path: 'providers', component: () => import('@/features/settings/sections/ProvidersSection.vue') },
|
||||
{ path: 'index', component: () => import('@/features/settings/sections/IndexSection.vue') },
|
||||
{ path: 'permissions', component: () => import('@/features/settings/sections/PermissionsSection.vue') },
|
||||
{ path: 'ai-core', component: () => import('@/features/settings/sections/AiCoreSection.vue') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -92,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,46 +1,69 @@
|
||||
import apiClient from './apiClient'
|
||||
import { SseClient } from './sseClient'
|
||||
import type { AgentRun, AgentEvent, 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,
|
||||
current_step: run.current_step,
|
||||
max_steps: run.max_steps,
|
||||
token_usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: run.token_usage,
|
||||
},
|
||||
started_at: run.created_at,
|
||||
completed_at: ['completed', 'failed', 'cancelled'].includes(run.status) ? run.updated_at : undefined,
|
||||
error: run.error_message ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgentRuns(params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ items: AgentRun[]; total: number }> {
|
||||
return apiClient.get('/api/agent/runs', { params })
|
||||
const response = await apiClient.get<{ items: ApiAgentRun[]; page: PageMeta }>('/api/agent/runs', { params })
|
||||
return { items: response.items.map(toAgentRun), total: response.page.total }
|
||||
}
|
||||
|
||||
export async function getAgentRun(runId: string): Promise<AgentRun> {
|
||||
return apiClient.get(`/api/agent/runs/${runId}`)
|
||||
return toAgentRun(await apiClient.get<ApiAgentRun>(`/api/agent/runs/${runId}`))
|
||||
}
|
||||
|
||||
export interface CreateAgentRunRequest {
|
||||
task: string
|
||||
provider_id?: string
|
||||
model?: string
|
||||
input: string
|
||||
provider_id: string
|
||||
model: string
|
||||
skill_id?: string
|
||||
allowed_tools?: string[]
|
||||
max_steps?: number
|
||||
tool_timeout?: number
|
||||
run_timeout?: number
|
||||
tool_timeout_seconds?: number
|
||||
run_timeout_seconds?: number
|
||||
token_budget?: number
|
||||
allow_network?: boolean
|
||||
max_concurrent_tools?: number
|
||||
}
|
||||
|
||||
export async function createAgentRun(request: CreateAgentRunRequest): Promise<AgentRun> {
|
||||
return apiClient.post('/api/agent/runs', request)
|
||||
return toAgentRun(await apiClient.post<ApiAgentRun>('/api/agent/runs', request))
|
||||
}
|
||||
|
||||
export async function cancelAgentRun(runId: string): Promise<void> {
|
||||
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[]> {
|
||||
try {
|
||||
return await apiClient.get('/api/tools')
|
||||
} catch {
|
||||
return mockTools
|
||||
}
|
||||
const response = await apiClient.get<{ items: ToolDefinition[] }>('/api/tools')
|
||||
return response.items
|
||||
}
|
||||
|
||||
export function streamAgentEvents(
|
||||
@@ -50,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'],
|
||||
@@ -75,12 +101,10 @@ export function streamAgentEvents(
|
||||
export async function respondToPermission(
|
||||
runId: string,
|
||||
requestId: string,
|
||||
decision: 'allow' | 'deny',
|
||||
scope?: 'once' | 'session' | 'always'
|
||||
): Promise<void> {
|
||||
decision: 'allow_once' | 'allow_session' | 'deny'
|
||||
): Promise<OperationResponse> {
|
||||
return apiClient.post(`/api/agent/runs/${runId}/permissions/${requestId}`, {
|
||||
decision,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ApiError, ErrorResponse } from '@/contracts'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE || ''
|
||||
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
|
||||
|
||||
export function resolveApiUrl(path: string): string {
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
return `${BASE_URL.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
|
||||
}
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
@@ -22,7 +28,7 @@ export class ApiErrorClass extends Error {
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, token, headers, ...rest } = options
|
||||
|
||||
let url = path.startsWith('http') ? path : `${BASE_URL}${path}`
|
||||
let url = resolveApiUrl(path)
|
||||
|
||||
if (params) {
|
||||
const usp = new URLSearchParams()
|
||||
@@ -58,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
|
||||
@@ -94,6 +101,13 @@ export const apiClient = {
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
},
|
||||
put<T>(path: string, body?: unknown, options?: Omit<RequestOptions, 'method' | 'body'>) {
|
||||
return request<T>(path, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
},
|
||||
delete<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
|
||||
return request<T>(path, { ...options, method: 'DELETE' })
|
||||
},
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import apiClient from './apiClient'
|
||||
import { SseClient } from './sseClient'
|
||||
import type { Conversation, ChatMessage, ModelEvent } from '@/contracts'
|
||||
|
||||
export async function listConversations(): Promise<Conversation[]> {
|
||||
return apiClient.get('/api/conversations')
|
||||
}
|
||||
|
||||
export async function getConversation(conversationId: string): Promise<Conversation> {
|
||||
return apiClient.get(`/api/conversations/${conversationId}`)
|
||||
}
|
||||
|
||||
export async function getMessages(conversationId: string): Promise<ChatMessage[]> {
|
||||
return apiClient.get(`/api/conversations/${conversationId}/messages`)
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
provider_id: string
|
||||
model: string
|
||||
conversation_id?: string
|
||||
message: string
|
||||
provider_id?: string
|
||||
model?: string
|
||||
system?: string
|
||||
messages: Array<{
|
||||
role: 'system' | 'user' | 'assistant' | 'tool'
|
||||
content: string
|
||||
name?: string
|
||||
tool_call_id?: string
|
||||
}>
|
||||
use_rag?: boolean
|
||||
skill_id?: string
|
||||
attachments?: string[]
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { apiClient, ApiErrorClass } from './apiClient'
|
||||
export type { ApiError } from './apiClient'
|
||||
export type { ApiError } from '@/contracts'
|
||||
export { SseClient } from './sseClient'
|
||||
export type { SseClientOptions, SseEventHandler } from './sseClient'
|
||||
export * as noteService from './noteService'
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { IndexStatus } from '@/contracts'
|
||||
import type { ApiIndexJob, ApiIndexStatus, IndexStatus } from '@/contracts'
|
||||
|
||||
export async function getIndexStatus(): Promise<IndexStatus> {
|
||||
try {
|
||||
return await apiClient.get('/api/index/status')
|
||||
} catch {
|
||||
return mockIndexStatus
|
||||
function toIndexStatus(status: ApiIndexStatus): IndexStatus {
|
||||
return {
|
||||
status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
|
||||
pending_jobs: status.pending_jobs,
|
||||
total_notes: 0,
|
||||
total_blocks: 0,
|
||||
fts_enabled: true,
|
||||
vector_enabled: true,
|
||||
last_indexed_at: status.last_completed_at ?? undefined,
|
||||
error: status.error_message ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<{ job_id: string }> {
|
||||
return apiClient.post('/api/index/rebuild', { scope })
|
||||
export async function getIndexStatus(): Promise<IndexStatus> {
|
||||
return toIndexStatus(await apiClient.get<ApiIndexStatus>('/api/index/status'))
|
||||
}
|
||||
|
||||
export async function getIndexJob(jobId: string): Promise<{
|
||||
job_id: string
|
||||
status: 'queued' | 'running' | 'completed' | 'failed'
|
||||
progress: number
|
||||
total: number
|
||||
error?: string
|
||||
}> {
|
||||
export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<ApiIndexJob> {
|
||||
const apiScope = scope === 'full' ? 'all' : scope === 'fts' ? 'notes' : 'vectors'
|
||||
return apiClient.post<ApiIndexJob>('/api/index/rebuild', { scope: apiScope })
|
||||
}
|
||||
|
||||
export async function getIndexJob(jobId: string): Promise<ApiIndexJob> {
|
||||
return apiClient.get(`/api/index/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { Note, NoteBlock } from '@/contracts'
|
||||
import type { ApiNote, ApiNoteSummary, OperationResponse, PageMeta } from '@/contracts'
|
||||
|
||||
export async function listNotes(params?: {
|
||||
folder?: string
|
||||
tag?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ items: Note[]; total: number }> {
|
||||
}): Promise<{ items: ApiNoteSummary[]; page: PageMeta }> {
|
||||
return apiClient.get('/api/notes', { params })
|
||||
}
|
||||
|
||||
export async function getNote(noteId: string): Promise<{ note: Note; blocks: NoteBlock[] }> {
|
||||
export async function getNote(noteId: string): Promise<ApiNote> {
|
||||
return apiClient.get(`/api/notes/${noteId}`)
|
||||
}
|
||||
|
||||
export async function createNote(data: {
|
||||
title: string
|
||||
folder_path?: string
|
||||
content?: string
|
||||
}): Promise<Note> {
|
||||
folder?: string
|
||||
markdown?: string
|
||||
tags?: string[]
|
||||
}): Promise<ApiNote> {
|
||||
return apiClient.post('/api/notes', data)
|
||||
}
|
||||
|
||||
export async function updateNote(
|
||||
noteId: string,
|
||||
data: { title?: string; content?: string; tags?: string[] }
|
||||
): Promise<Note> {
|
||||
data: { title?: string; markdown?: string; tags?: string[] }
|
||||
): Promise<ApiNote> {
|
||||
return apiClient.patch(`/api/notes/${noteId}`, data)
|
||||
}
|
||||
|
||||
export async function deleteNote(noteId: string): Promise<void> {
|
||||
export async function deleteNote(noteId: string): Promise<OperationResponse> {
|
||||
return apiClient.delete(`/api/notes/${noteId}`)
|
||||
}
|
||||
|
||||
export async function moveNote(noteId: string, target_folder: string): Promise<Note> {
|
||||
return apiClient.post(`/api/notes/${noteId}/move`, { target_folder })
|
||||
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,31 +1,60 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { Plugin } from '@/contracts'
|
||||
import type { ApiPlugin, OperationResponse, Plugin, PluginContribution } from '@/contracts'
|
||||
|
||||
export async function listPlugins(): Promise<Plugin[]> {
|
||||
try {
|
||||
return await apiClient.get('/api/plugins')
|
||||
} catch {
|
||||
return mockPlugins
|
||||
function toPlugin(plugin: ApiPlugin): Plugin {
|
||||
const { manifest } = plugin
|
||||
const contributions: PluginContribution[] = []
|
||||
const append = (type: PluginContribution['type'], values: string[]) => {
|
||||
values.forEach((id) => contributions.push({ type, id, name: id }))
|
||||
}
|
||||
append('tool', manifest.contributes.tools)
|
||||
append('command', manifest.contributes.commands)
|
||||
append('importer', manifest.contributes.importers)
|
||||
append('exporter', manifest.contributes.exporters)
|
||||
append('sidebar_panel', manifest.contributes.panels)
|
||||
append('settings_section', manifest.contributes.settings_sections)
|
||||
return {
|
||||
plugin_id: manifest.plugin_id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
status: plugin.status,
|
||||
enabled: plugin.enabled,
|
||||
permissions: manifest.permissions,
|
||||
granted_permissions: plugin.granted_permissions,
|
||||
contributions,
|
||||
backend_type: manifest.backend.type,
|
||||
transport: manifest.backend.transport,
|
||||
last_error: plugin.error_message ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlugin(pluginId: string): Promise<Plugin> {
|
||||
return apiClient.get(`/api/plugins/${pluginId}`)
|
||||
export async function listPlugins(): Promise<Plugin[]> {
|
||||
const response = await apiClient.get<{ items: ApiPlugin[] }>('/api/plugins')
|
||||
return response.items.map(toPlugin)
|
||||
}
|
||||
|
||||
export async function installPlugin(pluginId: string): Promise<Plugin> {
|
||||
return apiClient.post('/api/plugins/install', { plugin_id: pluginId })
|
||||
export async function getPlugin(pluginId: string): Promise<Plugin> {
|
||||
return toPlugin(await apiClient.get<ApiPlugin>(`/api/plugins/${pluginId}`))
|
||||
}
|
||||
|
||||
export async function installPlugin(packagePath: string): Promise<Plugin> {
|
||||
return toPlugin(await apiClient.post<ApiPlugin>('/api/plugins/install', { package_path: packagePath }))
|
||||
}
|
||||
|
||||
export async function enablePlugin(pluginId: string): Promise<Plugin> {
|
||||
return apiClient.post(`/api/plugins/${pluginId}/enable`)
|
||||
return toPlugin(await apiClient.post<ApiPlugin>(`/api/plugins/${pluginId}/enable`))
|
||||
}
|
||||
|
||||
export async function disablePlugin(pluginId: string): Promise<Plugin> {
|
||||
return apiClient.post(`/api/plugins/${pluginId}/disable`)
|
||||
return toPlugin(await apiClient.post<ApiPlugin>(`/api/plugins/${pluginId}/disable`))
|
||||
}
|
||||
|
||||
export async function uninstallPlugin(pluginId: string): Promise<void> {
|
||||
export async function grantPluginPermissions(pluginId: string, permissions: string[]): Promise<Plugin> {
|
||||
return toPlugin(await apiClient.put<ApiPlugin>(`/api/plugins/${pluginId}/permissions`, { permissions }))
|
||||
}
|
||||
|
||||
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
|
||||
return apiClient.delete(`/api/plugins/${pluginId}`)
|
||||
}
|
||||
|
||||
@@ -35,8 +64,8 @@ export const mockPlugins: Plugin[] = [
|
||||
name: 'GitHub 集成',
|
||||
version: '1.3.2',
|
||||
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
|
||||
icon: '🐙',
|
||||
author: '知笔知己团队',
|
||||
icon: '',
|
||||
author: 'NotesAgent 团队',
|
||||
status: 'ready',
|
||||
enabled: true,
|
||||
permissions: ['notes.read', 'network.request'],
|
||||
@@ -54,7 +83,7 @@ export const mockPlugins: Plugin[] = [
|
||||
name: '翻译助手',
|
||||
version: '1.0.0',
|
||||
description: '提供多语言翻译能力,支持文档批量翻译',
|
||||
icon: '🌐',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
status: 'ready',
|
||||
enabled: false,
|
||||
@@ -72,7 +101,7 @@ export const mockPlugins: Plugin[] = [
|
||||
name: '看板视图',
|
||||
version: '0.8.0',
|
||||
description: '为任务提供看板视图,支持拖拽排序和多维度筛选',
|
||||
icon: '📋',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
status: 'installed',
|
||||
enabled: false,
|
||||
@@ -80,15 +109,15 @@ export const mockPlugins: Plugin[] = [
|
||||
contributions: [
|
||||
{ type: 'sidebar_panel', id: 'kanban.panel', name: '任务看板', description: '以看板方式查看和管理任务' },
|
||||
],
|
||||
backend_type: 'internal',
|
||||
backend_type: 'internal_rpc',
|
||||
},
|
||||
{
|
||||
plugin_id: 'pdf-importer',
|
||||
name: 'PDF 导入',
|
||||
version: '2.1.0',
|
||||
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
|
||||
icon: '📄',
|
||||
author: '知笔知己团队',
|
||||
icon: '',
|
||||
author: 'NotesAgent 团队',
|
||||
status: 'error',
|
||||
enabled: false,
|
||||
permissions: ['notes.write', 'attachments.read'],
|
||||
@@ -104,7 +133,7 @@ export const mockPlugins: Plugin[] = [
|
||||
name: '日历同步',
|
||||
version: '0.5.0',
|
||||
description: '同步日历事件,自动生成相关笔记和任务提醒',
|
||||
icon: '📅',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
status: 'dependency_missing',
|
||||
enabled: false,
|
||||
@@ -114,6 +143,6 @@ export const mockPlugins: Plugin[] = [
|
||||
{ type: 'sidebar_panel', id: 'calendar.widget', name: '日历小部件', description: '侧边栏日历视图' },
|
||||
],
|
||||
backend_type: 'mcp',
|
||||
transport: 'websocket',
|
||||
transport: 'http',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,36 +1,84 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { ProviderConfig, ModelInfo } from '@/contracts'
|
||||
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset } from '@/contracts'
|
||||
|
||||
export async function listProviders(): Promise<ProviderConfig[]> {
|
||||
try {
|
||||
return await apiClient.get('/api/providers')
|
||||
} catch {
|
||||
return mockProviders
|
||||
function capabilityMap(capabilities: string[]): Partial<ModelCapability> {
|
||||
return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability>
|
||||
}
|
||||
|
||||
function toProvider(provider: ApiProviderConfig): ProviderConfig {
|
||||
return {
|
||||
provider_id: provider.provider_id,
|
||||
provider_type: provider.provider_type,
|
||||
name: provider.name,
|
||||
base_url: provider.base_url ?? undefined,
|
||||
default_model: provider.default_model ?? '',
|
||||
enabled: provider.enabled,
|
||||
capabilities: capabilityMap(provider.capabilities),
|
||||
credential_id: provider.credential_id ?? undefined,
|
||||
has_credential: Boolean(provider.credential_id) || provider.provider_type === 'mock',
|
||||
}
|
||||
}
|
||||
|
||||
function toModel(model: ApiModelInfo): ModelInfo {
|
||||
return { model_id: model.model, name: model.display_name, capabilities: capabilityMap(model.capabilities) }
|
||||
}
|
||||
|
||||
export async function listProviders(): Promise<ProviderConfig[]> {
|
||||
const response = await apiClient.get<{ items: ApiProviderConfig[] }>('/api/providers')
|
||||
return response.items.map(toProvider)
|
||||
}
|
||||
|
||||
export async function getProvider(providerId: string): Promise<ProviderConfig> {
|
||||
return apiClient.get(`/api/providers/${providerId}`)
|
||||
return toProvider(await apiClient.get<ApiProviderConfig>(`/api/providers/${providerId}`))
|
||||
}
|
||||
|
||||
export async function createProvider(data: Omit<ProviderConfig, 'provider_id'> & { api_key?: string }): Promise<ProviderConfig> {
|
||||
return apiClient.post('/api/providers', data)
|
||||
export async function createProvider(data: Omit<ProviderConfig, 'provider_id'>): Promise<ProviderConfig> {
|
||||
const response = await apiClient.post<ApiProviderConfig>('/api/providers', {
|
||||
provider_type: data.provider_type,
|
||||
name: data.name,
|
||||
base_url: data.base_url,
|
||||
default_model: data.default_model || null,
|
||||
credential_id: data.credential_id,
|
||||
enabled: data.enabled,
|
||||
})
|
||||
return toProvider(response)
|
||||
}
|
||||
|
||||
export async function updateProvider(providerId: string, data: Partial<ProviderConfig> & { api_key?: string }): Promise<ProviderConfig> {
|
||||
return apiClient.patch(`/api/providers/${providerId}`, data)
|
||||
export async function listProviderPresets(): Promise<ProviderPreset[]> {
|
||||
const response = await apiClient.get<{ items: ApiProviderPreset[] }>('/api/providers/presets')
|
||||
return response.items
|
||||
}
|
||||
|
||||
export async function deleteProvider(providerId: string): Promise<void> {
|
||||
export async function getCredentialStatus(credentialId: string): Promise<boolean> {
|
||||
const response = await apiClient.get<{ credential_id: string; configured: boolean }>(`/api/credentials/${encodeURIComponent(credentialId)}`)
|
||||
return response.configured
|
||||
}
|
||||
|
||||
export async function putCredential(credentialId: string, apiKey: string): Promise<void> {
|
||||
await apiClient.put<{ credential_id: string; configured: boolean }>(
|
||||
`/api/credentials/${encodeURIComponent(credentialId)}`,
|
||||
{ api_key: apiKey },
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateProvider(providerId: string, data: Partial<ProviderConfig>): Promise<ProviderConfig> {
|
||||
const response = await apiClient.patch<ApiProviderConfig>(`/api/providers/${providerId}`, {
|
||||
name: data.name,
|
||||
base_url: data.base_url,
|
||||
default_model: data.default_model,
|
||||
credential_id: data.credential_id,
|
||||
enabled: data.enabled,
|
||||
})
|
||||
return toProvider(response)
|
||||
}
|
||||
|
||||
export async function deleteProvider(providerId: string): Promise<OperationResponse> {
|
||||
return apiClient.delete(`/api/providers/${providerId}`)
|
||||
}
|
||||
|
||||
export async function listModels(providerId: string): Promise<ModelInfo[]> {
|
||||
try {
|
||||
return await apiClient.get(`/api/providers/${providerId}/models`)
|
||||
} catch {
|
||||
return mockModels[providerId] || []
|
||||
}
|
||||
const response = await apiClient.get<{ provider_id: string; items: ApiModelInfo[] }>(`/api/providers/${providerId}/models`)
|
||||
return response.items.map(toModel)
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
@@ -42,8 +90,8 @@ export interface TestResult {
|
||||
|
||||
export async function testProvider(providerId: string): Promise<TestResult> {
|
||||
try {
|
||||
const result = await apiClient.post<{ success: boolean; latency_ms: number }>('/api/providers/test', { provider_id: providerId })
|
||||
return { success: result.success, latency_ms: result.latency_ms }
|
||||
const result = await apiClient.post<{ success: boolean; latency_ms?: number | null; message: string }>('/api/providers/test', { provider_id: providerId })
|
||||
return { success: result.success, latency_ms: result.latency_ms ?? undefined, error_message: result.success ? undefined : result.message }
|
||||
} catch (e: any) {
|
||||
return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message }
|
||||
}
|
||||
@@ -51,7 +99,7 @@ export async function testProvider(providerId: string): Promise<TestResult> {
|
||||
|
||||
export const mockProviders: ProviderConfig[] = [
|
||||
{
|
||||
provider_id: 'mock-provider',
|
||||
provider_id: 'mock',
|
||||
provider_type: 'mock',
|
||||
name: 'Mock Provider (测试)',
|
||||
default_model: 'mock-1',
|
||||
@@ -69,7 +117,7 @@ export const mockProviders: ProviderConfig[] = [
|
||||
},
|
||||
{
|
||||
provider_id: 'openai-compat-1',
|
||||
provider_type: 'openai-compatible',
|
||||
provider_type: 'openai_compatible',
|
||||
name: 'OpenAI 兼容服务',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
default_model: 'gpt-4o-mini',
|
||||
@@ -106,7 +154,7 @@ export const mockProviders: ProviderConfig[] = [
|
||||
]
|
||||
|
||||
export const mockModels: Record<string, ModelInfo[]> = {
|
||||
'mock-provider': [
|
||||
mock: [
|
||||
{
|
||||
model_id: 'mock-1',
|
||||
name: 'Mock Model v1',
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { SearchRequest, SearchResult } from '@/contracts'
|
||||
import type { ApiSearchResult, PageMeta, SearchRequest, SearchResult } from '@/contracts'
|
||||
|
||||
export async function search(request: SearchRequest): Promise<{
|
||||
results: SearchResult[]
|
||||
total: number
|
||||
mode: SearchRequest['mode']
|
||||
}> {
|
||||
return apiClient.post('/api/search', request)
|
||||
const response = await apiClient.post<{
|
||||
query: string
|
||||
mode: 'fts' | 'vector' | 'hybrid'
|
||||
items: ApiSearchResult[]
|
||||
page: PageMeta
|
||||
}>('/api/search', {
|
||||
query: request.query,
|
||||
mode: request.mode ?? 'hybrid',
|
||||
folders: request.folder ? [request.folder] : [],
|
||||
note_ids: request.note_id ? [request.note_id] : [],
|
||||
tags: request.tag ? [request.tag] : [],
|
||||
limit: request.limit ?? 20,
|
||||
offset: request.offset ?? 0,
|
||||
})
|
||||
return {
|
||||
results: response.items.map((item) => ({
|
||||
block_id: item.block_id,
|
||||
note_id: item.note_id,
|
||||
note_title: item.title,
|
||||
file_path: item.file_path,
|
||||
heading_path: item.heading_path.join(' / '),
|
||||
snippet: item.snippet ?? '',
|
||||
score: item.score,
|
||||
match_type: response.mode,
|
||||
})),
|
||||
total: response.page.total,
|
||||
mode: response.mode,
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchMock(query: string, mode = 'hybrid' as const): Promise<{
|
||||
export async function searchMock(
|
||||
query: string,
|
||||
mode: 'fts' | 'vector' | 'hybrid' = 'hybrid'
|
||||
): Promise<{
|
||||
results: SearchResult[]
|
||||
total: number
|
||||
mode: 'fts' | 'vector' | 'hybrid'
|
||||
|
||||
@@ -1,31 +1,45 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { Skill } from '@/contracts'
|
||||
import type { ApiSkill, OperationResponse, Skill } from '@/contracts'
|
||||
|
||||
export async function listSkills(): Promise<Skill[]> {
|
||||
try {
|
||||
return await apiClient.get('/api/skills')
|
||||
} catch {
|
||||
return mockSkills
|
||||
function toSkill(skill: ApiSkill): Skill {
|
||||
const { manifest } = skill
|
||||
return {
|
||||
skill_id: manifest.skill_id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
permissions: manifest.permissions,
|
||||
tools: manifest.tools,
|
||||
retrieval_config: manifest.retrieval,
|
||||
model_requirements: { capabilities: manifest.model.required_capabilities },
|
||||
status: skill.status,
|
||||
missing_dependencies: skill.missing_dependencies,
|
||||
enabled: skill.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSkill(skillId: string): Promise<Skill> {
|
||||
return apiClient.get(`/api/skills/${skillId}`)
|
||||
export async function listSkills(): Promise<Skill[]> {
|
||||
const response = await apiClient.get<{ items: ApiSkill[] }>('/api/skills')
|
||||
return response.items.map(toSkill)
|
||||
}
|
||||
|
||||
export async function installSkill(skillId: string): Promise<Skill> {
|
||||
return apiClient.post('/api/skills/install', { skill_id: skillId })
|
||||
export async function getSkill(skillId: string): Promise<Skill> {
|
||||
return toSkill(await apiClient.get<ApiSkill>(`/api/skills/${skillId}`))
|
||||
}
|
||||
|
||||
export async function installSkill(packagePath: string): Promise<Skill> {
|
||||
return toSkill(await apiClient.post<ApiSkill>('/api/skills/install', { package_path: packagePath }))
|
||||
}
|
||||
|
||||
export async function enableSkill(skillId: string): Promise<Skill> {
|
||||
return apiClient.post(`/api/skills/${skillId}/enable`)
|
||||
return toSkill(await apiClient.post<ApiSkill>(`/api/skills/${skillId}/enable`))
|
||||
}
|
||||
|
||||
export async function disableSkill(skillId: string): Promise<Skill> {
|
||||
return apiClient.post(`/api/skills/${skillId}/disable`)
|
||||
return toSkill(await apiClient.post<ApiSkill>(`/api/skills/${skillId}/disable`))
|
||||
}
|
||||
|
||||
export async function uninstallSkill(skillId: string): Promise<void> {
|
||||
export async function uninstallSkill(skillId: string): Promise<OperationResponse> {
|
||||
return apiClient.delete(`/api/skills/${skillId}`)
|
||||
}
|
||||
|
||||
@@ -35,8 +49,8 @@ export const mockSkills: Skill[] = [
|
||||
name: '期末复习助手',
|
||||
version: '1.0.0',
|
||||
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
|
||||
icon: '📚',
|
||||
author: '知笔知己团队',
|
||||
icon: '',
|
||||
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 },
|
||||
@@ -49,8 +63,8 @@ export const mockSkills: Skill[] = [
|
||||
name: '会议纪要生成',
|
||||
version: '1.1.0',
|
||||
description: '从音频或文本中提取会议要点、行动项和待办任务',
|
||||
icon: '📝',
|
||||
author: '知笔知己团队',
|
||||
icon: '',
|
||||
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 },
|
||||
@@ -63,7 +77,7 @@ export const mockSkills: Skill[] = [
|
||||
name: '代码解读助手',
|
||||
version: '0.9.0',
|
||||
description: '分析代码片段,解释功能、复杂度和优化建议',
|
||||
icon: '💻',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
permissions: ['notes.search', 'notes.read'],
|
||||
tools: ['notes.search', 'notes.read', 'rag.search'],
|
||||
@@ -77,7 +91,7 @@ export const mockSkills: Skill[] = [
|
||||
name: '文献研究助手',
|
||||
version: '1.2.0',
|
||||
description: '自动整理文献笔记,生成研究综述和引用关系图',
|
||||
icon: '🔬',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
permissions: ['notes.search', 'notes.read', 'notes.write'],
|
||||
tools: ['notes.search', 'notes.read', 'notes.create', 'rag.search'],
|
||||
@@ -92,7 +106,7 @@ export const mockSkills: Skill[] = [
|
||||
name: '语言学习助手',
|
||||
version: '0.5.0',
|
||||
description: '基于你的学习笔记生成语言练习和记忆卡片',
|
||||
icon: '🌍',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
permissions: ['notes.search', 'notes.read', 'tasks.create'],
|
||||
tools: ['notes.search', 'notes.read', 'tasks.create'],
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user