0.1.11 ai修复与全屏
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
apikey:AIzaSyDKIEiUoh2pUM1LDfozBG8LVl7kfYMfLGw
|
||||
|
||||
https://aichem.dpdns.org/v1
|
||||
|
||||
gemini-2.5-flash
|
||||
@@ -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<docId, Map<assetKey, asset>>` 一次遍历构建
|
||||
|
||||
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 存储迁移方案
|
||||
|
||||
@@ -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] 多选后右键任一已选行 → 点击“删除到垃圾桶”,应删除整个选择集(而不是仅最后右键项)
|
||||
- [ ] 多选包含「页面 + 附件」时:无论最后右键落在页面行还是附件行,“删除”都应按选择集执行
|
||||
|
||||
@@ -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. 刷新页面后仍存在(说明落盘成功)。
|
||||
|
||||
@@ -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=<n>`(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=<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=<query>&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=<n>`
|
||||
- [ ] 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 示例)。
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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$;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
turbopack: {
|
||||
// 避免 monorepo/多 lockfile 场景下 root 误判,减少构建与热更新的不确定性
|
||||
root: __dirname,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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:]))
|
||||
@@ -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<Record<string, any>>;
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
| { 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<SearxResult[]> => {
|
||||
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<string, string>) => {
|
||||
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<string, unknown>): 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<string, unknown>;
|
||||
return { type: "tool", tool, args };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeAllowedTools = (payload: RequestPayload): Set<ToolName> => {
|
||||
const mode = payload.toolChoice?.mode ?? "auto";
|
||||
if (mode !== "manual") {
|
||||
return new Set<ToolName>(["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<ToolName>();
|
||||
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<string, unknown>;
|
||||
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<MindmapOp[]> => {
|
||||
const targetUid = String(args.targetUid || "").trim();
|
||||
const instruction = String(args.instruction || "").trim();
|
||||
const ops: MindmapOp[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
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<string, number>();
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -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<AgentAssetItem[]> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<SearxResult[]> => {
|
||||
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<string, string>) => {
|
||||
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<string, unknown>): 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<TrashedMindmapMeta, "trashedFileName">) {
|
||||
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<Array<{ meta: TrashedMindmapMeta; metaPath: string; trashedPath: string }>> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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<CustomBlockSchema>;
|
||||
|
||||
export default function MindmapFullscreenPage({
|
||||
}: Record<string, never>) {
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-white">
|
||||
<MindmapBlockView
|
||||
block={stubBlock}
|
||||
editor={editorStub}
|
||||
fullscreen
|
||||
onExitFullscreen={() => router.push(`/documents/${docId}`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export function BlockNoteEditor({
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
[documentId],
|
||||
[documentId, normalizedInitialContent],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -627,6 +627,9 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
openTableFullScreen: (tableId: string) => {
|
||||
setFullScreenTableId(tableId);
|
||||
},
|
||||
insertMediaAsset: (asset: MediaAsset) => {
|
||||
insertMediaAssetBlock(asset);
|
||||
},
|
||||
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
|
||||
@@ -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<ToolName, string> = {
|
||||
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<AgentMessage[]>([
|
||||
{
|
||||
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<ToolName[]>(DEFAULT_TOOLS);
|
||||
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
|
||||
const [assets, setAssets] = useState<AgentAssetItem[]>([]);
|
||||
const [workspaceId, setWorkspaceId] = useState<string>("");
|
||||
const [attachments, setAttachments] = useState<AgentAssetItem[]>([]);
|
||||
const [debug, setDebug] = useState<string>("");
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(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<string, (data: unknown) => 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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-2 pb-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
{selectedUids.length ? `选中节点:${selectedUids[0]}` : "未选中节点(将以整图为上下文)"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${networkOn ? "border-blue-200 bg-blue-50 text-blue-700" : "border-gray-200 text-gray-500"}`}
|
||||
title="联网检索(SearxNG)"
|
||||
onClick={() => setNetworkOn((v) => !v)}
|
||||
>
|
||||
<Network className="h-3 w-3" />
|
||||
联网
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${toolPickerOpen ? "border-gray-300 bg-gray-50 text-gray-700" : "border-gray-200 text-gray-600"}`}
|
||||
title="选择允许使用的工具"
|
||||
onClick={() => setToolPickerOpen((v) => !v)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
工具
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolPickerOpen && (
|
||||
<div className="mb-3 rounded-md border border-gray-200 bg-white p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-gray-700">工具选择</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2 py-1 text-xs ${toolAuto ? "bg-blue-500 text-white" : "border border-gray-200 text-gray-600"}`}
|
||||
onClick={() => setToolAuto((v) => !v)}
|
||||
title="自动:AI 自行选择;手动:仅允许勾选工具"
|
||||
>
|
||||
{toolAuto ? "自动" : "手动"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{DEFAULT_TOOLS.map((t) => (
|
||||
<label key={t} className={`flex cursor-pointer items-center gap-2 text-xs ${toolAuto ? "opacity-50" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={toolAuto}
|
||||
checked={selectedTools.includes(t)}
|
||||
onChange={() => toggleTool(t)}
|
||||
/>
|
||||
<span className="text-gray-700">{TOOL_LABEL[t]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-gray-400">
|
||||
提示:如果你希望 AI 一定要“写入导图”,请在需求中明确说“请写入并保存”。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white p-2">
|
||||
<div className="space-y-2">
|
||||
{messages.map((m, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`whitespace-pre-wrap rounded-md px-2 py-2 text-sm ${m.role === "user" ? "bg-gray-50 text-gray-900" : "bg-white text-gray-800"}`}
|
||||
>
|
||||
<div className="mb-1 text-[11px] text-gray-400">{m.role === "user" ? "你" : "AI"}</div>
|
||||
<div>{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{attachments.map((a) => (
|
||||
<span
|
||||
key={a.id}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] text-gray-700"
|
||||
title={a.fileUrl}
|
||||
>
|
||||
@{a.title}
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-700"
|
||||
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 rounded-md border border-gray-200 bg-white px-2 py-2 text-xs text-gray-700">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="text-gray-500">AI:</div>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
data-testid="mindmap-ai-provider-online"
|
||||
checked={aiProvider === "online"}
|
||||
onChange={() => setAiProvider("online")}
|
||||
/>
|
||||
在线
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
data-testid="mindmap-ai-provider-local"
|
||||
checked={aiProvider === "local"}
|
||||
onChange={() => setAiProvider("local")}
|
||||
/>
|
||||
本地
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-500">模型:</div>
|
||||
{aiProvider === "online" ? (
|
||||
<select
|
||||
data-testid="mindmap-ai-model-select"
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
<option key={m || "__default__"} value={m}>
|
||||
{m ? m : "默认(ai.md)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
data-testid="mindmap-ai-model-input"
|
||||
className="w-[220px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{aiProvider === "local" ? (
|
||||
<div className="mt-1 text-[11px] text-gray-400">
|
||||
本地 AI 需配置 `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` 或 `ai.local.md` / `ai-local.md`
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative mt-2">
|
||||
{mentionOpen && filteredAssets.length > 0 && (
|
||||
<div className="absolute bottom-[calc(100%+8px)] left-0 right-0 z-30 max-h-56 overflow-auto rounded-md border border-gray-200 bg-white shadow">
|
||||
{filteredAssets.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
|
||||
onClick={() => insertMention(a)}
|
||||
>
|
||||
<span className="truncate text-gray-800">{a.title}</span>
|
||||
<span className="shrink-0 text-[11px] text-gray-400">{a.kind}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
data-testid="mindmap-ai-input"
|
||||
className="w-full resize-none rounded-md border border-gray-200 bg-white p-2 text-sm outline-none focus:border-blue-300"
|
||||
rows={4}
|
||||
value={input}
|
||||
placeholder="输入你的需求。使用 @ 选择文件(PDF/附件/本地导图),例如:总结 @卤化反应原理_1-9.pdf 并写入导图(章->节->要点)。"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setInput(v);
|
||||
updateMentionState(v, e.target.selectionStart ?? v.length);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// 焦点在 AI 输入框时,不应触发导图 Enter/Tab 快捷键
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
setMentionOpen(false);
|
||||
setToolPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
// Enter 发送;Shift+Enter 换行
|
||||
if (!e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
if (!loading) void send();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const el = e.currentTarget;
|
||||
updateMentionState(el.value, el.selectionStart ?? el.value.length);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传文件到当前页面附件"
|
||||
disabled={loading}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
上传
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void uploadFiles(e.target.files);
|
||||
}}
|
||||
accept="*/*"
|
||||
/>
|
||||
<div className="text-[11px] text-gray-400">Enter 发送 · Shift+Enter 换行</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
disabled={loading || !input.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{loading ? "执行中..." : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{debug ? (
|
||||
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
||||
<summary className="cursor-pointer select-none">调试信息(tool trace)</summary>
|
||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -296,6 +296,7 @@ const MindmapBlockView = ({
|
||||
block,
|
||||
editor,
|
||||
fullscreen = false,
|
||||
onExitFullscreen,
|
||||
}: {
|
||||
block: SpecificBlock<
|
||||
CustomBlockSchema,
|
||||
@@ -305,6 +306,7 @@ const MindmapBlockView = ({
|
||||
>;
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
fullscreen?: boolean;
|
||||
onExitFullscreen?: () => void;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -327,9 +329,8 @@ const MindmapBlockView = ({
|
||||
const hotkeyScopeRef = useRef(false);
|
||||
const lastInteractionAtRef = useRef(0);
|
||||
const skipNextPasteRef = useRef(false);
|
||||
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
|
||||
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
|
||||
const recentNodeDblclickRef = useRef(false);
|
||||
const [fullscreenApiActive, setFullscreenApiActive] = useState(false);
|
||||
const deletingRef = useRef(false);
|
||||
|
||||
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
|
||||
@@ -441,6 +442,7 @@ const MindmapBlockView = ({
|
||||
|
||||
// 这里用 setTimeout(0) 而不是 microtask:BlockNote/ProseMirror 可能会在同一轮事件里重新抢回焦点,
|
||||
// 导致“选中节点后 Ctrl+V 把思维导图替换成纯文本”。延后一拍把焦点拉回 wrapper,保证快捷键/粘贴作用域稳定。
|
||||
if (effectiveFullscreen) return;
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
@@ -455,13 +457,14 @@ const MindmapBlockView = ({
|
||||
document.removeEventListener("pointerdown", onPointerDownCapture, true);
|
||||
document.removeEventListener("mousedown", onPointerDownCapture, true);
|
||||
};
|
||||
}, []);
|
||||
}, [effectiveFullscreen]);
|
||||
|
||||
// 关键:阻止鼠标事件冒泡到 BlockNote/ProseMirror(它们会在 contenteditable 上处理 mousedown,从而产生 NodeSelection)。
|
||||
// 不能用 React 的 onMouseDown(事件委托在 document,太晚了),必须用原生监听挂在 wrapper 上,确保在 bubble 链路中先于 editor DOM。
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
if (effectiveFullscreen) return;
|
||||
|
||||
const stopBubble = (e: Event) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
@@ -525,7 +528,10 @@ const MindmapBlockView = ({
|
||||
|
||||
const exitLocalFullscreen = useCallback(() => {
|
||||
setActiveSidebar(null);
|
||||
if (fullscreen) return;
|
||||
if (fullscreen) {
|
||||
onExitFullscreen?.();
|
||||
return;
|
||||
}
|
||||
if (typeof document === "undefined") {
|
||||
setLocalFullscreen(false);
|
||||
return;
|
||||
@@ -540,29 +546,12 @@ const MindmapBlockView = ({
|
||||
return;
|
||||
}
|
||||
setLocalFullscreen(false);
|
||||
}, [fullscreen]);
|
||||
}, [fullscreen, onExitFullscreen]);
|
||||
|
||||
const enterLocalFullscreen = useCallback(() => {
|
||||
if (fullscreen) return;
|
||||
setLocalFullscreen(true);
|
||||
setActiveSidebar(null);
|
||||
if (typeof document === "undefined") return;
|
||||
if (!document.fullscreenEnabled) return;
|
||||
if (document.fullscreenElement) return;
|
||||
// 必须在“用户手势回调”中同步调用,避免 Fullscreen API 被浏览器拒绝
|
||||
try {
|
||||
const target = document.documentElement as unknown as {
|
||||
requestFullscreen?: () => Promise<void>;
|
||||
};
|
||||
const p = target.requestFullscreen?.();
|
||||
if (p && typeof p.catch === "function") {
|
||||
p.catch(() => {
|
||||
// ignore:失败则保持 Portal 伪全屏
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore:失败则保持 Portal 伪全屏
|
||||
}
|
||||
}, [fullscreen]);
|
||||
|
||||
// 沉浸式全屏:锁定页面滚动、支持 ESC 退出(仅对内嵌全屏生效)
|
||||
@@ -596,24 +585,6 @@ const MindmapBlockView = ({
|
||||
};
|
||||
}, [localFullscreen, fullscreen, exitLocalFullscreen]);
|
||||
|
||||
// “真全屏”:使用 Fullscreen API 隐藏浏览器/桌面窗口的外层 UI(Electron/Web 都可用)
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const onFsChange = () => {
|
||||
const active = Boolean(document.fullscreenElement);
|
||||
setFullscreenApiActive(active);
|
||||
// 注意:浏览器在打开原生对话框(例如 file picker / alert / confirm)时可能会自动退出
|
||||
// Fullscreen API。此时不应退出“沉浸式全屏”UI,否则会导致用户在全屏编辑中执行导入/新建
|
||||
// 等操作时被强制退出全屏。
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", onFsChange);
|
||||
};
|
||||
}, [localFullscreen]);
|
||||
|
||||
// 让 Enter/Tab/复制/粘贴在“思维导图块”内生效(避免 BlockNote 抢走快捷键)
|
||||
useLayoutEffect(() => {
|
||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||
@@ -706,6 +677,21 @@ const MindmapBlockView = ({
|
||||
//(例如 Ctrl+C/Ctrl+V 作为文本复制粘贴)。
|
||||
if (isNodeTextEditing) return;
|
||||
|
||||
if ((key === "Delete" || key === "Backspace") && !e.shiftKey && !e.altKey && !isMod) {
|
||||
stop();
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
inst.execCommand?.("REMOVE_NODE");
|
||||
hasLocalEditsRef.current = true;
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMod && lower === "c") {
|
||||
stop();
|
||||
const inst = ensureActiveBefore(mm);
|
||||
@@ -1043,10 +1029,10 @@ const MindmapBlockView = ({
|
||||
);
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data }),
|
||||
body: JSON.stringify({ data, createOnly: true }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(
|
||||
@@ -1426,9 +1412,14 @@ const MindmapBlockView = ({
|
||||
window.__mindmapInstance = instance;
|
||||
const w = window as unknown as {
|
||||
__mindmapInstancesById?: Record<string, MindMapInstance>;
|
||||
__mindmapPersistById?: Record<string, (data: unknown) => void>;
|
||||
};
|
||||
if (!w.__mindmapInstancesById) w.__mindmapInstancesById = {};
|
||||
w.__mindmapInstancesById[mindmapId] = instance;
|
||||
if (!w.__mindmapPersistById) w.__mindmapPersistById = {};
|
||||
w.__mindmapPersistById[mindmapId] = (data: unknown) => {
|
||||
persistDataRef.current?.(data);
|
||||
};
|
||||
}
|
||||
|
||||
setMindmap(instance);
|
||||
@@ -1471,13 +1462,17 @@ const MindmapBlockView = ({
|
||||
// 这里用内部事件标记“当前在思维导图作用域内”,确保 Ctrl+C/Ctrl+V 不会被 BlockNote 抢走。
|
||||
hotkeyScopeRef.current = true;
|
||||
lastInteractionAtRef.current = Date.now();
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
// 仅内嵌视图需要把焦点从编辑器拉回本块;全屏(Portal)下强制 focus
|
||||
// 可能会抢走节点文本编辑的输入焦点,导致双击编辑不出光标。
|
||||
if (!effectiveFullscreen) {
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
instance.on?.("node_click", (node: unknown) => {
|
||||
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
|
||||
@@ -1499,13 +1494,17 @@ const MindmapBlockView = ({
|
||||
);
|
||||
hotkeyScopeRef.current = true;
|
||||
lastInteractionAtRef.current = Date.now();
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
// 仅内嵌视图需要把焦点从编辑器拉回本块;全屏(Portal)下强制 focus
|
||||
// 可能会抢走节点文本编辑的输入焦点,导致双击编辑不出光标。
|
||||
if (!effectiveFullscreen) {
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
instance.on?.("painter_start", () => setPainterMode(true));
|
||||
instance.on?.("painter_end", () => setPainterMode(false));
|
||||
@@ -1601,10 +1600,16 @@ const MindmapBlockView = ({
|
||||
window.__mindmapInstance = null;
|
||||
}
|
||||
try {
|
||||
const w = window as unknown as { __mindmapInstancesById?: Record<string, MindMapInstance> };
|
||||
const w = window as unknown as {
|
||||
__mindmapInstancesById?: Record<string, MindMapInstance>;
|
||||
__mindmapPersistById?: Record<string, (data: unknown) => void>;
|
||||
};
|
||||
if (w.__mindmapInstancesById && w.__mindmapInstancesById[mindmapId] === createdInstance) {
|
||||
delete w.__mindmapInstancesById[mindmapId];
|
||||
}
|
||||
if (w.__mindmapPersistById && w.__mindmapPersistById[mindmapId]) {
|
||||
delete w.__mindmapPersistById[mindmapId];
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -2392,7 +2397,7 @@ const MindmapBlockView = ({
|
||||
>
|
||||
<div className="fixed left-0 right-0 top-0 z-30 flex h-12 items-center justify-between border-b border-gray-200 bg-white/90 px-4 backdrop-blur">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
思维导图编辑(全屏{fullscreenApiActive ? "·真" : ""})
|
||||
思维导图编辑(全屏)
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2421,6 +2426,8 @@ const MindmapBlockView = ({
|
||||
onSelect={setActiveSidebar}
|
||||
/>
|
||||
<MindmapSidebar
|
||||
documentId={docId}
|
||||
mindmapId={mindmapId}
|
||||
mindmap={mindmap}
|
||||
activeNodes={activeNodes}
|
||||
activeTab={activeSidebar}
|
||||
@@ -2510,6 +2517,8 @@ const MindmapBlockView = ({
|
||||
onSelect={setActiveSidebar}
|
||||
/>
|
||||
<MindmapSidebar
|
||||
documentId={docId}
|
||||
mindmapId={mindmapId}
|
||||
mindmap={mindmap}
|
||||
activeNodes={activeNodes}
|
||||
activeTab={activeSidebar}
|
||||
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
} from "./mindmapOptions";
|
||||
import iconConfig from "./mindmapIconConfig";
|
||||
import imageConfig from "./mindmapImageConfig";
|
||||
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
|
||||
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
|
||||
import type { MindMapNode } from "./mindmapTypes";
|
||||
import { MindmapAiAgentPanel } from "./MindmapAiAgentPanel";
|
||||
// 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入
|
||||
const loadIconModules = async () => {
|
||||
const { nodeIconList } = await import("simple-mind-map/src/svg/icons.js");
|
||||
@@ -34,6 +35,8 @@ const loadIconModules = async () => {
|
||||
};
|
||||
|
||||
type SidebarProps = {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
mindmap: any;
|
||||
activeNodes: MindMapNode[];
|
||||
activeTab: SidebarPanel | null;
|
||||
@@ -1067,7 +1070,17 @@ const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMa
|
||||
|
||||
type AiMode = "chat" | "full" | "partial";
|
||||
|
||||
const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapNode[] }) => {
|
||||
const AiPanel = ({
|
||||
mindmap,
|
||||
activeNodes,
|
||||
documentId,
|
||||
mindmapId,
|
||||
}: {
|
||||
mindmap: any;
|
||||
activeNodes: MindMapNode[];
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
}) => {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [mode, setMode] = useState<AiMode>("full");
|
||||
const [model, setModel] = useState("qwen3:30b-a3b-instruct-2507-q4_K_M");
|
||||
@@ -1077,6 +1090,38 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
|
||||
const [loading, setLoading] = useState(false);
|
||||
const controllerRef = React.useRef<AbortController | null>(null);
|
||||
|
||||
// 文档驱动:从 PDF 大纲生成导图(M1)
|
||||
const [docSource, setDocSource] = useState<"test" | "url">("test");
|
||||
const [testPdfName, setTestPdfName] = useState("卤化反应原理_1-9.pdf");
|
||||
const [docUrl, setDocUrl] = useState("");
|
||||
const [docTitle, setDocTitle] = useState("");
|
||||
const [docPreferProvider, setDocPreferProvider] = useState<"online" | "ollama" | "heuristic">("online");
|
||||
const [docMaxPages, setDocMaxPages] = useState(9);
|
||||
const [docLoading, setDocLoading] = useState(false);
|
||||
const [docDebug, setDocDebug] = useState("");
|
||||
|
||||
// AI Agent:补完选中节点(服务端:SearxNG + 在线 AI -> ops -> 落盘)
|
||||
const [expandInstruction, setExpandInstruction] = useState("");
|
||||
const [expandLoading, setExpandLoading] = useState(false);
|
||||
const [expandDebug, setExpandDebug] = useState("");
|
||||
const [expandUseSearx, setExpandUseSearx] = useState(true);
|
||||
|
||||
const persistMindmapData = (data: unknown): boolean => {
|
||||
try {
|
||||
const w = window as unknown as {
|
||||
__mindmapPersistById?: Record<string, (data: unknown) => void>;
|
||||
};
|
||||
const fn = w.__mindmapPersistById?.[mindmapId];
|
||||
if (typeof fn === "function") {
|
||||
fn(data);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const resetStream = () => {
|
||||
setStreamText("");
|
||||
};
|
||||
@@ -1087,6 +1132,133 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const runDocOutlineToMindmap = async () => {
|
||||
setDocDebug("");
|
||||
setDocLoading(true);
|
||||
try {
|
||||
const body =
|
||||
docSource === "test"
|
||||
? {
|
||||
source: { kind: "test", name: testPdfName },
|
||||
ollama: { baseUrl, model },
|
||||
options: { preferProvider: docPreferProvider, maxPages: docMaxPages },
|
||||
}
|
||||
: {
|
||||
source: { kind: "url", fileUrl: docUrl, title: docTitle || undefined },
|
||||
ollama: { baseUrl, model },
|
||||
options: { preferProvider: docPreferProvider, maxPages: docMaxPages },
|
||||
};
|
||||
|
||||
const res = await fetch("/api/mindmap-ai/outline-to-mindmap", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok) {
|
||||
throw new Error(String(json?.error ?? `请求失败:${res.status}`));
|
||||
}
|
||||
if (!json?.mindmapData) {
|
||||
throw new Error("接口返回缺少 mindmapData");
|
||||
}
|
||||
mindmap?.setData?.(json.mindmapData);
|
||||
mindmap?.command?.clearHistory?.();
|
||||
// 直接用服务端返回的结构化数据保存(避免 setData 异步导致快照仍为旧数据,从而覆盖成空树)
|
||||
const ok = persistMindmapData(json.mindmapData);
|
||||
if (!ok) {
|
||||
// 兜底:延迟触发一次 data_change,让外层自行抓取快照保存
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
mindmap?.emit?.("data_change");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
setDocDebug(
|
||||
`已生成:${json?.meta?.title ?? "文档"};provider=${json?.meta?.providerUsed ?? "unknown"};候选行 ${json?.candidates?.length ?? 0};节点 ${json?.plan?.chapters ? "plan" : (json?.outline?.length ?? 0)}`,
|
||||
);
|
||||
} catch (e) {
|
||||
setDocDebug(`生成失败:${e instanceof Error ? e.message : String(e)}`);
|
||||
window.alert(`从 PDF 生成导图失败:${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setDocLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runExpandSelectedNode = async () => {
|
||||
setExpandDebug("");
|
||||
if (!documentId || !mindmapId) {
|
||||
window.alert("缺少 documentId/mindmapId,无法补完。");
|
||||
return;
|
||||
}
|
||||
const list = getActiveList();
|
||||
if (!list?.length) {
|
||||
window.alert("请先选中一个节点再补完。");
|
||||
return;
|
||||
}
|
||||
const node = list[0] as any;
|
||||
const uid =
|
||||
node?.nodeData?.data?.uid ||
|
||||
node?.nodeData?.uid ||
|
||||
node?.getData?.("uid") ||
|
||||
node?.uid ||
|
||||
"";
|
||||
if (!uid) {
|
||||
window.alert("选中节点缺少 uid,无法补完。");
|
||||
return;
|
||||
}
|
||||
const text =
|
||||
node?.getData?.("text") ||
|
||||
node?.nodeData?.data?.text ||
|
||||
node?.data?.text ||
|
||||
"";
|
||||
setExpandLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/mindmap-ai/expand-node", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
mindmapId,
|
||||
targetUid: uid,
|
||||
instruction: expandInstruction || undefined,
|
||||
sources: { searxng: expandUseSearx },
|
||||
}),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok) {
|
||||
throw new Error(String(json?.error ?? `请求失败:${res.status}`));
|
||||
}
|
||||
if (!json?.data) {
|
||||
throw new Error("接口返回缺少 data");
|
||||
}
|
||||
mindmap?.setData?.(json.data);
|
||||
mindmap?.command?.clearHistory?.();
|
||||
// 直接用服务端返回的结构化数据保存(避免 setData 异步导致快照仍为旧数据,从而覆盖成空树)
|
||||
const ok = persistMindmapData(json.data);
|
||||
if (!ok) {
|
||||
// 兜底:延迟触发一次 data_change,让外层自行抓取快照保存
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
mindmap?.emit?.("data_change");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
setExpandDebug(
|
||||
`已补完:${String(text || "目标节点").slice(0, 50)};新增 ${json?.applied ?? 0};searx=${json?.meta?.searched ? "on" : "off"}(${json?.meta?.searxCount ?? 0})`,
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setExpandDebug(`补完失败:${msg}`);
|
||||
window.alert(`补完节点失败:${msg}`);
|
||||
} finally {
|
||||
setExpandLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runChat = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
resetStream();
|
||||
@@ -1359,6 +1531,139 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">文档导图(按大纲生成)</Label>
|
||||
{docSource === "test" && (
|
||||
<a
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
href={`/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testPdfName)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
打开 PDF
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-2 py-2 text-sm hover:bg-gray-50 ${docSource === "test" ? "border-blue-500 text-blue-600" : "border-gray-300 text-gray-700"}`}
|
||||
onClick={() => setDocSource("test")}
|
||||
>
|
||||
测试 PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-2 py-2 text-sm hover:bg-gray-50 ${docSource === "url" ? "border-blue-500 text-blue-600" : "border-gray-300 text-gray-700"}`}
|
||||
onClick={() => setDocSource("url")}
|
||||
>
|
||||
URL / Signed URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{docSource === "test" ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">测试文件名(wolai-frontend/test)</Label>
|
||||
<Input
|
||||
value={testPdfName}
|
||||
onChange={(e) => setTestPdfName(e.target.value)}
|
||||
placeholder="例如:卤化反应原理_1-9.pdf"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">仅本地开发可用:用于快速验证“生成导图 + 节点跳页链接”。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">PDF 地址</Label>
|
||||
<Input
|
||||
value={docUrl}
|
||||
onChange={(e) => setDocUrl(e.target.value)}
|
||||
placeholder="http://127.0.0.1:xxx/file.pdf 或 supabase signed url"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">标题(可选)</Label>
|
||||
<Input value={docTitle} onChange={(e) => setDocTitle(e.target.value)} placeholder="不填则使用“文档”" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">出于安全考虑,当前仅允许本机或 supabase 域名。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">优先模型</Label>
|
||||
<NativeSelect
|
||||
value={docPreferProvider}
|
||||
onChange={(v) => setDocPreferProvider(v as "online" | "ollama" | "heuristic")}
|
||||
options={[
|
||||
{ label: "在线 AI(默认)", value: "online" },
|
||||
{ label: "本地 Ollama", value: "ollama" },
|
||||
{ label: "规则兜底", value: "heuristic" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">最大页数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
step={1}
|
||||
value={docMaxPages}
|
||||
onChange={(e) => setDocMaxPages(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={docLoading || (docSource === "url" && !docUrl.trim())}
|
||||
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={runDocOutlineToMindmap}
|
||||
>
|
||||
{docLoading ? "生成中..." : "从 PDF 生成导图(替换当前)"}
|
||||
</button>
|
||||
{docDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{docDebug}</div>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">AI 补完(选中节点)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
pressed={expandUseSearx}
|
||||
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
<Network className="h-4 w-4 mr-1" />
|
||||
联网
|
||||
</Toggle>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
||||
rows={3}
|
||||
value={expandInstruction}
|
||||
onChange={(e) => setExpandInstruction(e.target.value)}
|
||||
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={expandLoading}
|
||||
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={runExpandSelectedNode}
|
||||
>
|
||||
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
|
||||
</button>
|
||||
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
|
||||
<p className="text-xs text-gray-400">
|
||||
说明:服务端会用 SearxNG 检索证据 + 在线 AI 生成 ops,并自动落盘到当前 mindmap 文件中。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Label className="text-xs text-gray-500">模式</Label>
|
||||
<NativeSelect
|
||||
value={mode}
|
||||
@@ -1462,7 +1767,14 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
|
||||
);
|
||||
};
|
||||
|
||||
export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: SidebarProps) => {
|
||||
export const MindmapSidebar = ({
|
||||
documentId,
|
||||
mindmapId,
|
||||
mindmap,
|
||||
activeNodes,
|
||||
activeTab,
|
||||
onClose,
|
||||
}: SidebarProps) => {
|
||||
const content = useMemo(() => {
|
||||
switch (activeTab as SidebarPanel | null) {
|
||||
case "style":
|
||||
@@ -1484,11 +1796,18 @@ export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: Sid
|
||||
case "note":
|
||||
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
|
||||
case "ai":
|
||||
return <AiPanel mindmap={mindmap} activeNodes={activeNodes} />;
|
||||
return (
|
||||
<MindmapAiAgentPanel
|
||||
mindmap={mindmap}
|
||||
activeNodes={activeNodes}
|
||||
documentId={documentId}
|
||||
mindmapId={mindmapId}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [activeTab, mindmap, activeNodes]);
|
||||
}, [activeTab, mindmap, activeNodes, documentId, mindmapId]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!activeTab) return "";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
|
||||
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
|
||||
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
|
||||
@@ -12,6 +12,7 @@ import { DocumentHistoryDrawer } from "@/components/editor/document-history-draw
|
||||
import type { DocumentSnapshot } from "@/types/document";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
@@ -31,6 +32,7 @@ export interface DocumentContentProps {
|
||||
initialContent: unknown;
|
||||
initialOptions: PageOptionsState;
|
||||
initialStats: DocumentStats | null;
|
||||
openTableId?: string | null;
|
||||
}
|
||||
|
||||
const defaultOptions: PageOptionsState = {
|
||||
@@ -52,14 +54,45 @@ export function DocumentContent({
|
||||
initialContent,
|
||||
initialOptions,
|
||||
initialStats,
|
||||
openTableId,
|
||||
}: DocumentContentProps) {
|
||||
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
|
||||
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
|
||||
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
const router = useRouter();
|
||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
||||
const [content, setContent] = useState<unknown>(initialContent);
|
||||
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
const [contentReloadKey, setContentReloadKey] = useState(0);
|
||||
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
|
||||
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingOpenTableRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const tableId = (openTableId ?? "").trim();
|
||||
if (!tableId) return;
|
||||
if (!editorBridge?.openTableFullScreen) return;
|
||||
if (pendingOpenTableRef.current === tableId) return;
|
||||
pendingOpenTableRef.current = tableId;
|
||||
|
||||
editorBridge.openTableFullScreen(tableId);
|
||||
|
||||
// 清理 URL 参数,避免刷新/回退时重复触发
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("openTableId");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} catch {
|
||||
// fallback:不影响主流程
|
||||
router.replace(`/documents/${documentId}`);
|
||||
}
|
||||
}
|
||||
}, [documentId, editorBridge, openTableId, router]);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
@@ -75,6 +108,72 @@ export function DocumentContent({
|
||||
}, [initialStats]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
const load = async () => {
|
||||
setContentError(null);
|
||||
setContentLoading(initialContent == null);
|
||||
setContent(initialContent);
|
||||
setShowContentLoadingIndicator(false);
|
||||
|
||||
if (initialContent != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentLoadingTimerRef.current) {
|
||||
clearTimeout(contentLoadingTimerRef.current);
|
||||
contentLoadingTimerRef.current = null;
|
||||
}
|
||||
// 避免“秒闪”的加载提示:只有当加载超过短阈值时才显示提示
|
||||
contentLoadingTimerRef.current = setTimeout(() => {
|
||||
if (!canceled) {
|
||||
setShowContentLoadingIndicator(true);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/documents/content?documentId=${encodeURIComponent(documentId)}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload?.error ?? "加载页面内容失败");
|
||||
}
|
||||
const payload = (await response.json()) as { content?: unknown };
|
||||
if (canceled) return;
|
||||
setContent(payload.content ?? null);
|
||||
} catch (error) {
|
||||
if (canceled) return;
|
||||
if ((error as { name?: string })?.name === "AbortError") return;
|
||||
setContentError(error instanceof Error ? error.message : "加载页面内容失败");
|
||||
} finally {
|
||||
if (!canceled) {
|
||||
setContentLoading(false);
|
||||
setShowContentLoadingIndicator(false);
|
||||
}
|
||||
if (contentLoadingTimerRef.current) {
|
||||
clearTimeout(contentLoadingTimerRef.current);
|
||||
contentLoadingTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
controller.abort();
|
||||
if (contentLoadingTimerRef.current) {
|
||||
clearTimeout(contentLoadingTimerRef.current);
|
||||
contentLoadingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [documentId, initialContent, contentReloadKey]);
|
||||
|
||||
const persistTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
const payload = nextTitle.trim() || "无标题";
|
||||
@@ -226,14 +325,39 @@ export function DocumentContent({
|
||||
<p className="text-sm text-gray-400">最近更新:{formattedUpdatedAt}</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-12 py-6">
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={initialContent}
|
||||
pageOptions={options}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
/>
|
||||
{contentLoading ? (
|
||||
showContentLoadingIndicator ? (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-gray-400">
|
||||
页面内容加载中...
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64" />
|
||||
)
|
||||
) : contentError ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2 text-sm text-red-600">
|
||||
<div>{contentError}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-200 bg-red-50 px-3 py-1 text-sm text-red-700 hover:bg-red-100"
|
||||
onClick={() => {
|
||||
setContentError(null);
|
||||
setContentLoading(true);
|
||||
setContentReloadKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
pageOptions={options}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
/>
|
||||
)}
|
||||
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -213,6 +213,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
props: { tableId: newTable.id, title: newTable.title },
|
||||
content: [],
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: newTable.id } }));
|
||||
} catch (error) {
|
||||
console.error("Failed to create table:", error);
|
||||
// TODO: 插入错误提示块
|
||||
|
||||
@@ -44,6 +44,12 @@ export function PageBacklinksPanel({ workspaceId, documentId, className }: PageB
|
||||
});
|
||||
|
||||
const records = useMemo(() => data ?? [], [data]);
|
||||
// 避免页面切换时“先出现加载态、随后又消失”的闪烁:
|
||||
// 当尚未拿到任何记录且正在加载时,直接不渲染面板。
|
||||
if (!error && records.length === 0 && (isLoading || isFetching)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isLoading && !error && records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import { getFileTreeRowLabel } from "@/lib/file-tree/types";
|
||||
@@ -38,6 +39,29 @@ export function FileTree({
|
||||
onDropFiles,
|
||||
onInternalDrop,
|
||||
}: FileTreeProps) {
|
||||
const [dragOverRowId, setDragOverRowId] = useState<string | null>(null);
|
||||
|
||||
const dragOverRange = useMemo(() => {
|
||||
if (!dragOverRowId) return null;
|
||||
const startIndex = rows.findIndex((row) => row.rowId === dragOverRowId);
|
||||
if (startIndex < 0) return null;
|
||||
|
||||
const target = rows[startIndex];
|
||||
const targetDepth = target.depth;
|
||||
|
||||
// VS Code 的树在拖拽悬停到“展开的文件夹”时,会把该节点的可渲染范围都
|
||||
// 标记为 drop feedback(包含它的所有可见子节点)。我们用“扁平化 rows +
|
||||
// depth”来近似计算该范围。
|
||||
let endIndex = startIndex + 1;
|
||||
if (target.kind === "doc" && target.isExpanded && target.hasChildren) {
|
||||
while (endIndex < rows.length && rows[endIndex].depth > targetDepth) {
|
||||
endIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { startIndex, endIndex };
|
||||
}, [dragOverRowId, rows]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||||
}
|
||||
@@ -47,6 +71,12 @@ export function FileTree({
|
||||
className="w-full min-w-0 max-w-full space-y-0.5 overflow-x-hidden"
|
||||
onDragOver={(event) => {
|
||||
if (!onDropFiles) return;
|
||||
if (event.target === event.currentTarget) {
|
||||
setDragOverRowId(null);
|
||||
}
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const hasFiles = types.includes("Files") || Boolean(event.dataTransfer.files?.length);
|
||||
if (!hasFiles) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files?.length) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
@@ -55,6 +85,7 @@ export function FileTree({
|
||||
onDrop={(event) => {
|
||||
if (onDropFiles && event.dataTransfer.files?.length) {
|
||||
event.preventDefault();
|
||||
setDragOverRowId(null);
|
||||
const files = event.dataTransfer.files;
|
||||
const activeDocRow = rows.find(
|
||||
(row) => row.kind === "doc" && row.docId === activeId,
|
||||
@@ -64,18 +95,27 @@ export function FileTree({
|
||||
onDropFiles(targetDocId, files);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
const relatedTarget = (event as unknown as { relatedTarget?: EventTarget | null }).relatedTarget;
|
||||
if (relatedTarget && event.currentTarget.contains(relatedTarget as Node)) return;
|
||||
setDragOverRowId(null);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onBlankMouseDown?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rows.map((row) => {
|
||||
{rows.map((row, index) => {
|
||||
const label = getFileTreeRowLabel(row);
|
||||
const selected = selectedRowIds.has(row.rowId);
|
||||
const active = row.kind !== "asset" && row.docId === activeId;
|
||||
const active = row.kind !== "asset" && row.docId === activeId;
|
||||
const draggable =
|
||||
row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset));
|
||||
const inDropFeedback =
|
||||
dragOverRange &&
|
||||
index >= dragOverRange.startIndex &&
|
||||
index < dragOverRange.endIndex;
|
||||
const baseClass =
|
||||
"flex w-full min-w-0 max-w-full select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]";
|
||||
const activeClass =
|
||||
@@ -90,7 +130,12 @@ export function FileTree({
|
||||
return (
|
||||
<div
|
||||
key={row.rowId}
|
||||
className={cn(baseClass, active && activeClass, selected && "bg-[#e8f2ff] text-[#2563eb]")}
|
||||
className={cn(
|
||||
baseClass,
|
||||
active && activeClass,
|
||||
selected && "bg-[#e8f2ff] text-[#2563eb]",
|
||||
inDropFeedback && "bg-gray-200/70",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
onClick={(event) => onRowClick(row, event)}
|
||||
onDoubleClick={(event) => onRowDoubleClick(row, event)}
|
||||
@@ -118,21 +163,29 @@ export function FileTree({
|
||||
event.dataTransfer.setData("text/plain", payload);
|
||||
event.dataTransfer.effectAllowed = event.altKey ? "copyMove" : "move";
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragOverRowId(null);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const hasInternal = types.includes("application/x-mnote-file-tree");
|
||||
if (hasInternal && onInternalDrop) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = event.altKey ? "copy" : "move";
|
||||
setDragOverRowId((prev) => (prev === row.rowId ? prev : row.rowId));
|
||||
return;
|
||||
}
|
||||
if (!onDropFiles) return;
|
||||
const hasFiles = types.includes("Files") || Boolean(event.dataTransfer.files?.length);
|
||||
if (!hasFiles) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files?.length) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
setDragOverRowId((prev) => (prev === row.rowId ? prev : row.rowId));
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
setDragOverRowId(null);
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const isInternal = types.includes("application/x-mnote-file-tree");
|
||||
if (isInternal && onInternalDrop) {
|
||||
|
||||
@@ -48,10 +48,9 @@ import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
|
||||
import { reduceFileTreeSelection } from "@/lib/file-tree/selection";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import { parseFileTreeRowId } from "@/lib/file-tree/types";
|
||||
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
|
||||
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
||||
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
||||
import {
|
||||
inferPasteTargetDocId,
|
||||
isTextInputTarget,
|
||||
@@ -110,9 +109,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
||||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [trashSearch, setTrashSearch] = useState("");
|
||||
const [trashTab, setTrashTab] = useState<"documents" | "assets">("documents");
|
||||
const [emptyingTrash, setEmptyingTrash] = useState(false);
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
|
||||
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
|
||||
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -141,9 +142,13 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
setMindmapAssets(sidebarData.mindmapAssets ?? []);
|
||||
}, [sidebarData.mindmapAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableAssets(sidebarData.tableAssets ?? []);
|
||||
}, [sidebarData.tableAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null });
|
||||
}, [mediaAssets, mindmapAssets, sidebarData.documents]);
|
||||
}, [mediaAssets, mindmapAssets, tableAssets, sidebarData.documents]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
@@ -159,6 +164,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else if (asset.asset_type === "luckysheet") {
|
||||
setTableAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else {
|
||||
setMediaAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
@@ -176,6 +186,17 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
};
|
||||
}, [sidebarQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const onSaved = () => void sidebarQuery.refetch();
|
||||
const onDeleted = () => void sidebarQuery.refetch();
|
||||
window.addEventListener("online-table-saved", onSaved as EventListener);
|
||||
window.addEventListener("online-table-deleted", onDeleted as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("online-table-saved", onSaved as EventListener);
|
||||
window.removeEventListener("online-table-deleted", onDeleted as EventListener);
|
||||
};
|
||||
}, [sidebarQuery]);
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
await sidebarQuery.refetch();
|
||||
}, [sidebarQuery]);
|
||||
@@ -241,9 +262,21 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
);
|
||||
}, [sidebarData.trashedDocuments, trashSearch]);
|
||||
|
||||
const filteredTrashedMediaAssets = useMemo(() => {
|
||||
const assets = [
|
||||
...(sidebarData.trashedMediaAssets ?? []),
|
||||
...(sidebarData.trashedMindmapAssets ?? []),
|
||||
];
|
||||
const keyword = trashSearch.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return assets;
|
||||
}
|
||||
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
|
||||
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, trashSearch]);
|
||||
|
||||
const assetsByDoc = useMemo(() => {
|
||||
const map: Record<string, MediaAsset[]> = {};
|
||||
const assets = [...(mediaAssets ?? []), ...(mindmapAssets ?? [])];
|
||||
const assets = [...(mediaAssets ?? []), ...(mindmapAssets ?? []), ...(tableAssets ?? [])];
|
||||
|
||||
assets.forEach((asset) => {
|
||||
if (!map[asset.document_id]) {
|
||||
@@ -255,7 +288,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [mediaAssets, mindmapAssets]);
|
||||
}, [mediaAssets, mindmapAssets, tableAssets]);
|
||||
|
||||
const fileTreeRows = useMemo(
|
||||
() =>
|
||||
@@ -290,17 +323,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
return map;
|
||||
}, [sidebarData.documents]);
|
||||
|
||||
const selectedAssetIdsForMenu = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
fileTreeSelection.selectedRowIds.forEach((rowId) => {
|
||||
const parsed = parseFileTreeRowId(rowId);
|
||||
if (parsed?.kind === "asset") {
|
||||
ids.push(parsed.assetId);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}, [fileTreeSelection.selectedRowIds]);
|
||||
|
||||
const activeWorkspace =
|
||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||
sidebarData.workspaces[0];
|
||||
@@ -386,7 +408,17 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
const handleOpenAsset = useCallback((asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
router.push(`/documents/${asset.document_id}`);
|
||||
router.push(`/mindmap/${asset.document_id}/${asset.id}`);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
if (activeId && activeId === asset.document_id && editorBridge?.openTableFullScreen) {
|
||||
editorBridge.openTableFullScreen(asset.id);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
router.push(`/documents/${asset.document_id}?openTableId=${encodeURIComponent(asset.id)}`);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -398,7 +430,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}, [router, setOpen]);
|
||||
}, [activeId, editorBridge, router, setOpen]);
|
||||
|
||||
const handleFileTreeBlankMouseDown = useCallback(() => {
|
||||
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
|
||||
@@ -418,8 +450,20 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 与 VS Code 的单击打开不同:为了避免误触导致重资源文件(思维导图/表格等)被
|
||||
// 直接打开,我们在“无修饰键”的单击时只跳转到对应页面的 index.md(即文档本身)。
|
||||
if (event.button !== 0) return;
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey) return;
|
||||
|
||||
const targetDocId = row.docId;
|
||||
if (!targetDocId) return;
|
||||
if (activeId && activeId === targetDocId) return;
|
||||
|
||||
// doc/index/asset 都统一跳转到所属页面(index.md)
|
||||
handleOpenDocument(targetDocId, "main");
|
||||
},
|
||||
[fileTreeVisibleRowIds],
|
||||
[activeId, fileTreeVisibleRowIds, handleOpenDocument],
|
||||
);
|
||||
|
||||
const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => {
|
||||
@@ -602,7 +646,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const handleCopyAssetLink = useCallback(
|
||||
async (asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
await copyText(buildDocumentUrl(asset.document_id), "页面链接已复制");
|
||||
await copyText(buildMindmapUrl(asset.document_id, asset.id), "思维导图链接已复制");
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
await copyText(buildTableUrl(asset.id), "表格链接已复制");
|
||||
return;
|
||||
}
|
||||
const url = asset.signed_url ?? asset.file_url ?? "";
|
||||
@@ -615,16 +663,18 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
|
||||
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
|
||||
const path =
|
||||
asset.asset_type === "mindmap"
|
||||
? ((asset.file_url ? asset.file_url.replace(/^\//, "") : "") ||
|
||||
`documents/${asset.document_id}/${asset.file_name ?? "mindmap.json"}`)
|
||||
: asset.storage_path || asset.file_url || asset.file_name || "附件";
|
||||
: asset.asset_type === "luckysheet"
|
||||
? (`tables/${asset.id}`)
|
||||
: asset.storage_path || asset.file_url || asset.file_name || "附件";
|
||||
await copyText(path, "存储路径已复制");
|
||||
}, []);
|
||||
|
||||
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
|
||||
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`);
|
||||
if (!resp.ok) {
|
||||
@@ -642,6 +692,10 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
window.alert("在线表格暂不支持下载(后续可做导出 JSON/Excel)");
|
||||
return;
|
||||
}
|
||||
const url = asset.signed_url ?? asset.file_url;
|
||||
if (!url) {
|
||||
window.alert("暂无可用的下载链接");
|
||||
@@ -658,7 +712,30 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
window.alert("思维导图暂不支持重命名");
|
||||
return;
|
||||
}
|
||||
const input = window.prompt("输入新文件名", asset.file_name ?? "");
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
const currentTitle =
|
||||
(asset.file_name ?? "").toLowerCase().endsWith(".luckysheet")
|
||||
? (asset.file_name ?? "").slice(0, -".luckysheet".length)
|
||||
: (asset.file_name ?? "");
|
||||
const input = window.prompt("输入新表格名", currentTitle);
|
||||
if (!input || !input.trim()) return;
|
||||
const newTitle = input.trim();
|
||||
const resp = await fetch(`/api/tables/${asset.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: newTitle }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "重命名失败");
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: asset.id } }));
|
||||
await sidebarQuery.refetch();
|
||||
setAssetMenu(null);
|
||||
return;
|
||||
}
|
||||
const input = window.prompt("输入新文件名", asset.file_name ?? "");
|
||||
if (!input || !input.trim()) return;
|
||||
const newName = input.trim();
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
@@ -684,7 +761,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
window.alert("思维导图文件无需移动,请在页面中直接编辑");
|
||||
return;
|
||||
}
|
||||
const target = window.prompt("输入目标页面 ID", asset.document_id);
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
window.alert("在线表格暂不支持移动(后续可实现跨页面迁移)");
|
||||
return;
|
||||
}
|
||||
const target = window.prompt("输入目标页面 ID", asset.document_id);
|
||||
if (!target || !target.trim()) return;
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
@@ -712,7 +793,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
async (assetIds: string[], assetHint?: MediaAsset) => {
|
||||
const uniqueAssetIds = Array.from(new Set(assetIds));
|
||||
const assets = uniqueAssetIds
|
||||
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id))
|
||||
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
|
||||
.filter(Boolean) as MediaAsset[];
|
||||
|
||||
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
|
||||
@@ -720,6 +801,10 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
|
||||
const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
|
||||
const tableAssetsToDelete = assets.filter((item) => item.asset_type === "luckysheet");
|
||||
const fileAssetsToDelete = assets.filter(
|
||||
(item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
|
||||
);
|
||||
const mindmapIdsByDocId = new Map<string, string[]>();
|
||||
mindmapAssetsToDelete.forEach((item) => {
|
||||
const prev = mindmapIdsByDocId.get(item.document_id) ?? [];
|
||||
@@ -727,13 +812,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
mindmapIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
const fileAssetIdsByDocId = new Map<string, string[]>();
|
||||
assets
|
||||
.filter((item) => item.asset_type !== "mindmap")
|
||||
.forEach((item) => {
|
||||
const prev = fileAssetIdsByDocId.get(item.document_id) ?? [];
|
||||
prev.push(item.id);
|
||||
fileAssetIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
fileAssetsToDelete.forEach((item) => {
|
||||
const prev = fileAssetIdsByDocId.get(item.document_id) ?? [];
|
||||
prev.push(item.id);
|
||||
fileAssetIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat();
|
||||
|
||||
for (const asset of mindmapAssetsToDelete) {
|
||||
@@ -745,6 +828,16 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of tableAssetsToDelete) {
|
||||
const resp = await fetch(`/api/tables/${asset.id}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除在线表格失败");
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId: asset.id } }));
|
||||
}
|
||||
|
||||
if (fileAssetIds.length > 0) {
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
@@ -759,14 +852,19 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
|
||||
if (uniqueAssetIds.length > 0) {
|
||||
setMediaAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
|
||||
setMindmapAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
|
||||
const mindmapSet = new Set(mindmapAssetsToDelete.map((item) => item.id));
|
||||
const tableSet = new Set(tableAssetsToDelete.map((item) => item.id));
|
||||
const fileSet = new Set(fileAssetsToDelete.map((item) => item.id));
|
||||
setMediaAssets((prev) => prev.filter((item) => !fileSet.has(item.id)));
|
||||
setMindmapAssets((prev) => prev.filter((item) => !mindmapSet.has(item.id)));
|
||||
setTableAssets((prev) => prev.filter((item) => !tableSet.has(item.id)));
|
||||
}
|
||||
setAssetMenu(null);
|
||||
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
|
||||
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
|
||||
await sidebarQuery.refetch();
|
||||
},
|
||||
[mediaAssets, mindmapAssets, sidebarQuery],
|
||||
[mediaAssets, mindmapAssets, sidebarQuery, tableAssets],
|
||||
);
|
||||
|
||||
const handleDeleteFileTreeSelection = useCallback(async () => {
|
||||
@@ -781,7 +879,19 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
|
||||
const docText = docIds.length > 0 ? `${docIds.length} 个页面(删除到垃圾桶)` : "";
|
||||
const assetText = assetIds.length > 0 ? `${assetIds.length} 个附件(彻底删除)` : "";
|
||||
const selectedAssets = assetIds
|
||||
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
|
||||
.filter(Boolean) as MediaAsset[];
|
||||
const mindmapCount = selectedAssets.filter((item) => item.asset_type === "mindmap").length;
|
||||
const tableCount = selectedAssets.filter((item) => item.asset_type === "luckysheet").length;
|
||||
const fileCount = selectedAssets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
|
||||
const unknownCount = Math.max(0, assetIds.length - mindmapCount - tableCount - fileCount);
|
||||
const assetTextParts: string[] = [];
|
||||
if (fileCount > 0) assetTextParts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
|
||||
if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图(删除)`);
|
||||
if (tableCount > 0) assetTextParts.push(`${tableCount} 个在线表格(删除)`);
|
||||
if (unknownCount > 0) assetTextParts.push(`${unknownCount} 个对象(删除)`);
|
||||
const assetText = assetTextParts.length > 0 ? assetTextParts.join(" + ") : "";
|
||||
const joinText = docText && assetText ? " + " : "";
|
||||
const ok = window.confirm(`确认删除选中的 ${docText}${joinText}${assetText} 吗?`);
|
||||
if (!ok) return;
|
||||
@@ -826,6 +936,9 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
fileTreeRows,
|
||||
fileTreeSelection.selectedRowIds,
|
||||
handleDeleteAssets,
|
||||
mediaAssets,
|
||||
mindmapAssets,
|
||||
tableAssets,
|
||||
refreshTree,
|
||||
router,
|
||||
]);
|
||||
@@ -1269,6 +1382,124 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
}, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]);
|
||||
|
||||
const handleRestoreMediaAssetFromTrash = useCallback(
|
||||
async (assetId: string) => {
|
||||
if (!confirmTrashAction("确认恢复该附件吗?")) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restore", assetIds: [assetId] }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "恢复附件失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handlePurgeMediaAssetFromTrash = useCallback(
|
||||
async (assetId: string) => {
|
||||
if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/media/purge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assetId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "彻底删除附件失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleEmptyMediaTrash = useCallback(async () => {
|
||||
if (!sidebarData.activeWorkspaceId) {
|
||||
window.alert("暂无可清空的工作空间");
|
||||
return;
|
||||
}
|
||||
if (!confirmTrashAction("清空附件垃圾桶后将无法恢复,是否继续?")) {
|
||||
return;
|
||||
}
|
||||
setEmptyingTrash(true);
|
||||
try {
|
||||
const [mediaResp, mindmapResp] = await Promise.all([
|
||||
fetch("/api/media/empty-trash", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
|
||||
}),
|
||||
fetch("/api/mindmap-trash/empty", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
|
||||
}),
|
||||
]);
|
||||
if (!mediaResp.ok) {
|
||||
const payload = await mediaResp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "清空附件垃圾桶失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
if (!mindmapResp.ok) {
|
||||
const payload = await mindmapResp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "清空思维导图垃圾桶失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
} finally {
|
||||
setEmptyingTrash(false);
|
||||
}
|
||||
}, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]);
|
||||
|
||||
const handleRestoreMindmapFromTrash = useCallback(
|
||||
async (documentId: string, mindmapId: string) => {
|
||||
if (!confirmTrashAction("确认恢复该思维导图吗?")) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restore" }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "恢复思维导图失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handlePurgeMindmapFromTrash = useCallback(
|
||||
async (documentId: string, mindmapId: string) => {
|
||||
if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "purge" }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "彻底删除思维导图失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleWorkspaceSwitch = useCallback(
|
||||
async (workspaceId: string) => {
|
||||
if (workspaceId === sidebarData.activeWorkspaceId) {
|
||||
@@ -1533,7 +1764,12 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
<Trash2 className="h-4 w-4 text-gray-500" />
|
||||
垃圾桶
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{sidebarData.trashedDocuments.length} 条</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{sidebarData.trashedDocuments.length +
|
||||
(sidebarData.trashedMediaAssets?.length ?? 0) +
|
||||
(sidebarData.trashedMindmapAssets?.length ?? 0)}{" "}
|
||||
条
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1584,12 +1820,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
onCopyPath={handleCopyAssetPath}
|
||||
onRename={handleRenameAsset}
|
||||
onMove={handleMoveAsset}
|
||||
onDelete={(ids) =>
|
||||
void handleDeleteAssets(
|
||||
selectedAssetIdsForMenu.length > 0 ? selectedAssetIdsForMenu : ids,
|
||||
assetMenu.asset,
|
||||
)
|
||||
}
|
||||
onDelete={() => void handleDeleteFileTreeSelection()}
|
||||
onDownload={handleDownloadAsset}
|
||||
/>
|
||||
)}
|
||||
@@ -1597,11 +1828,43 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>垃圾桶</DrawerTitle>
|
||||
<p className="mt-1 text-xs text-gray-500">180 天内的记录都可以在这里恢复。</p>
|
||||
{trashTab === "documents" ? (
|
||||
<p className="mt-1 text-xs text-gray-500">180 天内的记录都可以在这里恢复。</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
附件删除后 10 分钟内可撤销;也可在此手动清空(不可撤销)。
|
||||
</p>
|
||||
)}
|
||||
</DrawerHeader>
|
||||
<div className="space-y-4 px-4 pb-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-3 py-1 text-sm ${
|
||||
trashTab === "documents"
|
||||
? "border-[#d7e5ff] bg-[#eff5ff] text-[#2563eb]"
|
||||
: "border-[#e8e8e8] text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setTrashTab("documents")}
|
||||
>
|
||||
页面 ({sidebarData.trashedDocuments.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-3 py-1 text-sm ${
|
||||
trashTab === "assets"
|
||||
? "border-[#d7e5ff] bg-[#eff5ff] text-[#2563eb]"
|
||||
: "border-[#e8e8e8] text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setTrashTab("assets")}
|
||||
>
|
||||
附件 (
|
||||
{(sidebarData.trashedMediaAssets?.length ?? 0) + (sidebarData.trashedMindmapAssets?.length ?? 0)}
|
||||
)
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="搜索删除的页面..."
|
||||
placeholder={trashTab === "documents" ? "搜索删除的页面..." : "搜索删除的附件..."}
|
||||
value={trashSearch}
|
||||
onChange={(event) => setTrashSearch(event.target.value)}
|
||||
/>
|
||||
@@ -1618,39 +1881,88 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void handleEmptyTrash()}
|
||||
onClick={() => void (trashTab === "documents" ? handleEmptyTrash() : handleEmptyMediaTrash())}
|
||||
disabled={emptyingTrash}
|
||||
>
|
||||
{emptyingTrash ? "清空中..." : "清空垃圾桶"}
|
||||
{emptyingTrash
|
||||
? "清空中..."
|
||||
: trashTab === "documents"
|
||||
? "清空垃圾桶"
|
||||
: "清空附件垃圾桶"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[#eaeaea]">
|
||||
{filteredTrash.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">暂无已删除页面</div>
|
||||
{trashTab === "documents" ? (
|
||||
filteredTrash.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">暂无已删除页面</div>
|
||||
) : (
|
||||
filteredTrash.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{item.title || "无标题"}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
删除时间:{new Date(item.deleted_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
|
||||
onClick={() => void handleRestoreFromTrash(item.id)}
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
|
||||
onClick={() => void handlePurgeFromTrash(item.id)}
|
||||
>
|
||||
彻底删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : filteredTrashedMediaAssets.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">暂无已删除附件</div>
|
||||
) : (
|
||||
filteredTrash.map((item) => (
|
||||
filteredTrashedMediaAssets.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{item.title || "无标题"}</div>
|
||||
<div className="min-w-0 pr-2">
|
||||
<div className="truncate font-medium text-gray-800">{item.file_name || "未命名附件"}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
删除时间:{new Date(item.deleted_at).toLocaleString()}
|
||||
删除时间:{item.deleted_at ? new Date(item.deleted_at).toLocaleString() : "-"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
类型:{item.mime_type ?? item.asset_type ?? "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
|
||||
onClick={() => void handleRestoreFromTrash(item.id)}
|
||||
onClick={() =>
|
||||
void (item.asset_type === "mindmap"
|
||||
? handleRestoreMindmapFromTrash(item.document_id, item.id)
|
||||
: handleRestoreMediaAssetFromTrash(item.id))
|
||||
}
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
|
||||
onClick={() => void handlePurgeFromTrash(item.id)}
|
||||
onClick={() =>
|
||||
void (item.asset_type === "mindmap"
|
||||
? handlePurgeMindmapFromTrash(item.document_id, item.id)
|
||||
: handlePurgeMediaAssetFromTrash(item.id))
|
||||
}
|
||||
>
|
||||
彻底删除
|
||||
</button>
|
||||
@@ -1957,6 +2269,20 @@ const buildDocumentUrl = (documentId: string): string => {
|
||||
return `${window.location.origin}/documents/${documentId}`;
|
||||
};
|
||||
|
||||
const buildMindmapUrl = (documentId: string, mindmapId: string): string => {
|
||||
if (typeof window === "undefined" || !window.location) {
|
||||
return `/mindmap/${documentId}/${mindmapId}`;
|
||||
}
|
||||
return `${window.location.origin}/mindmap/${documentId}/${mindmapId}`;
|
||||
};
|
||||
|
||||
const buildTableUrl = (tableId: string): string => {
|
||||
if (typeof window === "undefined" || !window.location) {
|
||||
return `/tables/${tableId}/view`;
|
||||
}
|
||||
return `${window.location.origin}/tables/${tableId}/view`;
|
||||
};
|
||||
|
||||
const copyText = async (text: string, successMessage: string) => {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,13 @@ export interface SidebarInitialData {
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
trashedDocuments: TrashRecord[];
|
||||
trashedMediaAssets?: MediaAsset[];
|
||||
trashedMindmapAssets?: MediaAsset[];
|
||||
/**
|
||||
* 在线表格(Luckysheet)在文件树中的“虚拟文件”列表。
|
||||
* 仅用于文件树展示与操作(单击跳转 index / 双击全屏打开 / 同步删除)。
|
||||
*/
|
||||
tableAssets?: MediaAsset[];
|
||||
/**
|
||||
* 已存在思维导图文件(本地或 supabase)对应的页面 id 列表
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type LocalAiConfig = {
|
||||
baseUrl: string; // 形如 http(s)://host/v1
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
const tryReadText = async (file: string) => {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const findConfigText = async () => {
|
||||
const cwd = process.cwd();
|
||||
const candidates = [
|
||||
path.join(cwd, "ai.local.md"),
|
||||
path.join(cwd, "ai-local.md"),
|
||||
path.join(cwd, "..", "ai.local.md"),
|
||||
path.join(cwd, "..", "ai-local.md"),
|
||||
path.join(cwd, "..", "..", "ai.local.md"),
|
||||
path.join(cwd, "..", "..", "ai-local.md"),
|
||||
];
|
||||
for (const f of candidates) {
|
||||
const text = await tryReadText(f);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseAiMd = (raw: string): LocalAiConfig | null => {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// 兼容格式:
|
||||
// apikey:xxxx(可选)
|
||||
// http://.../v1
|
||||
// model-name
|
||||
const apiLine = lines.find((l) => /^apikey[::]/i.test(l));
|
||||
const apiKey = apiLine ? apiLine.replace(/^apikey[::]\s*/i, "").trim() : "";
|
||||
const baseUrl = lines.find((l) => /^https?:\/\//i.test(l)) ?? "";
|
||||
const model =
|
||||
(lines.findLast?.((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
lines.find((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
"").trim();
|
||||
|
||||
if (!baseUrl || !model) return null;
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: baseUrl.replace(/\/+$/, ""),
|
||||
model,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadLocalAiConfig = async (): Promise<LocalAiConfig | null> => {
|
||||
// 环境变量优先(方便部署),其次读取 ai.local.md / ai-local.md
|
||||
const envBase = (process.env.LOCAL_AI_BASE_URL ?? "").trim();
|
||||
const envModel = (process.env.LOCAL_AI_MODEL ?? "").trim();
|
||||
const envKey = (process.env.LOCAL_AI_API_KEY ?? "").trim();
|
||||
if (envBase && envModel) {
|
||||
return {
|
||||
baseUrl: envBase.replace(/\/+$/, ""),
|
||||
apiKey: envKey,
|
||||
model: envModel,
|
||||
};
|
||||
}
|
||||
|
||||
const text = await findConfigText();
|
||||
if (!text) return null;
|
||||
return parseAiMd(text);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type OnlineAiConfig = {
|
||||
baseUrl: string; // 形如 http(s)://host/v1
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
const tryReadText = async (file: string) => {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const findConfigText = async () => {
|
||||
const cwd = process.cwd();
|
||||
const candidates = [
|
||||
path.join(cwd, "ai.md"),
|
||||
path.join(cwd, "..", "ai.md"),
|
||||
path.join(cwd, "..", "..", "ai.md"),
|
||||
];
|
||||
for (const f of candidates) {
|
||||
const text = await tryReadText(f);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseAiMd = (raw: string): OnlineAiConfig | null => {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// 兼容格式:
|
||||
// apikey:xxxx
|
||||
// http://.../v1
|
||||
// model-name
|
||||
const apiLine = lines.find((l) => /^apikey[::]/i.test(l));
|
||||
const apiKey = apiLine ? apiLine.replace(/^apikey[::]\s*/i, "").trim() : "";
|
||||
const baseUrl = lines.find((l) => /^https?:\/\//i.test(l)) ?? "";
|
||||
// model 行通常是最后一行
|
||||
const model = (lines.findLast?.((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
lines.find((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
"").trim();
|
||||
|
||||
if (!apiKey || !baseUrl || !model) return null;
|
||||
|
||||
// Cloudflare 场景下 http 可能只允许 GET(/models),但 POST(/chat/completions)会被拦截;
|
||||
// 对非本机地址默认升级到 https,确保在线推理可用。
|
||||
let normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
|
||||
try {
|
||||
const u = new URL(normalizedBaseUrl);
|
||||
if (u.protocol === "http:" && u.hostname !== "127.0.0.1" && u.hostname !== "localhost") {
|
||||
u.protocol = "https:";
|
||||
normalizedBaseUrl = u.toString().replace(/\/+$/, "");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
model,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadOnlineAiConfig = async (): Promise<OnlineAiConfig | null> => {
|
||||
// 环境变量优先(方便生产/部署),其次读取 ai.md(本地开发快捷配置)
|
||||
const envKey = (process.env.ONLINE_AI_API_KEY ?? "").trim();
|
||||
const envBase = (process.env.ONLINE_AI_BASE_URL ?? "").trim();
|
||||
const envModel = (process.env.ONLINE_AI_MODEL ?? "").trim();
|
||||
if (envKey && envBase && envModel) {
|
||||
// 同 parseAiMd:默认把非本机 http 升级为 https
|
||||
let normalizedBaseUrl = envBase.replace(/\/+$/, "");
|
||||
try {
|
||||
const u = new URL(normalizedBaseUrl);
|
||||
if (u.protocol === "http:" && u.hostname !== "127.0.0.1" && u.hostname !== "localhost") {
|
||||
u.protocol = "https:";
|
||||
normalizedBaseUrl = u.toString().replace(/\/+$/, "");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { apiKey: envKey, baseUrl: normalizedBaseUrl, model: envModel };
|
||||
}
|
||||
|
||||
const text = await findConfigText();
|
||||
if (!text) return null;
|
||||
return parseAiMd(text);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
export type OpenAiCompatibleChatMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type OpenAiCompatibleChatOptions = {
|
||||
baseUrl: string; // 形如 https://host/v1
|
||||
apiKey: string;
|
||||
model: string;
|
||||
timeoutMs?: number;
|
||||
maxTokens?: number;
|
||||
// 某些 OpenAI 兼容网关使用 max_completion_tokens 字段;如需可传入该值
|
||||
maxCompletionTokens?: number;
|
||||
responseFormat?: "json_object";
|
||||
};
|
||||
|
||||
export const tryExtractJsonObject = (value: string): Record<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));
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const openAiCompatibleChat = async (
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
opts: OpenAiCompatibleChatOptions,
|
||||
): Promise<{ text: string; raw: unknown }> => {
|
||||
const timeoutMs = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000));
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
||||
const maxCompletionTokens =
|
||||
typeof opts.maxCompletionTokens === "number" && Number.isFinite(opts.maxCompletionTokens)
|
||||
? Math.max(64, Math.min(16_000, Math.floor(opts.maxCompletionTokens)))
|
||||
: undefined;
|
||||
const maxTokens =
|
||||
typeof opts.maxTokens === "number" && Number.isFinite(opts.maxTokens)
|
||||
? Math.max(64, Math.min(16_000, Math.floor(opts.maxTokens)))
|
||||
: undefined;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${opts.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: opts.model,
|
||||
stream: false,
|
||||
temperature: 0.2,
|
||||
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
||||
...(maxCompletionTokens ? { max_completion_tokens: maxCompletionTokens } : {}),
|
||||
...(opts.responseFormat === "json_object" ? { response_format: { type: "json_object" } } : {}),
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
|
||||
const raw = (await res.json().catch(() => null)) as unknown;
|
||||
if (!res.ok) {
|
||||
const errText =
|
||||
typeof raw === "object" && raw && "error" in (raw as any)
|
||||
? String((raw as any).error?.message ?? (raw as any).error)
|
||||
: `HTTP ${res.status}`;
|
||||
throw new Error(`在线 AI 调用失败:${errText}`);
|
||||
}
|
||||
|
||||
const choiceText =
|
||||
(raw as any)?.choices?.[0]?.message?.content ??
|
||||
(raw as any)?.choices?.[0]?.text ??
|
||||
"";
|
||||
const text = String(choiceText ?? "");
|
||||
return { text, raw };
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
};
|
||||
|
||||
export const openAiCompatibleChatJson = async (
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
opts: OpenAiCompatibleChatOptions,
|
||||
): Promise<{ json: Record<string, unknown>; text: string; raw: unknown }> => {
|
||||
const { text, raw } = await openAiCompatibleChat(messages, opts);
|
||||
const json = tryExtractJsonObject(text);
|
||||
if (!json) {
|
||||
const preview = String(text || "").slice(0, 220).replace(/\s+/g, " ").trim();
|
||||
throw new Error(`在线 AI 未返回可解析的 JSON 对象:${preview || "(empty)"}`);
|
||||
}
|
||||
return { json, text, raw };
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import "server-only";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
|
||||
@@ -85,3 +87,94 @@ export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMi
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
type TrashedMindmapMeta = {
|
||||
docId: string;
|
||||
mindmapId: string;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
trashedFileName: string;
|
||||
deleted_at: string;
|
||||
};
|
||||
|
||||
async function tryReadJsonFile<T>(file: string): Promise<T | null> {
|
||||
try {
|
||||
const content = await fs.readFile(file, "utf8");
|
||||
return JSON.parse(content) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function listTrashMetasForFolder(folder: string): Promise<TrashedMindmapMeta[]> {
|
||||
const trashDir = path.join(folder, ".trash");
|
||||
try {
|
||||
const entries = await fs.readdir(trashDir);
|
||||
const metas: TrashedMindmapMeta[] = [];
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith(".deleted.json")) continue;
|
||||
const metaPath = path.join(trashDir, name);
|
||||
const meta = await tryReadJsonFile<TrashedMindmapMeta>(metaPath);
|
||||
if (meta?.docId && meta?.mindmapId && meta?.trashedFileName && meta?.deleted_at) {
|
||||
// 若原路径已存在(用户撤销/恢复),则不再展示为垃圾桶记录,避免堆积。
|
||||
try {
|
||||
await fs.access(meta.originalPath);
|
||||
await fs.rm(path.join(trashDir, meta.trashedFileName), { force: true });
|
||||
await fs.rm(metaPath, { force: true });
|
||||
continue;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
metas.push(meta);
|
||||
}
|
||||
}
|
||||
return metas;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectLocalTrashedMindmapAssets(
|
||||
workspaceId: string,
|
||||
docIds: string[],
|
||||
): Promise<MediaAsset[]> {
|
||||
const results: MediaAsset[] = [];
|
||||
|
||||
for (const docId of docIds) {
|
||||
const preferredFolder = path.join(preferredBaseDir, docId);
|
||||
const legacyFolder = path.join(legacyBaseDir, docId);
|
||||
const metas = [
|
||||
...(await listTrashMetasForFolder(preferredFolder)),
|
||||
...(await listTrashMetasForFolder(legacyFolder)),
|
||||
];
|
||||
|
||||
metas.forEach((meta) => {
|
||||
results.push({
|
||||
id: meta.mindmapId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: docId,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: meta.originalFileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: meta.deleted_at,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
signed_url: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
results.sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? ""));
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import "server-only";
|
||||
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { preferredBaseDir, legacyBaseDir } from "@/lib/mindmap-files";
|
||||
|
||||
export function resolveMindmapFileName(mindmapId: string) {
|
||||
if (mindmapId === "legacy" || mindmapId.startsWith("legacy-")) {
|
||||
return "mindmap.json";
|
||||
}
|
||||
return `mindmap-${mindmapId}.json`;
|
||||
}
|
||||
|
||||
async function ensureDir(dir: string) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function ensureIndexFile(folder: string, title = "无标题") {
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
await fs.writeFile(indexFile, `# ${title}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function tryReadJson(file: string) {
|
||||
try {
|
||||
const content = await fs.readFile(file, "utf8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type ReadMindmapResult =
|
||||
| { ok: true; data: any; source: "preferred" | "legacy" }
|
||||
| { ok: false; data: null; source: null };
|
||||
|
||||
export async function readMindmapLocal(docId: string, mindmapId: string): Promise<ReadMindmapResult> {
|
||||
const preferredFolder = path.join(preferredBaseDir, docId);
|
||||
const preferredFile = path.join(preferredFolder, resolveMindmapFileName(mindmapId));
|
||||
const preferredLegacy = path.join(preferredFolder, "mindmap.json");
|
||||
const legacyDirFile =
|
||||
path.basename(preferredFile) === "mindmap.json"
|
||||
? path.join(legacyBaseDir, docId, "mindmap.json")
|
||||
: null;
|
||||
|
||||
const data =
|
||||
(await tryReadJson(preferredFile)) ??
|
||||
(await tryReadJson(preferredLegacy)) ??
|
||||
(legacyDirFile ? await tryReadJson(legacyDirFile) : null);
|
||||
|
||||
if (data) return { ok: true, data, source: "preferred" };
|
||||
return { ok: false, data: null, source: null };
|
||||
}
|
||||
|
||||
export async function writeMindmapLocal(
|
||||
docId: string,
|
||||
mindmapId: string,
|
||||
data: unknown,
|
||||
docTitle: string,
|
||||
) {
|
||||
const folder = path.join(preferredBaseDir, docId);
|
||||
const file = path.join(folder, resolveMindmapFileName(mindmapId));
|
||||
await ensureDir(folder);
|
||||
await ensureIndexFile(folder, docTitle || "无标题");
|
||||
await fs.writeFile(file, JSON.stringify(data ?? { data: { text: "中心主题" }, children: [] }, null, 2), "utf8");
|
||||
return { folder, file };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import "server-only";
|
||||
|
||||
export type NodeRef = {
|
||||
kind: "pdf" | "docx" | "pptx" | "url";
|
||||
assetId?: string;
|
||||
fileUrl?: string;
|
||||
page?: number; // 1-based
|
||||
slide?: number; // 1-based
|
||||
title?: string;
|
||||
snippet?: string;
|
||||
};
|
||||
|
||||
export type MindmapNodeData = {
|
||||
uid?: string;
|
||||
text?: string;
|
||||
hyperlink?: string;
|
||||
note?: string;
|
||||
refs?: NodeRef[];
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
export type MindmapTreeNode = {
|
||||
data: MindmapNodeData;
|
||||
children?: MindmapTreeNode[];
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
const createUid = () => {
|
||||
const anyCrypto = globalThis.crypto as unknown as { randomUUID?: () => string } | undefined;
|
||||
if (anyCrypto?.randomUUID) return anyCrypto.randomUUID();
|
||||
return Math.random().toString(36).slice(2);
|
||||
};
|
||||
|
||||
export const ensureMindmapUids = (root: MindmapTreeNode) => {
|
||||
const walk = (node: MindmapTreeNode) => {
|
||||
if (!node.data) node.data = {};
|
||||
if (!node.data.uid) node.data.uid = createUid();
|
||||
if (typeof node.data.text !== "string") node.data.text = String(node.data.text ?? "新节点");
|
||||
if (!Array.isArray(node.children)) node.children = [];
|
||||
node.children.forEach(walk);
|
||||
};
|
||||
walk(root);
|
||||
return root;
|
||||
};
|
||||
|
||||
type Indexed = {
|
||||
node: MindmapTreeNode;
|
||||
parent: MindmapTreeNode | null;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const buildIndex = (root: MindmapTreeNode) => {
|
||||
const map = new Map<string, Indexed>();
|
||||
const walk = (node: MindmapTreeNode, parent: MindmapTreeNode | null) => {
|
||||
const uid = String(node?.data?.uid || "");
|
||||
if (uid) map.set(uid, { node, parent, index: -1 });
|
||||
const children = Array.isArray(node.children) ? node.children : [];
|
||||
children.forEach((child, idx) => {
|
||||
const cuid = String(child?.data?.uid || "");
|
||||
if (cuid) map.set(cuid, { node: child, parent: node, index: idx });
|
||||
walk(child, node);
|
||||
});
|
||||
};
|
||||
walk(root, null);
|
||||
return map;
|
||||
};
|
||||
|
||||
const safeUrlOrNull = (value: unknown) => {
|
||||
const s = typeof value === "string" ? value.trim() : "";
|
||||
if (!s) return null;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
return u.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const applyMindmapOps = (
|
||||
raw: unknown,
|
||||
ops: MindmapOp[],
|
||||
): { data: MindmapTreeNode; applied: number; errors: string[] } => {
|
||||
const root = (raw && typeof raw === "object" ? (raw as MindmapTreeNode) : null) ?? {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
ensureMindmapUids(root);
|
||||
|
||||
const errors: string[] = [];
|
||||
let applied = 0;
|
||||
|
||||
for (const op of ops) {
|
||||
ensureMindmapUids(root);
|
||||
const index = buildIndex(root);
|
||||
|
||||
if (!op || typeof op !== "object" || !("op" in op)) {
|
||||
errors.push("无效 op");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.op === "addChild") {
|
||||
const parent = index.get(op.parentUid)?.node ?? null;
|
||||
if (!parent) {
|
||||
errors.push(`addChild: 找不到 parentUid=${op.parentUid}`);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(parent.children)) parent.children = [];
|
||||
const uid = op.node.uid || createUid();
|
||||
parent.children.push({
|
||||
data: {
|
||||
uid,
|
||||
text: String(op.node.text ?? "新节点"),
|
||||
...(op.node.note ? { note: String(op.node.note) } : {}),
|
||||
...(op.node.refs ? { refs: op.node.refs } : {}),
|
||||
...(op.node.hyperlink ? { hyperlink: safeUrlOrNull(op.node.hyperlink) ?? undefined } : {}),
|
||||
},
|
||||
children: [],
|
||||
});
|
||||
applied += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.op === "addSiblingAfter") {
|
||||
const hit = index.get(op.targetUid);
|
||||
if (!hit?.parent) {
|
||||
errors.push(`addSiblingAfter: 找不到 targetUid=${op.targetUid} 或无父节点(不能对根节点加同级)`);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(hit.parent.children)) hit.parent.children = [];
|
||||
const uid = op.node.uid || createUid();
|
||||
const insertAt = Math.max(0, Math.min(hit.parent.children.length, hit.index + 1));
|
||||
hit.parent.children.splice(insertAt, 0, {
|
||||
data: {
|
||||
uid,
|
||||
text: String(op.node.text ?? "新节点"),
|
||||
...(op.node.note ? { note: String(op.node.note) } : {}),
|
||||
...(op.node.refs ? { refs: op.node.refs } : {}),
|
||||
...(op.node.hyperlink ? { hyperlink: safeUrlOrNull(op.node.hyperlink) ?? undefined } : {}),
|
||||
},
|
||||
children: [],
|
||||
});
|
||||
applied += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.op === "updateText") {
|
||||
const hit = index.get(op.uid)?.node ?? null;
|
||||
if (!hit) {
|
||||
errors.push(`updateText: 找不到 uid=${op.uid}`);
|
||||
continue;
|
||||
}
|
||||
hit.data = hit.data || {};
|
||||
hit.data.text = String(op.text ?? "");
|
||||
applied += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.op === "setHyperlink") {
|
||||
const hit = index.get(op.uid)?.node ?? null;
|
||||
if (!hit) {
|
||||
errors.push(`setHyperlink: 找不到 uid=${op.uid}`);
|
||||
continue;
|
||||
}
|
||||
hit.data = hit.data || {};
|
||||
const url = safeUrlOrNull(op.hyperlink);
|
||||
if (!url) {
|
||||
delete (hit.data as any).hyperlink;
|
||||
} else {
|
||||
hit.data.hyperlink = url;
|
||||
}
|
||||
applied += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.op === "setRefs") {
|
||||
const hit = index.get(op.uid)?.node ?? null;
|
||||
if (!hit) {
|
||||
errors.push(`setRefs: 找不到 uid=${op.uid}`);
|
||||
continue;
|
||||
}
|
||||
hit.data = hit.data || {};
|
||||
hit.data.refs = Array.isArray(op.refs) ? op.refs : [];
|
||||
applied += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.op === "appendNote") {
|
||||
const hit = index.get(op.uid)?.node ?? null;
|
||||
if (!hit) {
|
||||
errors.push(`appendNote: 找不到 uid=${op.uid}`);
|
||||
continue;
|
||||
}
|
||||
hit.data = hit.data || {};
|
||||
const prev = typeof hit.data.note === "string" ? hit.data.note : "";
|
||||
const next = String(op.markdown ?? "");
|
||||
hit.data.note = prev ? `${prev}\n\n${next}` : next;
|
||||
applied += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.op === "deleteNode") {
|
||||
const hit = index.get(op.uid);
|
||||
if (!hit) {
|
||||
errors.push(`deleteNode: 找不到 uid=${op.uid}`);
|
||||
continue;
|
||||
}
|
||||
if (!hit.parent) {
|
||||
errors.push("deleteNode: 不能删除根节点");
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(hit.parent.children)) hit.parent.children = [];
|
||||
hit.parent.children = hit.parent.children.filter((c) => String(c?.data?.uid || "") !== op.uid);
|
||||
applied += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
errors.push(`不支持的 op: ${(op as any).op}`);
|
||||
}
|
||||
|
||||
ensureMindmapUids(root);
|
||||
return { data: root, applied, errors };
|
||||
};
|
||||
|
||||
@@ -7,14 +7,25 @@ import { buildDocumentTree } from "@/lib/documents";
|
||||
|
||||
type TypedClient = SupabaseClient<Database>;
|
||||
|
||||
export type SidebarTableRow = {
|
||||
id: string;
|
||||
workspace_id: string | null;
|
||||
document_id: string;
|
||||
title: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export interface SidebarDataset {
|
||||
documents: DocumentRecord[];
|
||||
trashedDocuments: TrashRecord[];
|
||||
trashedMediaAssets: MediaAsset[];
|
||||
/**
|
||||
* 仅依据远端 mindmap_data 判定的页面 id 列表;本地文件检测由服务器侧辅助完成
|
||||
*/
|
||||
mindmapDocs: string[];
|
||||
mediaAssets?: MediaAsset[];
|
||||
tables?: SidebarTableRow[];
|
||||
}
|
||||
|
||||
export async function fetchSidebarDataset(
|
||||
@@ -76,6 +87,7 @@ export async function fetchSidebarDataset(
|
||||
.from("media_assets")
|
||||
.select("*")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.is("deleted_at", null)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (assetError) {
|
||||
@@ -84,11 +96,43 @@ export async function fetchSidebarDataset(
|
||||
|
||||
const mediaAssets: MediaAsset[] = (assetRows ?? []) as MediaAsset[];
|
||||
|
||||
const { data: trashedAssetRows, error: trashedAssetError } = await client
|
||||
.from("media_assets")
|
||||
.select("*")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.not("deleted_at", "is", null)
|
||||
.is("purged_at", null)
|
||||
.order("deleted_at", { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
if (trashedAssetError) {
|
||||
throw new Error(`获取附件垃圾桶失败:${trashedAssetError.message}`);
|
||||
}
|
||||
|
||||
const trashedMediaAssets: MediaAsset[] = (trashedAssetRows ?? []) as MediaAsset[];
|
||||
|
||||
// 说明:当前 supabase types 可能未包含 document_tables;这里用 any 兜底,
|
||||
// 避免类型缺失阻塞侧边栏功能。
|
||||
const { data: tableRows, error: tableError } = await (client as any)
|
||||
.from("document_tables")
|
||||
.select("id,workspace_id,document_id,title,created_at,updated_at,is_archived")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("is_archived", false)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (tableError) {
|
||||
throw new Error(`获取在线表格列表失败:${tableError.message}`);
|
||||
}
|
||||
|
||||
const tables: SidebarTableRow[] = (tableRows ?? []) as unknown as SidebarTableRow[];
|
||||
|
||||
return {
|
||||
documents,
|
||||
trashedDocuments,
|
||||
trashedMediaAssets,
|
||||
mindmapDocs,
|
||||
mediaAssets,
|
||||
tables,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { ReferenceTarget } from "@/types/search";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export interface EditorReferenceBridgeResult {
|
||||
blockId: string | null;
|
||||
@@ -11,6 +12,11 @@ export interface EditorReferenceBridgeResult {
|
||||
export interface EditorReferenceBridge {
|
||||
insertInlineReference: (target: ReferenceTarget, alias?: string) => EditorReferenceBridgeResult;
|
||||
insertEmbedReference: (target: ReferenceTarget) => EditorReferenceBridgeResult;
|
||||
/**
|
||||
* 向当前打开的文档插入一个“附件/媒体”块。
|
||||
* 主要用于侧边栏文件树拖拽上传后,把文件显示到主编辑区。
|
||||
*/
|
||||
insertMediaAsset?: (asset: MediaAsset) => void;
|
||||
replaceWithSnapshot: (blocks: Json) => void;
|
||||
openTableFullScreen?: (tableId: string) => void;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface MediaAsset {
|
||||
ocr_strategy?: string | null;
|
||||
ocr_text: string | null;
|
||||
ocr_status: string | null;
|
||||
deleted_at?: string | null;
|
||||
deleted_by?: string | null;
|
||||
purged_at?: string | null;
|
||||
signed_url?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -297,6 +297,9 @@ export type Database = {
|
||||
ocr_strategy: string | null;
|
||||
ocr_text: string | null;
|
||||
ocr_status: string | null;
|
||||
deleted_at: string | null;
|
||||
deleted_by: string | null;
|
||||
purged_at: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -317,6 +320,9 @@ export type Database = {
|
||||
ocr_strategy?: string | null;
|
||||
ocr_text?: string | null;
|
||||
ocr_status?: string | null;
|
||||
deleted_at?: string | null;
|
||||
deleted_by?: string | null;
|
||||
purged_at?: string | null;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -337,6 +343,9 @@ export type Database = {
|
||||
ocr_strategy?: string | null;
|
||||
ocr_text?: string | null;
|
||||
ocr_status?: string | null;
|
||||
deleted_at?: string | null;
|
||||
deleted_by?: string | null;
|
||||
purged_at?: string | null;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -360,6 +369,12 @@ export type Database = {
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "media_assets_deleted_by_fkey";
|
||||
columns: ["deleted_by"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
user_recent_pages: {
|
||||
|
||||
Reference in New Issue
Block a user