0.1.14 上线前更改

This commit is contained in:
liaibo
2026-01-11 12:35:53 +08:00
parent be71849aa5
commit 725a60d3aa
44 changed files with 3427 additions and 310 deletions
+34
View File
@@ -0,0 +1,34 @@
# Repository Guidelines
## 项目结构与模块组织
- `wolai-frontend/`:主前端(Next.js),源码在 `wolai-frontend/src/`,静态资源在 `wolai-frontend/public/`
- `wolai-backend/`:后端(FastAPI + Celery),源码在 `wolai-backend/app/`
- `services/`:配套服务与集成点(如 `services/mineru/` OCR、RAG 相关服务等)。
- `supabase/`:本地 Supabase 迁移与配置(参考 `supabase.md` 的本地端口说明)。
- `pw-tests/`:端到端测试(Playwright + Python),脚本在 `pw-tests/scripts/`,产物在 `pw-tests/artifacts/`
- 根目录 `src/`:Next.js 相关代码与共享模块(如 `src/app/``src/components/` 等)。
## 构建、测试与本地开发命令
- 一键热启动(推荐):在仓库根目录执行 `npm run desktop:hot`(启动 `wolai-frontend` + `wolai-backend`Redis 可用时自动启动 Celery)。
- 前端:`cd wolai-frontend && pnpm dev|build|start`;质量检查:`pnpm lint`;单测:`pnpm test`
- 后端:`cd wolai-backend && pip install -r requirements.txt`;运行:`uvicorn app.main:app --reload --port 8000`Worker`celery -A app.workers.celery_app worker --loglevel=info`
- E2E`cd pw-tests/scripts && python e2e_mindmap_sync.py`(更多脚本见 `pw-tests/README.md`)。
## 编码风格与命名约定
- 文件编码:统一使用 UTF-8;缩进建议:TS/TSX 2 空格,Python 4 空格。
- 命名示例:组件 `PascalCase.tsx`hooks `useXxx.ts`,测试文件 `*.test.ts(x)`Vitest 配置包含 `src/**/*.test.ts(x)`)。
- 优先遵循现有 ESLint/Vitest 配置(见 `eslint.config.mjs``vitest.config.ts`)。
## 提交与 Pull Request 规范
- 提交信息在历史中常见两类:版本号摘要(如 `0.1.13 ...`)与类 Conventional Commits(如 `feat(mindmap): ...` / `fix(mindmap): ...` / `chore: ...`)。新增提交建议延续该风格。
- PR 需要:变更说明(动机/影响范围)、必要的 UI 截图、可复现步骤或测试结果、关联的 issue/任务链接。
## 安全与配置提示
- 不要提交密钥/Token/账号;本地配置优先放在 `.env.local``wolai-frontend/.env.local``wolai-backend/.env`
- 如发现敏感信息已进入仓库,请立即轮换密钥,并与维护者确认是否需要清理历史记录。
## Agent/自动化协作注意
- 仅修改与目标相关的文件;不要擅自回滚、覆盖或丢弃他人改动;代码注释使用简体中文并保持 UTF-8。
## 代码索引
- 更细的前后端代码层级索引见 `CODE_INDEX.md`(按路由/API/模块/职责整理,便于后续快速定位与开发)。
+95
View File
@@ -0,0 +1,95 @@
# 代码索引(前后端)
本文用于后续开发“快速定位入口/职责/调用链”,按目录与功能点整理。路径以仓库根目录为基准。
## 启动链路与关键入口
- 一键启动脚本:`scripts/desktop-hot.js`(启动 `wolai-frontend` + `wolai-backend`Redis 可用时启动 Celery)。
- 前端入口(Next App Router):
- 根布局:`wolai-frontend/src/app/layout.tsx`(创建 Supabase Server Client,注入 `SupabaseProvider`/`QueryProvider`)。
- 首页跳转:`wolai-frontend/src/app/page.tsx`(未登录跳 `/login`;确保默认工作区;跳转首个文档或创建“新页面”)。
- 后端入口(FastAPI):`wolai-backend/app/main.py`(挂载 `root_router``/api/v1``api_router`)。
## 前端(wolai-frontend
### 页面路由(App Router
- 登录页:`wolai-frontend/src/app/(auth)/login/page.tsx`
- 主应用布局:`wolai-frontend/src/app/(app)/layout.tsx`(组装侧边栏数据:workspaces、documents、trash、media、mindmap 资产等)。
- 文档页:`wolai-frontend/src/app/(app)/documents/[id]/page.tsx`(读取 documents 表的标题/选项/统计,渲染 `DocumentShell`
- 思维导图全屏页:`wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx`
- 在线表格页:`wolai-frontend/src/app/tables/[tableId]/view/page.tsx`
- OnlyOffice 页:`wolai-frontend/src/app/onlyoffice/page.tsx`
- 调试页:
- AI Agent`wolai-frontend/src/app/dev/ai-agent/page.tsx`
- Mindmap`wolai-frontend/src/app/dev/mindmap/page.tsx`
### APINext Route Handlers
这些接口基本承担“鉴权 + 读写 Supabase + 返回给前端”的 BFF 职责:
- 文档:`wolai-frontend/src/app/api/documents/*/route.ts`
- content/title/options/save/create/duplicate/move/delete/restore/purge/stats/copy-tree/...
- 思维导图:`wolai-frontend/src/app/api/mindmap/**/route.ts`
- `api/mindmap/[docId]/route.ts``api/mindmap/[docId]/[mindmapId]/route.ts``api/mindmap/[docId]/[mindmapId]/ops/route.ts`
- `api/mindmap/[id]/route.ts`(按 id 的路由分支)
- 思维导图 AI`wolai-frontend/src/app/api/mindmap-ai/*/route.ts`
- `agent` / `assets` / `expand-node` / `outline-to-mindmap` / `test-pdf`
- 媒体/附件:`wolai-frontend/src/app/api/media/*/route.ts`
- upload/sign/signed-url/assets/batch/ocr/empty-trash/purge
- AI Agent(统一入口):`wolai-frontend/src/app/api/ai-agent/run/route.ts`
- 负责:加载在线/本地模型配置、构建 tool registry、按 scope 过滤工具、SSE 流式返回。
- 后端探活转发:`wolai-frontend/src/app/api/backend/health/route.ts`
- 其它:`wolai-frontend/src/app/api/search/*``wolai-frontend/src/app/api/sidebar/route.ts``wolai-frontend/src/app/api/workspaces/switch/route.ts` 等。
### 编辑器与核心 UI
- 文档壳(客户端动态加载编辑器):`wolai-frontend/src/components/editor/document-shell.tsx`
- 文档内容(核心编辑/保存/选项/历史/AI 面板):`wolai-frontend/src/components/editor/document-content.tsx`
- BlockNote 编辑器:`wolai-frontend/src/components/editor/blocknote-editor.tsx`
- Block 级功能(重点在思维导图/媒体/表格):
- 思维导图 Block`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- 思维导图侧栏/工具栏:`wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx``wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx`
- AI Agent 面板:`wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx``wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx`
- 侧边栏(文件树/回收站/资源):`wolai-frontend/src/components/sidebar/sidebar.tsx``wolai-frontend/src/components/sidebar/file-tree.tsx`
### 状态与业务库(lib/store
- Supabase 客户端封装:`wolai-frontend/src/lib/supabase/{client.ts,server.ts,admin.ts}`
- 工作区与侧边栏数据:`wolai-frontend/src/lib/workspaces.ts``wolai-frontend/src/lib/sidebar-tree.ts`
- 文件树算法与单测:`wolai-frontend/src/lib/file-tree/*``*.test.ts` 是 Vitest 单测入口之一)
- 思维导图存储/操作:`wolai-frontend/src/lib/mindmap/*``wolai-frontend/src/lib/mindmap-files.ts`
- AI 配置:`wolai-frontend/src/lib/ai/*`
- AI Agent 引擎:
- 运行时:`wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts`
- 工具注册:`wolai-frontend/src/lib/ai-agent/tools/registry.ts`
- 内置工具集合:`wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts``tools/builtins/*/*Tools.ts`
- Zustand stores`wolai-frontend/src/store/*`(如 `editor-bridge.ts``sidebar.ts``ai-agent-ui.ts`
## 后端(wolai-backend
### FastAPI 路由与鉴权
- 路由聚合:`wolai-backend/app/routers/__init__.py`
- `root_router``/health` + `ws`Luckysheet
- `api_router`(前缀 `/api/v1`):tasks/chat
- 健康检查:`wolai-backend/app/routers/health.py`GET `/health`
- 任务(OCR):`wolai-backend/app/routers/tasks.py`
- POST `/api/v1/tasks/ocr`(鉴权:`AuthDep`,创建 `background_tasks` 记录并投递 Celery
- GET `/api/v1/tasks/{task_id}`(查询任务状态)
- SSE 占位对话:`wolai-backend/app/routers/chat.py`GET `/api/v1/chat?query=...&document_id=...`
- Luckysheet 协同 WS`wolai-backend/app/routers/luckysheet_ws.py``/ws/luckysheet`,校验 Supabase 用户与 workspace 成员关系)
- 鉴权依赖:`wolai-backend/app/deps.py`(通过 Supabase `/auth/v1/user` 验证 Bearer token
### Celery 与服务层
- Celery 配置:`wolai-backend/app/workers/celery_app.py`
- Celery 任务:`wolai-backend/app/workers/tasks.py`
- `ocr_pipeline`stage0 占位实现(更新 `background_tasks` 状态;回写 `documents.content/raw_text/index_status`
- Supabase REST 封装:`wolai-backend/app/services/supabase_rest.py`
- 任务追踪:`wolai-backend/app/services/task_tracker.py`(写入/读取 `background_tasks`
- 占位集成点:
- MinerU`wolai-backend/app/services/mineru_service.py`
- LightRAG`wolai-backend/app/services/lightrag_service.py`
- 配置:`wolai-backend/app/config.py``.env`UTF-8
## Supabase(本地与迁移)
- 本地端口与 URL 说明:`supabase.md`
- 迁移目录:`supabase/migrations/`
## 备注(避免踩坑)
- `wolai-frontend` 才是 `desktop-hot` 启动的实际前端;根目录的 `src/` 与其结构相似,可能是历史/镜像目录,改动前建议先确认是否仍在使用。
- 路由目录包含括号与中括号(如 `(app)``[id]`),在 PowerShell 中操作建议用 `-LiteralPath` 避免通配符误匹配。
-36
View File
@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+40 -13
View File
@@ -109,7 +109,9 @@
- Client Tools 先不做“由 AI 主动触发”,而是通过 attachments 提前把必要上下文注入(@selection@currentFile@mindmapSelection)。 - Client Tools 先不做“由 AI 主动触发”,而是通过 attachments 提前把必要上下文注入(@selection@currentFile@mindmapSelection)。
执行方式(v2 目标): 执行方式(v2 目标):
- 引入“前端工具宿主”:当服务端解析到 `<client_tool>` 时,通过 SSE 向前端发起请求,前端执行后再回传结果(类似 VSCode extension host / Cline host provider)。 - 引入“前端工具宿主”:当服务端需要执行 Client Tool 时,通过 SSE 向前端发起请求,前端执行后再回传结果(类似 VSCode extension host / Cline host provider)。
- 现状:已在 OnlyOffice 场景落地(SSE 事件 `client_tool_call` → 前端调用 OnlyOffice 插件 API → 回调 `/api/ai-agent/client-tool-result`)。
- 后续:把该机制抽象成通用 Client Tool Host,复用到 BlockNote/思维导图的“选区/选中节点”等能力。
--- ---
@@ -178,14 +180,18 @@
#### D. OnlyOffice(文档阅读/编辑) #### D. OnlyOffice(文档阅读/编辑)
v1 只做“可落地”的 v1(先做最基础的增/删/改/查 + 可落地的“文档驱动导图”)
- `asset_get`:读取文件树中的 asset 元信息(类型/URL/页数等 - `oo_get_selection`:读取当前选区(查
- `asset_extract_outline`:从 PDF/Word/PPT 提取大纲(复用现有 PDF 逻辑,Word/PPT 先降级 - `oo_replace_selection`:替换当前选区(改/删;删=传空字符串
- `asset_to_mindmap`:从 asset 生成 mindmap(返回 ops 或完整树,再由 `mindmap_apply_ops` 落盘 - `oo_insert_text` / `oo_insert_html`:插入内容(增
- `oo_insert_image`:插入图片(增)
- `asset_extract_outline`:附件(PDF 优先)→结构化大纲(含页码)
- `asset_to_mindmap`:附件大纲→思维导图落盘(含 refs/hyperlink
v2 再做: v2 再做:
- `onlyoffice_jump`:跳转到某页/某段(依赖 OnlyOffice API 能力) - `onlyoffice_jump`:跳转到某页/某段(依赖 OnlyOffice API 能力)
- `onlyoffice_insert_comment`:插入批注/引用锚点 - `onlyoffice_insert_comment`:插入批注/引用锚点
- `onlyoffice_forcesave`:强制保存/落盘(用于“编辑中→解析/索引→生成”闭环)
### 4.2 ToolSet(便于 UI 一键勾选) ### 4.2 ToolSet(便于 UI 一键勾选)
@@ -194,7 +200,8 @@ v2 再做:
- `toolset.readonly`search_web、rag_query、asset_get、mindmap_get(只读) - `toolset.readonly`search_web、rag_query、asset_get、mindmap_get(只读)
- `toolset.mindmap_write`mindmap_apply_ops、mindmap_expand_node - `toolset.mindmap_write`mindmap_apply_ops、mindmap_expand_node
- `toolset.doc_write`doc_insert_blocks、doc_replace_range - `toolset.doc_write`doc_insert_blocks、doc_replace_range
- `toolset.onlyoffice`asset_extract_outline、asset_to_mindmap、onlyoffice_jumpv2 - `toolset.onlyoffice_editor`oo_get_selection、oo_replace_selection、oo_insert_*(选区增删改查
- `toolset.onlyoffice_read`/`toolset.onlyoffice_write`asset_extract_outline / asset_to_mindmap(文档驱动导图)
--- ---
@@ -228,7 +235,7 @@ v2 再做:
## 6. 里程碑计划(慢慢实现,但每一步都可验收) ## 6. 里程碑计划(慢慢实现,但每一步都可验收)
### 完成进度(截至 2026-01-10 ### 完成进度(截至 2026-01-11
> 说明:本节用于记录“已经落地到代码”的进度,避免只停留在规划层。 > 说明:本节用于记录“已经落地到代码”的进度,避免只停留在规划层。
> ✅=已完成;🟡=部分完成/已打通但仍需扩展;⬜=未开始 > ✅=已完成;🟡=部分完成/已打通但仍需扩展;⬜=未开始
@@ -240,7 +247,7 @@ v2 再做:
| M0 | Tool RegistryTool/ToolSet | ✅ | `wolai-frontend/src/lib/ai-agent/tools/registry.ts` + `wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts` | | M0 | Tool RegistryTool/ToolSet | ✅ | `wolai-frontend/src/lib/ai-agent/tools/registry.ts` + `wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts` |
| M0 | Agent Runtimestep budget + 工具日志) | ✅ | `wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts` | | M0 | Agent Runtimestep budget + 工具日志) | ✅ | `wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts` |
| M0 | SSE 输出(assistant/tool_call/tool_result/completion | ✅ | `/api/ai-agent/run` 已支持 SSE | | M0 | SSE 输出(assistant/tool_call/tool_result/completion | ✅ | `/api/ai-agent/run` 已支持 SSE |
| M0 | 前端 AI 面板(可复用) | 🟡 | 目前已在 dev 面板 + 思维导图面板接入;BlockNote/OnlyOffice 仍需继续推进 | | M0 | 前端 AI 面板(可复用) | 🟡 | 已在 dev + 思维导图 + OnlyOffice 接入;BlockNote 仍需继续推进 |
| M1 | mindmap 读写工具(增量 ops + 细粒度工具) | ✅ | `wolai-frontend/src/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools.ts` | | M1 | mindmap 读写工具(增量 ops + 细粒度工具) | ✅ | `wolai-frontend/src/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools.ts` |
| M1 | `mindmap_expand_node`(检索→生成→落盘) | ✅ | 同上(支持可选 `search_web` 证据) | | M1 | `mindmap_expand_node`(检索→生成→落盘) | ✅ | 同上(支持可选 `search_web` 证据) |
| M1 | UI:选中节点上下文注入(给 AI) | ✅ | 思维导图面板通过 `context.selectedUids` 注入;并对用户显示纯文本节点内容 | | M1 | UI:选中节点上下文注入(给 AI) | ✅ | 思维导图面板通过 `context.selectedUids` 注入;并对用户显示纯文本节点内容 |
@@ -251,6 +258,14 @@ v2 再做:
| M2 | 跨页面文档工具(docs_* | ✅ | `docs_search` + `docs_read`(按 title/raw_text | | M2 | 跨页面文档工具(docs_* | ✅ | `docs_search` + `docs_read`(按 title/raw_text |
| M2 | 图片读取工具(OCR) | ✅ | `image_read`(读取 `media_assets.ocr_text` | | M2 | 图片读取工具(OCR) | ✅ | `image_read`(读取 `media_assets.ocr_text` |
| M2 | 斜杠命令工具 | ✅ | `slash_run`/new 创建文档、/rename 重命名) | | M2 | 斜杠命令工具 | ✅ | `slash_run`/new 创建文档、/rename 重命名) |
| M2 | UICline 风格工具栏 + 侧边栏内切页 | ✅ | 文档/思维导图 AI 面板统一顶部工具栏(工具/历史/账户/设置),不再使用居中弹窗 |
| M2 | UI:工具日志可折叠 | ✅ | tool_call/tool_result 支持折叠展开,便于长日志查看 |
| M3 | OnlyOffice 作用域(scope=onlyoffice+ ToolSet | ✅ | `/api/ai-agent/run/route.ts` 支持 onlyoffice scope,并允许 `toolset.onlyoffice_*`(含 `toolset.onlyoffice_editor` |
| M3 | OnlyOffice 客户端工具桥接(SSE↔回调) | ✅ | `wolai-frontend/src/lib/ai-agent/runtime/clientToolBridge.ts` + `/api/ai-agent/client-tool-result` + SSE `client_tool_call` |
| M3 | oo_* 工具(选区增/删/改/查) | ✅ | `registryBuiltins.ts` 注册 + OnlyOffice 插件 `wolai-frontend/public/onlyoffice/plugins/agent-tools/*` |
| M3 | OnlyOffice AI 面板接入(浮层) | ✅ | `wolai-frontend/src/app/onlyoffice/page.tsx` + `wolai-frontend/src/components/onlyoffice/OnlyOfficeAiAgentPanel.tsx` |
| M3 | `asset_extract_outline`PDF→大纲,含页码) | 🟡 | `wolai-frontend/src/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools.ts`MinerU content_list |
| M3 | `asset_to_mindmap`(附件→导图落盘) | 🟡 | 同上(把大纲转成 mindmap ops + refs + hyperlink |
### M0(基础设施):统一 Agent Runtime + 工具协议 + 日志 ### M0(基础设施):统一 Agent Runtime + 工具协议 + 日志
@@ -281,18 +296,30 @@ v2 再做:
验收(pw-tests): 验收(pw-tests):
- 选中一段文本,让 AI “改写成更简洁版本并保留要点”,结果直接落入文档(不是生成一段文本)。 - 选中一段文本,让 AI “改写成更简洁版本并保留要点”,结果直接落入文档(不是生成一段文本)。
### M3OnlyOffice):文档驱动导图 + 可跳转引用 ### M3OnlyOffice):插件端实时改文档 + 文档驱动导图
- [ ] `asset_extract_outline`PDF 优先,Word/PPT 先降级(至少按标题层级 - [x] OnlyOffice 插件注入(autostart + pluginsData + CORS
- [ ] `asset_to_mindmap`:从文档大纲生成 mindmap(章→节→要点) - [x] oo_* 工具(选区增/删/改/查):`oo_get_selection` / `oo_replace_selection` / `oo_insert_*`
- [ ] 引用:节点 refspage/slide+ hyperlink`#page=` - [x] 前端工具宿主(OnlyOffice 版):SSE `client_tool_call` ↔ 回调 `/api/ai-agent/client-tool-result`
- [x] `asset_extract_outline`PDF 优先(基于 MinerU 的 content_list 提取 text_level + page_idx
- [x] `asset_to_mindmap`:从文档大纲生成 mindmap(章→节→要点)并落盘
- [ ] 引用:节点 refspage/slide+ hyperlink`#page=`)在 OnlyOffice/PDF 预览器中可稳定跳转(仍需实测与兼容)
补充说明(OnlyOffice “能否穿透拿到信息”):
1) **常规集成(不做插件)**OnlyOffice Docs 集成侧主要通过 `editorConfig.callbackUrl` 回传保存/状态;外部并不会天然得到“当前文档全文/选区文本”。
2) **要拿到编辑器内信息**:需要(A)保证文档已落盘(callback + 保存/forcesave)后从存储侧读取,或(B)开发 OnlyOffice 插件/宏(在编辑器内执行 Office API,再把结果回传到宿主)。
3) **M3 v1 策略(双通道)**
- **实时编辑(Word/PPT/Excel 的基础增删改查)**:走 OnlyOffice 插件(编辑器内执行)→ 通过前端工具宿主把结果回传给服务端 Agent。
- **结构化理解/导图生成**:走“附件/存储→MinerU 结构化解析→导图写入”,不依赖编辑器能直接吐出全文/层级信息。
- 后续再补:`onlyoffice_forcesave`(保证拿到最新落盘版本)+ 更强的“书签/页码/定位”跳转能力。
验收(pw-tests): 验收(pw-tests):
- 对指定 PDF 生成“章→节→内容”层级导图,点击节点跳到对应页(至少 URL 含 `#page=`)。 - 对指定 PDF 生成“章→节→内容”层级导图,点击节点跳到对应页(至少 URL 含 `#page=`)。
### M4(v2):前端工具宿主 + MCP 工具 + 类“技能”系统 ### M4(v2):前端工具宿主 + MCP 工具 + 类“技能”系统
- [ ] 前端工具宿主:允许 AI 请求 client tool(读取当前选区、OnlyOffice 当前页) - [ ] 前端工具宿主(通用版):允许 AI 请求 client tool(读取 BlockNote 选区、思维导图当前选中节点、OnlyOffice 当前页
- [ ] MCP:把 supabase_local / searxng / 未来自定义 MCP 作为工具源(像 cline) - [ ] MCP:把 supabase_local / searxng / 未来自定义 MCP 作为工具源(像 cline)
- [ ] skills:把“工作流说明”做成可加载的技能文件,AI 可按需激活(类似你现在的 Codex skills - [ ] skills:把“工作流说明”做成可加载的技能文件,AI 可按需激活(类似你现在的 Codex skills
+3
View File
@@ -7,6 +7,7 @@
- `app.aichem.dpdns.org``wolai-frontend`Next 3000:页面 + /api/* - `app.aichem.dpdns.org``wolai-frontend`Next 3000:页面 + /api/*
- `supabase.aichem.dpdns.org``Supabase Kong`(示例 18000 - `supabase.aichem.dpdns.org``Supabase Kong`(示例 18000
- `backend.aichem.dpdns.org``wolai-backend`(示例 8000,可选) - `backend.aichem.dpdns.org``wolai-backend`(示例 8000,可选)
- `onlyoffice.aichem.dpdns.org``OnlyOffice Document Server`(示例 8081
- `lightrag.aichem.dpdns.org``LightRAG`(示例 7777,建议加 Access - `lightrag.aichem.dpdns.org``LightRAG`(示例 7777,建议加 Access
- `mineru.aichem.dpdns.org``MinerU`(示例 18888,建议加 Access - `mineru.aichem.dpdns.org``MinerU`(示例 18888,建议加 Access
@@ -35,6 +36,8 @@
- `NEXT_PUBLIC_SUPABASE_URL=https://supabase.aichem.dpdns.org` - `NEXT_PUBLIC_SUPABASE_URL=https://supabase.aichem.dpdns.org`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon key>` - `NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon key>`
- `NEXT_PUBLIC_BACKEND_URL=https://backend.aichem.dpdns.org`(可选) - `NEXT_PUBLIC_BACKEND_URL=https://backend.aichem.dpdns.org`(可选)
- `NEXT_PUBLIC_ONLYOFFICE_BASE_URL=https://onlyoffice.aichem.dpdns.org`OnlyOffice
- `NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE=supabase.aichem.dpdns.org`(可选:仅当你的 signedUrl 里仍出现 127.0.0.1/host.docker.internal 时用)
服务端内网(仅 Next 服务器用,不暴露给浏览器): 服务端内网(仅 Next 服务器用,不暴露给浏览器):
- `SUPABASE_INTERNAL_URL=http://127.0.0.1:18000` - `SUPABASE_INTERNAL_URL=http://127.0.0.1:18000`
+1
View File
@@ -1,5 +1,6 @@
{ {
"scripts": { "scripts": {
"desktop": "node scripts/desktop-prod.js",
"desktop:hot": "node scripts/desktop-hot.js" "desktop:hot": "node scripts/desktop-hot.js"
}, },
"dependencies": { "dependencies": {
+4
View File
@@ -34,5 +34,9 @@ ingress:
- hostname: mineru.aichem.dpdns.org - hostname: mineru.aichem.dpdns.org
service: http://127.0.0.1:18888 service: http://127.0.0.1:18888
# 6) OnlyOffice Document Serverdocker: onlyoffice-dev 暴露到 8081
- hostname: onlyoffice.aichem.dpdns.org
service: http://127.0.0.1:8081
# 未匹配的请求直接 404 # 未匹配的请求直接 404
- service: http_status:404 - service: http_status:404
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env node
/**
* 一键启动“生产模式”的前后端与相关服务(适用于 Cloudflare Tunnel / 公网访问)。
*
* 目标:
* - 前端使用 Next production(读取 wolai-frontend/.env.production.local
* - 后端与内部服务使用本机/内网地址(读取各自 .env 或仓库根目录 .env.local
*
* 可选环境变量:
* - SKIP_FRONTEND_BUILD=1:跳过前端 build(仅 start
* - FRONTEND_PORT:默认 3000
* - BACKEND_PORT:默认 8000
* - RAG_GATEWAY_PORT:默认 8778
* - INGEST_PORT:默认 8779
* - PYTHON_BIN:默认 python
* - CELERY_BIN:默认 celery
* - SKIP_CELERY=1:跳过 Celery worker
* - REDIS_URL:用于探测 Redis,默认 redis://127.0.0.1:6379/0
*/
const { spawn } = require("child_process");
const fs = require("fs");
const net = require("net");
const path = require("path");
const { URL } = require("url");
const rootDir = path.resolve(__dirname, "..");
const frontendDir = path.join(rootDir, "wolai-frontend");
const backendDir = path.join(rootDir, "wolai-backend");
const ingestDir = path.join(rootDir, "services", "ingest_service");
const ragGatewayDir = path.join(rootDir, "services", "rag_gateway");
const pythonBin = process.env.PYTHON_BIN || "python";
const celeryBin = process.env.CELERY_BIN || "celery";
const skipCelery =
(process.env.SKIP_CELERY || "").toLowerCase() === "1" ||
(process.env.SKIP_CELERY || "").toLowerCase() === "true";
const redisUrl = process.env.REDIS_URL || "redis://127.0.0.1:6379/0";
const frontendPort = Number(process.env.FRONTEND_PORT || 3000);
const backendPort = Number(process.env.BACKEND_PORT || 8000);
const ragGatewayPort = Number(process.env.RAG_GATEWAY_PORT || 8778);
const ingestPort = Number(process.env.INGEST_PORT || 8779);
const children = [];
let shuttingDown = false;
function logPrefix(name, message) {
console.log(`[${name}] ${message}`);
}
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, "utf8");
return content
.split(/\r?\n/)
.filter((line) => line.trim() && !line.trim().startsWith("#"))
.reduce((acc, line) => {
const idx = line.indexOf("=");
if (idx === -1) return acc;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
acc[key] = value;
return acc;
}, {});
}
async function waitForExit(child, name) {
return await new Promise((resolve) => {
child.on("exit", (code, signal) => {
const status = signal ? `信号 ${signal}` : `退出码 ${code ?? "null"}`;
logPrefix(name, `进程结束(${status}`);
resolve(code ?? 0);
});
child.on("error", () => resolve(1));
});
}
function spawnTask({ name, command, cwd, env }) {
logPrefix(name, `启动命令:${command}`);
const child = spawn(command, {
cwd,
stdio: "inherit",
shell: true,
env,
});
child.on("exit", (code, signal) => {
if (shuttingDown) return;
const status = signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`;
logPrefix(name, `进程结束(${status}),准备清理其它任务。`);
shutdown(code ?? 0);
});
child.on("error", (err) => {
logPrefix(name, `启动失败:${err.message}`);
shutdown(1);
});
children.push(child);
return child;
}
function shutdown(code) {
if (shuttingDown) return;
shuttingDown = true;
logPrefix("system", "收到终止信号,正在关闭所有子进程…");
for (const child of children) {
if (!child.killed) {
child.kill("SIGINT");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGTERM");
}
}, 5000);
}
}
setTimeout(() => process.exit(code), 200);
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
async function checkPortOpen(host, port, timeoutMs = 1200) {
return await new Promise((resolve) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, timeoutMs);
socket.once("connect", () => {
clearTimeout(timer);
socket.end();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
}
async function checkRedisReachable(urlString, timeoutMs = 2000) {
try {
const url = new URL(urlString);
const host = url.hostname || "127.0.0.1";
const port = Number(url.port) || 6379;
return await checkPortOpen(host, port, timeoutMs);
} catch (error) {
logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`);
return true;
}
}
function requireFile(filePath, hint) {
if (!fs.existsSync(filePath)) {
console.error(`[system] 缺少文件:${filePath}`);
if (hint) console.error(`[system] 提示:${hint}`);
process.exit(1);
}
}
async function main() {
const frontendProdEnv = path.join(frontendDir, ".env.production.local");
// 说明:Next.js 在 production 也会加载 wolai-frontend/.env.local。
// 为避免被本地开发环境(可能指向远端 Supabase)的配置污染,这里把“服务端专用”的关键 env 显式注入。
// 规则:优先使用 wolai-frontend/.env.production.local,其次使用仓库根目录 .env.local/.env。
const envRoot = {
...loadEnvFile(path.join(rootDir, ".env.local")),
...loadEnvFile(path.join(rootDir, ".env")),
};
requireFile(
frontendProdEnv,
"请先创建 wolai-frontend/.env.production.local(可参考 wolai-frontend/.env.production.example",
);
// 生产模式:前端必须使用 production env,避免把 .env.local 的 127.0.0.1 泄露到公网用户
const envFrontend = {
...process.env,
...loadEnvFile(frontendProdEnv),
NODE_ENV: "production",
NEXT_TELEMETRY_DISABLED: "1",
};
// Next 服务端(API Route)需要 service role 才能生成签名 URL、清理资源等。
if (envRoot.SUPABASE_SERVICE_ROLE_KEY) {
envFrontend.SUPABASE_SERVICE_ROLE_KEY = envRoot.SUPABASE_SERVICE_ROLE_KEY;
}
// 兼容旧代码:部分模块读取 SUPABASE_URL(而非 SUPABASE_INTERNAL_URL)。
// 强制覆盖:避免从系统环境变量或 wolai-frontend/.env.local 继承到“远端 Supabase”。
envFrontend.SUPABASE_URL = envFrontend.SUPABASE_INTERNAL_URL || envRoot.SUPABASE_URL || envFrontend.SUPABASE_URL || "";
// 后端/内部服务:尽量使用本机/内网配置(不会暴露给浏览器)
// wolai-backend 使用自身目录下 .env
const envBackend = {
...process.env,
...loadEnvFile(path.join(backendDir, ".env")),
PYTHONUTF8: "1",
};
// ingest_service / rag_gateway 主要依赖仓库根目录 .env.local/.env
const envInternalServices = {
...process.env,
...loadEnvFile(path.join(rootDir, ".env.local")),
...loadEnvFile(path.join(rootDir, ".env")),
PYTHONUTF8: "1",
};
const shouldSkipBuild =
(process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "1" ||
(process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "true";
if (!shouldSkipBuild) {
logPrefix("frontend", "开始 production build…");
const build = spawn("pnpm -C wolai-frontend build", {
cwd: rootDir,
stdio: "inherit",
shell: true,
env: envFrontend,
});
const code = await waitForExit(build, "frontend");
if (code !== 0) {
process.exit(code);
}
} else {
logPrefix("frontend", "已跳过 buildSKIP_FRONTEND_BUILD=1");
}
// 预检查:提醒必要端口是否已就绪(不强制退出,避免误伤已有进程)
const portChecks = [
{ name: "frontend", port: frontendPort },
{ name: "backend", port: backendPort },
{ name: "rag_gateway", port: ragGatewayPort },
{ name: "ingest_service", port: ingestPort },
{ name: "supabase(kong)", port: 18000 },
];
for (const item of portChecks) {
// 仅检查本机 127.0.0.1 是否已有服务在监听,方便你快速判断“是否重复启动”
// 监听着不代表冲突:可能是你已经手动启动了。
// eslint-disable-next-line no-await-in-loop
const open = await checkPortOpen("127.0.0.1", item.port);
if (open) {
logPrefix("system", `检测到端口 ${item.port} 已有服务监听(${item.name}),若你准备全量启动,请确认不会端口冲突。`);
}
}
// 启动前端(production
spawnTask({
name: "frontend",
// Windows 下 pnpm 对 `start -- ...` 的参数转发偶发不稳定;用 `pnpm exec next start` 更可靠。
command: `pnpm -C wolai-frontend exec next start -p ${frontendPort} -H 0.0.0.0`,
cwd: rootDir,
env: envFrontend,
});
// 启动 wolai-backendFastAPI
spawnTask({
name: "backend",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${backendPort}`,
cwd: backendDir,
env: envBackend,
});
// Celery(可选)
if (!skipCelery) {
const ok = await checkRedisReachable(envBackend.REDIS_URL || redisUrl);
if (ok) {
spawnTask({
name: "celery",
command: `${celeryBin} -A app.workers.celery_app worker --loglevel=info`,
cwd: backendDir,
env: envBackend,
});
} else {
logPrefix("celery", `检测到 Redis 不可达(${envBackend.REDIS_URL || redisUrl}),跳过 Celery。`);
}
} else {
logPrefix("celery", "已跳过 CelerySKIP_CELERY=1");
}
// 启动 ingest_service(自动入库、OCR 触发、延迟删除清理)
spawnTask({
name: "ingest",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ingestPort}`,
cwd: ingestDir,
env: envInternalServices,
});
// 启动 rag_gateway/rag 查询网关)
spawnTask({
name: "rag_gateway",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ragGatewayPort}`,
cwd: ragGatewayDir,
env: envInternalServices,
});
logPrefix(
"system",
`已启动:frontend:${frontendPort} backend:${backendPort} ingest:${ingestPort} rag_gateway:${ragGatewayPort}(如需公网访问,请确保 cloudflared tunnel 正在运行)`,
);
}
main().catch((error) => {
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
-36
View File
@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+14
View File
@@ -5,6 +5,20 @@ const nextConfig: NextConfig = {
// 避免 monorepo/多 lockfile 场景下 root 误判,减少构建与热更新的不确定性 // 避免 monorepo/多 lockfile 场景下 root 误判,减少构建与热更新的不确定性
root: __dirname, root: __dirname,
}, },
async headers() {
// 说明:ONLYOFFICE 文档服务器会在其 iframe 内跨域拉取插件 manifest/js/html。
// 这里对插件资源放开 CORS,避免 pluginsData 加载失败。
return [
{
source: "/onlyoffice/plugins/:path*",
headers: [
{ key: "Access-Control-Allow-Origin", value: "*" },
{ key: "Access-Control-Allow-Methods", value: "GET,OPTIONS" },
{ key: "Access-Control-Allow-Headers", value: "*" },
],
},
];
},
}; };
export default nextConfig; export default nextConfig;
@@ -0,0 +1,17 @@
{
"name": "MNOTE AI Bridge",
"guid": "asc.{F2B9A7B4-3A22-4E0E-8B32-3B0DE7AE8A60}",
"version": "1.0.0",
"baseUrl": "",
"variations": [
{
"description": "MNOTE AI 工具桥接(选区读写)",
"url": "index.html",
"icons": ["icon.svg"],
"isViewer": true,
"EditorsSupport": ["word", "cell", "slide", "pdf"],
"isVisual": false
}
]
}
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<path d="M16 14h32a4 4 0 0 1 4 4v28a4 4 0 0 1-4 4H16a4 4 0 0 1-4-4V18a4 4 0 0 1 4-4Z" stroke="currentColor" stroke-width="4"/>
<path d="M22 28h20M22 36h14" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 305 B

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MNOTE OnlyOffice Agent Tools</title>
</head>
<body>
<script src="plugin.js"></script>
</body>
</html>
@@ -0,0 +1,100 @@
// 说明:该插件用于把 ONLYOFFICE 编辑器“选区读/写”能力暴露给宿主页面(MNOTE)。
// 宿主页面通过 postMessage 下发 oo_* 工具调用;插件执行后再 postMessage 回传结果。
(function () {
const CHANNEL = "mnote_onlyoffice_agent_tools_v1";
const safePostToTop = (payload) => {
try {
if (typeof window === "undefined") return;
if (!window.top) return;
window.top.postMessage({ channel: CHANNEL, ...payload }, "*");
} catch {
// ignore
}
};
const execMethod = (method, args) =>
new Promise((resolve, reject) => {
try {
if (!window.Asc || !window.Asc.plugin) throw new Error("ONLYOFFICE 插件 API 未就绪");
window.Asc.plugin.executeMethod(method, Array.isArray(args) ? args : [], (res) => resolve(res));
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
});
const handleTool = async (tool, args) => {
const a = args && typeof args === "object" ? args : {};
if (tool === "oo_get_selection") {
// 说明:先做最基础的“纯文本选区读取”,HTML 可在后续升级(需要更复杂的导出方式)。
const text = await execMethod("GetSelectedText", []);
return { format: "text", text: String(text ?? "") };
}
if (tool === "oo_replace_selection") {
const format = String(a.format ?? "text");
const text = String(a.text ?? "");
if (format === "html") {
await execMethod("PasteHtml", [text]);
return { ok: true, format: "html", length: text.length };
}
await execMethod("PasteText", [text]);
return { ok: true, format: "text", length: text.length };
}
if (tool === "oo_insert_text") {
const text = String(a.text ?? "");
await execMethod("PasteText", [text]);
return { ok: true, length: text.length };
}
if (tool === "oo_insert_html") {
const html = String(a.html ?? "");
await execMethod("PasteHtml", [html]);
return { ok: true, length: html.length };
}
if (tool === "oo_insert_image") {
// 说明:优先用 HTML 插入 <img>,避免依赖 PutImageDataToSelection 的参数差异。
const src = String(a.src ?? a.imageData ?? "");
if (!src) throw new Error("缺少图片数据(src/imageData");
const w = Number(a.width ?? 0);
const h = Number(a.height ?? 0);
const widthAttr = Number.isFinite(w) && w > 0 ? ` width=\"${Math.floor(w)}\"` : "";
const heightAttr = Number.isFinite(h) && h > 0 ? ` height=\"${Math.floor(h)}\"` : "";
const html = `<img src=\"${src}\"${widthAttr}${heightAttr} />`;
await execMethod("PasteHtml", [html]);
return { ok: true };
}
throw new Error(`未知工具:${tool}`);
};
window.Asc = window.Asc || {};
window.Asc.plugin = window.Asc.plugin || {};
window.Asc.plugin.init = function () {
safePostToTop({ type: "ready" });
};
window.addEventListener("message", async (ev) => {
const msg = ev && ev.data ? ev.data : null;
if (!msg || typeof msg !== "object") return;
if (msg.channel !== CHANNEL) return;
if (msg.type !== "call") return;
const callId = String(msg.callId ?? "").trim();
const tool = String(msg.tool ?? "").trim();
const args = msg.args && typeof msg.args === "object" ? msg.args : {};
if (!callId || !tool) return;
try {
const result = await handleTool(tool, args);
safePostToTop({ type: "result", callId, ok: true, result });
} catch (e) {
const err = e instanceof Error ? e.message : String(e);
safePostToTop({ type: "result", callId, ok: false, error: err });
}
});
})();
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import {
buildClientToolKey,
resolveClientToolCall,
type ClientToolResult,
} from "@/lib/ai-agent/runtime/clientToolBridge";
export const dynamic = "force-dynamic";
type Payload = {
requestId: string;
callId: string;
ok: boolean;
result?: unknown;
error?: string;
};
export async function POST(request: Request) {
const payload = (await request.json().catch(() => null)) as Payload | null;
if (!payload) return NextResponse.json({ error: "缺少请求体" }, { status: 400 });
const requestId = String(payload.requestId ?? "").trim();
const callId = String(payload.callId ?? "").trim();
if (!requestId || !callId) {
return NextResponse.json({ error: "缺少 requestId/callId" }, { status: 400 });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 });
const result: ClientToolResult = payload.ok
? { ok: true, result: "result" in payload ? payload.result : null }
: { ok: false, error: String(payload.error ?? "客户端工具执行失败") };
const key = buildClientToolKey(requestId, callId);
const resolved = resolveClientToolCall({
key,
userId: session.user.id,
result,
});
if (!resolved.ok) {
return NextResponse.json({ error: resolved.error }, { status: 404 });
}
return NextResponse.json({ ok: true });
}
@@ -12,11 +12,13 @@ import { createRagServerTools } from "@/lib/ai-agent/tools/builtins/rag/lightrag
import { createDocsServerTools, type DocsSupabaseClient } from "@/lib/ai-agent/tools/builtins/docs/docsServerTools"; import { createDocsServerTools, type DocsSupabaseClient } from "@/lib/ai-agent/tools/builtins/docs/docsServerTools";
import { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent/tools/builtins/media/mediaServerTools"; import { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent/tools/builtins/media/mediaServerTools";
import { createSlashServerTools, type SlashSupabaseClient } from "@/lib/ai-agent/tools/builtins/slash/slashServerTools"; import { createSlashServerTools, type SlashSupabaseClient } from "@/lib/ai-agent/tools/builtins/slash/slashServerTools";
import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools";
import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type AgentMessage = { role: "user" | "assistant"; content: string }; type AgentMessage = { role: "user" | "assistant"; content: string };
type AgentScope = "global" | "mindmap" | "document"; type AgentScope = "global" | "mindmap" | "document" | "onlyoffice";
type RequestPayload = { type RequestPayload = {
stream?: boolean; stream?: boolean;
@@ -36,6 +38,19 @@ type RequestPayload = {
}; };
const DEFAULT_MAX_STEPS = 10; const DEFAULT_MAX_STEPS = 10;
const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;
const makeRunId = () => {
try {
const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto;
if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
} catch {
// ignore
}
return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
};
const isOnlyOfficeClientTool = (toolId: string) => toolId.startsWith("oo_");
const sseHeaders = { const sseHeaders = {
"Content-Type": "text/event-stream; charset=utf-8", "Content-Type": "text/event-stream; charset=utf-8",
@@ -90,7 +105,7 @@ export async function POST(request: Request) {
const mindmapId = String(payload.context?.mindmapId ?? "").trim(); const mindmapId = String(payload.context?.mindmapId ?? "").trim();
const scope: AgentScope = (() => { const scope: AgentScope = (() => {
const raw = String(payload.scope ?? "").trim(); const raw = String(payload.scope ?? "").trim();
if (raw === "global" || raw === "mindmap" || raw === "document") return raw; if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") return raw;
// 兜底:有 mindmapId 则认为在 mindmap 场景,否则视为全局场景 // 兜底:有 mindmapId 则认为在 mindmap 场景,否则视为全局场景
return mindmapId ? "mindmap" : "global"; return mindmapId ? "mindmap" : "global";
})(); })();
@@ -101,7 +116,9 @@ export async function POST(request: Request) {
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.mindmap_read", "toolset.mindmap_write"] ? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.mindmap_read", "toolset.mindmap_write"]
: scope === "document" : scope === "document"
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.doc_read", "toolset.doc_write", "toolset.slash_write"] ? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.doc_read", "toolset.doc_write", "toolset.slash_write"]
: ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.slash_write"]; : scope === "onlyoffice"
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.onlyoffice_read", "toolset.onlyoffice_write", "toolset.onlyoffice_editor"]
: ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.slash_write"];
const allowlist = new Set<string>(); const allowlist = new Set<string>();
for (const sid of allowToolSetIds) { for (const sid of allowToolSetIds) {
const s = registry.toolSetsById.get(sid); const s = registry.toolSetsById.get(sid);
@@ -210,6 +227,15 @@ export async function POST(request: Request) {
}) })
: null; : null;
const onlyofficeTools =
allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap")
? createOnlyOfficeServerTools({
supabase: supabase as unknown as OnlyOfficeSupabaseClient,
ctx: { userId: session.user.id, documentId: documentId || undefined, attachments },
allowedToolIds,
})
: null;
const slashTools = allowedToolIds.has("slash_run") const slashTools = allowedToolIds.has("slash_run")
? createSlashServerTools({ ? createSlashServerTools({
supabase: supabase as unknown as SlashSupabaseClient, supabase: supabase as unknown as SlashSupabaseClient,
@@ -219,6 +245,9 @@ export async function POST(request: Request) {
: null; : null;
const runTool = async (toolId: string, toolArgs: Record<string, unknown>) => { const runTool = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (isOnlyOfficeClientTool(toolId)) {
throw new Error("oo_* 属于客户端工具:必须使用 stream 模式并在 OnlyOffice 页面内执行");
}
if (toolId === "search_web") { if (toolId === "search_web") {
const query = String(toolArgs.query ?? "").trim(); const query = String(toolArgs.query ?? "").trim();
const count = Number(toolArgs.count ?? 6); const count = Number(toolArgs.count ?? 6);
@@ -236,6 +265,10 @@ export async function POST(request: Request) {
if (!mediaTools) throw new Error(`工具未初始化:${toolId}`); if (!mediaTools) throw new Error(`工具未初始化:${toolId}`);
return await mediaTools.run(toolId, toolArgs); return await mediaTools.run(toolId, toolArgs);
} }
if (toolId.startsWith("asset_")) {
if (!onlyofficeTools) throw new Error(`工具未初始化:${toolId}`);
return await onlyofficeTools.run(toolId, toolArgs);
}
if (toolId === "slash_run") { if (toolId === "slash_run") {
if (!slashTools) throw new Error(`工具未初始化:${toolId}`); if (!slashTools) throw new Error(`工具未初始化:${toolId}`);
return await slashTools.run(toolId, toolArgs); return await slashTools.run(toolId, toolArgs);
@@ -281,7 +314,30 @@ export async function POST(request: Request) {
}; };
// 先发一个 ready,方便前端快速进入“流式模式” // 先发一个 ready,方便前端快速进入“流式模式”
send("ready", { ok: true }); const requestId = makeRunId();
send("ready", { ok: true, requestId });
let lastToolCall: { id: string; tool: string; args: Record<string, unknown> } | null = null;
const runToolStream = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (isOnlyOfficeClientTool(toolId)) {
const callId = lastToolCall?.tool === toolId ? lastToolCall.id : `call_${Date.now()}`;
const key = buildClientToolKey(requestId, callId);
const wait = registerClientToolCall({
key,
userId: session.user.id,
timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
});
// 说明:客户端收到该事件后,需要执行插件 API 并回调 /api/ai-agent/client-tool-result
send("client_tool_call", { requestId, callId, tool: toolId, args: toolArgs });
const result = await wait;
if (!result.ok) throw new Error(result.error);
return result.result;
}
return await runTool(toolId, toolArgs);
};
const ping = setInterval(() => { const ping = setInterval(() => {
// 避免某些代理/浏览器长连接超时 // 避免某些代理/浏览器长连接超时
@@ -297,12 +353,30 @@ export async function POST(request: Request) {
userMessages: payload.messages.slice(0, 50), userMessages: payload.messages.slice(0, 50),
cfg: { ...cfg, model: modelOverride ?? cfg.model }, cfg: { ...cfg, model: modelOverride ?? cfg.model },
allowedToolIds, allowedToolIds,
runTool, runTool: runToolStream,
maxSteps, maxSteps,
systemContextText, systemContextText,
defaultMindmapTargetUid: selectedUids[0] ?? "", defaultMindmapTargetUid: selectedUids[0] ?? "",
onEvent: (ev) => { onEvent: (ev) => {
if (!ev?.type) return; if (!ev?.type) return;
if (ev.type === "tool_call") {
try {
const d = ev.data as unknown;
const obj =
typeof d === "object" && d
? (d as Record<string, unknown>)
: ({} as Record<string, unknown>);
const id = String(obj.id ?? "").trim();
const tool = String(obj.tool ?? "").trim();
const args =
typeof obj.args === "object" && obj.args
? (obj.args as Record<string, unknown>)
: ({} as Record<string, unknown>);
if (id && tool) lastToolCall = { id, tool, args };
} catch {
// ignore
}
}
send(ev.type, ev.data ?? null); send(ev.type, ev.data ?? null);
}, },
}); });
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { extname } from "path"; import { extname } from "path";
import { makeUniqueFileName } from "@/lib/file-tree/naming"; import { makeUniqueFileName } from "@/lib/file-tree/naming";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -11,6 +12,7 @@ interface BatchPayload {
action: Action; action: Action;
assetIds: string[]; assetIds: string[];
targetDocumentId?: string; targetDocumentId?: string;
targetSubPath?: string;
newName?: string; newName?: string;
} }
@@ -49,6 +51,18 @@ function resolveAssetLocation(asset: any): { bucket: string; path: string } | nu
return null; return null;
} }
function sanitizeSubPath(input: string | undefined): string {
const raw = typeof input === "string" ? input : "";
const cleaned = raw
.replace(/\\/g, "/")
.split("/")
.map((seg) => seg.trim())
.filter((seg) => Boolean(seg) && seg !== "." && seg !== "..")
.map((seg) => seg.replace(/[\\/]/g, "_"))
.slice(0, 8);
return cleaned.join("/");
}
export async function POST(request: Request) { export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
@@ -117,12 +131,21 @@ export async function POST(request: Request) {
typeof crypto.randomUUID === "function" typeof crypto.randomUUID === "function"
? crypto.randomUUID() ? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`; : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const targetPath = `${asset.workspace_id}/${asset.document_id}/${Date.now()}-${uniqueId}-${safeName}`; const baseDir = (() => {
const current = location.path;
const idx = current.lastIndexOf("/");
if (idx > 0) return current.slice(0, idx);
return `${asset.workspace_id}/${asset.document_id}`;
})();
const targetPath = `${baseDir}/${Date.now()}-${uniqueId}-${safeName}`;
const bucket = location.bucket || asset.bucket || DEFAULT_DOC_BUCKET || BUCKET; const bucket = location.bucket || asset.bucket || DEFAULT_DOC_BUCKET || BUCKET;
const moveResult = await supabase.storage.from(bucket).move(location.path, targetPath); const moveResult = await supabase.storage.from(bucket).move(location.path, targetPath);
if (moveResult.error) throw moveResult.error; if (moveResult.error) throw moveResult.error;
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7); const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
const signedUrl = signed?.signedUrl ?? null; let signedUrl = signed?.signedUrl ?? null;
if (signedUrl) {
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
}
const { error } = await supabase const { error } = await supabase
.from("media_assets") .from("media_assets")
.update({ .update({
@@ -172,13 +195,18 @@ export async function POST(request: Request) {
typeof crypto.randomUUID === "function" typeof crypto.randomUUID === "function"
? crypto.randomUUID() ? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`; : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const targetPath = `${targetDoc.workspace_id}/${payload.targetDocumentId}/${Date.now()}-${uniqueId}-${fileName}`; const subdir = sanitizeSubPath(payload.targetSubPath);
const prefix = `${targetDoc.workspace_id}/${payload.targetDocumentId}${subdir ? `/${subdir}` : ""}`;
const targetPath = `${prefix}/${Date.now()}-${uniqueId}-${fileName}`;
const bucket = location.bucket || asset.bucket || DEFAULT_DOC_BUCKET || BUCKET; const bucket = location.bucket || asset.bucket || DEFAULT_DOC_BUCKET || BUCKET;
if (payload.action === "copy") { if (payload.action === "copy") {
const copyRes = await supabase.storage.from(bucket).copy(location.path, targetPath); const copyRes = await supabase.storage.from(bucket).copy(location.path, targetPath);
if (copyRes.error) throw copyRes.error; if (copyRes.error) throw copyRes.error;
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7); const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
const signedUrl = signed?.signedUrl ?? null; let signedUrl = signed?.signedUrl ?? null;
if (signedUrl) {
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
}
const { data: inserted, error } = await supabase const { data: inserted, error } = await supabase
.from("media_assets") .from("media_assets")
.insert({ .insert({
@@ -202,7 +230,10 @@ export async function POST(request: Request) {
const moveRes = await supabase.storage.from(bucket).move(location.path, targetPath); const moveRes = await supabase.storage.from(bucket).move(location.path, targetPath);
if (moveRes.error) throw moveRes.error; if (moveRes.error) throw moveRes.error;
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7); const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
const signedUrl = signed?.signedUrl ?? null; let signedUrl = signed?.signedUrl ?? null;
if (signedUrl) {
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
}
const { data: updated, error } = await supabase const { data: updated, error } = await supabase
.from("media_assets") .from("media_assets")
.update({ .update({
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -66,6 +67,8 @@ export async function GET(request: Request) {
signedUrl = signedUrlData.signedUrl; signedUrl = signedUrlData.signedUrl;
} }
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
return NextResponse.json({ return NextResponse.json({
signedUrl, signedUrl,
asset: { asset: {
@@ -1,8 +1,28 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import supabaseAdmin from "@/lib/supabase/admin"; import supabaseAdmin from "@/lib/supabase/admin";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const applyHostOverride = (rawUrl: string, hostOverride: string) => {
try {
const u = new URL(rawUrl);
// 支持两种写法:hostname 或完整 originhttps://xxx:port
if (/^https?:\/\//i.test(hostOverride)) {
const ov = new URL(hostOverride);
u.protocol = ov.protocol;
u.host = ov.host;
return u.toString();
}
u.hostname = hostOverride;
return u.toString();
} catch {
return rawUrl;
}
};
const parseStoragePath = (fileUrl: string) => { const parseStoragePath = (fileUrl: string) => {
try { try {
const url = new URL(fileUrl); const url = new URL(fileUrl);
@@ -51,27 +71,22 @@ export async function GET(request: Request) {
const { bucket, path } = parsed; const { bucket, path } = parsed;
const { data, error } = await supabaseAdmin.storage // OnlyOffice 需要“可被文档服务器拉取”的 URL:不要强制 downloadContent-Disposition: attachment)。
.from(bucket) const { data, error } = forOnlyOffice
.createSignedUrl(path, 60 * 60, { download: fileName }); ? await supabaseAdmin.storage.from(bucket).createSignedUrl(path, 60 * 60)
: await supabaseAdmin.storage.from(bucket).createSignedUrl(path, 60 * 60, { download: fileName });
if (error || !data?.signedUrl) { if (error || !data?.signedUrl) {
return NextResponse.json({ error: error?.message ?? "生成签名 URL 失败" }, { status: 500 }); return NextResponse.json({ error: error?.message ?? "生成签名 URL 失败" }, { status: 500 });
} }
let signedUrl = data.signedUrl; let signedUrl = data.signedUrl;
if ( if (forOnlyOffice && hostOverride) {
forOnlyOffice && // 优先按 OnlyOffice 专用回源地址改写(可设置为 http://host.docker.internal:18000 以降低延迟)
hostOverride && signedUrl = applyHostOverride(signedUrl, hostOverride);
(signedUrl.includes("127.0.0.1") || signedUrl.includes("localhost")) } else {
) { // 返回给浏览器的 URL 必须是公网可达的(不能是 127.0.0.1:18000
try { signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
const url = new URL(signedUrl);
url.hostname = hostOverride;
signedUrl = url.toString();
} catch {
// ignore parse errors
}
} }
return NextResponse.json({ signedUrl }); return NextResponse.json({ signedUrl });
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import { extname } from "path"; import { extname } from "path";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -28,6 +29,7 @@ export async function POST(request: Request) {
const file = formData.get("file"); const file = formData.get("file");
const workspaceId = String(formData.get("workspaceId") ?? ""); const workspaceId = String(formData.get("workspaceId") ?? "");
const documentId = String(formData.get("documentId") ?? ""); const documentId = String(formData.get("documentId") ?? "");
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
if (!(file instanceof File) || !workspaceId || !documentId) { if (!(file instanceof File) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 }); return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
@@ -38,7 +40,11 @@ export async function POST(request: Request) {
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const extension = extname(file.name || "").replace(/\s+/g, ""); const extension = extname(file.name || "").replace(/\s+/g, "");
const uniqueId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(36).slice(2); const uniqueId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(36).slice(2);
const path = `${workspaceId}/${Date.now()}-${uniqueId}${extension}`; // 与 /api/media/batch 的 move/rename 规则对齐:放到 workspaceId/documentId 下
const mindmapId =
mindmapIdRaw && /^[a-zA-Z0-9_-]{1,128}$/.test(mindmapIdRaw) ? mindmapIdRaw : "";
const subdir = mindmapId ? `mindmaps/${mindmapId}` : "";
const path = `${workspaceId}/${documentId}${subdir ? `/${subdir}` : ""}/${Date.now()}-${uniqueId}${extension}`;
const assetType = resolveAssetType(file.type || ""); const assetType = resolveAssetType(file.type || "");
const { error: uploadError } = await supabase.storage.from(DOC_BUCKET).upload(path, buffer, { const { error: uploadError } = await supabase.storage.from(DOC_BUCKET).upload(path, buffer, {
@@ -66,6 +72,8 @@ export async function POST(request: Request) {
signedUrl = signed?.signedUrl ?? ""; signedUrl = signed?.signedUrl ?? "";
} }
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
const { data: asset, error } = await supabase const { data: asset, error } = await supabase
.from("media_assets") .from("media_assets")
.insert({ .insert({
@@ -6,6 +6,7 @@ import type { SidebarInitialData } from "@/components/sidebar/types";
import { import {
detectLocalMindmapFiles, detectLocalMindmapFiles,
detectLocalMindmapDocs, detectLocalMindmapDocs,
detectLocalMindmapImageAssetIdsByMindmapId,
detectLocalTrashedMindmapAssets, detectLocalTrashedMindmapAssets,
} from "@/lib/mindmap-files"; } from "@/lib/mindmap-files";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
@@ -37,6 +38,8 @@ export async function GET(request: Request) {
const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId); const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId);
const docIds = dataset.documents.map((d) => d.id); const docIds = dataset.documents.map((d) => d.id);
const localMindmapFiles = await detectLocalMindmapFiles(docIds); const localMindmapFiles = await detectLocalMindmapFiles(docIds);
const mindmapAssetChildren =
await detectLocalMindmapImageAssetIdsByMindmapId(localMindmapFiles);
const mindmapDocs = Array.from(new Set([...(dataset.mindmapDocs ?? []), ...(await detectLocalMindmapDocs(docIds))])); const mindmapDocs = Array.from(new Set([...(dataset.mindmapDocs ?? []), ...(await detectLocalMindmapDocs(docIds))]));
const trashedMindmapAssets = await detectLocalTrashedMindmapAssets(targetWorkspaceId, docIds); const trashedMindmapAssets = await detectLocalTrashedMindmapAssets(targetWorkspaceId, docIds);
const docById = new Map(dataset.documents.map((d) => [d.id, d])); const docById = new Map(dataset.documents.map((d) => [d.id, d]));
@@ -100,6 +103,7 @@ export async function GET(request: Request) {
trashedMindmapAssets, trashedMindmapAssets,
mindmapDocs, mindmapDocs,
mindmapAssets, mindmapAssets,
mindmapAssetChildren,
tableAssets, tableAssets,
mediaAssets: dataset.mediaAssets ?? [], mediaAssets: dataset.mediaAssets ?? [],
}; };
+49 -14
View File
@@ -2,9 +2,13 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
type EditorMode = "view" | "edit"; type EditorMode = "view" | "edit";
const MNOTE_AGENT_PLUGIN_GUID = "asc.{F2B9A7B4-3A22-4E0E-8B32-3B0DE7AE8A60}";
const loadScript = (src: string) => const loadScript = (src: string) =>
new Promise<void>((resolve, reject) => { new Promise<void>((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`); const existing = document.querySelector(`script[src="${src}"]`);
@@ -34,10 +38,13 @@ const docTypeFromExt = (ext: string) => {
const word = ["doc", "docx", "odt", "rtf"]; const word = ["doc", "docx", "odt", "rtf"];
const slide = ["ppt", "pptx", "odp"]; const slide = ["ppt", "pptx", "odp"];
const sheet = ["xls", "xlsx", "ods", "csv"]; const sheet = ["xls", "xlsx", "ods", "csv"];
if (word.includes(ext)) return "text"; const pdf = ["pdf"];
if (slide.includes(ext)) return "presentation"; // 说明:ONLYOFFICE 文档类型使用 word/cell/slide/pdf(旧的 text/spreadsheet/presentation 已逐步弃用)
if (sheet.includes(ext)) return "spreadsheet"; if (word.includes(ext)) return "word";
return "text"; if (slide.includes(ext)) return "slide";
if (sheet.includes(ext)) return "cell";
if (pdf.includes(ext)) return "pdf";
return "word";
}; };
export default function OnlyOfficePage() { export default function OnlyOfficePage() {
@@ -46,6 +53,7 @@ export default function OnlyOfficePage() {
const fileName = params.get("fileName") ?? "未命名文档"; const fileName = params.get("fileName") ?? "未命名文档";
const fileType = (params.get("fileType") ?? "docx").toLowerCase(); const fileType = (params.get("fileType") ?? "docx").toLowerCase();
const mode = (params.get("mode") ?? "edit") as EditorMode; const mode = (params.get("mode") ?? "edit") as EditorMode;
const assetId = params.get("assetId") ?? "";
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const baseUrl = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL; const baseUrl = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL;
const storageHostOverride = const storageHostOverride =
@@ -54,19 +62,29 @@ export default function OnlyOfficePage() {
const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]); const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]);
const resolvedFileUrl = useMemo(() => { const resolvedFileUrl = useMemo(() => {
if (!fileUrl) return ""; if (!fileUrl) return "";
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
const base = storageHostOverride
? fileUrl
: rewriteToPublicOrigin(fileUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
try { try {
const u = new URL(fileUrl); const u = new URL(base);
if ( if (!storageHostOverride) return u.toString();
storageHostOverride &&
(u.hostname === "127.0.0.1" || // 兼容两种写法:hostname 或完整 originhttps://xxx
u.hostname === "localhost" || if (/^https?:\/\//i.test(storageHostOverride)) {
u.hostname === "host.docker.internal") const ov = new URL(storageHostOverride);
) { u.protocol = ov.protocol;
u.hostname = storageHostOverride; u.host = ov.host;
return u.toString();
} }
u.hostname = storageHostOverride;
return u.toString(); return u.toString();
} catch { } catch {
return fileUrl; return base;
} }
}, [fileUrl, storageHostOverride]); }, [fileUrl, storageHostOverride]);
@@ -87,6 +105,7 @@ export default function OnlyOfficePage() {
throw new Error("未检测到 DocsAPI,请检查 ONLYOFFICE 版本。"); throw new Error("未检测到 DocsAPI,请检查 ONLYOFFICE 版本。");
} }
// eslint-disable-next-line new-cap,@typescript-eslint/no-explicit-any // eslint-disable-next-line new-cap,@typescript-eslint/no-explicit-any
const pluginConfigUrl = `${window.location.origin}/onlyoffice/plugins/agent-tools/config.json`;
new (window as any).DocsAPI.DocEditor("onlyoffice-frame", { new (window as any).DocsAPI.DocEditor("onlyoffice-frame", {
width: "100%", width: "100%",
height: "100%", height: "100%",
@@ -103,6 +122,10 @@ export default function OnlyOfficePage() {
customization: { customization: {
feedback: { visible: false }, feedback: { visible: false },
}, },
plugins: {
autostart: [MNOTE_AGENT_PLUGIN_GUID],
pluginsData: [pluginConfigUrl],
},
}, },
}); });
}) })
@@ -120,5 +143,17 @@ export default function OnlyOfficePage() {
); );
} }
return <div id="onlyoffice-frame" className="h-screen w-screen bg-slate-50" />; return (
<div className="relative h-screen w-screen bg-slate-50">
<div id="onlyoffice-frame" className="h-full w-full" />
<OnlyOfficeAiAgentPanel
openFile={{
id: assetId || `onlyoffice_${hashKey(`${resolvedFileUrl}-${fileName}`)}`,
title: fileName,
fileUrl: resolvedFileUrl,
mimeType: null,
}}
/>
</div>
);
} }
@@ -579,7 +579,7 @@ export function DocumentAiAgentPanel({
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="p-0"> <SheetContent side="right" showCloseButton={false} className="p-0">
<SheetHeader className="border-b"> <SheetHeader className="border-b">
<SheetTitle className="flex items-center justify-between gap-2"> <SheetTitle className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@@ -115,7 +115,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
}, [block.props.fileName, fileUrl]); }, [block.props.fileName, fileUrl]);
const isOfficeDoc = useMemo( const isOfficeDoc = useMemo(
() => () =>
["doc", "docx", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt"].includes( ["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
extension, extension,
), ),
[extension], [extension],
@@ -1,7 +1,10 @@
"use client"; "use client";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Network, Paperclip, Settings2, Send, X } from "lucide-react"; import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
type AgentAssetItem = { type AgentAssetItem = {
kind: "media" | "local-mindmap" | "test-pdf"; kind: "media" | "local-mindmap" | "test-pdf";
@@ -20,6 +23,54 @@ type MindmapInstanceLike = {
command?: { clearHistory?: () => void }; command?: { clearHistory?: () => void };
}; };
const DEFAULT_SESSION_MESSAGES: AgentMessage[] = [
{
role: "assistant",
content:
"你好,我是思维导图 AI Agent。\n- 直接说需求(例如:补完选中节点、总结 @PDF 并写入导图、从 @PDF 生成章/节结构导图)\n- 使用 @ 选择文件或上传文件\n- 你可以让 AI 自动选择工具,或在“工具”里切换到手动并勾选允许使用的工具",
},
];
type ToolLog =
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "error"; message: string };
type ChatSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messages: AgentMessage[];
toolLogs: ToolLog[];
attachments: AgentAssetItem[];
};
type PanelPage = "chat" | "tools" | "history" | "account" | "settings";
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
const generateId = () => {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `sess_${Math.random().toString(16).slice(2, 10)}`;
};
const normalizeSessions = (sessions: ChatSession[]) => {
const maxSessions = 20;
const maxMessages = 40;
const maxToolLogs = 80;
const maxAttachments = 20;
return sessions
.slice(0, maxSessions)
.map((s) => ({
...s,
messages: Array.isArray(s.messages) ? s.messages.slice(-maxMessages) : [],
toolLogs: Array.isArray(s.toolLogs) ? s.toolLogs.slice(-maxToolLogs) : [],
attachments: Array.isArray(s.attachments) ? s.attachments.slice(-maxAttachments) : [],
}))
.sort((a, b) => b.updatedAt - a.updatedAt);
};
const isRecord = (v: unknown): v is Record<string, unknown> => const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v); typeof v === "object" && v !== null && !Array.isArray(v);
@@ -99,45 +150,40 @@ export function MindmapAiAgentPanel({
mindmapId, mindmapId,
mindmap, mindmap,
activeNodes, activeNodes,
onClose,
}: { }: {
documentId: string; documentId: string;
mindmapId: string; mindmapId: string;
mindmap: MindmapInstanceLike | null | undefined; mindmap: MindmapInstanceLike | null | undefined;
activeNodes: unknown[]; activeNodes: unknown[];
onClose?: () => void;
}) { }) {
const [messages, setMessages] = useState<AgentMessage[]>([ const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_SESSION_MESSAGES);
{
role: "assistant",
content:
"你好,我是思维导图 AI Agent。你可以:\n- 直接说需求(例如:补完选中节点、总结 @PDF 并写入导图、从 @PDF 生成章/节结构导图)\n- 使用 @ 选择文件或上传文件\n- 让 AI 自动选择工具,或手动勾选允许使用的工具",
},
]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [networkOn, setNetworkOn] = useState(true); const [networkOn, setNetworkOn] = useState(true);
const [toolAuto, setToolAuto] = useState(true); const [toolAuto, setToolAuto] = useState(true);
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
const [toolPickerOpen, setToolPickerOpen] = useState(false); const [toolPickerOpen, setToolPickerOpen] = useState(false);
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS); const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
const [aiProvider, setAiProvider] = useState<"online" | "local">("online"); const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
const [aiModel, setAiModel] = useState<string>(""); const [aiModel, setAiModel] = useState<string>("");
const [maxSteps, setMaxSteps] = useState<number>(10); const [maxSteps, setMaxSteps] = useState<number>(10);
const [page, setPage] = useState<PanelPage>("chat");
const [assets, setAssets] = useState<AgentAssetItem[]>([]); const [assets, setAssets] = useState<AgentAssetItem[]>([]);
const [workspaceId, setWorkspaceId] = useState<string>(""); const [workspaceId, setWorkspaceId] = useState<string>("");
const [attachments, setAttachments] = useState<AgentAssetItem[]>([]); const [attachments, setAttachments] = useState<AgentAssetItem[]>([]);
const [debug, setDebug] = useState<string>(""); const [debug, setDebug] = useState<string>("");
const [toolLogs, setToolLogs] = useState< const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
Array< const [sessions, setSessions] = useState<ChatSession[]>([]);
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> } const [activeSessionId, setActiveSessionId] = useState<string>("");
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "error"; message: string }
>
>([]);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const syncTimerRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
try { try {
@@ -165,6 +211,112 @@ export function MindmapAiAgentPanel({
} }
}, [aiProvider, aiModel, maxSteps]); }, [aiProvider, aiModel, maxSteps]);
useEffect(() => {
// 切换导图时,默认回到对话页
setPage("chat");
}, [mindmapId]);
// 会话/历史:按 mindmapId 隔离持久化
useEffect(() => {
try {
const key = `mindmap_ai_sessions:${mindmapId}`;
const raw = window.localStorage.getItem(key);
if (!raw) {
const id = generateId();
const now = Date.now();
const session: ChatSession = {
id,
title: "新会话",
createdAt: now,
updatedAt: now,
messages: DEFAULT_SESSION_MESSAGES,
toolLogs: [],
attachments: [],
};
setSessions([session]);
setActiveSessionId(id);
setMessages(session.messages);
setToolLogs([]);
setAttachments([]);
return;
}
const parsed = JSON.parse(raw) as unknown;
const list = typeof parsed === "object" && parsed && "sessions" in parsed ? (parsed as any).sessions : null;
const active =
typeof parsed === "object" && parsed && "activeSessionId" in parsed
? String((parsed as any).activeSessionId ?? "")
: "";
if (!Array.isArray(list) || list.length === 0) return;
const loaded = normalizeSessions(
list
.map((x) => {
const id = String((x as any)?.id ?? "").trim() || generateId();
const createdAt = Number((x as any)?.createdAt ?? Date.now());
const updatedAt = Number((x as any)?.updatedAt ?? createdAt);
const title = String((x as any)?.title ?? "").trim() || "历史会话";
const messages = Array.isArray((x as any)?.messages) ? (x as any).messages : DEFAULT_SESSION_MESSAGES;
const toolLogs = Array.isArray((x as any)?.toolLogs) ? (x as any).toolLogs : [];
const attachments = Array.isArray((x as any)?.attachments) ? (x as any).attachments : [];
return { id, title, createdAt, updatedAt, messages, toolLogs, attachments } as ChatSession;
})
.filter((s) => s.id),
);
setSessions(loaded);
const picked = active && loaded.some((s) => s.id === active) ? active : loaded[0]!.id;
setActiveSessionId(picked);
const cur = loaded.find((s) => s.id === picked) ?? loaded[0]!;
setMessages(cur.messages?.length ? cur.messages : DEFAULT_SESSION_MESSAGES);
setToolLogs(cur.toolLogs ?? []);
setAttachments(cur.attachments ?? []);
} catch {
// ignore
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mindmapId]);
useEffect(() => {
if (!activeSessionId) return;
if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
syncTimerRef.current = window.setTimeout(() => {
setSessions((prev) => {
const now = Date.now();
const next = prev.some((s) => s.id === activeSessionId)
? prev.map((s) =>
s.id === activeSessionId ? { ...s, messages, toolLogs, attachments, updatedAt: now } : s,
)
: [
{
id: activeSessionId,
title: "新会话",
createdAt: now,
updatedAt: now,
messages,
toolLogs,
attachments,
},
...prev,
];
return normalizeSessions(next);
});
}, 200);
return () => {
if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
};
}, [activeSessionId, attachments, messages, toolLogs]);
useEffect(() => {
if (!mindmapId) return;
try {
const key = `mindmap_ai_sessions:${mindmapId}`;
const payload = JSON.stringify({ activeSessionId, sessions: normalizeSessions(sessions) });
if (payload.length <= 900_000) window.localStorage.setItem(key, payload);
} catch {
// ignore
}
}, [activeSessionId, mindmapId, sessions]);
// @ 选择 // @ 选择
const [mentionOpen, setMentionOpen] = useState(false); const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState(""); const [mentionQuery, setMentionQuery] = useState("");
@@ -435,11 +587,142 @@ export function MindmapAiAgentPanel({
} }
}; };
const currentSession = useMemo(() => sessions.find((s) => s.id === activeSessionId) ?? null, [activeSessionId, sessions]);
const currentSessionTitle = currentSession?.title || "新会话";
const pageTitle = useMemo(() => {
switch (page) {
case "tools":
return "工具(代替 MCP";
case "history":
return "历史会话";
case "account":
return "账户 / 模型";
case "settings":
return "设置";
default:
return "思维导图 AI Agent";
}
}, [page]);
const startNewSession = () => {
if (loading) return;
const id = generateId();
const now = Date.now();
const next: ChatSession = {
id,
title: "新会话",
createdAt: now,
updatedAt: now,
messages: DEFAULT_SESSION_MESSAGES,
toolLogs: [],
attachments: [],
};
setSessions((prev) => normalizeSessions([next, ...prev]));
setActiveSessionId(id);
setMessages(next.messages);
setToolLogs([]);
setAttachments([]);
setInput("");
setDebug("");
};
const switchSession = (id: string) => {
if (loading) return;
const target = sessions.find((s) => s.id === id);
if (!target) return;
setActiveSessionId(target.id);
setMessages(target.messages?.length ? target.messages : DEFAULT_SESSION_MESSAGES);
setToolLogs(target.toolLogs ?? []);
setAttachments(target.attachments ?? []);
setInput("");
setDebug("");
};
const resetCurrentSession = () => {
if (loading) return;
setMessages(DEFAULT_SESSION_MESSAGES);
setToolLogs([]);
setAttachments([]);
setInput("");
setDebug("");
if (activeSessionId) {
setSessions((prev) =>
normalizeSessions(
prev.map((s) =>
s.id === activeSessionId
? { ...s, messages: DEFAULT_SESSION_MESSAGES, toolLogs: [], attachments: [], updatedAt: Date.now(), title: s.title || "当前会话" }
: s,
),
),
);
}
};
const clearHistory = () => {
if (loading) return;
const base: ChatSession =
currentSession ??
({
id: activeSessionId || generateId(),
title: "当前会话",
createdAt: Date.now(),
updatedAt: Date.now(),
messages,
toolLogs,
attachments,
} as ChatSession);
setSessions([
{
...base,
title: base.title || "当前会话",
updatedAt: Date.now(),
messages,
toolLogs,
attachments,
},
]);
setActiveSessionId(base.id);
};
const deleteSession = (id: string) => {
if (loading) return;
setSessions((prev) => normalizeSessions(prev.filter((s) => s.id !== id)));
if (id === activeSessionId) {
const fallback = sessions.filter((s) => s.id !== id)[0];
if (fallback) {
setActiveSessionId(fallback.id);
setMessages(fallback.messages?.length ? fallback.messages : DEFAULT_SESSION_MESSAGES);
setToolLogs(fallback.toolLogs ?? []);
setAttachments(fallback.attachments ?? []);
} else {
startNewSession();
}
}
};
const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]);
const stop = () => {
try {
abortRef.current?.abort();
} catch {
// ignore
} finally {
abortRef.current = null;
setLoading(false);
}
};
const send = async () => { const send = async () => {
const content = input.trim(); const content = input.trim();
if (!content) return; if (!content) return;
setDebug(""); setDebug("");
setToolLogs([]); setToolLogs([]);
if (activeSessionId && currentSessionTitle === "新会话") {
const title = content.length > 18 ? `${content.slice(0, 18)}` : content;
setSessions((prev) => normalizeSessions(prev.map((s) => (s.id === activeSessionId ? { ...s, title, updatedAt: Date.now() } : s))));
}
const nextMessages: AgentMessage[] = [...messages, { role: "user", content }]; const nextMessages: AgentMessage[] = [...messages, { role: "user", content }];
setMessages(nextMessages); setMessages(nextMessages);
@@ -604,6 +887,421 @@ export function MindmapAiAgentPanel({
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="border-b p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{page === "chat" ? (
<Sparkles className="h-4 w-4" />
) : (
<Button variant="ghost" size="icon" onClick={() => setPage("chat")} disabled={loading} title="返回对话">
<ChevronLeft className="h-4 w-4" />
</Button>
)}
<span className="text-sm font-medium">{pageTitle}</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={startNewSession} disabled={loading} title="新建会话">
<Plus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("tools")} disabled={loading} title="工具(代替 MCP">
<Wrench className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("history")} disabled={loading} title="历史">
<History className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("account")} disabled={loading} title="账户/模型">
<User className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("settings")} disabled={loading} title="设置">
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onClose?.()} disabled={loading} title="关闭">
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{page === "chat" ? (
<>
<div
className="border-b p-3 text-xs text-muted-foreground"
title={
selectedNodes.length
? selectedNodes
.map((n) => (n.text ? `${n.text}${n.uid}` : n.uid))
.slice(0, 3)
.join("")
: ""
}
>
{selectedNodes.length
? `选中节点:${selectedNodes[0]?.text || selectedNodes[0]?.uid}${selectedNodes.length > 1 ? `+${selectedNodes.length - 1}` : ""}`
: "未选中节点(将以整图为上下文)"}
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 border-b">
<div className="px-3 py-2 text-sm font-medium"></div>
<ScrollArea className="h-[36vh] border-t">
<div className="space-y-3 p-3 text-sm">
{messages.map((m, idx) => (
<div key={idx} className="space-y-1">
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
<div className="whitespace-pre-wrap">{m.content}</div>
</div>
))}
</div>
</ScrollArea>
</div>
<div className="min-h-0">
<div className="px-3 py-2 text-sm font-medium"></div>
<ScrollArea className="h-[24vh] border-t">
<div className="space-y-3 p-3 text-sm">
{toolLogs.length === 0 ? <div className="text-muted-foreground"></div> : null}
{toolLogs.map((l, idx) => {
if (l.type === "error") {
return (
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<details key={idx} className="rounded border p-2">
<summary className="cursor-pointer select-none text-xs text-muted-foreground">
tool_call · {l.tool} · {l.id}
</summary>
<pre className="mt-2 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
</details>
);
}
return (
<details key={idx} className="rounded border p-2">
<summary className="cursor-pointer select-none text-xs text-muted-foreground">
tool_result · {l.tool} · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
</summary>
<pre className="mt-2 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
</details>
);
})}
</div>
</ScrollArea>
</div>
</div>
<div className="border-t p-3">
{attachments.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((a) => (
<span
key={a.id}
className="inline-flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs text-foreground"
title={a.fileUrl}
>
<span className="max-w-[160px] truncate">{a.title}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
disabled={loading}
title="移除附件"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : null}
<div className="relative">
{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 bg-background 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-muted"
onClick={() => insertMention(a)}
disabled={loading}
>
<span className="truncate">{a.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">{a.kind}</span>
</button>
))}
</div>
) : null}
<Textarea
ref={textareaRef}
data-testid="mindmap-ai-input"
className="min-h-[96px]"
value={input}
placeholder="输入你的需求。使用 @ 选择文件(PDF/附件/本地导图),例如:总结 @xx.pdf 并写入导图(章->节->要点)。"
disabled={loading}
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);
return;
}
if (e.key === "Enter") {
// Enter 发送;Shift+Enter 换行
const isComposing = Boolean((e.nativeEvent as unknown as { isComposing?: boolean })?.isComposing);
if (!e.shiftKey && !isComposing) {
e.preventDefault();
if (canSend) void send();
}
return;
}
}}
onClick={(e) => {
const el = e.currentTarget;
updateMentionState(el.value, el.selectionStart ?? el.value.length);
}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={loading} title="上传文件到当前页面附件">
<Paperclip className="mr-2 h-4 w-4" />
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
void uploadFiles(e.target.files);
}}
accept="*/*"
/>
<div className="text-xs text-muted-foreground">Enter · Shift+Enter </div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" disabled={!loading} onClick={stop} title="停止本次执行">
<X className="mr-2 h-4 w-4" />
</Button>
<Button disabled={!canSend} onClick={() => void send()}>
<Send className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
</>
) : null}
{page === "tools" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
</div>
{!toolAuto ? (
<div>
<div className="mb-2 text-xs text-muted-foreground">使</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) => (prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t]))
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">AI ToolSet </div>
)}
</div>
</ScrollArea>
) : null}
{page === "history" ? (
<ScrollArea className="h-full">
<div className="p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-muted-foreground"> 20 </div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={resetCurrentSession} disabled={loading}>
</Button>
<Button variant="outline" onClick={startNewSession} disabled={loading}>
<Plus className="mr-2 h-4 w-4" />
</Button>
<Button variant="destructive" onClick={clearHistory} disabled={loading}>
</Button>
</div>
</div>
<div className="mt-3 space-y-2">
{sessions.map((s) => {
const active = s.id === activeSessionId;
const time = new Date(s.updatedAt || s.createdAt).toLocaleString();
return (
<div key={s.id} className={`flex items-center gap-2 rounded border px-3 py-2 ${active ? "border-[#111827]" : "border-border"}`}>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
switchSession(s.id);
setPage("chat");
}}
disabled={loading}
title={active ? "当前会话" : "切换到该会话"}
>
<div className="flex items-center justify-between gap-2">
<div className="truncate font-medium">{s.title || "未命名"}</div>
<div className="shrink-0 text-xs text-muted-foreground">{time}</div>
</div>
<div className="mt-1 text-xs text-muted-foreground"> {s.messages.length} · {s.toolLogs.length}</div>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSession(s.id)}
disabled={loading || active}
title={active ? "不能删除当前会话" : "删除会话"}
>
</Button>
</div>
);
})}
</div>
</div>
</ScrollArea>
) : null}
{page === "account" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-muted-foreground"></label>
<select
className="h-9 rounded border bg-white px-2 text-sm"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
</select>
<label className="ml-2 text-xs text-muted-foreground"></label>
{aiProvider === "online" ? (
<select
data-testid="mindmap-ai-model-select"
className="h-9 rounded border bg-white px-2 text-sm"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
{ONLINE_MODELS.map((m) => (
<option key={m || "__default__"} value={m}>
{m ? m : "默认(ai.md"}
</option>
))}
</select>
) : (
<input
data-testid="mindmap-ai-model-input"
className="h-9 w-[200px] rounded border bg-white px-2 text-sm"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
disabled={loading}
/>
)}
</div>
{aiProvider === "local" ? (
<div className="text-xs text-muted-foreground">
AI `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` `ai.local.md` / `ai-local.md`
</div>
) : null}
</div>
</ScrollArea>
) : null}
{page === "settings" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${networkOn ? "bg-white" : "bg-muted"}`}
onClick={() => setNetworkOn((v) => !v)}
disabled={loading}
title="联网检索(SearxNG"
>
<Network className="h-3.5 w-3.5" />
{networkOn ? "联网" : "离线"}
</button>
<label className="flex items-center gap-2">
<input
className="w-[92px] rounded border px-2 py-1 text-sm"
type="number"
min={1}
max={24}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(clamp(Math.floor(v), 1, 24));
}}
disabled={loading}
/>
</label>
</div>
<div className="text-xs text-muted-foreground"></div>
{debug ? (
<details className="rounded border bg-background p-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">SSE </summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
</details>
) : null}
</div>
</ScrollArea>
) : null}
</div>
{false ? (
<div className="hidden">
<div className="flex items-center justify-between gap-2 pb-3"> <div className="flex items-center justify-between gap-2 pb-3">
<div <div
className="text-xs text-gray-500" className="text-xs text-gray-500"
@@ -924,6 +1622,8 @@ export function MindmapAiAgentPanel({
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre> <pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
</details> </details>
) : null} ) : null}
</div>
) : null}
</div> </div>
); );
} }
@@ -155,9 +155,18 @@ const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData;
// 递归收集所有 asset:id // 递归收集所有 asset:id
const collectAssetIds = (node: any) => { const collectAssetIds = (node: any) => {
if (!node) return; if (!node) return;
if (node.image?.url?.startsWith?.("asset:")) { const candidates: unknown[] = [
assetIds.push(node.image.url.replace("asset:", "")); node?.data?.image,
} node?.image,
node?.image?.url,
node?.data?.image?.url,
];
candidates.forEach((value) => {
if (typeof value !== "string") return;
if (!value.startsWith("asset:")) return;
const id = value.replace(/^asset:/, "").trim();
if (id) assetIds.push(id);
});
if (Array.isArray(node.children)) { if (Array.isArray(node.children)) {
node.children.forEach(collectAssetIds); node.children.forEach(collectAssetIds);
} }
@@ -176,9 +185,7 @@ const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData;
const result = await response.json(); const result = await response.json();
urlMap.set(id, result.signedUrl); urlMap.set(id, result.signedUrl);
} }
} catch (e) { } catch {}
console.error(`获取 asset ${id} 签名 URL 失败`, e);
}
})); }));
// 递归替换 asset:id 为签名 URL,并建立反向映射 // 递归替换 asset:id 为签名 URL,并建立反向映射
@@ -186,15 +193,49 @@ const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData;
const replaceAssetIds = (node: any): any => { const replaceAssetIds = (node: any): any => {
if (!node) return node; if (!node) return node;
const newNode = { ...node }; const newNode = { ...node };
if (newNode.image?.url?.startsWith?.("asset:")) {
const id = newNode.image.url.replace("asset:", ""); const replaceAssetString = (value: unknown): string | null => {
if (typeof value !== "string") return null;
if (!value.startsWith("asset:")) return null;
const id = value.replace(/^asset:/, "").trim();
if (!id) return null;
const signedUrl = urlMap.get(id); const signedUrl = urlMap.get(id);
if (signedUrl) { if (signedUrl) {
newNode.image = { ...newNode.image, url: signedUrl };
// 建立反向映射:signedUrl -> asset:id
urlToAssetId.set(signedUrl, id); urlToAssetId.set(signedUrl, id);
return signedUrl;
}
// 即使签名失败,也保留 asset:id -> id 的映射,方便后续删除/撤销逻辑使用
urlToAssetId.set(`asset:${id}`, id);
return `asset:${id}`;
};
// 常见:node.data.image = "asset:xxx"
const nextDataImage = replaceAssetString(newNode?.data?.image);
if (nextDataImage && newNode.data && typeof newNode.data === "object") {
newNode.data = { ...newNode.data, image: nextDataImage };
}
// 兼容:node.image = "asset:xxx"
if (typeof newNode.image === "string") {
const nextImage = replaceAssetString(newNode.image);
if (nextImage) newNode.image = nextImage;
}
// 兼容:node.image.url = "asset:xxx"
const nextImageUrl = replaceAssetString(newNode?.image?.url);
if (nextImageUrl && newNode.image && typeof newNode.image === "object") {
newNode.image = { ...newNode.image, url: nextImageUrl };
}
// 兼容:node.data.image.url = "asset:xxx"
const nextDataImageUrl = replaceAssetString(newNode?.data?.image?.url);
if (nextDataImageUrl && newNode.data && typeof newNode.data === "object") {
const dataImage = (newNode.data as any).image;
if (dataImage && typeof dataImage === "object") {
(newNode.data as any).image = { ...(dataImage as any), url: nextDataImageUrl };
} }
} }
if (Array.isArray(newNode.children)) { if (Array.isArray(newNode.children)) {
newNode.children = newNode.children.map(replaceAssetIds); newNode.children = newNode.children.map(replaceAssetIds);
} }
@@ -209,12 +250,41 @@ const revertToAssetIds = (data: MindMapData, urlToAssetId: Map<string, string>):
const revertNode = (node: any): any => { const revertNode = (node: any): any => {
if (!node) return node; if (!node) return node;
const newNode = { ...node }; const newNode = { ...node };
if (newNode.image?.url && typeof newNode.image.url === "string") {
const assetId = urlToAssetId.get(newNode.image.url); const revertSigned = (value: unknown): string | null => {
if (assetId) { if (typeof value !== "string") return null;
newNode.image = { ...newNode.image, url: `asset:${assetId}` }; const assetId = urlToAssetId.get(value);
if (!assetId) return null;
return `asset:${assetId}`;
};
// 常见:node.data.image = signedUrl
const nextDataImage = revertSigned(newNode?.data?.image);
if (nextDataImage && newNode.data && typeof newNode.data === "object") {
newNode.data = { ...newNode.data, image: nextDataImage };
}
// 兼容:node.image = signedUrl
if (typeof newNode.image === "string") {
const nextImage = revertSigned(newNode.image);
if (nextImage) newNode.image = nextImage;
}
// 兼容:node.image.url = signedUrl
const nextImageUrl = revertSigned(newNode?.image?.url);
if (nextImageUrl && newNode.image && typeof newNode.image === "object") {
newNode.image = { ...newNode.image, url: nextImageUrl };
}
// 兼容:node.data.image.url = signedUrl
const nextDataImageUrl = revertSigned(newNode?.data?.image?.url);
if (nextDataImageUrl && newNode.data && typeof newNode.data === "object") {
const dataImage = (newNode.data as any).image;
if (dataImage && typeof dataImage === "object") {
(newNode.data as any).image = { ...(dataImage as any), url: nextDataImageUrl };
} }
} }
if (Array.isArray(newNode.children)) { if (Array.isArray(newNode.children)) {
newNode.children = newNode.children.map(revertNode); newNode.children = newNode.children.map(revertNode);
} }
@@ -297,10 +367,7 @@ const patchSvgRbox = async () => {
cx: Math.max(0, viewportWidth / 2), cx: Math.max(0, viewportWidth / 2),
cy: Math.max(0, viewportHeight / 2), cy: Math.max(0, viewportHeight / 2),
}; };
if (!warned) { if (!warned) warned = true;
console.warn("rbox 失败,使用 DOM 边界框降级避免崩溃", error, fallback);
warned = true;
}
return fallback; return fallback;
} }
}; };
@@ -510,9 +577,7 @@ const MindmapBlockView = ({
}, 0); }, 0);
} }
} }
} catch (error) { } catch {}
console.warn("加载本地/远端思维导图失败", error);
}
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
@@ -1232,7 +1297,7 @@ const MindmapBlockView = ({
}); });
} }
}) })
.catch((err) => console.warn("思维导图同步失败", err)); .catch(() => {});
} }
}, },
[autosaveKey, block, docId, editor, mindmapId, effectiveFullscreen], [autosaveKey, block, docId, editor, mindmapId, effectiveFullscreen],
@@ -1292,15 +1357,8 @@ const MindmapBlockView = ({
body: JSON.stringify({ data, createOnly: true }), body: JSON.stringify({ data, createOnly: true }),
}); });
if (!resp.ok) { if (!resp.ok) {
console.warn(
"初次创建思维导图文件失败",
resp.status,
await resp.text().catch(() => ""),
);
} }
} catch (err) { } catch {} finally {
console.warn("初次创建思维导图文件失败", err);
} finally {
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新 // 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
const fileName = `mindmap-${mindmapId}.json`; const fileName = `mindmap-${mindmapId}.json`;
emitAssetsChanged(docId, { emitAssetsChanged(docId, {
@@ -1479,7 +1537,6 @@ const MindmapBlockView = ({
plugins.forEach(({ name, plugin }) => { plugins.forEach(({ name, plugin }) => {
if (!plugin) { if (!plugin) {
console.warn(`思维导图插件加载失败:${name}`);
return; return;
} }
const MindMapCtor = MindMap as unknown as { const MindMapCtor = MindMap as unknown as {
@@ -1491,7 +1548,6 @@ const MindmapBlockView = ({
typeof hasPlugin === "function" ? hasPlugin(plugin) === -1 : true; typeof hasPlugin === "function" ? hasPlugin(plugin) === -1 : true;
const registerPlugin = MindMapCtor.usePlugin; const registerPlugin = MindMapCtor.usePlugin;
if (notRegistered && typeof registerPlugin === "function") { if (notRegistered && typeof registerPlugin === "function") {
console.log(`注册插件: ${name}`);
registerPlugin(plugin); registerPlugin(plugin);
} }
}); });
@@ -1673,11 +1729,9 @@ const MindmapBlockView = ({
renderer.setRootNodeCenter(); renderer.setRootNodeCenter();
} }
instance.view?.fit?.(); instance.view?.fit?.();
} catch (error) { } catch {
if (retry < 3) { if (retry < 3) {
window.setTimeout(() => centerAndFit(retry + 1), 50); window.setTimeout(() => centerAndFit(retry + 1), 50);
} else {
console.warn("思维导图初始居中失败,已跳过", error);
} }
} }
}; };
@@ -2089,9 +2143,7 @@ const MindmapBlockView = ({
const data = await response.json(); const data = await response.json();
return data.signedUrl; return data.signedUrl;
} }
} catch (e) { } catch {}
console.error("获取签名 URL 失败", e);
}
return urlOrAssetId; return urlOrAssetId;
} }
// 如果是完整的 URL,直接返回 // 如果是完整的 URL,直接返回
@@ -2104,9 +2156,17 @@ const MindmapBlockView = ({
const urls: string[] = []; const urls: string[] = [];
const traverse = (node: any) => { const traverse = (node: any) => {
if (!node) return; if (!node) return;
if (node.image && typeof node.image === "string" && node.image) { const candidates: unknown[] = [
urls.push(node.image); node?.data?.image,
} node?.image,
node?.image?.url,
node?.data?.image?.url,
];
candidates.forEach((value) => {
if (typeof value === "string" && value) {
urls.push(value);
}
});
if (Array.isArray(node.children)) { if (Array.isArray(node.children)) {
node.children.forEach(traverse); node.children.forEach(traverse);
} }
@@ -2119,7 +2179,6 @@ const MindmapBlockView = ({
const deleteImageAssets = async (assetIds: string[]) => { const deleteImageAssets = async (assetIds: string[]) => {
if (!assetIds.length || !docId) return; if (!assetIds.length || !docId) return;
try { try {
console.log("[MindmapBlock] Deleting image assets:", assetIds);
const response = await fetch("/api/media/batch", { const response = await fetch("/api/media/batch", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -2128,12 +2187,11 @@ const MindmapBlockView = ({
if (response.ok) { if (response.ok) {
// 记录到已删除列表 // 记录到已删除列表
assetIds.forEach(id => deletedAssetIdsRef.current.add(id)); assetIds.forEach(id => deletedAssetIdsRef.current.add(id));
console.log("[MindmapBlock] Image assets deleted successfully, emitting ASSETS_CHANGED_EVENT");
// 通知文件树刷新,传递被删除的 assetIds // 通知文件树刷新,传递被删除的 assetIds
emitAssetsChanged(docId, undefined, assetIds); emitAssetsChanged(docId, undefined, assetIds);
} else { } else {
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
console.error("[MindmapBlock] Failed to delete image assets:", payload?.error); console.error("删除图片资源失败", payload?.error);
} }
} catch (e) { } catch (e) {
console.error("删除图片资源失败", e); console.error("删除图片资源失败", e);
@@ -2280,20 +2338,15 @@ const MindmapBlockView = ({
useEffect(() => { useEffect(() => {
if (!mindmap || !docId) return; if (!mindmap || !docId) return;
console.log("[MindmapBlock] Setting up image deletion monitoring");
// 初始化:收集当前所有图片 URL // 初始化:收集当前所有图片 URL
const initialData = mindmap.getData?.(); const initialData = mindmap.getData?.();
if (initialData) { if (initialData) {
const urls = collectImageUrls(initialData); const urls = collectImageUrls(initialData);
currentImageUrlsRef.current = new Set(urls); currentImageUrlsRef.current = new Set(urls);
console.log("[MindmapBlock] Initial image URLs:", urls.length, "Map size:", signedUrlToAssetIdRef.current.size);
console.log("[MindmapBlock] Initial URLs:", urls);
} }
// 处理数据变化 // 处理数据变化
const handleDataChange = () => { const handleDataChange = () => {
console.log("[MindmapBlock] data_change event fired!");
const newData = mindmap.getData?.(); const newData = mindmap.getData?.();
if (!newData) return; if (!newData) return;
@@ -2301,10 +2354,6 @@ const MindmapBlockView = ({
const newUrlsSet = new Set(newUrls); const newUrlsSet = new Set(newUrls);
const oldUrlsSet = currentImageUrlsRef.current; const oldUrlsSet = currentImageUrlsRef.current;
console.log("[MindmapBlock] Old URLs:", Array.from(oldUrlsSet));
console.log("[MindmapBlock] New URLs:", newUrls);
console.log("[MindmapBlock] Map entries:", Array.from(signedUrlToAssetIdRef.current.entries()));
// 检测被删除的图片(在旧集合中但不在新集合中) // 检测被删除的图片(在旧集合中但不在新集合中)
const deletedUrls: string[] = []; const deletedUrls: string[] = [];
oldUrlsSet.forEach(url => { oldUrlsSet.forEach(url => {
@@ -2323,30 +2372,22 @@ const MindmapBlockView = ({
// 处理删除的图片 // 处理删除的图片
if (deletedUrls.length > 0) { if (deletedUrls.length > 0) {
console.log("[MindmapBlock] Detected deleted URLs:", deletedUrls);
const assetIdsToDelete: string[] = []; const assetIdsToDelete: string[] = [];
deletedUrls.forEach(url => { deletedUrls.forEach(url => {
const assetId = signedUrlToAssetIdRef.current.get(url); const assetId = signedUrlToAssetIdRef.current.get(url);
console.log("[MindmapBlock] URL:", url.substring(0, 100), "-> Asset ID:", assetId);
if (assetId) { if (assetId) {
assetIdsToDelete.push(assetId); assetIdsToDelete.push(assetId);
} else {
console.warn("[MindmapBlock] Asset ID not found in map for URL:", url.substring(0, 100));
} }
}); });
console.log("[MindmapBlock] Asset IDs to delete:", assetIdsToDelete);
if (assetIdsToDelete.length > 0) { if (assetIdsToDelete.length > 0) {
deleteImageAssets(assetIdsToDelete); deleteImageAssets(assetIdsToDelete);
} else {
console.warn("[MindmapBlock] No asset IDs found for deleted URLs!");
} }
} }
// 处理恢复的图片(可能是撤销操作) // 处理恢复的图片(可能是撤销操作)
if (addedUrls.length > 0) { if (addedUrls.length > 0) {
console.log("[MindmapBlock] Detected added URLs:", addedUrls);
const assetIdsToRestore: string[] = []; const assetIdsToRestore: string[] = [];
addedUrls.forEach(url => { addedUrls.forEach(url => {
const assetId = signedUrlToAssetIdRef.current.get(url); const assetId = signedUrlToAssetIdRef.current.get(url);
@@ -2354,7 +2395,6 @@ const MindmapBlockView = ({
assetIdsToRestore.push(assetId); assetIdsToRestore.push(assetId);
} }
}); });
console.log("[MindmapBlock] Asset IDs to restore:", assetIdsToRestore);
if (assetIdsToRestore.length > 0) { if (assetIdsToRestore.length > 0) {
restoreImageAssets(assetIdsToRestore); restoreImageAssets(assetIdsToRestore);
} }
@@ -2369,10 +2409,7 @@ const MindmapBlockView = ({
mindmap.on?.("back_forward", handleDataChange); mindmap.on?.("back_forward", handleDataChange);
mindmap.on?.("node_data_change", handleDataChange); mindmap.on?.("node_data_change", handleDataChange);
console.log("[MindmapBlock] Event listeners registered");
return () => { return () => {
console.log("[MindmapBlock] Cleaning up event listeners");
mindmap.off?.("data_change", handleDataChange); mindmap.off?.("data_change", handleDataChange);
mindmap.off?.("back_forward", handleDataChange); mindmap.off?.("back_forward", handleDataChange);
mindmap.off?.("node_data_change", handleDataChange); mindmap.off?.("node_data_change", handleDataChange);
@@ -2482,11 +2519,11 @@ const MindmapBlockView = ({
let displayUrl = url; let displayUrl = url;
if (url.startsWith("asset:")) { if (url.startsWith("asset:")) {
displayUrl = await resolveImageUrl(url); displayUrl = await resolveImageUrl(url);
// 记录映射关系供保存时使用 // 记录映射关系供保存/删除/撤销使用(无论是否签名成功)
if (displayUrl !== url) { const assetId = url.replace(/^asset:/, "").trim();
const assetId = url.replace("asset:", ""); if (assetId) {
signedUrlToAssetIdRef.current.set(displayUrl, assetId); signedUrlToAssetIdRef.current.set(displayUrl, assetId);
console.log("[MindmapBlock] New image mapped:", displayUrl.substring(0, 80), "-> Asset ID:", assetId); signedUrlToAssetIdRef.current.set(`asset:${assetId}`, assetId);
} }
} }
@@ -2511,7 +2548,6 @@ const MindmapBlockView = ({
if (newData) { if (newData) {
const newUrls = collectImageUrls(newData); const newUrls = collectImageUrls(newData);
currentImageUrlsRef.current = new Set(newUrls); currentImageUrlsRef.current = new Set(newUrls);
console.log("[MindmapBlock] Updated currentImageUrlsRef after insert:", newUrls);
} }
}, 100); }, 100);
}; };
@@ -2589,8 +2625,7 @@ const MindmapBlockView = ({
} }
window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .mmap / .md"); window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .mmap / .md");
} catch (error) { } catch {
console.error(error);
window.alert("导入失败:文件格式或内容错误"); window.alert("导入失败:文件格式或内容错误");
} finally { } finally {
reset(); reset();
@@ -2627,8 +2662,7 @@ const MindmapBlockView = ({
const handleExport = async (type: string, name = "mindmap") => { const handleExport = async (type: string, name = "mindmap") => {
try { try {
await mindmap?.doExport?.export(type, true, name); await mindmap?.doExport?.export(type, true, name);
} catch (error) { } catch {
console.error(error);
window.alert(`导出 ${type.toUpperCase()} 失败,请稍后再试`); window.alert(`导出 ${type.toUpperCase()} 失败,请稍后再试`);
} }
}; };
@@ -2861,6 +2895,9 @@ const MindmapBlockView = ({
form.append("file", file); form.append("file", file);
form.append("workspaceId", workspaceId); form.append("workspaceId", workspaceId);
form.append("documentId", docId); form.append("documentId", docId);
if (mindmapId) {
form.append("mindmapId", mindmapId);
}
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: form, body: form,
@@ -3001,24 +3038,10 @@ const MindmapBlockView = ({
tabIndex={0} tabIndex={0}
className="fixed inset-0 z-[9999] overflow-hidden bg-white" className="fixed inset-0 z-[9999] overflow-hidden bg-white"
> >
<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="fixed left-1/2 top-2 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
<div className="text-sm font-medium text-gray-700">
</div>
<button
type="button"
title="退出全屏"
className="rounded p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
onClick={exitLocalFullscreen}
>
<span className="text-lg leading-none">×</span>
</button>
</div>
<div className="fixed left-1/2 top-14 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
<MindmapToolbar {...toolbarProps} /> <MindmapToolbar {...toolbarProps} />
</div> </div>
<div className="relative h-full w-full pt-12"> <div className="relative h-full w-full pt-0">
<div <div
ref={containerRef} ref={containerRef}
className="h-full w-full" className="h-full w-full"
@@ -1802,12 +1802,13 @@ export const MindmapSidebar = ({
activeNodes={activeNodes} activeNodes={activeNodes}
documentId={documentId} documentId={documentId}
mindmapId={mindmapId} mindmapId={mindmapId}
onClose={onClose}
/> />
); );
default: default:
return null; return null;
} }
}, [activeTab, mindmap, activeNodes, documentId, mindmapId]); }, [activeTab, mindmap, activeNodes, documentId, mindmapId, onClose]);
const title = useMemo(() => { const title = useMemo(() => {
if (!activeTab) return ""; if (!activeTab) return "";
@@ -1820,15 +1821,17 @@ export const MindmapSidebar = ({
activeTab ? "translate-x-0" : "translate-x-full" activeTab ? "translate-x-0" : "translate-x-full"
}`} }`}
> >
{activeTab === "ai" ? null : (
<div className="flex items-center justify-between border-b px-4 py-3 shrink-0"> <div className="flex items-center justify-between border-b px-4 py-3 shrink-0">
<span className="font-medium text-gray-700">{title}</span> <span className="font-medium text-gray-700">{title}</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"> <button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{content}
</div> </div>
)}
<div className={activeTab === "ai" ? "flex-1 overflow-hidden p-0" : "flex-1 overflow-y-auto px-4 py-4"}>
{content}
</div>
</div> </div>
); );
}; };
@@ -0,0 +1,610 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Bot, Settings, Wrench, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
type AgentMessage = { role: "user" | "assistant"; content: string };
type ToolLog =
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "error"; message: string };
type PanelPage = "chat" | "tools" | "settings";
type AgentAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
const CHANNEL = "mnote_onlyoffice_agent_tools_v1";
const DEFAULT_MESSAGES: AgentMessage[] = [
{
role: "assistant",
content:
"你好,我是 OnlyOffice AI Agent。\n- 先选中一段文字,再说“改写/补全/翻译/润色/删除/插入”\n- 我会通过 oo_* 工具读取/替换选区\n- 需要引用资料时可联网检索或用 LightRAG/文档检索",
},
];
const ONLINE_MODELS = [
"",
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
] as const;
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => {
if (!res.body) throw new Error("响应不支持流式读取");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const sep = buffer.indexOf("\n\n");
if (sep === -1) break;
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
// 注释/心跳:以 ":" 开头
if (raw.trimStart().startsWith(":")) continue;
const lines = raw.split(/\r?\n/);
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message";
if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart());
}
onEvent(event, dataLines.join("\n"));
}
}
};
const blobToDataUrl = (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ""));
reader.onerror = () => reject(new Error("读取图片失败(FileReader"));
reader.readAsDataURL(blob);
});
export function OnlyOfficeAiAgentPanel({
openFile,
}: {
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
}) {
const [open, setOpen] = useState(false);
const [page, setPage] = useState<PanelPage>("chat");
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_MESSAGES);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
const [networkOn, setNetworkOn] = useState(true);
const [toolAuto, setToolAuto] = useState(true);
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
const [aiModel, setAiModel] = useState<string>("");
const [maxSteps, setMaxSteps] = useState<number>(10);
const [pluginReady, setPluginReady] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const pluginTargetRef = useRef<{ win: Window | null; origin: string }>({ win: null, origin: "*" });
const pendingPluginCallsRef = useRef<
Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timeoutId: number }>
>(new Map());
const attachments = useMemo<AgentAttachment[]>(
() => [
{
id: openFile.id,
title: openFile.title,
fileUrl: openFile.fileUrl,
mimeType: openFile.mimeType ?? null,
},
],
[openFile.fileUrl, openFile.id, openFile.mimeType, openFile.title],
);
useEffect(() => {
try {
const stepsRaw = window.localStorage.getItem("onlyoffice_ai_max_steps") || "";
const providerRaw = (window.localStorage.getItem("onlyoffice_ai_provider") || "").trim();
const modelRaw = window.localStorage.getItem("onlyoffice_ai_model") || "";
const parsed = Number(stepsRaw);
if (providerRaw === "online" || providerRaw === "local") setAiProvider(providerRaw);
if (typeof modelRaw === "string") setAiModel(modelRaw);
if (Number.isFinite(parsed) && parsed >= 1) setMaxSteps(clamp(Math.floor(parsed), 1, 24));
} catch {
// ignore
}
}, []);
useEffect(() => {
try {
window.localStorage.setItem("onlyoffice_ai_provider", aiProvider);
window.localStorage.setItem("onlyoffice_ai_model", aiModel);
window.localStorage.setItem("onlyoffice_ai_max_steps", String(maxSteps));
} catch {
// ignore
}
}, [aiProvider, aiModel, maxSteps]);
useEffect(() => {
const onMessage = (ev: MessageEvent) => {
const data = ev.data as unknown;
if (!isRecord(data)) return;
if (data.channel !== CHANNEL) return;
const type = String(data.type ?? "").trim();
if (type === "ready") {
// 记录插件窗口与来源,后续回发消息更稳
pluginTargetRef.current = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
win: (ev.source as any) && typeof (ev.source as any).postMessage === "function" ? ((ev.source as any) as Window) : null,
origin: String(ev.origin || "*"),
};
setPluginReady(true);
return;
}
if (type === "result") {
const callId = String(data.callId ?? "").trim();
if (!callId) return;
const pending = pendingPluginCallsRef.current.get(callId);
if (!pending) return;
pendingPluginCallsRef.current.delete(callId);
window.clearTimeout(pending.timeoutId);
const ok = Boolean(data.ok);
if (ok) {
pending.resolve("result" in data ? (data as Record<string, unknown>).result : null);
} else {
pending.reject(new Error(String((data as Record<string, unknown>).error ?? "插件执行失败")));
}
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
const callPlugin = async (callId: string, tool: string, args: Record<string, unknown>) => {
const target = pluginTargetRef.current;
if (!target.win) throw new Error("插件未就绪(未收到 ready),请稍等或刷新文档");
const payload = { channel: CHANNEL, type: "call", callId, tool, args };
const result = await new Promise<unknown>((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
pendingPluginCallsRef.current.delete(callId);
reject(new Error("插件调用超时"));
}, 60_000);
pendingPluginCallsRef.current.set(callId, { resolve, reject, timeoutId });
try {
target.win!.postMessage(payload, target.origin || "*");
} catch (e) {
window.clearTimeout(timeoutId);
pendingPluginCallsRef.current.delete(callId);
reject(e instanceof Error ? e : new Error(String(e)));
}
});
return result;
};
const postClientToolResult = async ({
requestId,
callId,
ok,
result,
error,
}: {
requestId: string;
callId: string;
ok: boolean;
result?: unknown;
error?: string;
}) => {
await fetch("/api/ai-agent/client-tool-result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestId, callId, ok, result, error }),
});
};
const resolveImageRefToDataUrl = async (imageRef: string) => {
const s = String(imageRef ?? "").trim();
if (!s) throw new Error("缺少 imageRef");
// 1) 优先当作附件 id
const match = attachments.find((a) => a.id === s) ?? null;
const url = match ? match.fileUrl : s;
const res = await fetch(url);
if (!res.ok) throw new Error(`图片下载失败:HTTP ${res.status}`);
const blob = await res.blob();
return await blobToDataUrl(blob);
};
const handleClientToolCall = async (payloadText: string) => {
let data: unknown = null;
try {
data = JSON.parse(payloadText || "null");
} catch {
return;
}
const obj = isRecord(data) ? data : ({} as Record<string, unknown>);
const requestId = String(obj.requestId ?? "").trim();
const callId = String(obj.callId ?? "").trim();
const tool = String(obj.tool ?? "").trim();
const args = isRecord(obj.args) ? (obj.args as Record<string, unknown>) : {};
if (!requestId || !callId || !tool) return;
try {
let result: unknown = null;
if (tool === "oo_insert_image") {
const imageRef = String(args.imageRef ?? "").trim();
const src = await resolveImageRefToDataUrl(imageRef);
const width = Number(args.width ?? 0);
const height = Number(args.height ?? 0);
result = await callPlugin(callId, tool, { ...args, src, width, height });
} else {
result = await callPlugin(callId, tool, args);
}
await postClientToolResult({ requestId, callId, ok: true, result });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
await postClientToolResult({ requestId, callId, ok: false, error: msg });
}
};
const stop = () => {
abortRef.current?.abort();
abortRef.current = null;
setLoading(false);
};
const send = async () => {
const text = input.trim();
if (!text) return;
if (loading) return;
const nextMessages: AgentMessage[] = [...messages, { role: "user", content: text }];
setMessages(nextMessages);
setInput("");
setLoading(true);
setToolLogs([]);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch("/api/ai-agent/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
stream: true,
maxSteps,
scope: "onlyoffice",
messages: nextMessages.slice(-20),
attachments,
toolChoice: {
mode: toolAuto ? "auto" : "manual",
toolSets: [
"toolset.readonly",
"toolset.rag_read",
"toolset.media_read",
"toolset.docs_read",
"toolset.onlyoffice_editor",
],
},
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel } },
}),
});
if (!res.ok) {
const j = (await res.json().catch(() => null)) as unknown;
const err =
typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
throw new Error(err || `HTTP ${res.status}`);
}
await parseSseChunks(res, (event, dataText) => {
if (event === "assistant_message") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const t = isRecord(d) && "text" in d ? String(d.text ?? "") : "";
if (t) setMessages((prev) => [...prev, { role: "assistant", content: t }]);
} catch {
// ignore
}
return;
}
if (event === "client_tool_call") {
void handleClientToolCall(dataText);
return;
}
if (event === "tool_call") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
setToolLogs((prev) => [
...prev,
{
type: "tool_call",
id: String(obj.id ?? ""),
tool: String(obj.tool ?? ""),
args: (isRecord(obj.args) ? obj.args : {}) as Record<string, unknown>,
},
]);
} catch {
// ignore
}
return;
}
if (event === "tool_result") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
setToolLogs((prev) => [
...prev,
{
type: "tool_result",
id: String(obj.id ?? ""),
tool: String(obj.tool ?? ""),
ok: Boolean(obj.ok),
ms: Number(obj.ms ?? 0),
result: "result" in obj ? obj.result : null,
},
]);
} catch {
// ignore
}
return;
}
if (event === "error") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const msg = isRecord(d) && "message" in d ? String(d.message ?? "") : "";
setToolLogs((prev) => [...prev, { type: "error", message: msg || "未知错误" }]);
} catch {
setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
}
return;
}
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
} finally {
abortRef.current = null;
setLoading(false);
}
};
const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]);
return (
<>
<div className="fixed bottom-4 right-4 z-[60]">
<Button
className="shadow"
onClick={() => {
setOpen((v) => !v);
if (!open) setPage("chat");
}}
>
<Bot className="mr-2 h-4 w-4" />
AI
</Button>
</div>
{open ? (
<div className="fixed right-0 top-0 z-[70] h-screen w-[420px] border-l bg-background shadow-xl">
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="text-sm font-semibold">OnlyOffice AI</div>
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-1 border-b px-2 py-2">
<Button
variant={page === "chat" ? "default" : "secondary"}
size="sm"
onClick={() => setPage("chat")}
>
<Bot className="mr-2 h-4 w-4" />
</Button>
<Button
variant={page === "tools" ? "default" : "secondary"}
size="sm"
onClick={() => setPage("tools")}
>
<Wrench className="mr-2 h-4 w-4" />
</Button>
<Button
variant={page === "settings" ? "default" : "secondary"}
size="sm"
onClick={() => setPage("settings")}
>
<Settings className="mr-2 h-4 w-4" />
</Button>
</div>
{page === "tools" ? (
<div className="space-y-3 p-3 text-sm">
<div className="flex items-center justify-between">
<div className="font-medium"></div>
<input
type="checkbox"
checked={networkOn}
onChange={(e) => setNetworkOn(e.target.checked)}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between">
<div className="font-medium"></div>
<input
type="checkbox"
checked={toolAuto}
onChange={(e) => setToolAuto(e.target.checked)}
disabled={loading}
/>
</div>
<div className="rounded border p-2 text-xs text-muted-foreground">
<div>{pluginReady ? "已连接" : "未连接(等待 ready"}</div>
<div>oo_* </div>
</div>
<details open className="rounded border p-2">
<summary className="cursor-pointer select-none text-sm font-medium"></summary>
<div className="mt-2 space-y-2">
{toolLogs.length === 0 ? <div className="text-muted-foreground"></div> : null}
{toolLogs.map((l, idx) => {
if (l.type === "error") {
return (
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<div key={idx} className="rounded border p-2">
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
<div className="font-medium">{l.tool}</div>
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
</div>
);
}
return (
<div key={idx} className="rounded border p-2">
<div className="text-xs text-muted-foreground">
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
</div>
<div className="font-medium">{l.tool}</div>
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
</div>
);
})}
</div>
</details>
</div>
) : null}
{page === "settings" ? (
<div className="space-y-3 p-3 text-sm">
<label className="flex items-center justify-between gap-2">
<span className="font-medium"></span>
<input
className="w-[96px] rounded border px-2 py-1 text-xs"
type="number"
min={1}
max={24}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(clamp(Math.floor(v), 1, 24));
}}
disabled={loading}
/>
</label>
<label className="flex items-center justify-between gap-2">
<span className="font-medium">AI </span>
<select
className="rounded border px-2 py-1 text-xs"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
</select>
</label>
<label className="flex items-center justify-between gap-2">
<span className="font-medium"></span>
<select
className="w-[220px] rounded border px-2 py-1 text-xs"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading || aiProvider !== "online"}
>
{ONLINE_MODELS.map((m) => (
<option key={m} value={m}>
{m || "默认"}
</option>
))}
</select>
</label>
</div>
) : null}
{page === "chat" ? (
<div className="flex h-[calc(100vh-96px)] flex-col">
<ScrollArea className="flex-1">
<div className="space-y-3 p-3 text-sm">
{messages.map((m, idx) => (
<div key={idx} className="space-y-1">
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
<div className="whitespace-pre-wrap">{m.content}</div>
</div>
))}
</div>
</ScrollArea>
<div className="border-t p-3">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入你的需求(Enter 发送,Shift+Enter 换行)"
className="min-h-[72px] flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSend) void send();
}
}}
/>
<div className="flex flex-col gap-2">
<Button disabled={!canSend} onClick={() => void send()}>
</Button>
<Button variant="secondary" disabled={!loading} onClick={stop}>
</Button>
</div>
</div>
</div>
</div>
) : null}
</div>
) : null}
</>
);
}
@@ -17,9 +17,10 @@ interface FileTreeProps {
onRowContextMenu: (row: FileTreeRow, event: React.MouseEvent) => void; onRowContextMenu: (row: FileTreeRow, event: React.MouseEvent) => void;
onRowDragStart?: (row: FileTreeRow, event: React.DragEvent) => void; onRowDragStart?: (row: FileTreeRow, event: React.DragEvent) => void;
onToggleExpand: (docId: string) => void; onToggleExpand: (docId: string) => void;
onToggleAssetFolderExpand?: (assetId: string) => void;
onCreateChild: (parentId: string | null) => void; onCreateChild: (parentId: string | null) => void;
onBlankMouseDown?: (event: React.MouseEvent) => void; onBlankMouseDown?: (event: React.MouseEvent) => void;
onDropFiles?: (docId: string, files: FileList) => void; onDropFiles?: (docId: string, files: FileList, targetRow?: FileTreeRow) => void;
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void; onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
} }
@@ -34,6 +35,7 @@ export function FileTree({
onRowContextMenu, onRowContextMenu,
onRowDragStart, onRowDragStart,
onToggleExpand, onToggleExpand,
onToggleAssetFolderExpand,
onCreateChild, onCreateChild,
onBlankMouseDown, onBlankMouseDown,
onDropFiles, onDropFiles,
@@ -53,7 +55,7 @@ export function FileTree({
// 标记为 drop feedback(包含它的所有可见子节点)。我们用“扁平化 rows + // 标记为 drop feedback(包含它的所有可见子节点)。我们用“扁平化 rows +
// depth”来近似计算该范围。 // depth”来近似计算该范围。
let endIndex = startIndex + 1; let endIndex = startIndex + 1;
if (target.kind === "doc" && target.isExpanded && target.hasChildren) { if ((target.kind === "doc" || target.kind === "asset-folder") && target.isExpanded && target.hasChildren) {
while (endIndex < rows.length && rows[endIndex].depth > targetDepth) { while (endIndex < rows.length && rows[endIndex].depth > targetDepth) {
endIndex += 1; endIndex += 1;
} }
@@ -210,7 +212,7 @@ export function FileTree({
if (onDropFiles && event.dataTransfer.files?.length) { if (onDropFiles && event.dataTransfer.files?.length) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onDropFiles(row.docId, event.dataTransfer.files); onDropFiles(row.docId, event.dataTransfer.files, row);
} }
}} }}
role="button" role="button"
@@ -258,6 +260,31 @@ export function FileTree({
<FileText className="h-4 w-4 text-gray-500" /> <FileText className="h-4 w-4 text-gray-500" />
<span className="min-w-0 flex-1 truncate text-left">{label}</span> <span className="min-w-0 flex-1 truncate text-left">{label}</span>
</> </>
) : row.kind === "asset-folder" ? (
<>
{row.hasChildren ? (
<button
type="button"
aria-label="展开或折叠"
onClick={(event) => {
event.stopPropagation();
onToggleAssetFolderExpand?.(row.asset.id);
}}
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
row.isExpanded && "rotate-90",
)}
/>
</button>
) : (
<span className="h-5 w-5" />
)}
<Folder className="h-4 w-4 text-[#2563eb]" />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
</>
) : ( ) : (
<> <>
<span className="h-4 w-4" /> <span className="h-4 w-4" />
+221 -44
View File
@@ -6,10 +6,8 @@ import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { import {
ArrowRightLeft, ArrowRightLeft,
ArrowUpRight, ArrowUpRight,
Bell,
ChevronRight, ChevronRight,
Copy, Copy,
Dice5,
Edit3, Edit3,
GitMerge, GitMerge,
Globe, Globe,
@@ -19,7 +17,6 @@ import {
Link as LinkIcon, Link as LinkIcon,
MoreHorizontal, MoreHorizontal,
PanelRightOpen, PanelRightOpen,
PenSquare,
Plus, Plus,
Search as SearchIcon, Search as SearchIcon,
Share2, Share2,
@@ -66,10 +63,10 @@ const TOP_BUTTONS = [
{ id: "graph", icon: Share2, label: "关系图" }, { id: "graph", icon: Share2, label: "关系图" },
{ id: "import", icon: Upload, label: "导入" }, { id: "import", icon: Upload, label: "导入" },
{ id: "members", icon: Users, label: "成员" }, { id: "members", icon: Users, label: "成员" },
{ id: "inbox", icon: Bell, label: "消息箱" }, { id: "starred", icon: Star, label: "星标置顶" },
{ id: "quick-note", icon: PenSquare, label: "今日速记" }, { id: "public", icon: Globe, label: "公共页面" },
{ id: "lucky", icon: Dice5, label: "手气不错" }, { id: "shared", icon: Shield, label: "共享页面" },
{ id: "more", icon: MoreHorizontal, label: "更多" }, { id: "templates", icon: LayoutGrid, label: "模板中心" },
] as const; ] as const;
const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNode> = { const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNode> = {
@@ -79,6 +76,29 @@ const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNod
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />, templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
}; };
const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/");
const extractMindmapIdFromStoragePath = (
storagePath: string | null | undefined,
): string | null => {
if (!storagePath) return null;
const normalized = normalizeStoragePath(storagePath);
const prefix = "mindmaps/";
if (normalized.startsWith(prefix)) {
const rest = normalized.slice(prefix.length);
const id = rest.split("/")[0];
return id ? id : null;
}
const marker = "/mindmaps/";
const idx = normalized.indexOf(marker);
if (idx === -1) return null;
const rest = normalized.slice(idx + marker.length);
const id = rest.split("/")[0];
return id ? id : null;
};
interface SidebarProps { interface SidebarProps {
initialData: SidebarInitialData; initialData: SidebarInitialData;
} }
@@ -90,8 +110,11 @@ interface ContextMenuState {
} }
export function Sidebar({ initialData }: SidebarProps) { export function Sidebar({ initialData }: SidebarProps) {
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } = const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
useSidebarStore(); useSidebarStore();
const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
const viewMode = useSidebarStore((state) => state.viewMode); const viewMode = useSidebarStore((state) => state.viewMode);
const setViewMode = useSidebarStore((state) => state.setViewMode); const setViewMode = useSidebarStore((state) => state.setViewMode);
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch); const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
@@ -274,9 +297,67 @@ export function Sidebar({ initialData }: SidebarProps) {
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword)); return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, trashSearch]); }, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, trashSearch]);
const mindmapChildrenSnapshot = useMemo(() => {
const mapping = sidebarData.mindmapAssetChildren ?? {};
const mindmapDocById = new Map<string, string>(
(mindmapAssets ?? [])
.filter((asset) => asset.asset_type === "mindmap")
.map((asset) => [asset.id, asset.document_id]),
);
const mindmapIds = new Set(mindmapDocById.keys());
const mediaById = new Map<string, MediaAsset>(
(mediaAssets ?? []).map((asset) => [asset.id, asset]),
);
const childAssetsByMindmapId: Record<string, MediaAsset[]> = {};
const childIds = new Set<string>();
const assigned = new Set<string>();
// 物理目录:storage_path 归属到 mindmaps/<mindmapId>/ 的附件,作为导图文件夹内容
(mediaAssets ?? []).forEach((asset) => {
const sp = asset.storage_path;
if (!sp || typeof sp !== "string") return;
const mindmapId = extractMindmapIdFromStoragePath(sp);
if (!mindmapId) return;
if (!mindmapIds.has(mindmapId)) return;
const docId = mindmapDocById.get(mindmapId);
if (docId && asset.document_id !== docId) return;
if (assigned.has(asset.id)) return;
assigned.add(asset.id);
childIds.add(asset.id);
if (!childAssetsByMindmapId[mindmapId]) childAssetsByMindmapId[mindmapId] = [];
childAssetsByMindmapId[mindmapId].push(asset);
});
// 引用图片:从 mindmap JSON 解析出的 assetIds,也放到导图文件夹下(去重)
(mindmapAssets ?? []).forEach((mindmapAsset) => {
const ids = mapping[mindmapAsset.id] ?? [];
if (!Array.isArray(ids) || ids.length === 0) return;
ids.forEach((id) => {
const asset = mediaById.get(id);
if (!asset) return;
if (asset.document_id !== mindmapAsset.document_id) return;
if (assigned.has(asset.id)) return;
assigned.add(asset.id);
childIds.add(asset.id);
if (!childAssetsByMindmapId[mindmapAsset.id]) childAssetsByMindmapId[mindmapAsset.id] = [];
childAssetsByMindmapId[mindmapAsset.id].push(asset);
});
});
return { childAssetsByMindmapId, childIds };
}, [mediaAssets, mindmapAssets, sidebarData.mindmapAssetChildren]);
const assetsByDoc = useMemo(() => { const assetsByDoc = useMemo(() => {
const map: Record<string, MediaAsset[]> = {}; const map: Record<string, MediaAsset[]> = {};
const assets = [...(mediaAssets ?? []), ...(mindmapAssets ?? []), ...(tableAssets ?? [])]; const assets = [
...((mediaAssets ?? []).filter(
(asset) => !mindmapChildrenSnapshot.childIds.has(asset.id),
)),
...(mindmapAssets ?? []),
...(tableAssets ?? []),
];
assets.forEach((asset) => { assets.forEach((asset) => {
if (!map[asset.document_id]) { if (!map[asset.document_id]) {
@@ -288,7 +369,9 @@ export function Sidebar({ initialData }: SidebarProps) {
} }
}); });
return map; return map;
}, [mediaAssets, mindmapAssets, tableAssets]); }, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
const fileTreeRows = useMemo( const fileTreeRows = useMemo(
() => () =>
@@ -296,8 +379,10 @@ export function Sidebar({ initialData }: SidebarProps) {
nodes: filteredPrivateTree, nodes: filteredPrivateTree,
expanded, expanded,
assetsByDoc, assetsByDoc,
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
expandedAssetFolderIds: expandedAssetFolders,
}), }),
[assetsByDoc, expanded, filteredPrivateTree], [assetsByDoc, expanded, expandedAssetFolders, filteredPrivateTree, mindmapChildrenSnapshot.childAssetsByMindmapId],
); );
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]); const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
@@ -406,6 +491,29 @@ export function Sidebar({ initialData }: SidebarProps) {
}); });
}, []); }, []);
const knownMindmapFolderIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
setExpandedAssetFolders((prev) => {
const next = new Set(prev);
(mindmapAssets ?? []).forEach((asset) => {
if (asset.asset_type !== "mindmap") return;
if (knownMindmapFolderIdsRef.current.has(asset.id)) return;
knownMindmapFolderIdsRef.current.add(asset.id);
next.add(asset.id);
});
return next;
});
}, [mindmapAssets]);
const toggleAssetFolderExpand = useCallback((assetId: string) => {
setExpandedAssetFolders((prev) => {
const next = new Set(prev);
if (next.has(assetId)) next.delete(assetId);
else next.add(assetId);
return next;
});
}, []);
const handleOpenAsset = useCallback((asset: MediaAsset) => { const handleOpenAsset = useCallback((asset: MediaAsset) => {
if (asset.asset_type === "mindmap") { if (asset.asset_type === "mindmap") {
router.push(`/mindmap/${asset.document_id}/${asset.id}`); router.push(`/mindmap/${asset.document_id}/${asset.id}`);
@@ -480,7 +588,7 @@ export function Sidebar({ initialData }: SidebarProps) {
const handleFileTreeRowDoubleClick = useCallback( const handleFileTreeRowDoubleClick = useCallback(
(row: FileTreeRow, _event?: React.MouseEvent) => { (row: FileTreeRow, _event?: React.MouseEvent) => {
if (row.kind === "asset") { if (row.kind === "asset" || row.kind === "asset-folder") {
handleOpenAsset(row.asset); handleOpenAsset(row.asset);
return; return;
} }
@@ -497,7 +605,7 @@ export function Sidebar({ initialData }: SidebarProps) {
reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }), reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }),
); );
if (row.kind === "asset") { if (row.kind === "asset" || row.kind === "asset-folder") {
setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY }); setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY });
return; return;
} }
@@ -1052,11 +1160,26 @@ export function Sidebar({ initialData }: SidebarProps) {
); );
const handleFileTreeDropFiles = useCallback( const handleFileTreeDropFiles = useCallback(
(docId: string, files: FileList) => { (docId: string, files: FileList, targetRow?: FileTreeRow) => {
void (async () => { void (async () => {
const droppedFiles = Array.from(files ?? []); const droppedFiles = Array.from(files ?? []);
if (droppedFiles.length === 0) return; if (droppedFiles.length === 0) return;
const targetMindmapId = (() => {
if (!targetRow) return null;
if (targetRow.kind === "asset-folder" && targetRow.asset.asset_type === "mindmap") {
return targetRow.asset.id;
}
if (targetRow.kind === "asset") {
return extractMindmapIdFromStoragePath(targetRow.asset.storage_path);
}
return null;
})();
if (targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
}
const inferredTargetDocId = const inferredTargetDocId =
docId || docId ||
inferPasteTargetDocId({ inferPasteTargetDocId({
@@ -1085,6 +1208,9 @@ export function Sidebar({ initialData }: SidebarProps) {
form.append("file", file); form.append("file", file);
form.append("workspaceId", workspaceId); form.append("workspaceId", workspaceId);
form.append("documentId", inferredTargetDocId); form.append("documentId", inferredTargetDocId);
if (targetMindmapId) {
form.append("mindmapId", targetMindmapId);
}
const resp = await fetch("/api/media/upload", { method: "POST", body: form }); const resp = await fetch("/api/media/upload", { method: "POST", body: form });
if (!resp.ok) { if (!resp.ok) {
const payload = await resp.json().catch(() => ({})); const payload = await resp.json().catch(() => ({}));
@@ -1095,7 +1221,7 @@ export function Sidebar({ initialData }: SidebarProps) {
if (payload.asset?.id) { if (payload.asset?.id) {
emitAssetsChanged(inferredTargetDocId, payload.asset); emitAssetsChanged(inferredTargetDocId, payload.asset);
// 拖拽文件进文件树:如果落点就是当前打开的页面,则把文件也插入到主编辑区 // 拖拽文件进文件树:如果落点就是当前打开的页面,则把文件也插入到主编辑区
if (inferredTargetDocId === activeId) { if (inferredTargetDocId === activeId && !targetMindmapId) {
editorBridge?.insertMediaAsset?.(payload.asset); editorBridge?.insertMediaAsset?.(payload.asset);
} }
} else { } else {
@@ -1140,6 +1266,22 @@ export function Sidebar({ initialData }: SidebarProps) {
return; return;
} }
const targetMindmapId = (() => {
if (args.targetRow.kind === "asset-folder" && args.targetRow.asset.asset_type === "mindmap") {
return args.targetRow.asset.id;
}
if (args.targetRow.kind === "asset") {
return extractMindmapIdFromStoragePath(args.targetRow.asset.storage_path);
}
return null;
})();
const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined;
if (targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
}
const uniqueRowIds: string[] = []; const uniqueRowIds: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
args.rowIds.forEach((id) => { args.rowIds.forEach((id) => {
@@ -1188,6 +1330,7 @@ export function Sidebar({ initialData }: SidebarProps) {
action: "copy", action: "copy",
assetIds: copyableAssetIds, assetIds: copyableAssetIds,
targetDocumentId: targetDocId, targetDocumentId: targetDocId,
targetSubPath,
}), }),
}); });
if (!resp.ok) { if (!resp.ok) {
@@ -1248,6 +1391,7 @@ export function Sidebar({ initialData }: SidebarProps) {
action: "move", action: "move",
assetIds: copyableAssetIds, assetIds: copyableAssetIds,
targetDocumentId: targetDocId, targetDocumentId: targetDocId,
targetSubPath,
}), }),
}); });
if (!resp.ok) { if (!resp.ok) {
@@ -1533,9 +1677,20 @@ export function Sidebar({ initialData }: SidebarProps) {
openSearchPalette(); openSearchPalette();
return; return;
} }
if (
buttonId === "starred" ||
buttonId === "public" ||
buttonId === "shared" ||
buttonId === "templates"
) {
setViewMode("section");
setSectionsTrayOpen(true);
setSectionCollapsed(buttonId, false);
return;
}
window.alert("该功能即将上线,敬请期待"); window.alert("该功能即将上线,敬请期待");
}, },
[openSearchPalette], [openSearchPalette, setSectionCollapsed, setSectionsTrayOpen, setViewMode],
); );
useEffect(() => { useEffect(() => {
@@ -1666,34 +1821,55 @@ export function Sidebar({ initialData }: SidebarProps) {
{viewMode === "section" ? ( {viewMode === "section" ? (
<> <>
<SectionList <button
label="星标置顶" type="button"
icon={SECTION_ICONS.starred} className="flex w-full items-center justify-between border-b border-[#f1f1f1] px-4 py-3 text-sm font-medium text-gray-600 hover:bg-gray-50"
nodes={starredNodes} onClick={toggleSectionsTray}
collapsed={collapsedSections.starred} >
onToggle={() => toggleSection("starred")} <span className="flex items-center gap-2">
/> <Star className="h-4 w-4 text-[#f5a623]" />
<SectionList / / /
label="公共页面" </span>
icon={SECTION_ICONS.public} <ChevronRight
nodes={publicNodes} className={cn(
collapsed={collapsedSections.public} "h-4 w-4 text-gray-400 transition-transform",
onToggle={() => toggleSection("public")} sectionsTrayOpen && "rotate-90",
/> )}
<SectionList />
label="共享页面" </button>
icon={SECTION_ICONS.shared}
nodes={sharedNodes} {sectionsTrayOpen ? (
collapsed={collapsedSections.shared} <>
onToggle={() => toggleSection("shared")} <SectionList
/> label="星标置顶"
<SectionList icon={SECTION_ICONS.starred}
label="模板中心" nodes={starredNodes}
icon={SECTION_ICONS.templates} collapsed={collapsedSections.starred}
nodes={templateNodes} onToggle={() => toggleSection("starred")}
collapsed={collapsedSections.templates} />
onToggle={() => toggleSection("templates")} <SectionList
/> label="公共页面"
icon={SECTION_ICONS.public}
nodes={publicNodes}
collapsed={collapsedSections.public}
onToggle={() => toggleSection("public")}
/>
<SectionList
label="共享页面"
icon={SECTION_ICONS.shared}
nodes={sharedNodes}
collapsed={collapsedSections.shared}
onToggle={() => toggleSection("shared")}
/>
<SectionList
label="模板中心"
icon={SECTION_ICONS.templates}
nodes={templateNodes}
collapsed={collapsedSections.templates}
onToggle={() => toggleSection("templates")}
/>
</>
) : null}
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]"> <div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<button <button
@@ -1740,6 +1916,7 @@ export function Sidebar({ initialData }: SidebarProps) {
onRowContextMenu={handleFileTreeRowContextMenu} onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)} onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand} onToggleExpand={toggleExpand}
onToggleAssetFolderExpand={toggleAssetFolderExpand}
onCreateChild={handleCreate} onCreateChild={handleCreate}
onBlankMouseDown={handleFileTreeBlankMouseDown} onBlankMouseDown={handleFileTreeBlankMouseDown}
onDropFiles={handleFileTreeDropFiles} onDropFiles={handleFileTreeDropFiles}
@@ -32,6 +32,11 @@ export interface SidebarInitialData {
* 思维导图文件列表(用于文件树显示,多导图时一页可有多个) * 思维导图文件列表(用于文件树显示,多导图时一页可有多个)
*/ */
mindmapAssets?: MediaAsset[]; mindmapAssets?: MediaAsset[];
/**
* 思维导图(mindmapAssets)引用的图片附件:mindmapId -> media_asset ids
* 用于在文件树中把“导图图片”显示在对应 mindmap 文件下一级。
*/
mindmapAssetChildren?: Record<string, string[]>;
/** /**
* 可选的媒体资源列表(旧字段向后兼容) * 可选的媒体资源列表(旧字段向后兼容)
*/ */
+8 -4
View File
@@ -48,9 +48,11 @@ function SheetContent({
className, className,
children, children,
side = "right", side = "right",
showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@@ -72,10 +74,12 @@ function SheetContent({
{...props} {...props}
> >
{children} {children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> {showCloseButton ? (
<XIcon className="size-4" /> <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<span className="sr-only">Close</span> <XIcon className="size-4" />
</SheetPrimitive.Close> <span className="sr-only">Close</span>
</SheetPrimitive.Close>
) : null}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) )
@@ -0,0 +1,74 @@
export type ClientToolResult =
| { ok: true; result: unknown }
| { ok: false; error: string };
type PendingClientTool = {
userId: string;
createdAt: number;
timeoutId: ReturnType<typeof setTimeout>;
resolve: (v: ClientToolResult) => void;
};
const nowMs = () => Date.now();
// 说明:这是一个“进程内”桥接(仅用于本地/单实例)。
// 若未来部署到多实例/Serverless,需要替换为 Redis / Realtime / WebSocket 等可共享通道。
const pendingCalls: Map<string, PendingClientTool> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((globalThis as any).__mnote_pending_client_tool_calls as Map<string, PendingClientTool> | undefined) ??
new Map<string, PendingClientTool>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).__mnote_pending_client_tool_calls = pendingCalls;
export const buildClientToolKey = (requestId: string, callId: string) =>
`${String(requestId)}:${String(callId)}`;
export const registerClientToolCall = ({
key,
userId,
timeoutMs = 60_000,
}: {
key: string;
userId: string;
timeoutMs?: number;
}): Promise<ClientToolResult> => {
const existing = pendingCalls.get(key);
if (existing) {
// 同一个 call 只能注册一次,避免重复等待导致难以清理。
return Promise.resolve({ ok: false, error: "客户端工具调用已注册(重复注册)" });
}
return new Promise<ClientToolResult>((resolve) => {
const timeoutId = setTimeout(() => {
pendingCalls.delete(key);
resolve({ ok: false, error: "客户端工具执行超时(未收到回调)" });
}, Math.max(1_000, timeoutMs));
pendingCalls.set(key, {
userId,
createdAt: nowMs(),
timeoutId,
resolve,
});
});
};
export const resolveClientToolCall = ({
key,
userId,
result,
}: {
key: string;
userId: string;
result: ClientToolResult;
}): { ok: true } | { ok: false; error: string } => {
const pending = pendingCalls.get(key);
if (!pending) return { ok: false, error: "未找到待回调的客户端工具调用(可能已超时)" };
if (pending.userId !== userId) return { ok: false, error: "无权限回调该工具调用" };
clearTimeout(pending.timeoutId);
pendingCalls.delete(key);
pending.resolve(result);
return { ok: true };
};
@@ -30,6 +30,7 @@ const safeParseJsonObject = (raw: string): Record<string, unknown> | null => {
const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string) => { const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string) => {
const toolLines: string[] = []; const toolLines: string[] = [];
const guideLines: string[] = [];
if (allowedTools.has("search_web")) { if (allowedTools.has("search_web")) {
toolLines.push("- search_web<search_web>{\"query\":\"...\",\"count\":6}</search_web>"); toolLines.push("- search_web<search_web>{\"query\":\"...\",\"count\":6}</search_web>");
} }
@@ -47,6 +48,45 @@ const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string
if (allowedTools.has("image_read")) { if (allowedTools.has("image_read")) {
toolLines.push("- image_read<image_read>{\"attachmentRef\":\"...\"}</image_read>"); toolLines.push("- image_read<image_read>{\"attachmentRef\":\"...\"}</image_read>");
} }
if (allowedTools.has("asset_extract_outline")) {
toolLines.push(
"- asset_extract_outline<asset_extract_outline>{\"attachmentRef\":\"...\"}</asset_extract_outline>",
);
}
if (allowedTools.has("asset_to_mindmap")) {
toolLines.push(
"- asset_to_mindmap<asset_to_mindmap>{\"mindmapId\":\"...\",\"assetId\":\"...\",\"maxItems\":120,\"reason\":\"...\"}</asset_to_mindmap>",
);
}
if (allowedTools.has("oo_get_selection")) {
toolLines.push("- oo_get_selection<oo_get_selection>{\"format\":\"text\"}</oo_get_selection>");
}
if (allowedTools.has("oo_replace_selection")) {
toolLines.push(
"- oo_replace_selection<oo_replace_selection>{\"text\":\"...\",\"format\":\"text\",\"reason\":\"...\"}</oo_replace_selection>",
);
}
if (allowedTools.has("oo_insert_text")) {
toolLines.push("- oo_insert_text<oo_insert_text>{\"text\":\"...\",\"reason\":\"...\"}</oo_insert_text>");
}
if (allowedTools.has("oo_insert_html")) {
toolLines.push("- oo_insert_html<oo_insert_html>{\"html\":\"<p>...</p>\",\"reason\":\"...\"}</oo_insert_html>");
}
if (allowedTools.has("oo_insert_image")) {
toolLines.push(
"- oo_insert_image<oo_insert_image>{\"imageRef\":\"...\",\"width\":320,\"height\":180,\"reason\":\"...\"}</oo_insert_image>",
);
}
if (
allowedTools.has("oo_get_selection") ||
allowedTools.has("oo_replace_selection") ||
allowedTools.has("oo_insert_text") ||
allowedTools.has("oo_insert_html") ||
allowedTools.has("oo_insert_image")
) {
guideLines.push("- OnlyOffice 增/删/改:先 oo_get_selection 读选区,再用 oo_replace_selection / oo_insert_* 写回。");
guideLines.push("- oo_* 属于客户端插件工具:会直接修改当前 OnlyOffice 文档的选区/光标位置。");
}
if (allowedTools.has("slash_run")) { if (allowedTools.has("slash_run")) {
toolLines.push("- slash_run<slash_run>{\"text\":\"/new 新页面标题\"}</slash_run>"); toolLines.push("- slash_run<slash_run>{\"text\":\"/new 新页面标题\"}</slash_run>");
} }
@@ -113,8 +153,6 @@ const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string
if (allowedTools.has("mindmap_expand_node")) { if (allowedTools.has("mindmap_expand_node")) {
toolLines.push("- mindmap_expand_node<mindmap_expand_node>{\"targetUid\":\"...\",\"instruction\":\"...\"}</mindmap_expand_node>"); toolLines.push("- mindmap_expand_node<mindmap_expand_node>{\"targetUid\":\"...\",\"instruction\":\"...\"}</mindmap_expand_node>");
} }
const guideLines: string[] = [];
guideLines.push("- 先用只读工具定位(需要时再检索),再做最小范围写入。"); guideLines.push("- 先用只读工具定位(需要时再检索),再做最小范围写入。");
if (allowedTools.has("search_web")) { if (allowedTools.has("search_web")) {
guideLines.push("- 需要来源时先 search_web,再把 URL 放进最终回答或写入引用字段。"); guideLines.push("- 需要来源时先 search_web,再把 URL 放进最终回答或写入引用字段。");
@@ -128,6 +166,10 @@ const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string
if (allowedTools.has("image_read")) { if (allowedTools.has("image_read")) {
guideLines.push("- 需要读图:用 image_read 从 media_assets.ocr_text 获取文字(attachmentRef 可用附件 id/title/url 片段)。"); guideLines.push("- 需要读图:用 image_read 从 media_assets.ocr_text 获取文字(attachmentRef 可用附件 id/title/url 片段)。");
} }
if (allowedTools.has("asset_extract_outline") || allowedTools.has("asset_to_mindmap")) {
guideLines.push("- PDF→导图(M3):先 asset_extract_outline 提取标题层级/页码,再根据需要调用 asset_to_mindmap 落盘到指定 mindmap。");
guideLines.push("- 注意:asset_to_mindmap 是写工具,只有在用户明确要求“生成/写入思维导图”时才调用。");
}
if (allowedTools.has("slash_run")) { if (allowedTools.has("slash_run")) {
guideLines.push("- 需要创建/改名:用 slash_run 执行 /new 或 /rename(这是写工具,只有在用户明确要求时才调用)。"); guideLines.push("- 需要创建/改名:用 slash_run 执行 /new 或 /rename(这是写工具,只有在用户明确要求时才调用)。");
} }
@@ -160,6 +202,18 @@ const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string
if (allowedTools.has("slash_run")) { if (allowedTools.has("slash_run")) {
hardRules.push("- slash_run 属于写工具:只有在用户明确要求“创建/改名/执行斜杠命令”时才调用;否则不要擅自创建新文档。"); hardRules.push("- slash_run 属于写工具:只有在用户明确要求“创建/改名/执行斜杠命令”时才调用;否则不要擅自创建新文档。");
} }
if (allowedTools.has("asset_to_mindmap")) {
hardRules.push("- asset_to_mindmap 属于写工具:只有在用户明确要求“从附件生成/写入思维导图”时才调用;否则不要擅自写入导图。");
}
if (
allowedTools.has("oo_replace_selection") ||
allowedTools.has("oo_insert_text") ||
allowedTools.has("oo_insert_html") ||
allowedTools.has("oo_insert_image")
) {
hardRules.push("- oo_* 属于写工具:只有在用户明确要求“修改/补全/插入 OnlyOffice 文档内容”时才调用;否则不要擅自改文档。");
hardRules.push("- 删除选区:用 oo_replace_selection 且 text 传空字符串。");
}
if (allowedTools.has("mindmap_apply_ops") || allowedTools.has("mindmap_expand_node")) { if (allowedTools.has("mindmap_apply_ops") || allowedTools.has("mindmap_expand_node")) {
hardRules.push("- 涉及思维导图写入时:只能通过 mindmap_* 写工具落盘;不要输出整棵树覆盖。"); hardRules.push("- 涉及思维导图写入时:只能通过 mindmap_* 写工具落盘;不要输出整棵树覆盖。");
} }
@@ -0,0 +1,384 @@
import supabaseAdmin from "@/lib/supabase/admin";
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
type SupabaseRouteClient = {
from: (table: string) => any;
};
export type OnlyOfficeSupabaseClient = SupabaseRouteClient;
export type OnlyOfficeToolContext = {
userId: string;
documentId?: string;
// 来自前端(@ 选择/上传)的附件列表,优先使用(避免额外查询)
attachments?: Array<{ id: string; title: string; fileUrl: string; mimeType?: string | null }>;
};
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
type ResolvedAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
const resolveAttachment = (ctx: OnlyOfficeToolContext, ref: string): ResolvedAttachment | null => {
const s = String(ref || "").trim();
if (!s) return null;
const list = Array.isArray(ctx.attachments) ? ctx.attachments : [];
const byId = list.find((a) => String(a.id) === s);
if (byId) return { id: String(byId.id), title: String(byId.title || byId.id), fileUrl: String(byId.fileUrl || ""), mimeType: byId.mimeType ?? null };
const exactTitle = list.find((a) => String(a.title) === s);
if (exactTitle)
return { id: String(exactTitle.id), title: String(exactTitle.title || exactTitle.id), fileUrl: String(exactTitle.fileUrl || ""), mimeType: exactTitle.mimeType ?? null };
const fuzzy = list.find((a) => String(a.title || "").includes(s) || String(a.fileUrl || "").includes(s));
if (fuzzy) return { id: String(fuzzy.id), title: String(fuzzy.title || fuzzy.id), fileUrl: String(fuzzy.fileUrl || ""), mimeType: fuzzy.mimeType ?? null };
return null;
};
const parseStoragePath = (fileUrl: string) => {
try {
const url = new URL(fileUrl);
const segments = url.pathname.split("/").filter(Boolean);
const objectIdx = segments.findIndex((seg) => seg === "object");
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
if (segments[objectIdx + 1] === "public") {
const bucket = segments[objectIdx + 2];
const path = segments.slice(objectIdx + 3).join("/");
return { bucket, path };
}
if (segments[objectIdx + 1] === "sign") {
const bucket = segments[objectIdx + 2];
const path = segments.slice(objectIdx + 3).join("/");
return { bucket, path };
}
return null;
} catch {
return null;
}
};
const guessExt = (fileNameOrUrl: string) => {
const s = String(fileNameOrUrl || "").trim();
if (!s) return "";
const cleaned = s.split("?")[0].split("#")[0];
const parts = cleaned.split(".");
if (parts.length < 2) return "";
return String(parts[parts.length - 1] || "").toLowerCase();
};
const isPdf = (mimeType: string, fileName: string) => {
const m = String(mimeType || "").toLowerCase();
if (m.includes("pdf")) return true;
return guessExt(fileName) === "pdf";
};
const signForDownload = async (row: Record<string, unknown>) => {
const bucket = typeof row.bucket === "string" ? row.bucket : "";
const storagePath = typeof row.storage_path === "string" ? row.storage_path : "";
const fileUrl = typeof row.file_url === "string" ? row.file_url : "";
const fileName = typeof row.file_name === "string" ? row.file_name : undefined;
if (bucket && storagePath) {
const { data, error } = await supabaseAdmin.storage.from(bucket).createSignedUrl(storagePath, 60 * 60, { download: fileName });
if (error || !data?.signedUrl) throw new Error(error?.message ?? "生成签名 URL 失败");
return data.signedUrl;
}
const parsed = fileUrl ? parseStoragePath(fileUrl) : null;
if (!parsed) return fileUrl;
const { data, error } = await supabaseAdmin.storage.from(parsed.bucket).createSignedUrl(parsed.path, 60 * 60, { download: fileName });
if (error || !data?.signedUrl) throw new Error(error?.message ?? "生成签名 URL 失败");
return data.signedUrl;
};
const downloadBytes = async (url: string) => {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`下载失败:${resp.status} ${resp.statusText}`);
return await resp.arrayBuffer();
};
const mineruParseContentList = async (args: { bytes: ArrayBuffer; filename: string; mimeType: string }) => {
const endpoint = (process.env.MINERU_ENDPOINT || "").trim().replace(/\/$/, "");
if (!endpoint) throw new Error("缺少 MINERU_ENDPOINT,无法解析 PDF 结构");
const form = new FormData();
form.append("files", new Blob([args.bytes], { type: args.mimeType || "application/octet-stream" }), args.filename || "file.pdf");
form.append("output_dir", "./output");
form.append("lang_list", "ch");
form.append("backend", "pipeline");
form.append("parse_method", "auto");
form.append("return_md", "false");
form.append("return_middle_json", "false");
form.append("return_model_output", "false");
form.append("return_content_list", "true");
form.append("return_images", "false");
form.append("response_format_zip", "false");
const resp = await fetch(`${endpoint}/file_parse`, { method: "POST", body: form });
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`MinerU 解析失败:${resp.status} ${resp.statusText}${text ? `${text.slice(0, 300)}` : ""}`);
}
const payload = (await resp.json().catch(() => null)) as unknown;
if (!isRecord(payload) || !isRecord(payload.results)) throw new Error("MinerU 返回格式不正确");
const keys = Object.keys(payload.results);
if (!keys.length) throw new Error("MinerU 返回为空");
const first = payload.results[keys[0]];
if (!isRecord(first)) throw new Error("MinerU 返回格式不正确(results.*");
const raw = first.content_list;
if (typeof raw !== "string" || !raw.trim()) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed as Array<Record<string, unknown>>;
};
type ExtractedOutlineItem = { level: number; title: string; page?: number };
const outlineFromContentList = (list: Array<Record<string, unknown>>) => {
const out: ExtractedOutlineItem[] = [];
const seen = new Set<string>();
for (const item of list) {
const levelRaw = item.text_level;
const textRaw = item.text;
const pageIdxRaw = item.page_idx;
if (typeof levelRaw !== "number" || !Number.isFinite(levelRaw)) continue;
const level = Math.max(1, Math.min(6, Math.floor(levelRaw)));
const title = String(textRaw ?? "").replace(/\s+/g, " ").trim();
if (!title) continue;
const pageIdx = typeof pageIdxRaw === "number" && Number.isFinite(pageIdxRaw) ? Math.max(0, Math.floor(pageIdxRaw)) : null;
const page = pageIdx === null ? undefined : pageIdx + 1;
const key = `${level}|${page ?? ""}|${title}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ level, title, page });
}
return out;
};
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 createUid = () => {
const anyCrypto = globalThis.crypto as unknown as { randomUUID?: () => string } | undefined;
if (anyCrypto?.randomUUID) return anyCrypto.randomUUID();
return Math.random().toString(36).slice(2);
};
const buildOutlineOps = (args: { parentUid: string; items: ExtractedOutlineItem[]; attachment: { assetId: string; fileUrl: string; title: string; mimeType?: string | null } }) => {
const ops: MindmapOp[] = [];
const parents: Record<number, string> = { 0: args.parentUid };
for (const item of args.items) {
const level = Math.max(1, Math.min(6, Math.floor(item.level)));
const parent = parents[level - 1] || args.parentUid;
const uid = createUid();
const page = item.page;
const refs: NodeRef[] = [
{
kind: "pdf",
assetId: args.attachment.assetId,
fileUrl: args.attachment.fileUrl,
page,
title: args.attachment.title,
snippet: page ? `${page}` : undefined,
},
];
const hyperlink = (() => {
const base = String(args.attachment.fileUrl || "").trim();
if (!base) return undefined;
if (page && isPdf(String(args.attachment.mimeType ?? ""), base)) return `${base}#page=${page}`;
return base;
})();
ops.push({
op: "addChild",
parentUid: parent,
node: {
uid,
text: item.title,
hyperlink,
refs,
},
});
parents[level] = uid;
for (let d = level + 1; d <= 10; d += 1) delete parents[d];
}
return ops;
};
export const createOnlyOfficeServerTools = (args: {
supabase: OnlyOfficeSupabaseClient;
ctx: OnlyOfficeToolContext;
allowedToolIds: Set<string>;
}) => {
const asset_extract_outline = async (toolArgs: Record<string, unknown>) => {
const assetId = String(toolArgs.assetId ?? "").trim();
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
const resolved = attachmentRef ? resolveAttachment(args.ctx, attachmentRef) : null;
const targetAssetId = assetId || resolved?.id || "";
if (!targetAssetId) throw new Error("缺少 assetId / attachmentRef");
const { data, error } = await args.supabase
.from("media_assets")
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,storage_path,bucket,updated_at,deleted_at,purged_at")
.eq("id", targetAssetId)
.is("deleted_at", null)
.limit(1)
.maybeSingle();
if (error) throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取附件失败");
const row = (data && typeof data === "object" ? (data as Record<string, unknown>) : null) ?? null;
if (!row) return { ok: true, found: false };
const fileName = String(row.file_name ?? "");
const fileUrl = String(row.file_url ?? "");
const mimeType = String(row.mime_type ?? "");
const ocrText = String(row.ocr_text ?? "");
const ocrStatus = String(row.ocr_status ?? "");
const textPreview = ocrText.trim().slice(0, 3000);
if (!isPdf(mimeType, fileName || fileUrl)) {
return {
ok: true,
found: true,
supported: false,
note: "当前仅优先支持 PDFM3PDF→大纲→导图)。Word/PPT 将在后续阶段通过 ONLYOFFICE /converter 或其它解析策略补齐。",
asset: { id: String(row.id ?? ""), fileName, fileUrl, mimeType, ocrStatus, updatedAt: row.updated_at ?? null },
hasOcrText: Boolean(ocrText.trim()),
textPreview,
};
}
// M3PDF 优先使用 MinerU 的 content_list(含 page_idx + text_level),用于做可跳转大纲
let outline: ExtractedOutlineItem[] = [];
let strategy: "mineru_content_list" | "fallback_ocr_text" = "mineru_content_list";
try {
const signed = await signForDownload(row);
const bytes = await downloadBytes(signed);
const list = await mineruParseContentList({ bytes, filename: fileName || "file.pdf", mimeType: mimeType || "application/pdf" });
outline = outlineFromContentList(list);
} catch (e) {
// 兜底:返回 OCR 文本的一部分,至少让模型能“基于文本生成大纲”(但没有页码保障)
strategy = "fallback_ocr_text";
outline = [];
if (textPreview) outline.push({ level: 1, title: "(未解析到结构化标题:请结合 textPreview 自行生成提纲)" });
}
return {
ok: true,
found: true,
supported: true,
strategy,
asset: {
id: String(row.id ?? ""),
fileName,
fileUrl,
mimeType,
ocrStatus,
updatedAt: row.updated_at ?? null,
},
hasOcrText: Boolean(ocrText.trim()),
textPreview,
outline,
note:
outline.length > 0
? `已提取大纲条目:${outline.length}`
: strategy === "fallback_ocr_text"
? "未能从 MinerU 获取结构化标题;请确认 MINERU_ENDPOINT 正常,或稍后重试。"
: "未提取到标题(该 PDF 可能没有明显标题结构)。",
};
};
const asset_to_mindmap = async (toolArgs: Record<string, unknown>) => {
const mindmapId = String(toolArgs.mindmapId ?? "").trim();
if (!mindmapId) throw new Error("缺少 mindmapId");
const parentUidArg = String(toolArgs.parentUid ?? "").trim();
const maxItemsRaw = Number(toolArgs.maxItems ?? 120);
const maxItems = Number.isFinite(maxItemsRaw) ? Math.max(10, Math.min(600, Math.floor(maxItemsRaw))) : 120;
const documentId = String(args.ctx.documentId ?? "").trim();
if (!documentId) throw new Error("缺少 documentId 上下文(OnlyOffice 工具需要落盘到指定文档)");
const outlineResult = (await asset_extract_outline(toolArgs)) as unknown;
if (!isRecord(outlineResult) || outlineResult.ok !== true) throw new Error("提取大纲失败");
if (outlineResult.found !== true) throw new Error("未找到附件");
if (outlineResult.supported !== true) throw new Error(String(outlineResult.note ?? "附件类型暂不支持"));
const outline = Array.isArray(outlineResult.outline) ? (outlineResult.outline as unknown[]) : [];
const items = outline
.map((x) =>
isRecord(x)
? {
level: Number(x.level ?? 1),
title: String(x.title ?? ""),
...(typeof x.page === "number" ? { page: x.page } : {}),
}
: null,
)
.filter((x): x is ExtractedOutlineItem => x !== null && x.title.trim().length > 0)
.slice(0, maxItems);
if (!items.length) throw new Error("未提取到可用大纲条目");
const asset = isRecord(outlineResult.asset) ? outlineResult.asset : null;
const attachment = {
assetId: String(asset?.id ?? ""),
title: String(asset?.fileName ?? "附件"),
fileUrl: String(asset?.fileUrl ?? ""),
mimeType: isRecord(asset) ? (asset.mimeType as string | null | undefined) : null,
};
if (!attachment.assetId) throw new Error("缺少附件 id");
const { data: baseRaw } = await readMindmapLocal(documentId, mindmapId);
const base = (baseRaw && typeof baseRaw === "object" ? (baseRaw as MindmapTreeNode) : null) ?? { data: { text: "中心主题" }, children: [] };
ensureMindmapUids(base);
const rootUid = String(base?.data?.uid || "");
const parentUid = parentUidArg || rootUid;
if (!parentUid) throw new Error("无法确定 parentUidmindmap 根节点缺少 uid");
if (!findNodeByUid(base, parentUid)) throw new Error("未找到 parentUid 对应节点");
const ops = buildOutlineOps({ parentUid, items, attachment });
const { data: nextData, applied, errors } = applyMindmapOps(base, ops);
await writeMindmapLocal(documentId, mindmapId, nextData, "OnlyOffice 生成导图");
return {
ok: true,
mindmapId,
parentUid,
applied,
errors,
ops,
meta: {
assetId: attachment.assetId,
fileName: attachment.title,
items: items.length,
strategy: String(outlineResult.strategy ?? ""),
},
};
};
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
if (toolId === "asset_extract_outline") return await asset_extract_outline(toolArgs);
if (toolId === "asset_to_mindmap") return await asset_to_mindmap(toolArgs);
throw new Error(`未知工具:${toolId}`);
};
return { run };
};
@@ -239,6 +239,77 @@ export const builtinTools: AiAgentTool[] = [
requiresConfirmation: true, requiresConfirmation: true,
isWriteTool: true, isWriteTool: true,
}, },
{
id: "asset_extract_outline",
displayName: "提取附件大纲(PDF 优先)",
modelDescription:
"从附件(当前优先 PDF)提取结构化大纲(含层级与页码)。用于“文档驱动导图/可跳转引用”(不写入)。",
inputSchemaText: `{ "assetId?": "string", "attachmentRef?": "string (可用附件 id/title/url 片段)" }`,
source: "builtin",
requiresConfirmation: false,
isWriteTool: false,
},
{
id: "asset_to_mindmap",
displayName: "附件生成思维导图(写入)",
modelDescription:
"把附件大纲转为思维导图节点并落盘(PDF 页码会写入 refs,并尽量生成可跳转 hyperlink)。这是写工具,执行前必须确认。",
inputSchemaText:
`{ "mindmapId": "string", "parentUid?": "string (默认根节点)", "assetId?": "string", "attachmentRef?": "string", "maxItems?": "number (10~600, 默认120)", "reason?": "string" }`,
source: "builtin",
requiresConfirmation: true,
isWriteTool: true,
},
{
id: "oo_get_selection",
displayName: "读取选区(OnlyOffice",
modelDescription:
"读取 OnlyOffice 当前选区(用于增/删/改/查的基础能力:查/改前先读)。注意:该工具在客户端插件内执行。",
inputSchemaText: `{ "format?": "\"text\"|\"html\" (默认 text)" }`,
source: "builtin",
requiresConfirmation: false,
isWriteTool: false,
},
{
id: "oo_replace_selection",
displayName: "替换选区(OnlyOffice",
modelDescription:
"用文本/HTML 替换 OnlyOffice 当前选区(基础改/删:删=传空字符串)。注意:该工具在客户端插件内执行。",
inputSchemaText: `{ "text": "string", "format?": "\"text\"|\"html\" (默认 text)", "reason?": "string" }`,
source: "builtin",
requiresConfirmation: true,
isWriteTool: true,
},
{
id: "oo_insert_text",
displayName: "插入文本(OnlyOffice",
modelDescription:
"在 OnlyOffice 光标/选区位置插入文本(若存在选区通常会覆盖选区)。注意:该工具在客户端插件内执行。",
inputSchemaText: `{ "text": "string", "reason?": "string" }`,
source: "builtin",
requiresConfirmation: true,
isWriteTool: true,
},
{
id: "oo_insert_html",
displayName: "插入 HTMLOnlyOffice",
modelDescription:
"在 OnlyOffice 光标/选区位置插入 HTML(用于保留简单格式)。注意:该工具在客户端插件内执行。",
inputSchemaText: `{ "html": "string", "reason?": "string" }`,
source: "builtin",
requiresConfirmation: true,
isWriteTool: true,
},
{
id: "oo_insert_image",
displayName: "插入图片(OnlyOffice",
modelDescription:
"在 OnlyOffice 光标/选区位置插入图片(imageRef 可为附件 id 或 URL)。注意:该工具在客户端插件内执行。",
inputSchemaText: `{ "imageRef": "string (attachmentId 或 URL)", "width?": "number", "height?": "number", "reason?": "string" }`,
source: "builtin",
requiresConfirmation: true,
isWriteTool: true,
},
]; ];
export const builtinToolSets: AiAgentToolSet[] = [ export const builtinToolSets: AiAgentToolSet[] = [
@@ -262,6 +333,27 @@ export const builtinToolSets: AiAgentToolSet[] = [
displayName: "媒体读取(OCR/元信息)", displayName: "媒体读取(OCR/元信息)",
toolIds: ["image_read"], toolIds: ["image_read"],
}, },
{
id: "toolset.onlyoffice_read",
displayName: "OnlyOffice/附件结构化读取(M3",
toolIds: ["asset_extract_outline"],
},
{
id: "toolset.onlyoffice_write",
displayName: "OnlyOffice→思维导图(写入,M3",
toolIds: ["asset_to_mindmap"],
},
{
id: "toolset.onlyoffice_editor",
displayName: "OnlyOffice 编辑器(选区读写)",
toolIds: [
"oo_get_selection",
"oo_replace_selection",
"oo_insert_text",
"oo_insert_html",
"oo_insert_image",
],
},
{ {
id: "toolset.slash_write", id: "toolset.slash_write",
displayName: "斜杠命令(写入)", displayName: "斜杠命令(写入)",
@@ -103,6 +103,10 @@ export function inferPasteTargetDocId({
const parsed = parseFileTreeRowId(focusedRowId); const parsed = parseFileTreeRowId(focusedRowId);
if (parsed?.kind === "doc") return parsed.docId; if (parsed?.kind === "doc") return parsed.docId;
if (parsed?.kind === "index") return parsed.docId; if (parsed?.kind === "index") return parsed.docId;
if (parsed?.kind === "asset-folder") {
const row = rowById.get(focusedRowId);
return row?.kind === "asset-folder" ? row.docId : null;
}
if (parsed?.kind === "asset") { if (parsed?.kind === "asset") {
const row = rowById.get(focusedRowId); const row = rowById.get(focusedRowId);
return row?.kind === "asset" ? row.docId : null; return row?.kind === "asset" ? row.docId : null;
+1 -1
View File
@@ -27,7 +27,7 @@ export function computeFileTreeDeleteTargets(args: {
continue; continue;
} }
if (row.kind === "asset") { if (row.kind === "asset" || row.kind === "asset-folder") {
assetCandidates.push(row.asset.id); assetCandidates.push(row.asset.id);
assetDocIdByAssetId.set(row.asset.id, row.docId); assetDocIdByAssetId.set(row.asset.id, row.docId);
} }
+34 -2
View File
@@ -3,16 +3,20 @@
import type { DocumentNode } from "@/lib/documents"; import type { DocumentNode } from "@/lib/documents";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import type { FileTreeRow } from "./types"; import type { FileTreeRow } from "./types";
import { makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types"; import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
export function buildVisibleRows({ export function buildVisibleRows({
nodes, nodes,
expanded, expanded,
assetsByDoc, assetsByDoc,
assetChildrenByAssetId,
expandedAssetFolderIds,
}: { }: {
nodes: DocumentNode[]; nodes: DocumentNode[];
expanded: Set<string>; expanded: Set<string>;
assetsByDoc: Record<string, MediaAsset[]>; assetsByDoc: Record<string, MediaAsset[]>;
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
expandedAssetFolderIds?: Set<string>;
}): FileTreeRow[] { }): FileTreeRow[] {
const rows: FileTreeRow[] = []; const rows: FileTreeRow[] = [];
@@ -43,6 +47,35 @@ export function buildVisibleRows({
}); });
assets.forEach((asset) => { assets.forEach((asset) => {
if (asset.asset_type === "mindmap") {
const children = assetChildrenByAssetId?.[asset.id] ?? [];
const hasChildren = children.length > 0;
const isExpanded = expandedAssetFolderIds?.has(asset.id) ?? false;
rows.push({
kind: "asset-folder",
rowId: makeAssetFolderRowId(asset.id),
depth: depth + 1,
docId: node.id,
parentDocId: node.id,
asset,
hasChildren,
isExpanded,
});
if (hasChildren && isExpanded) {
children.forEach((child) => {
rows.push({
kind: "asset",
rowId: makeAssetRowId(child.id),
depth: depth + 2,
docId: node.id,
parentDocId: node.id,
asset: child,
});
});
}
return;
}
rows.push({ rows.push({
kind: "asset", kind: "asset",
rowId: makeAssetRowId(asset.id), rowId: makeAssetRowId(asset.id),
@@ -59,4 +92,3 @@ export function buildVisibleRows({
nodes.forEach((node) => walk(node, 0)); nodes.forEach((node) => walk(node, 0));
return rows; return rows;
} }
+30 -4
View File
@@ -3,14 +3,19 @@
import type { DocumentNode } from "@/lib/documents"; import type { DocumentNode } from "@/lib/documents";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
export type FileTreeRowKind = "doc" | "index" | "asset"; export type FileTreeRowKind = "doc" | "index" | "asset" | "asset-folder";
export type FileTreeRowId = `doc:${string}` | `index:${string}` | `asset:${string}`; export type FileTreeRowId =
| `doc:${string}`
| `index:${string}`
| `asset:${string}`
| `asset-folder:${string}`;
export type ParsedFileTreeRowId = export type ParsedFileTreeRowId =
| { kind: "doc"; docId: string } | { kind: "doc"; docId: string }
| { kind: "index"; docId: string } | { kind: "index"; docId: string }
| { kind: "asset"; assetId: string }; | { kind: "asset"; assetId: string }
| { kind: "asset-folder"; assetId: string };
export function makeDocRowId(docId: string): FileTreeRowId { export function makeDocRowId(docId: string): FileTreeRowId {
return `doc:${docId}`; return `doc:${docId}`;
@@ -24,6 +29,10 @@ export function makeAssetRowId(assetId: string): FileTreeRowId {
return `asset:${assetId}`; return `asset:${assetId}`;
} }
export function makeAssetFolderRowId(assetId: string): FileTreeRowId {
return `asset-folder:${assetId}`;
}
export function parseFileTreeRowId(rowId: string): ParsedFileTreeRowId | null { export function parseFileTreeRowId(rowId: string): ParsedFileTreeRowId | null {
const idx = rowId.indexOf(":"); const idx = rowId.indexOf(":");
if (idx <= 0) return null; if (idx <= 0) return null;
@@ -37,6 +46,8 @@ export function parseFileTreeRowId(rowId: string): ParsedFileTreeRowId | null {
return { kind: "index", docId: rest }; return { kind: "index", docId: rest };
case "asset": case "asset":
return { kind: "asset", assetId: rest }; return { kind: "asset", assetId: rest };
case "asset-folder":
return { kind: "asset-folder", assetId: rest };
default: default:
return null; return null;
} }
@@ -61,6 +72,16 @@ export type FileTreeRow =
parentDocId: string; parentDocId: string;
node: DocumentNode; node: DocumentNode;
} }
| {
kind: "asset-folder";
rowId: FileTreeRowId;
depth: number;
docId: string;
parentDocId: string;
asset: MediaAsset;
hasChildren: boolean;
isExpanded: boolean;
}
| { | {
kind: "asset"; kind: "asset";
rowId: FileTreeRowId; rowId: FileTreeRowId;
@@ -76,6 +97,11 @@ export function getFileTreeRowLabel(row: FileTreeRow): string {
return row.node.title || "无标题"; return row.node.title || "无标题";
case "index": case "index":
return "index.md"; return "index.md";
case "asset-folder": {
const name = row.asset.file_name || "附件";
// 思维导图展示为“文件夹”时,去掉 .json 结尾更直观
return row.asset.asset_type === "mindmap" ? name.replace(/\.json$/i, "") : name;
}
case "asset": case "asset":
return row.asset.file_name || "附件"; return row.asset.file_name || "附件";
} }
@@ -86,8 +112,8 @@ export function getOwningDocId(row: FileTreeRow): string {
case "doc": case "doc":
case "index": case "index":
return row.docId; return row.docId;
case "asset-folder":
case "asset": case "asset":
return row.asset.document_id; return row.asset.document_id;
} }
} }
+72
View File
@@ -88,6 +88,59 @@ export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMi
return results; return results;
} }
function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
const root = (() => {
if (!input || typeof input !== "object") return input;
const record = input as Record<string, unknown>;
return "root" in record ? record.root : input;
})();
const ids: string[] = [];
const seen = new Set<string>();
const push = (value: unknown) => {
if (typeof value !== "string") return;
if (!value.startsWith("asset:")) return;
const id = value.slice("asset:".length).trim();
if (!id) return;
if (seen.has(id)) return;
seen.add(id);
ids.push(id);
};
const get = (obj: unknown, key: string): unknown => {
if (!obj || typeof obj !== "object") return undefined;
return (obj as Record<string, unknown>)[key];
};
const walk = (node: unknown) => {
if (!node || typeof node !== "object") return;
const data = get(node, "data");
const image = get(node, "image");
// 常见:node.data.image = "asset:xxx"
push(get(data, "image"));
// 兼容:node.image = "asset:xxx"
push(image);
// 兼容:node.image.url = "asset:xxx"
push(get(image, "url"));
// 兼容:node.data.image.url = "asset:xxx"
push(get(get(data, "image"), "url"));
const children = get(node, "children");
if (Array.isArray(children)) {
children.forEach(walk);
}
};
walk(root);
return ids;
}
type TrashedMindmapMeta = { type TrashedMindmapMeta = {
docId: string; docId: string;
mindmapId: string; mindmapId: string;
@@ -106,6 +159,25 @@ async function tryReadJsonFile<T>(file: string): Promise<T | null> {
} }
} }
export async function detectLocalMindmapImageAssetIdsByMindmapId(
files: LocalMindmapFile[],
): Promise<Record<string, string[]>> {
const mapping: Record<string, string[]> = {};
for (const item of files) {
const baseDir = item.source === "legacy" ? legacyBaseDir : preferredBaseDir;
const filePath = path.join(baseDir, item.documentId, item.fileName);
const data = await tryReadJsonFile<unknown>(filePath);
if (!data) continue;
const ids = extractMindmapImageAssetIdsFromData(data);
if (ids.length > 0) {
mapping[item.mindmapId] = ids;
}
}
return mapping;
}
async function listTrashMetasForFolder(folder: string): Promise<TrashedMindmapMeta[]> { async function listTrashMetasForFolder(folder: string): Promise<TrashedMindmapMeta[]> {
const trashDir = path.join(folder, ".trash"); const trashDir = path.join(folder, ".trash");
try { try {
+1 -1
View File
@@ -1,7 +1,7 @@
import { createClient } from "@supabase/supabase-js"; import { createClient } from "@supabase/supabase-js";
const supabaseAdmin = createClient( const supabaseAdmin = createClient(
process.env.SUPABASE_URL ?? "", process.env.SUPABASE_INTERNAL_URL ?? process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
process.env.SUPABASE_SERVICE_ROLE_KEY ?? "", process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
{ {
auth: { auth: {
@@ -0,0 +1,27 @@
export const rewriteToPublicOrigin = (rawUrl: string, publicBaseUrl?: string) => {
if (!publicBaseUrl) return rawUrl;
const input = String(rawUrl || "").trim();
if (!input) return input;
try {
const u = new URL(input);
const pub = new URL(publicBaseUrl);
const isLocalHost =
u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
const isLikelyInternalPort = u.port === "18000";
if (!isLocalHost && !isLikelyInternalPort) {
return input;
}
// 统一改为公网可达的 supabase origin(协议 + host + 端口)
u.protocol = pub.protocol;
u.host = pub.host;
return u.toString();
} catch {
return input;
}
};
+37
View File
@@ -5,11 +5,14 @@ import type { SidebarSectionId } from "@/components/sidebar/types";
interface SidebarState { interface SidebarState {
open: boolean; open: boolean;
width: number; width: number;
sectionsTrayOpen: boolean;
collapsedSections: Record<SidebarSectionId, boolean>; collapsedSections: Record<SidebarSectionId, boolean>;
trashConfirm: boolean; trashConfirm: boolean;
viewMode: "section" | "filesystem"; viewMode: "section" | "filesystem";
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
setWidth: (width: number) => void; setWidth: (width: number) => void;
setSectionsTrayOpen: (open: boolean) => void;
toggleSectionsTray: () => void;
toggleSection: (section: SidebarSectionId) => void; toggleSection: (section: SidebarSectionId) => void;
setSectionCollapsed: (section: SidebarSectionId, collapsed: boolean) => void; setSectionCollapsed: (section: SidebarSectionId, collapsed: boolean) => void;
setTrashConfirm: (value: boolean) => void; setTrashConfirm: (value: boolean) => void;
@@ -30,11 +33,14 @@ export const useSidebarStore = create<SidebarState>()(
(set) => ({ (set) => ({
open: false, open: false,
width: 280, width: 280,
sectionsTrayOpen: false,
collapsedSections: { ...sectionDefaults }, collapsedSections: { ...sectionDefaults },
trashConfirm: true, trashConfirm: true,
viewMode: "section", viewMode: "section",
setOpen: (open) => set({ open }), setOpen: (open) => set({ open }),
setWidth: (width) => set({ width }), setWidth: (width) => set({ width }),
setSectionsTrayOpen: (open) => set({ sectionsTrayOpen: open }),
toggleSectionsTray: () => set((state) => ({ sectionsTrayOpen: !state.sectionsTrayOpen })),
toggleSection: (section) => toggleSection: (section) =>
set((state) => ({ set((state) => ({
collapsedSections: { collapsedSections: {
@@ -55,8 +61,39 @@ export const useSidebarStore = create<SidebarState>()(
}), }),
{ {
name: "sidebar-ui", name: "sidebar-ui",
version: 2,
migrate: (persistedState, version) => {
const state = persistedState as
| Partial<
Pick<
SidebarState,
"width" | "collapsedSections" | "trashConfirm" | "viewMode" | "sectionsTrayOpen"
>
>
| undefined;
if (!state) return state;
if (version >= 2) return state;
const nextCollapsedSections = {
...sectionDefaults,
...(state.collapsedSections ?? {}),
};
// 迁移到「分区入口可折叠」后,让分区默认展开,折叠由入口开关控制。
nextCollapsedSections.starred = false;
nextCollapsedSections.public = false;
nextCollapsedSections.shared = false;
nextCollapsedSections.templates = false;
return {
...state,
sectionsTrayOpen: false,
collapsedSections: nextCollapsedSections,
};
},
partialize: (state) => ({ partialize: (state) => ({
width: state.width, width: state.width,
sectionsTrayOpen: state.sectionsTrayOpen,
collapsedSections: state.collapsedSections, collapsedSections: state.collapsedSections,
trashConfirm: state.trashConfirm, trashConfirm: state.trashConfirm,
viewMode: state.viewMode, viewMode: state.viewMode,