diff --git a/ai.md b/ai.md new file mode 100644 index 00000000..c16eec6a --- /dev/null +++ b/ai.md @@ -0,0 +1,5 @@ +apikey:AIzaSyDKIEiUoh2pUM1LDfozBG8LVl7kfYMfLGw + +https://aichem.dpdns.org/v1 + +gemini-2.5-flash \ No newline at end of file diff --git a/design/cloudflare-deploy-and-performance-optimization.md b/design/cloudflare-deploy-and-performance-optimization.md new file mode 100644 index 00000000..4a93e746 --- /dev/null +++ b/design/cloudflare-deploy-and-performance-optimization.md @@ -0,0 +1,170 @@ +# Cloudflare 部署与性能优化方案(全盘) + +> 目标:前端可通过 Cloudflare 域名访问;后端(Supabase / LightRAG / ingest_service / Redis 等)通过 Cloudflare Tunnel 或等价方式对外暴露;同时解决当前“页面切换卡顿”的体感问题,并为后续 OCR(MinerU)与 RAG 自动入库留出扩展空间。 + +## 0. 现状结论(基于当前代码扫描) + +### 0.1 当前代码对 Cloudflare(Edge/Workers/Pages)的直接阻塞点 + +`wolai-frontend` 内存在大量 Node 内置依赖与本地文件系统读写(`fs/path/process.cwd()`),例如: + +- `wolai-frontend/src/lib/mindmap-files.ts`、`wolai-frontend/src/app/api/mindmap/**`:读写 `public/documents` / `public/mindmaps` 下的本地文件 +- `wolai-frontend/src/app/api/documents/{create,duplicate,copy-tree}`:本地文件写入/拷贝 +- `wolai-frontend/src/lib/ai/onlineAiConfig.ts`:本地读取配置文件 +- 部分 API 还使用 `crypto.randomUUID` + +这些在 Cloudflare Pages/Workers 的运行时中**不可用或不推荐**(没有传统 Node 文件系统/进程工作目录概念),因此“把整个 Next.js(含 API Routes)直接部署到 Cloudflare 并保持当前行为”会遇到现实阻碍。 + +### 0.2 当前“页面切换卡”的高概率根因 + +在改造前,`/documents/[id]` 会把 `documents.content` 作为 Server Component props 直接带到前端编辑器(BlockNote)。当 `content` 较大时,会显著增加: + +- 服务器到浏览器的 RSC Flight payload 体积 +- 浏览器端 JSON 解析、反序列化与 hydration 负担 + +这类卡顿会在“切换不同页面”场景中放大。 + +> 已落地修复:`/documents/[id]` 不再直接携带 `documents.content`,改为客户端按需请求 `/api/documents/content` 后再加载编辑器内容(见下方“P0 优化已实现”)。 + +--- + +## 1. Cloudflare 部署拓扑:推荐的三种路径 + +### 路径 A(推荐优先):Cloudflare 仅做反代/加速,本地跑完整 Next(含 API) + +**适用场景**:希望最少改代码、尽快可用;允许前端与 API 都跑在你本地/服务器(Docker),Cloudflare 只提供域名与隧道入口。 + +- Cloudflare Tunnel 将 `https://你的域名` 指向本地的 `wolai-frontend`(Next server) +- `/api/*` 也由同一 Next server 提供(无需跨域) +- Supabase/LightRAG 等服务同样通过 Tunnel 暴露(可用子域名或路径分流) + +**优点** +- 几乎不需要重构(保留 `fs`/本地 mindmap 文件体系) +- cookie / session 同源,前端调用 `/api` 最简单 + +**缺点** +- 首屏与静态资源仍取决于你本地带宽/延迟(可通过 Cloudflare 缓存静态资源缓解) +- 需要你保证本地服务稳定在线 + +### 路径 B:Cloudflare 部署“纯静态前端”(SPA/静态 Next),API 全部回源到本地 + +**适用场景**:希望前端 CDN 化(加载快),后端服务仍由你本地/服务器提供;愿意做一定的前后端解耦与配置改造。 + +关键改造点: +- 前端不能依赖 Next Server Components 的“服务器取数”,而应改为客户端请求后端 API +- Next 的 `app/api/*` 要迁移到后端服务(或保留在本地 Next,但需要确保 Cloudflare Pages 的 `/api` 反代到本地) +- 所有本地文件读写(mindmap 文件、copy-tree)必须移动到“后端服务”中执行 + +**优点** +- 前端体验更好(静态资源全球 CDN) +- 后端仍可保留现有 Node 能力(fs、pdf 处理、脚本等) + +**缺点** +- 需要统一“API Base URL / 反代规则 / CORS / cookie 域” +- 需要规划“哪些 API 放在本地 Next,哪些独立成后端服务” + +### 路径 C:Cloudflare 运行 Next(Workers/Pages Functions)+ 后端回源 + +**适用场景**:希望尽可能多逻辑在 Cloudflare 边缘,但愿意严格约束 Node 能力,重构较大。 + +现实结论:以当前代码结构(大量 `fs/path/process.cwd()`)来看,除非先做较大改造,否则不建议直接走该路径。 + +--- + +## 2. 性能优化路线图(按优先级) + +### P0(已实现,立刻收益):文档内容按需加载,减小路由切换负担 + +已改动: +- 新增 `GET /api/documents/content?documentId=...`:仅返回 `documents.content` +- `/documents/[id]` 不再 `select content`,避免 RSC 携带大字段 +- 编辑器内容在客户端加载:`DocumentContent` 在 `initialContent==null` 时拉取内容并展示“页面内容加载中...” +- `BlockNoteEditor` 在 `normalizedInitialContent` 变化时重新初始化(只会从 `null -> 实际内容` 触发一次) + +预期收益: +- 切换页面时,RSC payload 更小、解析更快 +- 为 Cloudflare 反代/跨网访问(带宽较差)场景奠定基础 + +### P1(强烈推荐,低风险高收益):侧边栏与文件树性能 + +1) 文件树虚拟列表 +- `FileTree` 当前对 `rows` 直接 `map` 渲染,文档/附件数量上来后会明显卡顿 +- 已有 `@tanstack/react-virtual` 用例(`PrivateTree`),可复用同一技术栈把 `FileTree` 也做虚拟化 + +2) `assetsByDoc` 构建从 O(n^2) 优化为 O(n) +- 当前实现每次 push 前会 `some()` 去重(n 较大时会变慢) +- 建议改为 `Map>` 一次遍历构建 + +3) `/api/sidebar` 数据裁剪与拆分 +- `fetchSidebarDataset` 对 `media_assets` 使用 `select("*")`,字段过多会拉大 payload +- 建议改为只取侧边栏需要字段;并进一步拆分: + - 文档树(documents) + - 垃圾桶(trashed docs/assets) + - 附件列表(只取当前展开节点/或按 docId 批量懒加载) + +### P2(中期):文档内容存储与传输优化(大文档体验) + +1) 内容分片/分页加载(可选) +- 对 `documents.content` 进行“块级分页”(例如按顶层 block 分块),在编辑器侧逐步加载 + +2) 只拉取必要字段 +- 页面切换只拉 meta(title/updated_at/options/stats),内容按需加载(已完成) +- backlinks / history 等面板可延迟加载或切换时懒加载 + +3) 缓存与压缩策略(配合 Cloudflare) +- 对静态资源启用强缓存(Cloudflare 默认可做) +- 对“用户私有”接口设置 `Cache-Control: private`,避免被共享缓存污染 +- 对“公开内容(public pages)”可用 `s-maxage` + `stale-while-revalidate` + +--- + +## 3. Cloudflare 反代/穿透下的关键工程点(必须规划) + +### 3.1 统一 API 入口与路径分流 + +建议以同一域名提供: +- `https://mnote.example.com/`:前端 +- `https://mnote.example.com/api/*`:后端 API(本地 Next 或独立服务) +- `https://mnote.example.com/supabase/*`:如需(或直接使用 supabase 子域名) +- `https://mnote.example.com/lightrag/*`:LightRAG 7777(建议仅内部/鉴权后暴露) + +这样可以最大化减少 CORS 与 cookie 域问题。 + +### 3.2 上传链路必须“直传” + +Cloudflare/反代链路对大文件上传很敏感: +- 建议前端改为“拿签名 URL 后直传到 Supabase Storage”(你已有 `/api/media/signed-url`) +- 避免通过前端服务器中转大文件(会受 CF/反代限制、也会拖慢) + +### 3.3 本地文件系统能力的迁移策略 + +当前 mindmap 文件存储在 `public/documents/**` 本地目录,这对 Cloudflare 部署不友好。 + +两条路: +- 保留“本地 Node 后端”(路径 A / B),让所有文件读写都在本地后端做 +- 或迁移到 Supabase Storage(推荐长期):mindmap 变成一个可版本化的对象文件(支持软删/回收站/延迟清理) + +--- + +## 4. 观测与量化(否则很难证明“不卡了”) + +建议先落地最小可用的观测: + +1) 前端导航耗时 +- 在 `router.push` 前后打点(`performance.mark`),记录到 console 或上报 + +2) 关键 API 延迟 +- `/api/sidebar`、`/api/documents/content`、`/api/search/*`:记录开始/结束与 payload 大小 + +3) 体感指标 +- 首次可交互(TTI) +- 文档打开到“编辑器可输入”的时间 + +--- + +## 5. 下一步执行清单(建议按顺序) + +1) 选择 Cloudflare 部署路径(A/B/C) +2) P1:`FileTree` 虚拟化 + `assetsByDoc` O(n) 化 + `/api/sidebar` select 裁剪 +3) P2:公开页面缓存策略、上传直传、mindmap 存储迁移方案 + diff --git a/design/file-tree-vscode-checklist.md b/design/file-tree-vscode-checklist.md index 39c6bbae..6bc4163d 100644 --- a/design/file-tree-vscode-checklist.md +++ b/design/file-tree-vscode-checklist.md @@ -110,6 +110,9 @@ **要做** - [x] 拖拽默认移动;按修饰键(`Alt`)为复制(浏览器层面 dropEffect 已设置;Alt-copy 建议人工补测一次) - [x] 起拖行在选择集中 → 拖整个选择集;否则仅拖当前行并先切为单选 +- [ ] 拖拽悬停反馈(drop feedback): + - [ ] 悬停到“收起的 doc(文件夹)行”时,仅该行变灰 + - [ ] 悬停到“展开的 doc(文件夹)行”时,该 doc 的可见子节点范围一起变灰(类似 VSCode Explorer) - [x] drop 目标: - [x] drop 到 doc 行:贴入其下 - [x] drop 到 index/asset 行:等同贴入其所属 doc @@ -134,6 +137,9 @@ - [ ] 复制 asset:生成新的存储对象(不是引用),新文件可独立下载(需要至少 1 个真实附件用例) - [x] 拖拽移动 doc:已通过页面拖拽触发 `/api/documents/move` 并验证 200 - [x] 从系统拖拽文件到文件树:落点为目标页面(doc 行 / index 行 / asset 行),触发 `/api/media/upload` 上传并作为“真实文件”附件挂到该页面下 +- [ ] 拖拽悬停反馈:收起 doc 行仅该行变灰;展开 doc 行则其可见范围一起变灰 +- [ ] 从系统拖拽单个文件:只创建 1 份附件记录(不应出现两个同名文件) +- [ ] 从系统拖拽文件到“当前打开的页面”:主编辑区自动插入 1 个附件/媒体块(可在页面正文中看到) - [ ] Alt+拖拽复制:逻辑已接入(`event.altKey`),需要人工补测一次(MCP 暂无法“按住 Alt 拖拽”) --- @@ -158,3 +164,4 @@ **手工验收** - [x] 多选后右键任一已选行 → 点击“删除到垃圾桶”,应删除整个选择集(而不是仅最后右键项) +- [ ] 多选包含「页面 + 附件」时:无论最后右键落在页面行还是附件行,“删除”都应按选择集执行 diff --git a/design/mindmap-ai-agent-api.md b/design/mindmap-ai-agent-api.md new file mode 100644 index 00000000..77b3f690 --- /dev/null +++ b/design/mindmap-ai-agent-api.md @@ -0,0 +1,144 @@ +# Mindmap AI Agent API 设计(读写思维导图,类似 CLI 工具) + +## 目标 + +让 AI 不再“只输出一段文本”,而是像 CLI 一样: + +1. **读**:获取当前页面中某个 Mindmap 的完整数据(包含节点、链接、引用)。 +2. **改**:以结构化的 `ops`(操作序列)形式修改导图(新增/删除/改名/加链接/加引用/加备注)。 +3. **可追溯**:每次 AI 修改都能记录“为什么这样改/引用来自哪里”(refs 作为第一公民)。 +4. **安全**:必须校验登录态 + document 权限;只能操作该 document 下的 mindmap 文件。 + +## 现状(简述) + +- 目前 Mindmap 的保存/加载已经存在(按文件树保存,支持同页多个独立 mindmap)。 +- AI v2 的 `/api/mindmap-ai/outline-to-mindmap` 能把 PDF 转成 mindmapData(节点带 `hyperlink` 与 `refs`)。 +- 仍缺少:AI 直接对“已有导图”做增量修改的能力(例如“补完此节点”“根据搜索扩展子节点”)。 + +## 设计原则 + +- **客户端只负责 UI/交互**:选择节点、展示进度、应用结果。 +- **服务端负责权限与落盘**:验证用户、读写 mindmap JSON、生成 ops、应用 ops。 +- **AI 输出必须是 ops**:避免 AI 直接吐一棵全量树造成覆盖/丢数据;也利于审计与回滚。 +- **引用强制**:默认每个新增节点都要给 `refs`(至少页码/URL),没有引用则标记为 `待核验`。 + +## 核心数据结构 + +### 1) Mindmap 节点引用 `NodeRef`(已在实现中使用) + +```ts +export type NodeRef = { + kind: "pdf" | "docx" | "pptx" | "url"; + assetId?: string; + fileUrl?: string; + page?: number; // 1-based + slide?: number; // 1-based + title?: string; + snippet?: string; +}; +``` + +### 2) 操作协议 `MindmapOp` + +> 说明:这里的 `uid` 指 simple-mind-map 节点 `data.uid`(当前实现已使用)。 + +```ts +export type MindmapOp = + | { op: "addChild"; parentUid: string; node: { uid?: string; text: string; hyperlink?: string; refs?: NodeRef[]; note?: string } } + | { op: "addSiblingAfter"; targetUid: string; node: { uid?: string; text: string; hyperlink?: string; refs?: NodeRef[]; note?: string } } + | { op: "updateText"; uid: string; text: string } + | { op: "setHyperlink"; uid: string; hyperlink: string | null } + | { op: "setRefs"; uid: string; refs: NodeRef[] } + | { op: "appendNote"; uid: string; markdown: string } + | { op: "deleteNode"; uid: string }; +``` + +### 3) 服务端应用 ops(纯 JSON 层) + +- 以“树遍历 + uid 索引”应用变更,保证: + - 不依赖浏览器端实例; + - 不依赖 simple-mind-map 内部状态; + - 可在服务端记录变更日志。 + +## API 设计(建议新增) + +### 1) 读取导图 + +`GET /api/mindmap/[documentId]/[mindmapId]` + +返回: + +```json +{ + "mindmapId": "xxx", + "documentId": "xxx", + "data": { "data": { "text": "...", "uid": "..." }, "children": [] }, + "updatedAt": "2026-01-09T00:00:00.000Z" +} +``` + +### 2) 应用 ops(增量修改 + 自动保存) + +`POST /api/mindmap/[documentId]/[mindmapId]/ops` + +入参: + +```json +{ + "ops": [ { "op": "addChild", "parentUid": "...", "node": { "text": "...", "hyperlink": "...", "refs": [] } } ], + "actor": { "kind": "ai", "provider": "online", "model": "gemini-2.5-flash" }, + "reason": "补完该节点:……(可选)" +} +``` + +出参: + +```json +{ + "ok": true, + "applied": 7, + "data": { "data": { "text": "...", "uid": "..." }, "children": [] } +} +``` + +### 3) AI 补完(服务端编排:检索 → 生成 ops → 应用 ops) + +`POST /api/mindmap-ai/expand-node` + +入参(建议): + +```json +{ + "documentId": "xxx", + "mindmapId": "xxx", + "targetUid": "xxx", + "instruction": "请补完该节点,要求每条必须带引用", + "sources": { "rag": true, "searxng": true } +} +``` + +输出: + +```json +{ + "ok": true, + "providerUsed": "online", + "ops": [ ... ], + "applied": 7 +} +``` + +> 注意:该接口内部会调用 `/api/mindmap/[...]/ops` 做落盘,避免重复代码。 + +## 与 SearxNG / RAG 的关系 + +- 对“搜索补完”:服务端先走检索(LightRAG + 可选 SearxNG),把证据(标题/URL/snippet)整理成短上下文,再让在线 AI 输出 `MindmapOp[]`。 +- 必须要求:每个新增节点都附带 refs(url 或 pdf page),否则标记为“待核验”并限制输出量。 + +## 验收建议(后续 pw-tests) + +1. 选中某节点,点击“AI 补完”,等待生成。 +2. 导图新增 ≥ 3 个子节点。 +3. 每个新增节点包含可点击 `hyperlink` 或 `refs`。 +4. 刷新页面后仍存在(说明落盘成功)。 + diff --git a/design/mindmap-ai-v2.md b/design/mindmap-ai-v2.md new file mode 100644 index 00000000..2a124d5a --- /dev/null +++ b/design/mindmap-ai-v2.md @@ -0,0 +1,294 @@ +# Mindmap AI v2 实施计划(文档大纲 / 小结带引用 / 搜索补全) + +> 目标:把当前“仅能对话并返回 Markdown 列表”的 AI,升级为**可读可用**的“文档驱动思维导图”能力: +> 1) PDF/Word/PPT 按目录与大纲生成思维导图,并且节点可跳转到对应页/幻灯片; +> 2) 基于文档内容生成小结/知识点,并附带可点击引用(页码/链接); +> 3) 支持在节点上“搜索/查询后补完内容”(RAG + 可选联网检索)。 + +--- + +## 0. 现状与问题 + +### 0.1 当前实现(代码落点) + +- `wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx`:AI 面板目前是“本地 Ollama /api/chat 流式输出 + Markdown 解析为节点”。 +- `services/ingest_service`:已具备 **Supabase -> MinerU -> LightRAG** 的自动入库链路(用于全文检索/RAG)。 +- `services/rag_gateway`:封装了对 LightRAG 的 HTTP 调用(`/query`、`/query/data`),可作为统一 RAG 网关。 +- `wolai-frontend/src/app/onlyoffice/page.tsx`:OnlyOffice 文档查看/编辑入口(目前用于 docx/pptx/xlsx 等)。 +- `wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`:节点超链接通过 `SET_NODE_HYPERLINK` 直接写入 URL。 + +### 0.2 为什么“离想象差很远” + +当前 AI 只有“生成文本 -> 解析成节点”的能力,缺少: + +- **与文档绑定**:没有把 PDF/Word/PPT 的结构(目录/标题层级)变成导图; +- **可验证的引用**:没有“这句话来自第几页/哪一段”,因此难以沉浸式阅读与回溯; +- **补全的检索依据**:没有把“补完节点”变成“基于检索结果 + 引用”输出,容易胡编; +- **工程化形态**:没有任务、进度、缓存、失败重试、可复用数据结构。 + +--- + +## 1. 用户故事与验收标准(以你的测试文件为准) + +### 1.1 文档按目录/大纲生成导图(PDF/Word/PPT) + +**用户故事** + +- 我上传(或选中)一个文档(PDF/Word/PPT),点击“按大纲生成思维导图”,自动生成多级节点。 +- 我点击任意节点,可以跳转到该文档对应页(PDF)或对应位置(Word/PPT 至少能定位页/提示页码,理想是直接跳转)。 + +**验收** + +- 对 `wolai-frontend/test/卤化反应原理_1-9.pdf`: + - 能生成至少 2 级结构(中心主题 -> 章节 -> 小节)。 + - 至少章节级节点带可点击引用(页码/链接)。 + - 点击节点后打开的 URL 包含 `#page=`(PDF 以浏览器内建查看器为准)。 + +### 1.2 小结/知识点生成(带链接引用) + +**用户故事** + +- 我选中“生成小结”,AI 输出“关键结论/反应机理/注意点”,每条结论附带引用(页码链接或文档片段来源)。 + +**验收** + +- 对 `wolai-frontend/test/卤化反应原理测试.pdf`: + - 生成不少于 N(建议 8)条要点; + - 每条要点至少 1 个引用(页码/链接); + - 引用能点击打开对应 PDF 页。 + +### 1.3 搜索/查询后补完节点内容 + +**用户故事** + +- 我选中一个/多个节点,输入“补完方向/问题”,AI 基于检索结果补充子节点或备注,附带引用。 + +**验收** + +- 对同一 PDF 文档: + - “补完”后的内容至少包含 3 个子节点或 1 段结构化备注; + - 结论带引用(页码/链接); + - 不允许“无引用的长篇自由发挥”(默认强制引用)。 + +--- + +## 2. 总体方案(推荐架构) + +核心原则:**先结构化,再生成**;**先检索证据,再写结论**;**引用是第一公民**。 + +### 2.1 三层能力拆分 + +1) **文档解析层(Document -> Outline/Chunks)** + - 输出:`DocOutline`(层级标题 + 页码/位置)与 `DocChunks`(可引用文本块)。 +2) **RAG/生成层(Outline/Chunks -> Mindmap/Summary/Expansion)** + - 输出:思维导图节点树(含引用)、小结节点(含引用)、补全子节点(含引用)。 +3) **渲染/交互层(Mindmap UI)** + - 节点点击:打开引用的文档页/位置;支持“查看引用”“展开更多证据”。 + +### 2.2 数据来源优先级(PDF) + +按可靠性排序: + +1) PDF 自带书签/目录(`pypdf` outline)→ **最靠谱**(本次 `卤化反应原理_1-9.pdf` 没有 outline) +2) MinerU 解析结果(建议开启 `return_content_list`/`return_middle_json`)→ 可拿到更结构化的块,并可能带页信息 +3) `pypdf` 分页提取文本 + 标题检测(规则 + LLM 辅助)→ 兜底方案 + +--- + +## 3. 关键数据结构(建议新增/统一) + +### 3.1 引用结构 `NodeRef` + +用于节点的“可点击跳转”和“可追溯引用”: + +```ts +export type NodeRef = { + kind: "pdf" | "docx" | "pptx" | "url"; + assetId?: string; // 优先用 assetId,避免 URL 过期 + fileUrl?: string; // public URL 或 signed URL(兜底) + page?: number; // PDF 页码(1-based) + slide?: number; // PPT 页码/幻灯片序号(1-based) + title?: string; // 引用标题(例如“2.1 卤化机理”) + snippet?: string; // 可选:引用片段 +}; +``` + +### 3.2 节点存储方式 + +- **短链**(推荐):`node.data.refs: NodeRef[]`(自定义字段) +- **展示用超链接**:仍使用 `SET_NODE_HYPERLINK` 写入一个可点击 URL(例如 PDF `...#page=3`) +- **引用详情**:写入 `node.data.note` 或 `node.data.data.note`(保持兼容)为 Markdown: + - `- [p3] 证据片段...` + - `- [p5] ...` + +说明:simple-mind-map 对 `data` 的自定义字段容忍度较高,但要确保序列化/反序列化后不丢字段。 + +--- + +## 4. 具体功能方案 + +### 4.1 “按目录/大纲生成思维导图” + +#### 4.1.1 API(建议新增) + +新增 Next Route(前端同域,避免跨域/鉴权麻烦): + +- `POST /api/mindmap-ai/outline-to-mindmap` + - 入参:`{ assetId, documentId?, prefer: "bookmark" | "mineru" | "heuristic" }` + - 出参:`{ mindmapData, outline, refsSummary }` + +后台实现可以优先走 `services/ingest_service` 或直接复用其逻辑(后续可沉到 `wolai-backend`)。 + +#### 4.1.2 解析策略(对测试 PDF 友好) + +因为 `卤化反应原理_1-9.pdf` 没有书签: + +1) 使用 `pypdf` 逐页提取文本(已有代码可参考 `services/ingest_service/app/services/auto_indexer.py`); +2) 对每页文本做标题候选抽取(规则:编号标题如 `1.` `1.1` `(一)` 等 + 行长/标点密度); +3) 用 LLM(可走 Ollama)把候选标题整理为层级结构,并返回 `{title, level, page}`; +4) 转换为 mindmap:中心主题=文件名/第一页大标题,子节点=章节;每个章节节点写入 hyperlink `fileUrl#page=`。 + +#### 4.1.3 Word/PPT + +阶段 1 先保证: + +- Word/PPT 也能“生成大纲导图”,但跳转能力允许降级: + - 若 OnlyOffice 支持跳转 API:实现真实跳转; + - 若不支持:点击节点打开文档,并弹出“建议跳转页码/幻灯片序号”的提示(至少可用)。 + +(后续再把 Word/PPT 的“位置”提升为可跳转锚点) + +--- + +### 4.2 “小结/知识点生成(带引用)” + +#### 4.2.1 证据来源:优先 LightRAG,其次本地分页文本 + +优先走 `services/rag_gateway`: + +- `POST /rag/query`:拿到 `response + references`(LightRAG 会返回引用列表) +- `POST /rag/graph`:拿结构化分块/引用(用于“点开看证据/更多片段”) + +如果 LightRAG 返回引用信息不足以映射到页码,需要补齐“页码映射”: + +- 方案 A(推荐):在入库时把“每页”作为 chunk,并把 `file_source + page` 写入可回传字段(需要改 ingest_service 装饰文本或分块入库方式) +- 方案 B(兜底):在本地用 `pypdf` 重新做“分页文本”,对引用片段做模糊匹配定位页码 + +#### 4.2.2 输出形态 + +在 Mindmap 里提供两种落地方式: + +- 生成到“备注”(适合长文本 + 引用列表) +- 生成到“子节点”(每条要点一个子节点,子节点携带 `refs` 和 hyperlink) + +默认要求:**每条要点至少 1 个引用**(无引用则标记为“待核验”,并提示用户继续检索)。 + +--- + +### 4.3 “搜索/查询后补完节点内容” + +#### 4.3.1 两种检索源(可配置) + +1) **本地文档库检索**:LightRAG(默认) +2) **联网检索**:SearxNG(本仓库已存在 Docker 服务,可直接用) + +##### 4.3.1.1 SearxNG 接入约定(基于仓库现状) + +仓库已存在 `services/searxng-docker/.env`,其中包含: + +- `SEARXNG_BASE_URL=http://127.0.0.1:8889` +- `SEARXNG_API_TOKEN=...` + +建议接入方式: + +- 前端**不要**直连 searxng(避免 token 暴露),通过 Next Route 代理: + - `POST /api/search/searxng` + - 入参:`{ q: string, count?: number, lang?: string }` + - 出参:`{ results: Array<{ title: string, url: string, snippet?: string, engine?: string }>} ` + +SearxNG 查询接口(推荐 JSON): + +- `GET ${SEARXNG_BASE_URL}/search?q=&format=json&language=zh-CN&categories=general&safesearch=1` + +鉴权策略(需要实际跑通后确定,做成可配置): + +- 方案 A:不加鉴权(本地内网服务) +- 方案 B:携带 token(例如 `X-API-Key` / `Authorization: Bearer` 之一;以你当前 searxng 配置为准) + +> 注意:SearxNG 返回结果字段不同版本略有差异,后端代理层要做一次“结果归一化”和去重(按 url)。 + +#### 4.3.2 交互与输出 + +- 输入:用户选中节点 + “补完问题/方向” +- 系统 prompt 固定:强制输出结构化(Markdown 列表)+ 引用 +- 输出策略: + - 子节点补全:把回答拆成 3~8 个子节点追加 + - 备注补全:写入 note,并附引用列表 + +--- + +## 5. 前端 UI 改造建议(MindmapSidebar 的 AI 面板) + +把当前“模型/地址/系统提示”改为“面向功能的工作流”,保留“高级设置”折叠: + +1) **从文档生成** + - 选择文档资产(assetId)+ 生成按钮 + 进度(解析中/生成中/完成) +2) **生成小结** + - 可选:作用范围(整篇/选中节点对应章节)+ 要点数量 + 输出到(子节点/备注) +3) **补完节点** + - 输入框 + 检索源选择 + 输出到(子节点/备注) + +同时在节点右键/工具栏增加: + +- “打开引用” +- “查看引用(弹窗列出页码/片段)” + +--- + +## 6. 工程落地步骤(里程碑) + +> 建议按“先可用,再做强”的顺序推进。 + +### M1(1~2 天):PDF 大纲导图(可跳页) + +- [ ] 新增 `/api/mindmap-ai/outline-to-mindmap`(仅支持 PDF) +- [ ] 实现 “pypdf 分页文本 + 标题候选 + LLM 整理层级” +- [ ] MindmapSidebar 增加“从 PDF 生成导图”入口(基于 assetId) +- [ ] 节点写入 hyperlink:`publicFileUrl#page=` +- [ ] pw-tests:导入 `卤化反应原理_1-9.pdf`,断言生成节点数与 `#page=` 链接存在,并截图 + +### M2(2~3 天):小结生成(强制引用) + +- [ ] 接入 `services/rag_gateway` 的 `/rag/query`(前端通过 Next Route 代理) +- [ ] 让小结输出“要点 + 引用” +- [ ] 引用映射到 `#page=`(先用本地分页匹配兜底) +- [ ] pw-tests:对 `卤化反应原理测试.pdf` 生成 N 条小结并截图 + +### M3(2~4 天):搜索补全节点(RAG) + +- [ ] AI 面板增加“补完节点”模式 +- [ ] 选中节点作为上下文,拼接检索 query:LightRAG(文档内)+ SearxNG(联网) +- [ ] 新增 `POST /api/search/searxng` 作为代理(隐藏 token + 统一返回格式) +- [ ] 将 searxng 的 `title/url/snippet` 作为“外部证据”,与 LightRAG 引用一起喂给模型生成 +- [ ] 防胡编:无引用则提示“需要更多证据/换关键词” +- [ ] pw-tests:选中某节点补完,校验新增子节点与引用 + +### M4(可选):Word/PPT 真跳转 + +- [ ] 调研 OnlyOffice 是否支持跳页/跳幻灯片 API(不支持则维持降级) +- [ ] 若支持:在 `/onlyoffice` 页面接收 `page/slide` 参数并调用 DocsAPI 跳转 + +--- + +## 7. 风险与对策 + +- **PDF 无书签(当前测试文件就是)**:必须做“文本标题抽取 + LLM 结构化”的兜底。 +- **页码引用难**:短期先用“本地分页匹配”;中期把“页级分块”纳入入库,引用直接带页码。 +- **URL 过期/权限**:优先存 `assetId`,点击时再换取可用 URL(必要时扩展 `/api/media/signed-url` 支持 assetId)。 +- **性能**:解析/生成尽量放到后端(Next Route 或 wolai-backend),前端只跑轻量 UI;长任务使用 job 表/轮询。 + +--- + +## 8. 与 BlockNote 的集成点(后续) + +- Slash Menu 可加入口(例如 `/mind ai`、`/mind from pdf`),插入/更新块可用 BlockNote 的 `insertOrUpdateBlockForSlashMenu` 与 `editor.updateBlock`(见 BlockNote 官方文档的 Suggestion Menus 示例)。 diff --git a/services/ingest_service/app/core/config.py b/services/ingest_service/app/core/config.py index 5141e0ff..cd5876d0 100644 --- a/services/ingest_service/app/core/config.py +++ b/services/ingest_service/app/core/config.py @@ -1,25 +1,78 @@ -import os +from __future__ import annotations + from functools import lru_cache +from pathlib import Path +from typing import Tuple, Type + from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings.sources import ( + DotEnvSettingsSource, + EnvSettingsSource, + PydanticBaseSettingsSource, +) + + +def _repo_root() -> Path: + # services/ingest_service/app/core/config.py -> .../MNOTE + return Path(__file__).resolve().parents[4] class Settings(BaseSettings): - """FastAPI 配置,统一读取 .env 或系统环境变量""" - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore") + """Ingest Service 配置(优先读取仓库根目录的 .env.local/.env)。""" + model_config = SettingsConfigDict( + env_file=( + str(_repo_root() / ".env.local"), + str(_repo_root() / ".env"), + ), + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # 服务基础 app_name: str = Field("AIMNOTE Ingest Service", env="INGEST_APP_NAME") environment: str = Field("development", env="INGEST_ENV") api_prefix: str = "/api" - supabase_url: str = Field("http://127.0.0.1:54321", env="SUPABASE_URL") - supabase_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY") + + # Supabase(通过 Kong: http://127.0.0.1:18000) + supabase_url: str = Field("http://127.0.0.1:18000", env="SUPABASE_URL") + supabase_service_role_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY") + + # LightRAG(独立服务 7777) lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL") lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY") - ollama_base_url: str = Field("http://127.0.0.1:11434/v1", env="OLLAMA_BASE_URL") - ollama_api_key: str = Field("ollama", env="OLLAMA_API_KEY") - embeddings_model: str = Field("qwen3-embedding:8b", env="EMBEDDING_MODEL") - llm_model: str = Field("qwen3:32b", env="DEFAULT_LLM_MODEL") - rerank_model: str = Field("qwen3-reranker-4b", env="RERANK_MODEL") + + # 自动入库(rag_index_sources -> LightRAG) + auto_index_enabled: bool = Field(True, env="AUTO_INDEX_ENABLED") + auto_index_interval_seconds: int = Field(5, env="AUTO_INDEX_INTERVAL_SECONDS") + auto_index_batch_size: int = Field(5, env="AUTO_INDEX_BATCH_SIZE") + auto_index_max_attempts: int = Field(6, env="AUTO_INDEX_MAX_ATTEMPTS") + + # 删除宽限期(用于“可撤销删除”):例如用户误删后 10 分钟内可恢复,宽限期内不清理 OCR / Storage / LightRAG 资源 + delete_grace_seconds: int = Field(600, env="DELETE_GRACE_SECONDS") + + # MinerU(用于 OCR / PDF 结构化解析) + mineru_enabled: bool = Field(True, env="MINERU_ENABLED") + mineru_endpoint: str = Field("", env="MINERU_ENDPOINT") + + @classmethod + def settings_customise_sources( + cls, + settings_cls: Type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: EnvSettingsSource, + dotenv_settings: DotEnvSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> Tuple[PydanticBaseSettingsSource, ...]: + # 关键点:优先使用 .env.local/.env 覆盖系统环境变量,避免被机器上的全局 env 污染。 + return ( + dotenv_settings, + env_settings, + init_settings, + file_secret_settings, + ) @lru_cache() diff --git a/services/ingest_service/app/main.py b/services/ingest_service/app/main.py index 61f04165..2e7e0de2 100644 --- a/services/ingest_service/app/main.py +++ b/services/ingest_service/app/main.py @@ -1,10 +1,9 @@ -import asyncio from fastapi import FastAPI from app.api.routes import router as api_router from app.core.config import get_settings from app.core.logging import setup_logging -from app.services.runtime import scheduler, worker +from app.services.runtime import auto_indexer, scheduler, worker settings = get_settings() @@ -17,6 +16,17 @@ app.include_router(api_router, prefix=settings.api_prefix) @app.on_event("startup") async def _startup() -> None: scheduler.start() + if getattr(settings, "auto_index_enabled", False): + try: + await auto_indexer.startup() + except Exception: # noqa: BLE001 + # 启动阶段不阻塞服务:自动入库失败会在后续 tick 中继续重试/记录 + pass + scheduler.add_interval_job( + job_id="auto_index_tick", + func=auto_indexer.tick, + seconds=int(getattr(settings, "auto_index_interval_seconds", 5)), + ) await worker.start() diff --git a/services/ingest_service/app/services/runtime.py b/services/ingest_service/app/services/runtime.py index 1d163f98..6e8b49cd 100644 --- a/services/ingest_service/app/services/runtime.py +++ b/services/ingest_service/app/services/runtime.py @@ -1,6 +1,9 @@ from app.core.config import get_settings +from app.services.auto_indexer import AutoIndexService from app.services.job_store import InMemoryJobStore +from app.services.mineru_client import MinerUClient from app.services.scheduler import SchedulerManager +from app.services.supabase_rest import SupabaseRestAsyncClient from app.services.tasks import IngestWorker from app.services.webhooks import LightRAGWebhook @@ -11,3 +14,8 @@ scheduler = SchedulerManager() webhook = LightRAGWebhook() worker = IngestWorker(job_store=job_store, webhook=webhook) +# 自动入库依赖:Supabase + MinerU + LightRAG +supabase = SupabaseRestAsyncClient() +mineru = MinerUClient() +auto_indexer = AutoIndexService(supabase=supabase, webhook=webhook, mineru=mineru) + diff --git a/services/ingest_service/app/services/webhooks.py b/services/ingest_service/app/services/webhooks.py index a950dd7a..c9472e8b 100644 --- a/services/ingest_service/app/services/webhooks.py +++ b/services/ingest_service/app/services/webhooks.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import Any, Dict, List, Optional import httpx @@ -42,3 +42,22 @@ class LightRAGWebhook: track_id = data.get("track_id") if isinstance(data, dict) else None logger.info("LightRAG 入库请求已提交(file_source=%s, track_id=%s)", file_source, track_id) return track_id + + async def delete_documents(self, doc_ids: List[str]) -> Dict[str, Any]: + """请求 LightRAG 删除文档(delete_document 为后台任务)。 + + 返回服务端响应(通常包含 status: deletion_started/busy/not_allowed)。 + """ + if not doc_ids: + return {"status": "not_allowed", "message": "doc_ids 为空", "doc_id": ""} + + payload = { + "doc_ids": doc_ids, + "delete_file": False, + "delete_llm_cache": False, + } + async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client: + resp = await client.request("DELETE", f"{self.base_url}/documents/delete_document", json=payload) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, dict) else {"raw": data} diff --git a/supabase/migrations/20260105214122_20260105_add_rag_index_sources.sql b/supabase/migrations/20260105214122_20260105_add_rag_index_sources.sql new file mode 100644 index 00000000..793bd9f5 --- /dev/null +++ b/supabase/migrations/20260105214122_20260105_add_rag_index_sources.sql @@ -0,0 +1,281 @@ +-- 说明:为自动入库(Supabase -> LightRAG)提供队列表与触发器/函数。 +-- 编码:UTF-8 + +create table public.rag_index_sources ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + source_type text not null, + source_id uuid not null, + document_id uuid references public.documents(id) on delete set null, + user_id uuid references public.profiles(id) on delete set null, + status text not null default 'pending', + attempts integer not null default 0, + last_error text, + source_updated_at timestamp with time zone, + last_enqueued_at timestamp with time zone not null default timezone('utc', now()), + last_processed_at timestamp with time zone, + created_at timestamp with time zone not null default timezone('utc', now()), + updated_at timestamp with time zone not null default timezone('utc', now()), + constraint rag_index_sources_source_type_check check ( + source_type = any (array['document'::text, 'mindmap'::text, 'media_asset'::text]) + ), + constraint rag_index_sources_status_check check ( + status = any (array['pending'::text, 'processing'::text, 'completed'::text, 'failed'::text, 'skipped'::text]) + ) +); + +create unique index rag_index_sources_unique_source + on public.rag_index_sources (source_type, source_id); + +create index rag_index_sources_status_idx + on public.rag_index_sources (status, last_enqueued_at); + +create index rag_index_sources_workspace_status_idx + on public.rag_index_sources (workspace_id, status, last_enqueued_at desc); + +alter table public.rag_index_sources enable row level security; + +create policy "Select rag index within workspace" + on public.rag_index_sources + as permissive + for select + to public + using ( + exists ( + select 1 + from public.workspace_members wm + where wm.workspace_id = rag_index_sources.workspace_id + and wm.user_id = uid() + ) + ); + +create or replace function public.set_rag_index_sources_updated_at() +returns trigger +language plpgsql +as $function$ +begin + new.updated_at := timezone('utc', now()); + return new; +end; +$function$; + +create trigger set_rag_index_sources_updated_at +before update on public.rag_index_sources +for each row execute function public.set_rag_index_sources_updated_at(); + +create or replace function public.upsert_rag_index_source( + p_workspace_id uuid, + p_source_type text, + p_source_id uuid, + p_document_id uuid, + p_user_id uuid, + p_source_updated_at timestamp with time zone +) +returns void +language plpgsql +security definer +set search_path to 'public' +as $function$ +begin + insert into public.rag_index_sources ( + workspace_id, + source_type, + source_id, + document_id, + user_id, + status, + source_updated_at, + last_enqueued_at, + last_error + ) + values ( + p_workspace_id, + p_source_type, + p_source_id, + p_document_id, + p_user_id, + 'pending', + p_source_updated_at, + timezone('utc', now()), + null + ) + on conflict (source_type, source_id) + do update set + workspace_id = excluded.workspace_id, + document_id = excluded.document_id, + user_id = excluded.user_id, + status = 'pending', + source_updated_at = excluded.source_updated_at, + last_enqueued_at = timezone('utc', now()), + last_error = null, + updated_at = timezone('utc', now()); +end; +$function$; + +create or replace function public.enqueue_rag_for_document() +returns trigger +language plpgsql +as $function$ +begin + perform public.upsert_rag_index_source( + new.workspace_id, + 'document', + new.id, + new.id, + new.user_id, + new.updated_at + ); + return new; +end; +$function$; + +create or replace function public.enqueue_rag_for_media_asset() +returns trigger +language plpgsql +as $function$ +declare + v_user_id uuid; +begin + select user_id + into v_user_id + from public.documents + where id = new.document_id; + + perform public.upsert_rag_index_source( + new.workspace_id, + 'media_asset', + new.id, + new.document_id, + coalesce(new.created_by, v_user_id), + new.updated_at + ); + return new; +end; +$function$; + +create or replace function public.enqueue_rag_for_mindmap_node() +returns trigger +language plpgsql +as $function$ +declare + v_user_id uuid; +begin + select user_id + into v_user_id + from public.documents + where id = new.document_id; + + perform public.upsert_rag_index_source( + new.workspace_id, + 'mindmap', + new.mindmap_id, + new.document_id, + v_user_id, + new.updated_at + ); + return new; +end; +$function$; + +create trigger enqueue_rag_for_document_insert +after insert on public.documents +for each row execute function public.enqueue_rag_for_document(); + +create trigger enqueue_rag_for_document_update +after update on public.documents +for each row +when ( + old.title is distinct from new.title + or old.content is distinct from new.content + or old.raw_text is distinct from new.raw_text + or old.mindmap_data is distinct from new.mindmap_data + or old.deleted_at is distinct from new.deleted_at +) +execute function public.enqueue_rag_for_document(); + +create trigger enqueue_rag_for_media_asset_insert +after insert on public.media_assets +for each row execute function public.enqueue_rag_for_media_asset(); + +create trigger enqueue_rag_for_media_asset_update +after update on public.media_assets +for each row +when ( + old.file_url is distinct from new.file_url + or old.storage_path is distinct from new.storage_path + or old.bucket is distinct from new.bucket + or old.ocr_text is distinct from new.ocr_text + or old.ocr_status is distinct from new.ocr_status + or old.mime_type is distinct from new.mime_type +) +execute function public.enqueue_rag_for_media_asset(); + +create trigger enqueue_rag_for_mindmap_node_insert +after insert on public.mindmap_nodes +for each row execute function public.enqueue_rag_for_mindmap_node(); + +create trigger enqueue_rag_for_mindmap_node_update +after update on public.mindmap_nodes +for each row +when ( + old.data is distinct from new.data + or old.parent_id is distinct from new.parent_id + or old.block_id is distinct from new.block_id + or old.order_index is distinct from new.order_index +) +execute function public.enqueue_rag_for_mindmap_node(); + +create or replace function public.backfill_rag_index_sources() +returns integer +language plpgsql +security definer +set search_path to 'public' +as $function$ +declare + v_count integer := 0; +begin + -- 仅允许 service_role 调用,避免被普通用户滥用造成全量重索引 + if auth.role() <> 'service_role' then + raise exception 'forbidden'; + end if; + + insert into public.rag_index_sources ( + workspace_id, + source_type, + source_id, + document_id, + user_id, + status, + source_updated_at, + last_enqueued_at, + last_error + ) + select + d.workspace_id, + 'document', + d.id, + d.id, + d.user_id, + 'pending', + d.updated_at, + timezone('utc', now()), + null + from public.documents d + on conflict (source_type, source_id) + do update set + workspace_id = excluded.workspace_id, + document_id = excluded.document_id, + user_id = excluded.user_id, + status = 'pending', + source_updated_at = excluded.source_updated_at, + last_enqueued_at = timezone('utc', now()), + last_error = null, + updated_at = timezone('utc', now()); + + get diagnostics v_count = row_count; + return v_count; +end; +$function$; + +grant execute on function public.backfill_rag_index_sources() to service_role; + diff --git a/supabase/migrations/20260109072800_20260109_add_rag_delete_triggers.sql b/supabase/migrations/20260109072800_20260109_add_rag_delete_triggers.sql new file mode 100644 index 00000000..dc24582c --- /dev/null +++ b/supabase/migrations/20260109072800_20260109_add_rag_delete_triggers.sql @@ -0,0 +1,63 @@ +-- 说明:为“删除文件时清理 LightRAG 资源”补齐删除触发器,避免垃圾堆积。 +-- 编码:UTF-8 + +-- media_assets:硬删除时也要入队(让 ingest_service 删除 LightRAG doc) +create or replace function public.enqueue_rag_for_media_asset_delete() +returns trigger +language plpgsql +as $function$ +declare + v_user_id uuid; +begin + select user_id + into v_user_id + from public.documents + where id = old.document_id; + + perform public.upsert_rag_index_source( + old.workspace_id, + 'media_asset', + old.id, + old.document_id, + coalesce(old.created_by, v_user_id), + timezone('utc', now()) + ); + return old; +end; +$function$; + +drop trigger if exists enqueue_rag_for_media_asset_delete on public.media_assets; +create trigger enqueue_rag_for_media_asset_delete +after delete on public.media_assets +for each row execute function public.enqueue_rag_for_media_asset_delete(); + +-- mindmap_nodes:硬删除节点时,也入队(让 ingest_service 删除/重建 mindmap 对应索引) +create or replace function public.enqueue_rag_for_mindmap_node_delete() +returns trigger +language plpgsql +as $function$ +declare + v_user_id uuid; +begin + select user_id + into v_user_id + from public.documents + where id = old.document_id; + + perform public.upsert_rag_index_source( + old.workspace_id, + 'mindmap', + old.mindmap_id, + old.document_id, + v_user_id, + timezone('utc', now()) + ); + return old; +end; +$function$; + +drop trigger if exists enqueue_rag_for_mindmap_node_delete on public.mindmap_nodes; +create trigger enqueue_rag_for_mindmap_node_delete +after delete on public.mindmap_nodes +for each row execute function public.enqueue_rag_for_mindmap_node_delete(); + diff --git a/supabase/migrations/20260109091500_20260109_media_assets_soft_delete.sql b/supabase/migrations/20260109091500_20260109_media_assets_soft_delete.sql new file mode 100644 index 00000000..bddc10ce --- /dev/null +++ b/supabase/migrations/20260109091500_20260109_media_assets_soft_delete.sql @@ -0,0 +1,36 @@ +-- 说明:为附件(media_assets)增加“可撤销删除”的软删除字段,并让自动入库队列感知删除/恢复。 +-- 编码:UTF-8 + +alter table public.media_assets + add column if not exists deleted_at timestamp with time zone; + +alter table public.media_assets + add column if not exists deleted_by uuid references public.profiles(id) on delete set null; + +-- 说明:purged_at 表示“已过宽限期并完成清理”(删除 OCR / Storage 文件 / LightRAG 资源等) +alter table public.media_assets + add column if not exists purged_at timestamp with time zone; + +create index if not exists media_assets_deleted_at_idx + on public.media_assets (workspace_id, deleted_at); + +create index if not exists media_assets_purged_at_idx + on public.media_assets (workspace_id, purged_at); + +-- 让自动入库队列能感知 deleted_at 的变化(删除/恢复都需要入队) +drop trigger if exists enqueue_rag_for_media_asset_update on public.media_assets; +create trigger enqueue_rag_for_media_asset_update +after update on public.media_assets +for each row +when ( + old.file_url is distinct from new.file_url + or old.storage_path is distinct from new.storage_path + or old.bucket is distinct from new.bucket + or old.ocr_text is distinct from new.ocr_text + or old.ocr_status is distinct from new.ocr_status + or old.mime_type is distinct from new.mime_type + or old.deleted_at is distinct from new.deleted_at + or old.purged_at is distinct from new.purged_at +) +execute function public.enqueue_rag_for_media_asset(); + diff --git a/supabase/migrations/20260109121000_20260109_fix_rag_media_asset_delete_fk.sql b/supabase/migrations/20260109121000_20260109_fix_rag_media_asset_delete_fk.sql new file mode 100644 index 00000000..2d4d2867 --- /dev/null +++ b/supabase/migrations/20260109121000_20260109_fix_rag_media_asset_delete_fk.sql @@ -0,0 +1,35 @@ +-- 修复:删除 documents 时会级联删除 media_assets,导致 media_assets 的 DELETE 触发器 +-- 往 rag_index_sources 写入 old.document_id(此时 documents 已被删除),从而触发外键报错。 +-- 解决:当 documents 已不存在时,入队时把 document_id 置空。 + +create or replace function public.enqueue_rag_for_media_asset_delete() +returns trigger +language plpgsql +as $function$ +declare + v_user_id uuid; + v_document_id uuid; +begin + select user_id + into v_user_id + from public.documents + where id = old.document_id; + + if found then + v_document_id := old.document_id; + else + v_document_id := null; + end if; + + perform public.upsert_rag_index_source( + old.workspace_id, + 'media_asset', + old.id, + v_document_id, + coalesce(old.created_by, v_user_id), + timezone('utc', now()) + ); + return old; +end; +$function$; + diff --git a/wolai-frontend/next.config.ts b/wolai-frontend/next.config.ts index e9ffa308..a9551bfa 100644 --- a/wolai-frontend/next.config.ts +++ b/wolai-frontend/next.config.ts @@ -1,7 +1,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + turbopack: { + // 避免 monorepo/多 lockfile 场景下 root 误判,减少构建与热更新的不确定性 + root: __dirname, + }, }; export default nextConfig; diff --git a/wolai-frontend/scripts/mindmap_ai_pdf_extract.py b/wolai-frontend/scripts/mindmap_ai_pdf_extract.py new file mode 100644 index 00000000..ae2afab5 --- /dev/null +++ b/wolai-frontend/scripts/mindmap_ai_pdf_extract.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +用于 mindmap-ai v2:从 PDF 中提取分页文本。 + +注意: +- 本脚本仅做“文本抽取”,不做大纲推断(大纲交给 TS 侧 + LLM/规则)。 +- 输出为 JSON,便于 Next.js API 通过 child_process 调用并解析。 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any, Dict, List, Optional + + +def _load_reader(path: str): + try: + # pypdf (推荐) + from pypdf import PdfReader # type: ignore + return PdfReader(path) + except Exception: + # PyPDF2 兜底 + from PyPDF2 import PdfReader # type: ignore + return PdfReader(path) + + +def _extract_pages(reader, max_pages: Optional[int] = None) -> List[Dict[str, Any]]: + pages: List[Dict[str, Any]] = [] + total = len(getattr(reader, "pages", []) or []) + limit = total if max_pages is None else min(total, max_pages) + + for i in range(limit): + try: + page = reader.pages[i] + text = page.extract_text() or "" + except Exception: + text = "" + # 清理一些不可见字符,避免 JSON/解析异常 + text = text.replace("\x00", "").strip() + pages.append({"page": i + 1, "text": text}) + return pages + + +def main(argv: List[str]) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--input", required=True, help="PDF 文件路径") + parser.add_argument("--max-pages", type=int, default=50, help="最多提取页数") + args = parser.parse_args(argv) + + reader = _load_reader(args.input) + meta_title = None + try: + info = getattr(reader, "metadata", None) + meta_title = getattr(info, "title", None) if info else None + except Exception: + meta_title = None + + pages = _extract_pages(reader, max_pages=args.max_pages) + payload: Dict[str, Any] = { + "meta": {"title": meta_title}, + "pages": pages, + "totalPages": len(getattr(reader, "pages", []) or []), + } + # Windows 控制台默认编码可能是 gbk,直接写入会导致 UnicodeEncodeError + try: + sys.stdout.reconfigure(encoding="utf-8") # py3.7+ + except Exception: + pass + sys.stdout.write(json.dumps(payload, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx index df8137c0..afcbea8b 100644 --- a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx +++ b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx @@ -5,10 +5,15 @@ import type { PageOptionsState, DocumentStats } from "@/types/page-options"; interface DocumentPageProps { params: Promise<{ id: string }>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + searchParams?: Promise>; } -export default async function DocumentPage({ params }: DocumentPageProps) { +export default async function DocumentPage({ params, searchParams }: DocumentPageProps) { const { id } = await params; + const resolvedSearch = (await searchParams) ?? {}; + const openTableIdRaw = resolvedSearch?.openTableId; + const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null; const supabase = await createSupabaseServerClient(); const { data: { session }, @@ -21,7 +26,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) { const { data: document } = await supabase .from("documents") .select( - "id,title,content,updated_at,workspace_id,wide_layout,use_small_text,show_heading_numbers,show_toc,show_structure,protect_editing,show_word_count,word_count,character_count,block_count", + "id,title,updated_at,workspace_id,wide_layout,use_small_text,show_heading_numbers,show_toc,show_structure,protect_editing,show_word_count,word_count,character_count,block_count", ) .eq("user_id", session.user.id) .eq("id", id) @@ -53,9 +58,10 @@ export default async function DocumentPage({ params }: DocumentPageProps) { workspaceId={document.workspace_id} title={document.title} updatedAt={document.updated_at} - initialContent={document.content} + initialContent={null} initialOptions={initialOptions} initialStats={initialStats} + openTableId={openTableId} /> ); } diff --git a/wolai-frontend/src/app/(app)/layout.tsx b/wolai-frontend/src/app/(app)/layout.tsx index c19a1411..554aef22 100644 --- a/wolai-frontend/src/app/(app)/layout.tsx +++ b/wolai-frontend/src/app/(app)/layout.tsx @@ -10,7 +10,7 @@ import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspace import { fetchSidebarDataset } from "@/lib/sidebar-tree"; import type { SidebarInitialData } from "@/components/sidebar/types"; import { SearchPalette } from "@/components/search/search-palette"; -import { detectLocalMindmapDocs } from "@/lib/mindmap-files"; +import { detectLocalMindmapDocs, detectLocalTrashedMindmapAssets } from "@/lib/mindmap-files"; export default async function AppLayout({ children }: { children: ReactNode }) { const supabase = await createSupabaseServerClient(); @@ -37,11 +37,17 @@ export default async function AppLayout({ children }: { children: ReactNode }) { const mindmapDocs = Array.from( new Set([...(dataset.mindmapDocs ?? []), ...localMindmaps]), ); + const trashedMindmapAssets = await detectLocalTrashedMindmapAssets( + activeWorkspaceId, + dataset.documents.map((d) => d.id), + ); sidebarInitialData = { activeWorkspaceId, workspaces, documents: dataset.documents, trashedDocuments: dataset.trashedDocuments, + trashedMediaAssets: dataset.trashedMediaAssets ?? [], + trashedMindmapAssets, mediaAssets: dataset.mediaAssets, mindmapDocs, }; diff --git a/wolai-frontend/src/app/api/documents/content/route.ts b/wolai-frontend/src/app/api/documents/content/route.ts new file mode 100644 index 00000000..bf914619 --- /dev/null +++ b/wolai-frontend/src/app/api/documents/content/route.ts @@ -0,0 +1,40 @@ +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: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const url = new URL(request.url); + const documentId = url.searchParams.get("documentId") ?? ""; + + if (!documentId) { + return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); + } + + const { data: document, error } = await supabase + .from("documents") + .select("id,content") + .eq("id", documentId) + .eq("user_id", session.user.id) + .single(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + if (!document) { + return NextResponse.json({ error: "页面不存在" }, { status: 404 }); + } + + return NextResponse.json({ content: document.content ?? null }); +} + diff --git a/wolai-frontend/src/app/api/documents/copy-tree/route.ts b/wolai-frontend/src/app/api/documents/copy-tree/route.ts index 49bfbaf6..819c3683 100644 --- a/wolai-frontend/src/app/api/documents/copy-tree/route.ts +++ b/wolai-frontend/src/app/api/documents/copy-tree/route.ts @@ -325,7 +325,8 @@ export async function POST(request: Request) { .from("media_assets") .select("id,workspace_id,document_id,asset_type,file_name,file_size,mime_type,file_url,thumbnail_url,bucket,storage_path") .in("document_id", oldDocIds) - .eq("workspace_id", workspaceId); + .eq("workspace_id", workspaceId) + .is("deleted_at", null); if (assetErr) { return NextResponse.json({ error: assetErr.message }, { status: 500 }); diff --git a/wolai-frontend/src/app/api/media/assets/route.ts b/wolai-frontend/src/app/api/media/assets/route.ts index 6c717a05..b1ab554a 100644 --- a/wolai-frontend/src/app/api/media/assets/route.ts +++ b/wolai-frontend/src/app/api/media/assets/route.ts @@ -26,6 +26,7 @@ export async function GET(request: Request) { .from("media_assets") .select("*") .eq("workspace_id", workspaceId) + .is("deleted_at", null) .order("created_at", { ascending: false }) .limit(Number.isNaN(limit) ? 12 : limit); diff --git a/wolai-frontend/src/app/api/media/batch/route.ts b/wolai-frontend/src/app/api/media/batch/route.ts index f2f062fd..dd158be5 100644 --- a/wolai-frontend/src/app/api/media/batch/route.ts +++ b/wolai-frontend/src/app/api/media/batch/route.ts @@ -5,7 +5,7 @@ import { makeUniqueFileName } from "@/lib/file-tree/naming"; export const dynamic = "force-dynamic"; -type Action = "copy" | "move" | "delete" | "rename"; +type Action = "copy" | "move" | "delete" | "rename" | "restore"; interface BatchPayload { action: Action; @@ -80,14 +80,26 @@ export async function POST(request: Request) { try { switch (payload.action) { case "delete": { - await Promise.all( - assets.map(async (asset) => { - const location = resolveAssetLocation(asset); - if (!location) return; - await supabase.storage.from(location.bucket || BUCKET).remove([location.path]); - }), - ); - const { error } = await supabase.from("media_assets").delete().in("id", payload.assetIds); + // 可撤销删除:仅标记 deleted_at,真正清理(OCR/Storage/LightRAG)由后台宽限期任务处理 + const { error } = await supabase + .from("media_assets") + .update({ + deleted_at: new Date().toISOString(), + deleted_by: session.user.id, + }) + .in("id", payload.assetIds); + if (error) throw error; + return NextResponse.json({ ok: true }); + } + case "restore": { + const { error } = await supabase + .from("media_assets") + .update({ + deleted_at: null, + deleted_by: null, + purged_at: null, + }) + .in("id", payload.assetIds); if (error) throw error; return NextResponse.json({ ok: true }); } diff --git a/wolai-frontend/src/app/api/media/empty-trash/route.ts b/wolai-frontend/src/app/api/media/empty-trash/route.ts new file mode 100644 index 00000000..f71fcf8c --- /dev/null +++ b/wolai-frontend/src/app/api/media/empty-trash/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +interface EmptyTrashPayload { + workspaceId?: string; +} + +function resolveGraceSeconds(): number { + const raw = + process.env.DELETE_GRACE_SECONDS ?? + process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? + "600"; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 600; + } + return Math.floor(parsed); +} + +function makeExpiredDeletedAt(): string { + const graceSeconds = resolveGraceSeconds(); + return new Date(Date.now() - (graceSeconds + 5) * 1000).toISOString(); +} + +export async function POST(request: Request) { + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({})); + if (!workspaceId) { + return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); + } + + const { data: membership, error: membershipError } = await supabase + .from("workspace_members") + .select("workspace_id") + .eq("workspace_id", workspaceId) + .eq("user_id", session.user.id) + .limit(1); + + if (membershipError) { + return NextResponse.json({ error: membershipError.message }, { status: 500 }); + } + + if (!membership || membership.length === 0) { + return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 }); + } + + const { data: assets, error: fetchError } = await supabase + .from("media_assets") + .select("id") + .eq("workspace_id", workspaceId) + .not("deleted_at", "is", null) + .is("purged_at", null) + .limit(2000); + + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }); + } + + const assetIds = (assets ?? []).map((row) => row.id).filter(Boolean); + if (assetIds.length === 0) { + return NextResponse.json({ success: true, updated: 0 }); + } + + const { error: updateError } = await supabase + .from("media_assets") + .update({ + deleted_at: makeExpiredDeletedAt(), + deleted_by: session.user.id, + }) + .in("id", assetIds); + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }); + } + + return NextResponse.json({ success: true, updated: assetIds.length }); +} + diff --git a/wolai-frontend/src/app/api/media/ocr/route.ts b/wolai-frontend/src/app/api/media/ocr/route.ts index 7a172cc2..802a04e5 100644 --- a/wolai-frontend/src/app/api/media/ocr/route.ts +++ b/wolai-frontend/src/app/api/media/ocr/route.ts @@ -20,6 +20,7 @@ export async function POST(request: Request) { .from("media_assets") .update({ ocr_status: "processing" }) .eq("id", assetId) + .is("deleted_at", null) .limit(1); if (error) { diff --git a/wolai-frontend/src/app/api/media/purge/route.ts b/wolai-frontend/src/app/api/media/purge/route.ts new file mode 100644 index 00000000..3a9b9b8b --- /dev/null +++ b/wolai-frontend/src/app/api/media/purge/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +interface PurgePayload { + assetId?: string; +} + +function resolveGraceSeconds(): number { + const raw = + process.env.DELETE_GRACE_SECONDS ?? + process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? + "600"; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 600; + } + return Math.floor(parsed); +} + +function makeExpiredDeletedAt(): string { + const graceSeconds = resolveGraceSeconds(); + return new Date(Date.now() - (graceSeconds + 5) * 1000).toISOString(); +} + +export async function POST(request: Request) { + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { assetId }: PurgePayload = await request.json().catch(() => ({})); + if (!assetId) { + return NextResponse.json({ error: "缺少 assetId" }, { status: 400 }); + } + + const { data: asset, error: fetchError } = await supabase + .from("media_assets") + .select("id,workspace_id,deleted_at,purged_at") + .eq("id", assetId) + .single(); + + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }); + } + + if (!asset) { + return NextResponse.json({ error: "未找到附件" }, { status: 404 }); + } + + const { data: membership, error: membershipError } = await supabase + .from("workspace_members") + .select("workspace_id") + .eq("workspace_id", asset.workspace_id) + .eq("user_id", session.user.id) + .limit(1); + + if (membershipError) { + return NextResponse.json({ error: membershipError.message }, { status: 500 }); + } + + if (!membership || membership.length === 0) { + return NextResponse.json({ error: "无权操作该附件" }, { status: 403 }); + } + + if (asset.purged_at) { + return NextResponse.json({ success: true, alreadyPurged: true }); + } + + const { error: updateError } = await supabase + .from("media_assets") + .update({ + deleted_at: makeExpiredDeletedAt(), + deleted_by: session.user.id, + }) + .eq("id", assetId); + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }); + } + + return NextResponse.json({ success: true }); +} + diff --git a/wolai-frontend/src/app/api/mindmap-ai/agent/route.ts b/wolai-frontend/src/app/api/mindmap-ai/agent/route.ts new file mode 100644 index 00000000..7c2e4ca4 --- /dev/null +++ b/wolai-frontend/src/app/api/mindmap-ai/agent/route.ts @@ -0,0 +1,788 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig"; +import { loadLocalAiConfig } from "@/lib/ai/localAiConfig"; +import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat"; +import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps"; +import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore"; + +export const dynamic = "force-dynamic"; + +type AgentMessage = { role: "user" | "assistant"; content: string }; + +type AgentAttachment = { + id: string; + title: string; + fileUrl: string; + mimeType?: string | null; +}; + +type RequestPayload = { + documentId: string; + mindmapId: string; + selectedUids?: string[]; + messages: AgentMessage[]; + attachments?: AgentAttachment[]; + toolChoice?: { mode: "auto" | "manual"; tools?: string[] }; + options?: { searxng?: boolean; ai?: { provider?: "online" | "local"; model?: string } }; +}; + +type ToolName = + | "mindmap_get" + | "mindmap_get_subtree" + | "search_web" + | "mindmap_apply_ops" + | "pdf_replace_mindmap"; + +type ToolCall = + | { type: "tool"; tool: ToolName; args: Record } + | { type: "final"; message: string; summary?: string }; + +type SearxResult = { title: string; url: string; snippet?: string; engine?: string }; + +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 searchSearxng = async (q: string, count = 6): Promise => { + 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(q)}&format=json&language=zh-CN&categories=general&safesearch=1`; + + const tryFetch = async (headers: Record) => { + const res = await fetch(url, { headers, method: "GET" }); + if (!res.ok) return null; + return (await res.json().catch(() => null)) as any; + }; + + let json: any = null; + if (token) { + json = (await tryFetch({ Authorization: `Bearer ${token}` })) ?? (await tryFetch({ "X-API-Key": token })) ?? null; + } + if (!json) json = await tryFetch({}); + + const results = Array.isArray(json?.results) ? json.results : []; + return results + .map((r: any) => ({ + title: String(r?.title ?? "").trim(), + url: String(r?.url ?? "").trim(), + snippet: String(r?.content ?? r?.snippet ?? "").trim(), + engine: String(r?.engine ?? "").trim(), + })) + .filter((r: SearxResult) => r.title && safeUrlOrNull(r.url)) + .slice(0, Math.max(1, Math.min(10, count))); +}; + +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 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 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 coerceToolCall = (raw: Record): ToolCall | null => { + const type = String((raw as any)?.type ?? ""); + if (type === "final") { + return { type: "final", message: String((raw as any)?.message ?? ""), summary: String((raw as any)?.summary ?? "") || undefined }; + } + if (type === "tool") { + const tool = String((raw as any)?.tool ?? "") as ToolName; + const args = ((raw as any)?.args ?? {}) as Record; + return { type: "tool", tool, args }; + } + return null; +}; + +const normalizeAllowedTools = (payload: RequestPayload): Set => { + const mode = payload.toolChoice?.mode ?? "auto"; + if (mode !== "manual") { + return new Set(["mindmap_get", "mindmap_get_subtree", "search_web", "mindmap_apply_ops", "pdf_replace_mindmap"]); + } + const list = Array.isArray(payload.toolChoice?.tools) ? payload.toolChoice!.tools! : []; + const allowed = new Set(); + for (const x of list) { + const t = String(x || "") as ToolName; + if (t === "mindmap_get" || t === "mindmap_get_subtree" || t === "search_web" || t === "mindmap_apply_ops" || t === "pdf_replace_mindmap") { + allowed.add(t); + } + } + // 手动模式但未选择:默认仍允许读导图(避免完全不可用) + if (allowed.size === 0) allowed.add("mindmap_get"); + return allowed; +}; + +const refsFromSearx = (items: SearxResult[]): NodeRef[] => + items + .map((r) => ({ + kind: "url" as const, + fileUrl: r.url, + title: r.title, + snippet: r.snippet ? r.snippet.slice(0, 320) : undefined, + })) + .filter((x) => safeUrlOrNull(x.fileUrl)); + +const stripHtml = (value: unknown) => String(value ?? "").replace(/<[^>]+>/g, "").trim(); + +const stableStringify = (value: unknown): string => { + if (value === null) return "null"; + const t = typeof value; + if (t === "string") return JSON.stringify(value); + if (t === "number" || t === "boolean") return String(value); + if (Array.isArray(value)) return `[${value.map((x) => stableStringify(x)).join(",")}]`; + if (t === "object") { + const obj = value as Record; + const keys = Object.keys(obj).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`; + } + return JSON.stringify(String(value)); +}; + +const formatSearchResults = (items: SearxResult[]) => { + const lines: string[] = []; + for (let i = 0; i < Math.min(8, items.length); i += 1) { + const r = items[i]; + const title = String(r.title || "").trim(); + const url = String(r.url || "").trim(); + const snippet = String(r.snippet || "").replace(/\s+/g, " ").trim(); + lines.push(`${i + 1}. ${title || "(无标题)"}\n${url}${snippet ? `\n${snippet.slice(0, 240)}` : ""}`); + } + return lines.join("\n\n").trim(); +}; + +const answerFromSearchResults = async (args: { + cfg: { baseUrl: string; apiKey: string; model: string }; + modelOverride?: string | null; + question: string; + results: SearxResult[]; +}) => { + const q = String(args.question || "").trim(); + const resultsText = formatSearchResults(args.results); + if (!q) return resultsText || "(无问题)"; + + try { + const { text } = await openAiCompatibleChat( + [ + { + role: "system", + content: [ + "你是一个严谨的中文助手。", + "用户提出一个问题,你将基于提供的检索结果回答。", + "要求:", + "- 直接回答问题,给出结论;信息不确定时明确说明需要以官方为准。", + "- 尽量在回答末尾附上 1-3 个最相关链接(原样 URL)。", + "- 不要输出 JSON/不要输出 Markdown 代码块。", + ].join("\n"), + }, + { role: "user", content: `问题:${q}\n\n检索结果:\n${resultsText || "(无结果)"}` }, + ], + { + baseUrl: args.cfg.baseUrl, + apiKey: args.cfg.apiKey, + model: (args.modelOverride ?? "").trim() || args.cfg.model, + timeoutMs: 55_000, + maxTokens: 1200, + }, + ); + const out = String(text ?? "").trim(); + if (out) return out; + } catch { + // ignore + } + + return resultsText || "(未检索到结果)"; +}; + +const buildFallbackExpandOps = async (args: { + mindmap: MindmapTreeNode; + targetUid: string; + instruction: string; + useSearx: boolean; +}): Promise => { + const targetUid = String(args.targetUid || "").trim(); + const instruction = String(args.instruction || "").trim(); + const ops: MindmapOp[] = []; + const seen = new Set(); + + const results = args.useSearx && instruction ? await searchSearxng(instruction, 6).catch(() => []) : []; + for (const r of results.slice(0, 6)) { + const title = String(r.title || "").trim(); + const url = safeUrlOrNull(r.url); + const snippet = String(r.snippet || "").replace(/\s+/g, " ").trim(); + if (!title || !url) continue; + const key = `${title}|${url}`; + if (seen.has(key)) continue; + seen.add(key); + + const explain = snippet ? snippet.slice(0, 46) : "相关资料(请核验)。"; + const text = `${title.slice(0, 20)}:${explain}`; + ops.push({ + op: "addChild", + parentUid: targetUid, + node: { + text, + hyperlink: url, + refs: [ + { + kind: "url", + fileUrl: url, + title, + snippet: snippet ? snippet.slice(0, 300) : undefined, + }, + ], + }, + }); + if (ops.length >= 6) break; + } + + // 兜底:确保至少 3 条可见的“内容型”节点(不是纯链接) + const base = instruction ? instruction.slice(0, 10) : stripHtml(args.mindmap?.data?.text) || "要点"; + let i = 1; + while (ops.length < 3) { + ops.push({ + op: "addChild", + parentUid: targetUid, + node: { + text: `${base}(要点)${i}:请补充定义、条件、例子与注意事项。`, + }, + }); + i += 1; + } + + return ops; +}; + +export async function POST(request: Request) { + const payload = (await request.json().catch(() => null)) as RequestPayload | null; + if (!payload?.documentId || !payload?.mindmapId || !Array.isArray(payload.messages) || payload.messages.length === 0) { + return NextResponse.json({ error: "缺少 documentId/mindmapId/messages" }, { status: 400 }); + } + + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { data: doc, error: docError } = await supabase + .from("documents") + .select("id,title,workspace_id,mindmap_data") + .eq("id", payload.documentId) + .eq("user_id", session.user.id) + .maybeSingle(); + if (docError) { + return NextResponse.json({ error: docError.message }, { status: 400 }); + } + if (!doc) { + return NextResponse.json({ error: "页面不存在" }, { status: 404 }); + } + + const local = await readMindmapLocal(payload.documentId, payload.mindmapId); + let mindmap = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode; + // 补齐 uid,避免后续 addChild 找不到 root uid + mindmap = applyMindmapOps(mindmap, []).data; + + 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 allowed = normalizeAllowedTools(payload); + const useSearx = payload.options?.searxng !== false; + if (!useSearx) { + allowed.delete("search_web"); + } + + const selected = Array.isArray(payload.selectedUids) ? payload.selectedUids.map((x) => String(x)).filter(Boolean).slice(0, 6) : []; + const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, 10) : []; + const attachmentLines = attachments + .map((a, idx) => `${idx + 1}. id=${a.id} title=${a.title} mime=${a.mimeType ?? ""} url=${a.fileUrl}`) + .join("\n"); + + const summary = walkSummaries(mindmap, 120); + const selectedSummaries = selected + .map((uid) => { + const hit = findNodeByUid(mindmap, uid); + if (!hit) return `- uid=${uid} (not found)`; + const t = String(hit.data?.text ?? "").replace(/<[^>]+>/g, "").trim(); + return `- uid=${uid} text=${t}`; + }) + .join("\n"); + + const toolSpec = [ + { + name: "mindmap_get", + desc: "读取整张导图的精简结构(uid+text+父子关系+子数量)。", + args: { maxNodes: "number? (默认120)" }, + }, + { + name: "mindmap_get_subtree", + desc: "读取指定 uid 的子树(用于定位/精确改写)。", + args: { uid: "string", depth: "number? (默认2)", maxNodes: "number? (默认60)" }, + }, + { + name: "search_web", + desc: "使用 SearxNG 搜索,返回标题/URL/摘要;用于提供可追溯来源。", + args: { query: "string", count: "number? (默认6)" }, + }, + { + name: "mindmap_apply_ops", + desc: "对导图应用增量 ops(支持新增/删/改/加链接/加refs/加note),并自动落盘保存。", + args: { ops: "MindmapOp[]", reason: "string? (可选)" }, + }, + { + name: "pdf_replace_mindmap", + desc: "从 PDF 生成“章->节->要点”的导图并替换当前导图(会保存)。fileRef 需匹配 attachments 里的 id 或标题。", + args: { fileRef: "string", maxPages: "number? (默认9)" }, + }, + ] as const; + + const toolsText = toolSpec + .filter((t) => allowed.has(t.name as ToolName)) + .map((t) => `- ${t.name}: ${t.desc} args=${JSON.stringify(t.args)}`) + .join("\n"); + + const system = [ + "你是“Mindmap AI Agent”。你可以像 CLI 工具一样,通过工具(API)完成任务:读导图、写导图、检索、从 PDF 生成导图。", + "", + "输出格式要求(硬性):", + "你必须输出严格 JSON(不要 Markdown/不要代码块/不要解释)。", + "你只能输出以下两种之一:", + '1) {"type":"tool","tool":ToolName,"args":{...}}', + '2) {"type":"final","message":"...","summary":"...可选"}', + "", + "行为要求(硬性):", + "- 当用户明确要求“修改/生成/补完/写入导图”时,你必须至少调用一次 mindmap_apply_ops 或 pdf_replace_mindmap 来产生实际变更;不能只聊天。", + "- 当用户只是“问问题/咨询”(未要求写入导图)时:你可以不改导图;若需要检索,最多调用 search_web 1 次,然后必须输出 final 直接回答;不要反复调用工具。", + "- 新增节点优先写“内容”(可用 note 或子节点表达),并尽量带 refs(引用来源 URL 或 PDF 页码)。只有纯链接会显得呆板,应避免。", + "- 若调用 search_web,必须把搜索结果转为 refs(NodeRef.kind=url),并用于新节点或 note。", + "- ops 中的 uid 必须来自导图数据;不允许凭空捏造现有 uid。", + "- 禁止重复调用同一个工具且参数完全相同。", + "", + "可用工具列表:", + toolsText || "(无)", + ].join("\n"); + + const userIntro = [ + `documentId=${payload.documentId}`, + `mindmapId=${payload.mindmapId}`, + selected.length ? `selectedUids=${selected.join(",")}` : "selectedUids=(none)", + "", + "当前导图摘要(最多 120 个节点):", + JSON.stringify(summary, null, 2), + "", + selected.length ? `当前选中节点:\n${selectedSummaries}` : "", + attachments.length ? `附件(可用 fileRef 引用):\n${attachmentLines}` : "附件:(无)", + ] + .filter(Boolean) + .join("\n"); + + const conversation = payload.messages + .slice(-10) + .map((m) => ({ role: m.role, content: String(m.content ?? "") })); + + const maxSteps = 6; + const trace: Array<{ step: number; call: ToolCall; toolResult?: unknown }> = []; + let didMutate = false; + const toolCounts = new Map(); + + const origin = new URL(request.url).origin; + const cookie = request.headers.get("cookie") ?? ""; + + const runPdfReplace = async (fileRef: string, maxPages: number) => { + const ref = String(fileRef || "").trim(); + if (!ref) throw new Error("pdf_replace_mindmap: 缺少 fileRef"); + + const attachment = + attachments.find((a) => a.id === ref) || + attachments.find((a) => a.title === ref) || + attachments.find((a) => a.title?.includes(ref)) || + null; + if (!attachment) throw new Error(`pdf_replace_mindmap: 未找到匹配附件:${ref}`); + + const fileUrl = String(attachment.fileUrl || ""); + const testName = (() => { + try { + const u = new URL(fileUrl, "http://local"); + if (u.pathname !== "/api/mindmap-ai/test-pdf") return null; + const name = u.searchParams.get("name"); + return name ? decodeURIComponent(name) : null; + } catch { + return null; + } + })(); + const body = + testName + ? { + source: { kind: "test", name: testName }, + options: { preferProvider: provider === "local" ? "ollama" : "online", maxPages }, + } + : { + source: { kind: "url", fileUrl }, + options: { preferProvider: provider === "local" ? "ollama" : "online", maxPages }, + }; + + const res = await fetch(`${origin}/api/mindmap-ai/outline-to-mindmap`, { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify(body), + }); + const json = (await res.json().catch(() => null)) as any; + if (!res.ok) { + throw new Error(String(json?.error ?? `outline-to-mindmap 失败:${res.status}`)); + } + if (!json?.mindmapData) { + throw new Error("outline-to-mindmap 返回缺少 mindmapData"); + } + mindmap = json.mindmapData as MindmapTreeNode; + await writeMindmapLocal(payload.documentId, payload.mindmapId, mindmap, doc.title ?? "无标题"); + didMutate = true; + return { ok: true, providerUsed: json?.meta?.providerUsed ?? "unknown", title: json?.meta?.title ?? "" }; + }; + + const lastUser = [...payload.messages].reverse().find((m) => m.role === "user")?.content ?? ""; + const userIntentModify = /补完|写入|保存|生成|替换|总结|导图|节点/i.test(String(lastUser)); + const rootUid = + String(mindmap?.data?.uid || "") || + String(walkSummaries(mindmap, 1)?.[0]?.uid || ""); + const defaultTargetUid = (selected[0] || rootUid || "").trim(); + + for (let step = 1; step <= maxSteps; step += 1) { + const { text } = await openAiCompatibleChat( + [ + { role: "system", content: system }, + { role: "user", content: userIntro }, + ...conversation, + ...(trace.length + ? [ + { + role: "assistant" as const, + content: `(工具执行历史摘要)\n${trace + .map((t) => `#${t.step} ${t.call.type === "tool" ? `tool=${t.call.tool}` : "final"} `) + .join("\n")}`, + }, + ] + : []), + ], + { + baseUrl: cfg.baseUrl, + apiKey: cfg.apiKey, + model: modelOverride || cfg.model, + timeoutMs: 55_000, + maxTokens: 2200, + maxCompletionTokens: 2200, + responseFormat: "json_object", + }, + ); + + let json = tryExtractJsonObject(text); + let call = json ? coerceToolCall(json) : null; + // 若解析失败,再用更小上下文重试一次;仍失败则走兜底(保证可用性,不返回 500) + if (!call) { + try { + const minimalSystem = [ + "你是 Mindmap AI Agent。", + "你必须只输出一个 JSON:", + '1) {"type":"tool","tool":ToolName,"args":{...}} 或 2) {"type":"final","message":"..."}', + "", + "可用工具:", + toolsText || "(无)", + ].join("\n"); + const minimalUser = [ + `documentId=${payload.documentId}`, + `mindmapId=${payload.mindmapId}`, + defaultTargetUid ? `targetUid=${defaultTargetUid}` : "", + `用户请求:${String(lastUser).slice(0, 500)}`, + "", + "导图根节点:", + JSON.stringify({ uid: rootUid, text: stripHtml(mindmap?.data?.text) }, null, 2), + ] + .filter(Boolean) + .join("\n"); + const retry = await openAiCompatibleChat( + [ + { role: "system", content: minimalSystem }, + { role: "user", content: minimalUser }, + ], + { + baseUrl: cfg.baseUrl, + apiKey: cfg.apiKey, + model: modelOverride || cfg.model, + timeoutMs: 35_000, + maxTokens: 900, + maxCompletionTokens: 900, + responseFormat: "json_object", + }, + ); + json = tryExtractJsonObject(retry.text); + call = json ? coerceToolCall(json) : null; + } catch { + call = null; + } + } + + if (!call) { + // 兜底:若用户只是问问题,则不要擅自写入导图;优先给出基于检索的回答 + if (!userIntentModify) { + const q = String(lastUser || "").trim(); + const results = allowed.has("search_web") && q ? await searchSearxng(q, 6).catch(() => []) : []; + if (results.length) { + trace.push({ + step, + call: { type: "tool", tool: "search_web", args: { query: q, count: 6 } }, + toolResult: results, + }); + } + const answer = results.length + ? await answerFromSearchResults({ cfg, modelOverride, question: q, results }) + : "AI 输出未能解析(未执行任何导图写入)。你可以:1) 开启联网检索;2) 换一个模型;3) 更明确说明要写入导图还是仅回答问题。"; + trace.push({ step, call: { type: "final", message: answer } }); + return NextResponse.json({ ok: true, message: answer, data: mindmap, trace }); + } + + // 用户明确要改导图:直接补完(写入并保存),避免用户看到“没做任何事” + const ops = await buildFallbackExpandOps({ + mindmap, + targetUid: defaultTargetUid || rootUid, + instruction: String(lastUser || "补完选中节点"), + useSearx: allowed.has("search_web"), + }); + const { data: next, applied, errors } = applyMindmapOps(mindmap, ops); + mindmap = next; + await writeMindmapLocal(payload.documentId, payload.mindmapId, mindmap, doc.title ?? "无标题"); + didMutate = applied > 0 || didMutate; + trace.push({ + step, + call: { type: "tool", tool: "mindmap_apply_ops", args: { ops, reason: "fallback: ai-json-parse-failed" } }, + toolResult: { applied, errors, fallback: true }, + }); + return NextResponse.json({ + ok: true, + message: "已补完并保存(AI 输出未能解析,已自动使用兜底策略确保可用)。", + data: mindmap, + trace, + }); + } + + if (call.type === "final") { + trace.push({ step, call }); + return NextResponse.json({ + ok: true, + message: call.message, + summary: call.summary ?? null, + data: mindmap, + trace, + }); + } + + // 避免模型在同一个工具上打转(导致“已达最大执行步数”) + const toolKey = `${call.tool}:${stableStringify(call.args)}`; + const prevCount = toolCounts.get(toolKey) ?? 0; + toolCounts.set(toolKey, prevCount + 1); + if (prevCount >= 1) { + trace.push({ step, call, toolResult: { warning: "检测到重复工具调用,已提前停止并返回结果。" } }); + const lastSearch = [...trace] + .reverse() + .find((t) => t.call.type === "tool" && t.call.tool === "search_web")?.toolResult as SearxResult[] | undefined; + const answer = + !userIntentModify && Array.isArray(lastSearch) && lastSearch.length + ? await answerFromSearchResults({ cfg, modelOverride, question: String(lastUser || ""), results: lastSearch }) + : "检测到重复工具调用,已停止。你可以换一个模型或提供更明确的目标/限制。"; + trace.push({ step, call: { type: "final", message: answer } }); + return NextResponse.json({ ok: true, message: answer, data: mindmap, trace }); + } + + if (!allowed.has(call.tool)) { + trace.push({ step, call, toolResult: { error: `工具未被允许:${call.tool}` } }); + continue; + } + + try { + if (call.tool === "mindmap_get") { + const maxNodes = Number(call.args.maxNodes ?? 120) || 120; + const result = walkSummaries(mindmap, Math.max(20, Math.min(300, maxNodes))); + trace.push({ step, call, toolResult: result }); + continue; + } + + if (call.tool === "mindmap_get_subtree") { + const uid = String(call.args.uid ?? ""); + const depth = Number(call.args.depth ?? 2) || 2; + const maxNodes = Number(call.args.maxNodes ?? 60) || 60; + const hit = findNodeByUid(mindmap, uid); + const result = hit ? summarizeSubtree(hit, Math.max(1, Math.min(6, depth)), Math.max(10, Math.min(200, maxNodes))) : null; + trace.push({ step, call, toolResult: result }); + continue; + } + + if (call.tool === "search_web") { + const query = String(call.args.query ?? "").trim(); + const count = Number(call.args.count ?? 6) || 6; + const result = query ? await searchSearxng(query, count) : []; + trace.push({ step, call, toolResult: result }); + // 若用户只是问问题(非写导图),拿到检索结果后直接回答,避免继续 tool loop + if (!userIntentModify && query && Array.isArray(result) && result.length) { + const answer = await answerFromSearchResults({ + cfg, + modelOverride, + question: String(lastUser || query), + results: result, + }); + trace.push({ step, call: { type: "final", message: answer } }); + return NextResponse.json({ ok: true, message: answer, data: mindmap, trace }); + } + continue; + } + + if (call.tool === "mindmap_apply_ops") { + const opsRaw = (call.args as any)?.ops; + const ops = Array.isArray(opsRaw) ? (opsRaw as MindmapOp[]) : []; + const safeOps = ops.filter((op) => op && typeof op === "object" && typeof (op as any).op === "string").slice(0, 80); + + // 若用户开启 searxng 且 ops 中缺 refs,可自动把最近一次 search_web 的结果补到 note/refs(避免纯链接/无来源) + const lastSearch = [...trace] + .reverse() + .find((t) => t.call.type === "tool" && t.call.tool === "search_web")?.toolResult as SearxResult[] | undefined; + const inferredRefs = Array.isArray(lastSearch) ? refsFromSearx(lastSearch).slice(0, 6) : []; + const patchedOps: MindmapOp[] = safeOps.map((op) => { + if ((op as any)?.op !== "addChild" && (op as any)?.op !== "addSiblingAfter") return op; + const node = (op as any).node ?? {}; + const hasRefs = Array.isArray(node.refs) && node.refs.length > 0; + if (hasRefs || inferredRefs.length === 0) return op; + return { + ...op, + node: { + ...node, + refs: inferredRefs, + }, + } as MindmapOp; + }); + + const { data: next, applied, errors } = applyMindmapOps(mindmap, patchedOps); + mindmap = next; + await writeMindmapLocal(payload.documentId, payload.mindmapId, mindmap, doc.title ?? "无标题"); + if (applied > 0) didMutate = true; + trace.push({ step, call, toolResult: { applied, errors } }); + continue; + } + + if (call.tool === "pdf_replace_mindmap") { + const fileRef = String(call.args.fileRef ?? "").trim(); + const maxPages = Number(call.args.maxPages ?? 9) || 9; + const result = await runPdfReplace(fileRef, Math.max(1, Math.min(40, maxPages))); + trace.push({ step, call, toolResult: result }); + continue; + } + } catch (e) { + trace.push({ step, call, toolResult: { error: e instanceof Error ? e.message : String(e) } }); + continue; + } + } + + if (!didMutate && userIntentModify && allowed.has("mindmap_apply_ops") && defaultTargetUid) { + const ops = await buildFallbackExpandOps({ + mindmap, + targetUid: defaultTargetUid, + instruction: String(lastUser || "补完选中节点"), + useSearx: allowed.has("search_web"), + }); + const { data: next, applied, errors } = applyMindmapOps(mindmap, ops); + mindmap = next; + await writeMindmapLocal(payload.documentId, payload.mindmapId, mindmap, doc.title ?? "无标题"); + trace.push({ + step: maxSteps + 1, + call: { type: "tool", tool: "mindmap_apply_ops", args: { ops, reason: "fallback: maxSteps-reached" } }, + toolResult: { applied, errors, fallback: true }, + }); + return NextResponse.json({ + ok: true, + message: "已补完并保存(AI 未在限定步数内完成工具调用,已自动使用兜底策略)。", + data: mindmap, + trace, + }); + } + + // 若用户只是问问题(不写导图),但模型卡在 tool loop:尽量基于最后一次检索结果给出回答 + const lastSearch = [...trace] + .reverse() + .find((t) => t.call.type === "tool" && t.call.tool === "search_web")?.toolResult as SearxResult[] | undefined; + if (!userIntentModify && Array.isArray(lastSearch) && lastSearch.length) { + const answer = await answerFromSearchResults({ cfg, modelOverride, question: String(lastUser || ""), results: lastSearch }); + trace.push({ step: maxSteps + 1, call: { type: "final", message: answer } }); + return NextResponse.json({ ok: true, message: answer, data: mindmap, trace }, { status: 200 }); + } + + return NextResponse.json( + { + ok: true, + message: "已达到最大执行步数,已停止。你可以补充更明确的目标或限制。", + data: mindmap, + trace, + }, + { status: 200 }, + ); +} diff --git a/wolai-frontend/src/app/api/mindmap-ai/assets/route.ts b/wolai-frontend/src/app/api/mindmap-ai/assets/route.ts new file mode 100644 index 00000000..db77ae50 --- /dev/null +++ b/wolai-frontend/src/app/api/mindmap-ai/assets/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { detectLocalMindmapFiles } from "@/lib/mindmap-files"; +import type { MediaAsset } from "@/types/media"; +import path from "path"; +import { promises as fs } from "fs"; + +export const dynamic = "force-dynamic"; + +type AgentAssetItem = { + kind: "media" | "local-mindmap" | "test-pdf"; + id: string; + title: string; + fileUrl: string; + mimeType?: string | null; + assetType?: string | null; + fileName?: string | null; +}; + +const TEST_DIR = path.join(process.cwd(), "test"); + +async function listTestPdfs(): Promise { + try { + const entries = await fs.readdir(TEST_DIR); + return entries + .filter((name) => name.toLowerCase().endsWith(".pdf")) + .slice(0, 200) + .map((name) => ({ + kind: "test-pdf" as const, + id: `test-pdf:${name}`, + title: name, + fileUrl: `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(name)}`, + mimeType: "application/pdf", + assetType: "file", + fileName: name, + })); + } catch { + return []; + } +} + +export async function GET(request: Request) { + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const documentId = String(searchParams.get("documentId") ?? "").trim(); + const q = String(searchParams.get("q") ?? "").trim().toLowerCase(); + if (!documentId) { + return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); + } + + const { data: doc, error: docError } = await supabase + .from("documents") + .select("id,workspace_id,title") + .eq("id", documentId) + .eq("user_id", session.user.id) + .maybeSingle(); + if (docError) { + return NextResponse.json({ error: docError.message }, { status: 400 }); + } + if (!doc) { + return NextResponse.json({ error: "页面不存在" }, { status: 404 }); + } + + const { data: mediaRows } = await supabase + .from("media_assets") + .select("id,asset_type,file_url,file_name,mime_type,document_id,workspace_id,deleted_at") + .eq("document_id", documentId) + .eq("workspace_id", doc.workspace_id) + .is("deleted_at", null) + .order("created_at", { ascending: false }) + .limit(200); + + const mediaAssets = ((mediaRows ?? []) as MediaAsset[]).map((row) => ({ + kind: "media" as const, + id: String(row.id), + title: String(row.file_name ?? row.id ?? "附件"), + fileUrl: String(row.file_url ?? ""), + mimeType: (row as any).mime_type ?? null, + assetType: (row as any).asset_type ?? null, + fileName: (row as any).file_name ?? null, + })); + + const localMindmaps = (await detectLocalMindmapFiles([documentId])) + .filter((x) => x.documentId === documentId) + .map((x) => ({ + kind: "local-mindmap" as const, + id: `mindmap:${x.mindmapId}`, + title: x.fileName, + fileUrl: `/documents/${documentId}/${x.fileName}`, + mimeType: "application/json", + assetType: "mindmap", + fileName: x.fileName, + })); + + const testPdfs = await listTestPdfs(); + + let items: AgentAssetItem[] = [...localMindmaps, ...mediaAssets, ...testPdfs]; + if (q) { + items = items.filter((it) => { + const hay = `${it.title} ${it.fileName ?? ""}`.toLowerCase(); + return hay.includes(q); + }); + } + + return NextResponse.json({ + ok: true, + workspaceId: doc.workspace_id, + documentId, + items, + }); +} + diff --git a/wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts b/wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts new file mode 100644 index 00000000..b6b76b0c --- /dev/null +++ b/wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts @@ -0,0 +1,311 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig"; +import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat"; +import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps"; +import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore"; + +type RequestPayload = { + documentId: string; + mindmapId: string; + targetUid: string; + instruction?: string; + sources?: { searxng?: boolean; rag?: boolean }; +}; + +type SearxResult = { title: string; url: string; snippet?: string; engine?: string }; + +const defaultMindmapData = { + data: { text: "中心主题" }, + children: [], +}; + +const findNodeByUid = (root: any, uid: string): any | null => { + const target = String(uid || ""); + if (!target) return null; + const walk = (node: any): any | null => { + const nuid = String(node?.data?.uid || node?.uid || ""); + if (nuid === target) return node; + const children = Array.isArray(node?.children) ? node.children : []; + for (const c of children) { + const hit = walk(c); + if (hit) return hit; + } + return null; + }; + return walk(root); +}; + +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 searchSearxng = async (q: string, count = 5): Promise => { + 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(q)}&format=json&language=zh-CN&categories=general&safesearch=1`; + + const tryFetch = async (headers: Record) => { + const res = await fetch(url, { headers, method: "GET" }); + if (!res.ok) return null; + return (await res.json().catch(() => null)) as any; + }; + + let json: any = null; + if (token) { + json = + (await tryFetch({ Authorization: `Bearer ${token}` })) ?? + (await tryFetch({ "X-API-Key": token })) ?? + null; + } + if (!json) { + json = await tryFetch({}); + } + const results = Array.isArray(json?.results) ? json.results : []; + const mapped: SearxResult[] = results + .map((r: any) => ({ + title: String(r?.title ?? "").trim(), + url: String(r?.url ?? "").trim(), + snippet: String(r?.content ?? r?.snippet ?? "").trim(), + engine: String(r?.engine ?? "").trim(), + })) + .filter((r: SearxResult) => r.title && safeUrlOrNull(r.url)) + .slice(0, Math.max(1, Math.min(10, count))); + return mapped; +}; + +const coerceOpsFromAiJson = (raw: Record): MindmapOp[] => { + const ops = (raw as any)?.ops; + return Array.isArray(ops) ? (ops as MindmapOp[]) : []; +}; + +export async function POST(request: Request) { + const payload = (await request.json().catch(() => null)) as RequestPayload | null; + if (!payload?.documentId || !payload?.mindmapId || !payload?.targetUid) { + return NextResponse.json({ error: "缺少 documentId/mindmapId/targetUid" }, { status: 400 }); + } + + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + // 校验页面归属 + const { data: doc, error: docError } = await supabase + .from("documents") + .select("id,title,mindmap_data") + .eq("id", payload.documentId) + .eq("user_id", session.user.id) + .maybeSingle(); + if (docError) { + return NextResponse.json({ error: docError.message }, { status: 400 }); + } + if (!doc) { + return NextResponse.json({ error: "页面不存在" }, { status: 404 }); + } + + const local = await readMindmapLocal(payload.documentId, payload.mindmapId); + const baseData = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode; + + const target = findNodeByUid(baseData, payload.targetUid); + if (!target) { + return NextResponse.json({ error: "未找到目标节点(uid 不存在)" }, { status: 404 }); + } + + const targetText = String(target?.data?.text ?? "").trim(); + const currentChildren = Array.isArray(target?.children) + ? target.children + .map((c: any) => String(c?.data?.text ?? "").trim()) + .filter(Boolean) + .slice(0, 20) + : []; + + const instruction = String(payload.instruction ?? "").trim(); + const query = [targetText, instruction].filter(Boolean).join(" "); + + const useSearx = payload.sources?.searxng !== false; + const searxResults = useSearx && query ? await searchSearxng(query, 6).catch(() => []) : []; + + const cfg = await loadOnlineAiConfig().catch(() => null); + if (!cfg) { + return NextResponse.json({ error: "未找到在线 AI 配置(ai.md 或环境变量)" }, { status: 500 }); + } + + const system = [ + "你是一个“思维导图补完器”。", + "你必须输出严格 JSON(不要 Markdown/不要代码块/不要解释)。", + "你只能输出 {\"ops\": MindmapOp[]} 这一个对象。", + "默认策略:为 targetUid 新增 3~6 个子节点(addChild)。", + "每个新增节点必须提供 text,并尽量提供 hyperlink 与 refs(引用来自搜索结果 URL)。", + "不要删除/改名已有节点;不要输出 addSiblingAfter/updateText/deleteNode。", + ].join("\n"); + + const user = [ + `documentId=${payload.documentId}`, + `mindmapId=${payload.mindmapId}`, + `targetUid=${payload.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)。", + "- 输出规模控制:最多 6 个节点。", + ] + .filter(Boolean) + .join("\n"); + + let finishReason = ""; + let ops: MindmapOp[] = []; + try { + const { text, raw } = await openAiCompatibleChat( + [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + { + baseUrl: cfg.baseUrl, + apiKey: cfg.apiKey, + model: cfg.model, + timeoutMs: 40_000, + maxTokens: 1800, + maxCompletionTokens: 1800, + responseFormat: "json_object", + }, + ); + + finishReason = String((raw as any)?.choices?.[0]?.finish_reason ?? ""); + + const json = tryExtractJsonObject(text); + if (json) { + ops = coerceOpsFromAiJson(json); + // 安全收敛:仅允许 addChild 且 parentUid==targetUid + ops = ops + .filter((op) => op && typeof op === "object" && (op as any).op === "addChild") + .filter((op) => String((op as any).parentUid || "") === payload.targetUid) + .slice(0, 8); + } + } catch { + // ignore: 后续走兜底策略 + ops = []; + } + + // 再做一次补齐/校验:refs/hyperlink + const fallbackRefsFrom = (r: SearxResult): NodeRef[] => [ + { + kind: "url", + fileUrl: r.url, + title: r.title, + snippet: r.snippet ? r.snippet.slice(0, 300) : undefined, + }, + ]; + + const existed = new Set(currentChildren); + const fixed: MindmapOp[] = []; + for (const op of ops) { + const node = (op as any).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 hasRefUrl = refs.some((x) => x && x.kind === "url" && safeUrlOrNull((x as any).fileUrl)); + + let finalRefs = refs; + if (!hasRefUrl && searxResults.length) { + finalRefs = fallbackRefsFrom(searxResults[0]); + } + + fixed.push({ + op: "addChild", + parentUid: payload.targetUid, + node: { + text: textVal, + ...(href ? { hyperlink: href } : {}), + ...(finalRefs.length ? { refs: finalRefs } : {}), + }, + }); + if (fixed.length >= 6) break; + } + + // 兜底:若 AI 没产出有效 ops,则直接用搜索结果生成节点(保证功能可用 + 可追溯) + if (!fixed.length && searxResults.length) { + for (const r of 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: payload.targetUid, + node: { + text: title, + hyperlink: url, + refs: fallbackRefsFrom(r), + }, + }); + if (fixed.length >= 6) break; + } + } + + if (!fixed.length) { + // 最后兜底:至少给出 3 个“待核验”节点(无引用) + const base = targetText || "补完"; + for (let i = 1; i <= 3; i++) { + fixed.push({ + op: "addChild", + parentUid: payload.targetUid, + node: { + text: `${base}(待核验)${i}`, + }, + }); + } + } + + const { data: nextData, applied, errors } = applyMindmapOps(baseData, fixed); + await writeMindmapLocal(payload.documentId, payload.mindmapId, nextData, doc.title ?? "无标题"); + + return NextResponse.json({ + ok: true, + providerUsed: "online", + applied, + errors, + ops: fixed, + data: nextData, + meta: { + finishReason, + searched: useSearx, + searxCount: searxResults.length, + }, + }); +} diff --git a/wolai-frontend/src/app/api/mindmap-ai/outline-to-mindmap/route.ts b/wolai-frontend/src/app/api/mindmap-ai/outline-to-mindmap/route.ts new file mode 100644 index 00000000..c4786872 --- /dev/null +++ b/wolai-frontend/src/app/api/mindmap-ai/outline-to-mindmap/route.ts @@ -0,0 +1,1137 @@ +import { NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; +import os from "os"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig"; +import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat"; + +export const dynamic = "force-dynamic"; + +const execFileAsync = promisify(execFile); + +type OutlineItem = { + title: string; + level: number; // 1-based + page: number; // 1-based +}; + +type RequestPayload = { + source: + | { kind: "test"; name: string } + | { kind: "url"; fileUrl: string; title?: string }; + ollama?: { + // 兼容 MindmapSidebar 目前的默认值:完整 /api/chat 地址 + baseUrl?: string; + model?: string; + }; + options?: { + maxPages?: number; + maxCandidates?: number; + preferProvider?: "online" | "ollama" | "heuristic"; + forceHeuristic?: boolean; + ollamaTimeoutMs?: number; + onlineTimeoutMs?: number; + }; +}; + +const TEST_PDF_DIR = path.join(process.cwd(), "test"); + +const safeResolveTestPdf = (name: string) => { + const trimmed = (name ?? "").trim(); + if (!trimmed) return null; + if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) return null; + if (!trimmed.toLowerCase().endsWith(".pdf")) return null; + return path.join(TEST_PDF_DIR, trimmed); +}; + +const cleanExtractedText = (value: string) => { + const v = String(value ?? ""); + // 1) 移除 NUL 与不可见控制字符 + // 2) 清理超长英文串(常见于扫描/坏编码提取产生的乱码) + // 3) 统一空白(保留换行,便于标题/段落检测) + return v + .replace(/\u0000/g, "") + .replace(/[\u0001-\u0008\u000B\u000C\u000E-\u001F]/g, " ") + .replace(/\uFFFD/g, "") // � 替换字符(坏编码/抽取失败常见) + .replace(/[\u200B-\u200D\uFEFF]/g, "") // 零宽字符 + .replace(/[A-Za-z]{8,}/g, "") + .replace(/[ \t\f\v]+/g, " ") + .replace(/\r\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +}; + +const normalizeLine = (value: string) => cleanExtractedText(value); + +const extractHeadingCandidates = (pages: { page: number; text: string }[], maxCandidates: number) => { + const candidates: { page: number; line: string }[] = []; + const seen = new Set(); + + const isMostlyGarbled = (line: string) => { + const s = line.replace(/\s+/g, ""); + if (!s) return true; + const len = s.length; + if (len < 2) return true; + const cjk = (s.match(/[\u4e00-\u9fff]/g) ?? []).length; + const digits = (s.match(/\d/g) ?? []).length; + const letters = (s.match(/[A-Za-z]/g) ?? []).length; + // 太像“乱码/噪声”:基本没有中文且长度不短 + if (cjk === 0 && len >= 10) return true; + // 非中文占比极高且长度较长 + if (len >= 14 && cjk / len < 0.12 && (letters + digits) / len > 0.55) return true; + // 连续符号堆叠 + if (/^[\W_]{8,}$/.test(s)) return true; + return false; + }; + + const isJunk = (line: string) => { + const l = line.trim(); + if (!l) return true; + if (l.length < 2) return true; + if (l.length > 80) return true; + if (/^第?\s*\d+\s*页$/i.test(l)) return true; + if (/^(http|https):\/\//i.test(l)) return true; + if (isMostlyGarbled(l)) return true; + return false; + }; + + const patterns: { re: RegExp; weight: number }[] = [ + { re: /^第[一二三四五六七八九十]+[章节篇]\s*\S+/, weight: 5 }, + { re: /^\d+(\.\d+){0,4}\s+\S+/, weight: 5 }, + { re: /^[一二三四五六七八九十]+[、..]\s*\S+/, weight: 4 }, + { re: /^[((][一二三四五六七八九十]+[))]\s*\S+/, weight: 3 }, + { re: /^(?\d+)\s*\S+/, weight: 2 }, + ]; + + const add = (page: number, line: string) => { + const key = `${page}:${line}`; + if (seen.has(key)) return; + seen.add(key); + candidates.push({ page, line }); + }; + + for (const p of pages) { + const lines = (p.text || "") + .split(/\r?\n/) + .map((l) => normalizeLine(l)) + .filter((l) => !isJunk(l)); + + // 先按规则挑“像标题”的行 + for (const line of lines) { + let score = 0; + for (const pat of patterns) { + if (pat.re.test(line)) score += pat.weight; + } + // 中文文档常见:标题行标点少、较短 + if (line.length <= 30) score += 1; + if (!/[。?!;,]/.test(line)) score += 1; + + if (score >= 5) { + add(p.page, line); + } + if (candidates.length >= maxCandidates) break; + } + if (candidates.length >= maxCandidates) break; + + // 若这一页没有候选,则取前若干行兜底(避免完全无结构) + const hasCandidateThisPage = candidates.some((c) => c.page === p.page); + if (!hasCandidateThisPage) { + for (const line of lines.slice(0, 3)) { + add(p.page, line); + if (candidates.length >= maxCandidates) break; + } + } + if (candidates.length >= maxCandidates) break; + } + + return candidates.slice(0, maxCandidates); +}; + +type TocEntry = { title: string; page: number; sourcePage: number }; + +const extractTocEntries = (pages: { page: number; text: string }[], maxPage: number, limit = 120): TocEntry[] => { + const linesForPage = (text: string) => + String(text || "") + .split(/\r?\n/) + .map((l) => cleanExtractedText(l)) + .map((l) => l.replace(/[·•●]+/g, "·").trim()) + .filter(Boolean); + + const tocLineToEntry = (line: string, maxP: number): { title: string; page: number } | null => { + const l = line.replace(/\s+/g, " ").trim(); + if (!l) return null; + if (l.length < 3 || l.length > 120) return null; + if (/^目录\s*$/.test(l) || /^目\s*录\s*$/.test(l)) return null; + if (/^第?\s*\d+\s*页$/i.test(l)) return null; + if (/(copyright|all rights|isbn)/i.test(l)) return null; + if (/[�]/.test(l)) return null; + + // 常见目录行:标题 + 引导点线 + 页码 + const m1 = l.match(/^(.+?)\s*(?:\.{2,}|…{2,}|·{2,}|—{2,}|-{2,}|_{2,})\s*(\d{1,3})\s*$/); + if (m1?.[1] && m1[2]) { + const title = m1[1].replace(/\s+/g, " ").trim(); + const page = Number(m1[2]); + if (!title) return null; + if (!Number.isFinite(page) || page < 1 || page > maxP) return null; + return { title, page: Math.floor(page) }; + } + + // 备选:末尾直接跟页码(无引导线) + const m2 = l.match(/^(.+?)\s+(\d{1,3})\s*$/); + if (m2?.[1] && m2[2]) { + const title = m2[1].replace(/\s+/g, " ").trim(); + const page = Number(m2[2]); + if (!title) return null; + if (!Number.isFinite(page) || page < 1 || page > maxP) return null; + // 过滤像“正文句子末尾刚好有数字”的情况:要求 title 看起来像标题 + const looksLikeHeading = + /^第[一二三四五六七八九十]+[章节篇]\b/.test(title) || + /^\d+(\.\d+){0,4}\s+/.test(title) || + /^[一二三四五六七八九十]+[、..]\s*/.test(title) || + /^[((][一二三四五六七八九十]+[))]\s*/.test(title); + if (!looksLikeHeading) return null; + return { title, page: Math.floor(page) }; + } + + return null; + }; + + const tocScore = (text: string) => { + const lines = linesForPage(text); + if (!lines.length) return 0; + let score = 0; + if (lines.some((l) => /^(目录|目\s*录)\s*$/.test(l))) score += 8; + // 目录页通常含有大量“点线+页码”形式 + const leaderHits = lines.filter((l) => /(\.{2,}|…{2,}|·{2,}|—{2,}|-{2,}|_{2,})\s*\d{1,3}\s*$/.test(l)).length; + score += Math.min(12, leaderHits); + const numberedHits = lines.filter((l) => /^第[一二三四五六七八九十]+[章节篇]/.test(l) || /^\d+(\.\d+){0,3}\s+/.test(l)).length; + score += Math.min(6, Math.floor(numberedHits / 2)); + return score; + }; + + const tocPages = [...pages] + .map((p) => ({ page: p.page, text: p.text, score: tocScore(p.text) })) + .filter((p) => p.score >= 10) + .sort((a, b) => b.score - a.score || a.page - b.page) + .slice(0, 3); + + const out: TocEntry[] = []; + const seen = new Set(); + for (const p of tocPages) { + const lines = linesForPage(p.text); + for (const line of lines) { + const entry = tocLineToEntry(line, maxPage); + if (!entry) continue; + const title = entry.title.replace(/\s+/g, " ").trim(); + if (!title) continue; + const key = `${entry.page}:${title}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ title, page: entry.page, sourcePage: p.page }); + if (out.length >= limit) break; + } + if (out.length >= limit) break; + } + + out.sort((a, b) => (a.page - b.page) || a.title.localeCompare(b.title, "zh")); + return out.slice(0, limit); +}; + +const inferLevelFromTitle = (title: string) => { + const t = title.trim(); + const m = t.match(/^(\d+(?:\.\d+){0,4})\s+/); + if (m?.[1]) { + return Math.max(1, m[1].split(".").length); + } + if (/^第[一二三四五六七八九十]+[章节篇]/.test(t)) return 1; + if (/^[一二三四五六七八九十]+[、..]/.test(t)) return 1; + if (/^[((][一二三四五六七八九十]+[))]/.test(t)) return 2; + if (/^(?\d+)/.test(t)) return 2; + return 2; +}; + +const buildOutlineHeuristic = (candidates: { page: number; line: string }[]): OutlineItem[] => { + const out: OutlineItem[] = []; + const seen = new Set(); + for (const c of candidates) { + const title = c.line.trim(); + if (!title) continue; + const key = `${c.page}:${title}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ + title, + level: Math.min(4, Math.max(1, inferLevelFromTitle(title))), + page: Math.max(1, c.page), + }); + } + return out; +}; + +const extractJsonArray = (value: string): unknown[] | null => { + const s = value ?? ""; + const start = s.indexOf("["); + const end = s.lastIndexOf("]"); + if (start === -1 || end === -1 || end <= start) return null; + try { + const parsed = JSON.parse(s.slice(start, end + 1)); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +}; + +const normalizeOutlineItems = (raw: unknown, maxPage: number): OutlineItem[] => { + if (!Array.isArray(raw)) return []; + const items: OutlineItem[] = []; + const seen = new Set(); + for (const it of raw) { + if (!it || typeof it !== "object") continue; + const obj = it as Record; + const title = typeof obj.title === "string" ? obj.title.trim() : ""; + const levelNum = typeof obj.level === "number" ? obj.level : Number(obj.level); + const pageNum = typeof obj.page === "number" ? obj.page : Number(obj.page); + if (!title) continue; + const level = Number.isFinite(levelNum) ? Math.max(1, Math.min(6, Math.floor(levelNum))) : 1; + const page = Number.isFinite(pageNum) ? Math.max(1, Math.min(maxPage, Math.floor(pageNum))) : 1; + const key = `${page}:${level}:${title}`; + if (seen.has(key)) continue; + seen.add(key); + items.push({ title, level, page }); + } + // 尽量保证输出按页码递增,避免树构建乱序 + items.sort((a, b) => (a.page - b.page) || (a.level - b.level) || a.title.localeCompare(b.title, "zh")); + return items; +}; + +const buildMindmapTree = (rootTitle: string, outline: OutlineItem[], linkForPage: (p: number) => string) => { + const cryptoAny = crypto as unknown as { randomUUID?: () => string }; + const uid = () => (typeof cryptoAny.randomUUID === "function" ? cryptoAny.randomUUID() : Math.random().toString(36).slice(2)); + + const root = { data: { text: rootTitle, uid: uid() }, children: [] as any[] }; + const stack: any[] = [root]; + + const pushNode = (item: OutlineItem) => { + const level = Math.max(1, Math.min(6, item.level)); + while (stack.length > level) stack.pop(); + while (stack.length < level) stack.push(stack[stack.length - 1]); + const parent = stack[level - 1] ?? root; + const href = linkForPage(item.page); + const node = { + data: { + text: item.title, + uid: uid(), + hyperlink: href, + // 预留:后续可用于“引用详情/跳转信息” + refs: [{ kind: "pdf", page: item.page, title: item.title }], + }, + children: [] as any[], + }; + parent.children.push(node); + stack[level] = node; + }; + + outline.forEach(pushNode); + return root; +}; + +type AiPoint = { text: string; page?: number }; +type AiSection = { title: string; page: number; points?: AiPoint[] | string[] }; +type AiChapter = { title: string; page: number; sections?: AiSection[] }; +type AiPlan = { title?: string; chapters?: AiChapter[] }; + +const isChapterTitle = (line: string) => /^第[一二三四五六七八九十]+章/.test(line.trim()); +const isSectionTitle = (line: string) => /^第[一二三四五六七八九十]+节/.test(line.trim()); +const isPointTitle = (line: string) => { + const t = line.trim(); + if (!t) return false; + if (isChapterTitle(t) || isSectionTitle(t)) return false; + if (/^\d+(\.\d+){2,}\s+/.test(t)) return true; // 1.1.1 + if (/^[一二三四五六七八九十]+[、..]\s*\S+/.test(t)) return true; // 一、 + if (/^[((][一二三四五六七八九十]+[))]\s*\S+/.test(t)) return true; // (一) + if (/^(?\d+)\s*\S+/.test(t)) return true; // (1)/1) + return false; +}; + +const enrichPlanPointsFromCandidates = ( + plan: AiPlan, + candidates: { page: number; line: string }[], +) => { + if (!plan.chapters?.length) return plan; + + const cleaned = candidates + .map((c) => ({ page: c.page, line: cleanExtractedText(c.line) })) + .map((c) => ({ ...c, line: c.line.trim() })) + .filter((c) => c.line && c.line.length <= 80) + .filter((c) => c.line.length >= 6) + .filter((c) => !/^(目录|目\s*录)$/u.test(c.line)) + .filter((c) => !/[《〈『【((]\s*$/u.test(c.line)) + .filter((c) => !/^[0-9\s.·•\-–—_]{6,}$/.test(c.line)); + + const findPointsInRange = (fromPage: number, toPage: number, exclude: Set) => { + const points: AiPoint[] = []; + const seen = new Set(); + for (const c of cleaned) { + if (c.page < fromPage || c.page > toPage) continue; + const t = c.line.trim(); + if (!t) continue; + if (exclude.has(t)) continue; + if (/[《〈『【((]\s*$/u.test(t)) continue; + if (!isPointTitle(t)) continue; + if (seen.has(t)) continue; + seen.add(t); + points.push({ text: t, page: c.page }); + if (points.length >= 6) break; + } + return points; + }; + + for (let ci = 0; ci < plan.chapters.length; ci++) { + const ch = plan.chapters[ci]; + const nextChapterPage = plan.chapters[ci + 1]?.page ?? Number.POSITIVE_INFINITY; + const sections = ch.sections ?? []; + for (let si = 0; si < sections.length; si++) { + const sec = sections[si]; + const nextSectionPage = sections[si + 1]?.page ?? nextChapterPage; + const fromPage = Math.max(1, sec.page); + // 控制范围:最多跨 2 页,避免引入别的章节噪声 + const toPage = Math.max(fromPage, Math.min(Number.isFinite(nextSectionPage) ? nextSectionPage : fromPage, fromPage + 2)); + const exclude = new Set([ch.title, sec.title]); + const points = findPointsInRange(fromPage, toPage, exclude); + if (points.length) { + sec.points = points; + continue; + } + + // 兜底:同页取 2~3 个候选行做要点 + const fallback = cleaned + .filter((c) => c.page == fromPage) + .map((c) => c.line.trim()) + .filter((t) => t && !exclude.has(t)) + .filter((t) => t.length >= 6) + .filter((t) => !/[《〈『【((]\s*$/u.test(t)) + .filter((t) => !/^[0-9\s.·•\-–—_]{6,}$/.test(t)) + .slice(0, 3) + .map((t) => ({ text: t, page: fromPage })); + if (fallback.length) sec.points = fallback; + } + } + + return plan; +}; + +const enrichPlanPointsFromPages = (plan: AiPlan, pages: { page: number; text: string }[]) => { + if (!plan.chapters?.length) return plan; + + const getLinesForRange = (fromPage: number, toPage: number) => { + const out: { page: number; line: string }[] = []; + for (const p of pages) { + if (p.page < fromPage || p.page > toPage) continue; + const lines = String(p.text || "") + .split(/\r?\n/) + .map((l) => cleanExtractedText(l)) + .map((l) => l.replace(/[ \t]+/g, " ").trim()) + .filter(Boolean); + for (const line of lines) out.push({ page: p.page, line }); + } + return out; + }; + + const isNoiseLine = (line: string) => { + const t = line.trim(); + if (!t) return true; + if (t.length < 6) return true; + if (t.length > 90) return true; + if (/^(目录|目\s*录)\s*$/.test(t)) return true; + if (/^第?\s*\d+\s*页$/.test(t)) return true; + if (/^(http|https):\/\//i.test(t)) return true; + if (/^[\W_]{8,}$/.test(t)) return true; + // 孤立的引号/括号开头结尾(常见于页眉/装饰符) + if (/[《〈『【((]\s*$/.test(t)) return true; + // 极低中文占比的长句:更像乱码或页眉/脚注 + const s = t.replace(/\s+/g, ""); + const cjk = (s.match(/[\u4e00-\u9fff]/g) ?? []).length; + if (s.length >= 18 && cjk / s.length < 0.12) return true; + return false; + }; + + const scoreLine = (line: string) => { + const t = line.trim(); + let score = 0; + if (/[。;:]/.test(t)) score += 2; + if (/[,、]/.test(t)) score += 1; + if (isPointTitle(t)) score += 2; + if (/^(?:-|\*|•|·)\s*\S+/.test(t)) score += 2; + if (/^[((][一二三四五六七八九十]+[))]/.test(t)) score += 2; + if (/^\d+(\.\d+){1,}\s+/.test(t)) score += 2; + if (t.length >= 10 && t.length <= 45) score += 1; + return score; + }; + + for (let ci = 0; ci < plan.chapters.length; ci++) { + const ch = plan.chapters[ci]; + const nextChapterPage = plan.chapters[ci + 1]?.page ?? Number.POSITIVE_INFINITY; + const sections = ch.sections ?? []; + for (let si = 0; si < sections.length; si++) { + const sec = sections[si]; + const nextSectionPage = sections[si + 1]?.page ?? nextChapterPage; + const fromPage = Math.max(1, sec.page); + // 内容抽取范围:最多跨 3 页(更接近“内容”而不只标题) + const toPage = Math.max( + fromPage, + Math.min(Number.isFinite(nextSectionPage) ? nextSectionPage : fromPage, fromPage + 3), + ); + const exclude = new Set([ch.title, sec.title]); + + const pool = getLinesForRange(fromPage, toPage) + .map((x) => ({ ...x, line: x.line.trim() })) + .filter((x) => x.line && !exclude.has(x.line)) + .filter((x) => !isNoiseLine(x.line)) + .filter((x) => !isChapterTitle(x.line) && !isSectionTitle(x.line)); + + const seen = new Set(); + const scored = pool + .filter((x) => { + if (seen.has(x.line)) return false; + seen.add(x.line); + return true; + }) + .map((x) => ({ ...x, score: scoreLine(x.line) })) + .sort((a, b) => b.score - a.score || a.page - b.page); + + const picked = scored.slice(0, 6).map((x) => ({ text: x.line, page: x.page })); + if (picked.length) { + sec.points = picked; + } + } + } + + return plan; +}; + +const normalizeAiPlan = (raw: unknown, maxPage: number): AiPlan | null => { + if (!raw || typeof raw !== "object") return null; + const obj = raw as Record; + const title = typeof obj.title === "string" ? obj.title.trim() : undefined; + const chaptersRaw = obj.chapters; + if (!Array.isArray(chaptersRaw)) return null; + + const clampPage = (p: unknown) => { + const n = typeof p === "number" ? p : Number(p); + if (!Number.isFinite(n)) return 1; + return Math.max(1, Math.min(maxPage, Math.floor(n))); + }; + + const chapters: AiChapter[] = []; + for (const ch of chaptersRaw) { + if (!ch || typeof ch !== "object") continue; + const c = ch as Record; + const chTitle = typeof c.title === "string" ? c.title.trim() : ""; + if (!chTitle) continue; + const chPage = clampPage(c.page); + const sectionsRaw = c.sections; + const sections: AiSection[] = []; + if (Array.isArray(sectionsRaw)) { + for (const sec of sectionsRaw) { + if (!sec || typeof sec !== "object") continue; + const s = sec as Record; + const secTitle = typeof s.title === "string" ? s.title.trim() : ""; + if (!secTitle) continue; + const secPage = clampPage(s.page ?? chPage); + let points: AiPoint[] | undefined; + if (Array.isArray(s.points)) { + points = s.points + .map((p) => { + if (typeof p === "string") return { text: p.trim(), page: secPage }; + if (p && typeof p === "object") { + const po = p as Record; + const text = typeof po.text === "string" ? po.text.trim() : ""; + if (!text) return null; + return { text, page: clampPage(po.page ?? secPage) }; + } + return null; + }) + .filter(Boolean) as AiPoint[]; + } + sections.push({ title: secTitle, page: secPage, points }); + } + } + chapters.push({ title: chTitle, page: chPage, sections }); + } + + if (!chapters.length) return null; + return { title, chapters }; +}; + +const stripLeadingNumbering = (title: string) => { + const t = String(title || "").trim(); + if (!t) return ""; + return ( + t + // 常见:二、xxx / 2. xxx / 2.xxx + .replace(/^[一二三四五六七八九十]+[、..]\s*/u, "") + .replace(/^\d+(\.\d+){0,3}\s*[、..]?\s*/u, "") + // (一)xxx / (1) xxx / (1)xxx + .replace(/^[((][一二三四五六七八九十]+[))]\s*/u, "") + .replace(/^(?\d+)\s*/u, "") + .trim() + ); +}; + +const normalizePlanTitle = (planTitle: string | undefined, fallbackTitle: string) => { + const t = String(planTitle || "").trim(); + if (!t) return fallbackTitle; + if (/^(目录|目\s*录)$/u.test(t)) return fallbackTitle; + if (/^(文档结构|文档大纲|文档目录|大纲|结构)$/u.test(t)) return fallbackTitle; + if (/document\s+outline/i.test(t)) return fallbackTitle; + // 纯英文/数字标题通常是网关默认值或无意义占位 + if (/^[A-Za-z0-9 _.-]{6,}$/u.test(t) && !/[\u4e00-\u9fff]/u.test(t)) return fallbackTitle; + // 太短/过于泛化的标题,直接用文件名/提取标题 + if (t.length <= 2) return fallbackTitle; + return t; +}; + +const ensureChapterSectionLabels = (plan: AiPlan) => { + const chapters = plan.chapters ?? []; + for (let ci = 0; ci < chapters.length; ci++) { + const ch = chapters[ci]; + const rawChTitle = String(ch.title || "").trim(); + const chTitleBase = stripLeadingNumbering(rawChTitle) || rawChTitle; + const hasChapterWord = /章/u.test(rawChTitle) || /^第[一二三四五六七八九十]+章/u.test(rawChTitle); + ch.title = hasChapterWord ? rawChTitle : `第${ci + 1}章 ${chTitleBase}`.trim(); + + const sections = ch.sections ?? []; + for (let si = 0; si < sections.length; si++) { + const sec = sections[si]; + const rawSecTitle = String(sec.title || "").trim(); + const secTitleBase = stripLeadingNumbering(rawSecTitle) || rawSecTitle; + const hasSectionWord = /节/u.test(rawSecTitle) || /^第[一二三四五六七八九十]+节/u.test(rawSecTitle); + sec.title = hasSectionWord ? rawSecTitle : `第${si + 1}节 ${secTitleBase}`.trim(); + } + } + return plan; +}; + +const postProcessAiPlan = (plan: AiPlan, fallbackTitle: string) => { + plan.title = normalizePlanTitle(plan.title, fallbackTitle); + return ensureChapterSectionLabels(plan); +}; + +const ensurePlanHasChapterAndSections = ( + plan: AiPlan, + pdfTitle: string, + tocEntries: TocEntry[], + candidates: { page: number; line: string }[], + maxPage: number, +) => { + const chapters = plan.chapters ?? []; + const hasAnySections = chapters.some((c) => Array.isArray(c.sections) && c.sections.length > 0); + if (hasAnySections) return plan; + + const buildSectionsFromEntries = (entries: { title: string; page: number }[]) => { + const out: AiSection[] = []; + const seen = new Set(); + for (const e of entries) { + const raw = String(e.title || "").trim(); + // 目录项/AI 可能会自带“第X章/第X节”,这里统一剥离后再由 ensureChapterSectionLabels 重新加前缀 + const noChapterPrefix = raw.replace(/^第[一二三四五六七八九十0-9]+章\s*/u, "").trim(); + const noSectionPrefix = noChapterPrefix.replace(/^第[一二三四五六七八九十0-9]+节\s*/u, "").trim(); + const title = stripLeadingNumbering(noSectionPrefix) || noSectionPrefix || raw; + if (!title) continue; + const page = Math.max(1, Math.min(maxPage, Math.floor(e.page || 1))); + const key = `${page}:${title}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ title, page }); + if (out.length >= 12) break; + } + return out; + }; + + // 1) 若 AI 把所有条目都当成“chapter”,则降级:合并成一个 chapter,其它变 section + if (chapters.length >= 2) { + const asSections = buildSectionsFromEntries( + chapters.map((c) => ({ title: String(c.title || ""), page: Math.max(1, Math.min(maxPage, c.page || 1)) })), + ); + const firstPage = asSections[0]?.page ?? Math.max(1, Math.min(maxPage, chapters[0]?.page ?? 1)); + const merged: AiPlan = { + title: plan.title, + chapters: [ + { + title: `第1章 ${pdfTitle}`.trim(), + page: firstPage, + sections: asSections, + }, + ], + }; + return ensureChapterSectionLabels(merged); + } + + // 2) 优先使用目录识别结果作为 sections + const tocSections = buildSectionsFromEntries(tocEntries); + if (tocSections.length) { + const firstPage = tocSections[0]?.page ?? 1; + const merged: AiPlan = { + title: plan.title, + chapters: [ + { + title: `第1章 ${pdfTitle}`.trim(), + page: firstPage, + sections: tocSections, + }, + ], + }; + return ensureChapterSectionLabels(merged); + } + + // 3) 最后用候选标题补一个 section 列表 + const candidateSections = buildSectionsFromEntries( + candidates + .map((c) => ({ title: c.line, page: c.page })) + .filter((c) => c.title && c.title.length <= 80) + .filter((c) => !/^(目录|目\s*录)$/u.test(c.title.trim())), + ); + const firstPage = candidateSections[0]?.page ?? 1; + const merged: AiPlan = { + title: plan.title, + chapters: [ + { + title: `第1章 ${pdfTitle}`.trim(), + page: firstPage, + sections: candidateSections, + }, + ], + }; + return ensureChapterSectionLabels(merged); +}; + +const buildMindmapFromPlan = ( + rootTitle: string, + plan: AiPlan, + linkForPage: (p: number) => string, +) => { + const cryptoAny = crypto as unknown as { randomUUID?: () => string }; + const uid = () => + typeof cryptoAny.randomUUID === "function" + ? cryptoAny.randomUUID() + : Math.random().toString(36).slice(2); + + const title = normalizePlanTitle(plan.title, rootTitle); + const root = { data: { text: title, uid: uid() }, children: [] as any[] }; + + const chapters = plan.chapters ?? []; + for (const ch of chapters) { + const chNode = { + data: { + text: ch.title, + uid: uid(), + hyperlink: linkForPage(ch.page), + refs: [{ kind: "pdf", page: ch.page, title: ch.title }], + }, + children: [] as any[], + }; + + const sections = ch.sections ?? []; + for (const sec of sections) { + const secNode = { + data: { + text: sec.title, + uid: uid(), + hyperlink: linkForPage(sec.page), + refs: [{ kind: "pdf", page: sec.page, title: sec.title }], + }, + children: [] as any[], + }; + + const points = Array.isArray(sec.points) ? sec.points : []; + for (const p of points.slice(0, 10)) { + const rawText = typeof p === "string" ? p : p.text; + const text = cleanExtractedText(rawText || ""); + if (!text) continue; + const page = Math.max(1, (typeof p === "string" ? sec.page : (p.page ?? sec.page))); + secNode.children.push({ + data: { + text, + uid: uid(), + hyperlink: linkForPage(page), + refs: [{ kind: "pdf", page, title: text }], + }, + children: [] as any[], + }); + } + + chNode.children.push(secNode); + } + + root.children.push(chNode); + } + + return root; +}; + +const callPdfExtractor = async (pdfFile: string, maxPages: number) => { + const script = path.join(process.cwd(), "scripts", "mindmap_ai_pdf_extract.py"); + const run = async (cmd: string, args: string[]) => { + const { stdout } = await execFileAsync(cmd, args, { windowsHide: true, maxBuffer: 1024 * 1024 * 20 }); + return stdout; + }; + try { + return await run("python", [script, "--input", pdfFile, "--max-pages", String(maxPages)]); + } catch { + return await run("py", ["-3", script, "--input", pdfFile, "--max-pages", String(maxPages)]); + } +}; + +const downloadToTemp = async (fileUrl: string) => { + const res = await fetch(fileUrl); + if (!res.ok) throw new Error(`下载 PDF 失败:${res.status}`); + const arrayBuffer = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "mindmap-ai-")); + const file = path.join(dir, `source-${Date.now()}.pdf`); + await fs.writeFile(file, buffer); + return { dir, file }; +}; + +const safeFetchableUrl = (raw: string) => { + const value = (raw ?? "").trim(); + if (!value) return null; + try { + const u = new URL(value); + const host = u.hostname; + // 基础防护:只允许本机或 supabase(同域/存储域名变化时可扩展) + if (host === "127.0.0.1" || host === "localhost") return u.toString(); + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ""; + if (supabaseUrl) { + const supaHost = new URL(supabaseUrl).hostname; + if (host === supaHost) return u.toString(); + } + // 若没有配置 supabase URL,则仍允许 https(但会有 SSRF 风险,默认不放开) + return null; + } catch { + return null; + } +}; + +const buildOutlineWithOllama = async ( + baseUrl: string, + model: string, + candidates: { page: number; line: string }[], + maxPage: number, + timeoutMs: number, +): Promise => { + const prompt = [ + "你是一个“文档结构抽取器”。", + "我将给你一组来自 PDF 的候选标题行(每行都带页码)。请输出一个 JSON 数组,每个元素格式为:", + '{ "title": string, "level": number, "page": number }', + "", + "要求:", + "- level 从 1 开始,越小越靠近中心主题;最多 4。", + "- title 要尽量保留原文标题含义,但去掉重复空格。", + "- page 必须是 1-based 页码。", + "- 只输出 JSON 数组本身,不要 Markdown,不要解释,不要额外字段。", + "", + "候选标题:", + ...candidates.map((c, idx) => `${idx + 1}. [p${c.page}] ${c.line}`), + ].join("\n"); + + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), Math.max(500, timeoutMs || 0)); + const res = await fetch(baseUrl, { + method: "POST", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + stream: false, + messages: [ + { role: "system", content: "只输出严格 JSON,不要代码块。" }, + { role: "user", content: prompt }, + ], + }), + }).finally(() => clearTimeout(t)); + if (!res.ok) return null; + const json = (await res.json().catch(() => null)) as any; + const content = String(json?.message?.content ?? ""); + const arr = extractJsonArray(content); + if (!arr) return null; + const normalized = normalizeOutlineItems(arr, maxPage); + return normalized.length ? normalized : null; +}; + +export async function POST(request: Request) { + const payload = (await request.json().catch(() => null)) as RequestPayload | null; + if (!payload?.source) { + return NextResponse.json({ error: "缺少 source" }, { status: 400 }); + } + + const maxPages = Math.max(1, Math.min(200, payload.options?.maxPages ?? 30)); + const maxCandidates = Math.max(10, Math.min(200, payload.options?.maxCandidates ?? 80)); + const forceHeuristic = Boolean(payload.options?.forceHeuristic); + const ollamaTimeoutMs = Math.max(500, Math.min(60_000, payload.options?.ollamaTimeoutMs ?? 8000)); + const onlineTimeoutMs = Math.max(500, Math.min(120_000, payload.options?.onlineTimeoutMs ?? 25_000)); + const preferProvider = payload.options?.preferProvider ?? "online"; + + let pdfTitle = "文档"; + let pdfUrlForLink: ((p: number) => string) | null = null; + let cleanupDir: string | null = null; + let pdfFile: string | null = null; + + try { + if (payload.source.kind === "test") { + if (process.env.NODE_ENV === "production") { + return NextResponse.json({ error: "生产环境不支持 test 源" }, { status: 404 }); + } + const testName = payload.source.name; + const file = safeResolveTestPdf(testName); + if (!file) { + return NextResponse.json({ error: "test 文件名非法" }, { status: 400 }); + } + pdfFile = file; + pdfTitle = testName.replace(/\.pdf$/i, ""); + pdfUrlForLink = (p) => `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testName)}#page=${p}`; + } else if (payload.source.kind === "url") { + const safe = safeFetchableUrl(payload.source.fileUrl); + if (!safe) { + return NextResponse.json({ error: "fileUrl 不允许(仅允许本机或 supabase 域名)" }, { status: 400 }); + } + const downloaded = await downloadToTemp(safe); + cleanupDir = downloaded.dir; + pdfFile = downloaded.file; + pdfTitle = payload.source.title?.trim() || "文档"; + pdfUrlForLink = (p) => `${safe}#page=${p}`; + } else { + return NextResponse.json({ error: "不支持的 source.kind" }, { status: 400 }); + } + + const extractedRaw = await callPdfExtractor(pdfFile, maxPages); + const extracted = JSON.parse(extractedRaw) as { + meta?: { title?: string | null }; + pages: { page: number; text: string }[]; + totalPages: number; + }; + + const maxPage = Math.max(1, Math.min(extracted.totalPages || maxPages, maxPages)); + if (extracted.meta?.title && String(extracted.meta.title).trim()) { + pdfTitle = String(extracted.meta.title).trim(); + } + + const pages = (extracted.pages ?? []).slice(0, maxPage); + // 先对分页文本做清洗,降低乱码对候选抽取的影响 + const cleanedPages = pages.map((p) => ({ page: p.page, text: cleanExtractedText(p.text || "") })); + const candidates = extractHeadingCandidates(cleanedPages, maxCandidates); + const tocEntries = extractTocEntries(cleanedPages, maxPage); + + let providerUsed: "online" | "ollama" | "heuristic" = "heuristic"; + let plan: AiPlan | null = null; + let outline: OutlineItem[] | null = null; + let onlineAvailable = false; + let onlineAttempted = false; + let onlineSucceeded = false; + let onlineError: string | null = null; + const ollamaBaseUrl = payload.ollama?.baseUrl?.trim() || "http://localhost:11434/api/chat"; + const ollamaModel = payload.ollama?.model?.trim() || "qwen3:30b-a3b-instruct-2507-q4_K_M"; + + const linkForPage = pdfUrlForLink ?? ((p) => `#page=${p}`); + + // 1) 在线 AI 优先:输出“章→节→要点”结构 + if (!forceHeuristic && preferProvider === "online") { + const cfg = await loadOnlineAiConfig().catch(() => null); + if (cfg) { + try { + onlineAvailable = true; + onlineAttempted = true; + const system = [ + "你是一个“PDF 思维导图结构化生成器”。", + "你必须输出严格 JSON(不要 Markdown/不要代码块/不要解释)。", + "目标结构必须是:章(第一层)→ 节(第二层)。", + "你要忽略无意义乱码/随机英文串;输出标题必须是可读中文(或常见编号标题,如 2.1 / (一))。", + "只输出章/节骨架:不要输出 points 字段,不要输出正文。", + ].join("\n"); + + const inputLines = + tocEntries.length > 0 + ? tocEntries.slice(0, Math.min(60, tocEntries.length)).map((e) => `[p${e.page}] ${e.title}`) + : candidates.slice(0, Math.min(60, candidates.length)).map((c) => `[p${c.page}] ${c.line}`); + + const user = [ + "请根据下方“目录/标题行(带页码)”生成思维导图结构 JSON。", + "", + "输出 JSON Schema:", + "{", + ' "title": string,', + ' "chapters": [', + " {", + ' "title": string,', + ' "page": number,', + ' "sections": [', + " {", + ' "title": string,', + ' "page": number', + " }", + " ]", + " }", + " ]", + "}", + "", + "硬性要求:", + "- 只允许输出上述字段;不要输出其它字段。", + "- chapters 必须按页码递增;sections 也按页码递增。", + "- page 为 1-based 页码(必须是整数)。", + "- title 不能包含无意义乱码(例如连续英文乱序、无意义符号堆叠)。", + "- 若候选行里存在“第X章/第X节”,优先按该结构组织。", + "- 输出规模控制:最多 10 个 chapter;每个 chapter 最多 10 个 section。", + "", + `输入行来源:${tocEntries.length > 0 ? "目录识别" : "标题候选"}`, + "输入行(格式:[p页码] 文本):", + ...inputLines, + ].join("\n"); + + const callOnce = async (userText: string, maxOutTokens: number) => { + const { text, raw } = await openAiCompatibleChat( + [ + { role: "system", content: system }, + { role: "user", content: userText }, + ], + { + baseUrl: cfg.baseUrl, + apiKey: cfg.apiKey, + model: cfg.model, + timeoutMs: onlineTimeoutMs, + maxTokens: maxOutTokens, + maxCompletionTokens: maxOutTokens, + responseFormat: "json_object", + }, + ); + const parsed = tryExtractJsonObject(text); + if (!parsed) { + const finishReason = String((raw as any)?.choices?.[0]?.finish_reason ?? ""); + const preview = String(text || "").slice(0, 220).replace(/\s+/g, " ").trim(); + throw new Error( + `在线 AI 未返回可解析 JSON(finish_reason=${finishReason || "unknown"}):${preview || "(empty)"}`, + ); + } + return parsed; + }; + + // attempt 1:优先使用目录识别(更少噪声),并加大输出 token 限制减少截断概率 + let json = await callOnce(user, 2600); + plan = normalizeAiPlan(json, maxPage); + + // attempt 2:更严格限制输入行与输出规模(应对网关硬性 token 上限) + if (!plan) { + const inputLinesSmall = + tocEntries.length > 0 + ? tocEntries.slice(0, Math.min(40, tocEntries.length)).map((e) => `[p${e.page}] ${e.title}`) + : candidates.slice(0, Math.min(40, candidates.length)).map((c) => `[p${c.page}] ${c.line}`); + const userSmall = [ + "请根据下方“目录/标题行(带页码)”生成思维导图结构 JSON。", + "", + "输出 JSON Schema:", + "{", + ' "title": string,', + ' "chapters": [', + " {", + ' "title": string,', + ' "page": number,', + ' "sections": [', + " {", + ' "title": string,', + ' "page": number', + " }", + " ]", + " }", + " ]", + "}", + "", + "硬性要求:", + "- 只允许输出上述字段;不要输出其它字段。", + "- chapters 必须按 page 递增;sections 也按 page 递增。", + "- page 为 1-based 页码(必须是整数)。", + "- title 不能包含无意义乱码。", + "- 输出规模控制:最多 8 个 chapter;每个 chapter 最多 10 个 section。", + "", + `输入行来源:${tocEntries.length > 0 ? "目录识别" : "标题候选"}`, + "输入行(格式:[p页码] 文本):", + ...inputLinesSmall, + ].join("\n"); + json = await callOnce(userSmall, 2600); + plan = normalizeAiPlan(json, maxPage); + } + + if (plan) { + plan = postProcessAiPlan(plan, pdfTitle); + plan = ensurePlanHasChapterAndSections(plan, pdfTitle, tocEntries, candidates, maxPage); + // 第三层“内容/要点”用规则从正文页抽取,避免在线输出膨胀导致截断/乱码 + plan = enrichPlanPointsFromPages(plan, cleanedPages); + // 兜底:用候选行补点(保持至少有内容) + plan = enrichPlanPointsFromCandidates(plan, candidates); + providerUsed = "online"; + onlineSucceeded = true; + } + } catch (e) { + onlineError = e instanceof Error ? e.message : String(e); + plan = null; + } + } + } + + // 2) 兜底:本地 Ollama(只做标题分级,不生成要点) + if (!plan && !forceHeuristic && (preferProvider === "ollama" || preferProvider === "online")) { + outline = await buildOutlineWithOllama(ollamaBaseUrl, ollamaModel, candidates, maxPage, ollamaTimeoutMs).catch(() => null); + if (outline?.length) providerUsed = "ollama"; + } + + // 3) 最后兜底:纯规则 + if (!plan && !outline) { + outline = buildOutlineHeuristic(candidates); + providerUsed = "heuristic"; + } + + // 若仍然极少结构,则兜底按页生成二级结构 + if (!plan && outline && outline.length < 3) { + outline = cleanedPages.flatMap((p) => [ + { title: `第 ${p.page} 页`, level: 1, page: p.page }, + ...((p.text || "") + .split(/\r?\n/) + .map((l) => normalizeLine(l)) + .filter(Boolean) + .slice(0, 3) + .map((l) => ({ title: l, level: 2, page: p.page }))), + ]); + } + + const mindmapData = plan + ? buildMindmapFromPlan(pdfTitle, plan, linkForPage) + : buildMindmapTree(pdfTitle, outline ?? [], linkForPage); + + return NextResponse.json({ + mindmapData, + outline: plan ? null : outline, + plan, + candidates, + tocEntries, + meta: { + title: pdfTitle, + totalPages: extracted.totalPages ?? null, + providerUsed, + onlineAvailable, + onlineAttempted, + onlineSucceeded, + onlineError, + }, + }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } finally { + if (cleanupDir) { + try { + await fs.rm(cleanupDir, { recursive: true, force: true }); + } catch { + // ignore + } + } + } +} diff --git a/wolai-frontend/src/app/api/mindmap-ai/test-pdf/route.ts b/wolai-frontend/src/app/api/mindmap-ai/test-pdf/route.ts new file mode 100644 index 00000000..7279eff4 --- /dev/null +++ b/wolai-frontend/src/app/api/mindmap-ai/test-pdf/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; + +export const dynamic = "force-dynamic"; + +const TEST_PDF_DIR = path.join(process.cwd(), "test"); + +const safeResolveTestPdf = (name: string) => { + const trimmed = (name ?? "").trim(); + if (!trimmed) return null; + // 防止目录穿越:只允许同目录文件名 + if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) { + return null; + } + if (!trimmed.toLowerCase().endsWith(".pdf")) { + return null; + } + return path.join(TEST_PDF_DIR, trimmed); +}; + +export async function GET(request: Request) { + // 仅用于本地测试文件的“真跳页链接” + // 生产环境不应该暴露本地磁盘文件,因此默认只允许非生产环境访问 + if (process.env.NODE_ENV === "production") { + return NextResponse.json({ error: "生产环境不支持 test-pdf" }, { status: 404 }); + } + + const url = new URL(request.url); + const name = url.searchParams.get("name") ?? ""; + const file = safeResolveTestPdf(name); + if (!file) { + return NextResponse.json({ error: "文件名非法" }, { status: 400 }); + } + + try { + const buffer = await fs.readFile(file); + return new NextResponse(buffer, { + headers: { + "Content-Type": "application/pdf", + // 内嵌预览,浏览器 PDF 查看器可识别 #page= + "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(name)}`, + "Cache-Control": "no-store", + }, + }); + } catch (error) { + return NextResponse.json({ error: `读取 PDF 失败:${String(error)}` }, { status: 404 }); + } +} + diff --git a/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts b/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts new file mode 100644 index 00000000..16322094 --- /dev/null +++ b/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { promises as fs } from "fs"; +import path from "path"; + +export const dynamic = "force-dynamic"; + +const preferredBaseDir = path.join(process.cwd(), "public", "documents"); +const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps"); + +interface EmptyTrashPayload { + workspaceId?: string; +} + +async function purgeTrashFolder(folder: string): Promise { + const trashDir = path.join(folder, ".trash"); + try { + const entries = await fs.readdir(trashDir); + let removed = 0; + for (const name of entries) { + await fs.rm(path.join(trashDir, name), { force: true, recursive: true }); + removed += 1; + } + return removed; + } catch { + return 0; + } +} + +export async function POST(request: Request) { + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({})); + if (!workspaceId) { + return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); + } + + const { data: membership, error: membershipError } = await supabase + .from("workspace_members") + .select("workspace_id") + .eq("workspace_id", workspaceId) + .eq("user_id", session.user.id) + .limit(1); + + if (membershipError) { + return NextResponse.json({ error: membershipError.message }, { status: 500 }); + } + + if (!membership || membership.length === 0) { + return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 }); + } + + const { data: documents, error: docError } = await supabase + .from("documents") + .select("id") + .eq("workspace_id", workspaceId) + .eq("user_id", session.user.id) + .limit(5000); + + if (docError) { + return NextResponse.json({ error: docError.message }, { status: 500 }); + } + + const docIds = (documents ?? []).map((row) => row.id).filter(Boolean); + let removed = 0; + for (const docId of docIds) { + removed += await purgeTrashFolder(path.join(preferredBaseDir, docId)); + removed += await purgeTrashFolder(path.join(legacyBaseDir, docId)); + } + + return NextResponse.json({ ok: true, removed }); +} + diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/ops/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/ops/route.ts new file mode 100644 index 00000000..2431d134 --- /dev/null +++ b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/ops/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps"; +import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore"; + +const defaultMindmapData = { + data: { text: "中心主题" }, + children: [], +}; + +type RequestPayload = { + ops: MindmapOp[]; + actor?: { kind?: string; provider?: string; model?: string }; + reason?: string; +}; + +export async function POST( + request: Request, + { params }: { params: Promise<{ docId: string; mindmapId: string }> }, +) { + const { docId, mindmapId } = await params; + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + // 校验页面归属 + const { data: doc, error: docError } = await supabase + .from("documents") + .select("id,title,mindmap_data") + .eq("id", docId) + .eq("user_id", session.user.id) + .maybeSingle(); + if (docError) { + return NextResponse.json({ error: docError.message }, { status: 400 }); + } + if (!doc) { + return NextResponse.json({ error: "页面不存在" }, { status: 404 }); + } + + const payload = (await request.json().catch(() => null)) as RequestPayload | null; + const ops = Array.isArray(payload?.ops) ? payload!.ops : []; + if (!ops.length) { + return NextResponse.json({ error: "缺少 ops" }, { status: 400 }); + } + if (ops.length > 80) { + return NextResponse.json({ error: "ops 过多(最多 80)" }, { status: 400 }); + } + + const local = await readMindmapLocal(docId, mindmapId); + const baseData = local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData); + + const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops); + + await writeMindmapLocal(docId, mindmapId, nextData, doc.title ?? "无标题"); + + return NextResponse.json({ + ok: true, + applied, + errors, + data: nextData, + meta: { + documentId: docId, + mindmapId, + actor: payload?.actor ?? null, + reason: payload?.reason ?? null, + }, + }); +} + diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts index 685f7fda..f9d45f63 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts @@ -8,7 +8,8 @@ const defaultMindmapData = { children: [], }; -const documentsBaseDir = path.join(process.cwd(), "public", "documents"); +const preferredBaseDir = path.join(process.cwd(), "public", "documents"); +const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps"); async function ensureDir(dir: string) { await fs.mkdir(dir, { recursive: true }); @@ -47,6 +48,64 @@ async function tryReadJson(file: string) { } } +type TrashedMindmapMeta = { + docId: string; + mindmapId: string; + originalFileName: string; + originalPath: string; + trashedFileName: string; + deleted_at: string; +}; + +async function fileExists(file: string) { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + +async function moveToTrash(file: string, meta: Omit) { + if (!(await fileExists(file))) { + return null; + } + const dir = path.dirname(file); + const trashDir = path.join(dir, ".trash"); + await ensureDir(trashDir); + const originalFileName = path.basename(file); + const trashedFileName = `${originalFileName}.${Date.now()}.deleted`; + const trashedPath = path.join(trashDir, trashedFileName); + const metaPath = path.join(trashDir, `${trashedFileName}.json`); + await fs.rename(file, trashedPath); + await fs.writeFile( + metaPath, + JSON.stringify({ ...meta, originalFileName, trashedFileName }, null, 2), + "utf8", + ); + return { trashedPath, metaPath }; +} + +async function listTrashMetas(folder: string): Promise> { + const trashDir = path.join(folder, ".trash"); + try { + const entries = await fs.readdir(trashDir); + const results: Array<{ meta: TrashedMindmapMeta; metaPath: string; trashedPath: string }> = []; + for (const name of entries) { + if (!name.endsWith(".deleted.json")) continue; + const metaPath = path.join(trashDir, name); + const metaRaw = await tryReadJson(metaPath); + const meta = metaRaw as TrashedMindmapMeta | null; + if (!meta?.docId || !meta?.mindmapId || !meta?.trashedFileName || !meta?.originalPath) continue; + const trashedPath = path.join(trashDir, meta.trashedFileName); + results.push({ meta, metaPath, trashedPath }); + } + return results; + } catch { + return []; + } +} + export async function GET( _req: Request, { params }: { params: Promise<{ docId: string; mindmapId: string }> }, @@ -60,11 +119,18 @@ export async function GET( return NextResponse.json({ error: "未登录" }, { status: 401 }); } - const folder = path.join(documentsBaseDir, docId); + const folder = path.join(preferredBaseDir, docId); const file = path.join(folder, resolveMindmapFileName(mindmapId)); const legacyFile = path.join(folder, "mindmap.json"); + const legacyDirFile = + path.basename(file) === "mindmap.json" + ? path.join(legacyBaseDir, docId, "mindmap.json") + : null; - const localData = (await tryReadJson(file)) ?? (await tryReadJson(legacyFile)); + const localData = + (await tryReadJson(file)) ?? + (await tryReadJson(legacyFile)) ?? + (legacyDirFile ? await tryReadJson(legacyDirFile) : null); if (localData) { return NextResponse.json({ data: localData, source: "local" }); } @@ -110,18 +176,25 @@ export async function POST( return NextResponse.json({ error: "页面不存在" }, { status: 404 }); } - const { data } = await request.json().catch(() => ({ data: null })); - const folder = path.join(documentsBaseDir, docId); - const file = path.join(folder, resolveMindmapFileName(mindmapId)); + const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as { + data?: unknown; + createOnly?: boolean; + }; + const folder = path.join(preferredBaseDir, docId); + const file = path.join(folder, resolveMindmapFileName(mindmapId)); try { await ensureDir(folder); await ensureIndexFile(folder, doc.title ?? "无标题"); + // 仅创建:避免“初始化写入”覆盖用户/AI 刚保存的内容(典型于快速操作 + 异步时序) + if (createOnly && (await fileExists(file))) { + return NextResponse.json({ ok: true, created: false, skipped: true }); + } await fs.writeFile(file, JSON.stringify(data ?? defaultMindmapData, null, 2), "utf8"); } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); + return NextResponse.json({ error: String(error) }, { status: 500 }); } - return NextResponse.json({ ok: true }); + return NextResponse.json({ ok: true, created: true }); } export async function DELETE( @@ -151,8 +224,106 @@ export async function DELETE( return NextResponse.json({ error: "页面不存在" }, { status: 404 }); } - const folder = path.join(documentsBaseDir, docId); - const file = path.join(folder, resolveMindmapFileName(mindmapId)); - await removeFileSafe(file); + const deletedAt = new Date().toISOString(); + const fileName = resolveMindmapFileName(mindmapId); + + const preferredFolder = path.join(preferredBaseDir, docId); + const preferredFile = path.join(preferredFolder, fileName); + + const candidates: string[] = [preferredFile]; + if (fileName === "mindmap.json") { + candidates.push(path.join(legacyBaseDir, docId, "mindmap.json")); + } + + let moved = 0; + for (const file of candidates) { + const movedInfo = await moveToTrash(file, { + docId, + mindmapId, + originalFileName: path.basename(file), + originalPath: file, + deleted_at: deletedAt, + }); + if (movedInfo) { + moved += 1; + } + } + + if (moved === 0) { + // 兼容:文件不存在也视为成功(避免前端卡死) + return NextResponse.json({ ok: true, moved: 0 }); + } + + return NextResponse.json({ ok: true, moved }); +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ docId: string; mindmapId: string }> }, +) { + const { docId, mindmapId } = await params; + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { action } = (await request.json().catch(() => ({}))) as { action?: string }; + if (action !== "restore" && action !== "purge") { + return NextResponse.json({ error: "不支持的操作" }, { status: 400 }); + } + + const { data: doc, error: docError } = await supabase + .from("documents") + .select("id") + .eq("id", docId) + .eq("user_id", session.user.id) + .maybeSingle(); + if (docError) { + return NextResponse.json({ error: docError.message }, { status: 400 }); + } + if (!doc) { + return NextResponse.json({ error: "页面不存在" }, { status: 404 }); + } + + const preferredFolder = path.join(preferredBaseDir, docId); + const legacyFolder = path.join(legacyBaseDir, docId); + const entries = [ + ...(await listTrashMetas(preferredFolder)), + ...(await listTrashMetas(legacyFolder)), + ].filter((item) => item.meta.mindmapId === mindmapId && item.meta.docId === docId); + + if (entries.length === 0) { + return NextResponse.json({ error: "未找到可操作的垃圾桶记录" }, { status: 404 }); + } + + if (action === "purge") { + let purged = 0; + for (const item of entries) { + await removeFileSafe(item.trashedPath); + await removeFileSafe(item.metaPath); + purged += 1; + } + return NextResponse.json({ ok: true, purged }); + } + + // restore:恢复最新的一条 + const latest = entries + .slice() + .sort((a, b) => (b.meta.deleted_at ?? "").localeCompare(a.meta.deleted_at ?? ""))[0]; + + if (!latest?.meta?.originalPath) { + return NextResponse.json({ error: "垃圾桶记录损坏" }, { status: 500 }); + } + + if (await fileExists(latest.meta.originalPath)) { + return NextResponse.json({ error: "目标文件已存在,无法恢复" }, { status: 409 }); + } + + await ensureDir(path.dirname(latest.meta.originalPath)); + await fs.rename(latest.trashedPath, latest.meta.originalPath); + await removeFileSafe(latest.metaPath); return NextResponse.json({ ok: true }); } diff --git a/wolai-frontend/src/app/api/search/documents/route.ts b/wolai-frontend/src/app/api/search/documents/route.ts index 027cb181..ce7abed5 100644 --- a/wolai-frontend/src/app/api/search/documents/route.ts +++ b/wolai-frontend/src/app/api/search/documents/route.ts @@ -132,6 +132,7 @@ const fetchOcrMatches = async ( .from("media_assets") .select("document_id,ocr_text") .eq("workspace_id", workspaceId) + .is("deleted_at", null) .not("ocr_text", "is", null) .ilike("ocr_text", likePattern) .limit(limit); diff --git a/wolai-frontend/src/app/api/sidebar/route.ts b/wolai-frontend/src/app/api/sidebar/route.ts index a65e4edf..561870dc 100644 --- a/wolai-frontend/src/app/api/sidebar/route.ts +++ b/wolai-frontend/src/app/api/sidebar/route.ts @@ -3,7 +3,11 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces"; import { fetchSidebarDataset } from "@/lib/sidebar-tree"; import type { SidebarInitialData } from "@/components/sidebar/types"; -import { detectLocalMindmapFiles, detectLocalMindmapDocs } from "@/lib/mindmap-files"; +import { + detectLocalMindmapFiles, + detectLocalMindmapDocs, + detectLocalTrashedMindmapAssets, +} from "@/lib/mindmap-files"; import type { MediaAsset } from "@/types/media"; export const dynamic = "force-dynamic"; @@ -30,12 +34,13 @@ export async function GET(request: Request) { } try { - const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId); + const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId); const docIds = dataset.documents.map((d) => d.id); const localMindmapFiles = await detectLocalMindmapFiles(docIds); const mindmapDocs = Array.from(new Set([...(dataset.mindmapDocs ?? []), ...(await detectLocalMindmapDocs(docIds))])); + const trashedMindmapAssets = await detectLocalTrashedMindmapAssets(targetWorkspaceId, docIds); const docById = new Map(dataset.documents.map((d) => [d.id, d])); - const mindmapAssets: MediaAsset[] = localMindmapFiles.map((item) => { + const mindmapAssets: MediaAsset[] = localMindmapFiles.map((item) => { const doc = docById.get(item.documentId); const workspaceId = doc?.workspace_id ?? targetWorkspaceId; const fileUrlBase = item.source === "legacy" ? `/mindmaps/${item.documentId}` : `/documents/${item.documentId}`; @@ -61,13 +66,41 @@ export async function GET(request: Request) { }; }); + const tableAssets: MediaAsset[] = (dataset.tables ?? []).map((row) => { + const base = (row.title ?? "未命名表格").trim() || "未命名表格"; + const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`; + return { + id: row.id, + workspace_id: row.workspace_id ?? targetWorkspaceId, + document_id: row.document_id, + asset_type: "luckysheet", + file_url: `/tables/${row.id}/view`, + thumbnail_url: null, + bucket: null, + storage_path: null, + file_name: fileName, + file_size: null, + mime_type: "application/json", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + signed_url: null, + created_at: row.created_at ?? "", + updated_at: row.updated_at ?? "", + }; + }); + const payload: SidebarInitialData = { activeWorkspaceId: targetWorkspaceId, workspaces, documents: dataset.documents, trashedDocuments: dataset.trashedDocuments, + trashedMediaAssets: dataset.trashedMediaAssets ?? [], + trashedMindmapAssets, mindmapDocs, mindmapAssets, + tableAssets, mediaAssets: dataset.mediaAssets ?? [], }; diff --git a/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx b/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx new file mode 100644 index 00000000..4e7f2498 --- /dev/null +++ b/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import type { BlockNoteEditor } from "@blocknote/core"; +import type { CustomBlockSchema } from "@/components/editor/schema"; +import { MindmapBlockView, defaultMindmapData } from "@/components/editor/blocks/MindmapBlock"; + +const editorStub = { + updateBlock: () => { + /* 独立全屏页中跳过 BlockNote 持久化(思维导图数据由自身 API 管理) */ + }, +} as unknown as BlockNoteEditor; + +export default function MindmapFullscreenPage({ +}: Record) { + const router = useRouter(); + const params = useParams<{ docId?: string; mindmapId?: string }>(); + const docId = params?.docId ?? ""; + const mindmapId = params?.mindmapId ?? ""; + + const stubBlock = useMemo( + () => + ({ + id: mindmapId, + type: "mindmap", + props: { + docId, + data: defaultMindmapData, + }, + content: [], + children: [], + }) as any, + [docId, mindmapId], + ); + + return ( +
+ router.push(`/documents/${docId}`)} + /> +
+ ); +} diff --git a/wolai-frontend/src/components/editor/blocknote-editor.tsx b/wolai-frontend/src/components/editor/blocknote-editor.tsx index 6ac13fa9..e9e8b6e8 100644 --- a/wolai-frontend/src/components/editor/blocknote-editor.tsx +++ b/wolai-frontend/src/components/editor/blocknote-editor.tsx @@ -209,7 +209,7 @@ export function BlockNoteEditor({ } : undefined, }, - [documentId], + [documentId, normalizedInitialContent], ); useEffect( @@ -627,6 +627,9 @@ const computeDocumentStats = (blocks: Block[]): DocumentStats openTableFullScreen: (tableId: string) => { setFullScreenTableId(tableId); }, + insertMediaAsset: (asset: MediaAsset) => { + insertMediaAssetBlock(asset); + }, insertInlineReference: (target: ReferenceTarget, aliasText?: string) => { editor.focus(); const cursor = editor.getTextCursorPosition(); diff --git a/wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx b/wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx new file mode 100644 index 00000000..7cd7bcbb --- /dev/null +++ b/wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx @@ -0,0 +1,570 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Network, Paperclip, Settings2, Send, X } from "lucide-react"; + +type AgentAssetItem = { + kind: "media" | "local-mindmap" | "test-pdf"; + id: string; + title: string; + fileUrl: string; + mimeType?: string | null; + assetType?: string | null; + fileName?: string | null; +}; + +type AgentMessage = { role: "user" | "assistant"; content: string }; + +type ToolName = + | "mindmap_get" + | "mindmap_get_subtree" + | "search_web" + | "mindmap_apply_ops" + | "pdf_replace_mindmap"; + +const TOOL_LABEL: Record = { + mindmap_get: "读导图(摘要)", + mindmap_get_subtree: "读子树(按 uid)", + search_web: "联网检索(SearxNG)", + mindmap_apply_ops: "写入导图(ops)", + pdf_replace_mindmap: "PDF→导图(替换当前)", +}; + +const DEFAULT_TOOLS: ToolName[] = [ + "mindmap_get", + "mindmap_get_subtree", + "search_web", + "mindmap_apply_ops", + "pdf_replace_mindmap", +]; + +const ONLINE_MODELS = [ + "", + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-3-pro-preview", + "gemini-3-flash-preview", +] as const; + +export function MindmapAiAgentPanel({ + documentId, + mindmapId, + mindmap, + activeNodes, +}: { + documentId: string; + mindmapId: string; + mindmap: any; + activeNodes: any[]; +}) { + const [messages, setMessages] = useState([ + { + role: "assistant", + content: + "你好,我是思维导图 AI Agent。你可以:\n- 直接说需求(例如:补完选中节点、总结 @PDF 并写入导图、从 @PDF 生成章/节结构导图)\n- 使用 @ 选择文件或上传文件\n- 让 AI 自动选择工具,或手动勾选允许使用的工具", + }, + ]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + + const [networkOn, setNetworkOn] = useState(true); + const [toolAuto, setToolAuto] = useState(true); + const [toolPickerOpen, setToolPickerOpen] = useState(false); + const [selectedTools, setSelectedTools] = useState(DEFAULT_TOOLS); + const [aiProvider, setAiProvider] = useState<"online" | "local">("online"); + const [aiModel, setAiModel] = useState(""); + + const [assets, setAssets] = useState([]); + const [workspaceId, setWorkspaceId] = useState(""); + const [attachments, setAttachments] = useState([]); + const [debug, setDebug] = useState(""); + + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + + useEffect(() => { + try { + const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim(); + const m = window.localStorage.getItem("mindmap_ai_model") || ""; + if (p === "local" || p === "online") setAiProvider(p); + if (typeof m === "string") setAiModel(m); + } catch { + // ignore + } + }, []); + + useEffect(() => { + try { + window.localStorage.setItem("mindmap_ai_provider", aiProvider); + window.localStorage.setItem("mindmap_ai_model", aiModel); + } catch { + // ignore + } + }, [aiProvider, aiModel]); + + // @ 选择 + const [mentionOpen, setMentionOpen] = useState(false); + const [mentionQuery, setMentionQuery] = useState(""); + const [mentionRange, setMentionRange] = useState<{ start: number; end: number } | null>(null); + + const persistMindmapData = (data: unknown): boolean => { + try { + const w = window as unknown as { + __mindmapPersistById?: Record void>; + }; + const fn = w.__mindmapPersistById?.[mindmapId]; + if (typeof fn === "function") { + fn(data); + return true; + } + } catch { + // ignore + } + return false; + }; + + const selectedUids = useMemo(() => { + const list = Array.isArray(activeNodes) ? activeNodes : []; + return list + .slice(0, 3) + .map((n) => String(n?.nodeData?.data?.uid ?? n?.nodeData?.uid ?? n?.getData?.("uid") ?? n?.uid ?? "")) + .filter(Boolean); + }, [activeNodes]); + + useEffect(() => { + let cancelled = false; + if (!documentId) return; + void (async () => { + try { + const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`); + const json = (await res.json().catch(() => null)) as any; + if (!res.ok) return; + if (cancelled) return; + setWorkspaceId(String(json?.workspaceId ?? "")); + setAssets(Array.isArray(json?.items) ? (json.items as AgentAssetItem[]) : []); + } catch { + // ignore + } + })(); + return () => { + cancelled = true; + }; + }, [documentId]); + + const filteredAssets = useMemo(() => { + const q = mentionQuery.trim().toLowerCase(); + const base = assets.slice(0, 200); + if (!q) return base.slice(0, 12); + return base + .filter((a) => { + const hay = `${a.title} ${a.fileName ?? ""}`.toLowerCase(); + return hay.includes(q); + }) + .slice(0, 12); + }, [assets, mentionQuery]); + + const updateMentionState = (value: string, cursor: number) => { + const before = value.slice(0, Math.max(0, cursor)); + const at = before.lastIndexOf("@"); + if (at === -1) { + setMentionOpen(false); + setMentionQuery(""); + setMentionRange(null); + return; + } + // 若 @ 前是非空白字符,视为 email/路径等,避免误触发 + if (at > 0 && /\S/.test(before[at - 1] || "")) { + setMentionOpen(false); + setMentionQuery(""); + setMentionRange(null); + return; + } + const token = before.slice(at + 1); + // 遇到换行/空格则不触发 + if (/\s/.test(token)) { + setMentionOpen(false); + setMentionQuery(""); + setMentionRange(null); + return; + } + setMentionOpen(true); + setMentionQuery(token); + setMentionRange({ start: at, end: cursor }); + }; + + const insertMention = (item: AgentAssetItem) => { + const el = textareaRef.current; + if (!el || !mentionRange) return; + const next = `${input.slice(0, mentionRange.start)}@${item.title}${input.slice(mentionRange.end)}`; + setInput(next); + setMentionOpen(false); + setMentionQuery(""); + setMentionRange(null); + // 去重加入附件 + setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item])); + // 光标移动到插入后 + window.setTimeout(() => { + try { + const pos = mentionRange.start + 1 + item.title.length; + el.focus(); + el.setSelectionRange(pos, pos); + } catch { + // ignore + } + }, 0); + }; + + const toggleTool = (tool: ToolName) => { + setSelectedTools((prev) => { + if (prev.includes(tool)) return prev.filter((t) => t !== tool); + return [...prev, tool]; + }); + }; + + const uploadFiles = async (files: FileList | null) => { + if (!files || files.length === 0) return; + if (!workspaceId || !documentId) { + window.alert("缺少 workspaceId/documentId,无法上传。请刷新后重试。"); + return; + } + const file = files[0]; + const form = new FormData(); + form.append("file", file); + form.append("workspaceId", workspaceId); + form.append("documentId", documentId); + + setLoading(true); + try { + const res = await fetch("/api/media/upload", { method: "POST", body: form }); + const json = (await res.json().catch(() => null)) as any; + if (!res.ok) throw new Error(String(json?.error ?? `上传失败:${res.status}`)); + const asset = json?.asset; + const item: AgentAssetItem = { + kind: "media", + id: String(asset?.id ?? `media:${Date.now()}`), + title: String(asset?.file_name ?? file.name), + fileUrl: String(asset?.file_url ?? ""), + mimeType: String(asset?.mime_type ?? file.type ?? ""), + assetType: String(asset?.asset_type ?? "file"), + fileName: String(asset?.file_name ?? file.name), + }; + setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item])); + // 刷新资产列表 + try { + const listRes = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`); + const listJson = (await listRes.json().catch(() => null)) as any; + if (listRes.ok && Array.isArray(listJson?.items)) { + setAssets(listJson.items as AgentAssetItem[]); + setWorkspaceId(String(listJson?.workspaceId ?? workspaceId)); + } + } catch { + // ignore + } + } catch (e) { + window.alert(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const send = async () => { + const content = input.trim(); + if (!content) return; + setDebug(""); + + const nextMessages: AgentMessage[] = [...messages, { role: "user", content }]; + setMessages(nextMessages); + setInput(""); + setLoading(true); + + try { + const res = await fetch("/api/mindmap-ai/agent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + documentId, + mindmapId, + selectedUids, + messages: nextMessages, + attachments: attachments.map((a) => ({ + id: a.id, + title: a.title, + fileUrl: a.fileUrl, + mimeType: a.mimeType ?? null, + })), + toolChoice: toolAuto ? { mode: "auto" } : { mode: "manual", tools: selectedTools }, + options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } }, + }), + }); + const json = (await res.json().catch(() => null)) as any; + if (!res.ok) throw new Error(String(json?.error ?? `请求失败:${res.status}`)); + + const assistantText = String(json?.message ?? ""); + setMessages((prev) => [...prev, { role: "assistant", content: assistantText || "(无输出)" }]); + + if (json?.data) { + mindmap?.setData?.(json.data); + mindmap?.command?.clearHistory?.(); + persistMindmapData(json.data); + } + + if (json?.trace) { + setDebug(JSON.stringify(json.trace, null, 2)); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setMessages((prev) => [...prev, { role: "assistant", content: `执行失败:${msg}` }]); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ {selectedUids.length ? `选中节点:${selectedUids[0]}` : "未选中节点(将以整图为上下文)"} +
+
+ + +
+
+ + {toolPickerOpen && ( +
+
+
工具选择
+ +
+
+ {DEFAULT_TOOLS.map((t) => ( + + ))} +
+
+ 提示:如果你希望 AI 一定要“写入导图”,请在需求中明确说“请写入并保存”。 +
+
+ )} + +
+
+ {messages.map((m, idx) => ( +
+
{m.role === "user" ? "你" : "AI"}
+
{m.content}
+
+ ))} +
+
+ + {attachments.length > 0 && ( +
+ {attachments.map((a) => ( + + @{a.title} + + + ))} +
+ )} + +
+
+
AI:
+ + +
+
模型:
+ {aiProvider === "online" ? ( + + ) : ( + setAiModel(e.target.value)} + placeholder="默认(ai.local.md/环境变量)" + /> + )} +
+
+ {aiProvider === "local" ? ( +
+ 本地 AI 需配置 `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` 或 `ai.local.md` / `ai-local.md` +
+ ) : null} +
+ +
+ {mentionOpen && filteredAssets.length > 0 && ( +
+ {filteredAssets.map((a) => ( + + ))} +
+ )} + +