52 KiB
实施方案
状态:已确认,项目骨架已确认,纵向功能开发中 方案版本:0.4.0 需求版本:0.3.0(已确认、已冻结) 确认人:用户 确认时间:2026-08-12
1. 约束与关键默认值
- 产品只面向单人、单实例,不设计多用户、多租户、分布式任务或跨实例协调。
- 应用不实现登录、身份校验、用户会话或访问凭据管理;访问保护完全属于外部反向代理职责。
- 当前版本只交付 Web 链路;CLI 不进入构建产物,但 Agent Core 必须能被无 Web 进程直接调用。
- 普通会话使用
DEFAULT_WORKSPACE_ROOT;项目会话使用所属 Project 保存的workspaceRoot。每次 Run 固化解析后的工作区,运行中不得切换。 - 同一时刻只允许一个 Agent Run 处于活动状态;“等待用户”仍属于活动状态。
- 首个模型适配器采用 DeepSeek OpenAI 兼容接口;Agent Core 只依赖自有
ModelPort,后续可以增加其他模型适配器。 - 运行时和包管理统一使用 Bun;应用代码统一使用 TypeScript 严格模式。
- 项目、会话、消息、运行事件与设置全部使用真实本地文件系统存储,不引入数据库;业务数据目录与各工作区使用不同根目录和权限边界。
- UI 使用 VanJS 单页应用,视觉实现以 CSS Modules 和设计令牌为主,避免组件库默认样式妨碍 Claude Desktop 还原。
- 主要目标环境为 macOS/Linux;Windows 不是 v0.1.0 验收平台。
2. 技术栈
| 层级 | 选择 | 用途与理由 |
|---|---|---|
| 运行时/包管理 | Bun Workspaces | 单一工具完成安装、脚本、测试和服务运行;适合单实例应用 |
| 语言 | TypeScript strict | 为跨层接口、事件和未来 CLI 提供稳定类型边界 |
| Web UI | VanJS + Vite | 使用接近原生 DOM 的函数组件和细粒度 State 绑定,运行时极轻;Vite 负责编译 TypeScript、CSS Modules 和生产构建 |
| 样式 | CSS Modules + CSS Variables | 精确控制尺寸、间距和状态,避免大而统一的全局样式文件 |
| Web Serve | Hono | 轻量路由、流式响应和 Bun 运行时支持 |
| 数据校验 | Zod | 在 HTTP 边界、环境配置和模型工具输入处执行运行时校验 |
| 本地数据 | Bun 文件 API + 原子替换 + NDJSON | 会话与运行数据直接落在可查看、可备份的真实目录中;按实体拆分,避免单个巨型 JSON 文件 |
| 模型接入 | DeepSeek OpenAI 兼容 API + openai SDK |
首版实现 DeepSeek 流式回复和工具调用;SDK 仅存在于独立适配包中 |
| Markdown | markdown-it + DOMPurify |
与前端框架解耦,渲染常用 Markdown,并在插入 DOM 前执行白名单净化 |
| 代码高亮 | Shiki | 输出稳定、主题可控,便于贴近目标视觉 |
| 日志 | Pino | 结构化日志、字段脱敏和运行标识关联 |
| 静态检查 | Biome + TypeScript | 格式、常见质量问题和类型检查 |
| 单元/集成测试 | Bun Test | 与运行时一致,覆盖 Core、存储、文件和服务接口 |
| 浏览器验收 | Playwright | 端到端流程、固定视口截图和视觉回归 |
不引入 React/Preact、JSX、Redux、Zustand、Tailwind、大型 UI 组件库、ORM、消息队列或独立数据库服务。Web 数据获取、SSE 生命周期和局部交互状态使用项目内的明确模块与 VanJS State 管理。
前端选型结论
- 采用 VanJS。 当前 UI 是单页、单用户桌面式界面,不依赖大型组件生态;核心交互可用 DOM 函数组件、
van.state和van.derive表达。 - 使用
vanjs-core的 NPM 包并通过 Vite 构建,不从 CDN 加载运行时代码。 - 默认只使用 VanJS Core。只有骨架或 F-001 的消息/会话列表验证证明细粒度数组操作明显更清晰时,才允许增加官方 VanX;增加前必须记录具体用途,不把它当作通用全局状态容器。
- 不启用社区 JSX 转换、社区路由或 UI 组件库。首版仅有一个应用入口,设置使用面板或弹窗,不需要前端路由。
- Markdown、代码高亮和内容净化继续使用框架无关的
markdown-it、Shiki 和 DOMPurify。 - 采用 VanJS 后必须显式约束状态粒度和资源释放,避免因缺少框架生命周期而产生重复订阅或泄漏。
3. 总体架构
┌──────────────────────────────────────────────────────┐
│ apps/web │
│ VanJS UI、页面状态、流式事件消费、视觉呈现 │
└──────────────────────────┬───────────────────────────┘
│ HTTP + SSE
┌──────────────────────────▼───────────────────────────┐
│ apps/web-server │
│ Hono 路由、DTO 转换、SSE、进程组合、静态资源 │
└─────────────┬──────────────┬──────────────┬──────────┘
│ │ │
┌─────────────▼──────┐ ┌─────▼────────┐ ┌───▼─────────────┐
│ packages/agent-core│ │ local-files │ │ local-data │
│ 领域、用例、Ports │ │ 工作区工具 │ │ 运行数据文件 │
└─────────────▲──────┘ └─────┬────────┘ └───┬─────────────┘
│ │ │
│ 实现 Core Ports │
┌─────────────┴──────────────┴──────────────┴──────────┐
│ packages/model-deepseek │
│ ModelPort 的 DeepSeek 适配器 │
└──────────────────────────────────────────────────────┘
未来:apps/cli -> agent-core + local-files + local-data + model adapter
代码依赖规则:
agent-core不依赖 Hono、VanJS、DeepSeek/OpenAI SDK 或 Node/Bun 文件 API。local-files、local-data、model-deepseek实现 Agent Core 定义的 Ports。web-server是组合根,负责创建适配器并注入 Agent Core。web只依赖 Web DTO 和事件契约,不导入服务端包。- 未来 CLI 直接组合 Agent Core 与相同适配器,不通过 HTTP 调用本机 Web Server。
4. 目录结构
great-agent2/
├── apps/
│ ├── web/
│ │ └── src/
│ │ ├── app/
│ │ ├── features/
│ │ │ ├── onboarding/
│ │ │ ├── projects/
│ │ │ ├── conversations/
│ │ │ ├── messages/
│ │ │ ├── interactions/
│ │ │ ├── composer/
│ │ │ ├── files/
│ │ │ └── settings/
│ │ ├── components/
│ │ ├── api/
│ │ └── styles/
│ └── web-server/
│ └── src/
│ ├── routes/
│ ├── streaming/
│ ├── http/
│ ├── config/
│ └── composition/
├── packages/
│ ├── agent-core/
│ │ └── src/
│ │ ├── domain/
│ │ ├── ports/
│ │ ├── use-cases/
│ │ ├── events/
│ │ └── errors/
│ ├── local-files/
│ │ └── src/
│ │ ├── paths/
│ │ ├── readers/
│ │ ├── search/
│ │ └── writers/
│ ├── local-data/
│ │ └── src/
│ │ ├── layout/
│ │ ├── atomic-writes/
│ │ ├── recovery/
│ │ ├── repositories/
│ │ └── indexing/
│ ├── model-deepseek/
│ │ └── src/
│ │ ├── adapter/
│ │ ├── streaming/
│ │ ├── tool-mapping/
│ │ └── interaction-mapping/
│ └── web-contracts/
│ └── src/
│ ├── requests/
│ ├── responses/
│ └── events/
├── tests/
│ ├── e2e/
│ ├── visual/
│ └── fixtures/
├── scripts/
│ ├── check-architecture.ts
│ └── check-file-size.ts
├── data/ # 运行时生成,Git 忽略
├── docs/
├── package.json
├── bunfig.toml
├── tsconfig.base.json
└── biome.json
不创建含糊的 shared/、helpers/ 或单一巨型 utils.ts。跨包内容必须有明确领域名称和使用方向。
5. 文件规模与模块拆分规则
- 业务源码目标不超过 250 行;超过 300 行必须拆分或在代码评审中记录明确理由。
- 非生成的 TypeScript 业务文件超过 400 行时,
check:file-size直接失败。 - 生成文件、格式迁移固定数据、测试固定数据和纯类型映射可豁免,但必须在检查脚本中显式列出。
- VanJS 应用外壳只负责组合 feature 组件,不直接包含 API、持久化或 Agent 规则。
- 路由处理器只做校验、调用用例、转换响应,不实现领域逻辑。
- 每个 Port、Repository、Use Case 和工具处理器独立成文件或小型同职责目录。
- 使用包导出控制依赖边界;
check:architecture禁止 Core 导入外层包和 Web 导入服务端实现。
6. Agent Core 设计
领域对象
Project:项目标识、名称、绑定工作区、时间和项目会话摘要。Conversation:会话标识、可空projectId、标题、时间和消息顺序。Message:角色、内容块、附件引用、运行标识和时间。AgentRun:等待、运行中、调用工具、等待用户、已完成、失败、已取消。ToolCall:工具名称、已校验输入、状态、结果摘要和错误。UserInteraction:所属 Run、消息位置、交互类型、问题、选项、限制、回答和状态。AttachmentRef:工作区相对路径、类型、可用状态和最近确认时间。
Ports
ModelPort:接收项目自有的模型请求,返回项目自有的异步事件流,并支持取消;它不是 TCP/HTTP 端口。ProjectRepository:项目实体与项目索引的一致性读写。ConversationRepository:会话与消息的一致性读写。RunRepository:运行状态、工具调用和流式草稿持久化。InteractionRepository:等待回答、已回答和已取消交互的一致性读写。SettingsRepository:非敏感配置。WorkspacePort:列出、搜索、读取、创建和修改文件。WorkspaceResolverPort:按普通会话或项目会话解析并校验当前工作区,不允许失败时跨边界回退。ClockPort、IdPort:保证测试可控。
核心用例
CreateProjectListProjectsGetProjectListProjectConversationsCreateConversationWithFirstRunListRecentConversationsRenameConversationDeleteConversationGetConversationStartAgentRunCancelAgentRunRetryAgentRunRespondToInteractionListWorkspaceFilesReadAttachmentGetSettings/UpdateSettings
单任务限制
- Core 内维护全局活动 Run 约束,而不是由按钮禁用单独保证。
- Core 的单进程运行协调器执行原子检查;
local-data同时维护活动 Run 指针,供重启恢复。 - “新任务”和项目内“新对话”只是 Web UI 草稿状态,不调用 Repository;第一条有效消息提交时,Core 才创建会话与首个 Run。
- 首次创建时先校验
projectId与工作区,再写入会话、用户消息、Run 事件日志和活动 Run 指针;失败时不得在索引中留下可见空会话。 - 已有会话创建 Run 前再次校验其 Project 与工作区绑定,绝不接受客户端直接传入任意工作区路径。
- 第二个 Run 请求返回稳定错误码
RUN_ALREADY_ACTIVE。 - 等待用户回答的 Run 同样占用全局活动位置;回答会恢复原 Run,停止会取消其全部未回答交互。
7. ModelPort 与 DeepSeek 适配器
ModelPort 是 Agent Core 自己定义的一份 TypeScript 接口,名称中的 Port 表示“架构边界”,不是网络端口。调用方向如下:
Agent Core -> ModelPort.stream(项目领域请求) -> DeepSeekModelAdapter
Agent Core <- AsyncIterable<项目领域事件> <- DeepSeek SSE/SDK 事件
它解决四个具体问题:
- Core 不认识 DeepSeek 的请求、响应或 SDK 类型,只认识
ModelRequest、ModelEvent、ToolDefinition等项目类型。 DeepSeekModelAdapter负责翻译角色、内容块、工具定义、增量文本、工具调用、结束原因和错误。- 单元测试可以注入
FakeModelAdapter,不联网也能确定性验证工具循环、取消和失败。 - 未来增加其他模型或 CLI 时复用 Core,不需要修改业务用例和 Web 协议。
概念接口如下,具体字段在骨架阶段再固化:
interface ModelPort {
stream(request: ModelRequest, signal: AbortSignal): AsyncIterable<ModelEvent>;
}
model-deepseek把领域消息映射为 DeepSeek 的 OpenAI 兼容 Chat Completions 请求,把流式 SSE/SDK 响应映射为 Core 事件。- SDK 对象和响应类型不得穿透
ModelPort。 - 工具定义由 Core 提供,适配器只负责协议映射。
- 取消操作通过
AbortSignal传递。 - 默认模型为当前 DeepSeek API 提供的
deepseek-v4-pro,但模型名、Base URL、思考模式、最大输出和超时均由环境配置提供;密钥只在服务端进程读取。 - 默认不把完整提示词、消息正文、文件内容或密钥写入日志。
- 模型错误统一映射为
MODEL_CONFIG_MISSING、MODEL_TIMEOUT、MODEL_RATE_LIMITED、MODEL_UNAVAILABLE或MODEL_RESPONSE_INVALID。
内置用户交互工具
Core 向模型提供名为 request_user_interaction 的内置工具,支持 single_choice、multiple_choice、confirmation 和 free_text。它与文件工具使用相同的模型工具调用入口,但执行方式不同:
model-deepseek只把 DeepSeek 工具调用转换成项目自有ToolRequest,不决定界面行为。- Core 识别
request_user_interaction,校验问题、选项、选择数量和文本上限。 - 校验成功后保存
UserInteraction,把 Run 设为“等待用户”,发送interaction.requested;不发送tool.completed。 - 当前 DeepSeek HTTP 流在工具调用结束后正常关闭;这里暂停的是项目内的 Run,不是长期占用一个模型连接。
- 用户回答后,Core 原子保存答案并发送
interaction.resolved,再以同一toolCallId构造工具结果,发起下一次 DeepSeek 请求。 - 后续模型请求、消息和工具调用继续归入原
runId,因此对用户仍是同一次任务。 - 用户停止任务时,未回答交互变为已取消并发送
interaction.cancelled。
无效的交互参数作为工具错误返回模型,不创建前端卡片。首版每个 Run 同一时刻最多有一个等待回答的交互;若模型并行请求多个用户交互,只接受事件顺序中的第一个,其余返回工具冲突错误。
如果用户在本阶段改选其他模型提供方,只替换模型适配包与环境配置,不修改 Core、Web Serve 或 Web UI 契约。
8. 本地文件层
工具集合
list_directory:列出目录内容。search_files:按文件名和文本内容搜索。read_text_file:读取 UTF-8 文本。create_text_file:只创建不存在的文本文件。apply_text_patch:基于预期内容哈希修改已有文本文件。
当前版本不提供删除、移动、目录递归覆盖和任意 Shell 工具。
路径保护
每次操作依次执行:
- Core 根据
conversationId读取可信的projectId,由WorkspaceResolverPort解析默认工作区或项目工作区;浏览器和模型都不能直接指定根目录。 - 项目工作区不存在或不可访问时返回
PROJECT_WORKSPACE_UNAVAILABLE,不得回退到默认工作区。 - 拒绝工具输入中的空字节、绝对路径和显式父目录逃逸。
- 将工具输入解析为当前工作区内相对路径。
- 对已存在目标执行
realpath,确认仍位于当前工作区真实路径下。 - 对新文件检查最近的已存在父目录,阻止符号链接逃逸。
- 检查文件类型、大小、编码和操作权限。
默认限制
- 单条用户文本最大 32 KiB。
- 单次最多附加 10 个文件。
- 单个可读附件最大 2 MiB。
- 单次 API 请求体最大 5 MiB。
- 单次文本搜索最多返回 200 个匹配项,每个文件最多返回 20 个片段。
- 超过限制时返回明确错误,不静默截断用户文件。
写入策略
- 新建文件使用排他创建,已存在时失败。
- 修改文件要求传入读取时获得的内容哈希,避免覆盖已变化内容。
- 在同目录写临时文件后执行原子替换,并尽可能保留原文件权限。
- 写入成功后返回新哈希和简洁 diff 摘要。
9. 真实本地文件持久化
所有项目、会话与运行数据默认位于 ${DATA_DIR}。不使用 SQLite,不把全部数据塞进单个 JSON;每个 Project、Conversation 和 Run 独立成目录,事件采用可追加、可重放的 NDJSON。
${DATA_DIR}/
├── version.json
├── settings.json
├── projects/
│ ├── index.json
│ └── <project-id>/
│ └── project.json
├── conversations/
│ ├── index.json
│ └── <conversation-id>/
│ ├── conversation.json
│ ├── messages/
│ │ └── <sequence>-<message-id>.json
│ └── runs.json
├── runs/
│ └── <run-id>/
│ ├── run.json
│ ├── events.ndjson
│ └── interactions/
│ └── <interaction-id>.json
├── runtime/
│ ├── active-run.json
│ └── intents/
│ └── <operation-id>.json
└── recovery/
└── quarantined/
project.json、conversation.json、消息文件、run.json、交互文件和settings.json均包含schemaVersion。project.json保存名称和规范化后的绝对workspaceRoot;conversation.json保存可空projectId。会话所属关系只以conversation.json为事实来源。events.ndjson是单 Run 的追加日志,每行一个带sequence的完整 JSON 事件;终态后不再修改。- Projects 与 Conversations 下的
index.json以及runs.json只是可重建索引,不是事实来源;损坏或缺失时从实体文件重建。 - 消息正文、工具输入和文件引用分别落到所属实体,不产生跨会话的巨型文件;模型密钥永不落盘。
- 交互文件保存问题、选项、约束、状态和答案;模型提供的文字按纯文本存储,前端不得直接当作 HTML 插入。
一致性与恢复策略
- 单实例内使用串行写队列;每个实体更新都在同目录写临时文件、刷新后原子
rename替换。 - 创建 Project、首次创建 Conversation+Run 等跨文件操作先写入带阶段标记的 intent;实体可靠落盘后才更新可见索引,全部完成后删除 intent。
- 用户回答使用同一个串行写队列:先原子更新交互文件,再追加
interaction.resolved,最后恢复 Run,防止答案已经送给模型但本地仍显示未回答。 - 删除项目使用 intent 分批移除项目实体、所属会话和运行数据;完成前项目继续可见,失败时按 intent 恢复或重试。删除流程从不操作
workspaceRoot下的文件。 - 启动时按 intent 阶段幂等继续或回滚未完成操作;未进入可见索引的半成品实体不会出现在 UI,无法自动处理时移入隔离目录。
- 运行事件先追加并刷新
events.ndjson,再更新可重建的摘要文件;服务崩溃后以事件日志恢复 Run。 - 启动时清理明确可识别的临时文件并检查事件序列:模型请求或普通工具执行中断的 Run 标记为
RUN_INTERRUPTED;具有有效待回答交互的 Run 恢复为“等待用户”,允许继续回答。 - 文件损坏不静默覆盖:原文件移入
recovery/quarantined,记录结构化错误,再从事件或实体重建;无法重建时向 UI 报告。 - 数据格式升级先生成
${DATA_DIR}.backup-<timestamp>同级备份,再逐实体迁移;迁移必须可重复执行。 - Repository 层屏蔽目录布局、原子写入和恢复细节,路由和 UI 不直接读写运行数据文件。
- 因为仅支持单人单进程,不实现跨进程事务或并发锁服务;启动时若发现另一个有效进程锁则拒绝启动,避免两个实例同时写同一
DATA_DIR。
10. Web Serve 接口
所有路径以 /api 为业务前缀。应用内部不设置访问身份相关端点或中间件。
状态与设置
GET /api/health:进程、数据目录和默认工作区可用状态,不因某个历史项目目录离线而使整个进程不健康。GET /api/status:模型是否已配置、默认工作区摘要、不可用项目数量、活动 Run 和版本。GET /api/settingsPATCH /api/settings
项目
GET /api/projects?cursor=:项目列表和工作区可用状态。POST /api/projects:提交名称和绝对workspaceRoot;服务端规范化、realpath并验证目标为可读写目录后创建。GET /api/projects/:projectIdPATCH /api/projects/:projectId:只允许修改非空名称,不允许修改workspaceRoot。DELETE /api/projects/:projectId:请求体携带项目名称作二次确认;有活动 Run 时拒绝。删除应用内项目及所属聊天数据,绝不访问工作区内容。GET /api/projects/:projectId/conversations?cursor=
会话
GET /api/conversations?scope=recent&cursor=:只返回普通会话。GET /api/conversations/:conversationIdPATCH /api/conversations/:conversationIdDELETE /api/conversations/:conversationId
不提供创建空会话接口。新任务在浏览器内保持草稿状态,第一条消息通过创建 Run 接口原子生成会话。
Agent 运行
POST /api/runs:提交以下互斥形态之一:- 已有会话:
conversationId + message + attachments。 - 普通新对话:
kind=ordinary + message + attachments。 - 项目新对话:
kind=project + projectId + message + attachments。
- 已有会话:
- 新对话请求必须在同一 Core 用例中创建 Conversation、首条消息和 Run;响应返回
conversationId与runId。 GET /api/runs/:runIdGET /api/runs/:runId/events:SSE 事件流。POST /api/runs/:runId/cancelPOST /api/runs/:runId/retry
用户交互
GET /api/interactions/:interactionId:刷新后读取等待、已回答或已取消状态。POST /api/interactions/:interactionId/response:提交单选、多选、确认或自由文本答案。- 回答成功后恢复原 Run;相同答案重复提交返回当前结果,不同答案重复提交返回
INTERACTION_ALREADY_RESOLVED。 - Run 取消后提交返回
INTERACTION_CANCELLED。
工作区
GET /api/workspace/tree?conversationId=&projectId=&path=&cursor=GET /api/workspace/search?conversationId=&projectId=&query=&cursor=GET /api/workspace/file?conversationId=&projectId=&path=:仅为附件选择和预览提供受限文本响应。
conversationId、projectId 和普通草稿上下文必须满足服务端定义的互斥校验。已有会话始终从持久化关系解析工作区;项目草稿使用已存在的 projectId;普通草稿使用默认工作区。
文件创建和修改不直接暴露为通用浏览器 API,由 Agent 工具通过 Core 调用,降低页面误操作面。
响应约定
成功响应直接使用有业务含义的字段,不统一套一层 data/meta。requestId 通过 X-Request-ID 响应头返回。
创建项目成功(201 Created):
{
"project": {
"id": "project_01",
"name": "Great Agent 2",
"workspaceRoot": "/workspace/great-agent2",
"workspaceAvailable": true,
"conversationCount": 0
}
}
列表成功(200 OK):
{
"items": [],
"nextCursor": null
}
首条消息创建会话和 Run(202 Accepted):
{
"conversationId": "conversation_01",
"runId": "run_01",
"status": "running"
}
回答交互成功(200 OK):
{
"interactionId": "interaction_01",
"runId": "run_01",
"status": "resolved",
"answer": {
"selectedOptionIds": ["minimal"]
}
}
错误统一使用:
{
"error": {
"code": "RUN_ALREADY_ACTIVE",
"message": "当前已有 Agent 任务正在运行",
"requestId": "..."
}
}
DTO 全部在 web-contracts 中定义并用 Zod 校验,领域错误由 Web Serve 映射为 HTTP 状态。
11. 流式事件协议
使用 SSE。每个事件都包含 runId、单调递增的 sequence、timestamp 和类型明确的 payload。
事件类型:
run.startedmessage.startedmessage.deltamessage.completedtool.startedtool.completedtool.failedinteraction.requestedinteraction.resolvedinteraction.cancelledconversation.title.updatedrun.completedrun.failedrun.cancelledstream.heartbeat
三类内容事件
message.*表示助手主要回复,必须带messageId。message.started先建立消息位置,message.delta只追加该消息文本,message.completed固化完整内容。tool.*表示文件读取、搜索、创建、修改等普通工具调用,必须带messageId和toolCallId。工具卡片插在对应助手消息的时间线位置。interaction.*表示需要用户操作的卡片,必须带messageId、toolCallId和interactionId。它不显示为普通工具完成卡片。
conversation.title.updated 带 conversationId 和新标题,用于首条消息后刷新左侧列表。服务端不发送 render_component、open_dialog 等前端组件命令;Web UI 根据事件类型和交互 kind 自行选择 VanJS 组件。
交互请求示例:
{
"type": "interaction.requested",
"runId": "run_01",
"messageId": "message_02",
"toolCallId": "tool_01",
"interactionId": "interaction_01",
"sequence": 8,
"timestamp": "2026-08-11T10:00:00Z",
"payload": {
"kind": "single_choice",
"question": "这次修改采用哪种方式?",
"options": [
{ "id": "minimal", "label": "最小修改" },
{ "id": "refactor", "label": "同时重构" }
],
"required": true
}
}
断线恢复:
- Web 保存最后收到的
sequence。 - 重新连接时通过
Last-Event-ID请求缺失事件。 - 服务端先从持久化记录补发,再接入当前内存流。
- 已完成 Run 的事件端点返回可重放的终态序列,不创建新 Run。
- 等待用户的 Run 重连后先补发
interaction.requested;若交互已经回答或取消,则按顺序补发对应终态事件。 - 页面刷新只读取原 Run,不自动重试模型请求。
12. Web UI 设计
页面状态
- 首次空状态:最近为空、项目为空、右侧对话引导
- 未选择状态:已有数据但右侧仍显示对话引导
- 普通新对话草稿状态
- 项目创建面板状态
- 项目重命名状态
- 项目删除确认和删除失败状态
- 空项目状态:项目已创建但还没有会话
- 项目新对话草稿状态
- 已选普通会话状态
- 已选项目会话状态
- 流式生成状态
- 工具执行状态
- 等待用户回答状态
- 交互已回答和已取消状态
- 失败状态
- 已取消状态
- 设置面板
- 会话重命名/删除菜单
组件拆分
AppShellNewTaskButtonConversationSidebarRecentSectionConversationListConversationMenuProjectsSectionProjectListProjectItemProjectMenuProjectConversationListProjectCreatePanelProjectRenameDialogProjectDeleteDialogConversationGuideChatHeaderMessageTimelineUserMessageAssistantMessageToolActivityInteractionBlockSingleChoiceCardMultipleChoiceCardConfirmationCardFeedbackInputCardMarkdownContentCodeBlockComposerAttachmentPickerSettingsPanel
组件只接收视图模型、VanJS State 和回调。请求、SSE、资源释放和领域状态分别放在 feature controller、stream subscription 与 API client 中。项目目录输入属于创建项目配置,只提交到服务端验证,不传入文件工具接口。
交互组件由前端渲染表选择,不允许服务端传组件名称:
single_choice -> SingleChoiceCard
multiple_choice -> MultipleChoiceCard
confirmation -> ConfirmationCard
free_text -> FeedbackInputCard
提交期间卡片进入只读加载状态;成功后显示只读答案,失败后恢复可编辑并展示明确错误。等待回答时普通输入框禁用,但停止任务仍可用。
页面选择状态机
Web 使用显式联合状态,不用多个可能互相冲突的布尔值表示当前右侧内容:
selection =
| { kind: "none" }
| { kind: "ordinary-draft" }
| { kind: "project-draft"; projectId: string }
| { kind: "conversation"; conversationId: string }
| { kind: "project-create" }
| { kind: "project-rename"; projectId: string }
| { kind: "project-delete"; projectId: string }
- 应用启动总是进入
none;加载列表不会自动选择第一项。 - 点击“新任务”进入
ordinary-draft,不会发起创建会话请求。 - 创建项目成功后进入该项目的
project-draft;项目仍可保持零会话。 - 项目重命名成功后保留当前选择并刷新项目列表;删除当前项目成功后回到
none,不自动选择其他项目。 - 普通或项目草稿首次发送成功后,使用服务端返回的
conversationId切换为conversation。 - 点击任意历史会话直接进入
conversation,加载完成后恢复消息并可续聊。 - 切换 selection 前先释放旧 SSE、Observer、全局事件和定时器。
视觉还原流程
在进入 UI 功能切片前建立参考包,至少包含 1440 × 900 下的首次空状态、普通新任务、项目创建/重命名/删除、空项目、普通历史会话、项目历史会话、流式回复、工具状态、四类用户交互、错误状态、侧栏菜单和设置面板截图。
实现顺序:
- 固定字体、颜色、间距、圆角、阴影和层级令牌。
- 对齐整体布局和主要区域尺寸。
- 对齐消息、输入框、菜单和交互状态。
- 使用 Playwright 在固定数据和固定视口下生成截图。
- 对参考截图进行人工并排检查,并为已确认的本项目截图建立回归基线。
不复制 Claude 商标、账号入口或当前版本不支持的小组件。没有实现能力的组件不渲染。
13. 状态与滚动行为
- 服务端会话数据是事实来源,Web 本地只保存正在编辑文本、侧栏折叠和滚动跟随状态。
- 项目、普通最近会话和项目会话列表由服务端数据驱动;当前
selection和尚未发送的草稿只存在浏览器内存中。 - 应用状态按 feature 拆分为小型
van.state,禁止创建一个包含全部会话、消息、设置和 UI 状态的巨型 Store。 - 会话和消息集合按不可变方式替换;正在流式生成的助手消息使用独立文本 State,收到 delta 时只更新该消息,不重建完整消息数组或时间线 DOM。
van.derive只用于纯派生值或有明确释放边界的副作用,不在渲染函数中隐式创建长期订阅。- 每个会创建 SSE、全局事件、Observer 或定时器的 feature controller 都必须返回
dispose();切换会话、关闭面板和卸载应用时显式调用。 - 发送成功前保留输入草稿;请求确认创建 Run 后再清空。
- 当用户距离底部小于阈值时自动跟随流式内容。
- 用户主动向上滚动后暂停自动跟随,并显示“回到底部”按钮。
- 切换会话或项目时关闭旧 SSE 订阅,根据 Run 状态决定是否订阅新会话活动流。
- 删除当前会话后回到
none,不自动选择最近会话。 - 项目工作区不可用时仍加载历史记录,但隐藏或禁用附件入口;Agent 文件工具返回稳定错误,不回退到普通默认工作区。
- 收到
interaction.requested后把对应 Run 标记为等待用户,禁用普通输入框并显示交互卡片。 - 回答成功以前不做乐观完成;只有收到成功响应或
interaction.resolved后才把卡片变为只读。 - 刷新页面时从会话历史和 Run 状态恢复交互卡片;不依赖浏览器内存保存待回答问题。
14. 错误、取消与重试
- 所有可预期错误使用稳定错误码,UI 不依据文本判断类型。
- 取消请求必须幂等;重复取消已结束 Run 返回其当前终态。
- 重试创建新 Run,并通过
retryOfRunId指向原 Run;不覆盖原始失败记录。 - 回答交互不属于重试,不创建新 Run;相同答案重复提交幂等,不同答案返回冲突。
- 停止等待用户的 Run 时,先把交互标记为已取消,再结束 Run;后续回答返回稳定错误。
- 模型失败时保留用户消息和已落盘的助手草稿,明确显示失败。
- 本地持久化失败时优先停止运行并提示数据未可靠保存。
- 文件工具失败只结束当前工具调用;是否继续由 Core 和模型响应决定。
- 交互参数无效时向模型返回工具错误,不渲染半成品卡片;答案保存失败时保持等待状态,不能恢复模型调用。
15. 配置与环境变量
HOST=127.0.0.1
PORT=3000
DATA_DIR=./data
DEFAULT_WORKSPACE_ROOT=/absolute/path/to/default-workspace
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-pro
DEEPSEEK_THINKING=enabled
MODEL_TIMEOUT_MS=120000
LOG_LEVEL=info
- 使用 Zod 在进程启动时校验配置。
- 本地开发允许模型密钥缺失,但
/api/status必须报告“未配置”,发送请求返回稳定错误。 - 生产启动要求
DATA_DIR和DEFAULT_WORKSPACE_ROOT为可读写的明确路径;Project 工作区在创建和每次使用前单独校验。 - 仓库只提供
.env.example,绝不提供真实.env。 - 不定义反向代理或访问凭据相关环境变量。
16. 日志与可观测性
- 每个 HTTP 请求生成
requestId,每次运行携带runId和conversationId。 - 默认记录状态、耗时、错误码、模型名称、工具名称和交互类型,不记录完整消息、交互问题/答案、文件内容或模型密钥。
- 本地开发使用可读日志,生产默认输出 JSON。
/api/health检查进程、数据目录布局、写入能力和默认工作区可访问性;不执行模型请求,也不因单个历史项目工作区离线失败。- 错误堆栈仅写服务端日志,客户端返回安全摘要。
- v0.1.0 不接入外部指标平台;结构化日志为后续监控保留基础。
17. 测试方案
Agent Core 单元测试
- Project 创建、项目会话所属关系和工作区解析。
- 普通草稿与项目草稿在首条消息前不持久化,首个 Run 创建后会话可见。
- 会话与消息状态流转。
- 单活动 Run 约束。
- 模型事件到领域事件的处理。
- 工具调用、取消、失败和重试。
- 四类交互请求校验、等待用户、回答后恢复同一 Run、取消和重复回答。
- DeepSeek 的交互工具调用被转换为
interaction.*,不错误地产生tool.completed。 - 无 Web Server 的 Core 初始化与运行。
本地文件测试
- 普通会话解析默认工作区,项目会话解析项目工作区。
- 项目工作区失效时明确失败且绝不回退到默认工作区。
- 正常列出、搜索、读取、创建和修改。
..、绝对路径、编码路径和空字节。- 文件与父目录符号链接逃逸。
- 文件大小、编码和权限错误。
- 内容哈希冲突与原子写入失败。
持久化测试
- Project 创建/读取、项目索引重建以及 Conversation
projectId关系恢复。 - Repository CRUD、串行写入和原子替换失败。
- schemaVersion 迁移、备份、索引重建、损坏隔离和 NDJSON 尾部残缺恢复。
- 服务重启后的恢复。
- 模型请求中的遗留 Run 转换为
RUN_INTERRUPTED;带有效待回答交互的 Run 保持“等待用户”。 - 等待交互、回答、取消和答案写入失败的恢复;答案不得先送给模型后丢失。
- 项目重命名和删除 intent;删除应用数据后工作区文件保持原样。
Web Serve 集成测试
- DTO 校验、错误映射和请求体限制。
- 项目创建/列表、普通最近会话、项目会话与三种 Run 创建形态。
- 第一条消息创建会话失败时没有可见空会话残留。
- 项目重命名、删除确认、活动 Run 删除拒绝和重复删除终态。
- 交互读取与回答接口、答案校验、幂等提交和冲突提交。
- SSE 顺序、心跳、补发、终态和断线重连。
- 应用内部不存在访问身份相关路由和中间件。
Web UI 测试
- 首次进入、无自动选择、普通草稿、项目创建、空项目和项目草稿状态。
- 项目与普通历史会话选择后分别还原记录并续聊。
- 项目重命名、删除确认文案、删除失败和当前项目删除后的返回状态。
- 单选、多选、确认和意见输入卡片;提交加载、成功只读、失败恢复、取消与刷新恢复。
- 会话切换、输入、停止和重试。
- 仅附件消息与空消息。
- 流式消息、工具状态和错误状态。
- 高频 delta 只更新活动消息节点,不重建整个消息时间线。
- 切换会话和关闭面板后,SSE、全局事件、Observer 与定时器均被释放,重复进入不会产生重复回调。
- 对
van.derive的异步传播使用显式等待,不依赖立即同步更新的错误假设。 - 滚动跟随暂停与恢复。
- 固定视口视觉截图。
端到端测试
- 新任务首条消息后创建普通会话并完成一次真实适配器的可替换测试流。
- 创建绑定临时目录的项目,在项目内创建两个独立会话,重启后恢复所属关系和历史。
- Fake Model 发起四类用户交互,回答后继续同一 Run;等待期间刷新仍可回答。
- 删除项目后项目聊天数据消失,但临时工作区内的校验文件仍存在。
- 使用确定性 Fake Model 完成工具调用全链路。
- 刷新与服务重启恢复。
- 模型失败、取消、文件消失和持久化失败。
- 工作区外文件访问被拒绝。
18. 验证命令
项目骨架阶段必须创建并实际运行以下命令:
bun install
bun run format:check
bun run lint
bun run typecheck
bun run check:file-size
bun run check:architecture
bun test
bun run build
功能开发阶段增加:
bun run test:e2e
bun run test:visual
不得用跳过测试、删除测试或放宽检查阈值的方式获得通过结果。
19. 部署形态
- 生产构建生成 Web 静态资源,由同一个 Hono/Bun 进程提供。
- 默认本机运行绑定
127.0.0.1;容器运行时可显式设置0.0.0.0,但只暴露到受控网络。 - 提供单进程启动命令和多阶段 Dockerfile。
- 数据目录、默认工作区和所有项目工作区通过宿主路径或容器卷挂载,不打包进镜像;容器内创建项目时填写容器可见路径。
- 进程接收终止信号后停止新 Run、取消活动模型请求、刷新写入队列并安全关闭文件句柄。
- 外部反向代理、TLS 和访问策略由用户在部署环境统一配置,不进入本仓库。
20. 项目骨架阶段范围
需求和实施方案确认后,项目骨架阶段只完成:
- Bun Workspaces 与上述目录边界。
- 各包最小入口、类型检查和受控导出。
- VanJS/Vite 可启动空页面,不实现 Claude Desktop 业务界面。
- Hono 健康检查与静态资源基础,不实现业务路由。
local-data的目录布局、原子 JSON 写入、NDJSON 追加和格式版本框架,不创建完整业务 Repository。- 环境配置校验、日志和错误基础。
- 文件规模与依赖方向检查脚本。
- 最小单元测试、构建和 Dockerfile。
骨架阶段不得提前实现会话、模型调用、本地文件工具或完整 UI。
项目骨架验证记录(2026-08-12)
骨架已按本节范围生成,实际验证环境为 Bun 1.3.11。以下命令均在项目根目录运行:
| 命令 | 结果 |
|---|---|
bun install |
通过;安装 76 个包并生成 bun.lock |
bun run format:check |
通过;检查 46 个文件,无需修改 |
bun run lint |
通过;检查 47 个文件,无警告或错误 |
bun run typecheck |
通过;7 个 Workspace 包全部通过 |
bun run check:file-size |
通过;TypeScript 文件均不超过 400 行 |
bun run check:architecture |
通过;Core 与 Web 依赖方向符合约束 |
bun test |
通过;5 个测试、13 个断言,0 失败 |
bun run build |
通过;VanJS/Vite 与 Hono/Bun 生产产物均生成成功 |
额外生产产物冒烟验证:使用 PORT=3137 和临时数据目录启动 apps/web-server/dist/index.js,GET /api/health、GET / 和构建后的 JavaScript 静态资源均返回 200;健康检查包含 X-Request-ID,并生成 projects、conversations、runs、indexes、recovery 五个数据目录。
当前未配置真实 DEEPSEEK_API_KEY、正式数据目录和正式默认工作区,这是骨架阶段的预期状态。代码中尚未实现项目、会话、Agent Run、模型请求、本地文件工具或 Claude Desktop 业务界面。确认骨架后,第一个纵向功能仍为 F-001“应用外壳、首次状态与普通会话”;进入该切片前先确定视觉参考截图包。
21. 有序纵向功能切片
F-001 应用外壳、首次状态与普通会话
- 用户可见结果:首次进入看到“最近/项目”空状态和右侧引导;点击“新任务”不会产生空记录,首条消息后普通会话出现并可恢复。
- 覆盖:VanJS 外壳、selection 状态机、普通会话 API、Conversation Repository、延迟创建、会话文件与索引、基础视觉截图。
- 主要验收:AC-001、AC-003、AC-006、AC-024、AC-025。
F-002 Agent Core 与 DeepSeek 流式回复
- 用户可见结果:普通会话发送消息后看到真实 DeepSeek 回复流式出现,并可继续聊天。
- 覆盖:Core Run 用例、ModelPort、DeepSeek Adapter、SSE、消息渲染与持久化。
- 主要验收:AC-004、AC-010、AC-014、AC-016。
F-003 项目管理与项目聊天
- 用户可见结果:创建绑定本地目录的项目,在项目中创建多个独立聊天,选择历史后还原并续聊;项目可以重命名和删除,删除项目不会删除绑定目录中的文件。
- 覆盖:Project 领域/Repository/API、项目列表和创建面板、项目草稿、重命名、删除确认与删除 intent、
projectId关系、WorkspaceResolver、项目索引与恢复。 - 主要验收:AC-026 至 AC-033。
F-004 用户交互卡片
- 用户可见结果:Agent 可以在回复中请求单选、多选、确认或意见输入;用户回答后,同一个 Run 继续执行并生成后续回复,刷新后仍可继续回答。
- 覆盖:内置
request_user_interaction工具、交互参数校验、Interaction Repository/API、interaction.*SSE、等待用户状态、VanJS 交互卡片、答案幂等、取消、持久化与恢复。 - 主要验收:AC-034 至 AC-038。
F-005 停止、失败与重试
- 用户可见结果:能够停止生成,并从普通或项目聊天的失败状态重新发送。
- 覆盖:AbortSignal、终态、错误映射、草稿保留、断线补发和单活动 Run。
- 主要验收:AC-005、AC-010、AC-011。
F-006 附件、文件列表、读取与搜索
- 用户可见结果:从当前聊天对应工作区选择附件,Agent 可以读取和搜索且不会跨工作区。
- 覆盖:上下文工作区浏览 API、附件元数据、路径保护、读取/搜索工具和工具状态 UI。
- 主要验收:AC-007、AC-008、AC-009、AC-021、AC-022、AC-028、AC-030。
F-007 文件创建与安全修改
- 用户可见结果:Agent 可以在普通默认工作区或当前项目工作区创建文件并通过补丁安全修改。
- 覆盖:可信工作区解析、排他创建、内容哈希、原子写入、diff 摘要和错误状态。
- 主要验收:AC-008、AC-009、AC-023、AC-028。
F-008 会话管理与个人设置
- 用户可见结果:重命名、删除普通或项目会话,查看并修改默认工作区等非敏感设置。
- 覆盖:菜单交互、设置 API、模型/默认工作区摘要和长列表边界状态。
- 主要验收:AC-003、AC-018。
F-009 Claude Desktop 视觉与交互收口
- 用户可见结果:首次引导、普通聊天、项目、消息和设置等全部支持状态在目标视口下尽量贴近参考界面。
- 覆盖:设计令牌、项目侧栏、消息排版、代码块、菜单、输入框、滚动行为和截图回归。
- 主要验收:AC-001、AC-002、AC-012、AC-013、AC-020、AC-024。
F-010 恢复、边界与发布前加固
- 用户可见结果:刷新、重启、项目目录离线、数据损坏和其他边界情况下仍有明确可恢复行为。
- 覆盖:格式迁移备份、项目/会话/交互索引重建、启动恢复、等待交互恢复、工作区失效、配置缺失、日志脱敏、E2E 和生产构建。
- 主要验收:AC-006、AC-017、AC-018、AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。
22. 需求追踪摘要
| 需求 | 主要实现位置 | 主要切片 |
|---|---|---|
| FR-001 会话管理 | Core 用例、local-data、conversation UI | F-001、F-008 |
| FR-002 消息与流式回复 | Core Run、model adapter、SSE、message UI | F-002、F-005 |
| FR-003 Agent Core | agent-core ports/use-cases/events | F-002 至 F-005 |
| FR-004 本地文件 | WorkspaceResolver、local-files、Core tools | F-003、F-006、F-007 |
| FR-005 文件附件 | local-files、attachments、composer | F-006 |
| FR-006 本地持久化 | local-data、layout/recovery/repositories | F-001 至 F-010 |
| FR-007 视觉还原 | web styles/components、Playwright | F-001、F-003、F-004、F-009 |
| FR-008 模块拆分 | Workspaces、导出边界、检查脚本 | 骨架、全部切片 |
| FR-009 CLI 扩展准备 | Core Ports、composition boundary | 骨架、F-002、F-003 |
| FR-010 项目管理与项目聊天 | Project Core/API/Repository/UI、WorkspaceResolver | F-003、F-010 |
| FR-011 用户交互请求 | Core 内置工具、Interaction Repository/API、SSE、VanJS 交互卡片 | F-004、F-010 |
23. 已知风险与处理
- Claude Desktop 视觉可能随版本变化:以开发开始前确认的截图包为唯一视觉基线,不追随之后的界面更新。
- 字体和系统渲染存在平台差异:视觉回归固定在同一浏览器、操作系统字体和视口。
- 模型流式工具事件复杂:模型协议封装在独立适配器,Core 只接收标准事件。
- 内置用户交互工具容易被误当成普通工具:Core 保留精确工具名
request_user_interaction,校验后只映射为interaction.*,不产生普通tool.completed。 - 等待用户可能长期占用模型连接:收到有效交互请求后结束本次 DeepSeek HTTP 流;用户回答后使用同一
runId和toolCallId发起后续模型请求,不保持悬空连接。 - 交互答案可能在恢复模型后丢失:必须先原子写入答案并标记交互已回答,再恢复模型调用;写入失败时继续保持等待状态。
- 用户可能重复或并发提交答案:相同答案返回已有结果,不同答案返回冲突;一个 Run 同时只允许一个待回答交互。
- Project 工作区属于服务端文件系统路径:UI 明确显示当前运行环境可见路径;容器部署时必须先挂载目录,再用容器内路径创建项目。
- 项目目录可能后来离线:历史数据照常读取,WorkspaceResolver 返回
PROJECT_WORKSPACE_UNAVAILABLE,任何代码路径都不得回退到默认工作区。 - 普通与项目会话可能被错误混列:
projectId是实体事实来源,普通“最近”查询强制筛选空projectId,项目列表按确定关系查询。 - 首条消息与会话创建跨多个文件:用单一 Core 用例、串行写队列和恢复记录保证失败后不暴露空会话,索引只在实体可靠落盘后更新。
- 项目重命名和删除会跨越多个实体:重命名使用原子替换;删除先写 intent,再删除本应用内的项目、会话、消息和 Run 数据,恢复流程可继续未完成删除,任何步骤都不得操作项目绑定的工作区。
- VanJS 生态和约定少于主流框架:不引入社区 JSX、路由或状态框架;项目自行固定组件签名、feature controller 和
dispose()资源释放约定,并用架构检查与浏览器测试守住边界。 - 流式更新可能误触发大范围 DOM 重建:活动助手消息使用独立文本 State,列表更新不得绑定到每个 token;用自动化测试记录时间线节点身份保持不变。
- 本地文件符号链接可能逃逸:对目标和最近存在父目录执行真实路径校验,并使用专门安全测试。
- 文件系统无法提供跨文件数据库事务:以单 Run 事件日志作为恢复依据,摘要和索引均可重建,实体更新使用同目录原子替换。
- NDJSON 末行可能在进程中断时残缺:启动恢复只截断无法解析的最后一行,保留原文件副本并检查事件序列。
- 单任务运行在刷新后可能遗留状态:启动恢复和 SSE 重连共同处理,不自动创建重复 Run。
24. 需要人工确认的技术决策
- 接受 Bun + TypeScript + VanJS/Vite + Hono 的单仓库工作区方案,不使用 JSX,不引入大型 UI 组件库。
- 接受首个模型适配器使用 DeepSeek OpenAI 兼容接口,默认
deepseek-v4-pro;Core 保持模型无关。 - 接受 Project、Conversation、Message、Run 和设置全部保存到真实本地文件系统,并采用“实体 JSON + Run NDJSON + 可重建索引”的布局。
- 接受普通会话使用
DEFAULT_WORKSPACE_ROOT,项目会话使用 Project 的workspaceRoot;项目目录失效时禁止回退。 - 接受新任务与项目新对话在首条有效消息前只存在于浏览器内存,首次创建 Conversation 与 Run 使用一个 Core 用例。
- 接受项目首版包含创建、列表、选择、重命名、删除和项目内多会话;删除只清理应用数据,绝不删除绑定工作区,不包含共享或云同步。
- 接受将流式事件分成三类:
message.*表示主要回复,tool.*表示普通工具活动,interaction.*表示需要用户操作的卡片。 - 接受首版交互类型为单选、多选、确认和意见输入;回答后使用同一
runId继续执行,不为回答新建 Run。 - 接受 SSE 作为浏览器流式协议。
- 接受 Bun Workspaces 的多包结构,并保留
WorkspaceResolverPort、ClockPort、IdPort和 Pino 日志边界。 - 接受单文件 300 行拆分提醒、400 行硬限制。
- 接受首版 CLI 只保留复用边界,不创建 CLI 应用。
- 接受视觉基线在 UI 开发前通过固定截图包确定。