diff --git a/frontend-web/docs/openCanvas-backend-checklist.md b/frontend-web/docs/openCanvas-backend-checklist.md new file mode 100644 index 00000000..31152fbe --- /dev/null +++ b/frontend-web/docs/openCanvas-backend-checklist.md @@ -0,0 +1,1031 @@ +# Open Canvas 后端实施清单 + +## 1. 文档目标 + +本文档是 [openCanvas.md](./openCanvas.md) 的后端落地版清单。 + +目标是把 Canvas 模式在当前 DeerFlow 后端中的实施工作拆成可以直接排期和执行的任务,覆盖: + +- 线程状态扩展 +- `canvas_agent` 设计 +- LangGraph 节点拆分 +- Gateway API 扩展 +- 流式输出契约 +- 模型配置与本地模型支持 +- 结构化 artifact 的持久化 +- 测试与验收顺序 + +本文档默认前端会新增独立的 Canvas 子系统,并通过现有 LangGraph 线程和 Gateway API 与后端通信。 + +--- + +## 2. 实施原则 + +### 2.1 总原则 + +- 不破坏现有 `lead_agent` +- 不破坏现有 `artifacts: list[str]` 文件产物体系 +- 优先增量扩展,不做推倒重构 +- 先打通状态和协议,再实现复杂 AI 行为 +- 先支持文本/代码双类型,再逐步扩展快捷动作 + +### 2.2 推荐路线 + +推荐采用“新增独立 `canvas_agent`”方案,而不是把所有 Canvas 行为塞进现有 `lead_agent`。 + +推荐原因: + +- 风险更低 +- 图逻辑更清晰 +- 更容易对齐 open-canvas 的状态结构 +- 不影响现有 DeerFlow 普通聊天与文件产物模式 + +### 2.3 后端目标边界 + +本次后端实施重点是: + +- 新增 Canvas 专用线程状态字段 +- 新增 Canvas Agent 图 +- 新增前端需要的模型能力元数据 +- 支持结构化 artifact 在线程状态中读写 + +本次不要求: + +- 完整复刻 open-canvas 的所有 TS 图结构 +- 完整复刻 Supabase 相关逻辑 +- 把所有模型 provider 完全抽象成统一能力中台 + +--- + +## 3. 现有后端基础能力盘点 + +当前 DeerFlow 后端已经具备非常好的基础设施: + +- LangGraph 线程与流式交互 +- FastAPI Gateway +- 模型动态配置 +- 本地线程状态管理 +- Sandbox/工具体系 +- 上传与文件 artifact 访问 +- Memory 与标题生成 + +因此 Canvas 模式并不是“从 0 开始做 Agent 平台”,而是“在现有平台上新增一类结构化创作模式”。 + +--- + +## 4. 目标代码落点 + +建议优先关注以下目录: + +```text +offline-backend-20260512/backend/ + app/gateway/ + routers/ + deps.py + app.py + packages/harness/deerflow/ + agents/ + factory.py + thread_state.py + lead_agent/ + middlewares/ + runtime/ + config/ + tools/ +``` + +建议为 Canvas 新增或扩展以下区域: + +```text +packages/harness/deerflow/ + agents/ + canvas_agent/ + __init__.py + agent.py + prompt.py + state.py + nodes/ + route_user_intent.py + generate_artifact.py + rewrite_artifact.py + update_highlighted_text.py + update_highlighted_code.py + generate_followup.py + quick_actions.py + web_search.py +``` + +如果不想把节点拆得太细,首版也可以把部分逻辑先收在 `agent.py` 里,但建议至少把 `state.py` 和 `prompt.py` 独立出来。 + +--- + +## 5. 开发阶段总览 + +后端建议分为 11 个阶段: + +1. 线程状态扩展 +2. 结构化 artifact 协议定义 +3. `canvas_agent` 接入到 Agent 工厂 +4. Canvas 图的最小节点实现 +5. Gateway/线程接口兼容 +6. 流式状态输出适配 +7. 模型能力元数据扩展 +8. 本地模型兼容与配置统一 +9. 快捷动作与局部改写 +10. Web 搜索增强 +11. 测试、回归、验收 + +--- + +## 6. 阶段一:线程状态扩展 + +### 6.1 任务目标 + +让 DeerFlow 的线程状态能原生保存 Canvas 所需的结构化信息。 + +### 6.2 任务清单 + +- 扩展 [thread_state.py](../../offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py) +- 定义 Canvas 相关 TypedDict 或 dataclass 结构 +- 确保状态字段可序列化 +- 不影响现有 `messages/title/artifacts/todos` 逻辑 + +### 6.3 建议新增字段 + +在现有 `ThreadState` 上新增: + +- `artifact` +- `highlighted_text` +- `highlighted_code` +- `canvas_mode` +- `quick_action_id` +- `web_search_enabled` +- `web_search_results` + +### 6.4 建议数据形态 + +可先采用 JSON-safe dict 结构,而不是在状态层引入过重的复杂对象。 + +建议: + +```python +class CanvasArtifactText(TypedDict): + index: int + type: Literal["text"] + title: str + fullMarkdown: str + +class CanvasArtifactCode(TypedDict): + index: int + type: Literal["code"] + title: str + language: str + code: str + +class CanvasArtifact(TypedDict): + currentIndex: int + contents: list[CanvasArtifactText | CanvasArtifactCode] +``` + +### 6.5 与现有 `artifacts` 的关系 + +必须保留现有: + +- `artifacts: list[str]` + +新增: + +- `artifact: dict | None` + +含义区分: + +- `artifacts` 表示文件型产物列表 +- `artifact` 表示 Canvas 当前主文档 + +### 6.6 验收标准 + +- 旧线程无 `artifact` 时不报错 +- 新线程可写入 `artifact` +- 线程状态能被 FastAPI 和 LangGraph 正确序列化 + +--- + +## 7. 阶段二:结构化 Artifact 协议定义 + +### 7.1 任务目标 + +统一前后端对 Canvas artifact 的字段约定。 + +### 7.2 任务清单 + +- 在后端建立一份明确的 artifact 协议定义 +- 明确字段命名、类型、兼容策略 +- 明确版本管理规则 + +### 7.3 协议要求 + +必须支持: + +- 文本 artifact +- 代码 artifact +- 标题 +- 当前版本索引 +- 历史版本列表 +- 后续支持局部改写 + +### 7.4 版本规则 + +- 每次 AI 整体生成,生成 `contents[0]` +- 每次 AI 重写,追加一个新版本 +- `currentIndex` 指向当前版本 +- 历史版本默认只保留在线程状态中,不额外落库 + +### 7.5 建议约束 + +- `index` 从 1 开始 +- `currentIndex` 必须落在 `contents` 范围内 +- `type` 只允许 `"text"` 或 `"code"` + +### 7.6 验收标准 + +- 前后端字段完全一致 +- 状态协议有明确默认值和边界规则 + +--- + +## 8. 阶段三:接入 `canvas_agent` + +### 8.1 任务目标 + +在现有 Agent 体系中注册一个独立的 Canvas Agent。 + +### 8.2 任务清单 + +- 新建 `packages/harness/deerflow/agents/canvas_agent/` +- 新建 `agent.py` +- 新建 `prompt.py` +- 新建 `state.py` +- 修改 [factory.py](../../offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py) + +### 8.3 注册方式建议 + +建议将 `canvas_agent` 作为一个显式 assistant id 暴露给 LangGraph 运行时。 + +前端调用时: + +- `assistant_id=canvas_agent` + +### 8.4 Agent 设计原则 + +- Canvas Agent 不负责 DeerFlow 全部工具生态 +- 首版专注于: + - 生成文档 + - 重写文档 + - 局部改写 + - 跟进回复 + +### 8.5 工具策略 + +首版建议: + +- 不强依赖 shell/sandbox +- 不强依赖 present_files +- 先把生成与改写链路做好 + +后续可以根据需要接入文件导出能力。 + +### 8.6 验收标准 + +- `canvas_agent` 可被识别 +- 前端能以 assistant id 调用它 +- 不影响 `lead_agent` + +--- + +## 9. 阶段四:Canvas 状态与图定义 + +### 9.1 任务目标 + +为 Canvas Agent 定义清晰、最小但可扩展的状态结构。 + +### 9.2 任务清单 + +- 新建 `canvas_agent/state.py` +- 定义图状态 +- 约束与 `ThreadState` 的映射方式 + +### 9.3 建议状态字段 + +- `messages` +- `_messages` 或等价内部消息 +- `artifact` +- `highlighted_text` +- `highlighted_code` +- `quick_action_id` +- `web_search_enabled` +- `web_search_results` +- `next` + +### 9.4 状态设计建议 + +优先复用 LangGraph 原生消息 reducer。 + +对结构化字段,尽量采用: + +- 可整体替换 +- 易序列化 +- 无 Python 特殊对象依赖 + +### 9.5 验收标准 + +- 状态能驱动 Canvas 节点运行 +- 状态可被序列化回线程 + +--- + +## 10. 阶段五:Canvas 图最小节点闭环 + +### 10.1 任务目标 + +先实现最小闭环,不要一开始就追求 open-canvas 全量功能。 + +### 10.2 首版最小节点 + +建议至少实现: + +- `route_user_intent` +- `reply_general_input` +- `generate_artifact` +- `rewrite_artifact` +- `generate_followup` + +### 10.3 节点职责 + +#### `route_user_intent` + +判断用户当前意图: + +- 普通聊天 +- 新建 artifact +- 重写当前 artifact + +#### `reply_general_input` + +仅返回普通消息,不更新 artifact。 + +#### `generate_artifact` + +根据用户需求生成第一版 artifact。 + +#### `rewrite_artifact` + +根据当前 artifact 和用户请求生成新版本。 + +#### `generate_followup` + +在 artifact 更新后补一条自然语言消息。 + +### 10.4 输出要求 + +必须至少更新: + +- `messages` +- `artifact` + +### 10.5 验收标准 + +- 无 artifact 时能生成新 artifact +- 有 artifact 时能整体重写 +- 重写后版本数增加 + +--- + +## 11. 阶段六:Prompt 设计与模型调用封装 + +### 11.1 任务目标 + +把 Canvas 模式的 prompt 和模型调用逻辑集中管理。 + +### 11.2 任务清单 + +- 在 `canvas_agent/prompt.py` 中集中定义 prompt 常量 +- 在 `canvas_agent/agent.py` 或节点工具模块中封装模型调用 + +### 11.3 最少需要的 prompt + +- 新建 artifact prompt +- 整体重写 artifact prompt +- 路由判断 prompt +- followup prompt + +### 11.4 设计建议 + +提示词必须显式约束: + +- 输出完整文档还是局部片段 +- 是否允许修改类型 +- 是否允许修改标题 +- 对文本和代码的输出格式要求 + +### 11.5 模型调用封装建议 + +不要在每个节点里重复拼模型配置。 + +建议提供统一函数: + +- `get_canvas_model(...)` +- `invoke_canvas_model(...)` + +### 11.6 验收标准 + +- prompt 集中管理 +- 节点中模型调用逻辑可复用 + +--- + +## 12. 阶段七:Gateway 线程接口兼容 + +### 12.1 任务目标 + +让前端读取线程状态时能拿到结构化 `artifact`。 + +### 12.2 任务清单 + +- 检查 [threads.py](../../offline-backend-20260512/backend/app/gateway/routers/threads.py) +- 扩展线程 state 响应的 `values` +- 确保 `artifact` 字段会被安全返回 + +### 12.3 要求 + +- `GET /api/threads/{id}` +- 线程历史 +- 状态读取接口 + +都要兼容 `artifact` + +### 12.4 注意事项 + +- 历史 checkpoint 可能没有 `artifact` +- 旧线程必须兼容 +- 结构化字段不能被错误裁剪 + +### 12.5 验收标准 + +- 前端取线程时能拿到 `artifact` +- 刷新页面后 Canvas 内容能恢复 + +--- + +## 13. 阶段八:流式输出适配 + +### 13.1 任务目标 + +让 Canvas 模式的 artifact 能通过 LangGraph 流实时更新。 + +### 13.2 任务清单 + +- 检查运行时序列化逻辑 +- 确保 `artifact` 在流事件中可见 +- 明确前端消费 `values` 还是 `updates` + +### 13.3 输出建议 + +首版允许采用较简单策略: + +- 每次节点完成后输出完整 `artifact` + +不强制首版做精细的 token 级 artifact 局部流。 + +### 13.4 原因 + +完整对象覆盖更新: + +- 容易实现 +- 容易调试 +- 前后端一致性更好 + +### 13.5 二期方向 + +后续再考虑: + +- 局部 chunk 更新 +- 文本与代码差异化流式拼接 + +### 13.6 验收标准 + +- 前端发送请求时,artifact 能跟随 run 更新 +- 流结束后状态与线程最终值一致 + +--- + +## 14. 阶段九:模型接口扩展 + +### 14.1 任务目标 + +让前端能拿到适合 Canvas 模式的模型能力信息。 + +### 14.2 任务清单 + +- 扩展 [models.py](../../offline-backend-20260512/backend/app/gateway/routers/models.py) +- 扩展模型响应结构 + +### 14.3 现有字段之外建议新增 + +- `provider` +- `supports_vision` +- `supports_tool_calling` +- `supports_canvas_generation` +- `supports_canvas_rewrite` +- `supports_canvas_code_edit` +- `supports_streaming_text` +- `supports_streaming_tool_calls` +- `is_local` +- `base_url` + +### 14.4 注意事项 + +不是所有字段都必须从配置文件显式写出。 + +可以采用: + +- 配置直出 +- provider 推断 +- 默认值补齐 + +### 14.5 验收标准 + +- 前端模型选择器能获取足够元数据 +- 本地模型和云模型能区分 + +--- + +## 15. 阶段十:本地模型支持与配置统一 + +### 15.1 任务目标 + +支持像 open-canvas 一样自由配置本地模型,但配置真源在 DeerFlow。 + +### 15.2 原则 + +- 以 `config.yaml` 为唯一真源 +- 前端不依赖 open-canvas 的 TS 模型表 +- 后端负责把配置转换成前端可消费的能力描述 + +### 15.3 首批支持范围 + +推荐先支持: + +- Ollama +- OpenAI-compatible 本地服务 + +例如: + +- LM Studio +- vLLM +- LocalAI +- One API/OpenAI 兼容代理 + +### 15.4 配置要求 + +建议模型配置支持以下字段: + +- `name` +- `display_name` +- `use` +- `model` +- `api_key` +- `base_url` +- `supports_vision` +- `supports_thinking` +- `supports_reasoning_effort` +- `supports_tool_calling` +- `is_local` + +### 15.5 Canvas 额外能力字段 + +建议再增加: + +- `supports_canvas_generation` +- `supports_canvas_rewrite` +- `supports_canvas_code_edit` + +### 15.6 验收标准 + +- 配置本地模型后 `/api/models` 可见 +- `canvas_agent` 可调用本地模型 +- 至少文本生成和整体改写可用 + +--- + +## 16. 阶段十一:整体重写节点完善 + +### 16.1 任务目标 + +让已有 artifact 能被稳定整体重写并产生新版本。 + +### 16.2 任务清单 + +- 实现 `rewrite_artifact` +- 明确文本与代码两种重写路径 +- 维护标题和类型变更规则 + +### 16.3 规则建议 + +- 默认保持当前类型 +- 用户明确要求时才允许改类型 +- 默认保持标题 +- 用户明确换主题时可更新标题 + +### 16.4 结果写入规则 + +- 新版本追加到 `contents` +- `currentIndex` 指向新版本 + +### 16.5 验收标准 + +- 文本 artifact 整体重写正常 +- 代码 artifact 整体重写正常 +- 历史版本保留 + +--- + +## 17. 阶段十二:局部改写节点 + +### 17.1 任务目标 + +支持文本和代码的局部选区修改。 + +### 17.2 任务清单 + +- 新增 `update_highlighted_text.py` +- 新增 `update_highlighted_code.py` +- 在状态中消费选区信息 + +### 17.3 文本局部改写要求 + +输入: + +- 当前 `fullMarkdown` +- 选区起止 +- 选中原文 +- 用户修改指令 + +输出: + +- 只返回替换片段或返回完整合并后文本 + +### 17.4 首版建议 + +首版优先采用“后端返回完整合并后文本”策略。 + +原因: + +- 前端逻辑更轻 +- 状态更一致 + +### 17.5 代码局部改写要求 + +输入: + +- 当前代码全文 +- 选区代码片段 +- 选区边界 +- 用户修改指令 + +输出: + +- 新的完整代码文本 + +### 17.6 验收标准 + +- 文本局部改写成功 +- 代码局部改写成功 +- 未选中区域不被误改 + +--- + +## 18. 阶段十三:快捷动作节点 + +### 18.1 任务目标 + +支持前端动作栏触发不同的重写行为。 + +### 18.2 任务清单 + +- 新增 `quick_actions.py` +- 定义动作 id 到 prompt 的映射 +- 在路由节点中识别 `quick_action_id` + +### 18.3 首版动作建议 + +文本: + +- `expand` +- `shorten` +- `formalize` +- `casualize` + +代码: + +- `add_comments` +- `add_logs` +- `fix_bugs` + +### 18.4 实现建议 + +动作不要散落在各节点里硬编码。 + +建议集中成映射表: + +- `action_id -> prompt builder` + +### 18.5 验收标准 + +- 指定动作可触发不同结果 +- 动作执行后生成新版本 + +--- + +## 19. 阶段十四:Web 搜索增强 + +### 19.1 任务目标 + +支持 Canvas 模式在需要时引入搜索结果。 + +### 19.2 任务清单 + +- 新增 `web_search.py` +- 消费 `web_search_enabled` +- 将搜索结果写入 `web_search_results` + +### 19.3 首版策略 + +首版只做: + +- 打开开关后,生成/重写前先搜索 +- 把结果摘要注入 prompt + +先不做复杂页面抓取多轮链路。 + +### 19.4 验收标准 + +- 开关打开时能引入搜索结果 +- 结果可回传前端展示 + +--- + +## 20. 阶段十五:线程创建与 metadata 策略 + +### 20.1 任务目标 + +让 Canvas 线程在创建和恢复时保留必要上下文。 + +### 20.2 任务清单 + +- 约定前端创建 Canvas thread 时写入 metadata +- 后端在 thread search / get 中保留这些 metadata + +### 20.3 推荐 metadata + +- `ui_mode: "canvas"` +- `agent_id: "canvas_agent"` +- `model_name` +- `canvas_type` 可选 + +### 20.4 验收标准 + +- Canvas thread 与普通 thread 可区分 +- 恢复线程时前端可据 metadata 恢复 UI + +--- + +## 21. 阶段十六:Gateway 扩展接口评估 + +### 21.1 任务目标 + +判断是否需要新增 Canvas 专用 REST API。 + +### 21.2 原则 + +能复用现有线程状态与 LangGraph 流,就不要新增太多新接口。 + +### 21.3 可以先不新增的接口 + +- Canvas artifact 专用 CRUD +- Canvas 快捷动作专用接口 + +这些都可以先通过: + +- 线程 state +- runs.stream + +完成。 + +### 21.4 可能需要新增的接口 + +二期如果需要,可增加: + +- `GET /api/canvas/models` +- `POST /api/canvas/export` +- `POST /api/canvas/convert-to-file` + +### 21.5 验收标准 + +- 首版尽量少增接口 +- 协议尽量复用现有线程与 run 模型 + +--- + +## 22. 阶段十七:持久化与导出联动 + +### 22.1 任务目标 + +让 Canvas 文档能和现有文件 artifact 体系联动。 + +### 22.2 任务清单 + +- 规划导出接口 +- 规划 artifact 转文件逻辑 +- 规划从 Canvas 生成 outputs 文件 + +### 22.3 推荐能力 + +- 保存为 `.md` +- 保存为代码文件 +- 将导出文件加入现有 `artifacts: list[str]` + +### 22.4 首版建议 + +这部分不是最早阶段必须做的。 + +建议等核心 Canvas 生成与重写稳定后再接。 + +### 22.5 验收标准 + +- 导出文件能出现在现有 artifact 文件体系中 + +--- + +## 23. 测试清单 + +### 23.1 单元测试建议 + +优先覆盖: + +- artifact 协议校验 +- 版本追加逻辑 +- 标题/类型变更规则 +- 文本局部改写拼接逻辑 +- 代码局部改写拼接逻辑 + +### 23.2 集成测试建议 + +应覆盖: + +- 创建 Canvas thread +- 首次生成 artifact +- 整体重写 +- 局部改写 +- 模型切换 +- 本地模型调用 + +### 23.3 回归测试重点 + +重点检查现有功能未被破坏: + +- 普通 `lead_agent` 对话 +- 文件型 `artifacts` +- uploads +- memory +- scheduled tasks + +### 23.4 验收标准 + +- Canvas 新功能通过 +- 现有 DeerFlow 核心路径无回归 + +--- + +## 24. 推荐开发顺序 + +强烈建议按以下顺序推进: + +1. 线程状态扩展 +2. artifact 协议定稿 +3. `canvas_agent` 注册 +4. 最小图闭环 +5. 线程读取与流式返回 +6. 模型接口扩展 +7. 整体生成与整体改写 +8. 局部改写 +9. 快捷动作 +10. 本地模型增强 +11. Web 搜索增强 +12. 导出联动 + +不要一开始就做: + +- 快捷动作全集 +- 局部 token 级流式 artifact 更新 +- 复杂导出体系 + +--- + +## 25. 多人协作拆分建议 + +### A 组:状态与线程 + +- `thread_state.py` +- 线程协议 +- Gateway 线程兼容 + +### B 组:Canvas Agent 图 + +- `canvas_agent` +- prompt +- route/generate/rewrite/followup + +### C 组:模型与配置 + +- `/api/models` +- 本地模型配置 +- provider 兼容 + +### D 组:增强能力 + +- 局部改写 +- 快捷动作 +- Web 搜索 + +--- + +## 26. 首版最小可交付范围 + +后端首版建议只交付这些: + +- 扩展线程状态支持 `artifact` +- 新增 `canvas_agent` +- 支持文本/代码 artifact 首次生成 +- 支持整体重写 +- 支持版本历史 +- 扩展 `/api/models` 返回 Canvas 能力元数据 +- 支持至少一种本地模型接入 + +首版先不强制: + +- 局部高亮改写 +- 全量快捷动作 +- 搜索增强 +- 导出文件联动 + +--- + +## 27. 总验收标准 + +### 27.1 功能层 + +- 无 artifact 时可生成新文档 +- 有 artifact 时可整体重写 +- 版本历史可保留 +- 前端刷新后可恢复 + +### 27.2 协议层 + +- `artifact` 字段稳定 +- 流式与最终线程状态一致 +- 模型能力字段可用于前端判断 + +### 27.3 兼容层 + +- 不破坏 `lead_agent` +- 不破坏现有 `artifacts: list[str]` +- 不破坏现有 Gateway 核心接口 + +--- + +## 28. 下一步建议 + +完成本文档后,建议继续补两份工程文档: + +### 28.1 Canvas 状态协议文档 + +明确: + +- `artifact` +- `highlighted_text` +- `highlighted_code` +- `quick_action_id` +- `web_search_results` + +的最终 JSON 格式。 + +### 28.2 Canvas Agent 节点接口文档 + +逐节点定义: + +- 输入字段 +- 输出字段 +- prompt 责任 +- 错误处理 + +这样进入实际编码时会更顺畅。 diff --git a/frontend-web/docs/openCanvas-frontend-checklist.md b/frontend-web/docs/openCanvas-frontend-checklist.md new file mode 100644 index 00000000..26a47ae8 --- /dev/null +++ b/frontend-web/docs/openCanvas-frontend-checklist.md @@ -0,0 +1,1373 @@ +# Open Canvas 前端实施清单 + +## 1. 文档目标 + +本文档是 [openCanvas.md](./openCanvas.md) 的前端落地版清单。 + +目标是把 Canvas 模式拆成可以直接开发的前端任务,覆盖: + +- 路由接入 +- 目录结构 +- 页面布局 +- 状态管理 +- 组件拆分 +- 接口适配 +- 流式更新 +- 版本管理 +- 局部改写 +- 模型选择 +- 验收标准 + +本文档默认后端会配合新增结构化 `artifact` 字段与 `canvas_agent`。 + +--- + +## 2. 实施原则 + +### 2.1 总原则 + +- 不破坏现有 `workspace` 聊天页面 +- 尽量复用现有基础设施 +- 先做骨架,再接数据,再接智能行为 +- 每一步都能跑、能看、能验收 + +### 2.2 可复用的现有能力 + +当前项目前端已有以下基础设施,可尽量复用: + +- `react-router-dom` 路由体系 +- `WorkspaceLayout` 工作区布局 +- `@langchain/langgraph-sdk` 客户端 +- `useThreadStream` 的流式线程模式 +- 模型列表接口 `/api/models` +- 现有认证头注入机制 +- `sonner` 提示 +- `react-resizable-panels` 双栏/拖拽布局 +- `CodeMirror` 代码编辑器 + +### 2.3 新增能力的边界 + +Canvas 模式前端是一个“新子系统”,但不建议做成完全独立的第二套应用。 + +建议方式: + +- 复用现有壳层和 API 客户端 +- 新建 `src/open-canvas` 目录承载新功能 +- 将 Canvas 模式接到现有 Page 层路由里 + +--- + +## 3. 目标目录结构 + +建议新增如下目录: + +```text +frontend-web/src/open-canvas/ + api/ + client.ts + models.ts + threads.ts + runs.ts + components/ + layout/ + CanvasShell.tsx + CanvasHeader.tsx + CanvasResizableLayout.tsx + chat/ + CanvasChatPanel.tsx + CanvasMessageList.tsx + CanvasInputBox.tsx + CanvasWelcome.tsx + artifact/ + CanvasArtifactPanel.tsx + CanvasArtifactRenderer.tsx + CanvasArtifactHeader.tsx + CanvasArtifactVersionNav.tsx + CanvasEmptyState.tsx + TextArtifactRenderer.tsx + CodeArtifactRenderer.tsx + selectors/ + CanvasModelSelector.tsx + CanvasModeSelector.tsx + quick-actions/ + QuickActionBar.tsx + QuickActionButton.tsx + web-search/ + CanvasWebSearchPanel.tsx + contexts/ + CanvasThreadContext.tsx + CanvasGraphContext.tsx + CanvasUIContext.tsx + hooks/ + useCanvasThread.ts + useCanvasStream.ts + useCanvasArtifact.ts + useCanvasSelection.ts + useCanvasQuickActions.ts + pages/ + CanvasHomePage.tsx + CanvasThreadPage.tsx + routes/ + OpenCanvasRoutes.tsx + types/ + artifact.ts + thread.ts + model.ts + selection.ts + quick-action.ts + utils/ + artifact.ts + selection.ts + thread.ts + stream.ts + quick-actions.ts + constants/ + index.ts +``` + +--- + +## 4. 开发阶段总览 + +前端建议分为 10 个实施阶段: + +1. 路由与目录骨架 +2. Canvas 页面布局 +3. 类型与状态协议 +4. API 适配层 +5. 线程和流式状态接入 +6. Artifact 渲染与版本管理 +7. 快速开始与空状态 +8. 局部选区改写交互 +9. 快捷动作系统 +10. 模型选择与增强能力接入 + +建议每个阶段完成后都做一次可视化验收。 + +--- + +## 5. 阶段一:路由与目录骨架 + +### 5.1 任务目标 + +把 Canvas 模式先挂到系统里,确保页面可进入。 + +### 5.2 任务清单 + +- 新建 `src/open-canvas/` 目录 +- 新建 `src/open-canvas/routes/OpenCanvasRoutes.tsx` +- 新建 `src/open-canvas/pages/CanvasHomePage.tsx` +- 新建 `src/open-canvas/pages/CanvasThreadPage.tsx` +- 修改 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) + +### 5.3 路由建议 + +建议新增: + +- `/page/canvas` +- `/page/canvas/:thread_id` + +建议在 `PageLayout` 内部新增: + +- `path="canvas/*"` + +子路由建议: + +- `index -> CanvasHomePage` +- `:thread_id -> CanvasThreadPage` +- `new -> 跳转到 CanvasHomePage 或创建临时线程态` + +### 5.4 页面行为要求 + +`CanvasHomePage`: + +- 展示 Canvas 欢迎页 +- 展示快速开始入口 +- 可进入新建文本或代码模式 + +`CanvasThreadPage`: + +- 只负责线程级页面装配 +- 挂载所有 Canvas 上下文提供器 +- 渲染主布局组件 + +### 5.5 验收标准 + +- 访问 `/page/canvas` 正常 +- 访问 `/page/canvas/xxx` 正常 +- 不影响 `/page/workspace/*` +- 页面为独立功能入口 + +--- + +## 6. 阶段二:Canvas 页面布局 + +### 6.1 任务目标 + +先搭出稳定的“左聊天右画布”骨架。 + +### 6.2 任务清单 + +- 新建 `layout/CanvasShell.tsx` +- 新建 `layout/CanvasHeader.tsx` +- 新建 `layout/CanvasResizableLayout.tsx` +- 新建 `chat/CanvasChatPanel.tsx` +- 新建 `artifact/CanvasArtifactPanel.tsx` +- 新建 `artifact/CanvasEmptyState.tsx` + +### 6.3 布局要求 + +推荐布局: + +- 顶部:Canvas 标题栏 +- 左侧:聊天面板 +- 右侧:artifact 面板 +- 可选右抽屉:Web 搜索结果 + +### 6.4 建议复用 + +可以优先复用: + +- `react-resizable-panels` +- 现有按钮、分隔线、Tabs、Tooltip 等 UI 组件 + +### 6.5 初始状态要求 + +无 thread 或 thread 中无 artifact 时: + +- 右侧显示空状态 +- 左侧显示欢迎语和快速开始 + +### 6.6 验收标准 + +- 双栏布局稳定 +- 可拖拽调整宽度 +- 空状态显示正常 +- 适配桌面优先,移动端先做降级布局 + +--- + +## 7. 阶段三:类型与状态协议 + +### 7.1 任务目标 + +先把前端需要消费和管理的数据类型定下来。 + +### 7.2 任务清单 + +- 新建 `types/artifact.ts` +- 新建 `types/thread.ts` +- 新建 `types/model.ts` +- 新建 `types/selection.ts` +- 新建 `types/quick-action.ts` + +### 7.3 必要类型 + +#### artifact.ts + +- `CanvasArtifact` +- `CanvasArtifactText` +- `CanvasArtifactCode` +- `CanvasArtifactContent` + +#### selection.ts + +- `CanvasTextSelection` +- `CanvasCodeSelection` +- `CanvasSelectionState` + +#### thread.ts + +- `CanvasThreadValues` +- `CanvasThreadMetadata` +- `CanvasThread` + +### 7.4 建议结构 + +```ts +export type CanvasArtifact = { + currentIndex: number; + contents: CanvasArtifactContent[]; +}; + +export type CanvasArtifactText = { + index: number; + type: "text"; + title: string; + fullMarkdown: string; +}; + +export type CanvasArtifactCode = { + index: number; + type: "code"; + title: string; + language: string; + code: string; +}; +``` + +### 7.5 线程值类型建议 + +```ts +export type CanvasThreadValues = { + title?: string; + messages?: Message[]; + artifact?: CanvasArtifact; + web_search_results?: unknown[]; + todos?: unknown[]; +}; +``` + +### 7.6 验收标准 + +- 所有 Canvas 组件只依赖 `open-canvas/types` +- 不把类型散落到页面或组件内部 +- 前后端协议字段统一命名 + +--- + +## 8. 阶段四:前端 Context 设计 + +### 8.1 任务目标 + +把 Canvas 的状态拆成职责清晰的三层上下文。 + +### 8.2 任务清单 + +- 新建 `CanvasThreadContext.tsx` +- 新建 `CanvasGraphContext.tsx` +- 新建 `CanvasUIContext.tsx` + +### 8.3 职责划分 + +#### CanvasThreadContext + +负责: + +- 当前 thread id +- thread 基础信息 +- thread 创建/切换 +- 模型名称 +- 模型配置 + +#### CanvasGraphContext + +负责: + +- messages +- artifact +- runId +- streaming 状态 +- error 状态 +- 发送消息 +- 停止生成 +- 切换版本 +- 更新 artifact 内容 + +#### CanvasUIContext + +负责: + +- 面板折叠状态 +- 是否正在编辑 +- 当前选区 +- Web 搜索面板开关 +- 快捷动作面板开关 + +### 8.4 验收标准 + +- 页面组件不直接持有大量跨层 useState +- 聊天状态和 UI 状态分离 +- 线程切换时可安全重置状态 + +--- + +## 9. 阶段五:API 适配层 + +### 9.1 任务目标 + +为 Canvas 模式建立专用的前端 API 包装层。 + +### 9.2 任务清单 + +- 新建 `api/client.ts` +- 新建 `api/models.ts` +- 新建 `api/threads.ts` +- 新建 `api/runs.ts` + +### 9.3 设计要求 + +不要让页面组件直接到处调用现有 `getAPIClient()` 或裸 `fetch`。 + +建议统一在 `src/open-canvas/api/` 中封装。 + +### 9.4 需要封装的能力 + +#### models.ts + +- `loadCanvasModels()` +- 对现有 `/api/models` 结果做标准化 + +#### threads.ts + +- `createCanvasThread()` +- `getCanvasThread(threadId)` +- `updateCanvasThreadMetadata(threadId, metadata)` +- `updateCanvasThreadState(threadId, values)` + +#### runs.ts + +- `streamCanvasRun(...)` +- `stopCanvasRun(...)` + +### 9.5 与现有 API 的关系 + +底层可以复用: + +- `src/core/api/api-client.ts` +- `src/core/api/fetch-client.ts` + +但上层调用应走 Canvas 专用 API。 + +### 9.6 验收标准 + +- 页面层不直接拼接 URL +- Canvas API 调用集中管理 +- 后续改协议时只动一层 + +--- + +## 10. 阶段六:线程与流式状态接入 + +### 10.1 任务目标 + +让 Canvas 页面能像现有 ChatPage 一样收发消息并接流。 + +### 10.2 任务清单 + +- 新建 `hooks/useCanvasThread.ts` +- 新建 `hooks/useCanvasStream.ts` +- 新建 `utils/stream.ts` + +### 10.3 设计建议 + +可以参考现有: + +- [ChatPage.tsx](../src/pages/ChatPage.tsx) +- [threads/hooks.ts](../src/core/threads/hooks.ts) + +但不要直接把现有 `ChatPage` 复制一份。 + +### 10.4 `useCanvasThread` 负责 + +- 当前 thread id 管理 +- 新建/切换 thread +- 从 URL 参数恢复 thread + +### 10.5 `useCanvasStream` 负责 + +- 发送消息 +- 停止生成 +- 处理流式事件 +- 更新 `messages` +- 更新 `artifact` +- 更新 `runId` + +### 10.6 流式更新要求 + +首版可以假设后端在 `values` 或 `updates` 中返回: + +- `messages` +- `artifact` + +前端需要: + +- 新消息追加 +- artifact 覆盖或增量更新 +- 流结束后保持最终状态 + +### 10.7 验收标准 + +- 能发消息 +- 能收到消息 +- 能实时更新 artifact +- 停止按钮可用 + +--- + +## 11. 阶段七:Artifact 渲染器 + +### 11.1 任务目标 + +实现文本/代码两类 artifact 的基础渲染与切换。 + +### 11.2 任务清单 + +- 新建 `artifact/CanvasArtifactRenderer.tsx` +- 新建 `artifact/CanvasArtifactHeader.tsx` +- 新建 `artifact/CanvasArtifactVersionNav.tsx` +- 新建 `artifact/TextArtifactRenderer.tsx` +- 新建 `artifact/CodeArtifactRenderer.tsx` + +### 11.3 组件职责 + +#### CanvasArtifactRenderer + +- 判断当前 artifact 是否存在 +- 判断当前内容是文本还是代码 +- 分发到具体渲染器 + +#### CanvasArtifactHeader + +- 显示标题 +- 显示保存状态 +- 显示版本导航 + +#### CanvasArtifactVersionNav + +- 上一个版本 +- 下一个版本 +- 当前版本号 + +#### TextArtifactRenderer + +- 渲染 markdown 内容 +- 支持编辑模式 +- 支持文本选区 + +#### CodeArtifactRenderer + +- 使用 CodeMirror +- 支持语言模式切换 +- 支持代码选区 + +### 11.4 首版策略 + +文本首版建议: + +- 编辑态用 textarea 或现有 markdown 编辑器 +- 展示态用现有 markdown renderer + +代码首版建议: + +- 直接复用现有 CodeMirror 基础能力 + +### 11.5 验收标准 + +- 文本 artifact 可看可编辑 +- 代码 artifact 可看可编辑 +- 版本切换正确 +- 标题与当前版本一致 + +--- + +## 12. 阶段八:快速开始与空状态 + +### 12.1 任务目标 + +让用户在没有任何 artifact 时也能顺畅开始。 + +### 12.2 任务清单 + +- 新建 `chat/CanvasWelcome.tsx` +- 在 `CanvasHomePage` 中增加快速开始面板 +- 在 `CanvasThreadPage` 空状态时提供快速开始 + +### 12.3 快速开始项建议 + +- 空白文本 +- 空白代码 +- React 组件模板 +- HTML 页面模板 +- Python 脚本模板 + +### 12.4 行为要求 + +点击后: + +- 创建本地前端 artifact 草稿 +- 必要时自动创建 thread +- 自动进入编辑态 + +### 12.5 验收标准 + +- 不发消息也能先建立 artifact +- 新建文本/代码入口可用 +- 初始编辑体验完整 + +--- + +## 13. 阶段九:Artifact 版本管理 + +### 13.1 任务目标 + +让每次 AI 生成或修改都成为一个新版本。 + +### 13.2 任务清单 + +- 新建 `hooks/useCanvasArtifact.ts` +- 新建 `utils/artifact.ts` + +### 13.3 必要能力 + +- 获取当前版本内容 +- 切换版本 +- 更新当前版本内容 +- 追加新版本 +- 判断 artifact 是否为空 + +### 13.4 推荐工具函数 + +- `getCurrentArtifactContent(artifact)` +- `setCurrentArtifactIndex(artifact, index)` +- `appendArtifactVersion(artifact, content)` +- `replaceCurrentArtifactContent(artifact, content)` +- `isTextArtifact(content)` +- `isCodeArtifact(content)` + +### 13.5 注意事项 + +- 前端切版本不能直接修改原对象引用 +- 每次更新要创建新对象,避免渲染异常 + +### 13.6 验收标准 + +- AI 更新后版本数增加 +- 手动切换历史版本稳定 +- 当前版本高亮正确 + +--- + +## 14. 阶段十:局部选区交互 + +### 14.1 任务目标 + +实现局部改写所需的前端选区采集与展示。 + +### 14.2 任务清单 + +- 新建 `hooks/useCanvasSelection.ts` +- 新建 `utils/selection.ts` + +### 14.3 文本选区需求 + +- 获取选中起止位置 +- 获取选中文本 +- 在 UI 上显示“已选中某段内容” +- 支持清除选区 + +### 14.4 代码选区需求 + +- 获取 CodeMirror 选区 +- 保存选中代码内容 +- 保存 start/end 或行列信息 + +### 14.5 交互建议 + +可以在选区出现后展示浮层或工具条: + +- 改写 +- 精简 +- 扩写 +- 解释 + +首版先只保留: + +- “对所选内容进行修改” 的统一入口 + +### 14.6 验收标准 + +- 文本选区可读 +- 代码选区可读 +- 选区状态可清空 +- 切换版本或 thread 时选区自动重置 + +--- + +## 15. 阶段十一:聊天面板适配 + +### 15.1 任务目标 + +实现 Canvas 模式专用聊天面板,不直接塞回现有 ChatBox。 + +### 15.2 任务清单 + +- 新建 `chat/CanvasMessageList.tsx` +- 新建 `chat/CanvasInputBox.tsx` +- 新建 `chat/CanvasChatPanel.tsx` + +### 15.3 设计建议 + +可以参考现有 ChatPage 的结构,但建议把 Canvas 模式聊天区做成更轻量版本。 + +### 15.4 必要功能 + +- 展示消息列表 +- 展示生成中状态 +- 提交输入 +- 停止生成 +- 显示模型选择器 +- 显示 Web 搜索开关 + +### 15.5 可以复用 + +- 现有消息渲染组件中的部分能力 +- 现有 InputBox 的一部分交互逻辑 + +### 15.6 不建议直接复用 + +- 当前工作区的文件 artifact 面板耦合 +- 当前以文件产物为中心的 ChatBox 结构 + +### 15.7 验收标准 + +- Canvas 聊天区独立可用 +- 能与 artifact 面板联动 +- 布局不受现有文件 artifact 影响 + +--- + +## 16. 阶段十二:模型选择器 + +### 16.1 任务目标 + +让 Canvas 模式可以使用 DeerFlow 的动态模型列表。 + +### 16.2 任务清单 + +- 新建 `selectors/CanvasModelSelector.tsx` +- 在 `api/models.ts` 中做标准化 + +### 16.3 UI 要求 + +- 显示模型名 +- 显示是否本地模型 +- 显示是否支持视觉/推理/Canvas + +### 16.4 状态要求 + +- 当前 thread 记录 `model_name` +- 必要时记录 `model_config` +- 切换 thread 时能恢复模型 + +### 16.5 首版能力 + +首版可以只做: + +- 下拉选择模型 +- 将模型信息写入 thread metadata 或 run configurable + +### 16.6 验收标准 + +- 模型列表正确展示 +- 切换模型后发消息使用新模型 +- 本地模型与云模型能在 UI 中区分 + +--- + +## 17. 阶段十三:快捷动作栏 + +### 17.1 任务目标 + +实现 artifact 上方或下方的快捷动作入口。 + +### 17.2 任务清单 + +- 新建 `quick-actions/QuickActionBar.tsx` +- 新建 `quick-actions/QuickActionButton.tsx` +- 新建 `hooks/useCanvasQuickActions.ts` +- 新建 `utils/quick-actions.ts` + +### 17.3 首版动作建议 + +文本类: + +- 扩写 +- 精简 +- 改正式 +- 改口语 + +代码类: + +- 加注释 +- 加日志 +- 修 bug + +### 17.4 行为要求 + +点击动作后: + +- 写入 `quick_action_id` +- 触发一次 Canvas run +- 生成新的 artifact 版本 + +### 17.5 验收标准 + +- 动作栏根据 artifact 类型动态变化 +- 动作点击后有效果 +- 新版本正确生成 + +--- + +## 18. 阶段十四:Web 搜索面板 + +### 18.1 任务目标 + +为后续搜索增强预留 UI 和交互位置。 + +### 18.2 任务清单 + +- 新建 `web-search/CanvasWebSearchPanel.tsx` +- 在 `CanvasUIContext` 中增加开关状态 + +### 18.3 首版要求 + +- 只做抽屉面板 +- 可展示结构化搜索结果列表 +- 可开关显示 + +### 18.4 二期能力 + +- 支持点击来源预览 +- 支持从结果插入上下文 + +### 18.5 验收标准 + +- 面板开关正常 +- 搜索结果区域可承载数据 + +--- + +## 19. 阶段十五:页面装配 + +### 19.1 任务目标 + +将所有子模块整合到页面中。 + +### 19.2 任务清单 + +`CanvasHomePage.tsx`: + +- 欢迎页 +- 快速开始 +- 最近 Canvas 线程入口 + +`CanvasThreadPage.tsx`: + +- 解析 route params +- 挂载 `CanvasThreadContext` +- 挂载 `CanvasGraphContext` +- 挂载 `CanvasUIContext` +- 渲染 `CanvasShell` + +### 19.3 结构建议 + +```tsx + + + + + + + +``` + +### 19.4 验收标准 + +- 页面层只负责装配,不承载复杂业务逻辑 +- 子组件依赖上下文而不是层层 props drilling + +--- + +## 20. 推荐逐文件实施顺序 + +建议按这个顺序建文件: + +1. `routes/OpenCanvasRoutes.tsx` +2. `pages/CanvasHomePage.tsx` +3. `pages/CanvasThreadPage.tsx` +4. `types/*` +5. `contexts/CanvasThreadContext.tsx` +6. `contexts/CanvasUIContext.tsx` +7. `contexts/CanvasGraphContext.tsx` +8. `layout/CanvasShell.tsx` +9. `layout/CanvasResizableLayout.tsx` +10. `artifact/CanvasEmptyState.tsx` +11. `artifact/CanvasArtifactRenderer.tsx` +12. `artifact/TextArtifactRenderer.tsx` +13. `artifact/CodeArtifactRenderer.tsx` +14. `chat/CanvasChatPanel.tsx` +15. `chat/CanvasInputBox.tsx` +16. `api/*` +17. `hooks/useCanvasStream.ts` +18. `hooks/useCanvasSelection.ts` +19. `quick-actions/*` +20. `web-search/*` + +--- + +## 21. 开发任务拆分建议 + +如果多人协作,建议这样拆: + +### A 组:页面与路由 + +- 路由接入 +- 页面骨架 +- 主布局 + +### B 组:artifact 视图 + +- artifact 类型 +- 渲染器 +- 版本导航 +- 代码/文本编辑器 + +### C 组:聊天与流式 + +- thread 管理 +- stream 接入 +- 消息面板 +- 模型切换 + +### D 组:增强交互 + +- 选区 +- 快捷动作 +- 搜索面板 + +--- + +## 22. 每阶段验收清单 + +### 22.1 阶段一到三 + +- 路由正常 +- 空页面正常 +- 类型编译通过 +- 目录结构清晰 + +### 22.2 阶段四到六 + +- thread 可创建/读取 +- 消息可发送 +- artifact 可展示 +- 流式结果可更新 + +### 22.3 阶段七到九 + +- 文本/代码 artifact 渲染正确 +- 版本导航正确 +- 快速开始可用 +- 模型选择可用 + +### 22.4 阶段十到十四 + +- 局部选区可采集 +- 快捷动作可触发 +- 搜索面板可显示 +- 各功能互不冲突 + +--- + +## 23. 风险点与规避方式 + +### 23.1 不要直接复制现有 ChatPage + +原因: + +- 现有页面与文件 artifact 耦合较重 +- Canvas 需要结构化 artifact 而不是文件列表 + +### 23.2 不要把所有状态都放一个 Context + +原因: + +- 更新频率高 +- 容易导致全树重渲染 + +### 23.3 不要先做复杂富文本 + +原因: + +- 选区映射复杂 +- 首版容易卡在编辑器实现而不是业务链路 + +建议先以: + +- `fullMarkdown` +- `textarea/markdown renderer` + +作为最小闭环。 + +### 23.4 不要前端硬编码模型表 + +原因: + +- 后端才是真源 +- 后期本地模型和自定义 provider 会频繁变化 + +--- + +## 24. 首版最小可交付范围 + +前端首版建议只交付这些: + +- `canvas/*` 路由 +- Canvas 双栏布局 +- 文本/代码 artifact 渲染 +- 模型选择器 +- 流式聊天 +- artifact 版本切换 +- 快速开始 + +首版先不强制包含: + +- 复杂文本局部高亮浮层 +- 复杂快捷动作配置中心 +- 高级 Web 搜索结果联动 + +--- + +## 25. 下一步建议 + +完成本文档后,建议继续产出两份更细的执行文件: + +### 25.1 前端文件创建顺序清单 + +按天或按 PR 颗粒度,列出: + +- 每一批次新建哪些文件 +- 每一批次修改哪些已有文件 + +### 25.2 前端组件接口文档 + +列出关键组件 props: + +- `CanvasShell` +- `CanvasChatPanel` +- `CanvasArtifactRenderer` +- `CanvasModelSelector` + +这样正式开工时会更顺畅。 +--- + +## 26. 参考源码映射 + +前端实施清单建议补充对 `langchain-ai/open-canvas` 源码的“映射式参考”,但不建议把源码大段粘贴进本文档。 + +推荐做法是: + +- 记录参考文件路径 +- 说明它解决了什么问题 +- 说明在 DeerFlow 前端里应该落到哪个模块 +- 只摘能力点,不摘整段实现 + +这样做的原因: + +- 文档更清晰 +- 不会把 Next.js 运行时细节误当成可直接迁移代码 +- 后续维护更轻 + +### 26.1 推荐参考文件总览 + +以下文件最值得作为前端实现参考: + +- `apps/web/src/contexts/GraphContext.tsx` +- `apps/web/src/components/canvas/canvas.tsx` +- `apps/web/src/components/artifacts/ArtifactRenderer.tsx` +- `apps/web/src/components/artifacts/TextRenderer.tsx` +- `apps/web/src/components/artifacts/CodeRenderer.tsx` +- `apps/web/src/contexts/ThreadProvider.tsx` +- `apps/web/src/contexts/AssistantContext.tsx` +- `apps/web/src/hooks/useRuns.tsx` +- `apps/web/src/types.ts` +- `packages/shared/src/types.ts` +- `packages/shared/src/models.ts` +- `packages/shared/src/utils/artifacts.ts` + +### 26.2 逐文件映射建议 + +#### `apps/web/src/contexts/GraphContext.tsx` + +参考价值: + +- 这是 open-canvas 前端最核心的状态管理文件之一 +- 管理消息、artifact、流式状态、保存状态、版本切换、局部更新 +- 处理流中 artifact 更新和 thread 切换 + +DeerFlow 对应落点: + +- `src/open-canvas/contexts/CanvasGraphContext.tsx` +- `src/open-canvas/hooks/useCanvasStream.ts` +- `src/open-canvas/utils/stream.ts` +- `src/open-canvas/utils/artifact.ts` + +重点借鉴内容: + +- artifact 如何作为线程主状态存在 +- 流式过程中如何同步 messages 与 artifact +- 切换 thread 时如何恢复状态 +- 如何维护“当前版本”和“已保存状态” + +不建议直接照搬的部分: + +- 与 Next.js、`nuqs`、open-canvas 内部 hook 耦合的代码 +- 与其后端返回格式强绑定的处理逻辑 + +#### `apps/web/src/components/canvas/canvas.tsx` + +参考价值: + +- Open Canvas 的主页面装配组件 +- 管理左聊天右画布布局 +- 定义快速开始逻辑 +- 管理 chat collapsed、artifact 初始创建 + +DeerFlow 对应落点: + +- `src/open-canvas/components/layout/CanvasShell.tsx` +- `src/open-canvas/components/layout/CanvasResizableLayout.tsx` +- `src/open-canvas/pages/CanvasThreadPage.tsx` + +重点借鉴内容: + +- 快速开始时如何创建文本或代码 artifact +- 左右双栏如何组织 +- 页面级状态和上下文边界怎么划分 + +不建议直接照搬的部分: + +- Next 路由参数逻辑 +- 与 open-canvas ThreadProvider 强耦合的状态恢复代码 + +#### `apps/web/src/components/artifacts/ArtifactRenderer.tsx` + +参考价值: + +- 负责 artifact 总渲染分发 +- 根据当前版本内容选择文本或代码渲染器 +- 负责版本导航和选区交互挂钩 + +DeerFlow 对应落点: + +- `src/open-canvas/components/artifact/CanvasArtifactRenderer.tsx` +- `src/open-canvas/components/artifact/CanvasArtifactHeader.tsx` +- `src/open-canvas/components/artifact/CanvasArtifactVersionNav.tsx` + +重点借鉴内容: + +- 当前 artifact 的统一入口组件结构 +- 版本导航和正文渲染如何组合 +- 文本/代码视图如何统一被包装 + +#### `apps/web/src/components/artifacts/TextRenderer.tsx` + +参考价值: + +- 负责文本 artifact 的渲染与编辑 +- 处理 markdown 内容、更新、局部交互 + +DeerFlow 对应落点: + +- `src/open-canvas/components/artifact/TextArtifactRenderer.tsx` +- `src/open-canvas/hooks/useCanvasSelection.ts` + +重点借鉴内容: + +- 文本 artifact 的编辑态与展示态切换 +- 当前版本内容如何回写到状态 +- 局部选区的挂接点 + +实现上的取舍建议: + +- DeerFlow 首版不要追求和 open-canvas 一样复杂的富文本块级编辑 +- 建议先用 `fullMarkdown` + markdown renderer + 简单编辑态完成闭环 + +#### `apps/web/src/components/artifacts/CodeRenderer.tsx` + +参考价值: + +- 负责代码 artifact 的渲染与编辑 +- 与 CodeMirror 集成 +- 管理代码内容更新 + +DeerFlow 对应落点: + +- `src/open-canvas/components/artifact/CodeArtifactRenderer.tsx` + +重点借鉴内容: + +- 代码 artifact 如何绑定语言 +- 代码编辑结果如何同步回 artifact 状态 +- 局部代码选区的接入位置 + +#### `apps/web/src/contexts/ThreadProvider.tsx` + +参考价值: + +- 负责 thread 与模型相关状态 +- 管理 thread 切换和 thread 级配置 + +DeerFlow 对应落点: + +- `src/open-canvas/contexts/CanvasThreadContext.tsx` +- `src/open-canvas/hooks/useCanvasThread.ts` + +重点借鉴内容: + +- 当前 thread id 的管理方式 +- thread metadata 与 model state 的关系 +- 页面恢复 thread 时的上下文同步 + +#### `apps/web/src/contexts/AssistantContext.tsx` + +参考价值: + +- 管理 assistant 选择 +- 管理与 assistant 相关的 thread 行为 + +DeerFlow 对应落点: + +- DeerFlow 首版可以弱化这部分 +- 如果首版只有 `canvas_agent` 一个固定 assistant,可只保留最小 assistant 绑定能力 + +重点借鉴内容: + +- thread 与 assistant 的耦合方式 + +#### `apps/web/src/hooks/useRuns.tsx` + +参考价值: + +- 管理 run 相关行为 + +DeerFlow 对应落点: + +- `src/open-canvas/api/runs.ts` +- `src/open-canvas/hooks/useCanvasStream.ts` + +重点借鉴内容: + +- run 行为与页面状态的解耦方式 + +#### `apps/web/src/types.ts` + +参考价值: + +- 保存了一部分 artifact 和消息类型定义 + +DeerFlow 对应落点: + +- `src/open-canvas/types/artifact.ts` +- `src/open-canvas/types/thread.ts` + +重点借鉴内容: + +- artifact 的前端表达方式 + +注意: + +- 更建议以 `packages/shared/src/types.ts` 为主参考,而不是这个文件 +- 因为 `src/types.ts` 里同时包含了部分历史兼容结构 + +#### `packages/shared/src/types.ts` + +参考价值: + +- 这是 open-canvas 中最重要的共享类型来源之一 +- 定义了 `ArtifactV3`、代码/文本内容、选择区和其他共享结构 + +DeerFlow 对应落点: + +- `src/open-canvas/types/artifact.ts` +- `src/open-canvas/types/selection.ts` +- 可作为前后端协议设计的主要参考 + +重点借鉴内容: + +- `ArtifactV3` 结构 +- 文本/代码 artifact 类型边界 +- 与版本管理有关的字段组织方式 + +建议: + +- DeerFlow 可以参考其字段设计 +- 但不要机械复制所有字段,保留适合当前项目的最小集 + +#### `packages/shared/src/models.ts` + +参考价值: + +- 展示了 open-canvas 如何组织前端模型列表和模型能力 + +DeerFlow 对应落点: + +- `src/open-canvas/types/model.ts` +- `src/open-canvas/api/models.ts` +- `src/open-canvas/components/selectors/CanvasModelSelector.tsx` + +重点借鉴内容: + +- 模型展示结构 +- provider 概念 +- 各模型能力标签的前端呈现思路 + +注意: + +- 不应作为 DeerFlow 的模型配置真源 +- DeerFlow 必须以自己后端 `/api/models` 为真源 + +#### `packages/shared/src/utils/artifacts.ts` + +参考价值: + +- 里面有 artifact 类型判断和内容获取工具 + +DeerFlow 对应落点: + +- `src/open-canvas/utils/artifact.ts` + +重点借鉴内容: + +- 获取当前版本内容 +- 判断当前 artifact 是文本还是代码 +- 为渲染器和快捷动作提供统一入口 + +### 26.3 可以直接参考但不要直接迁移的部分 + +以下内容可以借鉴思路,但不建议直接搬代码: + +- Next.js 路由和页面结构 +- `src/app/api/*` 目录中的 API Route +- Supabase SSR 和服务端 session 相关代码 +- `next/navigation` 相关逻辑 +- `middleware.ts` + +原因: + +- DeerFlow 前端是 `React 19 + Vite + react-router-dom` +- 这些代码依赖 Next.js 运行时 + +### 26.4 建议的使用方式 + +开发时推荐这样使用参考源码: + +1. 先按本文档实现 DeerFlow 自己的模块边界 +2. 遇到某个具体功能点时,再去查对应 open-canvas 文件 +3. 提炼实现思路,重写到 DeerFlow 的目录结构中 +4. 不要复制包含 Next.js 运行时依赖的代码 + +### 26.5 推荐补充动作 + +如果后面要正式进入实现阶段,建议再单独新增一份源码映射文档,例如: + +- `frontend-web/docs/openCanvas-source-map.md` + +里面可以更细地记录: + +- open-canvas 文件路径 +- DeerFlow 对应目标文件 +- 迁移策略 +- 是否可直接借鉴 +- 是否需要重写 diff --git a/frontend-web/docs/openCanvas.md b/frontend-web/docs/openCanvas.md new file mode 100644 index 00000000..6ef38733 --- /dev/null +++ b/frontend-web/docs/openCanvas.md @@ -0,0 +1,950 @@ +# Open Canvas 模式需求拆解文档 + +## 1. 文档目标 + +本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。 + +目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现: + +- 前端:基于 `React 19 + Vite + react-router-dom` +- 后端:基于 `Python + FastAPI + LangGraph` +- 模型层:支持云模型与本地模型自由配置 +- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑 + +本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。 + +--- + +## 2. 最终目标 + +### 2.1 用户视角目标 + +用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验: + +- 左侧是聊天区,右侧是 Canvas 编辑区 +- 可以从空白文本或代码模板快速开始 +- 可以通过聊天让 AI 生成一份结构化 artifact +- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写 +- artifact 支持版本历史切换 +- artifact 支持文本和代码两种主要类型 +- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言 +- 支持启用 Web 搜索增强 +- 支持切换模型 +- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务 + +### 2.2 工程视角目标 + +新增的 Canvas 模式需要满足以下工程原则: + +- 不破坏现有 DeerFlow 工作区 +- 不影响现有基于 `artifacts: string[]` 的文件产物功能 +- 可逐阶段上线 +- 前后端状态协议清晰 +- 可兼容现有 LangGraph 线程、消息流、认证和模型接口 + +--- + +## 3. 范围定义 + +### 3.1 本次需求包含 + +- 前端新增 `open-canvas` 专用目录 +- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由 +- 前端新增 Canvas 页面、上下文、组件、状态管理 +- 后端新增 Canvas 专用线程状态字段 +- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支 +- 模型配置统一为 DeerFlow 后端配置驱动 +- 支持本地大模型配置与模型列表动态下发 +- 支持结构化 artifact 的保存、读取、更新、版本切换 + +### 3.2 本次需求不包含 + +- 一次性复刻 open-canvas 全部 UI 细节 +- 完整照搬 Supabase 鉴权体系 +- 直接复用 open-canvas 的 Next.js API Route +- 直接运行 open-canvas 的 LangGraph.js 后端 +- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力 + +### 3.3 迁移原则 + +- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构 +- 前端尽量复用其交互设计和数据结构 +- 后端以 Python 实现同等能力 +- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源 + +--- + +## 4. 总体设计思路 + +### 4.1 前端设计思路 + +在现有 `frontend-web/src` 下新增一个专用目录,例如: + +```text +src/ + open-canvas/ + pages/ + routes/ + components/ + contexts/ + hooks/ + types/ + api/ + utils/ + constants/ +``` + +建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。 + +建议路由形式: + +- `/canvas` +- `/canvas/:threadId` + +也可以挂在现有页面布局下,例如: + +- `/workspace/canvas` +- `/workspace/canvas/:threadId` + +推荐优先采用独立一级功能路由: + +- 逻辑更清晰 +- 状态隔离更容易 +- 不容易污染现有 `workspace` 功能 + +### 4.2 后端设计思路 + +后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一: + +#### 方案 A:新增独立 Canvas Assistant + +- 新增 `canvas_agent` +- 使用单独的 LangGraph 状态定义 +- 前端调用时通过 `assistant_id=canvas_agent` + +优点: + +- 与现有 `lead_agent` 隔离 +- 风险更低 +- 更容易对齐 open-canvas 的图结构 + +缺点: + +- 会产生一套新的 Agent 图维护成本 + +#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支 + +- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas` +- Agent 根据模式进入 Canvas 路径 + +优点: + +- 统一入口 + +缺点: + +- 会显著增加现有 Agent 复杂度 +- 容易影响现有聊天和文件产物模式 + +推荐先做方案 A。 + +### 4.3 模型配置设计思路 + +模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。 + +前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。 + +为了实现 open-canvas 风格体验,需要后端模型接口至少返回: + +- `name` +- `display_name` +- `provider` +- `supports_thinking` +- `supports_reasoning_effort` +- `supports_vision` +- `supports_tool_calling` +- `supports_artifact_editing` +- `supports_code_editing` +- `temperature_range` +- `max_tokens_range` +- `base_url_source` 或 provider 标识 +- `is_local` + +--- + +## 5. 与当前项目的关键差异 + +### 5.1 当前 DeerFlow 现状 + +当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表: + +- `artifacts: string[]` + +这更接近“生成文件产物”的工作模式。 + +前端当前 artifact 面板也主要是: + +- 文件列表 +- 文件详情 +- 文件下载/预览 + +### 5.2 open-canvas 的关键能力 + +open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”: + +- 一个 conversation 对应一个主 artifact +- artifact 具有类型、标题、内容、版本 +- 用户可以选中部分内容让 AI 改写 +- 每次改写都会生成新版本 + +### 5.3 需要新增的核心能力 + +为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态: + +- 当前 artifact +- 当前版本索引 +- 历史版本列表 +- 选区信息 +- 当前编辑模式 +- 快速动作状态 +- Web 搜索结果状态 + +--- + +## 6. 分阶段实施方案 + +以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。 + +--- + +## 7. 阶段一:前端路由与目录骨架 + +### 7.1 目标 + +在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。 + +### 7.2 需求 + +- 新增 `src/open-canvas/` 目录 +- 新增 Canvas 页面路由 +- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) +- 提供 Canvas 首页和 Canvas Thread 页 +- 支持空状态展示 + +### 7.3 建议目录 + +```text +src/open-canvas/ + api/ + threads.ts + runs.ts + models.ts + components/ + layout/ + chat/ + canvas/ + artifacts/ + quick-actions/ + selectors/ + contexts/ + CanvasThreadContext.tsx + CanvasGraphContext.tsx + CanvasUIContext.tsx + hooks/ + useCanvasThread.ts + useCanvasStream.ts + useCanvasArtifact.ts + pages/ + CanvasHomePage.tsx + CanvasThreadPage.tsx + routes/ + OpenCanvasRoutes.tsx + types/ + artifact.ts + thread.ts + model.ts + utils/ + artifact.ts + selection.ts + stream.ts + constants/ + index.ts +``` + +### 7.4 路由建议 + +- 在 `PageRoutes.tsx` 中新增 `canvas/*` +- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由 + +建议形式: + +```text +/canvas +/canvas/:threadId +``` + +### 7.5 验收标准 + +- 能访问 Canvas 首页 +- 能访问指定 thread 的 Canvas 页面 +- 不影响现有 `workspace/*` 与 `strategy/*` +- 页面有独立布局骨架 + +--- + +## 8. 阶段二:前端 Canvas 布局与交互容器 + +### 8.1 目标 + +先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。 + +### 8.2 需求 + +- 左侧聊天区 +- 右侧画布区 +- 支持面板拖拽或折叠 +- 初始空状态 +- 支持“快速开始”: + - 新建空白文本 artifact + - 新建代码 artifact + +### 8.3 组件拆分建议 + +- `CanvasShell` +- `CanvasSidebar` +- `CanvasChatPanel` +- `CanvasArtifactPanel` +- `CanvasEmptyState` +- `QuickStartActions` + +### 8.4 快速开始能力 + +用户进入空线程时可以选择: + +- 开始写文章 +- 开始写代码 +- 从模板开始 + +首版模板可只做最小集: + +- 空白文档 +- HTML 模板 +- React 组件模板 +- Python 脚本模板 + +### 8.5 验收标准 + +- 页面可稳定展示双栏布局 +- 快速开始能创建前端本地 artifact 状态 +- 代码和文本可分别进入不同渲染器 + +--- + +## 9. 阶段三:结构化 Artifact 数据模型 + +### 9.1 目标 + +定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。 + +### 9.2 核心要求 + +需要支持以下能力: + +- 文本 artifact +- 代码 artifact +- 标题 +- 当前版本索引 +- 版本历史 +- 当前选中版本切换 +- 后续支持局部高亮编辑 + +### 9.3 建议数据结构 + +```ts +type CanvasArtifact = { + currentIndex: number; + contents: Array; +}; + +type CanvasArtifactText = { + index: number; + type: "text"; + title: string; + fullMarkdown: string; +}; + +type CanvasArtifactCode = { + index: number; + type: "code"; + title: string; + language: string; + code: string; +}; +``` + +### 9.4 线程状态建议新增字段 + +前端与后端需共同支持: + +- `artifact` +- `highlighted_text` +- `highlighted_code` +- `canvas_mode` +- `web_search_enabled` +- `web_search_results` +- `quick_action_id` + +### 9.5 与现有 `artifacts: string[]` 的关系 + +建议保留现有 `artifacts: string[]` 不动。 + +新增字段: + +- `artifact` 用于 Canvas 主文档 +- `artifacts` 继续用于文件产物 + +这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。 + +### 9.6 验收标准 + +- 前端类型定义完成 +- 后端状态字段设计完成 +- 确认与现有线程状态兼容 + +--- + +## 10. 阶段四:后端线程状态与 API 扩展 + +### 10.1 目标 + +让后端线程状态可以安全存储和返回 Canvas Artifact。 + +### 10.2 需求 + +- 扩展 Python `ThreadState` +- 扩展状态序列化逻辑 +- 确保 `/api/threads/*` 返回结构化 `artifact` +- 确保 LangGraph 流中可带上 `artifact` 更新 + +### 10.3 后端改造点 + +建议关注以下模块: + +- `packages/harness/deerflow/agents/thread_state.py` +- 线程 state serializer +- `app/gateway/routers/threads.py` +- 流事件适配层 + +### 10.4 注意事项 + +- 旧线程没有 `artifact` 字段时必须兼容 +- 状态更新不能破坏现有消息结构 +- 必须确保 JSON 可序列化 + +### 10.5 验收标准 + +- 新建线程可写入 `artifact` +- 读取线程时可返回 `artifact` +- 不影响现有线程读取 +- 前端能从线程 state 中恢复 Canvas 内容 + +--- + +## 11. 阶段五:Canvas 专用 Agent 图 + +### 11.1 目标 + +实现与 open-canvas 类似的 AI 路由能力。 + +### 11.2 推荐方案 + +新增 `canvas_agent`,单独维护图逻辑。 + +### 11.3 节点拆分建议 + +首版建议至少拆成这些节点: + +- `route_user_intent` +- `reply_general_input` +- `generate_artifact` +- `rewrite_artifact` +- `update_highlighted_text` +- `update_highlighted_code` +- `generate_followup` + +二期可加: + +- `rewrite_artifact_theme` +- `rewrite_code_theme` +- `change_length` +- `change_reading_level` +- `translate_artifact` +- `add_comments` +- `add_logs` +- `fix_bugs` +- `port_language` +- `web_search_enrich` + +### 11.4 路由逻辑要求 + +系统需要能判断: + +- 用户是在闲聊 +- 用户要新生成 artifact +- 用户要整体重写当前 artifact +- 用户要局部修改当前 artifact +- 用户要执行某个快捷动作 + +### 11.5 状态输入要求 + +节点需要读取: + +- 当前消息历史 +- 当前 artifact +- 当前选区 +- 当前模型配置 +- Web 搜索开关 + +### 11.6 输出要求 + +节点输出必须能更新: + +- `messages` +- `artifact` +- `web_search_results` +- `followup` + +### 11.7 验收标准 + +- 能根据输入创建新 artifact +- 能根据输入整体改写 artifact +- 能保留版本历史 +- 能在改写后返回自然语言 followup + +--- + +## 12. 阶段六:前端流式同步与版本管理 + +### 12.1 目标 + +让前端能像 open-canvas 一样在流式过程中更新画布与聊天。 + +### 12.2 需求 + +- 复用现有 LangGraph stream client +- 在 Canvas 模式中解析 `artifact` 更新 +- 支持流式文本更新 +- 支持版本切换 +- 支持断线后恢复线程状态 + +### 12.3 前端状态层建议 + +- `CanvasThreadContext`:线程基础信息 +- `CanvasGraphContext`:消息、artifact、streaming 状态 +- `CanvasUIContext`:面板、编辑态、选中态 + +### 12.4 关键能力 + +- 当前版本切换 +- 新版本创建后自动定位到最新版本 +- 手动编辑内容后保存 +- 流结束后线程状态持久化 + +### 12.5 验收标准 + +- 发送消息时聊天和画布可同步更新 +- 版本数会增加 +- 切换历史版本稳定 +- 刷新页面后线程状态能恢复 + +--- + +## 13. 阶段七:局部选区改写 + +### 13.1 目标 + +实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。 + +### 13.2 文本选区需求 + +- 支持在 Markdown 文本中选择一段文本 +- 记录选区位置与原始文本 +- 用户发出修改请求后,仅重写该选区 +- 将选区结果合并为新版本 artifact + +### 13.3 代码选区需求 + +- 支持在代码编辑器中选择一段代码 +- 记录 start/end index 或行列信息 +- AI 只返回替换片段 +- 前端或后端负责将新片段拼接回完整代码 + +### 13.4 实现建议 + +首版建议: + +- 文本:前端记录纯文本字符位置 +- 代码:使用 CodeMirror selection 信息 + +不建议首版就做复杂块级富文本局部编辑。 + +### 13.5 风险 + +- Markdown 渲染态与源码态位置映射复杂 +- 富文本块级位置难维护 +- 流式更新中选区同步容易错位 + +### 13.6 首版折中方案 + +首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。 + +### 13.7 验收标准 + +- 文本局部改写成功 +- 代码局部改写成功 +- 原内容未改动部分保持不变 +- 新版本正确入历史 + +--- + +## 14. 阶段八:快捷动作系统 + +### 14.1 目标 + +实现 open-canvas 风格的快捷动作体系。 + +### 14.2 首批推荐动作 + +文本类: + +- 扩写 +- 精简 +- 改正式语气 +- 改口语语气 +- 改阅读难度 +- 翻译 + +代码类: + +- 加注释 +- 加日志 +- 修复 bug +- 优化代码 +- 转换语言 + +### 14.3 设计方式 + +快捷动作不要直接写死在 UI,建议配置化: + +- `id` +- `label` +- `type` +- `applicable_to` +- `prompt_template` + +### 14.4 后端设计 + +快捷动作可通过: + +- 前端传 `quick_action_id` +- 后端路由节点根据动作 id 选择 prompt + +### 14.5 验收标准 + +- 文本动作可执行 +- 代码动作可执行 +- 结果进入新版本 +- 不同动作提示词可扩展 + +--- + +## 15. 阶段九:模型配置统一与本地模型支持 + +### 15.1 目标 + +让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。 + +### 15.2 原则 + +- 后端 `config.yaml` 为真源 +- 前端只展示后端暴露的模型信息 +- 不维护一份脱离后端的前端常量模型表 + +### 15.3 本地模型支持范围 + +建议优先支持两类: + +- `Ollama` +- OpenAI-compatible 本地服务 + - LM Studio + - vLLM + - LocalAI + - one-api 风格代理 + +### 15.4 后端配置建议 + +后端模型配置应至少支持: + +- `name` +- `display_name` +- `use` +- `model` +- `api_key` +- `base_url` +- `supports_vision` +- `supports_thinking` +- `supports_tool_calling` +- `is_local` +- `canvas_capabilities` + +### 15.5 Canvas 模式额外要求 + +由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识: + +- `supports_canvas_generation` +- `supports_canvas_rewrite` +- `supports_canvas_code_edit` +- `supports_streaming_text` +- `supports_streaming_tool_calls` + +### 15.6 与 open-canvas 的关系 + +可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。 + +### 15.7 验收标准 + +- 前端模型选择器可显示后端返回模型 +- 可切换本地模型 +- Canvas 模式能用本地模型生成和改写 + +--- + +## 16. 阶段十:Web 搜索增强 + +### 16.1 目标 + +实现类似 open-canvas 的可选 Web 搜索增强。 + +### 16.2 需求 + +- UI 提供 Web 搜索开关 +- 后端在需要时走搜索节点 +- 搜索结果写入线程状态 +- 结果可在画布旁侧栏展示 + +### 16.3 首版建议 + +只做: + +- 开关 +- 搜索结果列表 +- 搜索结果用于生成 artifact + +暂不做复杂网页抓取编辑链路。 + +### 16.4 验收标准 + +- 打开搜索后可返回搜索结果 +- 搜索结果可参与 artifact 生成 +- 结果展示不阻塞主画布 + +--- + +## 17. 阶段十一:持久化、导出与文件产物联动 + +### 17.1 目标 + +让 Canvas 文档与 DeerFlow 原有文件能力协同。 + +### 17.2 需求 + +- artifact 持久化在线程 state 中 +- 可手动导出为文件 +- 可生成真实 HTML/Markdown/代码文件到现有 outputs +- Canvas artifact 与 `artifacts: string[]` 可双向关联 + +### 17.3 推荐能力 + +- 导出 Markdown +- 导出代码文件 +- 另存为 artifact file +- 从 artifact file 回填到 Canvas + +### 17.4 验收标准 + +- 当前版本可导出 +- 导出文件可在现有文件面板查看 +- 文件型 artifact 和 Canvas artifact 能共存 + +--- + +## 18. 推荐开发顺序 + +建议严格按以下顺序推进: + +1. 路由和目录骨架 +2. Canvas 页面布局 +3. 前端 artifact 数据模型 +4. 后端线程状态扩展 +5. 新建 Canvas Agent 图 +6. 前后端流式同步 +7. 整体生成与整体改写 +8. 局部选区改写 +9. 快捷动作 +10. 本地模型支持完善 +11. Web 搜索增强 +12. 导出与文件联动 + +不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。 + +--- + +## 19. 里程碑定义 + +### M1:页面可进入 + +- 路由接通 +- 双栏布局完成 +- 本地 mock artifact 可渲染 + +### M2:后端状态打通 + +- 线程可保存结构化 artifact +- 页面刷新后能恢复 + +### M3:AI 生成可用 + +- 可新建文本/代码 artifact +- 可整体改写 +- 可产生版本历史 + +### M4:核心体验可用 + +- 选区改写可用 +- 快捷动作可用 +- 模型切换可用 + +### M5:增强能力完整 + +- 本地模型支持完善 +- Web 搜索增强 +- 导出和文件联动 + +--- + +## 20. 风险与难点 + +### 20.1 最大难点 + +- 结构化 artifact 状态从无到有 +- 局部选区改写的精确性 +- 流式状态与版本管理的一致性 +- 不破坏现有 DeerFlow 文件型 artifact 体系 + +### 20.2 技术风险 + +- 线程 state JSON 结构过大 +- 大模型对局部改写指令遵循不稳定 +- 本地模型 tool calling 与流式能力差异大 +- 文本渲染态与源码态位置映射复杂 + +### 20.3 规避建议 + +- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑 +- 首版只支持文本和代码两种 artifact 类型 +- 首版先做整体生成和整体改写,再做局部改写 +- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效 + +--- + +## 21. 验收总清单 + +### 21.1 前端 + +- 有独立 Canvas 路由 +- 有专用目录和模块边界 +- 双栏布局可用 +- 文本和代码两类 artifact 可渲染 +- 历史版本可切换 +- 选区改写可交互 +- 快捷动作可触发 +- 模型可切换 + +### 21.2 后端 + +- 线程状态支持 `artifact` +- 新增 `canvas_agent` 或等价模式 +- 生成、重写、局部改写可用 +- 流式返回稳定 +- 模型列表接口支持 Canvas 能力元数据 + +### 21.3 本地模型 + +- 可配置 Ollama +- 可配置 OpenAI-compatible 本地服务 +- 前端可见并可切换 +- 至少能完成文本生成和整体改写 + +--- + +## 22. 推荐首版最小可交付范围 + +如果要控制风险,建议首版只交付以下内容: + +- 新增 `canvas/*` 路由 +- 双栏布局 +- 结构化 `artifact` +- `canvas_agent` +- 文本/代码 artifact 新建 +- 整体生成 +- 整体改写 +- 历史版本切换 +- 后端模型动态加载 +- Ollama 或本地 OpenAI-compatible 至少一种可用 + +先不要把以下内容放进首版强制目标: + +- 富文本块级精确编辑 +- 全量快捷动作 +- 复杂 Web 搜索增强 +- 多 artifact 并行管理 + +--- + +## 23. 后续实施建议 + +建议下一步基于本文档继续输出两份落地文档: + +### 23.1 前端实施清单 + +逐文件列出: + +- 要新建哪些文件 +- 哪些组件可复用 +- 哪些页面要接入路由 +- 哪些状态要从现有 hooks 抽离 + +### 23.2 后端实施清单 + +逐模块列出: + +- 要改哪些 Python 文件 +- 新增哪些状态字段 +- 新增哪些 Agent 节点 +- 哪些 API 需要扩展 + +--- + +## 24. 结论 + +这个需求很大,但完全可拆。 + +正确做法不是“先抄 UI”,而是按下面的主线推进: + +- 先建前端 Canvas 子系统 +- 再建结构化 artifact 状态 +- 再建 Canvas Agent 图 +- 再补局部改写和快捷动作 +- 最后统一本地模型能力 + +只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。 diff --git a/frontend-web/src/components/ui/breadcrumb.tsx b/frontend-web/src/components/ui/breadcrumb.tsx index 029c0997..bd3af481 100644 --- a/frontend-web/src/components/ui/breadcrumb.tsx +++ b/frontend-web/src/components/ui/breadcrumb.tsx @@ -31,23 +31,25 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { ); } -function BreadcrumbLink({ - asChild, - className, - ...props -}: React.ComponentProps<"a"> & { - asChild?: boolean; -}) { - const Comp = asChild ? Slot : "a"; - - return ( - - ); -} +const BreadcrumbLink = React.forwardRef< + HTMLAnchorElement, + React.ComponentProps<"a"> & { + asChild?: boolean; + } +>(({ asChild, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a"; + + return ( + + ); +}); + +BreadcrumbLink.displayName = "BreadcrumbLink"; function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { return ( diff --git a/frontend-web/src/components/ui/button.tsx b/frontend-web/src/components/ui/button.tsx index 961cf33b..31e89b42 100644 --- a/frontend-web/src/components/ui/button.tsx +++ b/frontend-web/src/components/ui/button.tsx @@ -1,6 +1,6 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; @@ -37,27 +37,27 @@ const buttonVariants = cva( }, ); -function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean; - }) { - const Comp = asChild ? Slot : "button"; - - return ( - - ); -} - -export { Button, buttonVariants }; +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + } +>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +}); + +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/frontend-web/src/components/ui/collapsible.tsx b/frontend-web/src/components/ui/collapsible.tsx index ce790a65..a3f08519 100644 --- a/frontend-web/src/components/ui/collapsible.tsx +++ b/frontend-web/src/components/ui/collapsible.tsx @@ -1,6 +1,7 @@ -"use client"; - -import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; +"use client"; + +import * as React from "react"; +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; import { cn } from "@/lib/utils"; @@ -10,28 +11,37 @@ function Collapsible({ return ; } -function CollapsibleTrigger({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CollapsibleContent({ - ...props -}: React.ComponentProps) { - return ( - - ); -} +const CollapsibleTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +CollapsibleTrigger.displayName = + CollapsiblePrimitive.CollapsibleTrigger.displayName; + +const CollapsibleContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ( + + ); +}); + +CollapsibleContent.displayName = + CollapsiblePrimitive.CollapsibleContent.displayName; export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/frontend-web/src/components/ui/dialog.tsx b/frontend-web/src/components/ui/dialog.tsx index 0282d589..4fa8d7fb 100644 --- a/frontend-web/src/components/ui/dialog.tsx +++ b/frontend-web/src/components/ui/dialog.tsx @@ -12,11 +12,20 @@ function Dialog({ return ; } -function DialogTrigger({ - ...props -}: React.ComponentProps) { - return ; -} +const DialogTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ( + + ); +}); + +DialogTrigger.displayName = DialogPrimitive.Trigger.displayName; function DialogPortal({ ...props @@ -24,61 +33,68 @@ function DialogPortal({ return ; } -function DialogClose({ - ...props -}: React.ComponentProps) { - return ; -} +const DialogClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ; +}); + +DialogClose.displayName = DialogPrimitive.Close.displayName; -function DialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; -function DialogContent({ - className, - children, - showCloseButton = true, - ...props -}: React.ComponentProps & { - showCloseButton?: boolean; -}) { - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ); -} +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + showCloseButton?: boolean; + } +>(({ className, children, showCloseButton = true, ...props }, ref) => { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +}); + +DialogContent.displayName = DialogPrimitive.Content.displayName; function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/frontend-web/src/components/ui/dropdown-menu.tsx b/frontend-web/src/components/ui/dropdown-menu.tsx index cf81f70c..766b5160 100644 --- a/frontend-web/src/components/ui/dropdown-menu.tsx +++ b/frontend-web/src/components/ui/dropdown-menu.tsx @@ -20,36 +20,42 @@ function DropdownMenuPortal({ ); } -function DropdownMenuTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ); -} +const DropdownMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ( + + ); +}); + +DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName; -function DropdownMenuContent({ - className, - sideOffset = 4, - ...props -}: React.ComponentProps) { - return ( - - - - ); -} +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => { + return ( + + + + ); +}); + +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; function DropdownMenuGroup({ ...props @@ -59,28 +65,29 @@ function DropdownMenuGroup({ ); } -function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props -}: React.ComponentProps & { - inset?: boolean; - variant?: "default" | "destructive"; -}) { - return ( - - ); -} +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: "default" | "destructive"; + } +>(({ className, inset, variant = "default", ...props }, ref) => { + return ( + + ); +}); + +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; function DropdownMenuCheckboxItem({ className, @@ -198,45 +205,51 @@ function DropdownMenuSub({ return ; } -function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: React.ComponentProps & { - inset?: boolean; -}) { - return ( - - {children} - - - ); -} +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => { + return ( + + {children} + + + ); +}); + +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; -function DropdownMenuSubContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; export { DropdownMenu, diff --git a/frontend-web/src/components/ui/hover-card.tsx b/frontend-web/src/components/ui/hover-card.tsx index 346b0ae3..b8a76875 100644 --- a/frontend-web/src/components/ui/hover-card.tsx +++ b/frontend-web/src/components/ui/hover-card.tsx @@ -11,34 +11,42 @@ function HoverCard({ return ; } -function HoverCardTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function HoverCardContent({ - className, - align = "center", - sideOffset = 4, - ...props -}: React.ComponentProps) { - return ( - - - - ); -} +const HoverCardTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ( + + ); +}); + +HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName; + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => { + return ( + + + + ); +}); + +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/frontend-web/src/components/ui/select.tsx b/frontend-web/src/components/ui/select.tsx index c1a87494..327e1350 100644 --- a/frontend-web/src/components/ui/select.tsx +++ b/frontend-web/src/components/ui/select.tsx @@ -24,68 +24,69 @@ function SelectValue({ return ; } -function SelectTrigger({ - className, - size = "default", - children, - ...props -}: React.ComponentProps & { - size?: "sm" | "default"; -}) { - return ( - - {children} - - - - - ); -} +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + size?: "sm" | "default"; + } +>(({ className, size = "default", children, ...props }, ref) => { + return ( + + {children} + + + + + ); +}); + +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; -function SelectContent({ - className, - children, - position = "item-aligned", - align = "center", - ...props -}: React.ComponentProps) { - return ( - - - - - {children} - - - - - ); -} +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => { + return ( + + + + + {children} + + + + + ); +}); + +SelectContent.displayName = SelectPrimitive.Content.displayName; function SelectLabel({ className, @@ -100,32 +101,34 @@ function SelectLabel({ ); } -function SelectItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ); -} +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + return ( + + + + + + + {children} + + ); +}); + +SelectItem.displayName = SelectPrimitive.Item.displayName; function SelectSeparator({ className, diff --git a/frontend-web/src/components/ui/sheet.tsx b/frontend-web/src/components/ui/sheet.tsx index 82c613fc..abb4b216 100644 --- a/frontend-web/src/components/ui/sheet.tsx +++ b/frontend-web/src/components/ui/sheet.tsx @@ -10,17 +10,23 @@ function Sheet({ ...props }: React.ComponentProps) { return ; } -function SheetTrigger({ - ...props -}: React.ComponentProps) { - return ; -} +const SheetTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ; +}); + +SheetTrigger.displayName = SheetPrimitive.Trigger.displayName; -function SheetClose({ - ...props -}: React.ComponentProps) { - return ; -} +const SheetClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ; +}); + +SheetClose.displayName = SheetPrimitive.Close.displayName; function SheetPortal({ ...props @@ -28,58 +34,62 @@ function SheetPortal({ return ; } -function SheetOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; -function SheetContent({ - className, - children, - side = "right", - ...props -}: React.ComponentProps & { - side?: "top" | "right" | "bottom" | "left"; -}) { - return ( - - - - {children} - - - Close - - - - ); -} +const SheetContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + side?: "top" | "right" | "bottom" | "left"; + } +>(({ className, children, side = "right", ...props }, ref) => { + return ( + + + + {children} + + + Close + + + + ); +}); + +SheetContent.displayName = SheetPrimitive.Content.displayName; function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/frontend-web/src/components/ui/sidebar.tsx b/frontend-web/src/components/ui/sidebar.tsx index 80e8e357..f6c5e3e4 100644 --- a/frontend-web/src/components/ui/sidebar.tsx +++ b/frontend-web/src/components/ui/sidebar.tsx @@ -397,49 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { ); } -function SidebarGroupLabel({ - className, - asChild = false, - ...props -}: React.ComponentProps<"div"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "div"; +const SidebarGroupLabel = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { asChild?: boolean } +>(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "div"; + + return ( + svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className, + )} + {...props} + /> + ); +}); + +SidebarGroupLabel.displayName = "SidebarGroupLabel"; - return ( - svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", - className, - )} - {...props} - /> - ); -} - -function SidebarGroupAction({ - className, - asChild = false, - ...props -}: React.ComponentProps<"button"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; - - return ( - svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} +const SidebarGroupAction = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { asChild?: boolean } +>(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 md:after:hidden", + "group-data-[collapsible=icon]:hidden", + className, + )} + {...props} + /> + ); +}); + +SidebarGroupAction.displayName = "SidebarGroupAction"; function SidebarGroupContent({ className, @@ -499,87 +503,99 @@ const sidebarMenuButtonVariants = cva( }, ); -function SidebarMenuButton({ - asChild = false, - isActive = false, - variant = "default", - size = "default", - tooltip, - className, - ...props -}: React.ComponentProps<"button"> & { - asChild?: boolean; - isActive?: boolean; - tooltip?: string | React.ComponentProps; -} & VariantProps) { - const Comp = asChild ? Slot : "button"; - const { isMobile, state } = useSidebar(); +const SidebarMenuButton = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean; + isActive?: boolean; + tooltip?: string | React.ComponentProps; + } & VariantProps +>( + ( + { + asChild = false, + isActive = false, + variant = "default", + size = "default", + tooltip, + className, + ...props + }, + ref, + ) => { + const Comp = asChild ? Slot : "button"; + const { isMobile, state } = useSidebar(); + + const button = ( + + ); + + if (!tooltip) { + return button; + } + + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + }; + } + + return ( + + {button} + + ); + }, +); + +SidebarMenuButton.displayName = "SidebarMenuButton"; - const button = ( - - ); - - if (!tooltip) { - return button; - } - - if (typeof tooltip === "string") { - tooltip = { - children: tooltip, - }; - } - - return ( - - {button} - - ); -} - -function SidebarMenuAction({ - className, - asChild = false, - showOnHover = false, - ...props -}: React.ComponentProps<"button"> & { - asChild?: boolean; - showOnHover?: boolean; -}) { - const Comp = asChild ? Slot : "button"; - - return ( - svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", - className, - )} - {...props} - /> - ); -} +const SidebarMenuAction = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean; + showOnHover?: boolean; + } +>(({ className, asChild = false, showOnHover = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 md:after:hidden", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + showOnHover && + "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", + className, + )} + {...props} + /> + ); +}); + +SidebarMenuAction.displayName = "SidebarMenuAction"; function SidebarMenuBadge({ className, @@ -670,37 +686,37 @@ function SidebarMenuSubItem({ ); } -function SidebarMenuSubButton({ - asChild = false, - size = "md", - isActive = false, - className, - ...props -}: React.ComponentProps<"a"> & { - asChild?: boolean; - size?: "sm" | "md"; - isActive?: boolean; -}) { - const Comp = asChild ? Slot : "a"; - - return ( - svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", - size === "sm" && "text-xs", - size === "md" && "text-sm", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} +const SidebarMenuSubButton = React.forwardRef< + HTMLAnchorElement, + React.ComponentProps<"a"> & { + asChild?: boolean; + size?: "sm" | "md"; + isActive?: boolean; + } +>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a"; + + return ( + svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + size === "sm" && "text-xs", + size === "md" && "text-sm", + "group-data-[collapsible=icon]:hidden", + className, + )} + {...props} + /> + ); +}); + +SidebarMenuSubButton.displayName = "SidebarMenuSubButton"; export { Sidebar, diff --git a/frontend-web/src/components/ui/tabs.tsx b/frontend-web/src/components/ui/tabs.tsx index 3d88efbf..41a53cc7 100644 --- a/frontend-web/src/components/ui/tabs.tsx +++ b/frontend-web/src/components/ui/tabs.tsx @@ -40,52 +40,60 @@ const tabsListVariants = cva( }, ); -function TabsList({ - className, - variant = "default", - ...props -}: React.ComponentProps & - VariantProps) { - return ( - - ); -} +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant = "default", ...props }, ref) => { + return ( + + ); +}); + +TabsList.displayName = TabsPrimitive.List.displayName; -function TabsTrigger({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; -function TabsContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +TabsContent.displayName = TabsPrimitive.Content.displayName; export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }; diff --git a/frontend-web/src/components/ui/tooltip.tsx b/frontend-web/src/components/ui/tooltip.tsx index ad120150..04286b1a 100644 --- a/frontend-web/src/components/ui/tooltip.tsx +++ b/frontend-web/src/components/ui/tooltip.tsx @@ -28,33 +28,43 @@ function Tooltip({ ); } -function TooltipTrigger({ - ...props -}: React.ComponentProps) { - return ; -} - -function TooltipContent({ - className, - sideOffset, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - ); -} +const TooltipTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ( + + ); +}); + +TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset, children, ...props }, ref) => { + return ( + + + {children} + + + ); +}); + +TooltipContent.displayName = TooltipPrimitive.Content.displayName; export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 3c30fcdb..94529739 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -16,7 +16,7 @@ export function AgentGallery() { const { t } = useI18n(); const { agents, isLoading } = useAgents(); const router = useRouter(); - const [activeTab, setActiveTab] = useState<"all" | "mine">("all"); + const [activeTab, setActiveTab] = useState<"all" | "mine">("mine"); const storedAuth = useMemo(() => getStoredAuth(), []); const currentUserId = storedAuth?.user_id ?? null; @@ -84,8 +84,8 @@ export function AgentGallery() { className="flex flex-1 flex-col overflow-hidden" > - 全部 - 自己的 + 我的 + 广场 diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 84841c53..f039e05d 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -117,6 +117,7 @@ export function InputBox({ onFollowupsVisibilityChange, onSubmit, onStop, + promptPrefix, ...props }: Omit, "onSubmit"> & { assistantId?: string | null; @@ -145,6 +146,16 @@ export function InputBox({ onFollowupsVisibilityChange?: (visible: boolean) => void; onSubmit?: (message: PromptInputMessage) => void; onStop?: () => void; + /** + * Optional toggle rendered next to the model selector. Used for the + * admin-configurable prompt-prefix switch on the chat input. Omitting the + * prop hides the button entirely. + */ + promptPrefix?: { + label: string; + on: boolean; + onToggle: () => void; + }; }) { const { t } = useI18n(); const searchParams = useSearchParams(); @@ -819,6 +830,34 @@ export function InputBox({ )} + {promptPrefix && ( + + + + {promptPrefix.label} + + {promptPrefix.on && ( + + )} + + + )} + {groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { return group.messages.map((msg) => { @@ -265,6 +269,7 @@ export function MessageList({ })} {thread.isLoading && }
+ ); diff --git a/frontend-web/src/components/workspace/messages/message-token-usage.tsx b/frontend-web/src/components/workspace/messages/message-token-usage.tsx index 8c4cabf3..094a2938 100644 --- a/frontend-web/src/components/workspace/messages/message-token-usage.tsx +++ b/frontend-web/src/components/workspace/messages/message-token-usage.tsx @@ -1,28 +1,98 @@ import type { Message } from "@langchain/langgraph-sdk"; -import { CoinsIcon } from "lucide-react"; +import { CoinsIcon, GaugeIcon } from "lucide-react"; import { useI18n } from "@/core/i18n/hooks"; import { formatTokenCount, getUsageMetadata } from "@/core/messages/usage"; +import { useLocalSettings } from "@/core/settings"; import { cn } from "@/lib/utils"; +import { + MessageMetricsContext, + useMessageMetric, + useMessageMetrics, + type MessageMetric, +} from "./use-message-metrics"; + +const FULL_SCALE_TOKENS_PER_SEC = 80; + +function classifySpeed(tps: number): { color: string; label: string } { + if (tps >= 50) return { color: "#16a34a", label: "极快" }; + if (tps >= 30) return { color: "#65a30d", label: "流畅" }; + if (tps >= 15) return { color: "#eab308", label: "一般" }; + if (tps >= 5) return { color: "#f97316", label: "偏慢" }; + return { color: "#ef4444", label: "缓慢" }; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`; + const mins = Math.floor(ms / 60_000); + const secs = Math.round((ms % 60_000) / 1000); + return `${mins}m ${secs}s`; +} + +function SpeedPill({ metric }: { metric: MessageMetric }) { + const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC)); + const { color, label } = classifySpeed(metric.tokensPerSec); + const display = + metric.tokensPerSec >= 100 + ? Math.round(metric.tokensPerSec).toString() + : metric.tokensPerSec.toFixed(1); + const prefix = metric.fromUsage ? "" : "~"; + + return ( + + + + {prefix} + {display} + + tok/s + + + + + ); +} + export function MessageTokenUsage({ className, enabled = false, isLoading = false, message, + metric, }: { className?: string; enabled?: boolean; isLoading?: boolean; message: Message; + metric?: MessageMetric; }) { const { t } = useI18n(); + const [settings] = useLocalSettings(); + const speedEnabled = settings.display.streamSpeedMeterEnabled; + const contextMetric = useMessageMetric(message.id as string | undefined); + const resolvedMetric = metric ?? contextMetric; - if (!enabled || isLoading || message.type !== "ai") { + if (message.type !== "ai") { return null; } + // Show speed-only line while streaming (no usage yet) if the user opted in, + // otherwise wait until usage arrives so the bar doesn't render on its own. const usage = getUsageMetadata(message); + const showUsage = enabled && !isLoading && Boolean(usage); + const showSpeed = speedEnabled && resolvedMetric !== undefined && resolvedMetric.durationMs > 0; + + if (!showUsage && !showSpeed) { + return null; + } return (
- - - {t.tokenUsage.label} - - {usage ? ( + {showUsage && usage ? ( <> + + + {t.tokenUsage.label} + {t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)} @@ -47,9 +117,17 @@ export function MessageTokenUsage({ {t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)} - ) : ( - {t.tokenUsage.unavailableShort} - )} + ) : null} + {showSpeed && resolvedMetric ? ( + <> + + {resolvedMetric.durationMs > 0 && ( + + {formatDuration(resolvedMetric.durationMs)} + + )} + + ) : null}
); } @@ -65,12 +143,14 @@ export function MessageTokenUsageList({ isLoading?: boolean; messages: Message[]; }) { - if (!enabled || isLoading) { + const [settings] = useLocalSettings(); + const speedEnabled = settings.display.streamSpeedMeterEnabled; + + if (!enabled && !speedEnabled) { return null; } const aiMessages = messages.filter((message) => message.type === "ai"); - if (aiMessages.length === 0) { return null; } @@ -89,3 +169,25 @@ export function MessageTokenUsageList({ ); } + +/** + * Hoist the metrics map computation to the top of the message list so + * descendant {@link MessageTokenUsage} components can read it via context + * instead of each running its own hook. + */ +export function MessageMetricsProvider({ + messages, + isLoading, + children, +}: { + messages: Message[]; + isLoading: boolean; + children: React.ReactNode; +}) { + const metrics = useMessageMetrics(messages, isLoading); + return ( + + {children} + + ); +} diff --git a/frontend-web/src/components/workspace/messages/use-message-metrics.ts b/frontend-web/src/components/workspace/messages/use-message-metrics.ts new file mode 100644 index 00000000..42224fbe --- /dev/null +++ b/frontend-web/src/components/workspace/messages/use-message-metrics.ts @@ -0,0 +1,235 @@ +"use client"; + +import type { Message } from "@langchain/langgraph-sdk"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; + +import { extractReasoningContentFromMessage } from "@/core/messages/utils"; +import { getUsageMetadata } from "@/core/messages/usage"; +import { textOfMessage } from "@/core/threads/utils"; + +/** + * Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable + * speed indicator for mixed Chinese/English replies without dragging in a + * real tokeniser. + */ +const CHARS_PER_TOKEN = 2; + +/** Sample throttle while a message is streaming. */ +const SAMPLE_THROTTLE_MS = 60; + +/** + * Per-AI-message metric: real-time during streaming, frozen once the run ends. + */ +export interface MessageMetric { + /** Rolling tokens/sec, or 0 when we cannot estimate yet. */ + tokensPerSec: number; + /** + * Wall-clock duration from when the message first appeared with text to + * either *now* (active) or to when the stream ended (frozen). + */ + durationMs: number; + /** True while this is the currently streaming message. */ + isActive: boolean; + /** + * Whether the speed value was computed from real usage_metadata output + * tokens (``true``) vs. a char-count fallback (``false``). UI uses this + * only to add a "~" prefix when the value is approximate. + */ + fromUsage: boolean; +} + +type Sample = { at: number; chars: number }; + +type TimingEntry = { + firstSeenAt: number; + endedAt: number | null; + samples: Sample[]; + lastChars: number; + /** True iff we *have already* observed `usage_metadata.output_tokens`. */ + hadUsage: boolean; +}; + +function isAiMessage(message: Message): boolean { + return message.type === "ai"; +} + +function getMessageId(message: Message, fallbackIndex: number): string { + const id = (message.id as string | undefined) ?? undefined; + return id ?? `idx-${fallbackIndex}`; +} + +function messageText(message: Message): string { + // Count both the visible answer text *and* the reasoning trace so the speed + // indicator stays meaningful during the thinking phase — for reasoning + // models the answer body can be empty for tens of seconds while the model + // streams reasoning tokens, and ignoring those would make the meter sit at + // zero. We treat the two as one continuous stream of generated characters. + const body = textOfMessage(message); + const reasoning = extractReasoningContentFromMessage(message) ?? ""; + const bodyText = typeof body === "string" ? body : ""; + return bodyText + reasoning; +} + +/** + * Returns a Map keyed by AI message id → live metric snapshot. + * + * Recording strategy: + * - Whenever a message renders with text, we anchor `firstSeenAt`. + * - On every text-length change we push a sample and recompute the rate. + * - When `isLoading` flips to false we mark *all* still-active messages as + * ended so subsequent renders return a frozen duration. + * - Token totals come from usage_metadata when present (most accurate); + * otherwise we fall back to char-count / 2. + */ +export function useMessageMetrics( + messages: Message[], + isLoading: boolean, +): Map { + const timingRef = useRef>(new Map()); + const lastSampleAtRef = useRef(0); + // Bump a counter to force recomputation when samples push but message refs + // didn't change — needed because the timing map mutation alone wouldn't + // re-render anything. + const [, setTick] = useState(0); + const lastActiveIdRef = useRef(null); + + // Walk messages and update timings. We intentionally read/write the ref + // synchronously inside an effect so React isn't tracking the map identity. + useEffect(() => { + const now = performance.now(); + let activeId: string | null = null; + + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if (!message || !isAiMessage(message)) continue; + const id = getMessageId(message, i); + const text = messageText(message); + if (!text) continue; + + let entry = timingRef.current.get(id); + if (!entry) { + entry = { + firstSeenAt: now, + endedAt: null, + samples: [{ at: now, chars: text.length }], + lastChars: text.length, + hadUsage: false, + }; + timingRef.current.set(id, entry); + } + + // Track the most recent AI message as the active one (while streaming). + activeId = id; + + // Sample only when actively streaming OR when content actually grew — + // throttle to keep arrays bounded. + const grew = text.length !== entry.lastChars; + if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) { + entry.samples.push({ at: now, chars: text.length }); + entry.lastChars = text.length; + if (entry.samples.length > 256) { + entry.samples.splice(0, entry.samples.length - 256); + } + lastSampleAtRef.current = now; + } else if (grew) { + // Still update lastChars so the next render captures it eventually. + entry.lastChars = text.length; + } + + if (getUsageMetadata(message)) { + entry.hadUsage = true; + } + } + + // When streaming stops, freeze any non-ended entry to the current time. + if (!isLoading) { + for (const entry of timingRef.current.values()) { + if (entry.endedAt === null) { + entry.endedAt = now; + } + } + } + + lastActiveIdRef.current = activeId; + setTick((v) => v + 1); + }, [messages, isLoading]); + + // Drive a slow tick while streaming so live durations refresh smoothly. + useEffect(() => { + if (!isLoading) return; + const interval = window.setInterval(() => setTick((v) => v + 1), 300); + return () => window.clearInterval(interval); + }, [isLoading]); + + // Build the snapshot map. We compute on every render so consumers see the + // freshest duration; the underlying timing storage is the source of truth. + const out = new Map(); + const now = performance.now(); + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if (!message || !isAiMessage(message)) continue; + const id = getMessageId(message, i); + const entry = timingRef.current.get(id); + if (!entry) continue; + + const endedAt = entry.endedAt ?? now; + const durationMs = Math.max(0, endedAt - entry.firstSeenAt); + const isActive = isLoading && id === lastActiveIdRef.current; + + let tokensPerSec = 0; + let fromUsage = false; + const usage = getUsageMetadata(message); + if (usage && usage.outputTokens > 0 && durationMs > 0) { + tokensPerSec = (usage.outputTokens / durationMs) * 1000; + fromUsage = true; + } else { + // Char-count fallback. Uses the rolling window during streaming so a + // mid-stream snapshot reflects current speed rather than average since + // the start. + if (isActive && entry.samples.length >= 2) { + const samples = entry.samples; + let start = samples.findIndex((s) => s.at >= now - 1500); + if (start === -1) start = samples.length - 2; + if (start < 0) start = 0; + const head = samples[start]; + const tail = samples[samples.length - 1]; + if (head && tail) { + const elapsedMs = tail.at - head.at; + const chars = Math.max(0, tail.chars - head.chars); + if (elapsedMs > 0) { + tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN; + } + } + } else if (durationMs > 0) { + // Finished message without usage data: average over full duration. + const chars = entry.lastChars; + tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN; + } + } + + out.set(id, { + tokensPerSec, + durationMs, + isActive, + fromUsage, + }); + } + + return out; +} + +const EMPTY_METRICS: Map = new Map(); + +/** + * Context for sharing the metrics map computed by ``useMessageMetrics`` from + * MessageList down to per-message renderers like MessageListItem, without + * threading a prop through every grouping branch. + */ +export const MessageMetricsContext = createContext>( + EMPTY_METRICS, +); + +export function useMessageMetric(messageId: string | undefined): MessageMetric | undefined { + const metrics = useContext(MessageMetricsContext); + return messageId ? metrics.get(messageId) : undefined; +} diff --git a/frontend-web/src/components/workspace/notification-bell.tsx b/frontend-web/src/components/workspace/notification-bell.tsx deleted file mode 100644 index 14310bea..00000000 --- a/frontend-web/src/components/workspace/notification-bell.tsx +++ /dev/null @@ -1,212 +0,0 @@ -"use client"; - -import { BellIcon, CheckCheckIcon, Loader2, Trash2Icon } from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; - -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - SidebarMenuButton, - useSidebar, -} from "@/components/ui/sidebar"; -import { useI18n } from "@/core/i18n/hooks"; -import { - useDeleteNotification, - useMarkAllNotificationsRead, - useMarkNotificationRead, - useNotifications, - useUnreadCount, -} from "@/core/notifications"; -import type { NotificationItem } from "@/core/notifications"; -import { formatTimeAgo } from "@/core/utils/datetime"; -import { cn } from "@/lib/utils"; - -const BELL_LIMIT = 20; - -export function NotificationBell() { - const { t } = useI18n(); - const { open: isSidebarOpen } = useSidebar(); - const [open, setOpen] = useState(false); - - // Render badge from the lightweight count endpoint so the bell stays cheap - // when nobody opens it. Only when the user actually opens the dropdown do - // we pay for the full list query. - const { data: unreadCount = 0 } = useUnreadCount(); - const { - data: notifications, - isLoading, - isFetching, - } = useNotifications({ limit: BELL_LIMIT }); - const markRead = useMarkNotificationRead(); - const markAllRead = useMarkAllNotificationsRead(); - const removeNotification = useDeleteNotification(); - - const hasUnread = unreadCount > 0; - const items = notifications ?? []; - - async function handleClickItem(notification: NotificationItem) { - if (notification.read) return; - try { - await markRead.mutateAsync(notification.id); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } - - async function handleMarkAllRead() { - if (!hasUnread) return; - try { - await markAllRead.mutateAsync(); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } - - async function handleDelete(notification: NotificationItem) { - try { - await removeNotification.mutateAsync(notification.id); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } - - return ( - - - - {isSidebarOpen ? ( -
-
- - {hasUnread && ( - - {unreadCount > 99 ? "99+" : unreadCount} - - )} -
- {t.notifications.title} - {hasUnread && ( - - {t.notifications.unreadShort.replace("{count}", String(unreadCount))} - - )} -
- ) : ( -
-
- - {hasUnread && ( - - )} -
-
- )} -
-
- -
-
- - - {t.notifications.title} - - {isFetching && !isLoading && ( - - )} -
- -
- - {isLoading ? ( -
- - {t.common.loading} -
- ) : items.length === 0 ? ( -
- {t.notifications.empty} -
- ) : ( -
    - {items.map((notification) => ( -
  • void handleClickItem(notification)} - > -
    - {!notification.read && ( - - )} -
    -
    -

    - {notification.title || notification.type} -

    - {notification.created_at && ( - - {formatTimeAgo(notification.created_at)} - - )} -
    - {notification.body && ( -

    - {notification.body} -

    - )} -
    - -
    -
  • - ))} -
- )} -
-
-
- ); -} diff --git a/frontend-web/src/components/workspace/notification-dialog.tsx b/frontend-web/src/components/workspace/notification-dialog.tsx new file mode 100644 index 00000000..73d2c0db --- /dev/null +++ b/frontend-web/src/components/workspace/notification-dialog.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { + BellIcon, + CheckCheckIcon, + ChevronLeftIcon, + Loader2, + SettingsIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Switch } from "@/components/ui/switch"; +import { useI18n } from "@/core/i18n/hooks"; +import { useNotification } from "@/core/notification/hooks"; +import { + useDeleteNotification, + useMarkAllNotificationsRead, + useMarkNotificationRead, + useNotifications, + useUnreadCount, +} from "@/core/notifications"; +import type { NotificationItem } from "@/core/notifications"; +import { useLocalSettings } from "@/core/settings"; +import { formatTimeAgo } from "@/core/utils/datetime"; +import { cn } from "@/lib/utils"; + +const BELL_LIMIT = 20; + +type NotificationView = "list" | "settings"; + +export function NotificationDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useI18n(); + const [view, setView] = useState("list"); + + const { + data: notifications, + isLoading, + isFetching, + } = useNotifications({ limit: BELL_LIMIT }); + const { data: unreadCount = 0 } = useUnreadCount(); + const markRead = useMarkNotificationRead(); + const markAllRead = useMarkAllNotificationsRead(); + const removeNotification = useDeleteNotification(); + + const hasUnread = unreadCount > 0; + const items = notifications ?? []; + + function handleOpenChange(next: boolean) { + onOpenChange(next); + // 关闭弹窗时回到列表视图,下次打开默认看到通知列表。 + if (!next) setView("list"); + } + + async function handleClickItem(notification: NotificationItem) { + if (notification.read) return; + try { + await markRead.mutateAsync(notification.id); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + async function handleMarkAllRead() { + if (!hasUnread) return; + try { + await markAllRead.mutateAsync(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + async function handleDelete(notification: NotificationItem) { + try { + await removeNotification.mutateAsync(notification.id); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + return ( + + + {view === "list" ? ( + <> +
+
+ + + {t.notifications.title} + + {isFetching && !isLoading && ( + + )} +
+
+ + + + + +
+
+ +
+ {isLoading ? ( +
+ + {t.common.loading} +
+ ) : items.length === 0 ? ( +
+
+ +
+

+ {t.notifications.empty} +

+
+ ) : ( +
    + {items.map((notification) => ( +
  • void handleClickItem(notification)} + > +
    + +
    +
    +

    + {notification.title || notification.type} +

    + {notification.created_at && ( + + {formatTimeAgo(notification.created_at)} + + )} +
    + {notification.body && ( +

    + {notification.body} +

    + )} +
    + +
    +
  • + ))} +
+ )} +
+
+ + ) : ( + setView("list")} /> + )} +
+
+ ); +} + +function NotificationSettingsView({ onBack }: { onBack: () => void }) { + const { t } = useI18n(); + const { permission, isSupported, requestPermission, showNotification } = + useNotification(); + const [settings, setSettings] = useLocalSettings(); + + const handleRequestPermission = async () => { + await requestPermission(); + }; + + const handleTestNotification = () => { + showNotification(t.settings.notification.testTitle, { + body: t.settings.notification.testBody, + }); + }; + + const handleEnableNotification = (enabled: boolean) => { + setSettings("notification", { enabled }); + }; + + return ( + <> +
+
+ + + {t.notifications.settingsTitle} + +
+ + + +
+
+ {!isSupported ? ( +

+ {t.settings.notification.notSupported} +

+ ) : ( + <> +
+

+ {t.settings.notification.description} +

+ +
+ + {permission === "default" && ( + + )} + + {permission === "denied" && ( +

+ {t.settings.notification.deniedHint} +

+ )} + + {permission === "granted" && settings.notification.enabled && ( + + )} + + )} +
+ + ); +} diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index cc60f7e5..20e833cf 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -173,91 +173,89 @@ export function RecentChatList() {
{visibleThreads.map((thread) => { const isActive = pathOfThread(thread) === pathname; - return ( - - -
- - {titleOfThread(thread)} - - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( - - - - - {t.common.more} - - - - - handleRenameClick( - thread.thread_id, - titleOfThread(thread), - ) - } - > - - {t.common.rename} - - handleShare(thread)} - > - - {t.common.share} - - - - - {t.common.export} - - - - handleExport(thread, "markdown") - } - > - - {t.common.exportAsMarkdown} - - - handleExport(thread, "json") - } - > - - {t.common.exportAsJSON} - - - - - handleDelete(thread.thread_id)} - > - - {t.common.delete} - - - - )} -
-
-
- ); - })} + return ( + + + + {titleOfThread(thread)} + + + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( + + + + + {t.common.more} + + + + + handleRenameClick( + thread.thread_id, + titleOfThread(thread), + ) + } + > + + {t.common.rename} + + handleShare(thread)} + > + + {t.common.share} + + + + + {t.common.export} + + + + handleExport(thread, "markdown") + } + > + + {t.common.exportAsMarkdown} + + + handleExport(thread, "json") + } + > + + {t.common.exportAsJSON} + + + + + handleDelete(thread.thread_id)} + > + + {t.common.delete} + + + + )} + + ); + })}
diff --git a/frontend-web/src/components/workspace/settings/account-settings-page.tsx b/frontend-web/src/components/workspace/settings/account-settings-page.tsx new file mode 100644 index 00000000..864e4258 --- /dev/null +++ b/frontend-web/src/components/workspace/settings/account-settings-page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useMemo } from "react"; +import { CopyIcon, LogOutIcon, UserIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { clearStoredAuth, getStoredAuth } from "@/core/auth"; + +import { SettingsSection } from "./settings-section"; + +function roleLabel(role?: string) { + if (role === "admin") return "管理员"; + if (role === "user") return "普通用户"; + return role || "未知角色"; +} + +function copy(text: string, hint = "已复制") { + if (!text) return; + navigator.clipboard + .writeText(text) + .then(() => toast.success(hint)) + .catch(() => toast.error("复制失败")); +} + +export function AccountSettingsPage() { + const auth = useMemo(() => getStoredAuth(), []); + + if (!auth) { + return ( + +
+ 没有可显示的账号信息。 +
+
+ ); + } + + const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? ""; + const displayName = usernameFromEmail || auth.user_id; + + const handleLogout = () => { + clearStoredAuth(); + // Match the rest of the app's logout flow: blow away the cookie session + // by hitting the gateway logout endpoint and then forcing a full reload + // so any cached query state goes too. + void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" }) + .catch(() => undefined) + .finally(() => { + window.location.assign("/"); + }); + }; + + return ( + +
+
+
+ +
+
+
{displayName}
+
+ {roleLabel(auth.system_role)} +
+
+ + {roleLabel(auth.system_role)} + +
+ + copy(displayName, "用户名已复制")} + /> + copy(auth.user_id, "用户 ID 已复制")} + /> + + + +
+ +
+
+
+ ); +} + +function InfoRow({ + label, + value, + onCopy, +}: { + label: string; + value: string; + onCopy?: () => void; +}) { + return ( +
+
{label}
+
{value || "—"}
+ {onCopy && value && ( + + )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index d304931a..cd1feb3b 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -12,8 +12,10 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n"; import { useI18n } from "@/core/i18n/hooks"; +import { useLocalSettings } from "@/core/settings"; import { cn } from "@/lib/utils"; import { SettingsSection } from "./settings-section"; @@ -27,6 +29,7 @@ export function AppearanceSettingsPage() { const { t, locale, changeLocale } = useI18n(); const { theme, setTheme, systemTheme } = useTheme(); const currentTheme = (theme ?? "system") as "system" | "light" | "dark"; + const [settings, setSettings] = useLocalSettings(); const themeOptions = useMemo( () => [ @@ -107,6 +110,30 @@ export function AppearanceSettingsPage() { + + + + +
+
+
回答速度指示器
+

+ 在生成回答时显示一个小胶囊,实时估算当前 token/s + 并用绿到红的渐变条直观反映快慢。默认关闭。 +

+
+ + setSettings("display", { streamSpeedMeterEnabled: checked }) + } + aria-label="回答速度指示器" + /> +
+
); } diff --git a/frontend-web/src/components/workspace/settings/mcp-server-edit-dialog.tsx b/frontend-web/src/components/workspace/settings/mcp-server-edit-dialog.tsx new file mode 100644 index 00000000..a359daa4 --- /dev/null +++ b/frontend-web/src/components/workspace/settings/mcp-server-edit-dialog.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { useUpsertMCPServer } from "@/core/mcp/hooks"; +import type { + MCPServerConfig, + MCPServerTransport, +} from "@/core/mcp/types"; + +type EditMode = "create" | "edit"; + +export interface MCPServerEditDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: EditMode; + originalName?: string; + initialServer?: MCPServerConfig; + existingNames: string[]; +} + +const TRANSPORT_OPTIONS: { value: MCPServerTransport; label: string }[] = [ + { value: "stdio", label: "stdio(本地进程)" }, + { value: "http", label: "http(远端 HTTP)" }, + { value: "sse", label: "sse(远端 SSE)" }, +]; + +const DEFAULT_SERVER: MCPServerConfig = { + enabled: true, + type: "stdio", + command: "", + args: [], + env: {}, + url: "", + headers: {}, + description: "", + disabled_tools: [], +}; + +function argsToText(args?: string[]): string { + return (args ?? []).join("\n"); +} + +function textToArgs(text: string): string[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function kvToText(map?: Record): string { + if (!map) return ""; + return Object.entries(map) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); +} + +function textToKV(text: string): { value: Record; error?: string } { + const result: Record = {}; + const lines = text.split("\n"); + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + const eq = line.indexOf("="); + if (eq <= 0) { + return { value: {}, error: `每行需为 KEY=VALUE 格式:${line}` }; + } + const key = line.slice(0, eq).trim(); + const value = line.slice(eq + 1); + if (!key) { + return { value: {}, error: `键名不能为空:${line}` }; + } + result[key] = value; + } + return { value: result }; +} + +export function MCPServerEditDialog({ + open, + onOpenChange, + mode, + originalName, + initialServer, + existingNames, +}: MCPServerEditDialogProps) { + const upsert = useUpsertMCPServer(); + + const [name, setName] = useState(""); + const [type, setType] = useState("stdio"); + const [enabled, setEnabled] = useState(true); + const [description, setDescription] = useState(""); + const [command, setCommand] = useState(""); + const [argsText, setArgsText] = useState(""); + const [envText, setEnvText] = useState(""); + const [url, setUrl] = useState(""); + const [headersText, setHeadersText] = useState(""); + const [disabledToolsText, setDisabledToolsText] = useState(""); + + useEffect(() => { + if (!open) return; + const base = initialServer ?? DEFAULT_SERVER; + setName(originalName ?? ""); + setType((base.type ?? "stdio") as MCPServerTransport); + setEnabled(base.enabled); + setDescription(base.description ?? ""); + setCommand(base.command ?? ""); + setArgsText(argsToText(base.args)); + setEnvText(kvToText(base.env)); + setUrl(base.url ?? ""); + setHeadersText(kvToText(base.headers)); + setDisabledToolsText((base.disabled_tools ?? []).join("\n")); + }, [open, initialServer, originalName]); + + const nameConflict = useMemo(() => { + const trimmed = name.trim(); + if (!trimmed) return false; + if (mode === "edit" && trimmed === originalName) return false; + return existingNames.includes(trimmed); + }, [name, existingNames, mode, originalName]); + + const handleSubmit = async () => { + const trimmedName = name.trim(); + if (!trimmedName) { + toast.error("请填写 MCP 服务器名称"); + return; + } + if (nameConflict) { + toast.error(`已存在同名 MCP 服务器:${trimmedName}`); + return; + } + + const env = textToKV(envText); + if (env.error) { + toast.error(`环境变量格式错误:${env.error}`); + return; + } + const headers = textToKV(headersText); + if (headers.error) { + toast.error(`Headers 格式错误:${headers.error}`); + return; + } + + const args = textToArgs(argsText); + const disabledTools = textToArgs(disabledToolsText); + + if (type === "stdio" && !command.trim()) { + toast.error("stdio 传输需要填写启动命令"); + return; + } + if ((type === "http" || type === "sse") && !url.trim()) { + toast.error(`${type} 传输需要填写服务器 URL`); + return; + } + + const server: MCPServerConfig = { + enabled, + type, + description: description.trim(), + command: type === "stdio" ? command.trim() : null, + args: type === "stdio" ? args : [], + env: type === "stdio" ? env.value : {}, + url: type === "stdio" ? null : url.trim(), + headers: type === "stdio" ? {} : headers.value, + disabled_tools: disabledTools, + }; + + try { + await upsert.mutateAsync({ + serverName: trimmedName, + server, + originalName: mode === "edit" ? originalName : undefined, + }); + toast.success(mode === "create" ? "MCP 服务器已创建" : "MCP 服务器已更新"); + onOpenChange(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }; + + return ( + + + + + {mode === "create" ? "新增 MCP 服务器" : `编辑 MCP 服务器`} + + +
+ + setName(e.target.value)} + placeholder="例如 github、filesystem" + aria-invalid={nameConflict || undefined} + /> + {nameConflict && ( +
已存在同名服务器
+ )} +
+ +
+ + + + +
+ + + {enabled ? "已启用" : "已停用"} + +
+
+
+ + +