0.1.13 AI功能大改
This commit is contained in:
@@ -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”保证可靠性。
|
||||||
|
- 核心点:模型输出 `<tool_name>...</tool_name>`,服务端解析后执行工具,再把 `<tool_result>...</tool_result>` 回喂模型。
|
||||||
|
|
||||||
|
### 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 目标):
|
||||||
|
- 引入“前端工具宿主”:当服务端解析到 `<client_tool>` 时,通过 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 工具标签协议(最小可用)
|
||||||
|
|
||||||
|
模型输出只能出现以下块之一:
|
||||||
|
|
||||||
|
- `<tool_name>...</tool_name>`:请求执行工具
|
||||||
|
- `<attempt_completion>...</attempt_completion>`:结束并输出最终结果
|
||||||
|
|
||||||
|
工具参数采用子标签:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<search_web>
|
||||||
|
<query>gemini 3 tokens price</query>
|
||||||
|
<count>5</count>
|
||||||
|
</search_web>
|
||||||
|
```
|
||||||
|
|
||||||
|
工具结果回喂:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<tool_result>
|
||||||
|
<tool_name>search_web</tool_name>
|
||||||
|
<result>{"results":[...]}</result>
|
||||||
|
</tool_result>
|
||||||
|
```
|
||||||
|
|
||||||
|
解析器参考:
|
||||||
|
- `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 的“工具 + 上下文 + 日志 + 权限”工程化形态。
|
||||||
@@ -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=<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。
|
||||||
@@ -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: <YOUR_TUNNEL_NAME_OR_ID>
|
||||||
|
credentials-file: ./<YOUR_TUNNEL_ID>.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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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<string>();
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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<Uint8Array>({
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,10 +7,11 @@ export const dynamic = "force-dynamic";
|
|||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { user },
|
||||||
} = await supabase.auth.getSession();
|
error: authError,
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
if (!session) {
|
if (authError || !user) {
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +48,11 @@ export async function GET(request: Request) {
|
|||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { user },
|
||||||
} = await supabase.auth.getSession();
|
error: authError,
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
if (!session) {
|
if (authError || !user) {
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ export async function POST(request: Request) {
|
|||||||
file_name: payload.fileName,
|
file_name: payload.fileName,
|
||||||
file_size: payload.fileSize ?? null,
|
file_size: payload.fileSize ?? null,
|
||||||
mime_type: payload.mimeType ?? null,
|
mime_type: payload.mimeType ?? null,
|
||||||
created_by: session.user.id,
|
created_by: user.id,
|
||||||
})
|
})
|
||||||
.select("*")
|
.select("*")
|
||||||
.single();
|
.single();
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
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) {
|
if (backendUrl) {
|
||||||
void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, {
|
void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -51,8 +51,20 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 为私有桶生成临时访问链接(7 天);前端可在需要时通过 /api/media/signed-url 刷新
|
// 为私有桶生成临时访问链接(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
|
const { data: asset, error } = await supabase
|
||||||
.from("media_assets")
|
.from("media_assets")
|
||||||
@@ -76,7 +88,11 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return NextResponse.json({ error: "上传失败" }, { status: 500 });
|
return NextResponse.json({ error: "上传失败" }, { status: 500 });
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ const answerFromSearchResults = async (args: {
|
|||||||
const resultsText = formatSearchResults(args.results);
|
const resultsText = formatSearchResults(args.results);
|
||||||
if (!q) return resultsText || "(无问题)";
|
if (!q) return resultsText || "(无问题)";
|
||||||
|
|
||||||
|
let aiError: string | null = null;
|
||||||
try {
|
try {
|
||||||
const { text } = await openAiCompatibleChat(
|
const { text } = await openAiCompatibleChat(
|
||||||
[
|
[
|
||||||
@@ -239,11 +240,12 @@ const answerFromSearchResults = async (args: {
|
|||||||
);
|
);
|
||||||
const out = String(text ?? "").trim();
|
const out = String(text ?? "").trim();
|
||||||
if (out) return out;
|
if (out) return out;
|
||||||
} catch {
|
} catch (e) {
|
||||||
// ignore
|
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: {
|
const buildFallbackExpandOps = async (args: {
|
||||||
@@ -511,6 +513,52 @@ export async function POST(request: Request) {
|
|||||||
String(walkSummaries(mindmap, 1)?.[0]?.uid || "");
|
String(walkSummaries(mindmap, 1)?.[0]?.uid || "");
|
||||||
const defaultTargetUid = (selected[0] || rootUid || "").trim();
|
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) {
|
for (let step = 1; step <= maxSteps; step += 1) {
|
||||||
const { text } = await openAiCompatibleChat(
|
const { text } = await openAiCompatibleChat(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { AiAgentPanel } from "@/components/ai-agent/AiAgentPanel";
|
||||||
|
|
||||||
|
export default function DevAiAgentPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1200px] p-4">
|
||||||
|
<div className="mb-3 text-lg font-semibold">AI Agent 平台 v1(M0)</div>
|
||||||
|
<div className="mb-4 text-sm text-muted-foreground">
|
||||||
|
这是并行实验入口:调用 <code className="rounded bg-muted px-1 py-0.5">/api/ai-agent/run</code>(SSE)
|
||||||
|
</div>
|
||||||
|
<AiAgentPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<string, unknown> }
|
||||||
|
| { 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<ChatMsg[]>([]);
|
||||||
|
const [logs, setLogs] = useState<ToolLog[]>([]);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [maxSteps, setMaxSteps] = useState(10);
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(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<string, unknown>).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<string, unknown>).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<string, unknown>) : {}) as Record<string, unknown>;
|
||||||
|
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<string, unknown>) : {}) as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} 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<string, unknown>) : {}) as Record<string, unknown>;
|
||||||
|
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<string, unknown>).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 (
|
||||||
|
<div className="flex h-[calc(100vh-80px)] w-full gap-4">
|
||||||
|
<Card className="flex w-[60%] flex-col p-3">
|
||||||
|
<div className="mb-2 text-sm font-medium">对话</div>
|
||||||
|
<ScrollArea className="flex-1 rounded border">
|
||||||
|
<div className="space-y-3 p-3 text-sm">
|
||||||
|
{messages.length === 0 ? <div className="text-muted-foreground">暂无消息</div> : null}
|
||||||
|
{messages.map((m, idx) => (
|
||||||
|
<div key={idx} className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
|
||||||
|
<div className="whitespace-pre-wrap">{m.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="输入你的问题(Enter 发送,Shift+Enter 换行)"
|
||||||
|
className="min-h-[72px] flex-1"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canSend) void send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
最大步数
|
||||||
|
<input
|
||||||
|
className="w-[72px] rounded border px-2 py-1 text-xs"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
step={1}
|
||||||
|
value={maxSteps}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
if (!Number.isFinite(v)) return;
|
||||||
|
setMaxSteps(Math.max(1, Math.min(24, Math.floor(v))));
|
||||||
|
}}
|
||||||
|
disabled={running}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button disabled={!canSend} onClick={() => void send()}>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" disabled={!running} onClick={stop}>
|
||||||
|
停止
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex w-[40%] flex-col p-3">
|
||||||
|
<div className="mb-2 text-sm font-medium">工具日志</div>
|
||||||
|
<ScrollArea className="flex-1 rounded border">
|
||||||
|
<div className="space-y-3 p-3 text-sm">
|
||||||
|
{logs.length === 0 ? <div className="text-muted-foreground">暂无工具日志</div> : null}
|
||||||
|
{logs.map((l, idx) => {
|
||||||
|
if (l.type === "error") {
|
||||||
|
return (
|
||||||
|
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
|
||||||
|
错误:{l.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (l.type === "tool_call") {
|
||||||
|
return (
|
||||||
|
<div key={idx} className="rounded border p-2">
|
||||||
|
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
|
||||||
|
<div className="font-medium">{l.tool}</div>
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={idx} className="rounded border p-2">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">{l.tool}</div>
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,63 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MessageCircle, Sparkles, Wand2 } from "lucide-react";
|
import { MessageCircle, Sparkles } from "lucide-react";
|
||||||
import { useBackendHealth } from "@/hooks/use-backend-health";
|
import { useBackendHealth } from "@/hooks/use-backend-health";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||||
|
|
||||||
export function BottomToolbar() {
|
export function BottomToolbar() {
|
||||||
const status = useBackendHealth();
|
const status = useBackendHealth();
|
||||||
|
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
|
||||||
|
const toggleDocumentAgentOpen = useAiAgentUiStore((s) => s.toggleDocumentAgentOpen);
|
||||||
const indicatorColor =
|
const indicatorColor =
|
||||||
status === "ok" ? "bg-green-500" : status === "error" ? "bg-red-500" : "bg-gray-300";
|
status === "ok"
|
||||||
|
? "bg-green-500"
|
||||||
|
: status === "error"
|
||||||
|
? "bg-red-500"
|
||||||
|
: "bg-gray-300";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="flex h-12 items-center justify-between border-t border-[#eeeeee] px-6 text-sm">
|
<footer className="flex h-12 items-center justify-between border-t border-[#eeeeee] px-6 text-sm">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
<span className={cn("h-2 w-2 rounded-full", indicatorColor)} />
|
<span className={cn("h-2 w-2 rounded-full", indicatorColor)} />
|
||||||
后端连接:{status === "ok" ? "正常" : status === "error" ? "异常" : "检测中"}
|
后端连接:
|
||||||
|
{status === "ok"
|
||||||
|
? "正常"
|
||||||
|
: status === "disabled"
|
||||||
|
? "未配置"
|
||||||
|
: status === "error"
|
||||||
|
? "异常"
|
||||||
|
: "检测中"}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-gray-600">
|
{/* 右侧预留空间,避免被 TanStack Devtools 悬浮按钮遮挡 */}
|
||||||
|
<div className="flex items-center gap-3 pr-16 text-gray-600">
|
||||||
<button type="button" className="wolai-hover rounded-full px-3 py-1">
|
<button type="button" className="wolai-hover rounded-full px-3 py-1">
|
||||||
<MessageCircle className="mr-1 inline h-4 w-4" />
|
<MessageCircle className="mr-1 inline h-4 w-4" />
|
||||||
发送
|
发送
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="wolai-hover rounded-full px-3 py-1">
|
<button
|
||||||
<Wand2 className="mr-1 inline h-4 w-4" />
|
type="button"
|
||||||
魔力
|
className={cn(
|
||||||
|
"wolai-hover rounded-full px-3 py-1",
|
||||||
|
!documentAgentAvailable && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleDocumentAgentOpen()}
|
||||||
|
disabled={!documentAgentAvailable}
|
||||||
|
title={documentAgentAvailable ? "打开页面 AI" : "仅在页面编辑区可用"}
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-1 inline h-4 w-4" />
|
||||||
|
AI
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2563eb] text-white shadow-sm"
|
className={cn(
|
||||||
|
"flex h-8 w-8 items-center justify-center rounded-full bg-[#2563eb] text-white shadow-sm",
|
||||||
|
!documentAgentAvailable && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleDocumentAgentOpen()}
|
||||||
|
disabled={!documentAgentAvailable}
|
||||||
|
aria-label="打开页面 AI"
|
||||||
|
title={documentAgentAvailable ? "打开页面 AI" : "仅在页面编辑区可用"}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,15 +20,20 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
|||||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||||
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
|
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
|
||||||
|
|
||||||
if (!path.length) {
|
// 新建页面后,侧边栏数据可能还没同步到 layout(SSR)注入的 documents,导致 findBreadcrumb 暂时为空。
|
||||||
|
// 这里用 activeId 兜底,避免右上角“收藏/更多”按钮消失。
|
||||||
|
if (!activeId) {
|
||||||
return <div className="text-sm text-gray-400">请选择一个页面</div>;
|
return <div className="text-sm text-gray-400">请选择一个页面</div>;
|
||||||
}
|
}
|
||||||
|
const displayPath: Array<Pick<DocumentRecord, "id" | "title">> = path.length
|
||||||
|
? path
|
||||||
|
: [{ id: activeId, title: "无标题" }];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-between">
|
<div className="flex flex-1 items-center justify-between">
|
||||||
<nav className="flex items-center text-sm text-gray-500">
|
<nav className="flex items-center text-sm text-gray-500">
|
||||||
{path.map((node, index) => {
|
{displayPath.map((node, index) => {
|
||||||
const isLast = index === path.length - 1;
|
const isLast = index === displayPath.length - 1;
|
||||||
return (
|
return (
|
||||||
<span key={node.id} className="flex items-center">
|
<span key={node.id} className="flex items-center">
|
||||||
{index > 0 && <ChevronRight className="mx-1 h-4 w-4 text-gray-400" />}
|
{index > 0 && <ChevronRight className="mx-1 h-4 w-4 text-gray-400" />}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,27 +15,75 @@ type AgentAssetItem = {
|
|||||||
|
|
||||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||||
|
|
||||||
|
type MindmapInstanceLike = {
|
||||||
|
setData?: (data: unknown) => void;
|
||||||
|
command?: { clearHistory?: () => void };
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
const getValue = (obj: unknown, key: string): unknown => (isRecord(obj) ? obj[key] : undefined);
|
||||||
|
|
||||||
type ToolName =
|
type ToolName =
|
||||||
| "mindmap_get"
|
| "mindmap_get"
|
||||||
| "mindmap_get_subtree"
|
| "mindmap_get_subtree"
|
||||||
| "search_web"
|
| "search_web"
|
||||||
|
| "rag_lightrag_query"
|
||||||
|
| "image_read"
|
||||||
| "mindmap_apply_ops"
|
| "mindmap_apply_ops"
|
||||||
| "pdf_replace_mindmap";
|
| "mindmap_add_child"
|
||||||
|
| "mindmap_add_sibling_after"
|
||||||
|
| "mindmap_update_node_text"
|
||||||
|
| "mindmap_set_hyperlink"
|
||||||
|
| "mindmap_append_note"
|
||||||
|
| "mindmap_set_refs"
|
||||||
|
| "mindmap_delete_node"
|
||||||
|
| "mindmap_add_attachment_ref"
|
||||||
|
| "mindmap_add_attachment_child"
|
||||||
|
| "mindmap_add_image_child"
|
||||||
|
| "mindmap_append_image_note"
|
||||||
|
| "mindmap_expand_node";
|
||||||
|
|
||||||
const TOOL_LABEL: Record<ToolName, string> = {
|
const TOOL_LABEL: Record<ToolName, string> = {
|
||||||
mindmap_get: "读导图(摘要)",
|
mindmap_get: "读导图(摘要)",
|
||||||
mindmap_get_subtree: "读子树(按 uid)",
|
mindmap_get_subtree: "读子树(按 uid)",
|
||||||
search_web: "联网检索(SearxNG)",
|
search_web: "联网检索(SearxNG)",
|
||||||
|
rag_lightrag_query: "LightRAG 检索",
|
||||||
|
image_read: "图片读取(OCR)",
|
||||||
mindmap_apply_ops: "写入导图(ops)",
|
mindmap_apply_ops: "写入导图(ops)",
|
||||||
pdf_replace_mindmap: "PDF→导图(替换当前)",
|
mindmap_add_child: "新增子节点",
|
||||||
|
mindmap_add_sibling_after: "新增同级(在后面)",
|
||||||
|
mindmap_update_node_text: "更新节点文本",
|
||||||
|
mindmap_set_hyperlink: "设置/清除超链接",
|
||||||
|
mindmap_append_note: "追加备注",
|
||||||
|
mindmap_set_refs: "设置 refs(引用)",
|
||||||
|
mindmap_delete_node: "删除节点",
|
||||||
|
mindmap_add_attachment_ref: "增加附件引用(refs)",
|
||||||
|
mindmap_add_attachment_child: "附件→子节点",
|
||||||
|
mindmap_add_image_child: "图片→子节点",
|
||||||
|
mindmap_append_image_note: "备注插入图片",
|
||||||
|
mindmap_expand_node: "补完节点(检索→写入)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_TOOLS: ToolName[] = [
|
const DEFAULT_TOOLS: ToolName[] = [
|
||||||
"mindmap_get",
|
"mindmap_get",
|
||||||
"mindmap_get_subtree",
|
"mindmap_get_subtree",
|
||||||
"search_web",
|
"search_web",
|
||||||
|
"rag_lightrag_query",
|
||||||
|
"image_read",
|
||||||
|
"mindmap_expand_node",
|
||||||
|
"mindmap_add_child",
|
||||||
|
"mindmap_update_node_text",
|
||||||
|
"mindmap_set_hyperlink",
|
||||||
|
"mindmap_append_note",
|
||||||
|
"mindmap_set_refs",
|
||||||
|
"mindmap_delete_node",
|
||||||
|
"mindmap_add_attachment_ref",
|
||||||
|
"mindmap_add_attachment_child",
|
||||||
|
"mindmap_add_image_child",
|
||||||
|
"mindmap_append_image_note",
|
||||||
"mindmap_apply_ops",
|
"mindmap_apply_ops",
|
||||||
"pdf_replace_mindmap",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const ONLINE_MODELS = [
|
const ONLINE_MODELS = [
|
||||||
@@ -54,8 +102,8 @@ export function MindmapAiAgentPanel({
|
|||||||
}: {
|
}: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
mindmapId: string;
|
mindmapId: string;
|
||||||
mindmap: any;
|
mindmap: MindmapInstanceLike | null | undefined;
|
||||||
activeNodes: any[];
|
activeNodes: unknown[];
|
||||||
}) {
|
}) {
|
||||||
const [messages, setMessages] = useState<AgentMessage[]>([
|
const [messages, setMessages] = useState<AgentMessage[]>([
|
||||||
{
|
{
|
||||||
@@ -73,21 +121,35 @@ export function MindmapAiAgentPanel({
|
|||||||
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
||||||
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
||||||
const [aiModel, setAiModel] = useState<string>("");
|
const [aiModel, setAiModel] = useState<string>("");
|
||||||
|
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||||
|
|
||||||
const [assets, setAssets] = useState<AgentAssetItem[]>([]);
|
const [assets, setAssets] = useState<AgentAssetItem[]>([]);
|
||||||
const [workspaceId, setWorkspaceId] = useState<string>("");
|
const [workspaceId, setWorkspaceId] = useState<string>("");
|
||||||
const [attachments, setAttachments] = useState<AgentAssetItem[]>([]);
|
const [attachments, setAttachments] = useState<AgentAssetItem[]>([]);
|
||||||
const [debug, setDebug] = useState<string>("");
|
const [debug, setDebug] = useState<string>("");
|
||||||
|
const [toolLogs, setToolLogs] = useState<
|
||||||
|
Array<
|
||||||
|
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||||
|
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
|
||||||
|
| { type: "error"; message: string }
|
||||||
|
>
|
||||||
|
>([]);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
|
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
|
||||||
const m = window.localStorage.getItem("mindmap_ai_model") || "";
|
const m = window.localStorage.getItem("mindmap_ai_model") || "";
|
||||||
|
const stepsRaw = window.localStorage.getItem("mindmap_ai_max_steps") || "";
|
||||||
if (p === "local" || p === "online") setAiProvider(p);
|
if (p === "local" || p === "online") setAiProvider(p);
|
||||||
if (typeof m === "string") setAiModel(m);
|
if (typeof m === "string") setAiModel(m);
|
||||||
|
const parsed = Number(stepsRaw);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 1) {
|
||||||
|
setMaxSteps(Math.max(1, Math.min(24, Math.floor(parsed))));
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -97,10 +159,11 @@ export function MindmapAiAgentPanel({
|
|||||||
try {
|
try {
|
||||||
window.localStorage.setItem("mindmap_ai_provider", aiProvider);
|
window.localStorage.setItem("mindmap_ai_provider", aiProvider);
|
||||||
window.localStorage.setItem("mindmap_ai_model", aiModel);
|
window.localStorage.setItem("mindmap_ai_model", aiModel);
|
||||||
|
window.localStorage.setItem("mindmap_ai_max_steps", String(maxSteps));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [aiProvider, aiModel]);
|
}, [aiProvider, aiModel, maxSteps]);
|
||||||
|
|
||||||
// @ 选择
|
// @ 选择
|
||||||
const [mentionOpen, setMentionOpen] = useState(false);
|
const [mentionOpen, setMentionOpen] = useState(false);
|
||||||
@@ -123,25 +186,127 @@ export function MindmapAiAgentPanel({
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedUids = useMemo(() => {
|
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);
|
||||||
|
|
||||||
|
// 注释/心跳:以 ":" 开头
|
||||||
|
if (raw.trimStart().startsWith(":")) continue;
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedNodes = useMemo(() => {
|
||||||
const list = Array.isArray(activeNodes) ? activeNodes : [];
|
const list = Array.isArray(activeNodes) ? activeNodes : [];
|
||||||
|
|
||||||
|
const readUid = (n: unknown) => {
|
||||||
|
const nodeData = getValue(n, "nodeData");
|
||||||
|
const nodeDataData = getValue(nodeData, "data");
|
||||||
|
const uidFromNodeData = String(getValue(nodeDataData, "uid") ?? getValue(nodeData, "uid") ?? "").trim();
|
||||||
|
if (uidFromNodeData) return uidFromNodeData;
|
||||||
|
|
||||||
|
// 兼容 xmind-editor 节点对象:getData("uid")
|
||||||
|
if (isRecord(n)) {
|
||||||
|
const getData = n["getData"];
|
||||||
|
if (typeof getData === "function") {
|
||||||
|
try {
|
||||||
|
const uid = (getData as (key: string) => unknown)("uid");
|
||||||
|
const s = String(uid ?? "").trim();
|
||||||
|
if (s) return s;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(getValue(n, "uid") ?? "").trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPlainText = (input: unknown) => {
|
||||||
|
const s = String(input ?? "").trim();
|
||||||
|
if (!s) return "";
|
||||||
|
// 选中节点的 text 可能是富文本 HTML(例如:<p>分支主题</p>),这里转换为纯文本展示给用户
|
||||||
|
if (!/[<>]/.test(s)) return s;
|
||||||
|
try {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.innerHTML = s;
|
||||||
|
return String(div.textContent || div.innerText || "").trim();
|
||||||
|
} catch {
|
||||||
|
return s.replace(/<[^>]*>/g, "").trim();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readText = (n: unknown) => {
|
||||||
|
try {
|
||||||
|
// 优先走 getData("text"),其次兜底 nodeData/data 结构
|
||||||
|
if (isRecord(n)) {
|
||||||
|
const getData = n["getData"];
|
||||||
|
if (typeof getData === "function") {
|
||||||
|
try {
|
||||||
|
const raw = (getData as (key: string) => unknown)("text");
|
||||||
|
const text = toPlainText(raw);
|
||||||
|
if (text) return text;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeData = getValue(n, "nodeData");
|
||||||
|
const nodeDataData = getValue(nodeData, "data");
|
||||||
|
const raw =
|
||||||
|
getValue(nodeDataData, "text") ??
|
||||||
|
getValue(nodeData, "text") ??
|
||||||
|
getValue(getValue(n, "data"), "text") ??
|
||||||
|
"";
|
||||||
|
return toPlainText(raw);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return list
|
return list
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((n) => String(n?.nodeData?.data?.uid ?? n?.nodeData?.uid ?? n?.getData?.("uid") ?? n?.uid ?? ""))
|
.map((n) => ({ uid: readUid(n), text: readText(n) }))
|
||||||
.filter(Boolean);
|
.filter((x) => Boolean(x.uid));
|
||||||
}, [activeNodes]);
|
}, [activeNodes]);
|
||||||
|
|
||||||
|
const selectedUids = useMemo(() => selectedNodes.map((n) => n.uid), [selectedNodes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
if (!documentId) return;
|
if (!documentId) return;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`);
|
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`);
|
||||||
const json = (await res.json().catch(() => null)) as any;
|
const json: unknown = await res.json().catch(() => null);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setWorkspaceId(String(json?.workspaceId ?? ""));
|
const obj = isRecord(json) ? json : {};
|
||||||
setAssets(Array.isArray(json?.items) ? (json.items as AgentAssetItem[]) : []);
|
setWorkspaceId(String(obj.workspaceId ?? ""));
|
||||||
|
setAssets(Array.isArray(obj.items) ? (obj.items as AgentAssetItem[]) : []);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -236,26 +401,28 @@ export function MindmapAiAgentPanel({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/media/upload", { method: "POST", body: form });
|
const res = await fetch("/api/media/upload", { method: "POST", body: form });
|
||||||
const json = (await res.json().catch(() => null)) as any;
|
const json: unknown = await res.json().catch(() => null);
|
||||||
if (!res.ok) throw new Error(String(json?.error ?? `上传失败:${res.status}`));
|
const obj = isRecord(json) ? json : {};
|
||||||
const asset = json?.asset;
|
if (!res.ok) throw new Error(String(obj.error ?? `上传失败:${res.status}`));
|
||||||
|
const asset = isRecord(obj.asset) ? obj.asset : {};
|
||||||
const item: AgentAssetItem = {
|
const item: AgentAssetItem = {
|
||||||
kind: "media",
|
kind: "media",
|
||||||
id: String(asset?.id ?? `media:${Date.now()}`),
|
id: String(asset.id ?? `media:${Date.now()}`),
|
||||||
title: String(asset?.file_name ?? file.name),
|
title: String(asset.file_name ?? file.name),
|
||||||
fileUrl: String(asset?.file_url ?? ""),
|
fileUrl: String(asset.file_url ?? ""),
|
||||||
mimeType: String(asset?.mime_type ?? file.type ?? ""),
|
mimeType: String(asset.mime_type ?? file.type ?? ""),
|
||||||
assetType: String(asset?.asset_type ?? "file"),
|
assetType: String(asset.asset_type ?? "file"),
|
||||||
fileName: String(asset?.file_name ?? file.name),
|
fileName: String(asset.file_name ?? file.name),
|
||||||
};
|
};
|
||||||
setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item]));
|
setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item]));
|
||||||
// 刷新资产列表
|
// 刷新资产列表
|
||||||
try {
|
try {
|
||||||
const listRes = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`);
|
const listRes = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`);
|
||||||
const listJson = (await listRes.json().catch(() => null)) as any;
|
const listJson: unknown = await listRes.json().catch(() => null);
|
||||||
if (listRes.ok && Array.isArray(listJson?.items)) {
|
const listObj = isRecord(listJson) ? listJson : {};
|
||||||
setAssets(listJson.items as AgentAssetItem[]);
|
if (listRes.ok && Array.isArray(listObj.items)) {
|
||||||
setWorkspaceId(String(listJson?.workspaceId ?? workspaceId));
|
setAssets(listObj.items as AgentAssetItem[]);
|
||||||
|
setWorkspaceId(String(listObj.workspaceId ?? workspaceId));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -272,6 +439,7 @@ export function MindmapAiAgentPanel({
|
|||||||
const content = input.trim();
|
const content = input.trim();
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
setDebug("");
|
setDebug("");
|
||||||
|
setToolLogs([]);
|
||||||
|
|
||||||
const nextMessages: AgentMessage[] = [...messages, { role: "user", content }];
|
const nextMessages: AgentMessage[] = [...messages, { role: "user", content }];
|
||||||
setMessages(nextMessages);
|
setMessages(nextMessages);
|
||||||
@@ -279,43 +447,157 @@ export function MindmapAiAgentPanel({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/mindmap-ai/agent", {
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
// 不把面板的“欢迎语”当作对话历史发送给服务端,避免影响任务执行
|
||||||
|
const payloadMessages = nextMessages.filter(
|
||||||
|
(m, idx) => !(idx === 0 && m.role === "assistant" && /思维导图 AI Agent/.test(m.content)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await fetch("/api/ai-agent/run", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
documentId,
|
stream: true,
|
||||||
mindmapId,
|
maxSteps,
|
||||||
selectedUids,
|
scope: "mindmap",
|
||||||
messages: nextMessages,
|
messages: payloadMessages.slice(-24),
|
||||||
|
toolChoice: toolAuto
|
||||||
|
? {
|
||||||
|
mode: "auto",
|
||||||
|
toolSets: [
|
||||||
|
"toolset.readonly",
|
||||||
|
"toolset.rag_read",
|
||||||
|
"toolset.media_read",
|
||||||
|
"toolset.mindmap_read",
|
||||||
|
"toolset.mindmap_write",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { mode: "manual", tools: selectedTools },
|
||||||
|
context: { documentId, mindmapId, selectedUids },
|
||||||
attachments: attachments.map((a) => ({
|
attachments: attachments.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
title: a.title,
|
title: a.title,
|
||||||
fileUrl: a.fileUrl,
|
fileUrl: a.fileUrl,
|
||||||
mimeType: a.mimeType ?? null,
|
mimeType: a.mimeType ?? null,
|
||||||
})),
|
})),
|
||||||
toolChoice: toolAuto ? { mode: "auto" } : { mode: "manual", tools: selectedTools },
|
|
||||||
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } },
|
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const json = (await res.json().catch(() => null)) as any;
|
if (!res.ok) {
|
||||||
if (!res.ok) throw new Error(String(json?.error ?? `请求失败:${res.status}`));
|
const j = (await res.json().catch(() => null)) as unknown;
|
||||||
|
const err = typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
|
||||||
const assistantText = String(json?.message ?? "");
|
throw new Error(err || `请求失败:${res.status}`);
|
||||||
setMessages((prev) => [...prev, { role: "assistant", content: assistantText || "(无输出)" }]);
|
|
||||||
|
|
||||||
if (json?.data) {
|
|
||||||
mindmap?.setData?.(json.data);
|
|
||||||
mindmap?.command?.clearHistory?.();
|
|
||||||
persistMindmapData(json.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json?.trace) {
|
const rawEvents: Array<{ event: string; dataText: string }> = [];
|
||||||
setDebug(JSON.stringify(json.trace, null, 2));
|
await parseSseChunks(res, (event, dataText) => {
|
||||||
}
|
rawEvents.push({ event, dataText });
|
||||||
|
|
||||||
|
if (event === "tool_call") {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(dataText || "null") as unknown;
|
||||||
|
const obj = (typeof data === "object" && data ? (data as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||||
|
setToolLogs((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
id: String(obj.id ?? ""),
|
||||||
|
tool: String(obj.tool ?? ""),
|
||||||
|
args: (typeof obj.args === "object" && obj.args ? (obj.args as Record<string, unknown>) : {}) as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} 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<string, unknown>) : {}) as Record<string, unknown>;
|
||||||
|
const tool = String(obj.tool ?? "");
|
||||||
|
const result = "result" in obj ? obj.result : null;
|
||||||
|
setToolLogs((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ type: "tool_result", id: String(obj.id ?? ""), tool, ok: Boolean(obj.ok), ms: Number(obj.ms ?? 0), result },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 若工具返回了新的 mindmap data,则立即应用到前端实例并落盘(与 MindmapSidebar 的策略一致)
|
||||||
|
if (
|
||||||
|
(tool === "mindmap_apply_ops" ||
|
||||||
|
tool === "mindmap_expand_node" ||
|
||||||
|
tool === "mindmap_add_child" ||
|
||||||
|
tool === "mindmap_add_sibling_after" ||
|
||||||
|
tool === "mindmap_update_node_text" ||
|
||||||
|
tool === "mindmap_set_hyperlink" ||
|
||||||
|
tool === "mindmap_append_note" ||
|
||||||
|
tool === "mindmap_set_refs" ||
|
||||||
|
tool === "mindmap_delete_node" ||
|
||||||
|
tool === "mindmap_add_attachment_ref" ||
|
||||||
|
tool === "mindmap_add_attachment_child" ||
|
||||||
|
tool === "mindmap_add_image_child" ||
|
||||||
|
tool === "mindmap_append_image_note") &&
|
||||||
|
obj.ok
|
||||||
|
) {
|
||||||
|
const r = result as unknown;
|
||||||
|
const dataNode =
|
||||||
|
typeof r === "object" && r && "data" in (r as Record<string, unknown>) ? (r as Record<string, unknown>).data : null;
|
||||||
|
if (dataNode) {
|
||||||
|
try {
|
||||||
|
mindmap?.setData?.(dataNode);
|
||||||
|
mindmap?.command?.clearHistory?.();
|
||||||
|
persistMindmapData(dataNode);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>).text ?? "") : "";
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "assistant", content: assistantText.trim() ? assistantText.trim() : "(无输出)" },
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: "(无输出)" }]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === "error") {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(dataText || "null") as unknown;
|
||||||
|
const msg =
|
||||||
|
typeof data === "object" && data && "message" in data ? String((data as Record<string, unknown>).message ?? "") : "";
|
||||||
|
setToolLogs((prev) => [...prev, { type: "error", message: msg || "未知错误" }]);
|
||||||
|
} catch {
|
||||||
|
setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setDebug(JSON.stringify(rawEvents.slice(-120), null, 2));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
setMessages((prev) => [...prev, { role: "assistant", content: `执行失败:${msg}` }]);
|
setMessages((prev) => [...prev, { role: "assistant", content: `执行失败:${msg}` }]);
|
||||||
|
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||||
} finally {
|
} finally {
|
||||||
|
abortRef.current = null;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -323,8 +605,20 @@ export function MindmapAiAgentPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-between gap-2 pb-3">
|
<div className="flex items-center justify-between gap-2 pb-3">
|
||||||
<div className="text-xs text-gray-500">
|
<div
|
||||||
{selectedUids.length ? `选中节点:${selectedUids[0]}` : "未选中节点(将以整图为上下文)"}
|
className="text-xs text-gray-500"
|
||||||
|
title={
|
||||||
|
selectedNodes.length
|
||||||
|
? selectedNodes
|
||||||
|
.map((n) => (n.text ? `${n.text}(${n.uid})` : n.uid))
|
||||||
|
.slice(0, 3)
|
||||||
|
.join(",")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedNodes.length
|
||||||
|
? `选中节点:${selectedNodes[0]?.text || selectedNodes[0]?.uid}${selectedNodes.length > 1 ? `(+${selectedNodes.length - 1})` : ""}`
|
||||||
|
: "未选中节点(将以整图为上下文)"}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -463,6 +757,24 @@ export function MindmapAiAgentPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-gray-500">最大步数:</div>
|
||||||
|
<input
|
||||||
|
className="w-[72px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
step={1}
|
||||||
|
value={maxSteps}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
if (!Number.isFinite(v)) return;
|
||||||
|
setMaxSteps(Math.max(1, Math.min(24, Math.floor(v))));
|
||||||
|
}}
|
||||||
|
title="工具调用/推理最大步数(上限 24)"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{aiProvider === "local" ? (
|
{aiProvider === "local" ? (
|
||||||
<div className="mt-1 text-[11px] text-gray-400">
|
<div className="mt-1 text-[11px] text-gray-400">
|
||||||
@@ -510,7 +822,8 @@ export function MindmapAiAgentPanel({
|
|||||||
}
|
}
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
// Enter 发送;Shift+Enter 换行
|
// Enter 发送;Shift+Enter 换行
|
||||||
if (!e.shiftKey && !e.isComposing) {
|
const isComposing = Boolean((e.nativeEvent as unknown as { isComposing?: boolean })?.isComposing);
|
||||||
|
if (!e.shiftKey && !isComposing) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!loading) void send();
|
if (!loading) void send();
|
||||||
}
|
}
|
||||||
@@ -547,21 +860,67 @@ export function MindmapAiAgentPanel({
|
|||||||
<div className="text-[11px] text-gray-400">Enter 发送 · Shift+Enter 换行</div>
|
<div className="text-[11px] text-gray-400">Enter 发送 · Shift+Enter 换行</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
type="button"
|
||||||
disabled={loading || !input.trim()}
|
className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||||
onClick={() => void send()}
|
disabled={!loading}
|
||||||
>
|
onClick={() => abortRef.current?.abort()}
|
||||||
<Send className="h-4 w-4" />
|
title="停止本次执行"
|
||||||
{loading ? "执行中..." : "发送"}
|
>
|
||||||
</button>
|
<X className="h-4 w-4" />
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
disabled={loading || !input.trim()}
|
||||||
|
onClick={() => void send()}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
{loading ? "执行中..." : "发送"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
||||||
|
<summary className="cursor-pointer select-none">工具日志(可折叠)</summary>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{toolLogs.length === 0 ? <div className="text-gray-400">暂无工具日志</div> : null}
|
||||||
|
{toolLogs.map((l, idx) => {
|
||||||
|
if (l.type === "error") {
|
||||||
|
return (
|
||||||
|
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
|
||||||
|
错误:{l.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (l.type === "tool_call") {
|
||||||
|
return (
|
||||||
|
<div key={idx} className="rounded border p-2">
|
||||||
|
<div className="text-[11px] text-gray-400">tool_call · {l.id}</div>
|
||||||
|
<div className="font-medium text-gray-800">{l.tool}</div>
|
||||||
|
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.args, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={idx} className="rounded border p-2">
|
||||||
|
<div className="text-[11px] text-gray-400">
|
||||||
|
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-gray-800">{l.tool}</div>
|
||||||
|
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.result, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
{debug ? (
|
{debug ? (
|
||||||
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
||||||
<summary className="cursor-pointer select-none">调试信息(tool trace)</summary>
|
<summary className="cursor-pointer select-none">调试信息(SSE 事件)</summary>
|
||||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
|
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
|
||||||
</details>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { MindmapSidebarTrigger } from "./MindmapSidebarTrigger";
|
|||||||
import { MindmapNavigator } from "./MindmapNavigator";
|
import { MindmapNavigator } from "./MindmapNavigator";
|
||||||
import { MindmapMiniMap } from "./MindmapMiniMap";
|
import { MindmapMiniMap } from "./MindmapMiniMap";
|
||||||
import { MindmapCount } from "./MindmapCount";
|
import { MindmapCount } from "./MindmapCount";
|
||||||
|
import { MindmapContextMenu } from "./MindmapContextMenu";
|
||||||
import type { SidebarPanel } from "./mindmapSidebarConfig";
|
import type { SidebarPanel } from "./mindmapSidebarConfig";
|
||||||
import type { MindMapNode } from "./mindmapTypes";
|
import type { MindMapNode } from "./mindmapTypes";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -51,6 +52,10 @@ const getImageSizeSafe = (url: string): Promise<{ width: number; height: number
|
|||||||
img.src = url;
|
img.src = url;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 类型检查辅助函数
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
type MindMapInstance = {
|
type MindMapInstance = {
|
||||||
execCommand: (command: string, ...args: unknown[]) => void;
|
execCommand: (command: string, ...args: unknown[]) => void;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
@@ -132,7 +137,7 @@ const normalizeMindmapData = (input: unknown): unknown => {
|
|||||||
return input;
|
return input;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 持久化/初始化统一使用“根节点对象”作为数据载体,避免把包含额外字段的 wrapper 误传给 simple-mind-map
|
// 持久化/初始化统一使用"根节点对象"作为数据载体,避免把包含额外字段的 wrapper 误传给 simple-mind-map
|
||||||
// 从而触发 RichText 对 wrapper.data 的处理(wrapper.data.text 可能不存在 → htmlEscape 崩溃)。
|
// 从而触发 RichText 对 wrapper.data 的处理(wrapper.data.text 可能不存在 → htmlEscape 崩溃)。
|
||||||
const canonicalizeMindmapData = (input: unknown): MindMapData => {
|
const canonicalizeMindmapData = (input: unknown): MindMapData => {
|
||||||
const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as any;
|
const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as any;
|
||||||
@@ -142,6 +147,82 @@ const canonicalizeMindmapData = (input: unknown): MindMapData => {
|
|||||||
return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData;
|
return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 将思维导图数据中的 asset:id 格式转换为实际的签名 URL
|
||||||
|
// 返回转换后的数据和 signed URL -> asset ID 的映射
|
||||||
|
const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData; urlToAssetId: Map<string, string> }> => {
|
||||||
|
const assetIds: string[] = [];
|
||||||
|
|
||||||
|
// 递归收集所有 asset:id
|
||||||
|
const collectAssetIds = (node: any) => {
|
||||||
|
if (!node) return;
|
||||||
|
if (node.image?.url?.startsWith?.("asset:")) {
|
||||||
|
assetIds.push(node.image.url.replace("asset:", ""));
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
node.children.forEach(collectAssetIds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collectAssetIds(data);
|
||||||
|
|
||||||
|
// 如果没有 asset:id,直接返回原数据和空映射
|
||||||
|
if (assetIds.length === 0) return { data, urlToAssetId: new Map() };
|
||||||
|
|
||||||
|
// 批量获取签名 URL
|
||||||
|
const urlMap = new Map<string, string>();
|
||||||
|
await Promise.all(assetIds.map(async (id) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/media/sign?assetId=${id}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
urlMap.set(id, result.signedUrl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`获取 asset ${id} 签名 URL 失败`, e);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 递归替换 asset:id 为签名 URL,并建立反向映射
|
||||||
|
const urlToAssetId = new Map<string, string>();
|
||||||
|
const replaceAssetIds = (node: any): any => {
|
||||||
|
if (!node) return node;
|
||||||
|
const newNode = { ...node };
|
||||||
|
if (newNode.image?.url?.startsWith?.("asset:")) {
|
||||||
|
const id = newNode.image.url.replace("asset:", "");
|
||||||
|
const signedUrl = urlMap.get(id);
|
||||||
|
if (signedUrl) {
|
||||||
|
newNode.image = { ...newNode.image, url: signedUrl };
|
||||||
|
// 建立反向映射:signedUrl -> asset:id
|
||||||
|
urlToAssetId.set(signedUrl, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(newNode.children)) {
|
||||||
|
newNode.children = newNode.children.map(replaceAssetIds);
|
||||||
|
}
|
||||||
|
return newNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { data: replaceAssetIds(data), urlToAssetId };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将思维导图数据中的签名 URL 转换回 asset:id 格式(用于保存)
|
||||||
|
const revertToAssetIds = (data: MindMapData, urlToAssetId: Map<string, string>): MindMapData => {
|
||||||
|
const revertNode = (node: any): any => {
|
||||||
|
if (!node) return node;
|
||||||
|
const newNode = { ...node };
|
||||||
|
if (newNode.image?.url && typeof newNode.image.url === "string") {
|
||||||
|
const assetId = urlToAssetId.get(newNode.image.url);
|
||||||
|
if (assetId) {
|
||||||
|
newNode.image = { ...newNode.image, url: `asset:${assetId}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(newNode.children)) {
|
||||||
|
newNode.children = newNode.children.map(revertNode);
|
||||||
|
}
|
||||||
|
return newNode;
|
||||||
|
};
|
||||||
|
return revertNode(data);
|
||||||
|
};
|
||||||
|
|
||||||
const STORAGE_PREFIX = "wolai-mindmap-autosave-";
|
const STORAGE_PREFIX = "wolai-mindmap-autosave-";
|
||||||
|
|
||||||
function downloadJson(data: unknown, name: string) {
|
function downloadJson(data: unknown, name: string) {
|
||||||
@@ -337,6 +418,12 @@ const MindmapBlockView = ({
|
|||||||
const lastTextEditRefocusAtRef = useRef(0);
|
const lastTextEditRefocusAtRef = useRef(0);
|
||||||
const pendingInitActivateRootRef = useRef(false);
|
const pendingInitActivateRootRef = useRef(false);
|
||||||
const pendingRenderEndActivateRootRef = useRef(false);
|
const pendingRenderEndActivateRootRef = useRef(false);
|
||||||
|
// 存储 signed URL 到 asset:id 的映射,用于保存时转换回 asset:id 格式
|
||||||
|
const signedUrlToAssetIdRef = useRef(new Map<string, string>());
|
||||||
|
// 存储当前思维导图中使用的所有图片 URL,用于检测图片删除
|
||||||
|
const currentImageUrlsRef = useRef(new Set<string>());
|
||||||
|
// 存储已删除的 asset ID,用于撤销时恢复
|
||||||
|
const deletedAssetIdsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
|
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
|
||||||
const instance = mm ?? mindmap;
|
const instance = mm ?? mindmap;
|
||||||
@@ -353,6 +440,27 @@ const MindmapBlockView = ({
|
|||||||
);
|
);
|
||||||
const mindmapId = block.id;
|
const mindmapId = block.id;
|
||||||
|
|
||||||
|
// 获取 workspaceId(用于上传图片)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!docId) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}`);
|
||||||
|
const json: unknown = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) return;
|
||||||
|
if (cancelled) return;
|
||||||
|
const obj = isRecord(json) ? json : {};
|
||||||
|
setWorkspaceId(String(obj.workspaceId ?? ""));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [docId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasLocalEditsRef.current = false;
|
hasLocalEditsRef.current = false;
|
||||||
applyingRemoteRef.current = false;
|
applyingRemoteRef.current = false;
|
||||||
@@ -1089,13 +1197,17 @@ const MindmapBlockView = ({
|
|||||||
(data: unknown) => {
|
(data: unknown) => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
// 关键:全屏/非全屏切换会重建 mindmap 实例,初始化数据必须跟随最新编辑
|
// 关键:全屏/非全屏切换会重建 mindmap 实例,初始化数据必须跟随最新编辑
|
||||||
// 否则切换视图时会用旧数据,表现为“看起来没保存”
|
// 否则切换视图时会用旧数据,表现为"看起来没保存"
|
||||||
const safe = canonicalizeMindmapData(data);
|
let safe = canonicalizeMindmapData(data);
|
||||||
|
// 将 signed URL 转换回 asset:id 格式用于保存
|
||||||
|
if (signedUrlToAssetIdRef.current.size > 0) {
|
||||||
|
safe = revertToAssetIds(safe, signedUrlToAssetIdRef.current);
|
||||||
|
}
|
||||||
initialDataRef.current = safe;
|
initialDataRef.current = safe;
|
||||||
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
|
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
|
||||||
// 注意:全屏(Portal 覆盖层)下如果调用 updateBlock,BlockNote/ProseMirror
|
// 注意:全屏(Portal 覆盖层)下如果调用 updateBlock,BlockNote/ProseMirror
|
||||||
// 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框
|
// 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框
|
||||||
// 被迫退出(表现为“有光标但过一会儿就消失”)。
|
// 被迫退出(表现为"有光标但过一会儿就消失")。
|
||||||
// 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载)
|
// 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载)
|
||||||
// 时再统一把最新数据写回 block。
|
// 时再统一把最新数据写回 block。
|
||||||
if (!effectiveFullscreen) {
|
if (!effectiveFullscreen) {
|
||||||
@@ -1418,8 +1530,12 @@ const MindmapBlockView = ({
|
|||||||
dataForInitSource = block.props.data ? "block.props.data" : "initialDataRef";
|
dataForInitSource = block.props.data ? "block.props.data" : "initialDataRef";
|
||||||
return block.props.data ?? initialDataRef.current ?? defaultMindmapData;
|
return block.props.data ?? initialDataRef.current ?? defaultMindmapData;
|
||||||
})();
|
})();
|
||||||
const dataForInit = canonicalizeMindmapData(rawForInit);
|
const dataForInitCanonical = canonicalizeMindmapData(rawForInit);
|
||||||
initialDataRef.current = dataForInit;
|
initialDataRef.current = dataForInitCanonical;
|
||||||
|
// 将 asset:id 转换为签名 URL,并获取映射
|
||||||
|
const { data: dataForInit, urlToAssetId } = await resolveAssetUrls(dataForInitCanonical);
|
||||||
|
// 保存映射供后续保存时使用
|
||||||
|
signedUrlToAssetIdRef.current = urlToAssetId;
|
||||||
|
|
||||||
type MindMapConstructor = new (options: {
|
type MindMapConstructor = new (options: {
|
||||||
el: HTMLElement;
|
el: HTMLElement;
|
||||||
@@ -1439,13 +1555,15 @@ const MindmapBlockView = ({
|
|||||||
const MindMapCtor = MindMap as unknown as MindMapConstructor;
|
const MindMapCtor = MindMap as unknown as MindMapConstructor;
|
||||||
|
|
||||||
// Portal 全屏时,simple-mind-map 的节点文本编辑框会 append 到 body,并受 z-index 影响:
|
// Portal 全屏时,simple-mind-map 的节点文本编辑框会 append 到 body,并受 z-index 影响:
|
||||||
// 若 z-index 低于我们的全屏覆盖层,会出现“进入编辑态但看不到独立输入框/光标”的问题。
|
// 若 z-index 低于我们的全屏覆盖层,会出现"进入编辑态但看不到独立输入框/光标"的问题。
|
||||||
// 这里显式提高节点编辑框 z-index,并把内部浮层挂到 wrapper 上,确保全屏下可见且不会被遮挡。
|
// 这里显式提高节点编辑框 z-index,并把内部浮层挂到 wrapper 上,确保全屏下可见且不会被遮挡。
|
||||||
const fullscreenTextEditOptions = effectiveFullscreen
|
const fullscreenTextEditOptions = effectiveFullscreen
|
||||||
? {
|
? {
|
||||||
// 让编辑框仍 append 到 body(库默认行为),只提高 z-index,避免被全屏覆盖层挡住。
|
// 让编辑框仍 append 到 body(库默认行为),只提高 z-index,避免被全屏覆盖层挡住。
|
||||||
// 注意:如果把编辑框 append 到 wrapper(overflow-hidden),可能会影响 box-shadow/可见性。
|
// 注意:如果把编辑框 append 到 wrapper(overflow-hidden),可能会影响 box-shadow/可见性。
|
||||||
nodeTextEditZIndex: 100000,
|
nodeTextEditZIndex: 100000,
|
||||||
|
// 将图片调整遮罩挂载到容器内,确保全屏下可见且事件正常
|
||||||
|
customInnerElsAppendTo: containerRef.current || undefined,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -1950,12 +2068,97 @@ const MindmapBlockView = ({
|
|||||||
const [imageHeight, setImageHeight] = useState<number | string>(200);
|
const [imageHeight, setImageHeight] = useState<number | string>(200);
|
||||||
const [imageTitle, setImageTitle] = useState("");
|
const [imageTitle, setImageTitle] = useState("");
|
||||||
const [imagePosition, setImagePosition] = useState<string>("top");
|
const [imagePosition, setImagePosition] = useState<string>("top");
|
||||||
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
|
const [workspaceId, setWorkspaceId] = useState("");
|
||||||
const fileInputForImage = useRef<HTMLInputElement | null>(null);
|
const fileInputForImage = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// 图片预览(双击节点图片)
|
// 图片预览(双击节点图片)
|
||||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||||
const [viewerSrc, setViewerSrc] = useState("");
|
const [viewerSrc, setViewerSrc] = useState("");
|
||||||
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
|
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
|
||||||
|
const [viewerZoom, setViewerZoom] = useState(1);
|
||||||
|
|
||||||
|
// 解析图片 URL,如果是 asset_id 格式则获取签名 URL
|
||||||
|
const resolveImageUrl = async (urlOrAssetId: string): Promise<string> => {
|
||||||
|
// 检查是否是 asset_id 格式
|
||||||
|
if (urlOrAssetId.startsWith("asset:")) {
|
||||||
|
const assetId = urlOrAssetId.replace("asset:", "");
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/media/sign?assetId=${assetId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return data.signedUrl;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("获取签名 URL 失败", e);
|
||||||
|
}
|
||||||
|
return urlOrAssetId;
|
||||||
|
}
|
||||||
|
// 如果是完整的 URL,直接返回
|
||||||
|
return urlOrAssetId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 递归收集思维导图中所有的图片 URL
|
||||||
|
const collectImageUrls = (data: any): string[] => {
|
||||||
|
if (!data) return [];
|
||||||
|
const urls: string[] = [];
|
||||||
|
const traverse = (node: any) => {
|
||||||
|
if (!node) return;
|
||||||
|
if (node.image && typeof node.image === "string" && node.image) {
|
||||||
|
urls.push(node.image);
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
node.children.forEach(traverse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
traverse(data);
|
||||||
|
return urls;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理图片删除(将 asset 移到垃圾桶)
|
||||||
|
const deleteImageAssets = async (assetIds: string[]) => {
|
||||||
|
if (!assetIds.length || !docId) return;
|
||||||
|
try {
|
||||||
|
console.log("[MindmapBlock] Deleting image assets:", assetIds);
|
||||||
|
const response = await fetch("/api/media/batch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "delete", assetIds }),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
// 记录到已删除列表
|
||||||
|
assetIds.forEach(id => deletedAssetIdsRef.current.add(id));
|
||||||
|
console.log("[MindmapBlock] Image assets deleted successfully, emitting ASSETS_CHANGED_EVENT");
|
||||||
|
// 通知文件树刷新,传递被删除的 assetIds
|
||||||
|
emitAssetsChanged(docId, undefined, assetIds);
|
||||||
|
} else {
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
console.error("[MindmapBlock] Failed to delete image assets:", payload?.error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("删除图片资源失败", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理图片恢复(从垃圾桶恢复)
|
||||||
|
const restoreImageAssets = async (assetIds: string[]) => {
|
||||||
|
if (!assetIds.length || !docId) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/media/batch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "restore", assetIds }),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
// 从已删除列表移除
|
||||||
|
assetIds.forEach(id => deletedAssetIdsRef.current.delete(id));
|
||||||
|
// 通知文件树刷新,传递恢复的 assetIds(空列表表示需要重新获取)
|
||||||
|
emitAssetsChanged(docId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("恢复图片资源失败", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
|
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [imgToolbarState, setImgToolbarState] = useState({
|
const [imgToolbarState, setImgToolbarState] = useState({
|
||||||
show: false,
|
show: false,
|
||||||
@@ -1964,6 +2167,7 @@ const MindmapBlockView = ({
|
|||||||
placement: "top" as "top" | "bottom" | "left" | "right",
|
placement: "top" as "top" | "bottom" | "left" | "right",
|
||||||
});
|
});
|
||||||
const imgToolbarHover = useRef(false);
|
const imgToolbarHover = useRef(false);
|
||||||
|
const imgNodeRef = useRef<{ node: MindMapNode | null; imgNode: any }>({ node: null, imgNode: null });
|
||||||
|
|
||||||
const handleImage = () => {
|
const handleImage = () => {
|
||||||
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
|
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
|
||||||
@@ -1974,10 +2178,24 @@ const MindmapBlockView = ({
|
|||||||
setShowImageModal(true);
|
setShowImageModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 图片工具栏更新位置函数
|
||||||
|
const updateImgToolbarPos = useCallback(() => {
|
||||||
|
if (!imgToolbarState.show || !imgNodeRef.current.imgNode || !imgToolbarRef.current) return;
|
||||||
|
const toolbarRect = imgToolbarRef.current.getBoundingClientRect();
|
||||||
|
const imgRbox = imgNodeRef.current.imgNode?.rbox?.();
|
||||||
|
if (!imgRbox) return;
|
||||||
|
const { width: imgWidth, x, y } = imgRbox;
|
||||||
|
setImgToolbarState((s) => ({
|
||||||
|
...s,
|
||||||
|
x: x + imgWidth / 2 - toolbarRect.width / 2,
|
||||||
|
y: y - toolbarRect.height - 5,
|
||||||
|
}));
|
||||||
|
}, [imgToolbarState.show]);
|
||||||
|
|
||||||
// 预览:监听 mindmap 事件
|
// 预览:监听 mindmap 事件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mindmap) return;
|
if (!mindmap) return;
|
||||||
const handler = (node: MindMapNode, e?: Event) => {
|
const handler = async (node: MindMapNode, e?: Event) => {
|
||||||
e?.stopPropagation?.();
|
e?.stopPropagation?.();
|
||||||
e?.preventDefault?.();
|
e?.preventDefault?.();
|
||||||
const srcRaw =
|
const srcRaw =
|
||||||
@@ -1986,7 +2204,9 @@ const MindmapBlockView = ({
|
|||||||
node?.data?.image;
|
node?.data?.image;
|
||||||
const src = typeof srcRaw === "string" ? srcRaw : "";
|
const src = typeof srcRaw === "string" ? srcRaw : "";
|
||||||
if (src) {
|
if (src) {
|
||||||
setViewerSrc(src);
|
// 解析图片 URL,如果是 asset_id 格式则获取签名 URL
|
||||||
|
const resolvedUrl = await resolveImageUrl(src);
|
||||||
|
setViewerSrc(resolvedUrl);
|
||||||
const sizeRaw = node?.getData?.("imageSize");
|
const sizeRaw = node?.getData?.("imageSize");
|
||||||
const size =
|
const size =
|
||||||
sizeRaw && typeof sizeRaw === "object"
|
sizeRaw && typeof sizeRaw === "object"
|
||||||
@@ -1998,56 +2218,183 @@ const MindmapBlockView = ({
|
|||||||
width: Number(size.width) || undefined,
|
width: Number(size.width) || undefined,
|
||||||
height: Number(size.height) || undefined,
|
height: Number(size.height) || undefined,
|
||||||
});
|
});
|
||||||
|
setViewerZoom(1); // 重置缩放
|
||||||
setShowImageViewer(true);
|
setShowImageViewer(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onActive = () => {
|
const onActive = (node: MindMapNode) => {
|
||||||
|
if (node === imgNodeRef.current.node) return;
|
||||||
const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[];
|
const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[];
|
||||||
const has = list.some((n) => !!n?.getData?.("image"));
|
const has = list.some((n) => !!n?.getData?.("image"));
|
||||||
if (!has) setImgToolbarState((s) => ({ ...s, show: false }));
|
if (!has) {
|
||||||
|
setImgToolbarState((s) => ({ ...s, show: false }));
|
||||||
|
imgNodeRef.current = { node: null, imgNode: null };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const showToolbarOnClick = (node: MindMapNode, _svgImg: unknown, evt: Event | undefined) => {
|
const showToolbarOnClick = (node: MindMapNode, imgNode: any, _evt: Event | undefined) => {
|
||||||
const target = evt?.target as Element | undefined;
|
imgNodeRef.current = { node, imgNode };
|
||||||
const bbox = target?.getBoundingClientRect?.();
|
|
||||||
const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right";
|
const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right";
|
||||||
if (!bbox) return;
|
|
||||||
setImagePosition(placement);
|
setImagePosition(placement);
|
||||||
setImgToolbarState({
|
setImgToolbarState((s) => ({
|
||||||
|
...s,
|
||||||
show: true,
|
show: true,
|
||||||
x: bbox.left,
|
|
||||||
y: bbox.top,
|
|
||||||
placement,
|
placement,
|
||||||
|
}));
|
||||||
|
// 使用 requestAnimationFrame 确保 DOM 更新后再定位
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateImgToolbarPos();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const hideToolbar = () => {
|
const hideToolbar = () => {
|
||||||
if (!imgToolbarHover.current) {
|
if (!imgToolbarHover.current) {
|
||||||
setImgToolbarState((s) => ({ ...s, show: false }));
|
setImgToolbarState((s) => ({ ...s, show: false }));
|
||||||
|
imgNodeRef.current = { node: null, imgNode: null };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
mindmap.on?.("node_img_dblclick", handler);
|
mindmap.on?.("node_img_dblclick", handler);
|
||||||
mindmap.on?.("node_active", onActive);
|
mindmap.on?.("node_active", onActive);
|
||||||
mindmap.on?.("node_img_click", showToolbarOnClick);
|
mindmap.on?.("node_img_click", showToolbarOnClick);
|
||||||
mindmap.on?.("draw_click", hideToolbar);
|
mindmap.on?.("draw_click", hideToolbar);
|
||||||
|
// 添加更多事件监听以正确隐藏/更新工具栏
|
||||||
|
mindmap.on?.("svg_mousedown", hideToolbar);
|
||||||
|
mindmap.on?.("node_dblclick", hideToolbar);
|
||||||
|
mindmap.on?.("scale", updateImgToolbarPos);
|
||||||
|
mindmap.on?.("translate", hideToolbar);
|
||||||
|
mindmap.on?.("node_img_adjust_btn_mousedown", hideToolbar);
|
||||||
|
mindmap.on?.("delete_node_img_from_delete_btn", hideToolbar);
|
||||||
return () => {
|
return () => {
|
||||||
mindmap.off?.("node_img_dblclick", handler);
|
mindmap.off?.("node_img_dblclick", handler);
|
||||||
mindmap.off?.("node_active", onActive);
|
mindmap.off?.("node_active", onActive);
|
||||||
mindmap.off?.("node_img_click", showToolbarOnClick);
|
mindmap.off?.("node_img_click", showToolbarOnClick);
|
||||||
mindmap.off?.("draw_click", hideToolbar);
|
mindmap.off?.("draw_click", hideToolbar);
|
||||||
|
mindmap.off?.("svg_mousedown", hideToolbar);
|
||||||
|
mindmap.off?.("node_dblclick", hideToolbar);
|
||||||
|
mindmap.off?.("scale", updateImgToolbarPos);
|
||||||
|
mindmap.off?.("translate", hideToolbar);
|
||||||
|
mindmap.off?.("node_img_adjust_btn_mousedown", hideToolbar);
|
||||||
|
mindmap.off?.("delete_node_img_from_delete_btn", hideToolbar);
|
||||||
};
|
};
|
||||||
}, [mindmap]);
|
}, [mindmap, updateImgToolbarPos]);
|
||||||
|
|
||||||
// 悬浮图片位置工具条
|
// 处理图片删除/恢复(支持撤销)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mindmap || !docId) return;
|
||||||
|
|
||||||
|
console.log("[MindmapBlock] Setting up image deletion monitoring");
|
||||||
|
|
||||||
|
// 初始化:收集当前所有图片 URL
|
||||||
|
const initialData = mindmap.getData?.();
|
||||||
|
if (initialData) {
|
||||||
|
const urls = collectImageUrls(initialData);
|
||||||
|
currentImageUrlsRef.current = new Set(urls);
|
||||||
|
console.log("[MindmapBlock] Initial image URLs:", urls.length, "Map size:", signedUrlToAssetIdRef.current.size);
|
||||||
|
console.log("[MindmapBlock] Initial URLs:", urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理数据变化
|
||||||
|
const handleDataChange = () => {
|
||||||
|
console.log("[MindmapBlock] data_change event fired!");
|
||||||
|
const newData = mindmap.getData?.();
|
||||||
|
if (!newData) return;
|
||||||
|
|
||||||
|
const newUrls = collectImageUrls(newData);
|
||||||
|
const newUrlsSet = new Set(newUrls);
|
||||||
|
const oldUrlsSet = currentImageUrlsRef.current;
|
||||||
|
|
||||||
|
console.log("[MindmapBlock] Old URLs:", Array.from(oldUrlsSet));
|
||||||
|
console.log("[MindmapBlock] New URLs:", newUrls);
|
||||||
|
console.log("[MindmapBlock] Map entries:", Array.from(signedUrlToAssetIdRef.current.entries()));
|
||||||
|
|
||||||
|
// 检测被删除的图片(在旧集合中但不在新集合中)
|
||||||
|
const deletedUrls: string[] = [];
|
||||||
|
oldUrlsSet.forEach(url => {
|
||||||
|
if (!newUrlsSet.has(url)) {
|
||||||
|
deletedUrls.push(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检测新增的图片(在新集合中但不在旧集合中)
|
||||||
|
const addedUrls: string[] = [];
|
||||||
|
newUrlsSet.forEach(url => {
|
||||||
|
if (!oldUrlsSet.has(url)) {
|
||||||
|
addedUrls.push(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理删除的图片
|
||||||
|
if (deletedUrls.length > 0) {
|
||||||
|
console.log("[MindmapBlock] Detected deleted URLs:", deletedUrls);
|
||||||
|
const assetIdsToDelete: string[] = [];
|
||||||
|
|
||||||
|
deletedUrls.forEach(url => {
|
||||||
|
const assetId = signedUrlToAssetIdRef.current.get(url);
|
||||||
|
console.log("[MindmapBlock] URL:", url.substring(0, 100), "-> Asset ID:", assetId);
|
||||||
|
if (assetId) {
|
||||||
|
assetIdsToDelete.push(assetId);
|
||||||
|
} else {
|
||||||
|
console.warn("[MindmapBlock] Asset ID not found in map for URL:", url.substring(0, 100));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[MindmapBlock] Asset IDs to delete:", assetIdsToDelete);
|
||||||
|
if (assetIdsToDelete.length > 0) {
|
||||||
|
deleteImageAssets(assetIdsToDelete);
|
||||||
|
} else {
|
||||||
|
console.warn("[MindmapBlock] No asset IDs found for deleted URLs!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理恢复的图片(可能是撤销操作)
|
||||||
|
if (addedUrls.length > 0) {
|
||||||
|
console.log("[MindmapBlock] Detected added URLs:", addedUrls);
|
||||||
|
const assetIdsToRestore: string[] = [];
|
||||||
|
addedUrls.forEach(url => {
|
||||||
|
const assetId = signedUrlToAssetIdRef.current.get(url);
|
||||||
|
if (assetId && deletedAssetIdsRef.current.has(assetId)) {
|
||||||
|
assetIdsToRestore.push(assetId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log("[MindmapBlock] Asset IDs to restore:", assetIdsToRestore);
|
||||||
|
if (assetIdsToRestore.length > 0) {
|
||||||
|
restoreImageAssets(assetIdsToRestore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前图片集合
|
||||||
|
currentImageUrlsRef.current = newUrlsSet;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听多种事件
|
||||||
|
mindmap.on?.("data_change", handleDataChange);
|
||||||
|
mindmap.on?.("back_forward", handleDataChange);
|
||||||
|
mindmap.on?.("node_data_change", handleDataChange);
|
||||||
|
|
||||||
|
console.log("[MindmapBlock] Event listeners registered");
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("[MindmapBlock] Cleaning up event listeners");
|
||||||
|
mindmap.off?.("data_change", handleDataChange);
|
||||||
|
mindmap.off?.("back_forward", handleDataChange);
|
||||||
|
mindmap.off?.("node_data_change", handleDataChange);
|
||||||
|
};
|
||||||
|
}, [mindmap, docId]);
|
||||||
|
|
||||||
|
// 悬浮图片位置工具条(参考 NodeImgPlacementToolbar.vue)
|
||||||
const renderImgToolbar = () => {
|
const renderImgToolbar = () => {
|
||||||
if (!imgToolbarState.show) return null;
|
// 预览打开时隐藏工具栏
|
||||||
|
if (!imgToolbarState.show || showImageViewer) return null;
|
||||||
const { x, y, placement } = imgToolbarState;
|
const { x, y, placement } = imgToolbarState;
|
||||||
|
|
||||||
const setPlacement = (p: "top" | "bottom" | "left" | "right") => {
|
const setPlacement = (p: "top" | "bottom" | "left" | "right") => {
|
||||||
setImagePosition(p);
|
setImagePosition(p);
|
||||||
applyToActiveNodes(mindmap, (node) =>
|
applyToActiveNodes(mindmap, (n) =>
|
||||||
mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: p }),
|
mindmap?.execCommand?.("SET_NODE_STYLES", n, { imgPlacement: p }),
|
||||||
);
|
);
|
||||||
setImgToolbarState((s) => ({ ...s, placement: p }));
|
setImgToolbarState((s) => ({ ...s, placement: p }));
|
||||||
};
|
};
|
||||||
const btn = (
|
|
||||||
|
// 位置按钮组件
|
||||||
|
const PosBtn = (
|
||||||
p: typeof placement,
|
p: typeof placement,
|
||||||
Icon: React.ComponentType<{ className?: string }>,
|
Icon: React.ComponentType<{ className?: string }>,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -2055,12 +2402,11 @@ const MindmapBlockView = ({
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex h-8 w-8 items-center justify-center rounded border bg-white/90 text-gray-700 shadow ${
|
className={`flex h-8 w-8 items-center justify-center rounded border bg-white text-gray-700 shadow-sm ${
|
||||||
placement === p ? "border-blue-500 text-blue-600" : "border-gray-200"
|
placement === p ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200 hover:bg-gray-100"
|
||||||
}`}
|
}`}
|
||||||
onMouseDown={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
|
||||||
setPlacement(p);
|
setPlacement(p);
|
||||||
}}
|
}}
|
||||||
title={title}
|
title={title}
|
||||||
@@ -2068,24 +2414,18 @@ const MindmapBlockView = ({
|
|||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={imgToolbarRef}
|
ref={imgToolbarRef}
|
||||||
className="pointer-events-auto fixed z-[9999] flex gap-1 rounded-lg bg-black/50 p-2 backdrop-blur"
|
className="pointer-events-auto fixed z-[9999] flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 shadow-md"
|
||||||
style={{ left: x, top: y - 42 }}
|
style={{ left: x, top: y }}
|
||||||
onMouseEnter={() => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
imgToolbarHover.current = true;
|
|
||||||
setImgToolbarState((s) => ({ ...s, show: true }));
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
imgToolbarHover.current = false;
|
|
||||||
setImgToolbarState((s) => ({ ...s, show: false }));
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{btn("top", ArrowUp, "顶部")}
|
{PosBtn("top", ArrowUp, "顶部")}
|
||||||
{btn("bottom", ArrowDown, "底部")}
|
{PosBtn("bottom", ArrowDown, "底部")}
|
||||||
{btn("left", ArrowLeft, "靠左")}
|
{PosBtn("left", ArrowLeft, "靠左")}
|
||||||
{btn("right", ArrowRight, "靠右")}
|
{PosBtn("right", ArrowRight, "靠右")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2122,22 +2462,41 @@ const MindmapBlockView = ({
|
|||||||
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
|
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageConfirm = () => {
|
const handleImageConfirm = async () => {
|
||||||
const url = imageUrl.trim();
|
const url = imageUrl.trim();
|
||||||
const width = Number(imageWidth) || 260;
|
// 获取原始图片尺寸,如果没有则使用默认值
|
||||||
const height = Number(imageHeight) || 200;
|
let width = Number(imageWidth) || 0;
|
||||||
|
let height = Number(imageHeight) || 0;
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
window.alert("请输入图片链接");
|
window.alert("请输入图片链接");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setShowImageModal(false);
|
setShowImageModal(false);
|
||||||
|
|
||||||
|
// 保存旧的 signed URL(如果存在),用于后续删除检测
|
||||||
|
const oldData = mindmap?.getData?.();
|
||||||
|
const oldUrls = oldData ? collectImageUrls(oldData) : [];
|
||||||
|
|
||||||
|
// 如果是 asset:id 格式,需要先转换为 signed URL 供显示
|
||||||
|
let displayUrl = url;
|
||||||
|
if (url.startsWith("asset:")) {
|
||||||
|
displayUrl = await resolveImageUrl(url);
|
||||||
|
// 记录映射关系供保存时使用
|
||||||
|
if (displayUrl !== url) {
|
||||||
|
const assetId = url.replace("asset:", "");
|
||||||
|
signedUrlToAssetIdRef.current.set(displayUrl, assetId);
|
||||||
|
console.log("[MindmapBlock] New image mapped:", displayUrl.substring(0, 80), "-> Asset ID:", assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
applyToActiveNodes(mindmap, (node) =>
|
applyToActiveNodes(mindmap, (node) =>
|
||||||
mindmap?.execCommand("SET_NODE_IMAGE", node, {
|
mindmap?.execCommand("SET_NODE_IMAGE", node, {
|
||||||
url,
|
url: displayUrl,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
title: imageTitle || "",
|
title: imageTitle || "",
|
||||||
position: imagePosition || "top",
|
custom: false, // 让 simple-mind-map 自动缩放到合适尺寸
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
applyToActiveNodes(mindmap, (node) =>
|
applyToActiveNodes(mindmap, (node) =>
|
||||||
@@ -2145,6 +2504,16 @@ const MindmapBlockView = ({
|
|||||||
imgPlacement: imagePosition || "top",
|
imgPlacement: imagePosition || "top",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 等待命令执行后更新 currentImageUrlsRef
|
||||||
|
setTimeout(() => {
|
||||||
|
const newData = mindmap?.getData?.();
|
||||||
|
if (newData) {
|
||||||
|
const newUrls = collectImageUrls(newData);
|
||||||
|
currentImageUrlsRef.current = new Set(newUrls);
|
||||||
|
console.log("[MindmapBlock] Updated currentImageUrlsRef after insert:", newUrls);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachment = () => {
|
const handleAttachment = () => {
|
||||||
@@ -2376,7 +2745,16 @@ const MindmapBlockView = ({
|
|||||||
|
|
||||||
const imageViewer = showImageViewer ? (
|
const imageViewer = showImageViewer ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setShowImageViewer(false)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setShowImageViewer(false)}>
|
||||||
<div className="relative max-h-full max-w-5xl text-white">
|
<div
|
||||||
|
className="relative max-h-full max-w-5xl text-white"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onWheel={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
setViewerZoom((prev) => Math.max(0.1, Math.min(5, prev + delta)));
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
className="absolute -top-3 -right-3 rounded-full bg-white/90 px-2 py-1 text-sm text-gray-700 shadow"
|
className="absolute -top-3 -right-3 rounded-full bg-white/90 px-2 py-1 text-sm text-gray-700 shadow"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -2388,20 +2766,45 @@ const MindmapBlockView = ({
|
|||||||
</button>
|
</button>
|
||||||
<div className="mb-2 flex items-center justify-between gap-4 px-1 text-xs text-gray-200">
|
<div className="mb-2 flex items-center justify-between gap-4 px-1 text-xs text-gray-200">
|
||||||
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
|
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
|
||||||
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
|
||||||
|
<span className="shrink-0 text-blue-300">{Math.round(viewerZoom * 100)}%</span>
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
|
||||||
|
onClick={() => setViewerZoom((prev) => Math.max(0.1, prev - 0.2))}
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
|
||||||
|
onClick={() => setViewerZoom(1)}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
|
||||||
|
onClick={() => setViewerZoom((prev) => Math.min(5, prev + 0.2))}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={viewerSrc}
|
src={viewerSrc}
|
||||||
alt={viewerMeta.title || "预览"}
|
alt={viewerMeta.title || "预览"}
|
||||||
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white"
|
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white transition-transform duration-100"
|
||||||
|
style={{ transform: `scale(${viewerZoom})` }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const imgToolbar = renderImgToolbar();
|
// 使用 createPortal 将工具栏挂载到 body,确保 position: fixed 相对于视口
|
||||||
|
const imgToolbar = typeof document !== "undefined" && imgToolbarState.show
|
||||||
|
? createPortal(renderImgToolbar(), document.body)
|
||||||
|
: null;
|
||||||
|
|
||||||
const imageModal = showImageModal ? (
|
const imageModal = showImageModal ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
@@ -2423,7 +2826,15 @@ const MindmapBlockView = ({
|
|||||||
选择文件
|
选择文件
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-gray-400 truncate">
|
<span className="text-xs text-gray-400 truncate">
|
||||||
{imageUrl.startsWith("data:") ? "已选择本地图片" : "未选择文件"}
|
{uploadingImage
|
||||||
|
? "上传中..."
|
||||||
|
: imageUrl.startsWith("asset:")
|
||||||
|
? "已选择图片"
|
||||||
|
: imageUrl.startsWith("data:")
|
||||||
|
? "已选择本地图片"
|
||||||
|
: imageUrl
|
||||||
|
? "已输入图片地址"
|
||||||
|
: "未选择文件"}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
ref={fileInputForImage}
|
ref={fileInputForImage}
|
||||||
@@ -2433,17 +2844,42 @@ const MindmapBlockView = ({
|
|||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
if (!workspaceId || !docId) {
|
||||||
reader.onload = async () => {
|
window.alert("缺少空间信息,无法上传文件");
|
||||||
const dataUrl = String(reader.result);
|
return;
|
||||||
setImageUrl(dataUrl);
|
}
|
||||||
const size = await getImageSizeSafe(dataUrl);
|
setUploadingImage(true);
|
||||||
if (size) {
|
try {
|
||||||
setImageWidth(size.width);
|
// 先获取原始图片尺寸
|
||||||
setImageHeight(size.height);
|
const localSize = await getImageSizeSafe(URL.createObjectURL(file));
|
||||||
|
if (localSize) {
|
||||||
|
setImageWidth(localSize.width);
|
||||||
|
setImageHeight(localSize.height);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("workspaceId", workspaceId);
|
||||||
|
form.append("documentId", docId);
|
||||||
|
const response = await fetch("/api/media/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
throw new Error(payload?.error ?? "上传失败");
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as { mindmapUrl?: string };
|
||||||
|
if (!payload.mindmapUrl) {
|
||||||
|
throw new Error("返回数据缺少图片地址");
|
||||||
|
}
|
||||||
|
setImageUrl(payload.mindmapUrl);
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setUploadingImage(false);
|
||||||
|
if (fileInputForImage.current) fileInputForImage.current.value = "";
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2619,6 +3055,7 @@ const MindmapBlockView = ({
|
|||||||
/>
|
/>
|
||||||
<MindmapCount mindmap={mindmap} />
|
<MindmapCount mindmap={mindmap} />
|
||||||
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
||||||
|
<MindmapContextMenu mindmap={mindmap} />
|
||||||
{imgToolbar}
|
{imgToolbar}
|
||||||
{noteModal}
|
{noteModal}
|
||||||
{imageModal}
|
{imageModal}
|
||||||
@@ -2710,6 +3147,7 @@ const MindmapBlockView = ({
|
|||||||
/>
|
/>
|
||||||
<MindmapCount mindmap={mindmap} />
|
<MindmapCount mindmap={mindmap} />
|
||||||
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
||||||
|
<MindmapContextMenu mindmap={mindmap} />
|
||||||
{imgToolbar}
|
{imgToolbar}
|
||||||
{noteModal}
|
{noteModal}
|
||||||
{imageModal}
|
{imageModal}
|
||||||
|
|||||||
@@ -0,0 +1,520 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import type { MindMapNode } from "./mindmapTypes";
|
||||||
|
|
||||||
|
// 菜单项配置
|
||||||
|
interface ContextMenuItem {
|
||||||
|
key?: string;
|
||||||
|
label?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
divider?: boolean;
|
||||||
|
show?: (node: MindMapNode | null) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节点右键菜单配置
|
||||||
|
const NODE_MENU_ITEMS: ContextMenuItem[] = [
|
||||||
|
{
|
||||||
|
key: "INSERT_NODE",
|
||||||
|
label: "插入同级节点",
|
||||||
|
shortcut: "Enter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "INSERT_CHILD_NODE",
|
||||||
|
label: "插入子级节点",
|
||||||
|
shortcut: "Tab",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "INSERT_PARENT_NODE",
|
||||||
|
label: "插入父节点",
|
||||||
|
shortcut: "Shift + Tab",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ADD_GENERALIZATION",
|
||||||
|
label: "插入概要",
|
||||||
|
shortcut: "Ctrl + G",
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
key: "UP_NODE",
|
||||||
|
label: "上移节点",
|
||||||
|
shortcut: "Ctrl + ↑",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "DOWN_NODE",
|
||||||
|
label: "下移节点",
|
||||||
|
shortcut: "Ctrl + ↓",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "UNEXPAND_ALL",
|
||||||
|
label: "收起所有下级节点",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "EXPAND_ALL",
|
||||||
|
label: "展开所有下级节点",
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
key: "REMOVE_NODE",
|
||||||
|
label: "删除节点",
|
||||||
|
shortcut: "Delete",
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "REMOVE_CURRENT_NODE",
|
||||||
|
label: "仅删除当前节点",
|
||||||
|
shortcut: "Shift + Backspace",
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
key: "COPY_NODE",
|
||||||
|
label: "复制节点",
|
||||||
|
shortcut: "Ctrl + C",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "CUT_NODE",
|
||||||
|
label: "剪切节点",
|
||||||
|
shortcut: "Ctrl + X",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PASTE_NODE",
|
||||||
|
label: "粘贴节点",
|
||||||
|
shortcut: "Ctrl + V",
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
key: "REMOVE_HYPERLINK",
|
||||||
|
label: "移除超链接",
|
||||||
|
show: (node) => !!node?.getData?.("hyperlink"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "REMOVE_NOTE",
|
||||||
|
label: "移除备注",
|
||||||
|
show: (node) => !!node?.getData?.("note"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "REMOVE_CUSTOM_STYLES",
|
||||||
|
label: "一键去除自定义样式",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "EXPORT_CUR_NODE_TO_PNG",
|
||||||
|
label: "导出该节点为图片",
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
key: "AI_CONTINUE",
|
||||||
|
label: "AI续写",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MindmapContextMenuProps {
|
||||||
|
mindmap: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [targetNode, setTargetNode] = useState<MindMapNode | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const requestShowRef = useRef<{ x: number; y: number; node: MindMapNode } | null>(null);
|
||||||
|
|
||||||
|
// 判断是否禁用某个菜单项
|
||||||
|
const isItemDisabled = useCallback(
|
||||||
|
(item: ContextMenuItem): boolean => {
|
||||||
|
if (!targetNode) return false;
|
||||||
|
|
||||||
|
const isRoot = (targetNode as any).isRoot === true;
|
||||||
|
const isGeneralization = (targetNode as any).isGeneralization === true;
|
||||||
|
|
||||||
|
switch (item.key) {
|
||||||
|
case "INSERT_NODE":
|
||||||
|
case "INSERT_PARENT_NODE":
|
||||||
|
case "ADD_GENERALIZATION":
|
||||||
|
return isRoot || isGeneralization;
|
||||||
|
|
||||||
|
case "INSERT_CHILD_NODE":
|
||||||
|
return isGeneralization;
|
||||||
|
|
||||||
|
case "COPY_NODE":
|
||||||
|
case "CUT_NODE":
|
||||||
|
return isGeneralization;
|
||||||
|
|
||||||
|
case "UP_NODE": {
|
||||||
|
if (isRoot || isGeneralization) return true;
|
||||||
|
const parent = (targetNode as any).parent;
|
||||||
|
if (!parent || !Array.isArray(parent.children)) return true;
|
||||||
|
return parent.children.findIndex((item: unknown) => item === targetNode) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DOWN_NODE": {
|
||||||
|
if (isRoot || isGeneralization) return true;
|
||||||
|
const parent = (targetNode as any).parent;
|
||||||
|
if (!parent || !Array.isArray(parent.children)) return true;
|
||||||
|
const children = parent.children;
|
||||||
|
return children.findIndex((item: unknown) => item === targetNode) === children.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[targetNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 过滤显示的菜单项
|
||||||
|
const getVisibleItems = useCallback((): ContextMenuItem[] => {
|
||||||
|
return NODE_MENU_ITEMS.filter((item) => {
|
||||||
|
if (item.divider) return true;
|
||||||
|
if (item.show && !item.show(targetNode)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [targetNode]);
|
||||||
|
|
||||||
|
// 执行命令
|
||||||
|
const executeCommand = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (!mindmap || !targetNode) return;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "COPY_NODE":
|
||||||
|
mindmap.renderer?.copy?.();
|
||||||
|
break;
|
||||||
|
case "CUT_NODE":
|
||||||
|
mindmap.renderer?.cut?.();
|
||||||
|
break;
|
||||||
|
case "PASTE_NODE":
|
||||||
|
mindmap.renderer?.paste?.();
|
||||||
|
break;
|
||||||
|
case "REMOVE_HYPERLINK":
|
||||||
|
if (typeof (targetNode as any).setHyperlink === "function") {
|
||||||
|
(targetNode as any).setHyperlink("", "");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "REMOVE_NOTE":
|
||||||
|
if (typeof (targetNode as any).setNote === "function") {
|
||||||
|
(targetNode as any).setNote("");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "EXPORT_CUR_NODE_TO_PNG": {
|
||||||
|
const getTextFromHtml = (html: string) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.innerHTML = html;
|
||||||
|
return div.textContent || div.innerText || "";
|
||||||
|
};
|
||||||
|
const nodeText = targetNode.getData?.("text") || "";
|
||||||
|
mindmap.doExport?.export("png", true, getTextFromHtml(String(nodeText)), false, targetNode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "UNEXPAND_ALL":
|
||||||
|
mindmap.execCommand?.(key, false, targetNode);
|
||||||
|
break;
|
||||||
|
case "EXPAND_ALL":
|
||||||
|
mindmap.execCommand?.(key, (targetNode as any).uid || "");
|
||||||
|
break;
|
||||||
|
case "AI_CONTINUE":
|
||||||
|
// 触发 AI 续写
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("mindmap-ai-continue", {
|
||||||
|
detail: { node: targetNode },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
mindmap.execCommand?.(key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide();
|
||||||
|
},
|
||||||
|
[mindmap, targetNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 隐藏菜单
|
||||||
|
const hide = useCallback(() => {
|
||||||
|
setVisible(false);
|
||||||
|
setTargetNode(null);
|
||||||
|
setPosition({ x: -9999, y: -9999 });
|
||||||
|
requestShowRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 显示菜单 - 使用 requestAnimationFrame 确保 DOM 更新后再显示
|
||||||
|
const show = useCallback((x: number, y: number, node: MindMapNode) => {
|
||||||
|
setTargetNode(node);
|
||||||
|
|
||||||
|
// 计算可见菜单项数量
|
||||||
|
const visibleItems = NODE_MENU_ITEMS.filter((item) => {
|
||||||
|
if (item.divider) return true;
|
||||||
|
if (item.show && !item.show(node)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算实际菜单高度(每个菜单项约40px,分隔符约10px)
|
||||||
|
const itemHeight = 40;
|
||||||
|
const dividerHeight = 10;
|
||||||
|
const estimatedHeight = visibleItems.reduce((acc, item) => {
|
||||||
|
return acc + (item.divider ? dividerHeight : itemHeight);
|
||||||
|
}, 0) + 16; // +16 是上下 padding
|
||||||
|
|
||||||
|
const menuWidth = 250;
|
||||||
|
const menuHeight = estimatedHeight + 20; // 额外的安全边距
|
||||||
|
|
||||||
|
// 初始位置:鼠标右侧下方
|
||||||
|
let posX = x + 10;
|
||||||
|
let posY = y + 10;
|
||||||
|
|
||||||
|
// 如果右侧空间不足,显示在左侧
|
||||||
|
if (posX + menuWidth > window.innerWidth) {
|
||||||
|
posX = x - menuWidth - 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果下方空间不足,固定到窗口底部(与参考实现一致)
|
||||||
|
if (posY + menuHeight > window.innerHeight) {
|
||||||
|
posY = window.innerHeight - menuHeight - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保不会超出左边界
|
||||||
|
if (posX < 10) {
|
||||||
|
posX = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保菜单顶部不会超出窗口
|
||||||
|
if (posY < 10) {
|
||||||
|
posY = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition({ x: posX, y: posY });
|
||||||
|
setVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听右键事件
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mindmap) return;
|
||||||
|
|
||||||
|
const handleContextMenu = (e: Event) => {
|
||||||
|
const mouseEvent = e as MouseEvent;
|
||||||
|
|
||||||
|
// 检查是否点击在节点上
|
||||||
|
const target = mouseEvent.target as HTMLElement | SVGElement;
|
||||||
|
|
||||||
|
// simple-mind-map 的节点结构
|
||||||
|
// 尝试多种选择器
|
||||||
|
const nodeSelectors = [
|
||||||
|
".smm-node", // 主节点容器
|
||||||
|
".smm-node-light", // 亮色主题节点
|
||||||
|
"g[role='node']", // 带 role 属性的 g 元素
|
||||||
|
"g.smooth-smooth", // 特定样式的 g 元素
|
||||||
|
];
|
||||||
|
|
||||||
|
let clickedNodeEl: Element | null = null;
|
||||||
|
for (const selector of nodeSelectors) {
|
||||||
|
clickedNodeEl = target.closest?.(selector) || null;
|
||||||
|
if (clickedNodeEl) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没找到节点选择器,尝试查找包含 text 的元素
|
||||||
|
if (!clickedNodeEl) {
|
||||||
|
const parent = target.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
// 检查父元素是否包含文本内容
|
||||||
|
const textContainer = parent.querySelector("text");
|
||||||
|
if (textContainer) {
|
||||||
|
clickedNodeEl = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clickedNodeEl) return;
|
||||||
|
|
||||||
|
// 阻止默认右键菜单
|
||||||
|
mouseEvent.preventDefault();
|
||||||
|
mouseEvent.stopPropagation();
|
||||||
|
|
||||||
|
// 获取当前激活的节点作为右键点击的节点
|
||||||
|
const renderer = mindmap.renderer;
|
||||||
|
if (!renderer) return;
|
||||||
|
|
||||||
|
// 使用 activeNodeList 或 lastActiveNodeList
|
||||||
|
const activeList = renderer.activeNodeList ?? [];
|
||||||
|
const lastActiveList = renderer.lastActiveNodeList ?? [];
|
||||||
|
|
||||||
|
const node = (activeList[0] ?? lastActiveList[0] ?? null) as MindMapNode | null;
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
show(mouseEvent.clientX, mouseEvent.clientY, node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 延迟查找容器,确保 DOM 已经渲染
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener("contextmenu", handleContextMenu, true);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||||
|
if (container) {
|
||||||
|
container.removeEventListener("contextmenu", handleContextMenu, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mindmap, show]);
|
||||||
|
|
||||||
|
// 监听画布点击事件隐藏菜单
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mindmap) return;
|
||||||
|
|
||||||
|
const hideMenu = () => {
|
||||||
|
hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
mindmap.on?.("draw_click", hideMenu);
|
||||||
|
mindmap.on?.("node_click", hideMenu);
|
||||||
|
mindmap.on?.("expand_btn_click", hideMenu);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mindmap.off?.("draw_click", hideMenu);
|
||||||
|
mindmap.off?.("node_click", hideMenu);
|
||||||
|
mindmap.off?.("expand_btn_click", hideMenu);
|
||||||
|
};
|
||||||
|
}, [mindmap, hide]);
|
||||||
|
|
||||||
|
// 点击外部隐藏菜单
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
document.addEventListener("scroll", handleScroll, true);
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
document.removeEventListener("scroll", handleScroll, true);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
};
|
||||||
|
}, [visible, hide]);
|
||||||
|
|
||||||
|
// 渲染菜单
|
||||||
|
const renderMenu = () => {
|
||||||
|
const visibleItems = getVisibleItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="mindmap-contextmenu"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: `${position.x}px`,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
zIndex: 9999,
|
||||||
|
minWidth: "200px",
|
||||||
|
maxWidth: "280px",
|
||||||
|
background: "#ffffff",
|
||||||
|
borderRadius: "8px",
|
||||||
|
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
|
||||||
|
padding: "8px 0",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleItems.map((item, index) => {
|
||||||
|
if (item.divider) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`divider-${index}`}
|
||||||
|
style={{
|
||||||
|
height: "1px",
|
||||||
|
background: "#e5e7eb",
|
||||||
|
margin: "4px 12px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = isItemDisabled(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.key || `item-${index}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (!item.key) return;
|
||||||
|
executeCommand(item.key);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 16px",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
color: item.danger ? "#ef4444" : disabled ? "#9ca3af" : "#1f2937",
|
||||||
|
background: "transparent",
|
||||||
|
transition: "background 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!disabled) {
|
||||||
|
e.currentTarget.style.background = "#f3f4f6";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "14px" }}>{item.label || ""}</span>
|
||||||
|
{item.shortcut && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#9ca3af",
|
||||||
|
marginLeft: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.shortcut}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof document === "undefined" || !visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(renderMenu(), document.body);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ export type MindMapNode = {
|
|||||||
getStyle: (key: string, checkRoot?: boolean) => unknown;
|
getStyle: (key: string, checkRoot?: boolean) => unknown;
|
||||||
setStyle?: (key: string, value: unknown) => void;
|
setStyle?: (key: string, value: unknown) => void;
|
||||||
setIcon?: (icons: string[]) => void;
|
setIcon?: (icons: string[]) => void;
|
||||||
setImage?: (img: { url: string; width?: number; height?: number } | null) => void;
|
setImage?: (img: { url: string; title?: string; width?: number; height?: number } | null) => void;
|
||||||
getData: (key: string) => unknown;
|
getData: (key: string) => unknown;
|
||||||
nodeData?: { data?: Record<string, unknown> };
|
nodeData?: { data?: Record<string, unknown> };
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
@@ -10,5 +10,25 @@ export type MindMapNode = {
|
|||||||
parent?: { children?: unknown[] } | null;
|
parent?: { children?: unknown[] } | null;
|
||||||
children?: unknown[];
|
children?: unknown[];
|
||||||
uid?: string;
|
uid?: string;
|
||||||
|
isRoot?: boolean;
|
||||||
|
isGeneralization?: boolean;
|
||||||
|
setHyperlink?: (url: string, title: string) => void;
|
||||||
|
setNote?: (note: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 思维导图实例:这里仅声明本项目用到的最小形状,避免到处用 any
|
||||||
|
export type MindMapInstance = {
|
||||||
|
renderer?: {
|
||||||
|
activeNodeList?: MindMapNode[];
|
||||||
|
lastActiveNodeList?: MindMapNode[];
|
||||||
|
copy?: () => void;
|
||||||
|
cut?: () => void;
|
||||||
|
paste?: () => void;
|
||||||
|
};
|
||||||
|
execCommand?: (command: string, ...args: unknown[]) => void;
|
||||||
|
doExport?: { export: (type: string, ...args: unknown[]) => void };
|
||||||
|
on?: (event: string, fn: (...args: unknown[]) => void) => void;
|
||||||
|
off?: (event: string, fn: (...args: unknown[]) => void) => void;
|
||||||
|
setData?: (data: unknown) => void;
|
||||||
|
command?: { clearHistory?: () => void };
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { DocumentSnapshot } from "@/types/document";
|
|||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||||
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { DocumentAiAgentPanel } from "./DocumentAiAgentPanel";
|
||||||
|
|
||||||
const BlockNoteEditor = dynamic(
|
const BlockNoteEditor = dynamic(
|
||||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||||
@@ -71,6 +72,7 @@ export function DocumentContent({
|
|||||||
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
|
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
|
||||||
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingOpenTableRef = useRef<string | null>(null);
|
const pendingOpenTableRef = useRef<string | null>(null);
|
||||||
|
const latestBlocksRef = useRef<Json | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tableId = (openTableId ?? "").trim();
|
const tableId = (openTableId ?? "").trim();
|
||||||
@@ -257,6 +259,7 @@ export function DocumentContent({
|
|||||||
}, [history, title]);
|
}, [history, title]);
|
||||||
|
|
||||||
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
|
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
|
||||||
|
latestBlocksRef.current = payload.blocks;
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (prev.length > 0 && now - prev[0].timestamp < 4000) {
|
if (prev.length > 0 && now - prev[0].timestamp < 4000) {
|
||||||
@@ -378,6 +381,7 @@ export function DocumentContent({
|
|||||||
history={history}
|
history={history}
|
||||||
onRestore={handleRestoreSnapshot}
|
onRestore={handleRestoreSnapshot}
|
||||||
/>
|
/>
|
||||||
|
<DocumentAiAgentPanel documentId={documentId} getLatestBlocks={() => latestBlocksRef.current} />
|
||||||
</ImagePickerProvider>
|
</ImagePickerProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type Status = "idle" | "ok" | "error";
|
type Status = "idle" | "ok" | "error" | "disabled";
|
||||||
|
|
||||||
export function useBackendHealth() {
|
export function useBackendHealth() {
|
||||||
const [status, setStatus] = useState<Status>(() => {
|
const [status, setStatus] = useState<Status>(() => {
|
||||||
if (!process.env.NEXT_PUBLIC_BACKEND_URL) {
|
|
||||||
return "error";
|
|
||||||
}
|
|
||||||
return "idle";
|
return "idle";
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
const url = process.env.NEXT_PUBLIC_BACKEND_URL;
|
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${url}/health`, { signal: controller.signal });
|
const response = await fetch("/api/backend/health", { signal: controller.signal });
|
||||||
|
const payload = (await response.json().catch(() => null)) as { status?: Status } | null;
|
||||||
if (!destroyed) {
|
if (!destroyed) {
|
||||||
setStatus(response.ok ? "ok" : "error");
|
setStatus(payload?.status ?? "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!destroyed) {
|
if (!destroyed) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export type ToolTagCall = {
|
||||||
|
name: string;
|
||||||
|
rawInput: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedToolTagMessage = {
|
||||||
|
text: string;
|
||||||
|
calls: ToolTagCall[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidTagName = (name: string) => /^[a-zA-Z][a-zA-Z0-9_]*$/.test(name);
|
||||||
|
|
||||||
|
// v1:简单、可预测的“类 XML 标签”协议:
|
||||||
|
// - 工具调用:<tool_name>{...json...}</tool_name>
|
||||||
|
// - 工具结果(回喂模型):<tool_result tool="tool_name">{...json...}</tool_result>
|
||||||
|
//
|
||||||
|
// 解析策略:
|
||||||
|
// - 只解析白名单工具名(避免误把普通 HTML 当工具)
|
||||||
|
// - 不做嵌套/属性解析(足够支撑 v1)
|
||||||
|
export const parseToolTagCalls = (input: string, allowedToolNames: Set<string>): ParsedToolTagMessage => {
|
||||||
|
const s = String(input ?? "");
|
||||||
|
if (!s) return { text: "", calls: [] };
|
||||||
|
|
||||||
|
const calls: ToolTagCall[] = [];
|
||||||
|
let outText = "";
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < s.length) {
|
||||||
|
const lt = s.indexOf("<", i);
|
||||||
|
if (lt === -1) {
|
||||||
|
outText += s.slice(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
outText += s.slice(i, lt);
|
||||||
|
|
||||||
|
const gt = s.indexOf(">", lt + 1);
|
||||||
|
if (gt === -1) {
|
||||||
|
outText += s.slice(lt);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagName = s.slice(lt + 1, gt).trim();
|
||||||
|
if (!isValidTagName(tagName) || !allowedToolNames.has(tagName)) {
|
||||||
|
outText += s.slice(lt, gt + 1);
|
||||||
|
i = gt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = `</${tagName}>`;
|
||||||
|
const closeIdx = s.indexOf(close, gt + 1);
|
||||||
|
if (closeIdx === -1) {
|
||||||
|
outText += s.slice(lt, gt + 1);
|
||||||
|
i = gt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawInput = s.slice(gt + 1, closeIdx).trim();
|
||||||
|
calls.push({ name: tagName, rawInput });
|
||||||
|
i = closeIdx + close.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: outText.trim(), calls };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatToolResultTag = (toolName: string, jsonText: string) => {
|
||||||
|
const safeName = isValidTagName(toolName) ? toolName : "unknown";
|
||||||
|
const body = String(jsonText ?? "").trim();
|
||||||
|
return `<tool_result tool="${safeName}">${body}</tool_result>`;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type AiAgentSseEvent =
|
||||||
|
| { event: "assistant_message"; data: { text: string } }
|
||||||
|
| { event: "tool_call"; data: { id: string; tool: string; args: Record<string, unknown> } }
|
||||||
|
| { event: "tool_result"; data: { id: string; tool: string; ok: boolean; ms: number; result: unknown } }
|
||||||
|
| { event: "completion"; data: { ok: true; text: string; steps: number } }
|
||||||
|
| { event: "error"; data: { ok: false; message: string } };
|
||||||
|
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
|
||||||
|
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
|
||||||
|
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
|
||||||
|
|
||||||
|
export type RunAiAgentArgs = {
|
||||||
|
userMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||||
|
cfg: OpenAiCompatibleChatOptions;
|
||||||
|
allowedToolIds: Set<string>;
|
||||||
|
runTool: (toolId: string, toolArgs: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
maxSteps?: number;
|
||||||
|
onEvent?: (event: { type: string; data: unknown }) => void;
|
||||||
|
systemContextText?: string;
|
||||||
|
defaultMindmapTargetUid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nowMs = () => Date.now();
|
||||||
|
const DEFAULT_MAX_STEPS = 10;
|
||||||
|
|
||||||
|
const safeParseJsonObject = (raw: string): Record<string, unknown> | null => {
|
||||||
|
const s = String(raw ?? "").trim();
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(s);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string) => {
|
||||||
|
const toolLines: string[] = [];
|
||||||
|
if (allowedTools.has("search_web")) {
|
||||||
|
toolLines.push("- search_web:<search_web>{\"query\":\"...\",\"count\":6}</search_web>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("rag_lightrag_query")) {
|
||||||
|
toolLines.push(
|
||||||
|
"- rag_lightrag_query:<rag_lightrag_query>{\"query\":\"...\",\"mode\":\"mix\",\"topK\":12,\"chunkTopK\":12}</rag_lightrag_query>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (allowedTools.has("docs_search")) {
|
||||||
|
toolLines.push("- docs_search:<docs_search>{\"query\":\"...\",\"limit\":12}</docs_search>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("docs_read")) {
|
||||||
|
toolLines.push("- docs_read:<docs_read>{\"documentId\":\"...\",\"maxChars\":2500}</docs_read>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("image_read")) {
|
||||||
|
toolLines.push("- image_read:<image_read>{\"attachmentRef\":\"...\"}</image_read>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("slash_run")) {
|
||||||
|
toolLines.push("- slash_run:<slash_run>{\"text\":\"/new 新页面标题\"}</slash_run>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("doc_get")) {
|
||||||
|
toolLines.push("- doc_get:<doc_get>{\"maxBlocks\":80}</doc_get>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("doc_find")) {
|
||||||
|
toolLines.push("- doc_find:<doc_find>{\"query\":\"...\",\"maxResults\":8}</doc_find>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("doc_insert_blocks")) {
|
||||||
|
toolLines.push(
|
||||||
|
"- doc_insert_blocks:<doc_insert_blocks>{\"afterBlockId\":\"...\",\"blocks\":[{\"type\":\"heading\",\"level\":2,\"text\":\"...\"},{\"type\":\"paragraph\",\"text\":\"...\"}]}</doc_insert_blocks>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (allowedTools.has("doc_replace_range")) {
|
||||||
|
toolLines.push(
|
||||||
|
"- doc_replace_range:<doc_replace_range>{\"blockId\":\"...\",\"text\":\"...\",\"mode\":\"replace\"}</doc_replace_range>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_get")) {
|
||||||
|
toolLines.push("- mindmap_get:<mindmap_get>{\"maxNodes\":120}</mindmap_get>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_get_subtree")) {
|
||||||
|
toolLines.push("- mindmap_get_subtree:<mindmap_get_subtree>{\"uid\":\"...\",\"depth\":2,\"maxNodes\":60}</mindmap_get_subtree>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_apply_ops")) {
|
||||||
|
toolLines.push("- mindmap_apply_ops:<mindmap_apply_ops>{\"ops\":[{\"op\":\"updateText\",\"uid\":\"...\",\"text\":\"...\"}],\"reason\":\"...\"}</mindmap_apply_ops>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_add_child")) {
|
||||||
|
toolLines.push(
|
||||||
|
"- mindmap_add_child:<mindmap_add_child>{\"parentUid\":\"...\",\"text\":\"...\",\"hyperlink\":\"https://...\",\"note\":\"...\",\"refs\":[{\"kind\":\"url\",\"fileUrl\":\"https://...\"}]}</mindmap_add_child>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_add_sibling_after")) {
|
||||||
|
toolLines.push("- mindmap_add_sibling_after:<mindmap_add_sibling_after>{\"targetUid\":\"...\",\"text\":\"...\",\"hyperlink\":\"https://...\"}</mindmap_add_sibling_after>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_update_node_text")) {
|
||||||
|
toolLines.push("- mindmap_update_node_text:<mindmap_update_node_text>{\"uid\":\"...\",\"text\":\"...\"}</mindmap_update_node_text>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_set_hyperlink")) {
|
||||||
|
toolLines.push("- mindmap_set_hyperlink:<mindmap_set_hyperlink>{\"uid\":\"...\",\"hyperlink\":\"https://...\"}</mindmap_set_hyperlink>(清除传 null)");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_append_note")) {
|
||||||
|
toolLines.push("- mindmap_append_note:<mindmap_append_note>{\"uid\":\"...\",\"markdown\":\"...\"}</mindmap_append_note>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_set_refs")) {
|
||||||
|
toolLines.push("- mindmap_set_refs:<mindmap_set_refs>{\"uid\":\"...\",\"refs\":[{\"kind\":\"url\",\"fileUrl\":\"https://...\"}]}</mindmap_set_refs>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_delete_node")) {
|
||||||
|
toolLines.push("- mindmap_delete_node:<mindmap_delete_node>{\"uid\":\"...\"}</mindmap_delete_node>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_add_attachment_ref")) {
|
||||||
|
toolLines.push("- mindmap_add_attachment_ref:<mindmap_add_attachment_ref>{\"uid\":\"...\",\"attachmentId\":\"...\",\"page\":12,\"mode\":\"append\"}</mindmap_add_attachment_ref>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_add_attachment_child")) {
|
||||||
|
toolLines.push("- mindmap_add_attachment_child:<mindmap_add_attachment_child>{\"parentUid\":\"...\",\"attachmentId\":\"...\",\"page\":12}</mindmap_add_attachment_child>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_add_image_child")) {
|
||||||
|
toolLines.push("- mindmap_add_image_child:<mindmap_add_image_child>{\"parentUid\":\"...\",\"imageRef\":\"...\",\"caption\":\"...\"}</mindmap_add_image_child>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_append_image_note")) {
|
||||||
|
toolLines.push("- mindmap_append_image_note:<mindmap_append_image_note>{\"uid\":\"...\",\"imageRef\":\"...\"}</mindmap_append_image_note>");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_expand_node")) {
|
||||||
|
toolLines.push("- mindmap_expand_node:<mindmap_expand_node>{\"targetUid\":\"...\",\"instruction\":\"...\"}</mindmap_expand_node>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const guideLines: string[] = [];
|
||||||
|
guideLines.push("- 先用只读工具定位(需要时再检索),再做最小范围写入。");
|
||||||
|
if (allowedTools.has("search_web")) {
|
||||||
|
guideLines.push("- 需要来源时先 search_web,再把 URL 放进最终回答或写入引用字段。");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("rag_lightrag_query")) {
|
||||||
|
guideLines.push("- 需要从本地知识库语义检索/汇总:优先 rag_lightrag_query;若还需要网页来源,再额外 search_web。");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("docs_search") || allowedTools.has("docs_read")) {
|
||||||
|
guideLines.push("- 跨页面找内容:先 docs_search 找到 documentId,再 docs_read 读取原文片段(需要时再到对应页面使用 doc_* 写入)。");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("image_read")) {
|
||||||
|
guideLines.push("- 需要读图:用 image_read 从 media_assets.ocr_text 获取文字(attachmentRef 可用附件 id/title/url 片段)。");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("slash_run")) {
|
||||||
|
guideLines.push("- 需要创建/改名:用 slash_run 执行 /new 或 /rename(这是写工具,只有在用户明确要求时才调用)。");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
allowedTools.has("doc_get") ||
|
||||||
|
allowedTools.has("doc_find") ||
|
||||||
|
allowedTools.has("doc_insert_blocks") ||
|
||||||
|
allowedTools.has("doc_replace_range")
|
||||||
|
) {
|
||||||
|
guideLines.push("- 写页面前:先 doc_get 或 doc_find 确认 blockId;再用 doc_* 写工具插入/替换。");
|
||||||
|
guideLines.push("- doc_insert_blocks 适合新增标题/段落;doc_replace_range 适合改写某个段落/标题的文本。");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
allowedTools.has("mindmap_get") ||
|
||||||
|
allowedTools.has("mindmap_get_subtree") ||
|
||||||
|
allowedTools.has("mindmap_apply_ops") ||
|
||||||
|
allowedTools.has("mindmap_expand_node")
|
||||||
|
) {
|
||||||
|
guideLines.push("- 思维导图小改动优先用细粒度 mindmap_* 写工具(add_child/update/set_hyperlink/append_note/set_refs 等)。");
|
||||||
|
guideLines.push("- 只有当需要一次性执行多条混合操作时才用 mindmap_apply_ops。");
|
||||||
|
guideLines.push("- 附件的 id 与 url 在“当前上下文”里(attachments 列表)。");
|
||||||
|
guideLines.push("- 挂到已有节点:mindmap_add_attachment_ref;把附件变成节点:mindmap_add_attachment_child。");
|
||||||
|
guideLines.push("- 插入图片:mindmap_add_image_child 或 mindmap_append_image_note(图片 URL 可来自 attachments 或 https 链接)。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hardRules: string[] = [];
|
||||||
|
hardRules.push("- 当用户询问价格/版本/配置信息并要求“最新/准确/带来源”时:优先调用 search_web 获取来源,再回答。");
|
||||||
|
hardRules.push("- 如果你调用了 search_web:最终回答需给出“来源”列表(至少 2 条 URL);未调用 search_web 时不要编造来源。");
|
||||||
|
hardRules.push("- 最终回答不要再输出任何工具标签。");
|
||||||
|
if (allowedTools.has("slash_run")) {
|
||||||
|
hardRules.push("- slash_run 属于写工具:只有在用户明确要求“创建/改名/执行斜杠命令”时才调用;否则不要擅自创建新文档。");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("mindmap_apply_ops") || allowedTools.has("mindmap_expand_node")) {
|
||||||
|
hardRules.push("- 涉及思维导图写入时:只能通过 mindmap_* 写工具落盘;不要输出整棵树覆盖。");
|
||||||
|
}
|
||||||
|
if (allowedTools.has("doc_insert_blocks") || allowedTools.has("doc_replace_range")) {
|
||||||
|
hardRules.push("- 涉及页面写入时:只能通过 doc_* 写工具落入页面;不要让用户复制粘贴,也不要输出整篇 blocks JSON 让用户手动替换。");
|
||||||
|
}
|
||||||
|
hardRules.push("- 若上下文包含 selectedUids:默认使用第一个 uid 作为目标节点(除非用户明确给出 targetUid)。");
|
||||||
|
|
||||||
|
return [
|
||||||
|
"你是一个“AI Agent”(类似 Cline/VSCode Chat)。你可以通过工具完成任务,并把步骤展示给用户。",
|
||||||
|
systemContextText ? `\n当前上下文:\n${systemContextText.trim()}\n` : "",
|
||||||
|
"",
|
||||||
|
"工具调用协议(硬性):",
|
||||||
|
"1) 当你需要调用工具时,必须只输出一个工具标签(不要输出其它文字)。",
|
||||||
|
"2) 标签格式:<tool_name>{...JSON...}</tool_name>,JSON 必须是严格 JSON(双引号)。",
|
||||||
|
"3) 你将收到工具结果:<tool_result tool=\"tool_name\">{...JSON...}</tool_result>,再继续。",
|
||||||
|
"",
|
||||||
|
"工具列表(允许集):",
|
||||||
|
...toolLines,
|
||||||
|
"",
|
||||||
|
"如何选择工具(建议):",
|
||||||
|
...guideLines,
|
||||||
|
"",
|
||||||
|
"行为要求(硬性):",
|
||||||
|
...hardRules,
|
||||||
|
].join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runAiAgent = async (args: RunAiAgentArgs): Promise<{ ok: true; text: string; steps: number } | { ok: false; error: string }> => {
|
||||||
|
const maxSteps = Math.max(1, Math.min(24, Math.floor(args.maxSteps ?? DEFAULT_MAX_STEPS)));
|
||||||
|
const allowedToolIds = args.allowedToolIds;
|
||||||
|
const allowedToolNames = new Set<string>(Array.from(allowedToolIds));
|
||||||
|
|
||||||
|
const messages: OpenAiCompatibleChatMessage[] = [
|
||||||
|
{ role: "system", content: buildSystemPrompt(allowedToolIds, args.systemContextText) },
|
||||||
|
...args.userMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const emit = (type: string, data: unknown) => args.onEvent?.({ type, data });
|
||||||
|
|
||||||
|
const stepId = (step: number) => `step_${step}_${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
|
||||||
|
let steps = 0;
|
||||||
|
let usedAnyTool = false;
|
||||||
|
|
||||||
|
const latestUserQuestion =
|
||||||
|
[...args.userMessages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "user")
|
||||||
|
?.content?.trim() ?? "";
|
||||||
|
|
||||||
|
const shouldForceSearchWeb = (question: string) => {
|
||||||
|
const q = String(question || "").trim();
|
||||||
|
if (!q) return false;
|
||||||
|
// v1:非常粗粒度的启发式,避免“问价格/最新信息”却不检索
|
||||||
|
return /价格|多少钱|价位|收费|pricing|price|token|tokens|最新版|最新|更新|版本|费率/i.test(q);
|
||||||
|
};
|
||||||
|
|
||||||
|
const looksLikeHasSources = (text: string) => {
|
||||||
|
const s = String(text || "");
|
||||||
|
const urls = s.match(/https?:\/\/\S+/g) ?? [];
|
||||||
|
return urls.length >= 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldForceMindmapExpand = (question: string) => {
|
||||||
|
const q = String(question || "").trim();
|
||||||
|
if (!q) return false;
|
||||||
|
// v1:仅在明确“要改导图”的意图时才兜底触发写工具,避免误操作
|
||||||
|
const wantsMindmap = /导图|思维导图|节点|子节点/i.test(q);
|
||||||
|
const wantsWrite = /写入|保存|落盘|应用|修改|更新|补完|扩展|完善|新增|添加/i.test(q);
|
||||||
|
return wantsMindmap && wantsWrite;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (; steps < maxSteps; steps += 1) {
|
||||||
|
const { text } = await openAiCompatibleChat(messages, args.cfg);
|
||||||
|
const parsed = parseToolTagCalls(text, allowedToolNames);
|
||||||
|
|
||||||
|
if (parsed.calls.length === 0) {
|
||||||
|
const finalText = String(text ?? "").trim();
|
||||||
|
|
||||||
|
// 若模型没调用工具且看起来也没给足来源,而问题又强依赖“最新/可追溯”,则自动补一次检索再让模型回答
|
||||||
|
if (!usedAnyTool && allowedToolIds.has("search_web") && shouldForceSearchWeb(latestUserQuestion) && !looksLikeHasSources(finalText)) {
|
||||||
|
const id = stepId(steps + 1);
|
||||||
|
const tool = "search_web";
|
||||||
|
const toolArgs = { query: latestUserQuestion, count: 6 };
|
||||||
|
emit("tool_call", { id, tool, args: toolArgs });
|
||||||
|
const t0 = nowMs();
|
||||||
|
try {
|
||||||
|
const result = await args.runTool(tool, toolArgs);
|
||||||
|
const ms = nowMs() - t0;
|
||||||
|
usedAnyTool = true;
|
||||||
|
emit("tool_result", { id, tool, ok: true, ms, result });
|
||||||
|
messages.push({ role: "assistant", content: `<${tool}>${JSON.stringify(toolArgs)}</${tool}>` });
|
||||||
|
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify(result)) });
|
||||||
|
continue;
|
||||||
|
} catch (e) {
|
||||||
|
const ms = nowMs() - t0;
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
usedAnyTool = true;
|
||||||
|
emit("tool_result", { id, tool, ok: false, ms, result: { error: msg } });
|
||||||
|
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify({ error: msg })) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若用户明确要“补完/写入思维导图”,但模型没触发工具,则兜底执行一次 mindmap_expand_node
|
||||||
|
if (
|
||||||
|
!usedAnyTool &&
|
||||||
|
allowedToolIds.has("mindmap_expand_node") &&
|
||||||
|
args.defaultMindmapTargetUid &&
|
||||||
|
shouldForceMindmapExpand(latestUserQuestion)
|
||||||
|
) {
|
||||||
|
const id = stepId(steps + 1);
|
||||||
|
const tool = "mindmap_expand_node";
|
||||||
|
const toolArgs = { targetUid: args.defaultMindmapTargetUid, instruction: latestUserQuestion };
|
||||||
|
emit("tool_call", { id, tool, args: toolArgs });
|
||||||
|
const t0 = nowMs();
|
||||||
|
try {
|
||||||
|
const result = await args.runTool(tool, toolArgs);
|
||||||
|
const ms = nowMs() - t0;
|
||||||
|
usedAnyTool = true;
|
||||||
|
emit("tool_result", { id, tool, ok: true, ms, result });
|
||||||
|
messages.push({ role: "assistant", content: `<${tool}>${JSON.stringify(toolArgs)}</${tool}>` });
|
||||||
|
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify(result)) });
|
||||||
|
continue;
|
||||||
|
} catch (e) {
|
||||||
|
const ms = nowMs() - t0;
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
usedAnyTool = true;
|
||||||
|
emit("tool_result", { id, tool, ok: false, ms, result: { error: msg } });
|
||||||
|
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify({ error: msg })) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("assistant_message", { text: finalText });
|
||||||
|
return { ok: true, text: finalText, steps: steps + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1:一次只允许执行第一个工具调用,避免模型一口气输出多次工具导致不可控
|
||||||
|
const call = parsed.calls[0];
|
||||||
|
const tool = call.name;
|
||||||
|
if (!allowedToolIds.has(tool)) {
|
||||||
|
return { ok: false, error: `工具未被允许:${tool}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolArgs = safeParseJsonObject(call.rawInput) ?? {};
|
||||||
|
const id = stepId(steps + 1);
|
||||||
|
emit("tool_call", { id, tool, args: toolArgs });
|
||||||
|
|
||||||
|
const t0 = nowMs();
|
||||||
|
try {
|
||||||
|
const result = await args.runTool(tool, toolArgs);
|
||||||
|
const ms = nowMs() - t0;
|
||||||
|
usedAnyTool = true;
|
||||||
|
emit("tool_result", { id, tool, ok: true, ms, result });
|
||||||
|
|
||||||
|
messages.push({ role: "assistant", content: `<${tool}>${JSON.stringify(toolArgs)}</${tool}>` });
|
||||||
|
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify(result)) });
|
||||||
|
} catch (e) {
|
||||||
|
const ms = nowMs() - t0;
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
usedAnyTool = true;
|
||||||
|
emit("tool_result", { id, tool, ok: false, ms, result: { error: msg } });
|
||||||
|
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify({ error: msg })) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: `已达到最大步数(${maxSteps})` };
|
||||||
|
};
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import type { Json } from "@/types/supabase";
|
||||||
|
|
||||||
|
export type DocToolContext = {
|
||||||
|
documentId: string;
|
||||||
|
userId: string;
|
||||||
|
/**
|
||||||
|
* 来自前端的“最新文档快照”(优先使用,避免覆盖用户尚未落盘的编辑)。
|
||||||
|
* 允许是 blocks 数组,或 { blocks } 结构。
|
||||||
|
*/
|
||||||
|
baseBlocks?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SupabaseRouteClient = {
|
||||||
|
from: (table: string) => {
|
||||||
|
select: (columns: string) => SupabaseQuery;
|
||||||
|
update: (values: Record<string, unknown>) => SupabaseUpdateQuery;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type SupabaseQuery = {
|
||||||
|
eq: (column: string, value: unknown) => SupabaseQuery;
|
||||||
|
single: () => Promise<{ data: unknown; error: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SupabaseUpdateQuery = {
|
||||||
|
eq: (column: string, value: unknown) => SupabaseUpdateQuery;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocSupabaseClient = SupabaseRouteClient;
|
||||||
|
|
||||||
|
type DocBlockSummary = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
depth: number;
|
||||||
|
childCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocBlockSpec = {
|
||||||
|
type: "paragraph" | "heading";
|
||||||
|
text: string;
|
||||||
|
level?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
const getValue = (obj: unknown, key: string): unknown => (isRecord(obj) ? obj[key] : undefined);
|
||||||
|
|
||||||
|
const normalizeBlocks = (content: unknown): unknown[] => {
|
||||||
|
if (Array.isArray(content)) return content;
|
||||||
|
const blocks = getValue(content, "blocks");
|
||||||
|
if (Array.isArray(blocks)) return blocks;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractInlineText = (block: unknown): string => {
|
||||||
|
const content = getValue(block, "content");
|
||||||
|
const nodes = Array.isArray(content) ? content : [];
|
||||||
|
const pieces: string[] = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
const t = getValue(n, "text");
|
||||||
|
if (typeof t === "string") pieces.push(t);
|
||||||
|
}
|
||||||
|
return pieces.join("").trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkSummaries = (rootBlocks: unknown[], maxNodes: number): DocBlockSummary[] => {
|
||||||
|
const list: DocBlockSummary[] = [];
|
||||||
|
const queue: Array<{ block: unknown; depth: number }> = rootBlocks.map((b) => ({ block: b, depth: 0 }));
|
||||||
|
while (queue.length > 0 && list.length < maxNodes) {
|
||||||
|
const item = queue.shift();
|
||||||
|
if (!item) break;
|
||||||
|
const { block, depth } = item;
|
||||||
|
const id = String(getValue(block, "id") ?? "").trim();
|
||||||
|
const type = String(getValue(block, "type") ?? "").trim();
|
||||||
|
const childrenRaw = getValue(block, "children");
|
||||||
|
const children = Array.isArray(childrenRaw) ? childrenRaw : [];
|
||||||
|
const text = extractInlineText(block);
|
||||||
|
if (id) {
|
||||||
|
list.push({ id, type: type || "unknown", text, depth, childCount: children.length });
|
||||||
|
}
|
||||||
|
children.forEach((c) => queue.push({ block: c, depth: depth + 1 }));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findContainerById = (
|
||||||
|
blocks: unknown[],
|
||||||
|
targetId: string,
|
||||||
|
): { container: unknown[]; index: number } | null => {
|
||||||
|
const id = String(targetId || "").trim();
|
||||||
|
if (!id) return null;
|
||||||
|
for (let i = 0; i < blocks.length; i += 1) {
|
||||||
|
const b = blocks[i];
|
||||||
|
const bid = String(getValue(b, "id") ?? "").trim();
|
||||||
|
if (bid === id) return { container: blocks, index: i };
|
||||||
|
const childrenRaw = getValue(b, "children");
|
||||||
|
const children = Array.isArray(childrenRaw) ? childrenRaw : [];
|
||||||
|
const found = findContainerById(children, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTextContent = (text: string): unknown[] => [{ type: "text", text }];
|
||||||
|
|
||||||
|
const generateId = () => {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `bn_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildBlockFromSpec = (spec: DocBlockSpec): Record<string, unknown> => {
|
||||||
|
const type = spec.type;
|
||||||
|
const text = String(spec.text ?? "").trim();
|
||||||
|
const base: Record<string, unknown> = {
|
||||||
|
id: generateId(),
|
||||||
|
type,
|
||||||
|
props: {},
|
||||||
|
content: createTextContent(text),
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
if (type === "heading") {
|
||||||
|
const level = Number(spec.level ?? 2);
|
||||||
|
base.props = { level: Math.max(1, Math.min(5, Number.isFinite(level) ? Math.floor(level) : 2)) };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext) => {
|
||||||
|
const base = normalizeBlocks(ctx.baseBlocks);
|
||||||
|
if (base.length > 0) {
|
||||||
|
return { blocks: base, source: "client" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id,content")
|
||||||
|
.eq("id", ctx.documentId)
|
||||||
|
.eq("user_id", ctx.userId)
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取文档失败");
|
||||||
|
}
|
||||||
|
const content = isRecord(data) ? data.content : null;
|
||||||
|
const blocks = normalizeBlocks(content);
|
||||||
|
return { blocks, source: "db" as const };
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext, blocks: unknown[]) => {
|
||||||
|
const resp = (await (supabase
|
||||||
|
.from("documents")
|
||||||
|
.update({ content: blocks as unknown as Json })
|
||||||
|
.eq("id", ctx.documentId)
|
||||||
|
.eq("user_id", ctx.userId) as unknown as Promise<{ error: unknown }>)) ?? { error: null };
|
||||||
|
const error = resp.error;
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "保存文档失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDocServerTools = (args: {
|
||||||
|
supabase: DocSupabaseClient;
|
||||||
|
ctx: DocToolContext;
|
||||||
|
allowedToolIds: Set<string>;
|
||||||
|
}) => {
|
||||||
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
|
if (!args.allowedToolIds.has(toolId)) {
|
||||||
|
throw new Error(`工具未被允许:${toolId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolId === "doc_get") {
|
||||||
|
const maxNodesRaw = Number(toolArgs.maxBlocks ?? 80);
|
||||||
|
const maxBlocks = Math.max(10, Math.min(240, Number.isFinite(maxNodesRaw) ? Math.floor(maxNodesRaw) : 80));
|
||||||
|
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
||||||
|
const summary = walkSummaries(blocks, maxBlocks);
|
||||||
|
return { ok: true, source, totalTopLevelBlocks: blocks.length, blocks: summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolId === "doc_find") {
|
||||||
|
const query = String(toolArgs.query ?? "").trim();
|
||||||
|
if (!query) throw new Error("缺少 query");
|
||||||
|
const maxRaw = Number(toolArgs.maxResults ?? 8);
|
||||||
|
const maxResults = Math.max(1, Math.min(30, Number.isFinite(maxRaw) ? Math.floor(maxRaw) : 8));
|
||||||
|
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
||||||
|
const summary = walkSummaries(blocks, 400);
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const hits = summary.filter((x) => x.text.toLowerCase().includes(q)).slice(0, maxResults);
|
||||||
|
return { ok: true, source, query, results: hits };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolId === "doc_insert_blocks") {
|
||||||
|
const afterBlockId = String(toolArgs.afterBlockId ?? "").trim();
|
||||||
|
const beforeBlockId = String(toolArgs.beforeBlockId ?? "").trim();
|
||||||
|
const specsRaw = toolArgs.blocks;
|
||||||
|
if (!Array.isArray(specsRaw) || specsRaw.length === 0) throw new Error("缺少 blocks");
|
||||||
|
if (specsRaw.length > 20) throw new Error("blocks 过多(最多 20)");
|
||||||
|
|
||||||
|
const specs: DocBlockSpec[] = specsRaw.map((x) => {
|
||||||
|
const t = isRecord(x) ? String(x.type ?? "paragraph") : "paragraph";
|
||||||
|
const text = isRecord(x) ? String(x.text ?? "") : "";
|
||||||
|
const level = isRecord(x) ? Number(x.level ?? 2) : 2;
|
||||||
|
return { type: t === "heading" ? "heading" : "paragraph", text, level };
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = specs.map(buildBlockFromSpec);
|
||||||
|
|
||||||
|
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
||||||
|
const targetId = beforeBlockId || afterBlockId;
|
||||||
|
const found = targetId ? findContainerById(blocks, targetId) : null;
|
||||||
|
if (targetId && !found) {
|
||||||
|
throw new Error(`未找到 blockId:${targetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
blocks.push(...created);
|
||||||
|
} else {
|
||||||
|
const insertAt = beforeBlockId ? found.index : found.index + 1;
|
||||||
|
found.container.splice(insertAt, 0, ...created);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source,
|
||||||
|
inserted: created.map((b) => String(b.id ?? "")),
|
||||||
|
data: blocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolId === "doc_replace_range") {
|
||||||
|
const blockId = String(toolArgs.blockId ?? "").trim();
|
||||||
|
const text = String(toolArgs.text ?? "").trim();
|
||||||
|
if (!blockId) throw new Error("缺少 blockId");
|
||||||
|
if (!text) throw new Error("缺少 text");
|
||||||
|
const modeRaw = String(toolArgs.mode ?? "replace").trim();
|
||||||
|
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
|
||||||
|
|
||||||
|
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
||||||
|
const found = findContainerById(blocks, blockId);
|
||||||
|
if (!found) throw new Error(`未找到 blockId:${blockId}`);
|
||||||
|
const block = found.container[found.index];
|
||||||
|
if (!isRecord(block)) throw new Error(`block 数据异常:${blockId}`);
|
||||||
|
const prevText = extractInlineText(block);
|
||||||
|
const nextText = mode === "append" ? `${prevText}${text}` : mode === "prepend" ? `${text}${prevText}` : text;
|
||||||
|
found.container[found.index] = { ...block, content: createTextContent(nextText) };
|
||||||
|
|
||||||
|
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
||||||
|
return { ok: true, source, blockId, mode, data: blocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`未知工具:${toolId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { run };
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
type SupabaseRouteClient = {
|
||||||
|
from: (table: string) => any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocsSupabaseClient = SupabaseRouteClient;
|
||||||
|
|
||||||
|
export type DocsToolContext = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
const escapeIlike = (value: string) =>
|
||||||
|
// 注意:PostgREST 的 or(...) 语法里逗号/括号有特殊含义,这里一并转义,避免解析失败
|
||||||
|
value.replace(/[%_,()]/g, (m) => `\\${m}`);
|
||||||
|
|
||||||
|
const snippetAround = (text: string, q: string, maxLen: number) => {
|
||||||
|
const s = String(text || "");
|
||||||
|
const query = String(q || "").trim();
|
||||||
|
if (!s) return "";
|
||||||
|
const limit = Math.max(80, Math.min(2000, Math.floor(maxLen || 0) || 320));
|
||||||
|
if (!query) return s.slice(0, limit);
|
||||||
|
const lower = s.toLowerCase();
|
||||||
|
const ql = query.toLowerCase();
|
||||||
|
const idx = lower.indexOf(ql);
|
||||||
|
if (idx === -1) return s.slice(0, limit);
|
||||||
|
const start = Math.max(0, idx - Math.floor(limit / 3));
|
||||||
|
const end = Math.min(s.length, start + limit);
|
||||||
|
const head = start > 0 ? "…" : "";
|
||||||
|
const tail = end < s.length ? "…" : "";
|
||||||
|
return `${head}${s.slice(start, end)}${tail}`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadWorkspaceIds = async (supabase: DocsSupabaseClient, userId: string) => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("workspace_members")
|
||||||
|
.select("workspace_id")
|
||||||
|
.eq("user_id", userId);
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取工作区失败");
|
||||||
|
}
|
||||||
|
const rows = Array.isArray(data) ? data : [];
|
||||||
|
return rows.map((r) => String((r as any)?.workspace_id ?? "")).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDocsServerTools = (args: {
|
||||||
|
supabase: DocsSupabaseClient;
|
||||||
|
ctx: DocsToolContext;
|
||||||
|
allowedToolIds: Set<string>;
|
||||||
|
}) => {
|
||||||
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
|
if (!args.allowedToolIds.has(toolId)) {
|
||||||
|
throw new Error(`工具未被允许:${toolId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolId === "docs_search") {
|
||||||
|
const query = String(toolArgs.query ?? "").trim();
|
||||||
|
if (!query) throw new Error("缺少 query");
|
||||||
|
const limitRaw = Number(toolArgs.limit ?? 12);
|
||||||
|
const limit = Math.max(1, Math.min(30, Number.isFinite(limitRaw) ? Math.floor(limitRaw) : 12));
|
||||||
|
const workspaceId = String(toolArgs.workspaceId ?? "").trim() || null;
|
||||||
|
const includeDeleted = Boolean(toolArgs.includeDeleted ?? false);
|
||||||
|
|
||||||
|
const wsIds = await loadWorkspaceIds(args.supabase, args.ctx.userId);
|
||||||
|
const wsFilter = workspaceId ? [workspaceId] : wsIds;
|
||||||
|
if (wsFilter.length === 0) return { ok: true, query, results: [] };
|
||||||
|
|
||||||
|
const pattern = `%${escapeIlike(query)}%`;
|
||||||
|
const q = args.supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id,title,workspace_id,parent_id,updated_at,raw_text,deleted_at")
|
||||||
|
.in("workspace_id", wsFilter)
|
||||||
|
.or(`title.ilike.${pattern},raw_text.ilike.${pattern}`)
|
||||||
|
.order("updated_at", { ascending: false })
|
||||||
|
.limit(limit);
|
||||||
|
if (!includeDeleted) q.is("deleted_at", null);
|
||||||
|
|
||||||
|
const { data, error } = await q;
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "搜索文档失败");
|
||||||
|
}
|
||||||
|
const rows = Array.isArray(data) ? data : [];
|
||||||
|
const results = rows.map((r) => {
|
||||||
|
const id = String((r as any)?.id ?? "");
|
||||||
|
const title = String((r as any)?.title ?? "");
|
||||||
|
const raw = String((r as any)?.raw_text ?? "");
|
||||||
|
const updatedAt = (r as any)?.updated_at ?? null;
|
||||||
|
const wid = String((r as any)?.workspace_id ?? "");
|
||||||
|
const pid = (r as any)?.parent_id ? String((r as any)?.parent_id) : null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
workspaceId: wid,
|
||||||
|
parentId: pid,
|
||||||
|
updatedAt,
|
||||||
|
snippet: snippetAround(raw, query, 360),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, query, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolId === "docs_read") {
|
||||||
|
const documentId = String(toolArgs.documentId ?? "").trim();
|
||||||
|
if (!documentId) throw new Error("缺少 documentId");
|
||||||
|
const maxCharsRaw = Number(toolArgs.maxChars ?? 2500);
|
||||||
|
const maxChars = Math.max(200, Math.min(20_000, Number.isFinite(maxCharsRaw) ? Math.floor(maxCharsRaw) : 2500));
|
||||||
|
const includeContent = Boolean(toolArgs.includeContent ?? false);
|
||||||
|
|
||||||
|
const { data, error } = await args.supabase
|
||||||
|
.from("documents")
|
||||||
|
.select(includeContent ? "id,title,raw_text,content,workspace_id,parent_id,updated_at" : "id,title,raw_text,workspace_id,parent_id,updated_at")
|
||||||
|
.eq("id", documentId)
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取文档失败");
|
||||||
|
}
|
||||||
|
const title = isRecord(data) ? String(data.title ?? "") : "";
|
||||||
|
const rawText = isRecord(data) ? String(data.raw_text ?? "") : "";
|
||||||
|
const trimmed = rawText.length > maxChars ? `${rawText.slice(0, maxChars)}…` : rawText;
|
||||||
|
const workspaceId = isRecord(data) ? String(data.workspace_id ?? "") : "";
|
||||||
|
const parentId = isRecord(data) && data.parent_id ? String(data.parent_id) : null;
|
||||||
|
const updatedAt = isRecord(data) ? (data as any).updated_at ?? null : null;
|
||||||
|
const content = includeContent && isRecord(data) ? (data as any).content ?? null : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
documentId,
|
||||||
|
title,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
updatedAt,
|
||||||
|
rawTextLength: rawText.length,
|
||||||
|
rawText: trimmed,
|
||||||
|
...(includeContent ? { content } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`未知工具:${toolId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { run };
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
type SupabaseRouteClient = {
|
||||||
|
from: (table: string) => any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaSupabaseClient = SupabaseRouteClient;
|
||||||
|
|
||||||
|
export type MediaToolContext = {
|
||||||
|
userId: string;
|
||||||
|
attachments?: Array<{ id: string; title: string; fileUrl: string; mimeType?: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
type ResolvedAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||||
|
|
||||||
|
const resolveAttachment = (ctx: MediaToolContext, ref: string): ResolvedAttachment | null => {
|
||||||
|
const s = String(ref || "").trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const list = Array.isArray(ctx.attachments) ? ctx.attachments : [];
|
||||||
|
const byId = list.find((a) => String(a.id) === s);
|
||||||
|
if (byId) return { id: String(byId.id), title: String(byId.title || byId.id), fileUrl: String(byId.fileUrl || ""), mimeType: byId.mimeType ?? null };
|
||||||
|
const exactTitle = list.find((a) => String(a.title) === s);
|
||||||
|
if (exactTitle) return { id: String(exactTitle.id), title: String(exactTitle.title || exactTitle.id), fileUrl: String(exactTitle.fileUrl || ""), mimeType: exactTitle.mimeType ?? null };
|
||||||
|
const fuzzy = list.find((a) => String(a.title || "").includes(s) || String(a.fileUrl || "").includes(s));
|
||||||
|
if (fuzzy) return { id: String(fuzzy.id), title: String(fuzzy.title || fuzzy.id), fileUrl: String(fuzzy.fileUrl || ""), mimeType: fuzzy.mimeType ?? null };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (obj: unknown, key: string) => (isRecord(obj) ? obj[key] : undefined);
|
||||||
|
|
||||||
|
export const createMediaServerTools = (args: {
|
||||||
|
supabase: MediaSupabaseClient;
|
||||||
|
ctx: MediaToolContext;
|
||||||
|
allowedToolIds: Set<string>;
|
||||||
|
}) => {
|
||||||
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
|
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
||||||
|
|
||||||
|
if (toolId === "image_read") {
|
||||||
|
const assetId = String(toolArgs.assetId ?? "").trim();
|
||||||
|
const fileUrl = String(toolArgs.fileUrl ?? "").trim();
|
||||||
|
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
||||||
|
|
||||||
|
const resolved = attachmentRef ? resolveAttachment(args.ctx, attachmentRef) : null;
|
||||||
|
const targetAssetId = assetId || resolved?.id || "";
|
||||||
|
const targetUrl = fileUrl || resolved?.fileUrl || "";
|
||||||
|
|
||||||
|
let row: unknown = null;
|
||||||
|
if (targetAssetId) {
|
||||||
|
const { data, error } = await args.supabase
|
||||||
|
.from("media_assets")
|
||||||
|
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
||||||
|
.eq("id", targetAssetId)
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
||||||
|
}
|
||||||
|
row = data;
|
||||||
|
} else if (targetUrl) {
|
||||||
|
const { data, error } = await args.supabase
|
||||||
|
.from("media_assets")
|
||||||
|
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
||||||
|
.eq("file_url", targetUrl)
|
||||||
|
.order("updated_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
||||||
|
}
|
||||||
|
row = data;
|
||||||
|
} else {
|
||||||
|
throw new Error("缺少 assetId / fileUrl / attachmentRef");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) return { ok: true, found: false };
|
||||||
|
|
||||||
|
const ocrText = String(pick(row, "ocr_text") ?? "");
|
||||||
|
const ocrStatus = String(pick(row, "ocr_status") ?? "");
|
||||||
|
const mimeType = String(pick(row, "mime_type") ?? "");
|
||||||
|
const result = {
|
||||||
|
ok: true,
|
||||||
|
found: true,
|
||||||
|
asset: {
|
||||||
|
id: String(pick(row, "id") ?? ""),
|
||||||
|
fileName: String(pick(row, "file_name") ?? ""),
|
||||||
|
fileUrl: String(pick(row, "file_url") ?? ""),
|
||||||
|
mimeType,
|
||||||
|
storagePath: pick(row, "storage_path") ? String(pick(row, "storage_path")) : null,
|
||||||
|
bucket: pick(row, "bucket") ? String(pick(row, "bucket")) : null,
|
||||||
|
documentId: pick(row, "document_id") ? String(pick(row, "document_id")) : null,
|
||||||
|
workspaceId: pick(row, "workspace_id") ? String(pick(row, "workspace_id")) : null,
|
||||||
|
deletedAt: pick(row, "deleted_at") ?? null,
|
||||||
|
purgedAt: pick(row, "purged_at") ?? null,
|
||||||
|
updatedAt: pick(row, "updated_at") ?? null,
|
||||||
|
},
|
||||||
|
ocrStatus,
|
||||||
|
ocrText,
|
||||||
|
hasOcrText: Boolean(ocrText.trim()),
|
||||||
|
note: ocrText.trim()
|
||||||
|
? "已返回 ocr_text。"
|
||||||
|
: "该图片暂未生成 ocr_text(可等待后台 OCR,或后续再补一个 image_ocr 写工具)。",
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`未知工具:${toolId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { run };
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
import type { OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
|
||||||
|
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||||
|
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||||
|
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||||
|
import { searchSearxng, type SearxResult } from "../searchWeb";
|
||||||
|
|
||||||
|
export type MindmapToolContext = {
|
||||||
|
documentId: string;
|
||||||
|
mindmapId: string;
|
||||||
|
userId: string;
|
||||||
|
// 仅用于生成更贴近当前选中节点的行为(可选)
|
||||||
|
selectedUids?: string[];
|
||||||
|
// 来自前端(@ 选择/上传)的附件列表,优先使用(避免额外查询)
|
||||||
|
attachments?: Array<{ id: string; title: string; fileUrl: string; mimeType?: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SupabaseRouteClient = {
|
||||||
|
from: (table: string) => {
|
||||||
|
select: (columns: string) => SupabaseQuery;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type SupabaseQuery = {
|
||||||
|
eq: (column: string, value: unknown) => SupabaseQuery;
|
||||||
|
is: (column: string, value: unknown) => SupabaseQuery;
|
||||||
|
maybeSingle: () => Promise<{ data: unknown; error: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MindmapSupabaseClient = SupabaseRouteClient;
|
||||||
|
|
||||||
|
const defaultMindmapData: MindmapTreeNode = { data: { text: "中心主题" }, children: [] };
|
||||||
|
|
||||||
|
const safeUrlOrNull = (value: unknown) => {
|
||||||
|
const s = typeof value === "string" ? value.trim() : "";
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findNodeByUid = (root: MindmapTreeNode, uid: string): MindmapTreeNode | null => {
|
||||||
|
const target = String(uid || "");
|
||||||
|
if (!target) return null;
|
||||||
|
const walk = (n: MindmapTreeNode): MindmapTreeNode | null => {
|
||||||
|
if (String(n?.data?.uid || "") === target) return n;
|
||||||
|
const children = Array.isArray(n.children) ? n.children : [];
|
||||||
|
for (const c of children) {
|
||||||
|
const hit = walk(c);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return walk(root);
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkSummaries = (root: MindmapTreeNode, maxNodes = 120) => {
|
||||||
|
const list: Array<{ uid: string; text: string; parentUid: string | null; depth: number; childCount: number }> = [];
|
||||||
|
const queue: Array<{ node: MindmapTreeNode; parentUid: string | null; depth: number }> = [{ node: root, parentUid: null, depth: 0 }];
|
||||||
|
while (queue.length && list.length < maxNodes) {
|
||||||
|
const { node, parentUid, depth } = queue.shift()!;
|
||||||
|
const uid = String(node?.data?.uid || "");
|
||||||
|
const text = String(node?.data?.text ?? "").replace(/<[^>]+>/g, "").trim();
|
||||||
|
const children = Array.isArray(node?.children) ? node.children : [];
|
||||||
|
if (uid) list.push({ uid, text, parentUid, depth, childCount: children.length });
|
||||||
|
children.forEach((c) => queue.push({ node: c, parentUid: uid || parentUid, depth: depth + 1 }));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
|
const summarizeSubtree = (node: MindmapTreeNode, depthLimit = 2, maxNodes = 60) => {
|
||||||
|
const list: Array<{ uid: string; text: string; depth: number; childCount: number }> = [];
|
||||||
|
const queue: Array<{ node: MindmapTreeNode; depth: number }> = [{ node, depth: 0 }];
|
||||||
|
while (queue.length && list.length < maxNodes) {
|
||||||
|
const { node: n, depth } = queue.shift()!;
|
||||||
|
const uid = String(n?.data?.uid || "");
|
||||||
|
const text = String(n?.data?.text ?? "").replace(/<[^>]+>/g, "").trim();
|
||||||
|
const children = Array.isArray(n.children) ? n.children : [];
|
||||||
|
if (uid) list.push({ uid, text, depth, childCount: children.length });
|
||||||
|
if (depth < depthLimit) children.forEach((c) => queue.push({ node: c, depth: depth + 1 }));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refsFromSearx = (r: SearxResult): NodeRef[] => [
|
||||||
|
{ kind: "url", fileUrl: r.url, title: r.title, snippet: r.snippet ? r.snippet.slice(0, 300) : undefined },
|
||||||
|
];
|
||||||
|
|
||||||
|
const coerceMimeKind = (mimeType: string) => {
|
||||||
|
const m = String(mimeType || "").toLowerCase();
|
||||||
|
if (m.includes("pdf")) return "pdf" as const;
|
||||||
|
if (m.includes("word") || m.includes("docx")) return "docx" as const;
|
||||||
|
if (m.includes("presentation") || m.includes("ppt")) return "pptx" as const;
|
||||||
|
return "url" as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||||
|
|
||||||
|
const resolveAttachmentFromContext = (ctx: MindmapToolContext, ref: string): ResolvedAttachment | null => {
|
||||||
|
const s = String(ref || "").trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const list = Array.isArray(ctx.attachments) ? ctx.attachments : [];
|
||||||
|
const byId = list.find((a) => String(a.id) === s);
|
||||||
|
if (byId) return { id: String(byId.id), title: String(byId.title || byId.id), fileUrl: String(byId.fileUrl || ""), mimeType: byId.mimeType ?? null };
|
||||||
|
const exactTitle = list.find((a) => String(a.title) === s);
|
||||||
|
if (exactTitle)
|
||||||
|
return { id: String(exactTitle.id), title: String(exactTitle.title || exactTitle.id), fileUrl: String(exactTitle.fileUrl || ""), mimeType: exactTitle.mimeType ?? null };
|
||||||
|
const fuzzy = list.find((a) => String(a.title || "").includes(s) || String(a.fileUrl || "").includes(s));
|
||||||
|
if (fuzzy) return { id: String(fuzzy.id), title: String(fuzzy.title || fuzzy.id), fileUrl: String(fuzzy.fileUrl || ""), mimeType: fuzzy.mimeType ?? null };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeRefsUnique = (a: NodeRef[], b: NodeRef[]) => {
|
||||||
|
const keyOf = (r: NodeRef) => `${r.kind}|${r.assetId ?? ""}|${r.fileUrl ?? ""}|${r.page ?? ""}|${r.slide ?? ""}`;
|
||||||
|
const map = new Map<string, NodeRef>();
|
||||||
|
for (const r of [...a, ...b]) {
|
||||||
|
if (!r || typeof r !== "object") continue;
|
||||||
|
map.set(keyOf(r), r);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeAddChildOps = (args: {
|
||||||
|
targetUid: string;
|
||||||
|
currentChildren: string[];
|
||||||
|
ops: MindmapOp[];
|
||||||
|
searxResults: SearxResult[];
|
||||||
|
}) => {
|
||||||
|
const existed = new Set(args.currentChildren);
|
||||||
|
const fixed: MindmapOp[] = [];
|
||||||
|
for (const raw of args.ops) {
|
||||||
|
if (!raw || typeof raw !== "object" || (raw as { op?: string }).op !== "addChild") continue;
|
||||||
|
const parentUid = String((raw as { parentUid?: string }).parentUid ?? "");
|
||||||
|
if (parentUid !== args.targetUid) continue;
|
||||||
|
const node = (raw as { node?: Record<string, unknown> }).node ?? {};
|
||||||
|
const textVal = String(node.text ?? "").trim();
|
||||||
|
if (!textVal) continue;
|
||||||
|
if (existed.has(textVal)) continue;
|
||||||
|
existed.add(textVal);
|
||||||
|
|
||||||
|
const href = safeUrlOrNull(node.hyperlink) ?? safeUrlOrNull(node.url) ?? null;
|
||||||
|
const refs = Array.isArray(node.refs) ? (node.refs as NodeRef[]) : [];
|
||||||
|
const hasRef = refs.some((x) => {
|
||||||
|
if (!x || x.kind !== "url") return false;
|
||||||
|
const fileUrl = typeof x.fileUrl === "string" ? x.fileUrl : "";
|
||||||
|
return Boolean(safeUrlOrNull(fileUrl));
|
||||||
|
});
|
||||||
|
const finalRefs = hasRef ? refs : args.searxResults[0] ? refsFromSearx(args.searxResults[0]) : [];
|
||||||
|
|
||||||
|
let finalText = textVal;
|
||||||
|
if (!finalRefs.length && !/待核验/.test(finalText)) finalText = `${finalText}(待核验)`;
|
||||||
|
|
||||||
|
fixed.push({
|
||||||
|
op: "addChild",
|
||||||
|
parentUid: args.targetUid,
|
||||||
|
node: {
|
||||||
|
text: finalText,
|
||||||
|
...(href ? { hyperlink: href } : {}),
|
||||||
|
...(finalRefs.length ? { refs: finalRefs } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (fixed.length >= 6) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fixed.length && args.searxResults.length) {
|
||||||
|
for (const r of args.searxResults.slice(0, 6)) {
|
||||||
|
const title = String(r.title || "").trim();
|
||||||
|
const url = safeUrlOrNull(r.url);
|
||||||
|
if (!title || !url) continue;
|
||||||
|
if (existed.has(title)) continue;
|
||||||
|
existed.add(title);
|
||||||
|
fixed.push({ op: "addChild", parentUid: args.targetUid, node: { text: title, hyperlink: url, refs: refsFromSearx(r) } });
|
||||||
|
if (fixed.length >= 6) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixed.length < 3) {
|
||||||
|
const base = args.currentChildren[0] ? "补完" : "补完";
|
||||||
|
for (let i = fixed.length + 1; i <= 3; i += 1) {
|
||||||
|
fixed.push({ op: "addChild", parentUid: args.targetUid, node: { text: `${base}(待核验)${i}` } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixed.slice(0, 6);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMindmapServerTools = (args: {
|
||||||
|
supabase: SupabaseRouteClient;
|
||||||
|
ctx: MindmapToolContext;
|
||||||
|
cfg: OpenAiCompatibleChatOptions;
|
||||||
|
allowedToolIds: Set<string>;
|
||||||
|
}) => {
|
||||||
|
const loadDoc = async () => {
|
||||||
|
const { documentId, userId } = args.ctx;
|
||||||
|
const query = args.supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id,title,workspace_id,mindmap_data")
|
||||||
|
.eq("id", documentId)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
const { data, error } = await query.maybeSingle();
|
||||||
|
if (error && typeof error === "object" && "message" in (error as Record<string, unknown>)) {
|
||||||
|
throw new Error(String((error as Record<string, unknown>).message ?? "读取页面失败"));
|
||||||
|
}
|
||||||
|
if (error) throw new Error("读取页面失败");
|
||||||
|
if (!data) throw new Error("页面不存在");
|
||||||
|
return data as { id: string; title: string | null; workspace_id: string | null; mindmap_data: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMindmap = async () => {
|
||||||
|
const doc = await loadDoc();
|
||||||
|
const local = await readMindmapLocal(args.ctx.documentId, args.ctx.mindmapId);
|
||||||
|
const base = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode;
|
||||||
|
ensureMindmapUids(base);
|
||||||
|
return { doc, base };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_get = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const maxNodes = Number(toolArgs.maxNodes ?? 120);
|
||||||
|
const { base } = await loadMindmap();
|
||||||
|
const list = walkSummaries(base, Number.isFinite(maxNodes) ? Math.max(10, Math.min(300, Math.floor(maxNodes))) : 120);
|
||||||
|
return { ok: true, documentId: args.ctx.documentId, mindmapId: args.ctx.mindmapId, nodes: list };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_get_subtree = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const uid = String(toolArgs.uid ?? "").trim();
|
||||||
|
if (!uid) throw new Error("缺少 uid");
|
||||||
|
const depth = Number(toolArgs.depth ?? 2);
|
||||||
|
const maxNodes = Number(toolArgs.maxNodes ?? 60);
|
||||||
|
const { base } = await loadMindmap();
|
||||||
|
const hit = findNodeByUid(base, uid);
|
||||||
|
if (!hit) throw new Error(`未找到 uid=${uid}`);
|
||||||
|
const list = summarizeSubtree(
|
||||||
|
hit,
|
||||||
|
Number.isFinite(depth) ? Math.max(0, Math.min(6, Math.floor(depth))) : 2,
|
||||||
|
Number.isFinite(maxNodes) ? Math.max(5, Math.min(200, Math.floor(maxNodes))) : 60,
|
||||||
|
);
|
||||||
|
return { ok: true, uid, nodes: list };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_apply_ops = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const ops = (toolArgs.ops ?? []) as unknown;
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
if (!Array.isArray(ops) || ops.length === 0) throw new Error("缺少 ops");
|
||||||
|
if (ops.length > 80) throw new Error("ops 过多(最多 80)");
|
||||||
|
|
||||||
|
const normalizeOp = (raw: unknown): MindmapOp | null => {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const op = String(o.op ?? "").trim();
|
||||||
|
|
||||||
|
// 兼容历史/模型常见写法:update_node/add_child/set_link/append_note/delete_node
|
||||||
|
if (op === "update_node" || op === "updateNode") {
|
||||||
|
const uid = String(o.uid ?? o.id ?? "").trim();
|
||||||
|
const text = String(o.text ?? o.value ?? "").trim();
|
||||||
|
if (!uid || !text) return null;
|
||||||
|
return { op: "updateText", uid, text };
|
||||||
|
}
|
||||||
|
if (op === "add_child" || op === "addChild") {
|
||||||
|
const parentUid = String(o.parentUid ?? o.parent_uid ?? "").trim();
|
||||||
|
const node = (o.node && typeof o.node === "object" ? (o.node as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||||
|
const text = String(node.text ?? "").trim();
|
||||||
|
if (!parentUid || !text) return null;
|
||||||
|
const hyperlink = safeUrlOrNull(node.hyperlink) ?? null;
|
||||||
|
const note = String(node.note ?? "").trim() || null;
|
||||||
|
const refs = Array.isArray(node.refs) ? (node.refs as NodeRef[]) : [];
|
||||||
|
return { op: "addChild", parentUid, node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) } };
|
||||||
|
}
|
||||||
|
if (op === "set_link" || op === "set_hyperlink" || op === "setHyperlink") {
|
||||||
|
const uid = String(o.uid ?? o.id ?? "").trim();
|
||||||
|
const hyperlinkRaw = o.hyperlink ?? o.url ?? null;
|
||||||
|
const hyperlink = hyperlinkRaw === null ? null : safeUrlOrNull(hyperlinkRaw);
|
||||||
|
if (!uid) return null;
|
||||||
|
return { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
|
||||||
|
}
|
||||||
|
if (op === "append_note" || op === "appendNote") {
|
||||||
|
const uid = String(o.uid ?? o.id ?? "").trim();
|
||||||
|
const markdown = String(o.markdown ?? o.note ?? "").trim();
|
||||||
|
if (!uid || !markdown) return null;
|
||||||
|
return { op: "appendNote", uid, markdown };
|
||||||
|
}
|
||||||
|
if (op === "set_refs" || op === "setRefs") {
|
||||||
|
const uid = String(o.uid ?? o.id ?? "").trim();
|
||||||
|
const refs = Array.isArray(o.refs) ? (o.refs as NodeRef[]) : [];
|
||||||
|
if (!uid || refs.length === 0) return null;
|
||||||
|
return { op: "setRefs", uid, refs };
|
||||||
|
}
|
||||||
|
if (op === "delete_node" || op === "deleteNode") {
|
||||||
|
const uid = String(o.uid ?? o.id ?? "").trim();
|
||||||
|
if (!uid) return null;
|
||||||
|
return { op: "deleteNode", uid };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原生协议(MindmapOp)
|
||||||
|
if (
|
||||||
|
op === "addChild" ||
|
||||||
|
op === "addSiblingAfter" ||
|
||||||
|
op === "updateText" ||
|
||||||
|
op === "setHyperlink" ||
|
||||||
|
op === "setRefs" ||
|
||||||
|
op === "appendNote" ||
|
||||||
|
op === "deleteNode"
|
||||||
|
) {
|
||||||
|
return o as unknown as MindmapOp;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = ops.map((x) => normalizeOp(x)).filter(Boolean) as MindmapOp[];
|
||||||
|
if (normalized.length === 0) throw new Error("ops 无有效操作(请使用 MindmapOp 协议或已支持的别名)");
|
||||||
|
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_add_child = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const parentUid = String(toolArgs.parentUid ?? "").trim();
|
||||||
|
const text = String(toolArgs.text ?? "").trim();
|
||||||
|
const hyperlink = safeUrlOrNull(toolArgs.hyperlink) ?? null;
|
||||||
|
const note = String(toolArgs.note ?? "").trim() || null;
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
const refs = Array.isArray(toolArgs.refs) ? (toolArgs.refs as NodeRef[]) : [];
|
||||||
|
if (!parentUid) throw new Error("缺少 parentUid");
|
||||||
|
if (!text) throw new Error("缺少 text");
|
||||||
|
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const op: MindmapOp = {
|
||||||
|
op: "addChild",
|
||||||
|
parentUid,
|
||||||
|
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||||
|
};
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_add_sibling_after = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const targetUid = String(toolArgs.targetUid ?? "").trim();
|
||||||
|
const text = String(toolArgs.text ?? "").trim();
|
||||||
|
const hyperlink = safeUrlOrNull(toolArgs.hyperlink) ?? null;
|
||||||
|
const note = String(toolArgs.note ?? "").trim() || null;
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
const refs = Array.isArray(toolArgs.refs) ? (toolArgs.refs as NodeRef[]) : [];
|
||||||
|
if (!targetUid) throw new Error("缺少 targetUid");
|
||||||
|
if (!text) throw new Error("缺少 text");
|
||||||
|
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const op: MindmapOp = {
|
||||||
|
op: "addSiblingAfter",
|
||||||
|
targetUid,
|
||||||
|
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||||
|
};
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_update_node_text = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const uid = String(toolArgs.uid ?? "").trim();
|
||||||
|
const text = String(toolArgs.text ?? "").trim();
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
if (!uid) throw new Error("缺少 uid");
|
||||||
|
if (!text) throw new Error("缺少 text");
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const op: MindmapOp = { op: "updateText", uid, text };
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_set_hyperlink = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const uid = String(toolArgs.uid ?? "").trim();
|
||||||
|
const hyperlinkRaw = toolArgs.hyperlink;
|
||||||
|
const hyperlink = hyperlinkRaw === null ? null : safeUrlOrNull(hyperlinkRaw);
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
if (!uid) throw new Error("缺少 uid");
|
||||||
|
if (hyperlinkRaw !== null && hyperlinkRaw !== undefined && !hyperlink) {
|
||||||
|
throw new Error("hyperlink 必须是 http(s) URL 或 null");
|
||||||
|
}
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const op: MindmapOp = { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_append_note = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const uid = String(toolArgs.uid ?? "").trim();
|
||||||
|
const markdown = String(toolArgs.markdown ?? "").trim();
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
if (!uid) throw new Error("缺少 uid");
|
||||||
|
if (!markdown) throw new Error("缺少 markdown");
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_set_refs = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const uid = String(toolArgs.uid ?? "").trim();
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
const refs = Array.isArray(toolArgs.refs) ? (toolArgs.refs as NodeRef[]) : [];
|
||||||
|
if (!uid) throw new Error("缺少 uid");
|
||||||
|
if (!refs.length) throw new Error("缺少 refs");
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const op: MindmapOp = { op: "setRefs", uid, refs };
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_delete_node = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const uid = String(toolArgs.uid ?? "").trim();
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
if (!uid) throw new Error("缺少 uid");
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const op: MindmapOp = { op: "deleteNode", uid };
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_add_attachment_ref = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const uid = String(toolArgs.uid ?? "").trim();
|
||||||
|
const attachmentId = String(toolArgs.attachmentId ?? toolArgs.attachmentRef ?? "").trim();
|
||||||
|
const fileUrlDirect = String(toolArgs.fileUrl ?? "").trim();
|
||||||
|
const mode = String(toolArgs.mode ?? "append").trim() as "append" | "replace";
|
||||||
|
const page = Number(toolArgs.page ?? 0);
|
||||||
|
const slide = Number(toolArgs.slide ?? 0);
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
const snippet = String(toolArgs.snippet ?? "").trim() || null;
|
||||||
|
if (!uid) throw new Error("缺少 uid");
|
||||||
|
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
||||||
|
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const node = findNodeByUid(base, uid);
|
||||||
|
if (!node) throw new Error(`未找到 uid=${uid}`);
|
||||||
|
|
||||||
|
let resolved: ResolvedAttachment | null = null;
|
||||||
|
if (attachmentId) resolved = resolveAttachmentFromContext(args.ctx, attachmentId);
|
||||||
|
|
||||||
|
if (!resolved && attachmentId) {
|
||||||
|
const workspaceId = String((doc as any).workspace_id ?? "").trim();
|
||||||
|
if (!workspaceId) throw new Error("缺少 workspace_id,无法解析附件");
|
||||||
|
const query = args.supabase
|
||||||
|
.from("media_assets")
|
||||||
|
.select("id,file_url,file_name,mime_type,document_id,workspace_id,deleted_at")
|
||||||
|
.eq("id", attachmentId)
|
||||||
|
.eq("document_id", args.ctx.documentId)
|
||||||
|
.eq("workspace_id", workspaceId)
|
||||||
|
.is("deleted_at", null);
|
||||||
|
const { data, error } = await query.maybeSingle();
|
||||||
|
if (error) throw new Error("查询附件失败");
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
const row = data as Record<string, unknown>;
|
||||||
|
resolved = {
|
||||||
|
id: String(row.id ?? attachmentId),
|
||||||
|
title: String(row.file_name ?? row.id ?? attachmentId),
|
||||||
|
fileUrl: String(row.file_url ?? ""),
|
||||||
|
mimeType: (row.mime_type as string | null | undefined) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUrl = (resolved?.fileUrl || fileUrlDirect || "").trim();
|
||||||
|
const finalTitle = String(resolved?.title || toolArgs.title || attachmentId || "附件").trim();
|
||||||
|
if (!finalUrl) throw new Error("附件缺少 fileUrl");
|
||||||
|
|
||||||
|
const kindOverride = String(toolArgs.kind ?? "").trim();
|
||||||
|
const kind =
|
||||||
|
kindOverride === "pdf" || kindOverride === "docx" || kindOverride === "pptx" || kindOverride === "url"
|
||||||
|
? (kindOverride as NodeRef["kind"])
|
||||||
|
: resolved?.mimeType
|
||||||
|
? coerceMimeKind(resolved.mimeType)
|
||||||
|
: ("url" as const);
|
||||||
|
|
||||||
|
const ref: NodeRef = {
|
||||||
|
kind,
|
||||||
|
...(attachmentId ? { assetId: attachmentId } : {}),
|
||||||
|
fileUrl: finalUrl,
|
||||||
|
...(Number.isFinite(page) && page > 0 ? { page: Math.floor(page) } : {}),
|
||||||
|
...(Number.isFinite(slide) && slide > 0 ? { slide: Math.floor(slide) } : {}),
|
||||||
|
...(finalTitle ? { title: finalTitle } : {}),
|
||||||
|
...(snippet ? { snippet } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevRefs = Array.isArray(node.data?.refs) ? (node.data.refs as NodeRef[]) : [];
|
||||||
|
const nextRefs = mode === "replace" ? [ref] : mergeRefsUnique(prevRefs, [ref]);
|
||||||
|
const op: MindmapOp = { op: "setRefs", uid, refs: nextRefs };
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_add_attachment_child = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const parentUid = String(toolArgs.parentUid ?? "").trim();
|
||||||
|
const attachmentId = String(toolArgs.attachmentId ?? toolArgs.attachmentRef ?? "").trim();
|
||||||
|
const fileUrlDirect = String(toolArgs.fileUrl ?? "").trim();
|
||||||
|
const titleOverride = String(toolArgs.title ?? "").trim();
|
||||||
|
const textOverride = String(toolArgs.text ?? "").trim();
|
||||||
|
const note = String(toolArgs.note ?? "").trim() || null;
|
||||||
|
const page = Number(toolArgs.page ?? 0);
|
||||||
|
const slide = Number(toolArgs.slide ?? 0);
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
if (!parentUid) throw new Error("缺少 parentUid");
|
||||||
|
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
||||||
|
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const resolved = attachmentId ? resolveAttachmentFromContext(args.ctx, attachmentId) : null;
|
||||||
|
const finalUrl = String(resolved?.fileUrl || fileUrlDirect || "").trim();
|
||||||
|
const finalTitle = String(titleOverride || resolved?.title || attachmentId || "附件").trim();
|
||||||
|
if (!finalUrl) throw new Error("附件缺少 fileUrl");
|
||||||
|
|
||||||
|
const kindOverride = String(toolArgs.kind ?? "").trim();
|
||||||
|
const kind =
|
||||||
|
kindOverride === "pdf" || kindOverride === "docx" || kindOverride === "pptx" || kindOverride === "url"
|
||||||
|
? (kindOverride as NodeRef["kind"])
|
||||||
|
: resolved?.mimeType
|
||||||
|
? coerceMimeKind(resolved.mimeType)
|
||||||
|
: ("url" as const);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(textOverride || `附件:${finalTitle}`);
|
||||||
|
if (Number.isFinite(page) && page > 0) parts.push(`p.${Math.floor(page)}`);
|
||||||
|
if (Number.isFinite(slide) && slide > 0) parts.push(`slide ${Math.floor(slide)}`);
|
||||||
|
const text = parts.join(" ");
|
||||||
|
|
||||||
|
const ref: NodeRef = {
|
||||||
|
kind,
|
||||||
|
...(attachmentId ? { assetId: attachmentId } : {}),
|
||||||
|
fileUrl: finalUrl,
|
||||||
|
...(Number.isFinite(page) && page > 0 ? { page: Math.floor(page) } : {}),
|
||||||
|
...(Number.isFinite(slide) && slide > 0 ? { slide: Math.floor(slide) } : {}),
|
||||||
|
...(finalTitle ? { title: finalTitle } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hyperlink = safeUrlOrNull(finalUrl) ?? null;
|
||||||
|
const op: MindmapOp = {
|
||||||
|
op: "addChild",
|
||||||
|
parentUid,
|
||||||
|
node: { text, ...(hyperlink ? { hyperlink } : {}), refs: [ref], ...(note ? { note } : {}) },
|
||||||
|
};
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_add_image_child = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const parentUid = String(toolArgs.parentUid ?? "").trim();
|
||||||
|
const imageRef = String(toolArgs.imageRef ?? toolArgs.imageId ?? toolArgs.imageUrl ?? "").trim();
|
||||||
|
const alt = String(toolArgs.alt ?? "").trim() || "image";
|
||||||
|
const caption = String(toolArgs.caption ?? "").trim() || "";
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
if (!parentUid) throw new Error("缺少 parentUid");
|
||||||
|
if (!imageRef) throw new Error("缺少 imageRef(可用 attachmentId 或图片 URL)");
|
||||||
|
|
||||||
|
const resolved = resolveAttachmentFromContext(args.ctx, imageRef);
|
||||||
|
const url = String(resolved?.fileUrl || imageRef).trim();
|
||||||
|
const title = String(resolved?.title || caption || "图片").trim();
|
||||||
|
if (!url) throw new Error("图片缺少 URL");
|
||||||
|
|
||||||
|
const markdown = ``;
|
||||||
|
const hyperlink = safeUrlOrNull(url) ?? null;
|
||||||
|
const op: MindmapOp = {
|
||||||
|
op: "addChild",
|
||||||
|
parentUid,
|
||||||
|
node: {
|
||||||
|
text: caption ? caption : `图片:${title}`,
|
||||||
|
...(hyperlink ? { hyperlink } : {}),
|
||||||
|
note: markdown,
|
||||||
|
refs: [{ kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_append_image_note = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const uid = String(toolArgs.uid ?? "").trim();
|
||||||
|
const imageRef = String(toolArgs.imageRef ?? toolArgs.imageId ?? toolArgs.imageUrl ?? "").trim();
|
||||||
|
const alt = String(toolArgs.alt ?? "").trim() || "image";
|
||||||
|
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||||
|
if (!uid) throw new Error("缺少 uid");
|
||||||
|
if (!imageRef) throw new Error("缺少 imageRef(可用 attachmentId 或图片 URL)");
|
||||||
|
|
||||||
|
const resolved = resolveAttachmentFromContext(args.ctx, imageRef);
|
||||||
|
const url = String(resolved?.fileUrl || imageRef).trim();
|
||||||
|
const title = String(resolved?.title || "图片").trim();
|
||||||
|
if (!url) throw new Error("图片缺少 URL");
|
||||||
|
|
||||||
|
const markdown = ``;
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
applied,
|
||||||
|
errors,
|
||||||
|
data: nextData,
|
||||||
|
meta: { reason, ref: { kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title } },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mindmap_expand_node = async (toolArgs: Record<string, unknown>) => {
|
||||||
|
const targetUid = String(toolArgs.targetUid ?? "").trim();
|
||||||
|
if (!targetUid) throw new Error("缺少 targetUid");
|
||||||
|
const instruction = String(toolArgs.instruction ?? "").trim();
|
||||||
|
const useSearx = args.allowedToolIds.has("search_web");
|
||||||
|
|
||||||
|
const { doc, base } = await loadMindmap();
|
||||||
|
const target = findNodeByUid(base, targetUid);
|
||||||
|
if (!target) throw new Error("未找到目标节点(uid 不存在)");
|
||||||
|
|
||||||
|
const targetText = String(target?.data?.text ?? "").trim();
|
||||||
|
const currentChildren = Array.isArray(target?.children)
|
||||||
|
? target.children
|
||||||
|
.map((c) => String(c?.data?.text ?? "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 20)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const query = [targetText, instruction].filter(Boolean).join(" ").trim();
|
||||||
|
const searxResults = useSearx && query ? await searchSearxng(query, 6).catch(() => []) : [];
|
||||||
|
|
||||||
|
const system = [
|
||||||
|
"你是一个“思维导图补完器”。",
|
||||||
|
"你必须输出严格 JSON(不要 Markdown/不要代码块/不要解释)。",
|
||||||
|
"你只能输出 {\"ops\": MindmapOp[]} 这一个对象。",
|
||||||
|
"默认策略:为 targetUid 新增 3~6 个子节点(addChild)。",
|
||||||
|
"每个新增节点必须提供 text,并尽量提供 hyperlink 与 refs(引用来自搜索结果 URL)。",
|
||||||
|
"不要删除/改名已有节点;不要输出 addSiblingAfter/updateText/deleteNode。",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const user = [
|
||||||
|
`documentId=${args.ctx.documentId}`,
|
||||||
|
`mindmapId=${args.ctx.mindmapId}`,
|
||||||
|
`targetUid=${targetUid}`,
|
||||||
|
"",
|
||||||
|
`目标节点:${targetText || "(empty)"}`,
|
||||||
|
currentChildren.length ? `当前子节点(供去重):${currentChildren.join(";")}` : "",
|
||||||
|
instruction ? `用户要求:${instruction}` : "",
|
||||||
|
"",
|
||||||
|
"可用证据(搜索结果):",
|
||||||
|
...(searxResults.length
|
||||||
|
? searxResults.map((r, idx) => {
|
||||||
|
const snip = (r.snippet || "").replace(/\s+/g, " ").slice(0, 180);
|
||||||
|
return `${idx + 1}. ${r.title}\n url: ${r.url}\n snippet: ${snip}`;
|
||||||
|
})
|
||||||
|
: ["(无)"]),
|
||||||
|
"",
|
||||||
|
"MindmapOp JSON Schema(仅供理解):",
|
||||||
|
'{ "ops": [ { "op": "addChild", "parentUid": string, "node": { "text": string, "hyperlink"?: string, "refs"?: NodeRef[] } } ] }',
|
||||||
|
'NodeRef 示例:{ "kind": "url", "fileUrl": "https://example.com", "title": "来源标题", "snippet": "..." }',
|
||||||
|
"",
|
||||||
|
"硬性约束:",
|
||||||
|
"- 仅输出 addChild;parentUid 必须等于 targetUid。",
|
||||||
|
"- 新增节点 text 不要与当前子节点重复。",
|
||||||
|
"- hyperlink 必须是 http(s) URL。",
|
||||||
|
"- 每个新增节点 refs 至少 1 条(kind=url, fileUrl=来源url);若没有来源,则在 text 末尾追加“(待核验)”。",
|
||||||
|
"- 输出规模控制:最多 6 个节点。",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
let ops: MindmapOp[] = [];
|
||||||
|
let finishReason = "";
|
||||||
|
try {
|
||||||
|
const { text, raw } = await openAiCompatibleChat(
|
||||||
|
[
|
||||||
|
{ role: "system", content: system },
|
||||||
|
{ role: "user", content: user },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
...args.cfg,
|
||||||
|
timeoutMs: 40_000,
|
||||||
|
maxTokens: 1800,
|
||||||
|
maxCompletionTokens: 1800,
|
||||||
|
responseFormat: "json_object",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (typeof raw === "object" && raw) {
|
||||||
|
const r = raw as Record<string, unknown>;
|
||||||
|
const choices = Array.isArray(r.choices) ? (r.choices as unknown[]) : [];
|
||||||
|
const first = (choices[0] && typeof choices[0] === "object" ? (choices[0] as Record<string, unknown>) : null) ?? null;
|
||||||
|
finishReason = first ? String(first.finish_reason ?? "") : "";
|
||||||
|
}
|
||||||
|
const json = tryExtractJsonObject(text);
|
||||||
|
if (json && Array.isArray((json as Record<string, unknown>).ops)) ops = (json as Record<string, unknown>).ops as MindmapOp[];
|
||||||
|
} catch {
|
||||||
|
ops = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixed = sanitizeAddChildOps({ targetUid, currentChildren, ops, searxResults });
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(base, fixed);
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
applied,
|
||||||
|
errors,
|
||||||
|
ops: fixed,
|
||||||
|
data: nextData,
|
||||||
|
meta: { finishReason, searched: useSearx, searxCount: searxResults.length },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
|
if (toolId === "mindmap_get") return await mindmap_get(toolArgs);
|
||||||
|
if (toolId === "mindmap_get_subtree") return await mindmap_get_subtree(toolArgs);
|
||||||
|
if (toolId === "mindmap_apply_ops") return await mindmap_apply_ops(toolArgs);
|
||||||
|
if (toolId === "mindmap_add_child") return await mindmap_add_child(toolArgs);
|
||||||
|
if (toolId === "mindmap_add_sibling_after") return await mindmap_add_sibling_after(toolArgs);
|
||||||
|
if (toolId === "mindmap_update_node_text") return await mindmap_update_node_text(toolArgs);
|
||||||
|
if (toolId === "mindmap_set_hyperlink") return await mindmap_set_hyperlink(toolArgs);
|
||||||
|
if (toolId === "mindmap_append_note") return await mindmap_append_note(toolArgs);
|
||||||
|
if (toolId === "mindmap_set_refs") return await mindmap_set_refs(toolArgs);
|
||||||
|
if (toolId === "mindmap_delete_node") return await mindmap_delete_node(toolArgs);
|
||||||
|
if (toolId === "mindmap_add_attachment_ref") return await mindmap_add_attachment_ref(toolArgs);
|
||||||
|
if (toolId === "mindmap_add_attachment_child") return await mindmap_add_attachment_child(toolArgs);
|
||||||
|
if (toolId === "mindmap_add_image_child") return await mindmap_add_image_child(toolArgs);
|
||||||
|
if (toolId === "mindmap_append_image_note") return await mindmap_append_image_note(toolArgs);
|
||||||
|
if (toolId === "mindmap_expand_node") return await mindmap_expand_node(toolArgs);
|
||||||
|
throw new Error(`不支持的工具:${toolId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { run };
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
export type RagToolContext = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RagFetchResult =
|
||||||
|
| { ok: true; status: number; data: unknown }
|
||||||
|
| { ok: false; status: number; error: string; data?: unknown };
|
||||||
|
|
||||||
|
const readEnv = (key: string) => {
|
||||||
|
try {
|
||||||
|
const v = process.env[key];
|
||||||
|
return typeof v === "string" ? v.trim() : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeJoinUrl = (base: string, path: string) => {
|
||||||
|
const b = String(base || "").trim().replace(/\/+$/, "");
|
||||||
|
const p = String(path || "").trim().replace(/^\/+/, "");
|
||||||
|
if (!b) return `/${p}`;
|
||||||
|
return `${b}/${p}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchJson = async (url: string, init: RequestInit, timeoutMs: number): Promise<RagFetchResult> => {
|
||||||
|
const ms = Math.max(500, Math.min(60_000, Math.floor(timeoutMs || 0) || 20_000));
|
||||||
|
const controller = new AbortController();
|
||||||
|
const t = setTimeout(() => controller.abort(), ms);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
const data = (await res.json().catch(() => null)) as unknown;
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail =
|
||||||
|
typeof data === "object" && data && "detail" in (data as any) ? String((data as any).detail ?? "") : "";
|
||||||
|
const msg = detail || `HTTP ${res.status}`;
|
||||||
|
return { ok: false, status: res.status, error: msg, data };
|
||||||
|
}
|
||||||
|
return { ok: true, status: res.status, data };
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return { ok: false, status: 0, error: msg };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRagServerTools = (args: {
|
||||||
|
ctx: RagToolContext;
|
||||||
|
allowedToolIds: Set<string>;
|
||||||
|
}) => {
|
||||||
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
|
if (!args.allowedToolIds.has(toolId)) {
|
||||||
|
throw new Error(`工具未被允许:${toolId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolId === "rag_lightrag_query") {
|
||||||
|
const query = String(toolArgs.query ?? "").trim();
|
||||||
|
if (!query) throw new Error("缺少 query");
|
||||||
|
if (query.length < 2) throw new Error("query 太短(至少 2 个字符)");
|
||||||
|
|
||||||
|
const modeRaw = String(toolArgs.mode ?? "mix").trim();
|
||||||
|
const mode =
|
||||||
|
modeRaw === "local" ||
|
||||||
|
modeRaw === "global" ||
|
||||||
|
modeRaw === "hybrid" ||
|
||||||
|
modeRaw === "naive" ||
|
||||||
|
modeRaw === "mix" ||
|
||||||
|
modeRaw === "bypass"
|
||||||
|
? modeRaw
|
||||||
|
: "mix";
|
||||||
|
|
||||||
|
const topKRaw = Number(toolArgs.topK ?? toolArgs.top_k ?? 12);
|
||||||
|
const topK = Math.max(1, Math.min(60, Number.isFinite(topKRaw) ? Math.floor(topKRaw) : 12));
|
||||||
|
|
||||||
|
const chunkTopKRaw = Number(toolArgs.chunkTopK ?? toolArgs.chunk_top_k ?? topK);
|
||||||
|
const chunkTopK = Math.max(1, Math.min(120, Number.isFinite(chunkTopKRaw) ? Math.floor(chunkTopKRaw) : topK));
|
||||||
|
|
||||||
|
const includeReferences = toolArgs.includeReferences !== false;
|
||||||
|
const includeChunkContent = Boolean(toolArgs.includeChunkContent ?? true);
|
||||||
|
const onlyNeedContext = Boolean(toolArgs.onlyNeedContext ?? false);
|
||||||
|
const responseType = String(toolArgs.responseType ?? "").trim() || undefined;
|
||||||
|
|
||||||
|
const baseUrl = readEnv("LIGHTRAG_URL");
|
||||||
|
if (!baseUrl) throw new Error("缺少环境变量:LIGHTRAG_URL");
|
||||||
|
const apiKey = readEnv("LIGHTRAG_API_KEY");
|
||||||
|
|
||||||
|
const url = safeJoinUrl(baseUrl, "/query");
|
||||||
|
const payload = {
|
||||||
|
query,
|
||||||
|
mode,
|
||||||
|
top_k: topK,
|
||||||
|
chunk_top_k: chunkTopK,
|
||||||
|
include_references: includeReferences,
|
||||||
|
include_chunk_content: includeChunkContent,
|
||||||
|
only_need_context: onlyNeedContext,
|
||||||
|
...(responseType ? { response_type: responseType } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = await fetchJson(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
...(apiKey ? { "X-API-Key": apiKey } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
35_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`LightRAG 查询失败:${r.error}`);
|
||||||
|
}
|
||||||
|
return { ok: true, provider: "lightrag", query, mode, result: r.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`未知工具:${toolId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { run };
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import type { AiAgentTool, AiAgentToolSet } from "../types";
|
||||||
|
|
||||||
|
export const builtinTools: AiAgentTool[] = [
|
||||||
|
{
|
||||||
|
id: "search_web",
|
||||||
|
displayName: "联网检索(SearxNG)",
|
||||||
|
modelDescription: "使用 SearxNG 搜索,返回标题/URL/摘要;用于提供可追溯来源。",
|
||||||
|
inputSchemaText: `{ "query": "string", "count?": "number (1~10, 默认6)" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
isWriteTool: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rag_lightrag_query",
|
||||||
|
displayName: "LightRAG 检索(RAG)",
|
||||||
|
modelDescription:
|
||||||
|
"调用本地/远程 LightRAG 服务进行检索与生成,返回 response + references(不写入)。",
|
||||||
|
inputSchemaText:
|
||||||
|
`{ "query": "string", "mode?": "\"mix\"|\"naive\"|\"local\"|\"global\"|\"hybrid\"|\"bypass\"", "topK?": "number (1~60, 默认12)", "chunkTopK?": "number (1~120, 默认与topK一致)", "includeReferences?": "boolean (默认 true)", "includeChunkContent?": "boolean (默认 true)", "onlyNeedContext?": "boolean (默认 false)", "responseType?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
isWriteTool: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "docs_search",
|
||||||
|
displayName: "搜索文档(跨页面)",
|
||||||
|
modelDescription: "在工作区内按 title/raw_text 搜索文档,返回匹配列表与摘要片段(不写入)。",
|
||||||
|
inputSchemaText:
|
||||||
|
`{ "query": "string", "limit?": "number (1~30, 默认12)", "workspaceId?": "string", "includeDeleted?": "boolean (默认 false)" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
isWriteTool: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "docs_read",
|
||||||
|
displayName: "读取文档(跨页面)",
|
||||||
|
modelDescription: "读取指定 documentId 的 title/raw_text(可选含 content),用于引用与对比(不写入)。",
|
||||||
|
inputSchemaText:
|
||||||
|
`{ "documentId": "string", "maxChars?": "number (200~20000, 默认2500)", "includeContent?": "boolean (默认 false)" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
isWriteTool: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "image_read",
|
||||||
|
displayName: "读取图片(OCR)",
|
||||||
|
modelDescription: "读取图片/附件的 OCR 文本(优先从 media_assets.ocr_text 获取,不写入)。",
|
||||||
|
inputSchemaText:
|
||||||
|
`{ "assetId?": "string", "fileUrl?": "string", "attachmentRef?": "string (可用附件 id/title/url 片段)" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
isWriteTool: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slash_run",
|
||||||
|
displayName: "斜杠命令(创建/改名)",
|
||||||
|
modelDescription:
|
||||||
|
"执行预置斜杠命令(例如 /new 创建文档、/rename 重命名)。这是写工具,执行前必须确认。",
|
||||||
|
inputSchemaText:
|
||||||
|
`{ "text?": "string (以 / 开头,例如 /new 标题)", "command?": "\"new_doc\"|\"rename_doc\"", "params?": "object (见命令说明)", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_get",
|
||||||
|
displayName: "读取页面内容(摘要)",
|
||||||
|
modelDescription: "读取当前文档的块摘要(blockId/type/text/depth),用于定位与规划(不写入)。",
|
||||||
|
inputSchemaText: `{ "maxBlocks?": "number (10~240, 默认80)" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
isWriteTool: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_find",
|
||||||
|
displayName: "在页面中查找(按块)",
|
||||||
|
modelDescription: "按块内容查找匹配的段落/标题,返回 blockId 列表(不写入)。",
|
||||||
|
inputSchemaText: `{ "query": "string", "maxResults?": "number (1~30, 默认8)" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
isWriteTool: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_insert_blocks",
|
||||||
|
displayName: "插入块(写入页面)",
|
||||||
|
modelDescription:
|
||||||
|
"在指定 block 前/后插入新块(目前支持 paragraph/heading)。写入后返回最新 blocks 快照(data)。",
|
||||||
|
inputSchemaText:
|
||||||
|
`{ "afterBlockId?": "string", "beforeBlockId?": "string", "blocks": "Array<{type:'paragraph'|'heading', text:string, level?:1..5}>", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_replace_range",
|
||||||
|
displayName: "替换块文本(写入页面)",
|
||||||
|
modelDescription:
|
||||||
|
"替换指定 block 的纯文本内容(不改 block 类型/props)。写入后返回最新 blocks 快照(data)。",
|
||||||
|
inputSchemaText: `{ "blockId": "string", "text": "string", "mode?": "'replace'|'append'|'prepend'", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_get",
|
||||||
|
displayName: "读取思维导图(摘要)",
|
||||||
|
modelDescription: "读取当前 mindmap 的精简结构(uid/text/父子关系/子数量),用于定位与规划。",
|
||||||
|
inputSchemaText: `{ "maxNodes?": "number (10~300, 默认120)" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
isWriteTool: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_get_subtree",
|
||||||
|
displayName: "读取思维导图子树(摘要)",
|
||||||
|
modelDescription: "读取指定 uid 的子树摘要(用于精确改写/补完)。",
|
||||||
|
inputSchemaText: `{ "uid": "string", "depth?": "number (0~6, 默认2)", "maxNodes?": "number (5~200, 默认60)" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
isWriteTool: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_apply_ops",
|
||||||
|
displayName: "应用思维导图增量操作(写入)",
|
||||||
|
modelDescription: "对 mindmap 应用增量 ops(新增/改名/引用/备注/删除等),并落盘保存。",
|
||||||
|
inputSchemaText: `{ "ops": "MindmapOp[]", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_add_child",
|
||||||
|
displayName: "新增子节点",
|
||||||
|
modelDescription: "在 parentUid 下新增一个子节点(可带 hyperlink/refs/note)。",
|
||||||
|
inputSchemaText: `{ "parentUid": "string", "text": "string", "hyperlink?": "string (http/https)", "note?": "string", "refs?": "NodeRef[]", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_add_sibling_after",
|
||||||
|
displayName: "在后面新增同级节点",
|
||||||
|
modelDescription: "在 targetUid 后插入一个同级节点(不能用于根节点)。",
|
||||||
|
inputSchemaText: `{ "targetUid": "string", "text": "string", "hyperlink?": "string (http/https)", "note?": "string", "refs?": "NodeRef[]", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_update_node_text",
|
||||||
|
displayName: "更新节点文本",
|
||||||
|
modelDescription: "更新指定 uid 的节点 text。",
|
||||||
|
inputSchemaText: `{ "uid": "string", "text": "string", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_set_hyperlink",
|
||||||
|
displayName: "设置/清除节点超链接",
|
||||||
|
modelDescription: "为节点设置 hyperlink(http/https),或传 null 清除。",
|
||||||
|
inputSchemaText: `{ "uid": "string", "hyperlink": "string|null", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_append_note",
|
||||||
|
displayName: "追加节点备注",
|
||||||
|
modelDescription: "向节点 note 追加一段 markdown 备注(不会覆盖原备注)。",
|
||||||
|
inputSchemaText: `{ "uid": "string", "markdown": "string", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_set_refs",
|
||||||
|
displayName: "设置节点引用(refs)",
|
||||||
|
modelDescription: "设置指定 uid 的 refs(覆盖写入)。",
|
||||||
|
inputSchemaText: `{ "uid": "string", "refs": "NodeRef[]", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_delete_node",
|
||||||
|
displayName: "删除节点",
|
||||||
|
modelDescription: "删除指定 uid 的节点(不能删除根节点)。",
|
||||||
|
inputSchemaText: `{ "uid": "string", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_add_attachment_ref",
|
||||||
|
displayName: "给节点增加附件引用(refs)",
|
||||||
|
modelDescription: "把附件作为 refs 追加/替换到指定节点(支持 pdf/docx/pptx/url + page/slide)。",
|
||||||
|
inputSchemaText:
|
||||||
|
`{ "uid": "string", "attachmentId?": "string", "fileUrl?": "string", "title?": "string", "kind?": "\"pdf\"|\"docx\"|\"pptx\"|\"url\"", "page?": "number", "slide?": "number", "snippet?": "string", "mode?": "\"append\"|\"replace\"", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_add_attachment_child",
|
||||||
|
displayName: "把附件插入为子节点",
|
||||||
|
modelDescription: "在 parentUid 下新增一个“附件节点”,并自动写 refs +(可选)hyperlink。",
|
||||||
|
inputSchemaText:
|
||||||
|
`{ "parentUid": "string", "attachmentId?": "string", "fileUrl?": "string", "title?": "string", "text?": "string", "note?": "string", "kind?": "\"pdf\"|\"docx\"|\"pptx\"|\"url\"", "page?": "number", "slide?": "number", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_add_image_child",
|
||||||
|
displayName: "插入图片为子节点",
|
||||||
|
modelDescription: "在 parentUid 下新增一个图片子节点(note 中包含 markdown 图片)。",
|
||||||
|
inputSchemaText:
|
||||||
|
`{ "parentUid": "string", "imageRef": "string (attachmentId 或 图片URL)", "alt?": "string", "caption?": "string", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_append_image_note",
|
||||||
|
displayName: "在节点备注中插入图片",
|
||||||
|
modelDescription: "向指定节点的 note 追加一张图片(markdown 格式)。",
|
||||||
|
inputSchemaText: `{ "uid": "string", "imageRef": "string (attachmentId 或 图片URL)", "alt?": "string", "reason?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mindmap_expand_node",
|
||||||
|
displayName: "补完思维导图节点(检索→生成→落盘)",
|
||||||
|
modelDescription: "补完指定节点:可选联网检索作为证据,生成 3~6 个子节点并强制 refs,不足则标记“待核验”。",
|
||||||
|
inputSchemaText: `{ "targetUid": "string", "instruction?": "string" }`,
|
||||||
|
source: "builtin",
|
||||||
|
requiresConfirmation: true,
|
||||||
|
isWriteTool: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const builtinToolSets: AiAgentToolSet[] = [
|
||||||
|
{
|
||||||
|
id: "toolset.readonly",
|
||||||
|
displayName: "只读工具(全局)",
|
||||||
|
toolIds: ["search_web"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toolset.rag_read",
|
||||||
|
displayName: "RAG 检索(LightRAG)",
|
||||||
|
toolIds: ["rag_lightrag_query"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toolset.docs_read",
|
||||||
|
displayName: "文档检索/读取(跨页面)",
|
||||||
|
toolIds: ["docs_search", "docs_read"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toolset.media_read",
|
||||||
|
displayName: "媒体读取(OCR/元信息)",
|
||||||
|
toolIds: ["image_read"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toolset.slash_write",
|
||||||
|
displayName: "斜杠命令(写入)",
|
||||||
|
toolIds: ["slash_run"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toolset.doc_read",
|
||||||
|
displayName: "页面读取(BlockNote)",
|
||||||
|
toolIds: ["doc_get", "doc_find"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toolset.doc_write",
|
||||||
|
displayName: "页面写入(BlockNote)",
|
||||||
|
toolIds: ["doc_insert_blocks", "doc_replace_range"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toolset.mindmap_read",
|
||||||
|
displayName: "思维导图读取",
|
||||||
|
toolIds: ["mindmap_get", "mindmap_get_subtree"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toolset.mindmap_write",
|
||||||
|
displayName: "思维导图写入",
|
||||||
|
toolIds: [
|
||||||
|
"mindmap_apply_ops",
|
||||||
|
"mindmap_add_child",
|
||||||
|
"mindmap_add_sibling_after",
|
||||||
|
"mindmap_update_node_text",
|
||||||
|
"mindmap_set_hyperlink",
|
||||||
|
"mindmap_append_note",
|
||||||
|
"mindmap_set_refs",
|
||||||
|
"mindmap_delete_node",
|
||||||
|
"mindmap_add_attachment_ref",
|
||||||
|
"mindmap_add_attachment_child",
|
||||||
|
"mindmap_add_image_child",
|
||||||
|
"mindmap_append_image_note",
|
||||||
|
"mindmap_expand_node",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export type SearxResult = { title: string; url: string; snippet?: string; engine?: string };
|
||||||
|
|
||||||
|
const safeUrlOrNull = (value: unknown) => {
|
||||||
|
const s = typeof value === "string" ? value.trim() : "";
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchSearxng = async (q: string, count = 6): Promise<SearxResult[]> => {
|
||||||
|
const query = String(q || "").trim();
|
||||||
|
if (!query) return [];
|
||||||
|
|
||||||
|
const base = (process.env.SEARXNG_BASE_URL ?? "http://127.0.0.1:8889").replace(/\/+$/, "");
|
||||||
|
const token = (process.env.SEARXNG_API_TOKEN ?? "").trim();
|
||||||
|
const url = `${base}/search?q=${encodeURIComponent(query)}&format=json&language=zhh-CN&categories=general&safesearch=1`;
|
||||||
|
|
||||||
|
const tryFetch = async (headers: Record<string, string>) => {
|
||||||
|
const res = await fetch(url, { headers, method: "GET" });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json().catch(() => null)) as unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
let json: unknown = null;
|
||||||
|
if (token) {
|
||||||
|
json = (await tryFetch({ Authorization: `Bearer ${token}` })) ?? (await tryFetch({ "X-API-Key": token })) ?? null;
|
||||||
|
}
|
||||||
|
if (!json) json = await tryFetch({});
|
||||||
|
|
||||||
|
const results = (() => {
|
||||||
|
if (typeof json !== "object" || !json) return [];
|
||||||
|
const obj = json as Record<string, unknown>;
|
||||||
|
const value = obj.results;
|
||||||
|
return Array.isArray(value) ? (value as unknown[]) : [];
|
||||||
|
})();
|
||||||
|
return results
|
||||||
|
.map((r: unknown) => {
|
||||||
|
const obj = (typeof r === "object" && r ? (r as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
title: String(obj.title ?? "").trim(),
|
||||||
|
url: String(obj.url ?? "").trim(),
|
||||||
|
snippet: String(obj.content ?? obj.snippet ?? "").trim(),
|
||||||
|
engine: String(obj.engine ?? "").trim(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
|
||||||
|
.slice(0, Math.max(1, Math.min(10, count)));
|
||||||
|
};
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
type SupabaseRouteClient = {
|
||||||
|
from: (table: string) => any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SlashSupabaseClient = SupabaseRouteClient;
|
||||||
|
|
||||||
|
export type SlashToolContext = {
|
||||||
|
userId: string;
|
||||||
|
// 可选:在 document scope 下提供,便于默认继承 workspace_id / parent_id
|
||||||
|
currentDocumentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
const pick = (obj: unknown, key: string) => (isRecord(obj) ? obj[key] : undefined);
|
||||||
|
|
||||||
|
const loadWorkspaceIds = async (supabase: SlashSupabaseClient, userId: string) => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("workspace_members")
|
||||||
|
.select("workspace_id")
|
||||||
|
.eq("user_id", userId);
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取工作区失败");
|
||||||
|
}
|
||||||
|
const rows = Array.isArray(data) ? data : [];
|
||||||
|
return rows.map((r) => String((r as any)?.workspace_id ?? "")).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inferWorkspaceIdFromDoc = async (supabase: SlashSupabaseClient, documentId: string) => {
|
||||||
|
const { data, error } = await supabase.from("documents").select("workspace_id").eq("id", documentId).single();
|
||||||
|
if (error) return null;
|
||||||
|
const wid = String(pick(data, "workspace_id") ?? "").trim();
|
||||||
|
return wid || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedSlash =
|
||||||
|
| { ok: true; command: "new_doc"; params: { title: string; parentId?: string | null; workspaceId?: string | null } }
|
||||||
|
| { ok: true; command: "rename_doc"; params: { documentId: string; title: string } }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
const parseSlash = (text: string): ParsedSlash => {
|
||||||
|
const raw = String(text || "").trim();
|
||||||
|
if (!raw.startsWith("/")) return { ok: false, error: "不是斜杠命令(必须以 / 开头)" };
|
||||||
|
const parts = raw.split(/\s+/).filter(Boolean);
|
||||||
|
const cmd = parts[0] ?? "";
|
||||||
|
const rest = raw.slice(cmd.length).trim();
|
||||||
|
|
||||||
|
if (cmd === "/new" || cmd === "/new-doc" || cmd === "/newdoc") {
|
||||||
|
const title = rest.trim();
|
||||||
|
if (!title) return { ok: false, error: "用法:/new <标题>" };
|
||||||
|
return { ok: true, command: "new_doc", params: { title } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd === "/rename" || cmd === "/rename-doc" || cmd === "/renamedoc") {
|
||||||
|
const documentId = String(parts[1] ?? "").trim();
|
||||||
|
const title = raw.split(/\s+/).slice(2).join(" ").trim();
|
||||||
|
if (!documentId || !title) return { ok: false, error: "用法:/rename <documentId> <新标题>" };
|
||||||
|
return { ok: true, command: "rename_doc", params: { documentId, title } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: `未知命令:${cmd}` };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSlashServerTools = (args: {
|
||||||
|
supabase: SlashSupabaseClient;
|
||||||
|
ctx: SlashToolContext;
|
||||||
|
allowedToolIds: Set<string>;
|
||||||
|
}) => {
|
||||||
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
|
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
||||||
|
|
||||||
|
if (toolId === "slash_run") {
|
||||||
|
const text = String(toolArgs.text ?? "").trim();
|
||||||
|
const command = String(toolArgs.command ?? "").trim();
|
||||||
|
const params = isRecord(toolArgs.params) ? toolArgs.params : null;
|
||||||
|
|
||||||
|
const parsed: ParsedSlash =
|
||||||
|
text && text.startsWith("/") ? parseSlash(text) : command === "new_doc"
|
||||||
|
? {
|
||||||
|
ok: true,
|
||||||
|
command: "new_doc",
|
||||||
|
params: {
|
||||||
|
title: String(pick(params, "title") ?? "").trim(),
|
||||||
|
parentId: pick(params, "parentId") ? String(pick(params, "parentId")) : null,
|
||||||
|
workspaceId: pick(params, "workspaceId") ? String(pick(params, "workspaceId")) : null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: command === "rename_doc"
|
||||||
|
? {
|
||||||
|
ok: true,
|
||||||
|
command: "rename_doc",
|
||||||
|
params: {
|
||||||
|
documentId: String(pick(params, "documentId") ?? "").trim(),
|
||||||
|
title: String(pick(params, "title") ?? "").trim(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { ok: false, error: "缺少 text(以 / 开头)或 command" };
|
||||||
|
|
||||||
|
if (!parsed.ok) throw new Error(parsed.error);
|
||||||
|
|
||||||
|
if (parsed.command === "new_doc") {
|
||||||
|
const title = String(parsed.params.title ?? "").trim();
|
||||||
|
if (!title) throw new Error("缺少标题");
|
||||||
|
const parentId = parsed.params.parentId ? String(parsed.params.parentId) : null;
|
||||||
|
const workspaceIdFromParams = parsed.params.workspaceId ? String(parsed.params.workspaceId) : null;
|
||||||
|
const workspaceId =
|
||||||
|
workspaceIdFromParams ||
|
||||||
|
(args.ctx.currentDocumentId ? await inferWorkspaceIdFromDoc(args.supabase, args.ctx.currentDocumentId) : null) ||
|
||||||
|
(await loadWorkspaceIds(args.supabase, args.ctx.userId))[0] ||
|
||||||
|
null;
|
||||||
|
if (!workspaceId) throw new Error("无法推断 workspaceId(请在 params.workspaceId 指定)");
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
user_id: args.ctx.userId,
|
||||||
|
parent_id: parentId,
|
||||||
|
title,
|
||||||
|
content: [] as unknown[],
|
||||||
|
raw_text: "",
|
||||||
|
};
|
||||||
|
const { data, error } = await args.supabase.from("documents").insert(payload).select("id,workspace_id,parent_id,title,created_at,updated_at").single();
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "创建文档失败");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
command: "new_doc",
|
||||||
|
document: {
|
||||||
|
id: String(pick(data, "id") ?? ""),
|
||||||
|
title: String(pick(data, "title") ?? ""),
|
||||||
|
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||||
|
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||||
|
createdAt: pick(data, "created_at") ?? null,
|
||||||
|
updatedAt: pick(data, "updated_at") ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.command === "rename_doc") {
|
||||||
|
const documentId = String(parsed.params.documentId ?? "").trim();
|
||||||
|
const title = String(parsed.params.title ?? "").trim();
|
||||||
|
if (!documentId || !title) throw new Error("缺少 documentId 或 title");
|
||||||
|
|
||||||
|
const { data, error } = await args.supabase
|
||||||
|
.from("documents")
|
||||||
|
.update({ title })
|
||||||
|
.eq("id", documentId)
|
||||||
|
.select("id,workspace_id,parent_id,title,updated_at")
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "重命名失败");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
command: "rename_doc",
|
||||||
|
document: {
|
||||||
|
id: String(pick(data, "id") ?? ""),
|
||||||
|
title: String(pick(data, "title") ?? ""),
|
||||||
|
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||||
|
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||||
|
updatedAt: pick(data, "updated_at") ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`未实现命令:${(parsed as any).command}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`未知工具:${toolId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { run };
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { AiAgentTool, AiAgentToolSet, ToolPermissions } from "./types";
|
||||||
|
|
||||||
|
export type ToolRegistry = {
|
||||||
|
toolsById: Map<string, AiAgentTool>;
|
||||||
|
toolSetsById: Map<string, AiAgentToolSet>;
|
||||||
|
permissions: ToolPermissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createToolRegistry = (args: {
|
||||||
|
tools: AiAgentTool[];
|
||||||
|
toolSets: AiAgentToolSet[];
|
||||||
|
permissions?: Partial<ToolPermissions>;
|
||||||
|
}): ToolRegistry => {
|
||||||
|
const toolsById = new Map<string, AiAgentTool>();
|
||||||
|
for (const t of args.tools) toolsById.set(t.id, t);
|
||||||
|
|
||||||
|
const toolSetsById = new Map<string, AiAgentToolSet>();
|
||||||
|
for (const s of args.toolSets) toolSetsById.set(s.id, s);
|
||||||
|
|
||||||
|
const permissions: ToolPermissions = {
|
||||||
|
read: args.permissions?.read ?? "allow",
|
||||||
|
write: args.permissions?.write ?? "confirm",
|
||||||
|
};
|
||||||
|
|
||||||
|
return { toolsById, toolSetsById, permissions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveAllowedToolIds = (args: {
|
||||||
|
registry: ToolRegistry;
|
||||||
|
mode: "auto" | "manual";
|
||||||
|
toolSetIds?: string[];
|
||||||
|
toolIds?: string[];
|
||||||
|
}) => {
|
||||||
|
const allowed = new Set<string>();
|
||||||
|
|
||||||
|
if (args.mode === "auto") {
|
||||||
|
// v1:auto 默认启用 readonly;若显式传入 toolSets,则在这些集合内自动选择
|
||||||
|
const toolSetIds = Array.isArray(args.toolSetIds) ? args.toolSetIds : [];
|
||||||
|
if (toolSetIds.length) {
|
||||||
|
for (const sid of toolSetIds) {
|
||||||
|
const s = args.registry.toolSetsById.get(String(sid));
|
||||||
|
s?.toolIds.forEach((id) => allowed.add(id));
|
||||||
|
}
|
||||||
|
if (allowed.size > 0) return allowed;
|
||||||
|
}
|
||||||
|
const readonly = args.registry.toolSetsById.get("toolset.readonly");
|
||||||
|
readonly?.toolIds.forEach((id) => allowed.add(id));
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolSetIds = Array.isArray(args.toolSetIds) ? args.toolSetIds : [];
|
||||||
|
for (const sid of toolSetIds) {
|
||||||
|
const s = args.registry.toolSetsById.get(String(sid));
|
||||||
|
s?.toolIds.forEach((id) => allowed.add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolIds = Array.isArray(args.toolIds) ? args.toolIds : [];
|
||||||
|
for (const tid of toolIds) {
|
||||||
|
if (args.registry.toolsById.has(String(tid))) allowed.add(String(tid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动模式但用户未选:至少保留 readonly,避免完全不可用
|
||||||
|
if (allowed.size === 0) {
|
||||||
|
const readonly = args.registry.toolSetsById.get("toolset.readonly");
|
||||||
|
readonly?.toolIds.forEach((id) => allowed.add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowed;
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export type AiAgentToolSource = "builtin" | "mcp" | "custom";
|
||||||
|
|
||||||
|
export type AiAgentTool = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
modelDescription: string;
|
||||||
|
// v1 先用“文字 schema”,后续再引入 zod/jsonschema
|
||||||
|
inputSchemaText: string;
|
||||||
|
source: AiAgentToolSource;
|
||||||
|
requiresConfirmation: boolean;
|
||||||
|
isWriteTool: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiAgentToolSet = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
toolIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolPermissionMode = "allow" | "confirm" | "deny";
|
||||||
|
|
||||||
|
export type ToolPermissions = {
|
||||||
|
// 默认:读允许、写确认(和 design 文档一致)
|
||||||
|
read: ToolPermissionMode;
|
||||||
|
write: ToolPermissionMode;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -3,14 +3,34 @@ import { getDecodedCookies } from "@/lib/server-cookies";
|
|||||||
|
|
||||||
export const createSupabaseServerClient = async () => {
|
export const createSupabaseServerClient = async () => {
|
||||||
const cookieStore = await getDecodedCookies();
|
const cookieStore = await getDecodedCookies();
|
||||||
|
const supabaseUrl = process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
|
throw new Error(
|
||||||
|
"缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
|
||||||
|
);
|
||||||
|
}
|
||||||
return createServerComponentClient({
|
return createServerComponentClient({
|
||||||
cookies: () => cookieStore as any,
|
cookies: () => cookieStore as any,
|
||||||
|
supabaseUrl,
|
||||||
|
supabaseKey: supabaseAnonKey,
|
||||||
} as any);
|
} as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createSupabaseRouteClient = async () => {
|
export const createSupabaseRouteClient = async () => {
|
||||||
const cookieStore = await getDecodedCookies();
|
const cookieStore = await getDecodedCookies();
|
||||||
|
const supabaseUrl = process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
|
throw new Error(
|
||||||
|
"缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
|
||||||
|
);
|
||||||
|
}
|
||||||
return createRouteHandlerClient({
|
return createRouteHandlerClient({
|
||||||
cookies: () => cookieStore as any,
|
cookies: () => cookieStore as any,
|
||||||
|
supabaseUrl,
|
||||||
|
supabaseKey: supabaseAnonKey,
|
||||||
} as any);
|
} as any);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type AiAgentUiState = {
|
||||||
|
documentAgentAvailable: boolean;
|
||||||
|
documentAgentOpen: boolean;
|
||||||
|
setDocumentAgentAvailable: (available: boolean) => void;
|
||||||
|
setDocumentAgentOpen: (open: boolean) => void;
|
||||||
|
toggleDocumentAgentOpen: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAiAgentUiStore = create<AiAgentUiState>((set, get) => ({
|
||||||
|
documentAgentAvailable: false,
|
||||||
|
documentAgentOpen: false,
|
||||||
|
setDocumentAgentAvailable: (available) => set({ documentAgentAvailable: available }),
|
||||||
|
setDocumentAgentOpen: (open) => set({ documentAgentOpen: open }),
|
||||||
|
toggleDocumentAgentOpen: () => {
|
||||||
|
const s = get();
|
||||||
|
if (!s.documentAgentAvailable) return;
|
||||||
|
set({ documentAgentOpen: !s.documentAgentOpen });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
Reference in New Issue
Block a user