diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..279e1816 --- /dev/null +++ b/AGENTS.md @@ -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/模块/职责整理,便于后续快速定位与开发)。 diff --git a/CODE_INDEX.md b/CODE_INDEX.md new file mode 100644 index 00000000..a96f030a --- /dev/null +++ b/CODE_INDEX.md @@ -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` + +### API(Next 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` 避免通配符误匹配。 + diff --git a/README.md b/README.md deleted file mode 100644 index e215bc4c..00000000 --- a/README.md +++ /dev/null @@ -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. diff --git a/design/ai-agent-platform-v1.md b/design/ai-agent-platform-v1.md index a5e44335..42494e10 100644 --- a/design/ai-agent-platform-v1.md +++ b/design/ai-agent-platform-v1.md @@ -109,7 +109,9 @@ - Client Tools 先不做“由 AI 主动触发”,而是通过 attachments 提前把必要上下文注入(@selection、@currentFile、@mindmapSelection)。 执行方式(v2 目标): -- 引入“前端工具宿主”:当服务端解析到 `` 时,通过 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(文档阅读/编辑) -v1 只做“可落地”的: -- `asset_get`:读取文件树中的 asset 元信息(类型/URL/页数等) -- `asset_extract_outline`:从 PDF/Word/PPT 提取大纲(复用现有 PDF 逻辑,Word/PPT 先降级) -- `asset_to_mindmap`:从 asset 生成 mindmap(返回 ops 或完整树,再由 `mindmap_apply_ops` 落盘) +v1(先做最基础的增/删/改/查 + 可落地的“文档驱动导图”): +- `oo_get_selection`:读取当前选区(查) +- `oo_replace_selection`:替换当前选区(改/删;删=传空字符串) +- `oo_insert_text` / `oo_insert_html`:插入内容(增) +- `oo_insert_image`:插入图片(增) +- `asset_extract_outline`:附件(PDF 优先)→结构化大纲(含页码) +- `asset_to_mindmap`:附件大纲→思维导图落盘(含 refs/hyperlink) v2 再做: - `onlyoffice_jump`:跳转到某页/某段(依赖 OnlyOffice API 能力) - `onlyoffice_insert_comment`:插入批注/引用锚点 +- `onlyoffice_forcesave`:强制保存/落盘(用于“编辑中→解析/索引→生成”闭环) ### 4.2 ToolSet(便于 UI 一键勾选) @@ -194,7 +200,8 @@ v2 再做: - `toolset.readonly`:search_web、rag_query、asset_get、mindmap_get(只读) - `toolset.mindmap_write`:mindmap_apply_ops、mindmap_expand_node - `toolset.doc_write`:doc_insert_blocks、doc_replace_range -- `toolset.onlyoffice`:asset_extract_outline、asset_to_mindmap、onlyoffice_jump(v2) +- `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. 里程碑计划(慢慢实现,但每一步都可验收) -### 完成进度(截至 2026-01-10) +### 完成进度(截至 2026-01-11) > 说明:本节用于记录“已经落地到代码”的进度,避免只停留在规划层。 > ✅=已完成;🟡=部分完成/已打通但仍需扩展;⬜=未开始 @@ -240,7 +247,7 @@ v2 再做: | M0 | Tool Registry(Tool/ToolSet) | ✅ | `wolai-frontend/src/lib/ai-agent/tools/registry.ts` + `wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts` | | M0 | Agent Runtime(step budget + 工具日志) | ✅ | `wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts` | | M0 | SSE 输出(assistant/tool_call/tool_result/completion) | ✅ | `/api/ai-agent/run` 已支持 SSE | -| M0 | 前端 AI 面板(可复用) | 🟡 | 目前已在 dev 面板 + 思维导图面板接入;BlockNote/OnlyOffice 仍需继续推进 | +| M0 | 前端 AI 面板(可复用) | 🟡 | 已在 dev + 思维导图 + OnlyOffice 接入;BlockNote 仍需继续推进 | | M1 | mindmap 读写工具(增量 ops + 细粒度工具) | ✅ | `wolai-frontend/src/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools.ts` | | M1 | `mindmap_expand_node`(检索→生成→落盘) | ✅ | 同上(支持可选 `search_web` 证据) | | M1 | UI:选中节点上下文注入(给 AI) | ✅ | 思维导图面板通过 `context.selectedUids` 注入;并对用户显示纯文本节点内容 | @@ -251,6 +258,14 @@ v2 再做: | M2 | 跨页面文档工具(docs_*) | ✅ | `docs_search` + `docs_read`(按 title/raw_text) | | M2 | 图片读取工具(OCR) | ✅ | `image_read`(读取 `media_assets.ocr_text`) | | M2 | 斜杠命令工具 | ✅ | `slash_run`(/new 创建文档、/rename 重命名) | +| M2 | UI:Cline 风格工具栏 + 侧边栏内切页 | ✅ | 文档/思维导图 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 + 工具协议 + 日志 @@ -281,18 +296,30 @@ v2 再做: 验收(pw-tests): - 选中一段文本,让 AI “改写成更简洁版本并保留要点”,结果直接落入文档(不是生成一段文本)。 -### M3(OnlyOffice):文档驱动导图 + 可跳转引用 +### M3(OnlyOffice):插件端实时改文档 + 文档驱动导图 -- [ ] `asset_extract_outline`:PDF 优先,Word/PPT 先降级(至少按标题层级) -- [ ] `asset_to_mindmap`:从文档大纲生成 mindmap(章→节→要点) -- [ ] 引用:节点 refs(page/slide)+ hyperlink(`#page=`) +- [x] OnlyOffice 插件注入(autostart + pluginsData + CORS) +- [x] oo_* 工具(选区增/删/改/查):`oo_get_selection` / `oo_replace_selection` / `oo_insert_*` +- [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(章→节→要点)并落盘 +- [ ] 引用:节点 refs(page/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): - 对指定 PDF 生成“章→节→内容”层级导图,点击节点跳到对应页(至少 URL 含 `#page=`)。 ### M4(v2):前端工具宿主 + MCP 工具 + 类“技能”系统 -- [ ] 前端工具宿主:允许 AI 请求 client tool(读取当前选区、OnlyOffice 当前页) +- [ ] 前端工具宿主(通用版):允许 AI 请求 client tool(读取 BlockNote 选区、思维导图当前选中节点、OnlyOffice 当前页等) - [ ] MCP:把 supabase_local / searxng / 未来自定义 MCP 作为工具源(像 cline) - [ ] skills:把“工作流说明”做成可加载的技能文件,AI 可按需激活(类似你现在的 Codex skills) diff --git a/design/cloudflare-path-a-runbook.md b/design/cloudflare-path-a-runbook.md index 6f3ca043..78a99b22 100644 --- a/design/cloudflare-path-a-runbook.md +++ b/design/cloudflare-path-a-runbook.md @@ -7,6 +7,7 @@ - `app.aichem.dpdns.org` → `wolai-frontend`(Next 3000:页面 + /api/*) - `supabase.aichem.dpdns.org` → `Supabase Kong`(示例 18000) - `backend.aichem.dpdns.org` → `wolai-backend`(示例 8000,可选) +- `onlyoffice.aichem.dpdns.org` → `OnlyOffice Document Server`(示例 8081) - `lightrag.aichem.dpdns.org` → `LightRAG`(示例 7777,建议加 Access) - `mineru.aichem.dpdns.org` → `MinerU`(示例 18888,建议加 Access) @@ -35,6 +36,8 @@ - `NEXT_PUBLIC_SUPABASE_URL=https://supabase.aichem.dpdns.org` - `NEXT_PUBLIC_SUPABASE_ANON_KEY=` - `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 服务器用,不暴露给浏览器): - `SUPABASE_INTERNAL_URL=http://127.0.0.1:18000` diff --git a/package.json b/package.json index a53775a9..7e5b208b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "scripts": { + "desktop": "node scripts/desktop-prod.js", "desktop:hot": "node scripts/desktop-hot.js" }, "dependencies": { diff --git a/scripts/cloudflared/config.yml.example b/scripts/cloudflared/config.yml.example index bec0049f..cd855a0e 100644 --- a/scripts/cloudflared/config.yml.example +++ b/scripts/cloudflared/config.yml.example @@ -34,5 +34,9 @@ ingress: - hostname: mineru.aichem.dpdns.org service: http://127.0.0.1:18888 + # 6) OnlyOffice Document Server(docker: onlyoffice-dev 暴露到 8081) + - hostname: onlyoffice.aichem.dpdns.org + service: http://127.0.0.1:8081 + # 未匹配的请求直接 404 - service: http_status:404 diff --git a/scripts/desktop-prod.js b/scripts/desktop-prod.js new file mode 100644 index 00000000..0c99e3e3 --- /dev/null +++ b/scripts/desktop-prod.js @@ -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", "已跳过 build(SKIP_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-backend(FastAPI) + 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", "已跳过 Celery(SKIP_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); +}); diff --git a/wolai-frontend/README.md b/wolai-frontend/README.md deleted file mode 100644 index e215bc4c..00000000 --- a/wolai-frontend/README.md +++ /dev/null @@ -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. diff --git a/wolai-frontend/next.config.ts b/wolai-frontend/next.config.ts index a9551bfa..c86a9b0f 100644 --- a/wolai-frontend/next.config.ts +++ b/wolai-frontend/next.config.ts @@ -5,6 +5,20 @@ const nextConfig: NextConfig = { // 避免 monorepo/多 lockfile 场景下 root 误判,减少构建与热更新的不确定性 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; diff --git a/wolai-frontend/public/onlyoffice/plugins/agent-tools/config.json b/wolai-frontend/public/onlyoffice/plugins/agent-tools/config.json new file mode 100644 index 00000000..9d5b7024 --- /dev/null +++ b/wolai-frontend/public/onlyoffice/plugins/agent-tools/config.json @@ -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 + } + ] +} + diff --git a/wolai-frontend/public/onlyoffice/plugins/agent-tools/icon.svg b/wolai-frontend/public/onlyoffice/plugins/agent-tools/icon.svg new file mode 100644 index 00000000..7d6aec8f --- /dev/null +++ b/wolai-frontend/public/onlyoffice/plugins/agent-tools/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/wolai-frontend/public/onlyoffice/plugins/agent-tools/index.html b/wolai-frontend/public/onlyoffice/plugins/agent-tools/index.html new file mode 100644 index 00000000..aa10e82e --- /dev/null +++ b/wolai-frontend/public/onlyoffice/plugins/agent-tools/index.html @@ -0,0 +1,12 @@ + + + + + + MNOTE OnlyOffice Agent Tools + + + + + + diff --git a/wolai-frontend/public/onlyoffice/plugins/agent-tools/plugin.js b/wolai-frontend/public/onlyoffice/plugins/agent-tools/plugin.js new file mode 100644 index 00000000..6248a9f7 --- /dev/null +++ b/wolai-frontend/public/onlyoffice/plugins/agent-tools/plugin.js @@ -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 插入 ,避免依赖 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 = ``; + 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 }); + } + }); +})(); + diff --git a/wolai-frontend/src/app/api/ai-agent/client-tool-result/route.ts b/wolai-frontend/src/app/api/ai-agent/client-tool-result/route.ts new file mode 100644 index 00000000..66289def --- /dev/null +++ b/wolai-frontend/src/app/api/ai-agent/client-tool-result/route.ts @@ -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 }); +} + diff --git a/wolai-frontend/src/app/api/ai-agent/run/route.ts b/wolai-frontend/src/app/api/ai-agent/run/route.ts index 1972234d..80c1b8e0 100644 --- a/wolai-frontend/src/app/api/ai-agent/run/route.ts +++ b/wolai-frontend/src/app/api/ai-agent/run/route.ts @@ -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 { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent/tools/builtins/media/mediaServerTools"; 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"; -type AgentMessage = { role: "user" | "assistant"; content: string }; -type AgentScope = "global" | "mindmap" | "document"; +type AgentMessage = { role: "user" | "assistant"; content: string }; +type AgentScope = "global" | "mindmap" | "document" | "onlyoffice"; type RequestPayload = { stream?: boolean; @@ -36,6 +38,19 @@ type RequestPayload = { }; 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 = { "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 scope: AgentScope = (() => { 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 场景,否则视为全局场景 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"] : scope === "document" ? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.doc_read", "toolset.doc_write", "toolset.slash_write"] - : ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.slash_write"]; + : 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(); for (const sid of allowToolSetIds) { const s = registry.toolSetsById.get(sid); @@ -210,6 +227,15 @@ export async function POST(request: Request) { }) : 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") ? createSlashServerTools({ supabase: supabase as unknown as SlashSupabaseClient, @@ -219,6 +245,9 @@ export async function POST(request: Request) { : null; const runTool = async (toolId: string, toolArgs: Record) => { + if (isOnlyOfficeClientTool(toolId)) { + throw new Error("oo_* 属于客户端工具:必须使用 stream 模式并在 OnlyOffice 页面内执行"); + } if (toolId === "search_web") { const query = String(toolArgs.query ?? "").trim(); const count = Number(toolArgs.count ?? 6); @@ -233,11 +262,15 @@ export async function POST(request: Request) { return await docsTools.run(toolId, toolArgs); } if (toolId === "image_read") { - if (!mediaTools) throw new Error(`工具未初始化:${toolId}`); + if (!mediaTools) throw new Error(`工具未初始化:${toolId}`); 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 (!slashTools) throw new Error(`工具未初始化:${toolId}`); + if (!slashTools) throw new Error(`工具未初始化:${toolId}`); return await slashTools.run(toolId, toolArgs); } if (toolId.startsWith("doc_")) { @@ -281,7 +314,30 @@ export async function POST(request: Request) { }; // 先发一个 ready,方便前端快速进入“流式模式” - send("ready", { ok: true }); + const requestId = makeRunId(); + send("ready", { ok: true, requestId }); + + let lastToolCall: { id: string; tool: string; args: Record } | null = null; + + const runToolStream = async (toolId: string, toolArgs: Record) => { + 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(() => { // 避免某些代理/浏览器长连接超时 @@ -297,12 +353,30 @@ export async function POST(request: Request) { userMessages: payload.messages.slice(0, 50), cfg: { ...cfg, model: modelOverride ?? cfg.model }, allowedToolIds, - runTool, + runTool: runToolStream, maxSteps, systemContextText, defaultMindmapTargetUid: selectedUids[0] ?? "", onEvent: (ev) => { 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) + : ({} as Record); + const id = String(obj.id ?? "").trim(); + const tool = String(obj.tool ?? "").trim(); + const args = + typeof obj.args === "object" && obj.args + ? (obj.args as Record) + : ({} as Record); + if (id && tool) lastToolCall = { id, tool, args }; + } catch { + // ignore + } + } send(ev.type, ev.data ?? null); }, }); diff --git a/wolai-frontend/src/app/api/media/batch/route.ts b/wolai-frontend/src/app/api/media/batch/route.ts index dd158be5..fdfe4227 100644 --- a/wolai-frontend/src/app/api/media/batch/route.ts +++ b/wolai-frontend/src/app/api/media/batch/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { extname } from "path"; import { makeUniqueFileName } from "@/lib/file-tree/naming"; +import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; export const dynamic = "force-dynamic"; @@ -11,6 +12,7 @@ interface BatchPayload { action: Action; assetIds: string[]; targetDocumentId?: string; + targetSubPath?: string; newName?: string; } @@ -41,7 +43,7 @@ const parseStoragePath = (fileUrl: string) => { function resolveAssetLocation(asset: any): { bucket: string; path: string } | null { if (asset?.storage_path) { - return { bucket: asset.bucket || BUCKET, path: asset.storage_path }; + return { bucket: asset.bucket || BUCKET, path: asset.storage_path }; } if (asset?.file_url) { return parseStoragePath(asset.file_url); @@ -49,6 +51,18 @@ function resolveAssetLocation(asset: any): { bucket: string; path: string } | nu 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) { const supabase = await createSupabaseRouteClient(); const { @@ -117,12 +131,21 @@ export async function POST(request: Request) { typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${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 moveResult = await supabase.storage.from(bucket).move(location.path, targetPath); if (moveResult.error) throw moveResult.error; 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 .from("media_assets") .update({ @@ -172,13 +195,18 @@ export async function POST(request: Request) { typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${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; if (payload.action === "copy") { const copyRes = await supabase.storage.from(bucket).copy(location.path, targetPath); if (copyRes.error) throw copyRes.error; 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 .from("media_assets") .insert({ @@ -202,7 +230,10 @@ export async function POST(request: Request) { const moveRes = await supabase.storage.from(bucket).move(location.path, targetPath); if (moveRes.error) throw moveRes.error; 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 .from("media_assets") .update({ diff --git a/wolai-frontend/src/app/api/media/sign/route.ts b/wolai-frontend/src/app/api/media/sign/route.ts index c67103fe..c182b7bc 100644 --- a/wolai-frontend/src/app/api/media/sign/route.ts +++ b/wolai-frontend/src/app/api/media/sign/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; export const dynamic = "force-dynamic"; @@ -66,6 +67,8 @@ export async function GET(request: Request) { signedUrl = signedUrlData.signedUrl; } + signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL); + return NextResponse.json({ signedUrl, asset: { diff --git a/wolai-frontend/src/app/api/media/signed-url/route.ts b/wolai-frontend/src/app/api/media/signed-url/route.ts index a1aa5594..ab652d22 100644 --- a/wolai-frontend/src/app/api/media/signed-url/route.ts +++ b/wolai-frontend/src/app/api/media/signed-url/route.ts @@ -1,8 +1,28 @@ import { NextResponse } from "next/server"; import supabaseAdmin from "@/lib/supabase/admin"; +import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; export const dynamic = "force-dynamic"; +const applyHostOverride = (rawUrl: string, hostOverride: string) => { + try { + const u = new URL(rawUrl); + + // 支持两种写法:hostname 或完整 origin(https://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) => { try { const url = new URL(fileUrl); @@ -51,27 +71,22 @@ export async function GET(request: Request) { const { bucket, path } = parsed; - const { data, error } = await supabaseAdmin.storage - .from(bucket) - .createSignedUrl(path, 60 * 60, { download: fileName }); + // OnlyOffice 需要“可被文档服务器拉取”的 URL:不要强制 download(Content-Disposition: attachment)。 + const { data, error } = forOnlyOffice + ? await supabaseAdmin.storage.from(bucket).createSignedUrl(path, 60 * 60) + : await supabaseAdmin.storage.from(bucket).createSignedUrl(path, 60 * 60, { download: fileName }); if (error || !data?.signedUrl) { return NextResponse.json({ error: error?.message ?? "生成签名 URL 失败" }, { status: 500 }); } let signedUrl = data.signedUrl; - if ( - forOnlyOffice && - hostOverride && - (signedUrl.includes("127.0.0.1") || signedUrl.includes("localhost")) - ) { - try { - const url = new URL(signedUrl); - url.hostname = hostOverride; - signedUrl = url.toString(); - } catch { - // ignore parse errors - } + if (forOnlyOffice && hostOverride) { + // 优先按 OnlyOffice 专用回源地址改写(可设置为 http://host.docker.internal:18000 以降低延迟) + signedUrl = applyHostOverride(signedUrl, hostOverride); + } else { + // 返回给浏览器的 URL 必须是公网可达的(不能是 127.0.0.1:18000) + signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL); } return NextResponse.json({ signedUrl }); diff --git a/wolai-frontend/src/app/api/media/upload/route.ts b/wolai-frontend/src/app/api/media/upload/route.ts index e06ae457..47b4fa69 100644 --- a/wolai-frontend/src/app/api/media/upload/route.ts +++ b/wolai-frontend/src/app/api/media/upload/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; import type { MediaAsset } from "@/types/media"; import { extname } from "path"; +import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; export const dynamic = "force-dynamic"; @@ -28,6 +29,7 @@ export async function POST(request: Request) { const file = formData.get("file"); const workspaceId = String(formData.get("workspaceId") ?? ""); const documentId = String(formData.get("documentId") ?? ""); + const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim(); if (!(file instanceof File) || !workspaceId || !documentId) { return NextResponse.json({ error: "缺少必要参数" }, { status: 400 }); @@ -38,7 +40,11 @@ export async function POST(request: Request) { const buffer = Buffer.from(arrayBuffer); const extension = extname(file.name || "").replace(/\s+/g, ""); 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 { 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 = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL); + const { data: asset, error } = await supabase .from("media_assets") .insert({ diff --git a/wolai-frontend/src/app/api/sidebar/route.ts b/wolai-frontend/src/app/api/sidebar/route.ts index 561870dc..5b62b2ab 100644 --- a/wolai-frontend/src/app/api/sidebar/route.ts +++ b/wolai-frontend/src/app/api/sidebar/route.ts @@ -6,6 +6,7 @@ import type { SidebarInitialData } from "@/components/sidebar/types"; import { detectLocalMindmapFiles, detectLocalMindmapDocs, + detectLocalMindmapImageAssetIdsByMindmapId, detectLocalTrashedMindmapAssets, } from "@/lib/mindmap-files"; import type { MediaAsset } from "@/types/media"; @@ -37,6 +38,8 @@ export async function GET(request: Request) { const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId); const docIds = dataset.documents.map((d) => d.id); const localMindmapFiles = await detectLocalMindmapFiles(docIds); + const mindmapAssetChildren = + await detectLocalMindmapImageAssetIdsByMindmapId(localMindmapFiles); const mindmapDocs = Array.from(new Set([...(dataset.mindmapDocs ?? []), ...(await detectLocalMindmapDocs(docIds))])); const trashedMindmapAssets = await detectLocalTrashedMindmapAssets(targetWorkspaceId, docIds); const docById = new Map(dataset.documents.map((d) => [d.id, d])); @@ -100,6 +103,7 @@ export async function GET(request: Request) { trashedMindmapAssets, mindmapDocs, mindmapAssets, + mindmapAssetChildren, tableAssets, mediaAssets: dataset.mediaAssets ?? [], }; diff --git a/wolai-frontend/src/app/onlyoffice/page.tsx b/wolai-frontend/src/app/onlyoffice/page.tsx index 908097ac..7517cc5f 100644 --- a/wolai-frontend/src/app/onlyoffice/page.tsx +++ b/wolai-frontend/src/app/onlyoffice/page.tsx @@ -2,9 +2,13 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; +import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel"; +import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; type EditorMode = "view" | "edit"; +const MNOTE_AGENT_PLUGIN_GUID = "asc.{F2B9A7B4-3A22-4E0E-8B32-3B0DE7AE8A60}"; + const loadScript = (src: string) => new Promise((resolve, reject) => { const existing = document.querySelector(`script[src="${src}"]`); @@ -34,10 +38,13 @@ const docTypeFromExt = (ext: string) => { const word = ["doc", "docx", "odt", "rtf"]; const slide = ["ppt", "pptx", "odp"]; const sheet = ["xls", "xlsx", "ods", "csv"]; - if (word.includes(ext)) return "text"; - if (slide.includes(ext)) return "presentation"; - if (sheet.includes(ext)) return "spreadsheet"; - return "text"; + const pdf = ["pdf"]; + // 说明:ONLYOFFICE 文档类型使用 word/cell/slide/pdf(旧的 text/spreadsheet/presentation 已逐步弃用) + if (word.includes(ext)) return "word"; + if (slide.includes(ext)) return "slide"; + if (sheet.includes(ext)) return "cell"; + if (pdf.includes(ext)) return "pdf"; + return "word"; }; export default function OnlyOfficePage() { @@ -46,27 +53,38 @@ export default function OnlyOfficePage() { const fileName = params.get("fileName") ?? "未命名文档"; const fileType = (params.get("fileType") ?? "docx").toLowerCase(); const mode = (params.get("mode") ?? "edit") as EditorMode; + const assetId = params.get("assetId") ?? ""; const [error, setError] = useState(null); const baseUrl = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL; const storageHostOverride = process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE; - const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]); + const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]); const resolvedFileUrl = useMemo(() => { 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 { - const u = new URL(fileUrl); - if ( - storageHostOverride && - (u.hostname === "127.0.0.1" || - u.hostname === "localhost" || - u.hostname === "host.docker.internal") - ) { - u.hostname = storageHostOverride; + const u = new URL(base); + if (!storageHostOverride) return u.toString(); + + // 兼容两种写法:hostname 或完整 origin(https://xxx) + if (/^https?:\/\//i.test(storageHostOverride)) { + const ov = new URL(storageHostOverride); + u.protocol = ov.protocol; + u.host = ov.host; + return u.toString(); } + + u.hostname = storageHostOverride; return u.toString(); } catch { - return fileUrl; + return base; } }, [fileUrl, storageHostOverride]); @@ -87,6 +105,7 @@ export default function OnlyOfficePage() { throw new Error("未检测到 DocsAPI,请检查 ONLYOFFICE 版本。"); } // 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", { width: "100%", height: "100%", @@ -103,6 +122,10 @@ export default function OnlyOfficePage() { customization: { feedback: { visible: false }, }, + plugins: { + autostart: [MNOTE_AGENT_PLUGIN_GUID], + pluginsData: [pluginConfigUrl], + }, }, }); }) @@ -120,5 +143,17 @@ export default function OnlyOfficePage() { ); } - return
; + return ( +
+
+ +
+ ); } diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx index 0760bc19..5dab7977 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx @@ -579,7 +579,7 @@ export function DocumentAiAgentPanel({ return ( - + diff --git a/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx b/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx index 8e067edd..7104ad51 100644 --- a/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx @@ -115,7 +115,7 @@ const MediaBlockContent = ({ block, editor }: any) => { }, [block.props.fileName, fileUrl]); 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], diff --git a/wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx b/wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx index ac5ab04b..551fe2b5 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx @@ -1,7 +1,10 @@ "use client"; 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 = { kind: "media" | "local-mindmap" | "test-pdf"; @@ -20,6 +23,54 @@ type MindmapInstanceLike = { 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 } + | { 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 => typeof v === "object" && v !== null && !Array.isArray(v); @@ -99,45 +150,40 @@ export function MindmapAiAgentPanel({ mindmapId, mindmap, activeNodes, + onClose, }: { documentId: string; mindmapId: string; mindmap: MindmapInstanceLike | null | undefined; activeNodes: unknown[]; + onClose?: () => void; }) { - const [messages, setMessages] = useState([ - { - role: "assistant", - content: - "你好,我是思维导图 AI Agent。你可以:\n- 直接说需求(例如:补完选中节点、总结 @PDF 并写入导图、从 @PDF 生成章/节结构导图)\n- 使用 @ 选择文件或上传文件\n- 让 AI 自动选择工具,或手动勾选允许使用的工具", - }, - ]); + const [messages, setMessages] = useState(() => DEFAULT_SESSION_MESSAGES); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [networkOn, setNetworkOn] = useState(true); const [toolAuto, setToolAuto] = useState(true); + // 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险 const [toolPickerOpen, setToolPickerOpen] = useState(false); const [selectedTools, setSelectedTools] = useState(DEFAULT_TOOLS); const [aiProvider, setAiProvider] = useState<"online" | "local">("online"); const [aiModel, setAiModel] = useState(""); const [maxSteps, setMaxSteps] = useState(10); + const [page, setPage] = useState("chat"); const [assets, setAssets] = useState([]); const [workspaceId, setWorkspaceId] = useState(""); const [attachments, setAttachments] = useState([]); const [debug, setDebug] = useState(""); - const [toolLogs, setToolLogs] = useState< - Array< - | { type: "tool_call"; id: string; tool: string; args: Record } - | { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown } - | { type: "error"; message: string } - > - >([]); + const [toolLogs, setToolLogs] = useState([]); + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(""); const textareaRef = useRef(null); const fileInputRef = useRef(null); const abortRef = useRef(null); + const syncTimerRef = useRef(null); useEffect(() => { try { @@ -165,6 +211,112 @@ export function MindmapAiAgentPanel({ } }, [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 [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 content = input.trim(); if (!content) return; setDebug(""); 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 }]; setMessages(nextMessages); @@ -604,6 +887,421 @@ export function MindmapAiAgentPanel({ return (
+
+
+
+ {page === "chat" ? ( + + ) : ( + + )} + {pageTitle} +
+
+ + + + + + +
+
+
+ +
+ {page === "chat" ? ( + <> +
(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})` : ""}` + : "未选中节点(将以整图为上下文)"} +
+ +
+
+
对话
+ +
+ {messages.map((m, idx) => ( +
+
{m.role === "user" ? "用户" : "AI"}
+
{m.content}
+
+ ))} +
+
+
+ +
+
工具日志
+ +
+ {toolLogs.length === 0 ?
暂无工具日志
: null} + {toolLogs.map((l, idx) => { + if (l.type === "error") { + return ( +
+ 错误:{l.message} +
+ ); + } + if (l.type === "tool_call") { + return ( +
+ + tool_call · {l.tool} · {l.id} + +
{JSON.stringify(l.args, null, 2)}
+
+ ); + } + return ( +
+ + tool_result · {l.tool} · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms + +
{JSON.stringify(l.result, null, 2)}
+
+ ); + })} +
+
+
+
+ +
+ {attachments.length > 0 ? ( +
+ {attachments.map((a) => ( + + {a.title} + + + ))} +
+ ) : null} + +
+ {mentionOpen && filteredAssets.length > 0 ? ( +
+ {filteredAssets.map((a) => ( + + ))} +
+ ) : null} + +