From be71849aa5de9e3841877b8016afe10a42585cff Mon Sep 17 00:00:00 2001 From: liaibo Date: Sat, 10 Jan 2026 23:08:56 +0800 Subject: [PATCH] =?UTF-8?q?0.1.13=20AI=E5=8A=9F=E8=83=BD=E5=A4=A7=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/ai-agent-platform-v1.md | 317 +++++ design/cloudflare-path-a-runbook.md | 63 + scripts/cloudflared/config.yml.example | 38 + .../docker-compose.cloudflared.example.yml | 14 + .../src/app/api/ai-agent/run/route.ts | 328 +++++ .../src/app/api/backend/health/route.ts | 25 + .../src/app/api/media/assets/route.ts | 16 +- wolai-frontend/src/app/api/media/ocr/route.ts | 2 +- .../src/app/api/media/sign/route.ts | 78 ++ .../src/app/api/media/upload/route.ts | 22 +- .../src/app/api/mindmap-ai/agent/route.ts | 54 +- wolai-frontend/src/app/dev/ai-agent/page.tsx | 14 + .../src/components/ai-agent/AiAgentPanel.tsx | 263 ++++ .../src/components/bottom-toolbar.tsx | 47 +- wolai-frontend/src/components/breadcrumb.tsx | 11 +- .../editor/DocumentAiAgentPanel.tsx | 1209 +++++++++++++++++ .../editor/blocks/MindmapAiAgentPanel.tsx | 473 ++++++- .../components/editor/blocks/MindmapBlock.tsx | 570 +++++++- .../editor/blocks/MindmapContextMenu.tsx | 520 +++++++ .../components/editor/blocks/mindmapTypes.ts | 22 +- .../components/editor/document-content.tsx | 6 +- .../src/hooks/use-backend-health.ts | 14 +- .../lib/ai-agent/protocol/toolTagProtocol.ts | 70 + .../src/lib/ai-agent/runtime/events.ts | 7 + .../src/lib/ai-agent/runtime/runAgent.ts | 332 +++++ .../tools/builtins/doc/docServerTools.ts | 258 ++++ .../tools/builtins/docs/docsServerTools.ts | 144 ++ .../tools/builtins/media/mediaServerTools.ts | 112 ++ .../builtins/mindmap/mindmapServerTools.ts | 736 ++++++++++ .../tools/builtins/rag/lightragServerTools.ts | 123 ++ .../tools/builtins/registryBuiltins.ts | 304 +++++ .../lib/ai-agent/tools/builtins/searchWeb.ts | 53 + .../tools/builtins/slash/slashServerTools.ts | 175 +++ .../src/lib/ai-agent/tools/registry.ts | 69 + .../src/lib/ai-agent/tools/types.ts | 27 + wolai-frontend/src/lib/supabase/server.ts | 20 + wolai-frontend/src/store/ai-agent-ui.ts | 24 + 37 files changed, 6400 insertions(+), 160 deletions(-) create mode 100644 design/ai-agent-platform-v1.md create mode 100644 design/cloudflare-path-a-runbook.md create mode 100644 scripts/cloudflared/config.yml.example create mode 100644 scripts/cloudflared/docker-compose.cloudflared.example.yml create mode 100644 wolai-frontend/src/app/api/ai-agent/run/route.ts create mode 100644 wolai-frontend/src/app/api/backend/health/route.ts create mode 100644 wolai-frontend/src/app/api/media/sign/route.ts create mode 100644 wolai-frontend/src/app/dev/ai-agent/page.tsx create mode 100644 wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx create mode 100644 wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx create mode 100644 wolai-frontend/src/components/editor/blocks/MindmapContextMenu.tsx create mode 100644 wolai-frontend/src/lib/ai-agent/protocol/toolTagProtocol.ts create mode 100644 wolai-frontend/src/lib/ai-agent/runtime/events.ts create mode 100644 wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts create mode 100644 wolai-frontend/src/lib/ai-agent/tools/builtins/doc/docServerTools.ts create mode 100644 wolai-frontend/src/lib/ai-agent/tools/builtins/docs/docsServerTools.ts create mode 100644 wolai-frontend/src/lib/ai-agent/tools/builtins/media/mediaServerTools.ts create mode 100644 wolai-frontend/src/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools.ts create mode 100644 wolai-frontend/src/lib/ai-agent/tools/builtins/rag/lightragServerTools.ts create mode 100644 wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts create mode 100644 wolai-frontend/src/lib/ai-agent/tools/builtins/searchWeb.ts create mode 100644 wolai-frontend/src/lib/ai-agent/tools/builtins/slash/slashServerTools.ts create mode 100644 wolai-frontend/src/lib/ai-agent/tools/registry.ts create mode 100644 wolai-frontend/src/lib/ai-agent/tools/types.ts create mode 100644 wolai-frontend/src/store/ai-agent-ui.ts diff --git a/design/ai-agent-platform-v1.md b/design/ai-agent-platform-v1.md new file mode 100644 index 00000000..a5e44335 --- /dev/null +++ b/design/ai-agent-platform-v1.md @@ -0,0 +1,317 @@ +# AI Agent 平台 v1(把 Wolai 当成“Web 版 VSCode + Cline”来做) + +> 目标:不只在思维导图里,而是在**主编辑区(BlockNote)**、**OnlyOffice**、**思维导图**三处都能使用同一套“会用工具做事”的 AI。 +> +> 我们把当前项目类比为 VSCode: +> - UI 里有一个统一的 Chat 面板(像 VSCode Chat / Cline Webview) +> - AI 不是“输出一段文本”,而是能调用工具(像 Cline 的工具 + VSCode 的 languageModelTools) +> - 工具调用可追溯、可控(权限/确认/日志),且可扩展(MCP / 内置工具 / 用户自定义) + +--- + +## 0. 现状与痛点(为什么现在“不智能”) + +当前项目的 AI 主要集中在 mindmap 相关 Next Route + 面板: + +- `wolai-frontend/src/app/api/mindmap-ai/*`:PDF 大纲生成导图、补完节点、agent 路由等。 +- `wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx`:对话 UI + 在线/本地模型切换 +(有限)工具选择。 + +痛点(用户反馈的核心): + +1) AI 常变成“对话 + 兜底链接”,看起来没真正**调用工具**完成“写入/修改/跳转/补全”。 +2) 缺少跨区域能力:主编辑区、OnlyOffice 没有统一的 AI 工具体系。 +3) 缺少可观测性:用户看不到 AI 做了哪些步骤、调用了哪些工具、产生了哪些改动。 +4) 工具调用协议不稳定:不同模型/网关对 JSON tool-calling 的支持差异大,容易进入循环/步数耗尽。 + +--- + +## 1. 设计目标(对齐 Cline / VSCode Chat 的能力) + +### 1.1 统一入口:一个 AI 面板,多处可用 + +- 右侧/浮层 AI 面板:所有区域共用(思维导图、BlockNote、OnlyOffice)。 +- 支持“快速对话”与“任务模式”(像 Cline:可以多步执行、展示步骤与工具日志)。 +- 支持 `@` 选择文件树资产/上传文件/引用当前选区。 + +### 1.2 统一协议:工具调用必须稳定、可解析、可追溯 + +参考 cankao/cline-main: + +- 使用**XML-like 工具标签协议**(而不是依赖模型原生 function-calling): + - 优点:更跨模型、可流式解析、可用“严格 parser”保证可靠性。 + - 核心点:模型输出 `...`,服务端解析后执行工具,再把 `...` 回喂模型。 + +### 1.3 统一工具模型:Tool / ToolSet / 权限 / 确认 + +参考 cankao/vscode 的 chat tools: + +- Tool 定义包含:id、展示名、modelDescription、inputSchema、来源、是否需要确认、是否可在 prompt 中被引用等。 +- ToolSet:工具集合(方便“手动勾选一组工具”或“按场景启用”)。 +- 权限策略:读操作默认允许;写操作可配置为“自动/需确认/禁止”。 + +### 1.4 统一上下文模型:Attachments / Variables(@ 引用) + +参考 VSCode chat 的 attachments / variables: + +- `@file`/`@folder`/`@selection`/`@mindmap`/`@onlyoffice` 统一变成“上下文条目”。 +- 每个条目可计算 token 预算,必要时“省略/压缩/摘要”。 + +--- + +## 2. 总体架构(分层 + 运行时) + +### 2.1 模块拆分(建议新增) + +在 `wolai-frontend/src/lib` 下新增统一平台包(后续逐步迁移 mindmap AI 逻辑): + +- `src/lib/ai-agent/protocol/` + - 工具标签协议(解析/序列化/流式切分) + - 参考:`cankao/cline-main/src/core/assistant-message/*` +- `src/lib/ai-agent/tools/` + - 工具注册表(Tool、ToolSet、schema) + - 参考:VSCode `languageModelTools*` +- `src/lib/ai-agent/runtime/` + - Agent 循环(step budget、工具执行、结果回喂、压缩/续写) + - 参考:Cline “tool use + new_task + context management” +- `src/lib/ai-agent/context/` + - Attachments/Variables(@ 引用) + - token 预算与省略策略 +- `src/lib/ai-agent/providers/` + - 在线/本地模型统一适配(复用现有 `openaiCompatibleChat`) + +### 2.2 服务端编排(主入口 API) + +新增统一入口(示例): + +- `POST /api/ai-agent/run` + - 输入:会话信息 + 用户输入 + attachments + 工具允许集(auto 或手动) + - 输出:SSE 流(推荐)或 JSON: + - assistant 文本流 + - tool_call / tool_result 事件流 + - 最终 completion(包含“做了什么/改了什么/引用来源/失败原因”) + +为什么需要 SSE: +- 让 UI 像 Cline 一样逐步显示:正在检索/正在读取文档/正在写入导图/已保存等。 + +### 2.3 客户端工具宿主(可选但很关键) + +很多能力必须在浏览器侧执行(例如读取当前选区、定位 OnlyOffice 页码、获取思维导图当前选中节点)。 + +设计为两类工具: + +1) **Server Tools**(在 Next Route 执行) + - searxng 检索、LightRAG 查询、读写 mindmap JSON、生成导图 ops、读写 BlockNote 文档存储等。 +2) **Client Tools**(在浏览器执行) + - 读取当前编辑器选区、读取 OnlyOffice 当前页/选中区域、读当前 mindmap 选中节点等。 + +执行方式(v1 推荐): +- `/api/ai-agent/run` 只执行 Server Tools; +- Client Tools 先不做“由 AI 主动触发”,而是通过 attachments 提前把必要上下文注入(@selection、@currentFile、@mindmapSelection)。 + +执行方式(v2 目标): +- 引入“前端工具宿主”:当服务端解析到 `` 时,通过 SSE 向前端发起请求,前端执行后再回传结果(类似 VSCode extension host / Cline host provider)。 + +--- + +## 3. 协议:工具调用(建议采用 Cline 风格 XML 标签) + +### 3.1 为什么不用纯 JSON tool-calling + +- 不同在线网关/模型对 `response_format=json_object`、function-calling 支持不一致。 +- 一旦输出不符合 schema,容易进入循环(步数耗尽),用户只看到“兜底链接”。 + +### 3.2 v1 工具标签协议(最小可用) + +模型输出只能出现以下块之一: + +- `...`:请求执行工具 +- `...`:结束并输出最终结果 + +工具参数采用子标签: + +```xml + + gemini 3 tokens price + 5 + +``` + +工具结果回喂: + +```xml + + search_web + {"results":[...]} + +``` + +解析器参考: +- `cankao/cline-main/src/core/assistant-message/parse-assistant-message.ts` + +--- + +## 4. 工具体系(跨 Mindmap / BlockNote / OnlyOffice) + +### 4.1 核心内置工具(v1 必做) + +#### A. 检索与证据 + +- `search_web`:通过 searxng 搜索,返回 title/url/snippet(必须可追溯) +- `rag_query`:通过 LightRAG 查询(文档内检索) + +#### B. 思维导图(Mindmap) + +(复用并升级 `design/mindmap-ai-agent-api.md` 的 ops 思路) + +- `mindmap_get`:读取某个 mindmap JSON(documentId + mindmapId) +- `mindmap_apply_ops`:应用 ops 并落盘(增量修改,禁止全量覆盖) +- `mindmap_expand_node`:检索→生成 ops→apply(服务端组合工具) + +#### C. 主编辑区(BlockNote 文档) + +> 目标:让 AI 像“写代码”一样能对文档做结构化编辑,而不是生成一段文本让用户复制粘贴。 + +- `doc_get_selection`(v1 先做成 attachment 注入;v2 再做成 client tool) +- `doc_insert_blocks`:在某个 block 前/后插入 blocks +- `doc_replace_range`:替换某个选区(或某 block 的内容) +- `doc_find`:在文档内查找某段文本/标题定位位置 + +#### D. OnlyOffice(文档阅读/编辑) + +v1 只做“可落地”的: +- `asset_get`:读取文件树中的 asset 元信息(类型/URL/页数等) +- `asset_extract_outline`:从 PDF/Word/PPT 提取大纲(复用现有 PDF 逻辑,Word/PPT 先降级) +- `asset_to_mindmap`:从 asset 生成 mindmap(返回 ops 或完整树,再由 `mindmap_apply_ops` 落盘) + +v2 再做: +- `onlyoffice_jump`:跳转到某页/某段(依赖 OnlyOffice API 能力) +- `onlyoffice_insert_comment`:插入批注/引用锚点 + +### 4.2 ToolSet(便于 UI 一键勾选) + +建议默认提供: + +- `toolset.readonly`:search_web、rag_query、asset_get、mindmap_get(只读) +- `toolset.mindmap_write`:mindmap_apply_ops、mindmap_expand_node +- `toolset.doc_write`:doc_insert_blocks、doc_replace_range +- `toolset.onlyoffice`:asset_extract_outline、asset_to_mindmap、onlyoffice_jump(v2) + +--- + +## 5. UI/交互(对齐“像 Cline 一样智能”) + +### 5.1 一个统一的 AI 面板(简洁) + +参考你给的 CherryStudio 截图:保持简洁,但必须有三块: + +1) 顶部:会话标题 + 模型选择(在线/本地 + model) +2) 中部:对话历史 + 工具执行日志(可折叠) +3) 底部:输入框 + `@` 选择附件 + 工具选择(自动/手动、多选 ToolSet/Tool) + +关键交互规则: +- 焦点在输入框:Enter = 发送;Shift+Enter = 换行;不触发思维导图/编辑器快捷键。 +- 焦点不在输入框:Enter/Tab 等交由当前编辑器(mindmap 节点快捷键、BlockNote 等)。 + +### 5.2 可观测性:必须展示“AI 做了什么” + +每条消息可附: +- 计划(可选) +- 调用过的工具列表(工具名 + 输入 + 输出摘要 + 耗时 + 是否保存) +- 改动摘要(例如:对 mindmap 增加了 7 个子节点;对文档插入了 3 个段落) + +### 5.3 “自动 vs 手动”的真正含义 + +- 自动:AI 在允许的 ToolSet 范围内自行调用工具。 +- 手动:只允许用户勾选的工具/ToolSet;AI 只能在这个集合内调用。 + +--- + +## 6. 里程碑计划(慢慢实现,但每一步都可验收) + +### 完成进度(截至 2026-01-10) + +> 说明:本节用于记录“已经落地到代码”的进度,避免只停留在规划层。 +> ✅=已完成;🟡=部分完成/已打通但仍需扩展;⬜=未开始 + +| 里程碑 | 条目 | 状态 | 备注(对应实现) | +| --- | --- | --- | --- | +| M0 | `POST /api/ai-agent/run` | ✅ | `wolai-frontend/src/app/api/ai-agent/run/route.ts` | +| M0 | XML-like 工具标签协议解析器 | ✅ | `wolai-frontend/src/lib/ai-agent/protocol/toolTagProtocol.ts` | +| M0 | Tool Registry(Tool/ToolSet) | ✅ | `wolai-frontend/src/lib/ai-agent/tools/registry.ts` + `wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts` | +| M0 | Agent Runtime(step budget + 工具日志) | ✅ | `wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts` | +| M0 | SSE 输出(assistant/tool_call/tool_result/completion) | ✅ | `/api/ai-agent/run` 已支持 SSE | +| M0 | 前端 AI 面板(可复用) | 🟡 | 目前已在 dev 面板 + 思维导图面板接入;BlockNote/OnlyOffice 仍需继续推进 | +| M1 | mindmap 读写工具(增量 ops + 细粒度工具) | ✅ | `wolai-frontend/src/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools.ts` | +| M1 | `mindmap_expand_node`(检索→生成→落盘) | ✅ | 同上(支持可选 `search_web` 证据) | +| M1 | UI:选中节点上下文注入(给 AI) | ✅ | 思维导图面板通过 `context.selectedUids` 注入;并对用户显示纯文本节点内容 | +| M1 | UI:最大步数可配置(不再固定 6) | ✅ | 思维导图面板/Dev 面板支持 1~24,默认 10 | +| M2 | ToolSet 按位置隔离(避免工具混淆) | ✅ | `/api/ai-agent/run` 按 `scope` 强制过滤;各面板按场景传不同 toolSets | +| M2 | BlockNote 文档工具(doc_*) | 🟡 | 已实现 doc_get/doc_find/doc_insert_blocks/doc_replace_range + 文档侧边 AI 面板;`@selection`/更丰富块类型仍需扩展 | +| M2 | LightRAG 检索工具(rag_*) | ✅ | `rag_lightrag_query`(调用 `LIGHTRAG_URL` 的 `/query`) | +| M2 | 跨页面文档工具(docs_*) | ✅ | `docs_search` + `docs_read`(按 title/raw_text) | +| M2 | 图片读取工具(OCR) | ✅ | `image_read`(读取 `media_assets.ocr_text`) | +| M2 | 斜杠命令工具 | ✅ | `slash_run`(/new 创建文档、/rename 重命名) | + +### M0(基础设施):统一 Agent Runtime + 工具协议 + 日志 + +- [x] 新增 `POST /api/ai-agent/run`(先不替换现有 mindmap-ai,做并行) +- [x] 实现 XML-like 工具协议解析器(参考 cline 的 parse-assistant-message) +- [x] 实现 Tool Registry(Tool/ToolSet/inputSchema/权限标记) +- [ ] 前端统一 AI 面板(可嵌入 mindmap/sidebar、主编辑区、OnlyOffice 页面)(已在 dev + mindmap 接入,BlockNote/OnlyOffice 待推进) +- [x] SSE 输出:assistant 文本 + tool_call + tool_result + completion + +验收(pw-tests): +- 输入“请给出 gemini-3 tokens 价格”,能看到工具日志(至少 search_web),并返回带来源的答案。 + +### M1(思维导图):把 mindmap AI 从“对话”升级为“增量 ops 写入” + +- [x] 复用 `design/mindmap-ai-agent-api.md`:完善 `mindmap_get`、`mindmap_apply_ops` +- [x] `mindmap_expand_node`:服务端检索→生成 ops→apply,并强制 refs +- [x] UI:在 mindmap 中选择节点后,@mindmapSelection 自动注入上下文(通过 `context.selectedUids` 注入) + +验收(pw-tests): +- 选中节点“补完”,新增子节点≥3,刷新仍存在,且每个新增节点带 refs/可跳转链接。 + +### M2(主编辑区 BlockNote):让 AI 能“读选区并结构化改写/插入” + +- [x] 定义 BlockNote 文档操作 API(只允许增量,禁止覆盖整篇)(当前以“块级插入/替换”为最小可用) +- [x] 工具:`doc_insert_blocks`、`doc_replace_range`、`doc_find`(另含 `doc_get`) +- [ ] UI:输入框支持 `@selection`(或“引用当前选区”按钮) + +验收(pw-tests): +- 选中一段文本,让 AI “改写成更简洁版本并保留要点”,结果直接落入文档(不是生成一段文本)。 + +### M3(OnlyOffice):文档驱动导图 + 可跳转引用 + +- [ ] `asset_extract_outline`:PDF 优先,Word/PPT 先降级(至少按标题层级) +- [ ] `asset_to_mindmap`:从文档大纲生成 mindmap(章→节→要点) +- [ ] 引用:节点 refs(page/slide)+ hyperlink(`#page=`) + +验收(pw-tests): +- 对指定 PDF 生成“章→节→内容”层级导图,点击节点跳到对应页(至少 URL 含 `#page=`)。 + +### M4(v2):前端工具宿主 + MCP 工具 + 类“技能”系统 + +- [ ] 前端工具宿主:允许 AI 请求 client tool(读取当前选区、OnlyOffice 当前页) +- [ ] MCP:把 supabase_local / searxng / 未来自定义 MCP 作为工具源(像 cline) +- [ ] skills:把“工作流说明”做成可加载的技能文件,AI 可按需激活(类似你现在的 Codex skills) + +验收: +- AI 能在一个任务里自行组合:检索→读文档→写导图→在主编辑区插入总结→生成可跳转引用。 + +--- + +## 7. 风险与策略 + +1) **安全/误操作**:默认只允许增量 ops;涉及删除/覆盖需要二次确认或显式指令。 +2) **上下文过长**:引入“attachments 省略/摘要”,并在会话接近上限时自动 compact(参考 opencode/crush)。 +3) **模型差异**:工具协议用 XML-like,避免强依赖 function-calling;必要时提供 provider 特化 prompt。 +4) **性能/长任务**:PDF 解析/大纲生成走后端 job(可加队列/轮询),前端只显示进度与结果。 + +--- + +## 8. 与现有 design 的关系(不推倒重来) + +- `design/mindmap-ai-v2.md`:保留“PDF/小结/RAG 补完”的业务目标与验收用例。 +- `design/mindmap-ai-agent-api.md`:保留“mindmap ops 增量协议”,作为 v1 中 Mindmap 工具的核心。 +- 本文档新增的是:**把 mindmap 的思路抽象成全站统一的 AI Agent 平台**,并且对齐 Cline/VSCode 的“工具 + 上下文 + 日志 + 权限”工程化形态。 diff --git a/design/cloudflare-path-a-runbook.md b/design/cloudflare-path-a-runbook.md new file mode 100644 index 00000000..6f3ca043 --- /dev/null +++ b/design/cloudflare-path-a-runbook.md @@ -0,0 +1,63 @@ +# 路径 A 实施手册:Cloudflare 入口 + 本地/自建服务器跑完整后端 + +> 目标:用户访问你的域名(Cloudflare,当前为 `aichem.dpdns.org`),请求通过 Cloudflare Tunnel 回源到你自建服务器(或家里电脑)上的 Next.js(wolai-frontend)与 Supabase/LightRAG/MinerU 等服务。 + +## 1. 推荐拓扑(最接近你当前代码形态) + +- `app.aichem.dpdns.org` → `wolai-frontend`(Next 3000:页面 + /api/*) +- `supabase.aichem.dpdns.org` → `Supabase Kong`(示例 18000) +- `backend.aichem.dpdns.org` → `wolai-backend`(示例 8000,可选) +- `lightrag.aichem.dpdns.org` → `LightRAG`(示例 7777,建议加 Access) +- `mineru.aichem.dpdns.org` → `MinerU`(示例 18888,建议加 Access) + +> 用子域名的原因:Supabase JS 在浏览器端需要一个“稳定的 base URL”。把 Supabase 放在 path(例如 /supabase)会牵涉 WebSocket/重写/多服务路径复杂度,不建议前期这样做。 + +## 2. Cloudflare Tunnel 配置 + +仓库提供了示例文件(已按 `aichem.dpdns.org` 预填 hostname,可直接改端口/删减服务): +- `scripts/cloudflared/config.yml.example` +- `scripts/cloudflared/docker-compose.cloudflared.example.yml` + +你需要做的事: +1) Cloudflare 控制台创建 Tunnel +2) 下载 `credentials.json` +3) 替换 `config.yml` 里的 `tunnel` 与 `credentials-file` +4) 绑定 DNS:为每个 hostname 绑定到该 tunnel +5) 在服务器上运行 cloudflared(Windows 可直接运行;Linux 可用 docker compose) + +## 3. 环境变量(关键:区分“浏览器访问地址”和“服务端内网地址”) + +在路径 A 下,**浏览器端**必须访问 Cloudflare 域名;但 **Next 服务端**访问 Supabase/Backend 建议走内网地址(更快、更稳定,不绕 Cloudflare)。 + +### 3.1 wolai-frontend + +浏览器端(公开): +- `NEXT_PUBLIC_SUPABASE_URL=https://supabase.aichem.dpdns.org` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY=` +- `NEXT_PUBLIC_BACKEND_URL=https://backend.aichem.dpdns.org`(可选) + +服务端内网(仅 Next 服务器用,不暴露给浏览器): +- `SUPABASE_INTERNAL_URL=http://127.0.0.1:18000` +- `SUPABASE_ANON_KEY=<同一个 anon key>` +- `BACKEND_URL=http://127.0.0.1:8000`(可选) + +推荐用 `wolai-frontend/.env.production.local` 来承载生产配置;仓库提供了模板: +- `wolai-frontend/.env.production.example` + +> 已做代码支持:`wolai-frontend/src/lib/supabase/server.ts` 会优先用 `SUPABASE_INTERNAL_URL`;`wolai-frontend/src/app/api/media/ocr/route.ts` 会优先用 `BACKEND_URL`。 + +### 3.2 ingest_service / rag_gateway + +你现在的 `services/ingest_service/app/core/config.py` 已优先读取仓库根目录 `.env.local/.env`。 +因此只要在根目录 `.env.local` 正确配置: +- `SUPABASE_URL=http://127.0.0.1:18000` +- `SUPABASE_SERVICE_ROLE_KEY=...` +- `LIGHTRAG_URL=http://127.0.0.1:7777` +- `MINERU_ENDPOINT=http://127.0.0.1:18888` + +即可。 + +## 4. 安全建议(路径 A 很重要) + +- `lightrag.<域名>`、`mineru.<域名>`:建议用 Cloudflare Access 或至少 IP 白名单,否则等同于把内部能力直接暴露到公网。 +- `supabase.<域名>`:若暴露到公网,务必确认 RLS 与 Auth 配置正确;不要泄露 service_role key。 diff --git a/scripts/cloudflared/config.yml.example b/scripts/cloudflared/config.yml.example new file mode 100644 index 00000000..bec0049f --- /dev/null +++ b/scripts/cloudflared/config.yml.example @@ -0,0 +1,38 @@ +# Cloudflare Tunnel 示例配置(路径 A:前端与 API 同源回源到本机) +# +# 用法(示意): +# 1) 在 Cloudflare 创建 tunnel 并下载 credentials.json +# 2) 把 credentials.json 放到与本文件同目录(或修改下面 credentials-file) +# 3) cloudflared tunnel --config .\config.yml run +# +# 注意: +# - hostname 需要先在 Cloudflare DNS 里绑定到该 tunnel +# - 本示例把 app/supabase/lightrag/mineru 分别用子域名暴露 +# - 生产环境建议对 lightrag/mineru 增加额外保护(Cloudflare Access / IP 白名单) + +tunnel: +credentials-file: ./.json + +ingress: + # 1) Next.js(wolai-frontend,含 /api/*) + - hostname: app.aichem.dpdns.org + service: http://127.0.0.1:3000 + + # 2) Supabase(Kong 入口,开发环境常见为 18000) + - hostname: supabase.aichem.dpdns.org + service: http://127.0.0.1:18000 + + # 3) wolai-backend(如需对外暴露) + - hostname: backend.aichem.dpdns.org + service: http://127.0.0.1:8000 + + # 4) LightRAG(你当前规划为 7777) + - hostname: lightrag.aichem.dpdns.org + service: http://127.0.0.1:7777 + + # 5) MinerU(你当前 .env.local 为 18888) + - hostname: mineru.aichem.dpdns.org + service: http://127.0.0.1:18888 + + # 未匹配的请求直接 404 + - service: http_status:404 diff --git a/scripts/cloudflared/docker-compose.cloudflared.example.yml b/scripts/cloudflared/docker-compose.cloudflared.example.yml new file mode 100644 index 00000000..4c9834af --- /dev/null +++ b/scripts/cloudflared/docker-compose.cloudflared.example.yml @@ -0,0 +1,14 @@ +version: "3.8" + +services: + cloudflared: + image: cloudflare/cloudflared:latest + container_name: cloudflared + restart: unless-stopped + command: tunnel --config /etc/cloudflared/config.yml run + network_mode: "host" + volumes: + - ./config.yml:/etc/cloudflared/config.yml:ro + # 把你的 credentials.json 放在同目录并映射进去 + - ./:/etc/cloudflared:ro + diff --git a/wolai-frontend/src/app/api/ai-agent/run/route.ts b/wolai-frontend/src/app/api/ai-agent/run/route.ts new file mode 100644 index 00000000..1972234d --- /dev/null +++ b/wolai-frontend/src/app/api/ai-agent/run/route.ts @@ -0,0 +1,328 @@ +import { NextResponse } from "next/server"; +import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig"; +import { loadLocalAiConfig } from "@/lib/ai/localAiConfig"; +import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/registry"; +import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins"; +import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb"; +import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools"; +import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools"; +import { createRagServerTools } from "@/lib/ai-agent/tools/builtins/rag/lightragServerTools"; +import { createDocsServerTools, type DocsSupabaseClient } from "@/lib/ai-agent/tools/builtins/docs/docsServerTools"; +import { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent/tools/builtins/media/mediaServerTools"; +import { createSlashServerTools, type SlashSupabaseClient } from "@/lib/ai-agent/tools/builtins/slash/slashServerTools"; + +export const dynamic = "force-dynamic"; + +type AgentMessage = { role: "user" | "assistant"; content: string }; +type AgentScope = "global" | "mindmap" | "document"; + +type RequestPayload = { + stream?: boolean; + maxSteps?: number; + scope?: AgentScope; + messages: AgentMessage[]; + attachments?: Array<{ id: string; title: string; fileUrl: string; mimeType?: string | null }>; + toolChoice?: { mode: "auto" | "manual"; toolSets?: string[]; tools?: string[] }; + context?: { + documentId?: string; + mindmapId?: string; + selectedUids?: string[]; + // v1:BlockNote 文档快照(前端可选传入,避免覆盖未落盘编辑) + documentBlocks?: unknown; + }; + options?: { searxng?: boolean; ai?: { provider?: "online" | "local"; model?: string } }; +}; + +const DEFAULT_MAX_STEPS = 10; + +const sseHeaders = { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", +} as const; + +const toSseFrame = (event: string, data: unknown) => { + const json = JSON.stringify(data ?? null); + return `event: ${event}\ndata: ${json}\n\n`; +}; + +export async function POST(request: Request) { + const payload = (await request.json().catch(() => null)) as RequestPayload | null; + if (!payload || !Array.isArray(payload.messages) || payload.messages.length === 0) { + return NextResponse.json({ error: "缺少 messages" }, { status: 400 }); + } + + // v1:先要求登录(避免在生产环境暴露推理能力);后续可做更细的权限控制 + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const provider = payload.options?.ai?.provider === "local" ? "local" : "online"; + const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null; + const cfg = + provider === "local" + ? await loadLocalAiConfig().catch(() => null) + : await loadOnlineAiConfig().catch(() => null); + if (!cfg) { + const tip = + provider === "local" + ? "未找到本地 AI 配置(LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md)" + : "未找到在线 AI 配置(ai.md 或 ONLINE_AI_* 环境变量)"; + return NextResponse.json({ error: tip }, { status: 500 }); + } + + const registry = createToolRegistry({ tools: builtinTools, toolSets: builtinToolSets }); + const allowedToolIds = resolveAllowedToolIds({ + registry, + mode: payload.toolChoice?.mode === "manual" ? "manual" : "auto", + toolSetIds: payload.toolChoice?.toolSets, + toolIds: payload.toolChoice?.tools, + }); + + const documentId = String(payload.context?.documentId ?? "").trim(); + const mindmapId = String(payload.context?.mindmapId ?? "").trim(); + const scope: AgentScope = (() => { + const raw = String(payload.scope ?? "").trim(); + if (raw === "global" || raw === "mindmap" || raw === "document") return raw; + // 兜底:有 mindmapId 则认为在 mindmap 场景,否则视为全局场景 + return mindmapId ? "mindmap" : "global"; + })(); + + // v1:按“使用位置”隔离工具,避免工具混淆/误调用(即使用户手动传入,也会被过滤) + const allowToolSetIds: string[] = + scope === "mindmap" + ? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.mindmap_read", "toolset.mindmap_write"] + : scope === "document" + ? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.doc_read", "toolset.doc_write", "toolset.slash_write"] + : ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.slash_write"]; + const allowlist = new Set(); + for (const sid of allowToolSetIds) { + const s = registry.toolSetsById.get(sid); + (s?.toolIds ?? []).forEach((id) => allowlist.add(id)); + } + for (const id of [...allowedToolIds]) { + if (!allowlist.has(id)) allowedToolIds.delete(id); + } + + // v1:允许通过 options.searxng 关闭联网检索(比如离线模型/内网) + if (payload.options?.searxng === false) allowedToolIds.delete("search_web"); + + // v1:mindmap 工具必须在提供上下文时才允许,避免模型盲调导致误操作 + const selectedUids = Array.isArray(payload.context?.selectedUids) + ? payload.context!.selectedUids!.map((x) => String(x)).filter(Boolean).slice(0, 6) + : []; + const hasMindmapContext = Boolean(documentId && mindmapId); + const hasDocumentContext = Boolean(documentId); + const documentBlocks = payload.context?.documentBlocks ?? null; + const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, 12) : []; + const attachmentLines = attachments + .map((a, idx) => `${idx + 1}. id=${String(a.id)} title=${String(a.title)} mime=${String(a.mimeType ?? "")} url=${String(a.fileUrl)}`) + .join("\n"); + if (!hasMindmapContext) { + allowedToolIds.delete("mindmap_get"); + allowedToolIds.delete("mindmap_get_subtree"); + allowedToolIds.delete("mindmap_apply_ops"); + allowedToolIds.delete("mindmap_expand_node"); + allowedToolIds.delete("mindmap_add_child"); + allowedToolIds.delete("mindmap_add_sibling_after"); + allowedToolIds.delete("mindmap_update_node_text"); + allowedToolIds.delete("mindmap_set_hyperlink"); + allowedToolIds.delete("mindmap_append_note"); + allowedToolIds.delete("mindmap_set_refs"); + allowedToolIds.delete("mindmap_delete_node"); + allowedToolIds.delete("mindmap_add_attachment_ref"); + allowedToolIds.delete("mindmap_add_attachment_child"); + allowedToolIds.delete("mindmap_add_image_child"); + allowedToolIds.delete("mindmap_append_image_note"); + } + + if (!hasDocumentContext) { + allowedToolIds.delete("doc_get"); + allowedToolIds.delete("doc_find"); + allowedToolIds.delete("doc_insert_blocks"); + allowedToolIds.delete("doc_replace_range"); + } + + const systemContextText = (() => { + const lines: string[] = []; + if (documentId) lines.push(`documentId=${documentId}`); + if (scope === "mindmap") { + if (mindmapId) lines.push(`mindmapId=${mindmapId}`); + if (selectedUids.length) lines.push(`selectedUids=${selectedUids.join(",")}`); + } + if (scope === "document") { + if (documentBlocks) lines.push("documentBlocks=provided"); + } + if (attachments.length) lines.push(`attachments:\n${attachmentLines}`); + return lines.join("\n").trim(); + })(); + + const mindmapTools = hasMindmapContext + ? createMindmapServerTools({ + supabase: supabase as unknown as MindmapSupabaseClient, + ctx: { documentId, mindmapId, userId: session.user.id, selectedUids, attachments }, + cfg: { ...cfg, model: modelOverride ?? cfg.model }, + allowedToolIds, + }) + : null; + + const docTools = + hasDocumentContext && + (allowedToolIds.has("doc_get") || + allowedToolIds.has("doc_find") || + allowedToolIds.has("doc_insert_blocks") || + allowedToolIds.has("doc_replace_range")) + ? createDocServerTools({ + supabase: supabase as unknown as DocSupabaseClient, + ctx: { documentId, userId: session.user.id, baseBlocks: documentBlocks }, + allowedToolIds, + }) + : null; + + const ragTools = allowedToolIds.has("rag_lightrag_query") + ? createRagServerTools({ + ctx: { userId: session.user.id }, + allowedToolIds, + }) + : null; + + const docsTools = + allowedToolIds.has("docs_search") || allowedToolIds.has("docs_read") + ? createDocsServerTools({ + supabase: supabase as unknown as DocsSupabaseClient, + ctx: { userId: session.user.id }, + allowedToolIds, + }) + : null; + + const mediaTools = allowedToolIds.has("image_read") + ? createMediaServerTools({ + supabase: supabase as unknown as MediaSupabaseClient, + ctx: { userId: session.user.id, attachments }, + allowedToolIds, + }) + : null; + + const slashTools = allowedToolIds.has("slash_run") + ? createSlashServerTools({ + supabase: supabase as unknown as SlashSupabaseClient, + ctx: { userId: session.user.id, currentDocumentId: documentId || undefined }, + allowedToolIds, + }) + : null; + + const runTool = async (toolId: string, toolArgs: Record) => { + if (toolId === "search_web") { + const query = String(toolArgs.query ?? "").trim(); + const count = Number(toolArgs.count ?? 6); + return await searchSearxng(query, Number.isFinite(count) ? count : 6); + } + if (toolId.startsWith("rag_")) { + if (!ragTools) throw new Error(`工具未初始化:${toolId}`); + return await ragTools.run(toolId, toolArgs); + } + if (toolId.startsWith("docs_")) { + if (!docsTools) throw new Error(`工具未初始化:${toolId}`); + return await docsTools.run(toolId, toolArgs); + } + if (toolId === "image_read") { + if (!mediaTools) throw new Error(`工具未初始化:${toolId}`); + return await mediaTools.run(toolId, toolArgs); + } + if (toolId === "slash_run") { + if (!slashTools) throw new Error(`工具未初始化:${toolId}`); + return await slashTools.run(toolId, toolArgs); + } + if (toolId.startsWith("doc_")) { + if (!hasDocumentContext) throw new Error(`工具需要 document 上下文:${toolId}`); + if (!docTools) throw new Error(`工具需要 document 上下文:${toolId}`); + return await docTools.run(toolId, toolArgs); + } + if (!hasMindmapContext) throw new Error(`工具需要 mindmap 上下文:${toolId}`); + if (!mindmapTools) throw new Error(`工具需要 mindmap 上下文:${toolId}`); + return await mindmapTools.run(toolId, toolArgs); + }; + + const maxSteps = (() => { + const raw = Number(payload.maxSteps ?? DEFAULT_MAX_STEPS); + if (!Number.isFinite(raw)) return DEFAULT_MAX_STEPS; + return Math.max(1, Math.min(24, Math.floor(raw))); + })(); + + const stream = payload.stream !== false; + if (!stream) { + const events: Array<{ type: string; data: unknown }> = []; + const result = await runAiAgent({ + userMessages: payload.messages.slice(0, 50), + cfg: { ...cfg, model: modelOverride ?? cfg.model }, + allowedToolIds, + runTool, + maxSteps, + systemContextText, + defaultMindmapTargetUid: selectedUids[0] ?? "", + onEvent: (ev) => events.push(ev), + }).catch((e) => ({ ok: false as const, error: e instanceof Error ? e.message : String(e) })); + if (!result.ok) return NextResponse.json({ error: result.error }, { status: 500 }); + return NextResponse.json({ text: result.text, steps: result.steps, events }); + } + + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + const send = (event: string, data: unknown) => { + controller.enqueue(encoder.encode(toSseFrame(event, data))); + }; + + // 先发一个 ready,方便前端快速进入“流式模式” + send("ready", { ok: true }); + + const ping = setInterval(() => { + // 避免某些代理/浏览器长连接超时 + try { + controller.enqueue(encoder.encode(`: ping ${Date.now()}\n\n`)); + } catch { + // ignore + } + }, 15_000); + + (async () => { + const result = await runAiAgent({ + userMessages: payload.messages.slice(0, 50), + cfg: { ...cfg, model: modelOverride ?? cfg.model }, + allowedToolIds, + runTool, + maxSteps, + systemContextText, + defaultMindmapTargetUid: selectedUids[0] ?? "", + onEvent: (ev) => { + if (!ev?.type) return; + send(ev.type, ev.data ?? null); + }, + }); + + if (!result.ok) { + send("error", { ok: false, message: result.error }); + return; + } + send("completion", { ok: true, text: result.text, steps: result.steps }); + })() + .catch((e) => { + const msg = e instanceof Error ? e.message : String(e); + send("error", { ok: false, message: msg }); + }) + .finally(() => { + clearInterval(ping); + controller.close(); + }); + }, + }); + + return new Response(body, { headers: sseHeaders }); +} diff --git a/wolai-frontend/src/app/api/backend/health/route.ts b/wolai-frontend/src/app/api/backend/health/route.ts new file mode 100644 index 00000000..9be3991d --- /dev/null +++ b/wolai-frontend/src/app/api/backend/health/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const backendUrl = process.env.BACKEND_URL ?? process.env.NEXT_PUBLIC_BACKEND_URL; + if (!backendUrl) { + return NextResponse.json({ status: "disabled" }, { status: 200 }); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2500); + try { + const response = await fetch(`${backendUrl}/health`, { + method: "GET", + signal: controller.signal, + }); + return NextResponse.json({ status: response.ok ? "ok" : "error" }, { status: 200 }); + } catch { + return NextResponse.json({ status: "error" }, { status: 200 }); + } finally { + clearTimeout(timer); + } +} + diff --git a/wolai-frontend/src/app/api/media/assets/route.ts b/wolai-frontend/src/app/api/media/assets/route.ts index b1ab554a..58069a60 100644 --- a/wolai-frontend/src/app/api/media/assets/route.ts +++ b/wolai-frontend/src/app/api/media/assets/route.ts @@ -7,10 +7,11 @@ export const dynamic = "force-dynamic"; export async function GET(request: Request) { const supabase = await createSupabaseRouteClient(); const { - data: { session }, - } = await supabase.auth.getSession(); + data: { user }, + error: authError, + } = await supabase.auth.getUser(); - if (!session) { + if (authError || !user) { return NextResponse.json({ error: "未登录" }, { status: 401 }); } @@ -47,10 +48,11 @@ export async function GET(request: Request) { export async function POST(request: Request) { const supabase = await createSupabaseRouteClient(); const { - data: { session }, - } = await supabase.auth.getSession(); + data: { user }, + error: authError, + } = await supabase.auth.getUser(); - if (!session) { + if (authError || !user) { return NextResponse.json({ error: "未登录" }, { status: 401 }); } @@ -80,7 +82,7 @@ export async function POST(request: Request) { file_name: payload.fileName, file_size: payload.fileSize ?? null, mime_type: payload.mimeType ?? null, - created_by: session.user.id, + created_by: user.id, }) .select("*") .single(); diff --git a/wolai-frontend/src/app/api/media/ocr/route.ts b/wolai-frontend/src/app/api/media/ocr/route.ts index 802a04e5..92ab7647 100644 --- a/wolai-frontend/src/app/api/media/ocr/route.ts +++ b/wolai-frontend/src/app/api/media/ocr/route.ts @@ -27,7 +27,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: error.message }, { status: 500 }); } - const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL; + const backendUrl = process.env.BACKEND_URL ?? process.env.NEXT_PUBLIC_BACKEND_URL; if (backendUrl) { void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, { method: "POST", diff --git a/wolai-frontend/src/app/api/media/sign/route.ts b/wolai-frontend/src/app/api/media/sign/route.ts new file mode 100644 index 00000000..c67103fe --- /dev/null +++ b/wolai-frontend/src/app/api/media/sign/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const supabase = await createSupabaseRouteClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const assetId = searchParams.get("assetId"); + + if (!assetId) { + return NextResponse.json({ error: "缺少 assetId" }, { status: 400 }); + } + + // 获取资源信息 + const { data: asset, error: assetError } = await supabase + .from("media_assets") + .select("*") + .eq("id", assetId) + .single(); + + if (assetError || !asset) { + return NextResponse.json({ error: "资源不存在" }, { status: 404 }); + } + + // 对于图片,添加高质量参数以获得更好的显示效果 + // Supabase Storage 支持通过 URL 参数控制图片质量和尺寸 + const isImage = asset.mime_type?.startsWith("image/"); + let signedUrl: string; + + if (isImage) { + // 使用 createSignedUrl 并添加高质量参数 + // 注意:transform 参数需要在签名时指定 + const { data: signedUrlData, error: signError } = await supabase.storage + .from(asset.bucket) + .createSignedUrl(asset.storage_path, 60 * 60, { + // 添加转换参数以获得高质量图片 + transform: { + quality: 95, // 高质量 + format: "origin", // 保持原始格式 + }, + }); + + if (signError || !signedUrlData) { + return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 }); + } + signedUrl = signedUrlData.signedUrl; + } else { + // 非图片文件,直接生成签名 URL + const { data: signedUrlData, error: signError } = await supabase.storage + .from(asset.bucket) + .createSignedUrl(asset.storage_path, 60 * 60); + + if (signError || !signedUrlData) { + return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 }); + } + signedUrl = signedUrlData.signedUrl; + } + + return NextResponse.json({ + signedUrl, + asset: { + id: asset.id, + file_name: asset.file_name, + mime_type: asset.mime_type, + file_size: asset.file_size, + }, + }); +} diff --git a/wolai-frontend/src/app/api/media/upload/route.ts b/wolai-frontend/src/app/api/media/upload/route.ts index e5845f73..e06ae457 100644 --- a/wolai-frontend/src/app/api/media/upload/route.ts +++ b/wolai-frontend/src/app/api/media/upload/route.ts @@ -51,8 +51,20 @@ export async function POST(request: Request) { } // 为私有桶生成临时访问链接(7 天);前端可在需要时通过 /api/media/signed-url 刷新 - const { data: signed } = await supabase.storage.from(DOC_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7); - const signedUrl = signed?.signedUrl ?? ""; + // 对于图片,使用高质量参数以获得更好的显示效果 + let signedUrl = ""; + if (assetType === "image") { + const { data: signed } = await supabase.storage.from(DOC_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7, { + transform: { + quality: 95, // 高质量 + format: "origin", // 保持原始格式 + }, + }); + signedUrl = signed?.signedUrl ?? ""; + } else { + const { data: signed } = await supabase.storage.from(DOC_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7); + signedUrl = signed?.signedUrl ?? ""; + } const { data: asset, error } = await supabase .from("media_assets") @@ -76,7 +88,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: error.message }, { status: 500 }); } - return NextResponse.json({ asset: asset as MediaAsset }); + // 返回 asset 和一个特殊的 asset:id 格式用于存储在思维导图中 + return NextResponse.json({ + asset: asset as MediaAsset, + mindmapUrl: `asset:${asset.id}`, + }); } catch (error) { console.error(error); return NextResponse.json({ error: "上传失败" }, { status: 500 }); diff --git a/wolai-frontend/src/app/api/mindmap-ai/agent/route.ts b/wolai-frontend/src/app/api/mindmap-ai/agent/route.ts index 7c2e4ca4..19391fb5 100644 --- a/wolai-frontend/src/app/api/mindmap-ai/agent/route.ts +++ b/wolai-frontend/src/app/api/mindmap-ai/agent/route.ts @@ -213,6 +213,7 @@ const answerFromSearchResults = async (args: { const resultsText = formatSearchResults(args.results); if (!q) return resultsText || "(无问题)"; + let aiError: string | null = null; try { const { text } = await openAiCompatibleChat( [ @@ -239,11 +240,12 @@ const answerFromSearchResults = async (args: { ); const out = String(text ?? "").trim(); if (out) return out; - } catch { - // ignore + } catch (e) { + aiError = e instanceof Error ? e.message : String(e); } - return resultsText || "(未检索到结果)"; + const prefix = aiError ? `(在线 AI 总结失败:${aiError})\n\n` : ""; + return `${prefix}${resultsText || "(未检索到结果)"}`.trim(); }; const buildFallbackExpandOps = async (args: { @@ -511,6 +513,52 @@ export async function POST(request: Request) { String(walkSummaries(mindmap, 1)?.[0]?.uid || ""); const defaultTargetUid = (selected[0] || rootUid || "").trim(); + // 纯问答模式:不要求改导图时,直接“检索 → 总结”返回,避免模型反复输出工具 JSON 导致卡步数 + if (!userIntentModify) { + const question = String(lastUser || "").trim(); + const qaTrace: Array<{ step: number; call: ToolCall; toolResult?: unknown }> = []; + + if (question && allowed.has("search_web")) { + const results = await searchSearxng(question, 6).catch(() => []); + qaTrace.push({ + step: 1, + call: { type: "tool", tool: "search_web", args: { query: question, count: 6 } }, + toolResult: results, + }); + const answer = results.length + ? await answerFromSearchResults({ cfg, modelOverride, question, results }) + : "(未检索到结果)"; + qaTrace.push({ step: 2, call: { type: "final", message: answer } }); + return NextResponse.json({ ok: true, message: answer, data: mindmap, trace: qaTrace }); + } + + // 未开启检索或无问题:直接用 AI 回答(不改导图) + const { text } = await openAiCompatibleChat( + [ + { + role: "system", + content: [ + "你是一个严谨的中文助手。", + "用户提出问题,请直接回答。", + "如果你不确定,明确说明并建议以官方为准。", + "不要输出 JSON。", + ].join("\n"), + }, + { role: "user", content: question || "(空)" }, + ], + { + baseUrl: cfg.baseUrl, + apiKey: cfg.apiKey, + model: modelOverride || cfg.model, + timeoutMs: 55_000, + maxTokens: 1200, + }, + ); + const answer = String(text || "").trim() || "(无输出)"; + qaTrace.push({ step: 1, call: { type: "final", message: answer } }); + return NextResponse.json({ ok: true, message: answer, data: mindmap, trace: qaTrace }); + } + for (let step = 1; step <= maxSteps; step += 1) { const { text } = await openAiCompatibleChat( [ diff --git a/wolai-frontend/src/app/dev/ai-agent/page.tsx b/wolai-frontend/src/app/dev/ai-agent/page.tsx new file mode 100644 index 00000000..15343172 --- /dev/null +++ b/wolai-frontend/src/app/dev/ai-agent/page.tsx @@ -0,0 +1,14 @@ +import { AiAgentPanel } from "@/components/ai-agent/AiAgentPanel"; + +export default function DevAiAgentPage() { + return ( +
+
AI Agent 平台 v1(M0)
+
+ 这是并行实验入口:调用 /api/ai-agent/run(SSE) +
+ +
+ ); +} + diff --git a/wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx b/wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx new file mode 100644 index 00000000..498b8ac2 --- /dev/null +++ b/wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +type ChatMsg = { role: "user" | "assistant"; content: string }; +type ToolLog = + | { type: "tool_call"; id: string; tool: string; args: Record } + | { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown } + | { type: "error"; message: string }; + +const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => { + if (!res.body) throw new Error("响应不支持流式读取"); + const reader = res.body.getReader(); + const decoder = new TextDecoder("utf-8"); + + let buffer = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + while (true) { + const sep = buffer.indexOf("\n\n"); + if (sep === -1) break; + const raw = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + + const lines = raw.split(/\r?\n/); + let event = "message"; + const dataLines: string[] = []; + for (const line of lines) { + if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message"; + if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart()); + } + onEvent(event, dataLines.join("\n")); + } + } +}; + +export function AiAgentPanel() { + const [input, setInput] = useState("请给出 gemini-3 tokens 价格,并提供来源链接。"); + const [messages, setMessages] = useState([]); + const [logs, setLogs] = useState([]); + const [running, setRunning] = useState(false); + const [maxSteps, setMaxSteps] = useState(10); + + const abortRef = useRef(null); + + const canSend = useMemo(() => input.trim().length > 0 && !running, [input, running]); + + const stop = () => { + abortRef.current?.abort(); + abortRef.current = null; + setRunning(false); + }; + + const send = async () => { + const text = input.trim(); + if (!text) return; + + const nextMessages: ChatMsg[] = [...messages, { role: "user", content: text }]; + setMessages(nextMessages); + setInput(""); + setRunning(true); + setLogs([]); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const res = await fetch("/api/ai-agent/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + stream: true, + maxSteps, + scope: "global", + messages: nextMessages.slice(-20), + toolChoice: { + mode: "auto", + toolSets: ["toolset.readonly", "toolset.rag_read", "toolset.docs_read", "toolset.media_read", "toolset.slash_write"], + }, + options: { searxng: true, ai: { provider: "online" } }, + }), + }); + if (!res.ok) { + const j = (await res.json().catch(() => null)) as unknown; + const err = + typeof j === "object" && j && "error" in j ? String((j as Record).error ?? "") : ""; + throw new Error(err || `HTTP ${res.status}`); + } + + await parseSseChunks(res, (event, dataText) => { + if (event === "assistant_message") { + try { + const data = JSON.parse(dataText || "null") as unknown; + const assistantText = + typeof data === "object" && data && "text" in data ? String((data as Record).text ?? "") : ""; + if (assistantText) setMessages((prev) => [...prev, { role: "assistant", content: assistantText }]); + } catch { + // ignore + } + return; + } + if (event === "tool_call") { + try { + const data = JSON.parse(dataText || "null") as unknown; + const obj = (typeof data === "object" && data ? (data as Record) : {}) as Record; + setLogs((prev) => [ + ...prev, + { + type: "tool_call", + id: String(obj.id ?? ""), + tool: String(obj.tool ?? ""), + args: (typeof obj.args === "object" && obj.args ? (obj.args as Record) : {}) as Record, + }, + ]); + } catch { + // ignore + } + return; + } + if (event === "tool_result") { + try { + const data = JSON.parse(dataText || "null") as unknown; + const obj = (typeof data === "object" && data ? (data as Record) : {}) as Record; + setLogs((prev) => [ + ...prev, + { + type: "tool_result", + id: String(obj.id ?? ""), + tool: String(obj.tool ?? ""), + ok: Boolean(obj.ok), + ms: Number(obj.ms ?? 0), + result: "result" in obj ? obj.result : null, + }, + ]); + } catch { + // ignore + } + return; + } + if (event === "error") { + try { + const data = JSON.parse(dataText || "null") as unknown; + const message = + typeof data === "object" && data && "message" in data ? String((data as Record).message ?? "") : ""; + setLogs((prev) => [...prev, { type: "error", message: message || "未知错误" }]); + } catch { + setLogs((prev) => [...prev, { type: "error", message: "未知错误" }]); + } + return; + } + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setLogs((prev) => [...prev, { type: "error", message: msg }]); + } finally { + abortRef.current = null; + setRunning(false); + } + }; + + return ( +
+ +
对话
+ +
+ {messages.length === 0 ?
暂无消息
: null} + {messages.map((m, idx) => ( +
+
{m.role === "user" ? "用户" : "AI"}
+
{m.content}
+
+ ))} +
+
+ +
+