diff --git a/.gitignore b/.gitignore index 6258d17a..c6b262c4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ temp/ .venv/ **/__pycache__/ __pycache__/ +# Convex 本机开发临时目录 +**/.convex-tmp/ # 大模型文件,防止超出 LFS 配额 services/mineru/models/ *.safetensors @@ -42,4 +44,4 @@ cankao/ pw-tests/** pw-tests/ wolai-frontend/public/documents/ -wolai-frontend/public/documents/** \ No newline at end of file +wolai-frontend/public/documents/** diff --git a/Coder-Codex-Gemini b/Coder-Codex-Gemini new file mode 160000 index 00000000..732de607 --- /dev/null +++ b/Coder-Codex-Gemini @@ -0,0 +1 @@ +Subproject commit 732de60740d58d01db9b7f978472d738adc14a80 diff --git a/design/convex-mir-design b/design/convex-mir-design new file mode 100644 index 00000000..c4d18b0f --- /dev/null +++ b/design/convex-mir-design @@ -0,0 +1,358 @@ + 可以,按你选的 1A/2B/3B,我建议的落地方案是: + + - Convex:自托管 Docker Compose(官方) + - 对象存储:MinIO(S3 兼容) + - 鉴权:固定“开发用户”(仅开发态),所有权限校验先按这个用户走 + - 后端:不再以 FastAPI/Celery 为主链路,长任务改为 Convex action + scheduler;如后续需要 OCR/RAG 重计算,再让 Convex + action 去调用现有 services/* HTTP 服务 + + 下面是我建议的“可执行迁移流程设计”(按阶段推进,每一阶段都能跑通): + + ——— + + ## 一、部署方案(Windows 本机) + + ### A. 运行组件 + + 1. convex-backend(端口:3210 + 3211 actions) + 2. convex-dashboard(端口:6791) + 3. minio(端口:9000 S3 API,9001 Console) + 4. minio-init(启动时自动建 bucket) + + ### B. 目录规划(建议) + + - infra/convex/ + - docker-compose.yml(Convex backend + dashboard + MinIO) + - .env(只放本机,不提交;或提交 .env.example) + - README.md(写清启动端口与初始化) + + ### C. 关键环境变量(Convex backend 使用 MinIO 作为 S3 存储) + + Convex 自托管支持把导出/导入/模块/用户文件/搜索索引都放到 S3,并允许通过 S3_ENDPOINT_URL 连接 MinIO。你将配置这些 + bucket(示例名): + + - convex-snapshot-exports + - convex-snapshot-imports + - convex-modules + - convex-user-files + - convex-search-indexes + + 并在 backend 容器里设置(示例): + + - AWS_REGION=us-east-1 + - AWS_ACCESS_KEY_ID= + - AWS_SECRET_ACCESS_KEY= + - S3_ENDPOINT_URL=http://minio:9000 + - S3_STORAGE_EXPORTS_BUCKET=...(以及其余 4 个) + + > 这样后续你在 Convex 里用文件能力(上传/存储/取 URL)会自然落到 MinIO,不需要再引入 Supabase Storage 或自写一套 presigned > 上传逻辑。 + + ——— + + ## 二、迁移总体策略(符合“先跑通但都换掉”) + + 你的项目目前最大的问题不是“后端”,而是数据访问散落在 Next Route Handlers + Supabase。迁移应以 “把 Supabase 数据面替换成 + Convex” 为主线,同时把鉴权先简化为固定用户。 + + 我建议采用“门面不变、内核替换”的方式: + + - 保留现有 wolai-frontend/src/app/api/**/route.ts 路由不变(前端 UI 不用立刻大改) + - 逐个把这些 route handler 内部从 supabase.* 改为 convex query/mutation/action + - 用一个总开关 USE_CONVEX=1 控制,方便随时切回 Supabase 对照(你没有用户数据,回退成本也接近 0) + + ——— + + ## 三、阶段化详细流程(每阶段验收点明确) + + ### 阶段 0:盘点与冻结范围(半天内) + + 目标:明确“哪些模块先迁、哪些先不动”,避免一次改爆。 + + - 以 CODE_INDEX.md 为基准,确认实际运行入口是 wolai-frontend/(不是根目录 src/)。 + - 先只迁:documents / workspaces / workspace_members / mindmap / background_tasks(足够跑主流程) + - 暂不迁或延后:luckysheet_ws(你现在也没实际测试/用户) + + 验收:列出一张迁移模块清单(我可以直接在仓库里生成 infra/convex/MIGRATION.md,你确认后再动代码)。 + + ——— + + ### 阶段 1:Convex + MinIO 跑起来(不改业务代码) + + 目标:本机基础设施 ready。 + + - 在 infra/convex/ 放置 compose,docker compose up -d + - 生成 admin key:docker compose exec backend ./generate_admin_key.sh + - 在 wolai-frontend/.env.local 配: + - CONVEX_SELF_HOSTED_URL=http://127.0.0.1:3210 + - CONVEX_SELF_HOSTED_ADMIN_KEY=... + - USE_CONVEX=1 + + 验收: + + - Dashboard 能打开:http://localhost:6791 + - MinIO Console 能打开:http://localhost:9001 + - Convex CLI 能连上自托管(下一阶段会做) + + ——— + + ### 阶段 2:在 wolai-frontend/ 初始化 Convex 项目(最小 demo) + + 目标:让前端工程具备 convex/ 目录与生成类型的能力。 + + - 安装 convex 依赖 + - 初始化 wolai-frontend/convex/(schema + demo function) + - 跑一次 npx convex dev(自托管模式,指向 CONVEX_SELF_HOSTED_URL) + + 验收:写一个 ping query,Next.js 页面/route 能调用到并返回结果。 + + ——— + + ### 阶段 3:引入“固定开发用户”与权限骨架(替代 Supabase Auth/RLS) + + 目标:所有数据访问都通过同一套“开发用户上下文”注入,避免到处散落假逻辑。 + 建议实现方式: + + - wolai-frontend/src/lib/auth/devUser.ts: + - getDevUser() 固定返回 { userId, email, name }(从 .env.local 读取,默认常量) + - Convex functions 不接受“任意 userId 参数”,而是由 route handler 统一注入(现在固定,未来替换为真实 auth) + + 权限策略(先最小化): + + - 所有写操作:要求 workspace_members 存在(你可以先自动把 dev 用户加入默认 workspace) + - 所有读操作:同上 + - 未来接入真实 auth 时,只需要把 getDevUser() 替换为 getAuthedUser(),Convex 侧的权限函数不变 + + 验收:不依赖 Supabase token,也能跑通“创建默认 workspace + 创建文档”。 + + ——— + + ### 阶段 4:迁移核心数据模型与最小 CRUD(documents/workspaces) + + 目标:主流程跑通(侧边栏、打开文档、保存)。 + + - 在 Convex schema 中创建对应集合(建议保留你现有 UUID 作为业务 id 字段,并建立唯一索引,避免 URL/引用大改) + - 实现 queries/mutations: + - workspaces.getOrCreateDefaultForUser + - documents.listByWorkspace + - documents.getById + - documents.create + - documents.updateContent(含 raw_text/index_status 等你现在写回字段) + + 然后改 wolai-frontend/src/app/api/documents/**/route.ts 内部实现: + + - 先只改:create/content/save/title/options/list 等最常用路径 + - 暂时保留 Supabase 版本分支(USE_CONVEX 开关) + + 验收:你打开应用后能创建/读取/保存文档(数据落在 Convex),且不再访问 Supabase 表。 + + ——— + + ### 阶段 5:迁移 mindmap 与任务(替换 Celery/trigger 的思路) + + 目标:把“异步/任务/进度”统一到 Convex。 + + - background_tasks:做成 Convex 表 + - startOcr:mutation 创建任务记录,然后 scheduler.runAfter() 触发 action + - ocrPipeline:action 模拟 OCR(对齐你现在 wolai-backend/app/workers/tasks.py 的行为:更新进度、回写文档内容、任务 + completed) + + 验收: + + - 前端调用“OCR/任务”相关 API 能返回 taskId + - 任务状态能轮询/订阅(先轮询即可) + - 不再需要 wolai-backend 的 Celery 才能完成基本链路 + + ——— + + ### 阶段 6:文件存储(MinIO + Convex file storage) + + 目标:后续做媒体/附件时不会再回到 Supabase Storage。 + + - 保持 Convex backend 已配置 S3(MinIO) + - 在 Convex 里实现: + - files.generateUploadUrl + - files.saveMeta(把文件与 document/workspace 关联) + - files.getUrl(用于展示/下载) + + 验收:能上传一个文件到 Convex file storage(底层落 MinIO),并在页面拿到可访问 URL。 + + ——— + + 到目前为止,你已经跑通了“文档/附件 + Convex 自部署”这条主链路;接下来要做的事,本质上是:把 + USE_CONVEX=1 时仍在走 Supabase 的那些模块逐步“替换或下线”,直到项目真的只依赖 Convex。 + + - 先确认“还剩哪些 Supabase 依赖” + - wolai-frontend/src/ 下仍有大量 Supabase 路由/工具在用:mindmap、references(RPC)、onlyoffice、 + luckysheet、ai-agent、sidebar、search(recent) 等(目前大概率是“Supabase 分支/兜底分支”)。 + - services/ingest_service/ 仍通过 Supabase REST 做任务/索引/清理(属于后续 RAG/索引链路)。 + - 根目录 src/ 也还有 Supabase 用法(更像历史/备用 Next 工程,若不参与桌面构建可先不动)。 + - 按你“尽量一体化绑死 Convex”的优先级,建议下一步这样排 + 1. 鉴权/权限从“固定用户”升级为可扩展的真实方案:否则后面所有“按用户/工作区隔离”都只能靠约定。 + Convex Auth 是官方路线之一,但对 Next.js server 侧支持仍在演进中,需要你接受一定不稳定/适配成 + 本。citeturn0search1 + 2. 把仍依赖 Supabase Storage 的功能全部切到 Convex Files:你已经验证 Dashboard Files 可见;下一步 + 是把 luckysheet/onlyoffice 等涉及上传/签名 URL 的地方也迁掉(或临时 501 下线),保证 + USE_CONVEX=1 时不会再触发 Supabase Storage。citeturn0search5 + 3. 把“搜索/索引/推荐”从 Postgres/RPC 思路迁到 Convex:文档搜索走 Convex 全文检索;RAG/embedding 走 Vector Search(注意向量检索需要在 action 里跑)。citeturn0search2turn0search4 + 4. 把 services/ingest_service 的“队列/任务状态机”迁出 Supabase:要么先停用该服务;要么改成 Convex + 内部的任务表 + actions + scheduler(这样系统更一体化)。 + 5. 做运维闭环(自部署必需):明确 Docker 卷备份/恢复策略(Convex 也在推进更易用的数据备份/恢复能 + 力,但你仍应以卷级备份为底线)。citeturn0search3 + - 关于“Convex 组件”与你项目的适配结论 + - Convex “Components”适合把通用能力(比如协作编辑、鉴权、工作流)做成可插拔模块,并且支持隔离/复 + 用;你的项目属于“练手快速迭代”,很适合用组件化方式逐块替换 Supabase 逻辑。citeturn1search0 + - Files 这一块:Convex 的 Files 更像“一个大池子/大桶”,不强调文件夹;你要的“按用户/工作区区分、路 + 径/目录视图”,仍建议用业务表字段(workspace_id、user_id、path)来实现管理与展示,这是最贴合你“一 体化 + 不引入外部对象存储”的路线。citeturn0search5 + checklist: + - M1(已完成):documents/workspaces/media/search 全链路 Convex 化 + 冒烟回归 + - M2:Mindmap 数据与接口迁移(/api/mindmap/**、/api/mindmap-trash/empty),落到 Convex(优先复用 + documents.mindmap_data) + - M3:References(页面引用/反链)迁移(/api/references/record、/api/references/backlinks),补齐目前的 占位实现 + - M4:AI Agent(/api/ai-agent/run、/api/ai-agent/client-tool-result)去 Supabase 化(鉴权/读写文档/工 + 具回调) + - M5:Online Table / Luckysheet(/api/tables/**、/api/luckysheet/**)迁移或在 Convex 模式下先禁用(给 + 出明确 UI 提示) + - M6:OnlyOffice(/api/onlyoffice/*)迁移或在 Convex 模式下先禁用(同上) + - M7:服务侧(services/ingest_service 等)去 Supabase 化:任务表/状态机迁到 Convex jobs/actions(或先 + 停用该链路) + - M8:鉴权从“固定用户”升级为可扩展方案(仍保持 Convex 一体化) + - M9:运维闭环:Convex 数据/Files 卷备份恢复、日志与健康检查、启动脚本收敛 + + 我刚做完的(你现在的代码状态) + + - 补齐 documents 的最后缺口:wolai-frontend/src/app/api/documents/embed/route.ts:1 已支持 Convex。 + - 抽了一个路由侧通用 helper:wolai-frontend/src/lib/convex/route.ts:1(统一拿 auth+client)。 + - “最近访问”已落库到 Convex:wolai-frontend/convex/schema.ts:1 新增 user_recent_pages;wolai-frontend/ convex/recents.ts:1 + 接入 wolai-frontend/src/app/api/search/recent/route.ts:1、wolai-frontend/src/ + app/api/search/documents/route.ts:1。 + - 回归脚本已扩展并跑通:pw-tests/scripts/e2e_convex_smoke.py:1;最新产物 pw-tests/artifacts/convex- + smoke-20260116-194415.png:1、pw-tests/artifacts/convex-smoke-console-20260116-194415.log:1。 + + 接下来我建议从 M2(Mindmap)开始做:它目前是 Convex 模式下仍“纯 Supabase 路由”的最大功能块。你希望优先 + 做 Mindmap,还是先做 AI Agent/OnlyOffice/表格? + + ——— + + ## 补充规格:编辑器“移动/嵌入到...”按 wolai 机制复刻(同页嵌入先禁止) + + ### 0. 结论(本次明确的产品语义) + + - “嵌入到...” = **块引用(同步编辑)**:目标页面插入“嵌入引用块”,引用源块;源块仍留在原位置。 + - “移动到...” = **移动块本体**:源块(含子树)从当前页面移到目标页面;块 ID 保持不变。 + - **同页嵌入先禁止**:当目标页面 = 源块所在页面时,禁止“嵌入到...”(避免递归/复杂边界)。 + + > 说明:当前代码里(CustomSideMenu/sidebar)做的是“把块变成子页面 + 插入 pageReference”,这不等价于 wolai 的块引用/块移动,需要整体重做。 + + ### 1. 术语 + + - 源块(sourceBlock):用户在编辑器里选中的那个块(BlockNote block),有稳定 `blockId`。 + - 源页面(sourceDoc):包含源块的页面(document)。 + - 目标页面(targetDoc):用户在弹窗里选择的页面。 + - 块引用(blockReference):一种特殊块,指向 `targetBlockId`,显示/编辑代理到源块。 + + ### 2. 行为规格(可直接转成 pw-tests 验收点) + + #### 2.1 “移动到...”(Move) + + - 触发:块左侧拖拽菜单 → “移动/嵌入到...” → 选“移动到” → 选目标页面。 + - 结果: + - 源页面:源块(含 children 子树)消失。 + - 目标页面:插入源块子树(MVP 先插在末尾)。 + - 不变量: + - 源块 `blockId` 不变(未来引用/链接仍指向同一块)。 + - 子树结构保持不变(children 仍挂在源块下)。 + - 限制: + - 选择目标页面为当前页面:视为 no-op(提示“已在当前页面”或直接关闭)。 + - 无权限/不存在:失败提示。 + + #### 2.2 “嵌入到...”(Embed) + + - 触发:块左侧拖拽菜单 → “移动/嵌入到...” → 切换“嵌入到” → 选目标页面。 + - 结果: + - 源页面:源块保持原位。 + - 目标页面:新增一个 `blockReference`(display=embed),`targetBlockId = 源块.blockId`(MVP 先插在末尾)。 + - 同步编辑: + - 在目标页面的嵌入引用中编辑内容,实际修改的是源块(刷新源页面可见变化)。 + - 删除语义: + - 删除目标页面里的引用块,只移除“引用”,不删除源块本体。 + - 跳转语义(先对齐 wolai 思路,后续可微调): + - 嵌入引用块本体不强制“点击跳转”;但在块菜单提供“跳转到原块”动作。 + - 限制: + - **同页嵌入禁止**:`targetDocId === sourceDocId` 时直接禁止(UI 禁用 + API 双重校验)。 + - 无权限/不存在:失败提示。 + + ### 3. 数据结构设计(MVP,兼容你当前“整页 content JSON”) + + #### 3.1 新增块类型:blockReference(区分于 pageReference) + + - `type: "blockReference"` + - `props`(建议): + - `targetBlockId: string`(必填) + - `display: "inline" | "embed"`(MVP 用 embed) + - `alias?: string`(行内引用别名,后续再做) + + #### 3.2 块索引(block_index)——用于从 blockId 反查所在页面 + + 因为块仍存放在 `documents.content` 内(整页 JSON),为了实现: + - “跳转到原块” + - “嵌入引用渲染/编辑时找到源块” + + 需要一个索引集合(Convex 表): + - `block_index { blockId, documentId, workspaceId, updatedAt }` + + 维护方式(MVP): + - 每次保存页面 content 时(documents.save / documents.updateContent),解析 blocks,批量 upsert 索引。 + - Move 操作需要同时更新源/目标页的索引(或依赖后续 save 再修正,但建议 move 立刻修正)。 + + #### 3.3 引用边(可选,但很有用) + + - `reference_edges { sourceDocumentId, targetBlockId, createdAt }` + - 用途:反链面板(Backlinks)、统计、权限校验辅助。 + + ### 4. API 设计(建议新增 blocks 维度接口,避免滥用 documents/embed) + + > 目标:把“块移动/块引用”从“创建子页面 + pageReference”的错误语义中解耦出来。 + + #### 4.1 `POST /api/blocks/move` + + - 入参:`{ sourceDocumentId, blockId, targetDocumentId, position?: "end" }` + - 行为:从 sourceDoc content 移除 block 子树,追加到 targetDoc content;更新索引。 + + #### 4.2 `POST /api/blocks/embed` + + - 入参:`{ sourceDocumentId, blockId, targetDocumentId, position?: "end" }` + - 行为:校验非同页;在 targetDoc content 追加 `blockReference(targetBlockId=blockId, display="embed")`。 + + #### 4.3 `GET /api/blocks/get?blockId=...` + + - 返回:`{ documentId, block, path? }` + - 用途:渲染引用块、跳转到原块、hover 预览(后续)。 + + #### 4.4 `POST /api/blocks/patch`(嵌入引用的同步编辑) + + - 入参:`{ blockId, patch }`(MVP 可先做 `replaceBlock`:提交完整 block JSON) + - 行为:定位源块所在文档,修改该块内容并保存;广播刷新(后续可用 Convex 订阅优化)。 + + ### 5. UI / 交互设计(与现有 MoveEmbedPickerDialog 的对接) + + - 仍复用现有 `MoveEmbedPickerDialog` 做“选页面”能力。 + - 在“嵌入到”模式下: + - Picker 直接排除当前页面(`excludeIds=[currentDocumentId]`),并在选中时二次校验。 + - 行为完成后的反馈: + - Move:toast “已移动到 XXX” + - Embed:toast “已在目标页面末尾插入引用块” + + ### 6. pw-tests 验收用例(最小集合,锁定行为不跑偏) + + - `move_basic`:A 页面块 → Move 到 B;断言 A 不存在、B 存在,且块 `blockId` 未变化。 + - `embed_basic`:A 页面块 → Embed 到 B;断言 A 仍存在、B 出现 `blockReference(targetBlockId=...)`。 + - `embed_edit_sync`:在 B 的嵌入引用中编辑 → 刷新 A → 断言源块同步变化。 + - `embed_same_page_forbidden`:Embed 目标选择当前页 → UI 提示/不可选,且服务端拒绝。 + - `embed_delete_only_reference`:删除 B 的引用块 → A 源块仍存在。 + + ### 7. 迁移实施顺序(避免一次性大爆炸) + + 1) 先落地 `blockReference` 块类型与只读渲染(不做同步编辑)。 + 2) 上 `block_index` 并在保存 content 时维护;补齐 `GET /api/blocks/get`。 + 3) 实现 `POST /api/blocks/embed` + UI 接入(替换旧 documents/embed 的错误语义)。 + 4) 实现 `POST /api/blocks/move`(跨文档移动块子树)。 + 5) 最后做 `POST /api/blocks/patch`,实现嵌入引用的同步编辑与限制(删除语义/跳转)。 diff --git a/infra/convex/README.md b/infra/convex/README.md new file mode 100644 index 00000000..d2de0b62 --- /dev/null +++ b/infra/convex/README.md @@ -0,0 +1,47 @@ +# Convex 自托管(Windows 本机) + +本目录用于在本机通过 Docker Compose 启动 Convex backend + dashboard,并使用 backend 自身的持久化卷存储(包含数据库与文件存储)。 + +## 启动 + +1. 确保 `infra/convex/.env` 存在(已在本仓库中生成,且被 `.gitignore` 忽略)。 +2. 在本目录执行: + +```bash +docker compose --env-file .env up -d +``` + +## 生成 Dashboard / CLI admin key + +在本目录执行: + +```bash +docker compose --env-file .env exec backend ./generate_admin_key.sh +``` + +## 访问地址(默认) + +- Dashboard:`http://localhost:6791` +- Backend:`http://127.0.0.1:3210` +- HTTP Actions:`http://127.0.0.1:3211` +- 文件存储:由 Convex 管理(Dashboard 的 Files 页面可查看) + +## 快速验证(可选) + +### 1) 验证 Convex functions 可用 + +在 `wolai-frontend/` 执行: + +```bash +npx convex dev --once --tail-logs disable --env-file .env.local --run ping:ping +``` + +### 2) 验证固定开发用户(阶段 3) + +启动前端后访问:`http://localhost:3000/api/dev/whoami` + +### 3) 验证异步任务骨架(阶段 5) + +启动前端后: +- `POST http://localhost:3000/api/dev/jobs/demo`(body 可选:`{ "ms": 800 }`)创建 demo job +- `GET http://localhost:3000/api/dev/jobs/demo?id=` 查询状态(queued/running/succeeded/failed) diff --git a/infra/convex/docker-compose.yml b/infra/convex/docker-compose.yml new file mode 100644 index 00000000..f830c8fb --- /dev/null +++ b/infra/convex/docker-compose.yml @@ -0,0 +1,48 @@ +services: + backend: + # 说明:官方自托管镜像。需要时可将 :latest 固定为特定版本。 + image: ghcr.io/get-convex/convex-backend:latest + stop_grace_period: 10s + stop_signal: SIGINT + ports: + - "${PORT:-3210}:3210" + - "${SITE_PROXY_PORT:-3211}:3211" + volumes: + - convex_data_v1:/convex/data + environment: + - ACTIONS_USER_TIMEOUT_SECS + - CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://127.0.0.1:${PORT:-3210}} + - CONVEX_RELEASE_VERSION_DEV + - CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://127.0.0.1:${SITE_PROXY_PORT:-3211}} + - DATABASE_URL + - DISABLE_BEACON + - DOCUMENT_RETENTION_DELAY=${DOCUMENT_RETENTION_DELAY:-172800} # 降低默认保留到 2 天 + - DO_NOT_REQUIRE_SSL + - HTTP_SERVER_TIMEOUT_SECONDS + - INSTANCE_NAME + - INSTANCE_SECRET + - MYSQL_URL + - POSTGRES_URL + - REDACT_LOGS_TO_CLIENT + - RUST_BACKTRACE + - RUST_LOG=${RUST_LOG:-info} + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3210/version"] + interval: 5s + start_period: 10s + + dashboard: + image: ghcr.io/get-convex/convex-dashboard:latest + stop_grace_period: 10s + stop_signal: SIGINT + ports: + - "${DASHBOARD_PORT:-6791}:6791" + environment: + - NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://127.0.0.1:${PORT:-3210}} + - NEXT_PUBLIC_LOAD_MONACO_INTERNALLY + depends_on: + backend: + condition: service_healthy + +volumes: + convex_data_v1: diff --git a/scripts/desktop-hot.js b/scripts/desktop-hot.js index 300fc19a..1a458982 100644 --- a/scripts/desktop-hot.js +++ b/scripts/desktop-hot.js @@ -12,7 +12,7 @@ * - SKIP_CELERY:设为 "1" or "true" 可跳过 Celery。 */ -const { spawn } = require("child_process"); +const { spawn, execSync } = require("child_process"); const path = require("path"); const net = require("net"); const { URL } = require("url"); @@ -29,6 +29,7 @@ const skipCelery = (process.env.SKIP_CELERY || "").toLowerCase() === "true"; const celeryCmdFromEnv = process.env.CELERY_CMD; const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0"; +const frontendPortFromEnv = Number(process.env.FRONTEND_PORT || 3000); const tasks = [ { @@ -48,6 +49,95 @@ const tasks = [ const children = []; let shuttingDown = false; +async function isPortFree(host, port, timeoutMs = 400) { + return await new Promise((resolve) => { + const socket = net.createConnection({ host, port }); + const timer = setTimeout(() => { + socket.destroy(); + resolve(true); + }, timeoutMs); + + socket.once("connect", () => { + clearTimeout(timer); + socket.end(); + resolve(false); + }); + + socket.once("error", () => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +async function findFreePort(startPort) { + let port = Number.isFinite(startPort) ? Math.floor(startPort) : 3000; + port = Math.max(1, Math.min(65535, port)); + + // 最多尝试 20 个端口,避免无限循环。 + for (let i = 0; i < 20; i += 1) { + // eslint-disable-next-line no-await-in-loop + const free = await isPortFree("127.0.0.1", port); + if (free) return port; + port += 1; + } + + return startPort; +} + +function getListeningPidsByPort(port) { + try { + // 说明:netstat 输出示例: + // TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345 + const out = execSync("netstat -ano -p tcp", { encoding: "utf8" }); + const pids = new Set(); + for (const line of out.split(/\r?\n/)) { + if (!line.includes(`:${port}`)) continue; + if (!/LISTENING/i.test(line)) continue; + const parts = line.trim().split(/\s+/); + const pid = Number(parts[parts.length - 1]); + if (Number.isFinite(pid) && pid > 0) { + pids.add(pid); + } + } + return [...pids]; + } catch { + return []; + } +} + +async function ensurePortFree(port, nameForLog) { + const free = await isPortFree("127.0.0.1", port); + if (free) return true; + + const pids = getListeningPidsByPort(port); + if (pids.length === 0) { + logPrefix(nameForLog, `检测到端口 ${port} 被占用,但无法定位 PID。`); + return false; + } + + logPrefix(nameForLog, `检测到端口 ${port} 被占用,准备重启(结束旧进程):${pids.join(", ")}`); + for (const pid of pids) { + try { + execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); + } catch { + // ignore + } + } + + // 等待端口释放 + for (let i = 0; i < 20; i += 1) { + // eslint-disable-next-line no-await-in-loop + const ok = await isPortFree("127.0.0.1", port, 250); + if (ok) return true; + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, 150)); + } + + logPrefix(nameForLog, `端口 ${port} 仍未释放,可能有其他程序占用。`); + return false; +} + function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) return {}; const content = fs.readFileSync(filePath, "utf8"); @@ -160,6 +250,35 @@ async function checkRedisReachable(urlString, timeoutMs = 2000) { } async function main() { + // 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。 + // 这里在启动前做一次“安全清理”,避免重启后仍卡住。 + const nextDevLockPath = path.join(frontendDir, ".next", "dev", "lock"); + try { + if (fs.existsSync(nextDevLockPath)) { + fs.rmSync(nextDevLockPath, { force: true }); + logPrefix("frontend", `检测到残留的 Next dev lock,已移除:${nextDevLockPath}`); + } + } catch (error) { + logPrefix("frontend", `尝试移除 Next dev lock 失败:${error.message}`); + } + + // 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。 + // 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。 + const desiredFrontendPort = frontendPortFromEnv; + const frontendPortOk = await ensurePortFree(desiredFrontendPort, "frontend"); + if (!frontendPortOk) { + console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`); + process.exit(1); + } + const frontendPort = desiredFrontendPort; + const frontendUrl = `http://localhost:${frontendPort}`; + + // 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。 + // 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。 + tasks[0].command = process.env.FRONTEND_CMD || `pnpm dev -p ${frontendPort}`; + logPrefix("frontend", `前端目录:${frontendDir}`); + logPrefix("frontend", `前端地址:${frontendUrl}`); + if (!skipCelery) { const celeryTask = { name: "celery", diff --git a/wolai-backend/app/config.py b/wolai-backend/app/config.py index 06e86c2c..7b610ee2 100644 --- a/wolai-backend/app/config.py +++ b/wolai-backend/app/config.py @@ -7,12 +7,14 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") - supabase_url: str - supabase_service_role_key: str + # 说明:项目迁移到 Convex 的过程中,后端可能暂时不依赖 Supabase。 + # 因此这里给出空字符串默认值,避免本地未配置 .env 时无法启动开发服务器。 + supabase_url: str = "" + supabase_service_role_key: str = "" redis_url: str = "redis://localhost:6379/0" frontend_url: str = "http://localhost:3000" openai_api_key: str = "" - lightrag_db_url: str + lightrag_db_url: str = "" lightrag_collection: str = "wolai-docs" diff --git a/wolai-frontend/convex/_generated/api.d.ts b/wolai-frontend/convex/_generated/api.d.ts new file mode 100644 index 00000000..f01d388d --- /dev/null +++ b/wolai-frontend/convex/_generated/api.d.ts @@ -0,0 +1,65 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as _utils_time from "../_utils/time.js"; +import type * as documents from "../documents.js"; +import type * as jobs from "../jobs.js"; +import type * as mediaAssets from "../mediaAssets.js"; +import type * as mindmaps from "../mindmaps.js"; +import type * as ping from "../ping.js"; +import type * as recents from "../recents.js"; +import type * as references from "../references.js"; +import type * as workspaces from "../workspaces.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; + +declare const fullApi: ApiFromModules<{ + "_utils/time": typeof _utils_time; + documents: typeof documents; + jobs: typeof jobs; + mediaAssets: typeof mediaAssets; + mindmaps: typeof mindmaps; + ping: typeof ping; + recents: typeof recents; + references: typeof references; + workspaces: typeof workspaces; +}>; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export declare const api: FilterApi< + typeof fullApi, + FunctionReference +>; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ +export declare const internal: FilterApi< + typeof fullApi, + FunctionReference +>; + +export declare const components: {}; diff --git a/wolai-frontend/convex/_generated/api.js b/wolai-frontend/convex/_generated/api.js new file mode 100644 index 00000000..44bf9858 --- /dev/null +++ b/wolai-frontend/convex/_generated/api.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi, componentsGeneric } from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/wolai-frontend/convex/_generated/dataModel.d.ts b/wolai-frontend/convex/_generated/dataModel.d.ts new file mode 100644 index 00000000..f97fd194 --- /dev/null +++ b/wolai-frontend/convex/_generated/dataModel.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/wolai-frontend/convex/_generated/server.d.ts b/wolai-frontend/convex/_generated/server.d.ts new file mode 100644 index 00000000..bec05e68 --- /dev/null +++ b/wolai-frontend/convex/_generated/server.d.ts @@ -0,0 +1,143 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/wolai-frontend/convex/_generated/server.js b/wolai-frontend/convex/_generated/server.js new file mode 100644 index 00000000..bf3d25ad --- /dev/null +++ b/wolai-frontend/convex/_generated/server.js @@ -0,0 +1,93 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export const httpAction = httpActionGeneric; diff --git a/wolai-frontend/convex/_utils/time.ts b/wolai-frontend/convex/_utils/time.ts new file mode 100644 index 00000000..e886ba89 --- /dev/null +++ b/wolai-frontend/convex/_utils/time.ts @@ -0,0 +1,4 @@ +export function nowIso(): string { + return new Date().toISOString(); +} + diff --git a/wolai-frontend/convex/documents.ts b/wolai-frontend/convex/documents.ts new file mode 100644 index 00000000..3f48b426 --- /dev/null +++ b/wolai-frontend/convex/documents.ts @@ -0,0 +1,457 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { nowIso } from "./_utils/time"; + +const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public")); + +export const getMeta = query({ + args: { userId: v.string(), id: v.string() }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) return null; + if (doc.user_id !== args.userId) return null; + return { + id: doc.id, + user_id: doc.user_id, + workspace_id: doc.workspace_id, + access_scope: doc.access_scope, + title: doc.title ?? null, + parent_id: doc.parent_id ?? null, + created_at: doc.created_at, + updated_at: doc.updated_at ?? null, + wide_layout: doc.wide_layout ?? null, + use_small_text: doc.use_small_text ?? null, + show_heading_numbers: doc.show_heading_numbers ?? null, + show_toc: doc.show_toc ?? null, + show_structure: doc.show_structure ?? null, + protect_editing: doc.protect_editing ?? null, + show_word_count: doc.show_word_count ?? null, + word_count: doc.word_count ?? null, + character_count: doc.character_count ?? null, + block_count: doc.block_count ?? null, + }; + }, +}); + +export const getContent = query({ + args: { userId: v.string(), id: v.string() }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) return null; + if (doc.user_id !== args.userId) return null; + return { content: doc.content ?? null }; + }, +}); + +export const listByWorkspace = query({ + args: { userId: v.string(), workspaceId: v.string() }, + handler: async (ctx, args) => { + const docs = await ctx.db + .query("documents") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .collect(); + + // 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。 + return docs + .filter((d) => d.user_id === args.userId) + .filter((d) => d.deleted_at == null) + .map((d) => ({ + access_scope: d.access_scope, + id: d.id, + workspace_id: d.workspace_id, + title: d.title ?? "无标题", + parent_id: d.parent_id ?? null, + sort_order: d.sort_order ?? null, + is_starred: d.is_starred ?? null, + is_template: d.is_template ?? false, + created_at: d.created_at, + updated_at: d.updated_at ?? null, + })); + }, +}); + +export const listTrashedByWorkspace = query({ + args: { userId: v.string(), workspaceId: v.string() }, + handler: async (ctx, args) => { + const docs = await ctx.db + .query("documents") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .collect(); + + return docs + .filter((d) => d.user_id === args.userId) + .filter((d) => d.deleted_at != null) + .sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? "")) + .slice(0, 100) + .map((d) => ({ + id: d.id, + title: d.title ?? null, + parent_id: d.parent_id ?? null, + deleted_at: d.deleted_at!, + access_scope: d.access_scope, + })); + }, +}); + +export const create = mutation({ + args: { + userId: v.string(), + id: v.string(), + workspaceId: v.string(), + parentId: v.union(v.string(), v.null()), + title: v.optional(v.union(v.string(), v.null())), + accessScope, + content: v.optional(v.any()), + }, + handler: async (ctx, args) => { + const siblings = await ctx.db + .query("documents") + .withIndex("by_workspace_parent", (q) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId)) + .collect(); + + const sortOrder = siblings.filter((d) => d.deleted_at == null).length; + const ts = nowIso(); + + const title = (args.title ?? "无标题") || "无标题"; + const content = typeof args.content === "undefined" ? [] : args.content; + + await ctx.db.insert("documents", { + id: args.id, + user_id: args.userId, + workspace_id: args.workspaceId, + parent_id: args.parentId, + title, + content, + access_scope: args.accessScope, + sort_order: sortOrder, + is_starred: false, + is_template: false, + + wide_layout: false, + use_small_text: false, + show_heading_numbers: true, + show_toc: false, + show_structure: false, + protect_editing: false, + show_word_count: true, + word_count: 0, + character_count: 0, + block_count: 0, + created_at: ts, + updated_at: ts, + deleted_at: null, + deleted_by: null, + }); + + return { + id: args.id, + title, + parent_id: args.parentId, + sort_order: sortOrder, + is_starred: false, + created_at: ts, + updated_at: ts, + workspace_id: args.workspaceId, + access_scope: args.accessScope, + is_template: false, + }; + }, +}); + +export const updateContent = mutation({ + args: { userId: v.string(), id: v.string(), content: v.any() }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.user_id !== args.userId) throw new Error("无权限"); + const ts = nowIso(); + await ctx.db.patch(doc._id, { content: args.content, updated_at: ts }); + return { ok: true, updated_at: ts }; + }, +}); + +export const updateTitle = mutation({ + args: { userId: v.string(), id: v.string(), title: v.union(v.string(), v.null()) }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.user_id !== args.userId) throw new Error("无权限"); + const ts = nowIso(); + await ctx.db.patch(doc._id, { title: args.title, updated_at: ts }); + return { ok: true, updated_at: ts }; + }, +}); + +export const move = mutation({ + args: { + userId: v.string(), + id: v.string(), + parentId: v.union(v.string(), v.null()), + sortOrder: v.number(), + }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.user_id !== args.userId) throw new Error("无权限"); + const ts = nowIso(); + await ctx.db.patch(doc._id, { + parent_id: args.parentId, + sort_order: args.sortOrder, + updated_at: ts, + }); + return { ok: true, updated_at: ts }; + }, +}); + +export const softDelete = mutation({ + args: { userId: v.string(), id: v.string() }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.user_id !== args.userId) throw new Error("无权限"); + const ts = nowIso(); + await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: args.userId, updated_at: ts }); + return { ok: true }; + }, +}); + +export const restore = mutation({ + args: { userId: v.string(), id: v.string() }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.user_id !== args.userId) throw new Error("无权限"); + const ts = nowIso(); + await ctx.db.patch(doc._id, { + deleted_at: null, + deleted_by: null, + parent_id: null, + access_scope: "private", + updated_at: ts, + }); + return { ok: true }; + }, +}); + +export const purge = mutation({ + args: { userId: v.string(), id: v.string() }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.user_id !== args.userId) throw new Error("无权限"); + await ctx.db.delete(doc._id); + return { ok: true }; + }, +}); + +export const emptyTrashByWorkspace = mutation({ + args: { userId: v.string(), workspaceId: v.string() }, + handler: async (ctx, args) => { + // 说明:阶段 4/5 先用“membership 存在即可”的规则,避免引入复杂权限模型。 + const membership = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId)) + .first(); + + if (!membership) { + throw new Error("无权操作该工作空间"); + } + + const docs = await ctx.db + .query("documents") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .collect(); + + const toDelete = docs.filter((d) => d.user_id === args.userId && d.deleted_at != null); + for (const d of toDelete) { + await ctx.db.delete(d._id); + } + + return { ok: true, deletedCount: toDelete.length }; + }, +}); + +export const updateOptions = mutation({ + args: { + userId: v.string(), + id: v.string(), + options: v.object({ + wideLayout: v.optional(v.boolean()), + smallText: v.optional(v.boolean()), + showHeadingNumbers: v.optional(v.boolean()), + showToc: v.optional(v.boolean()), + showStructure: v.optional(v.boolean()), + protectEditing: v.optional(v.boolean()), + showWordCount: v.optional(v.boolean()), + }), + }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.user_id !== args.userId) throw new Error("无权限"); + + const patch: Record = {}; + if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout; + if (typeof args.options.smallText === "boolean") patch.use_small_text = args.options.smallText; + if (typeof args.options.showHeadingNumbers === "boolean") + patch.show_heading_numbers = args.options.showHeadingNumbers; + if (typeof args.options.showToc === "boolean") patch.show_toc = args.options.showToc; + if (typeof args.options.showStructure === "boolean") patch.show_structure = args.options.showStructure; + if (typeof args.options.protectEditing === "boolean") patch.protect_editing = args.options.protectEditing; + if (typeof args.options.showWordCount === "boolean") patch.show_word_count = args.options.showWordCount; + + if (Object.keys(patch).length === 0) { + throw new Error("缺少可更新的选项"); + } + + const ts = nowIso(); + await ctx.db.patch(doc._id, { ...patch, updated_at: ts }); + return { ok: true, updated_at: ts }; + }, +}); + +export const updateStats = mutation({ + args: { + userId: v.string(), + id: v.string(), + wordCount: v.number(), + characterCount: v.number(), + blockCount: v.number(), + }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.user_id !== args.userId) throw new Error("无权限"); + const ts = nowIso(); + await ctx.db.patch(doc._id, { + word_count: args.wordCount, + character_count: args.characterCount, + block_count: args.blockCount, + updated_at: ts, + }); + return { ok: true, updated_at: ts }; + }, +}); + +export const duplicate = mutation({ + args: { + userId: v.string(), + sourceId: v.string(), + newId: v.string(), + title: v.optional(v.union(v.string(), v.null())), + }, + handler: async (ctx, args) => { + const source = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.sourceId)) + .first(); + if (!source) throw new Error("页面不存在或无权限访问"); + if (source.user_id !== args.userId) throw new Error("页面不存在或无权限访问"); + + const siblings = await ctx.db + .query("documents") + .withIndex("by_workspace_parent", (q) => + q.eq("workspace_id", source.workspace_id).eq("parent_id", source.parent_id), + ) + .collect(); + + const sortOrder = siblings.filter((d) => d.deleted_at == null).length; + const ts = nowIso(); + const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题"; + const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`; + + await ctx.db.insert("documents", { + id: args.newId, + user_id: args.userId, + workspace_id: source.workspace_id, + parent_id: source.parent_id, + title, + content: source.content ?? [], + access_scope: source.access_scope, + sort_order: sortOrder, + is_starred: false, + is_template: false, + + wide_layout: source.wide_layout ?? false, + use_small_text: source.use_small_text ?? false, + show_heading_numbers: source.show_heading_numbers ?? true, + show_toc: source.show_toc ?? false, + show_structure: source.show_structure ?? false, + protect_editing: source.protect_editing ?? false, + show_word_count: source.show_word_count ?? true, + word_count: source.word_count ?? 0, + character_count: source.character_count ?? 0, + block_count: source.block_count ?? 0, + + created_at: ts, + updated_at: ts, + deleted_at: null, + deleted_by: null, + }); + + return { + id: args.newId, + title, + parent_id: source.parent_id ?? null, + sort_order: sortOrder, + workspace_id: source.workspace_id, + access_scope: source.access_scope, + created_at: ts, + updated_at: ts, + }; + }, +}); + +export const listAllForCopy = query({ + args: { userId: v.string(), workspaceId: v.string() }, + handler: async (ctx, args) => { + const docs = await ctx.db + .query("documents") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .collect(); + + return docs + .filter((d) => d.user_id === args.userId) + .filter((d) => d.deleted_at == null) + .map((d) => ({ + id: d.id, + title: d.title ?? null, + parent_id: d.parent_id ?? null, + workspace_id: d.workspace_id, + access_scope: d.access_scope, + sort_order: d.sort_order ?? null, + created_at: d.created_at ?? null, + content: d.content ?? null, + })); + }, +}); diff --git a/wolai-frontend/convex/jobs.ts b/wolai-frontend/convex/jobs.ts new file mode 100644 index 00000000..5fa350d5 --- /dev/null +++ b/wolai-frontend/convex/jobs.ts @@ -0,0 +1,149 @@ +import { internalAction, internalMutation, internalQuery, mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { nowIso } from "./_utils/time"; +import { internal } from "./_generated/api"; + +export const get = query({ + args: { userId: v.string(), id: v.string() }, + handler: async (ctx, args) => { + const job = await ctx.db + .query("jobs") + .withIndex("by_job_id", (q) => q.eq("id", args.id)) + .first(); + if (!job) return null; + if (job.user_id !== args.userId) return null; + return { + id: job.id, + type: job.type, + status: job.status, + payload: job.payload, + result: job.result, + error: job.error, + created_at: job.created_at, + updated_at: job.updated_at, + started_at: job.started_at, + finished_at: job.finished_at, + }; + }, +}); + +export const enqueueDemo = mutation({ + args: { userId: v.string(), id: v.string(), ms: v.optional(v.number()) }, + handler: async (ctx, args) => { + const ts = nowIso(); + const payload = { ms: args.ms ?? 800 }; + await ctx.db.insert("jobs", { + id: args.id, + user_id: args.userId, + type: "demo.sleep", + status: "queued", + payload, + result: null, + error: null, + created_at: ts, + updated_at: ts, + started_at: null, + finished_at: null, + }); + + // 说明:阶段 5 骨架——用 scheduler 触发内部 mutation,再由内部 action 执行耗时逻辑。 + await ctx.scheduler.runAfter(0, internal.jobs.start, { id: args.id }); + return { ok: true, id: args.id }; + }, +}); + +export const start = internalMutation({ + args: { id: v.string() }, + handler: async (ctx, args) => { + const job = await ctx.db + .query("jobs") + .withIndex("by_job_id", (q) => q.eq("id", args.id)) + .first(); + if (!job) return; + if (job.status !== "queued") return; + const ts = nowIso(); + await ctx.db.patch(job._id, { status: "running", started_at: ts, updated_at: ts }); + await ctx.scheduler.runAfter(0, internal.jobs.run, { id: args.id }); + }, +}); + +export const run = internalAction({ + args: { id: v.string() }, + handler: async (ctx, args) => { + const job = await ctx.runQuery(internal.jobs._getInternal, { id: args.id }); + if (!job) return; + if (job.status !== "running") return; + + try { + if (job.type === "demo.sleep") { + const ms = typeof job.payload?.ms === "number" ? job.payload.ms : 800; + await new Promise((r) => setTimeout(r, ms)); + await ctx.runMutation(internal.jobs.finishSuccess, { + id: args.id, + result: { ok: true, sleptMs: ms }, + }); + return; + } + + throw new Error(`未知任务类型:${job.type}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await ctx.runMutation(internal.jobs.finishFailure, { id: args.id, error: message }); + } + }, +}); + +export const _getInternal = internalQuery({ + args: { id: v.string() }, + handler: async (ctx, args) => { + const job = await ctx.db + .query("jobs") + .withIndex("by_job_id", (q) => q.eq("id", args.id)) + .first(); + if (!job) return null; + return { + id: job.id, + type: job.type, + status: job.status, + payload: job.payload, + }; + }, +}); + +export const finishSuccess = internalMutation({ + args: { id: v.string(), result: v.any() }, + handler: async (ctx, args) => { + const job = await ctx.db + .query("jobs") + .withIndex("by_job_id", (q) => q.eq("id", args.id)) + .first(); + if (!job) return; + const ts = nowIso(); + await ctx.db.patch(job._id, { + status: "succeeded", + result: args.result, + error: null, + finished_at: ts, + updated_at: ts, + }); + }, +}); + +export const finishFailure = internalMutation({ + args: { id: v.string(), error: v.string() }, + handler: async (ctx, args) => { + const job = await ctx.db + .query("jobs") + .withIndex("by_job_id", (q) => q.eq("id", args.id)) + .first(); + if (!job) return; + const ts = nowIso(); + await ctx.db.patch(job._id, { + status: "failed", + result: null, + error: args.error, + finished_at: ts, + updated_at: ts, + }); + }, +}); diff --git a/wolai-frontend/convex/mediaAssets.ts b/wolai-frontend/convex/mediaAssets.ts new file mode 100644 index 00000000..0a85a92c --- /dev/null +++ b/wolai-frontend/convex/mediaAssets.ts @@ -0,0 +1,409 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { nowIso } from "./_utils/time"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; + +async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) { + const membership = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) + .first(); + if (!membership) { + throw new Error("无权访问该工作空间"); + } + return membership; +} + +export const getById = query({ + args: { userId: v.string(), id: v.string() }, + handler: async (ctx, args) => { + const row = await ctx.db + .query("media_assets") + .withIndex("by_asset_id", (q) => q.eq("id", args.id)) + .first(); + if (!row) return null; + await assertWorkspaceMember(ctx, args.userId, row.workspace_id); + return row; + }, +}); + +export const generateUploadUrl = mutation({ + args: { userId: v.string() }, + handler: async (ctx, args) => { + // 说明:简单兜底,要求用户至少有一个工作空间 membership。 + const membership = await ctx.db + .query("workspace_members") + .withIndex("by_user_id", (q) => q.eq("user_id", args.userId)) + .first(); + if (!membership) { + throw new Error("尚未初始化工作空间,无法上传"); + } + return await ctx.storage.generateUploadUrl(); + }, +}); + +export const listByWorkspace = query({ + args: { + userId: v.string(), + workspaceId: v.string(), + assetType: v.optional(v.string()), + includeDeleted: v.optional(v.boolean()), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + await assertWorkspaceMember(ctx, args.userId, args.workspaceId); + + const includeDeleted = Boolean(args.includeDeleted); + const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 12))); + + let rows = await ctx.db + .query("media_assets") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .order("desc") + .take(limit * 5); + + if (!includeDeleted) { + rows = rows.filter((r) => !r.deleted_at); + } + + if (args.assetType) { + rows = rows.filter((r) => r.asset_type === args.assetType); + } + + // 说明:Convex 的 take 以索引排序为准,这里再截一刀保证输出稳定。 + return rows.slice(0, limit); + }, +}); + +export const listByDocument = query({ + args: { + userId: v.string(), + documentId: v.string(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = Math.max(1, Math.min(500, Math.floor(args.limit ?? 200))); + const rows = await ctx.db + .query("media_assets") + .withIndex("by_document", (q) => q.eq("document_id", args.documentId)) + .order("desc") + .take(limit * 2); + + const filtered = rows.filter((r) => !r.deleted_at); + const ws = filtered[0]?.workspace_id ?? rows[0]?.workspace_id ?? null; + if (ws) await assertWorkspaceMember(ctx, args.userId, ws); + return filtered.slice(0, limit); + }, +}); + +export const listDeletedByWorkspace = query({ + args: { + userId: v.string(), + workspaceId: v.string(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + await assertWorkspaceMember(ctx, args.userId, args.workspaceId); + const limit = Math.max(1, Math.min(2000, Math.floor(args.limit ?? 2000))); + + // 说明:Convex 目前不支持“deleted_at is not null”这种索引条件,先全取再过滤(对练手项目足够)。 + const rows = await ctx.db + .query("media_assets") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .order("desc") + .take(limit * 3); + + return rows.filter((r) => Boolean(r.deleted_at) && !r.purged_at).slice(0, limit); + }, +}); + +export const listByIds = query({ + args: { userId: v.string(), ids: v.array(v.string()) }, + handler: async (ctx, args) => { + const ids = Array.from(new Set(args.ids.filter(Boolean))).slice(0, 200); + const out: any[] = []; + for (const id of ids) { + const row = await ctx.db + .query("media_assets") + .withIndex("by_asset_id", (q) => q.eq("id", id)) + .first(); + if (!row) continue; + await assertWorkspaceMember(ctx, args.userId, row.workspace_id); + out.push(row); + } + return out; + }, +}); + +export const create = mutation({ + args: { + userId: v.string(), + asset: v.object({ + id: v.string(), + workspace_id: v.string(), + document_id: v.string(), + asset_type: v.string(), + file_url: v.union(v.string(), v.null()), + thumbnail_url: v.union(v.string(), v.null()), + storage_id: v.optional(v.union(v.id("_storage"), v.null())), + bucket: v.union(v.string(), v.null()), + storage_path: v.union(v.string(), v.null()), + file_name: v.union(v.string(), v.null()), + file_size: v.union(v.number(), v.null()), + mime_type: v.union(v.string(), v.null()), + }), + }, + handler: async (ctx, args) => { + await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id); + const ts = nowIso(); + + await ctx.db.insert("media_assets", { + ...args.asset, + storage_id: args.asset.storage_id ?? null, + ocr_text: null, + ocr_status: null, + deleted_at: null, + deleted_by: null, + purged_at: null, + created_by: args.userId, + created_at: ts, + updated_at: ts, + }); + + return args.asset; + }, +}); + +export const createWithStorage = mutation({ + args: { + userId: v.string(), + storageId: v.id("_storage"), + asset: v.object({ + id: v.string(), + workspace_id: v.string(), + document_id: v.string(), + asset_type: v.string(), + file_name: v.union(v.string(), v.null()), + file_size: v.union(v.number(), v.null()), + mime_type: v.union(v.string(), v.null()), + }), + }, + handler: async (ctx, args) => { + await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id); + + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.asset.document_id)) + .first(); + + if (!doc || doc.workspace_id !== args.asset.workspace_id) { + throw new Error("目标页面不存在或不属于该工作空间"); + } + + const url = await ctx.storage.getUrl(args.storageId); + if (!url) { + throw new Error("文件不存在或已过期"); + } + + const ts = nowIso(); + const row = { + id: args.asset.id, + workspace_id: args.asset.workspace_id, + document_id: args.asset.document_id, + asset_type: args.asset.asset_type, + file_url: url, + thumbnail_url: url, + storage_id: args.storageId, + bucket: null, + storage_path: null, + file_name: args.asset.file_name, + file_size: args.asset.file_size, + mime_type: args.asset.mime_type, + + ocr_text: null, + ocr_status: null, + deleted_at: null, + deleted_by: null, + purged_at: null, + created_by: args.userId, + created_at: ts, + updated_at: ts, + }; + + await ctx.db.insert("media_assets", row); + return row; + }, +}); + +export const patchById = mutation({ + args: { + userId: v.string(), + id: v.string(), + patch: v.any(), + }, + handler: async (ctx, args) => { + const row = await ctx.db + .query("media_assets") + .withIndex("by_asset_id", (q) => q.eq("id", args.id)) + .first(); + if (!row) throw new Error("资源不存在"); + await assertWorkspaceMember(ctx, args.userId, row.workspace_id); + + const next = { ...(args.patch as Record), updated_at: nowIso() }; + await ctx.db.patch(row._id, next); + return { ok: true }; + }, +}); + +export const refreshUrl = mutation({ + args: { userId: v.string(), id: v.string() }, + handler: async (ctx, args) => { + const row = await ctx.db + .query("media_assets") + .withIndex("by_asset_id", (q) => q.eq("id", args.id)) + .first(); + if (!row) throw new Error("资源不存在"); + await assertWorkspaceMember(ctx, args.userId, row.workspace_id); + + const storageId = (row.storage_id as any) ?? null; + if (!storageId) { + // 外链资源:直接回传现有 URL + return { signedUrl: row.file_url }; + } + + const url = await ctx.storage.getUrl(storageId); + if (!url) { + throw new Error("文件不存在或已被删除"); + } + + await ctx.db.patch(row._id, { file_url: url, thumbnail_url: url, updated_at: nowIso() }); + return { signedUrl: url }; + }, +}); + +export const replaceStorageFromUpload = mutation({ + args: { userId: v.string(), id: v.string(), storageId: v.id("_storage") }, + handler: async (ctx, args) => { + const row = await ctx.db + .query("media_assets") + .withIndex("by_asset_id", (q) => q.eq("id", args.id)) + .first(); + if (!row) throw new Error("资源不存在"); + await assertWorkspaceMember(ctx, args.userId, row.workspace_id); + + const url = await ctx.storage.getUrl(args.storageId); + if (!url) { + throw new Error("文件不存在或已过期"); + } + + await ctx.db.patch(row._id, { + storage_id: args.storageId, + file_url: url, + thumbnail_url: url, + bucket: null, + storage_path: null, + updated_at: nowIso(), + }); + + return { ok: true, fileUrl: url }; + }, +}); + +export const purgeById = mutation({ + args: { userId: v.string(), id: v.string(), expiredDeletedAt: v.string() }, + handler: async (ctx, args) => { + const row = await ctx.db + .query("media_assets") + .withIndex("by_asset_id", (q) => q.eq("id", args.id)) + .first(); + if (!row) throw new Error("未找到附件"); + await assertWorkspaceMember(ctx, args.userId, row.workspace_id); + + if (row.purged_at) { + return { ok: true, alreadyPurged: true }; + } + + const storageId = (row.storage_id as any) ?? null; + if (storageId) { + const refs = await ctx.db + .query("media_assets") + .withIndex("by_storage_id", (q) => q.eq("storage_id", storageId)) + .collect(); + const otherAlive = refs.some((r) => r.id !== row.id && !r.purged_at); + if (!otherAlive) { + await ctx.storage.delete(storageId); + } + } + + const ts = nowIso(); + await ctx.db.patch(row._id, { + deleted_at: args.expiredDeletedAt, + deleted_by: args.userId, + purged_at: ts, + file_url: null, + thumbnail_url: null, + storage_id: null, + updated_at: ts, + }); + + return { ok: true }; + }, +}); + +export const emptyTrashByWorkspace = mutation({ + args: { userId: v.string(), workspaceId: v.string(), expiredDeletedAt: v.string() }, + handler: async (ctx, args) => { + await assertWorkspaceMember(ctx, args.userId, args.workspaceId); + + const rows = await ctx.db + .query("media_assets") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .order("desc") + .take(5000); + + const targets = rows.filter((r) => Boolean(r.deleted_at) && !r.purged_at); + if (targets.length === 0) { + return { ok: true, updated: 0 }; + } + + // 说明:按 storage_id 分组,只有当该 storage_id 没有任何“未清理”的引用时才删除底层文件。 + const byStorage = new Map(); + for (const r of targets) { + const sid = (r.storage_id as any) ?? null; + if (!sid) continue; + const list = byStorage.get(sid) ?? []; + list.push(r.id); + byStorage.set(sid, list); + } + + for (const [sid] of byStorage.entries()) { + const refs = await ctx.db + .query("media_assets") + .withIndex("by_storage_id", (q) => q.eq("storage_id", sid as any)) + .collect(); + const alive = refs.some((r) => !r.purged_at && !r.deleted_at); + if (!alive) { + try { + await ctx.storage.delete(sid as any); + } catch { + // ignore + } + } + } + + const ts = nowIso(); + for (const r of targets) { + await ctx.db.patch(r._id, { + deleted_at: args.expiredDeletedAt, + deleted_by: args.userId, + purged_at: ts, + file_url: null, + thumbnail_url: null, + storage_id: null, + updated_at: ts, + }); + } + + return { ok: true, updated: targets.length }; + }, +}); diff --git a/wolai-frontend/convex/mindmaps.ts b/wolai-frontend/convex/mindmaps.ts new file mode 100644 index 00000000..5bc0febb --- /dev/null +++ b/wolai-frontend/convex/mindmaps.ts @@ -0,0 +1,294 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { nowIso } from "./_utils/time"; + +const defaultMindmapData = { + data: { text: "中心主题" }, + children: [], +}; + +function normalizeMindmapId(docId: string, mindmapId: string): string { + const raw = String(mindmapId ?? "").trim(); + if (raw) return raw; + // 兜底:不允许空 mindmapId(否则无法索引) + return `legacy-${docId}`; +} + +async function requireOwnedDocument(ctx: any, userId: string, docId: string) { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q: any) => q.eq("id", docId)) + .first(); + if (!doc) { + throw new Error("页面不存在"); + } + if (doc.user_id !== userId) { + throw new Error("无权限"); + } + return doc; +} + +export const get = query({ + args: { userId: v.string(), docId: v.string(), mindmapId: v.string() }, + handler: async (ctx, args) => { + const doc = await requireOwnedDocument(ctx, args.userId, args.docId); + const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); + + const row = await ctx.db + .query("mindmaps") + .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) + .first(); + + // 兼容旧逻辑:不存在或已删除时仍返回默认导图,避免前端卡死。 + if (!row || row.deleted_at != null) { + return { + ok: true, + data: defaultMindmapData, + meta: { + workspace_id: doc.workspace_id, + document_id: args.docId, + mindmap_id: mindmapId, + exists: false, + deleted_at: row?.deleted_at ?? null, + }, + }; + } + + return { + ok: true, + data: row.data ?? defaultMindmapData, + meta: { + workspace_id: row.workspace_id, + document_id: row.document_id, + mindmap_id: row.mindmap_id, + exists: true, + deleted_at: row.deleted_at, + created_at: row.created_at, + updated_at: row.updated_at, + }, + }; + }, +}); + +export const put = mutation({ + args: { + userId: v.string(), + docId: v.string(), + mindmapId: v.string(), + data: v.any(), + createOnly: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const doc = await requireOwnedDocument(ctx, args.userId, args.docId); + const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); + + const existing = await ctx.db + .query("mindmaps") + .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) + .first(); + + const ts = nowIso(); + const payload = args.data ?? defaultMindmapData; + + if (existing && existing.deleted_at == null && args.createOnly) { + return { ok: true, created: false, skipped: true, updated_at: existing.updated_at ?? null }; + } + + if (existing) { + await ctx.db.patch(existing._id, { + data: payload, + updated_at: ts, + deleted_at: null, + deleted_by: null, + }); + return { ok: true, created: false, skipped: false, updated_at: ts }; + } + + await ctx.db.insert("mindmaps", { + id: `${args.docId}:${mindmapId}`, + user_id: args.userId, + workspace_id: doc.workspace_id, + document_id: args.docId, + mindmap_id: mindmapId, + data: payload, + created_at: ts, + updated_at: ts, + deleted_at: null, + deleted_by: null, + }); + + return { ok: true, created: true, skipped: false, updated_at: ts }; + }, +}); + +export const softDelete = mutation({ + args: { userId: v.string(), docId: v.string(), mindmapId: v.string() }, + handler: async (ctx, args) => { + await requireOwnedDocument(ctx, args.userId, args.docId); + const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); + + const existing = await ctx.db + .query("mindmaps") + .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) + .first(); + + if (!existing) { + // 兼容:不存在也视为成功 + return { ok: true, moved: 0 }; + } + + const ts = nowIso(); + await ctx.db.patch(existing._id, { deleted_at: ts, deleted_by: args.userId, updated_at: ts }); + return { ok: true, moved: 1, deleted_at: ts }; + }, +}); + +export const restore = mutation({ + args: { userId: v.string(), docId: v.string(), mindmapId: v.string() }, + handler: async (ctx, args) => { + await requireOwnedDocument(ctx, args.userId, args.docId); + const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); + + const existing = await ctx.db + .query("mindmaps") + .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) + .first(); + + if (!existing) { + throw new Error("未找到可操作的记录"); + } + + const ts = nowIso(); + await ctx.db.patch(existing._id, { deleted_at: null, deleted_by: null, updated_at: ts }); + return { ok: true }; + }, +}); + +export const purge = mutation({ + args: { userId: v.string(), docId: v.string(), mindmapId: v.string() }, + handler: async (ctx, args) => { + await requireOwnedDocument(ctx, args.userId, args.docId); + const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); + + const existing = await ctx.db + .query("mindmaps") + .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) + .first(); + + if (!existing) { + throw new Error("未找到可操作的记录"); + } + + await ctx.db.delete(existing._id); + return { ok: true }; + }, +}); + +export const listByWorkspace = query({ + args: { userId: v.string(), workspaceId: v.string(), includeDeleted: v.optional(v.boolean()) }, + handler: async (ctx, args) => { + // 说明:阶段 6 先按 membership 存在即可,避免引入复杂权限模型。 + const membership = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId)) + .first(); + if (!membership) { + throw new Error("无权操作该工作空间"); + } + + const rows = await ctx.db + .query("mindmaps") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .collect(); + + const includeDeleted = Boolean(args.includeDeleted); + + return rows + .filter((r) => r.user_id === args.userId) + .filter((r) => (includeDeleted ? true : r.deleted_at == null)) + .sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")) + .map((r) => ({ + id: r.id, + workspace_id: r.workspace_id, + document_id: r.document_id, + mindmap_id: r.mindmap_id, + data: r.data ?? defaultMindmapData, + created_at: r.created_at, + updated_at: r.updated_at, + deleted_at: r.deleted_at, + deleted_by: r.deleted_by, + })); + }, +}); + +export const copyByDocument = mutation({ + args: { userId: v.string(), sourceDocId: v.string(), targetDocId: v.string() }, + handler: async (ctx, args) => { + const sourceDoc = await requireOwnedDocument(ctx, args.userId, args.sourceDocId); + const targetDoc = await requireOwnedDocument(ctx, args.userId, args.targetDocId); + + // 说明:同一工作空间复制最常见;若跨工作空间复制,这里仍允许,但 mindmaps 会落到目标页面的 workspace_id。 + const sourceRows = await ctx.db + .query("mindmaps") + .withIndex("by_workspace", (q) => q.eq("workspace_id", sourceDoc.workspace_id)) + .collect(); + + const sourceMindmaps = sourceRows + .filter((r) => r.user_id === args.userId) + .filter((r) => r.document_id === args.sourceDocId) + .filter((r) => r.deleted_at == null); + + let copied = 0; + const ts = nowIso(); + + for (const r of sourceMindmaps) { + const existing = await ctx.db + .query("mindmaps") + .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.targetDocId).eq("mindmap_id", r.mindmap_id)) + .first(); + if (existing) continue; + + await ctx.db.insert("mindmaps", { + id: `${args.targetDocId}:${r.mindmap_id}`, + user_id: args.userId, + workspace_id: targetDoc.workspace_id, + document_id: args.targetDocId, + mindmap_id: r.mindmap_id, + data: r.data ?? defaultMindmapData, + created_at: ts, + updated_at: ts, + deleted_at: null, + deleted_by: null, + }); + copied += 1; + } + + return { ok: true, copied }; + }, +}); + +export const emptyTrashByWorkspace = mutation({ + args: { userId: v.string(), workspaceId: v.string() }, + handler: async (ctx, args) => { + const membership = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId)) + .first(); + if (!membership) { + throw new Error("无权操作该工作空间"); + } + + const rows = await ctx.db + .query("mindmaps") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .collect(); + + const toDelete = rows.filter((r) => r.user_id === args.userId && r.deleted_at != null); + for (const r of toDelete) { + await ctx.db.delete(r._id); + } + + return { ok: true, deletedCount: toDelete.length }; + }, +}); + diff --git a/wolai-frontend/convex/ping.ts b/wolai-frontend/convex/ping.ts new file mode 100644 index 00000000..68d43a71 --- /dev/null +++ b/wolai-frontend/convex/ping.ts @@ -0,0 +1,13 @@ +import { query } from "./_generated/server"; + +export const ping = query({ + args: {}, + handler: async () => { + return { + ok: true, + message: "pong", + now: Date.now(), + }; + }, +}); + diff --git a/wolai-frontend/convex/recents.ts b/wolai-frontend/convex/recents.ts new file mode 100644 index 00000000..0aa81d9f --- /dev/null +++ b/wolai-frontend/convex/recents.ts @@ -0,0 +1,49 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +export const listByWorkspace = query({ + args: { userId: v.string(), workspaceId: v.string(), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const rows = await ctx.db + .query("user_recent_pages") + .withIndex("by_user_workspace", (q) => q.eq("user_id", args.userId).eq("workspace_id", args.workspaceId)) + .collect(); + + const sorted = rows.sort((a, b) => (b.last_accessed_at ?? "").localeCompare(a.last_accessed_at ?? "")); + const limit = Math.max(0, Math.min(args.limit ?? 10, 50)); + return sorted.slice(0, limit).map((r) => ({ + user_id: r.user_id, + workspace_id: r.workspace_id, + document_id: r.document_id, + last_accessed_at: r.last_accessed_at, + })); + }, +}); + +export const upsert = mutation({ + args: { userId: v.string(), workspaceId: v.string(), documentId: v.string(), lastAccessedAt: v.string() }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("user_recent_pages") + .withIndex("by_user_document", (q) => q.eq("user_id", args.userId).eq("document_id", args.documentId)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + workspace_id: args.workspaceId, + last_accessed_at: args.lastAccessedAt, + }); + return { ok: true, updated: true }; + } + + await ctx.db.insert("user_recent_pages", { + user_id: args.userId, + workspace_id: args.workspaceId, + document_id: args.documentId, + last_accessed_at: args.lastAccessedAt, + }); + + return { ok: true, updated: false }; + }, +}); + diff --git a/wolai-frontend/convex/references.ts b/wolai-frontend/convex/references.ts new file mode 100644 index 00000000..d6094983 --- /dev/null +++ b/wolai-frontend/convex/references.ts @@ -0,0 +1,142 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { nowIso } from "./_utils/time"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; + +async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) { + const membership = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) + .first(); + if (!membership) { + throw new Error("无权访问该工作空间"); + } + return membership; +} + +const displayMode = v.union(v.literal("inline"), v.literal("embed")); + +export const record = mutation({ + args: { + userId: v.string(), + workspaceId: v.string(), + sourcePageId: v.string(), + targetPageId: v.string(), + sourceBlockId: v.union(v.string(), v.null()), + alias: v.union(v.string(), v.null()), + displayMode, + isPreviewable: v.boolean(), + }, + handler: async (ctx, args) => { + await assertWorkspaceMember(ctx, args.userId, args.workspaceId); + + const ts = nowIso(); + const existing = await ctx.db + .query("page_references") + .withIndex("by_unique", (q) => + q + .eq("workspace_id", args.workspaceId) + .eq("source_page_id", args.sourcePageId) + .eq("source_block_id", args.sourceBlockId) + .eq("target_page_id", args.targetPageId) + .eq("display_mode", args.displayMode), + ) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + alias: args.alias ?? null, + is_previewable: Boolean(args.isPreviewable), + updated_at: ts, + }); + return { + id: existing.id, + workspace_id: existing.workspace_id, + source_page_id: existing.source_page_id, + source_block_id: existing.source_block_id, + target_page_id: existing.target_page_id, + alias: args.alias ?? null, + display_mode: existing.display_mode, + is_previewable: Boolean(args.isPreviewable), + created_at: existing.created_at, + updated_at: ts, + }; + } + + // 说明:id 使用可读的组合键,便于调试;并不要求前端依赖该规则。 + const id = `ref:${args.workspaceId}:${args.sourcePageId}:${args.sourceBlockId ?? "page"}:${args.targetPageId}:${args.displayMode}:${ts}`; + + await ctx.db.insert("page_references", { + id, + workspace_id: args.workspaceId, + source_page_id: args.sourcePageId, + source_block_id: args.sourceBlockId ?? null, + target_page_id: args.targetPageId, + alias: args.alias ?? null, + display_mode: args.displayMode, + is_previewable: Boolean(args.isPreviewable), + created_by: args.userId, + created_at: ts, + updated_at: ts, + }); + + return { + id, + workspace_id: args.workspaceId, + source_page_id: args.sourcePageId, + source_block_id: args.sourceBlockId ?? null, + target_page_id: args.targetPageId, + alias: args.alias ?? null, + display_mode: args.displayMode, + is_previewable: Boolean(args.isPreviewable), + created_at: ts, + updated_at: ts, + }; + }, +}); + +export const listBacklinks = query({ + args: { + userId: v.string(), + workspaceId: v.string(), + pageId: v.string(), + limit: v.optional(v.number()), + offset: v.optional(v.number()), + }, + handler: async (ctx, args) => { + await assertWorkspaceMember(ctx, args.userId, args.workspaceId); + + const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 50))); + const offset = Math.max(0, Math.floor(args.offset ?? 0)); + + const rows = await ctx.db + .query("page_references") + .withIndex("by_workspace_target", (q) => q.eq("workspace_id", args.workspaceId).eq("target_page_id", args.pageId)) + .order("desc") + .take(limit + offset + 200); + + const sliced = rows.slice(offset, offset + limit); + const sourceIds = Array.from(new Set(sliced.map((r) => String((r as any).source_page_id ?? "")).filter(Boolean))); + + const sourceTitleById = new Map(); + for (const sid of sourceIds) { + const doc = await ctx.db.query("documents").withIndex("by_document_id", (q) => q.eq("id", sid)).first(); + sourceTitleById.set(sid, doc ? (doc.title ?? null) : null); + } + + return sliced.map((row) => { + const r = row as any; + return { + id: r.id, + source_page_id: r.source_page_id, + source_block_id: r.source_block_id ?? null, + alias: r.alias ?? null, + display_mode: r.display_mode, + is_previewable: Boolean(r.is_previewable), + created_at: r.created_at, + updated_at: r.updated_at, + source_title: sourceTitleById.get(String(r.source_page_id ?? "")) ?? null, + }; + }); + }, +}); diff --git a/wolai-frontend/convex/schema.ts b/wolai-frontend/convex/schema.ts new file mode 100644 index 00000000..08c91ab3 --- /dev/null +++ b/wolai-frontend/convex/schema.ts @@ -0,0 +1,190 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +// 说明: +// - 这里先按“兼容现有 Next API 返回结构”的思路设计字段:id/workspace_id/user_id 等命名保持与 Supabase 一致。 +// - Convex 自带的 _id 仍然存在,但我们暂时不把它暴露给上层业务,便于逐步迁移与回退。 + +export default defineSchema({ + workspaces: defineTable({ + id: v.string(), + name: v.string(), + type: v.union(v.literal("personal"), v.literal("team")), + icon_url: v.union(v.string(), v.null()), + created_by: v.string(), + created_at: v.string(), + }) + .index("by_workspace_id", ["id"]) + .index("by_created_by", ["created_by"]), + + workspace_members: defineTable({ + workspace_id: v.string(), + user_id: v.string(), + role: v.string(), + is_default: v.boolean(), + created_at: v.string(), + }) + .index("by_user_id", ["user_id"]) + .index("by_workspace_user", ["workspace_id", "user_id"]) + .index("by_workspace_id", ["workspace_id"]), + + documents: defineTable({ + id: v.string(), + user_id: v.string(), + workspace_id: v.string(), + + title: v.union(v.string(), v.null()), + parent_id: v.union(v.string(), v.null()), + sort_order: v.union(v.number(), v.null()), + is_starred: v.union(v.boolean(), v.null()), + is_template: v.boolean(), + access_scope: v.union(v.literal("private"), v.literal("shared"), v.literal("public")), + + // 页面选项(对应 Supabase documents 上的 UI 配置列) + wide_layout: v.union(v.boolean(), v.null()), + use_small_text: v.union(v.boolean(), v.null()), + show_heading_numbers: v.union(v.boolean(), v.null()), + show_toc: v.union(v.boolean(), v.null()), + show_structure: v.union(v.boolean(), v.null()), + protect_editing: v.union(v.boolean(), v.null()), + show_word_count: v.union(v.boolean(), v.null()), + + // 统计信息(由客户端编辑器计算后回写) + word_count: v.union(v.number(), v.null()), + character_count: v.union(v.number(), v.null()), + block_count: v.union(v.number(), v.null()), + + // 说明:当前文档内容结构还在演进,先用 any 承接(与 Supabase Json 一致的宽松形态)。 + content: v.any(), + + // 说明:后续可用于搜索/索引(目前先留空,不强制写入)。 + raw_text: v.optional(v.union(v.string(), v.null())), + + // 时间戳统一使用 ISO 字符串,便于直接复用前端现有排序逻辑。 + created_at: v.string(), + updated_at: v.union(v.string(), v.null()), + + // 软删除(阶段 4 先不实现垃圾桶逻辑,但字段先留好,便于后续迁移)。 + deleted_at: v.union(v.string(), v.null()), + deleted_by: v.union(v.string(), v.null()), + + // 兼容旧逻辑:Supabase documents.mindmap_data。 + mindmap_data: v.optional(v.any()), + }) + .index("by_document_id", ["id"]) + .index("by_user", ["user_id"]) + .index("by_workspace", ["workspace_id"]) + .index("by_workspace_parent", ["workspace_id", "parent_id"]), + + // 最近访问(替代 Supabase user_recent_pages) + user_recent_pages: defineTable({ + user_id: v.string(), + workspace_id: v.string(), + document_id: v.string(), + last_accessed_at: v.string(), + }) + .index("by_user_workspace", ["user_id", "workspace_id"]) + .index("by_user_document", ["user_id", "document_id"]), + + // 阶段 5:异步任务/队列表(最小骨架,后续可扩展为通用作业系统) + jobs: defineTable({ + id: v.string(), + user_id: v.string(), + type: v.string(), + status: v.union(v.literal("queued"), v.literal("running"), v.literal("succeeded"), v.literal("failed")), + payload: v.any(), + result: v.union(v.any(), v.null()), + error: v.union(v.string(), v.null()), + created_at: v.string(), + updated_at: v.string(), + started_at: v.union(v.string(), v.null()), + finished_at: v.union(v.string(), v.null()), + }) + .index("by_job_id", ["id"]) + .index("by_user", ["user_id"]) + .index("by_status", ["status"]), + + // 阶段 6:媒体/附件(替代 Supabase Storage + media_assets 表) + media_assets: defineTable({ + id: v.string(), + workspace_id: v.string(), + document_id: v.string(), + + asset_type: v.string(), + file_url: v.union(v.string(), v.null()), + thumbnail_url: v.union(v.string(), v.null()), + + // Convex Files(storage)定位信息 + // 说明:早期阶段 6(MinIO 直存)写入的记录可能没有该字段;允许缺失以便平滑迁移。 + storage_id: v.optional(v.union(v.id("_storage"), v.null())), + + // S3/MinIO 定位信息 + bucket: v.union(v.string(), v.null()), + storage_path: v.union(v.string(), v.null()), + + file_name: v.union(v.string(), v.null()), + file_size: v.union(v.number(), v.null()), + mime_type: v.union(v.string(), v.null()), + + // OCR 相关(后续再接入) + ocr_text: v.union(v.string(), v.null()), + ocr_status: v.union(v.string(), v.null()), + ocr_payload: v.optional(v.any()), + ocr_strategy: v.optional(v.union(v.string(), v.null())), + + // 回收站/清理 + deleted_at: v.union(v.string(), v.null()), + deleted_by: v.union(v.string(), v.null()), + purged_at: v.union(v.string(), v.null()), + + created_by: v.string(), + created_at: v.string(), + updated_at: v.string(), + }) + .index("by_asset_id", ["id"]) + .index("by_storage_id", ["storage_id"]) + .index("by_workspace", ["workspace_id"]) + .index("by_document", ["document_id"]) + .index("by_workspace_deleted", ["workspace_id", "deleted_at"]), + + // M2:思维导图(替代本地文件 public/documents//mindmap*.json + Supabase documents.mindmap_data) + // 说明: + // - 业务侧依然以 (document_id, mindmap_id) 作为“定位键”,避免跨页面冲突。 + // - id 是一个便于排查的全局唯一串(例如 `${docId}:${mindmapId}`),但不要求前端依赖它。 + mindmaps: defineTable({ + id: v.string(), + user_id: v.string(), + workspace_id: v.string(), + document_id: v.string(), + mindmap_id: v.string(), + data: v.any(), + created_at: v.string(), + updated_at: v.string(), + deleted_at: v.union(v.string(), v.null()), + deleted_by: v.union(v.string(), v.null()), + }) + .index("by_mindmap_id", ["id"]) + .index("by_doc_mindmap", ["document_id", "mindmap_id"]) + .index("by_workspace", ["workspace_id"]) + .index("by_workspace_deleted", ["workspace_id", "deleted_at"]) + .index("by_user", ["user_id"]), + + // M3:页面引用/反链(替代 Supabase RPC:record_page_ref、list_backlinks) + page_references: defineTable({ + id: v.string(), + workspace_id: v.string(), + source_page_id: v.string(), + source_block_id: v.union(v.string(), v.null()), + target_page_id: v.string(), + alias: v.union(v.string(), v.null()), + display_mode: v.union(v.literal("inline"), v.literal("embed")), + is_previewable: v.boolean(), + created_by: v.string(), + created_at: v.string(), + updated_at: v.string(), + }) + .index("by_reference_id", ["id"]) + .index("by_workspace_target", ["workspace_id", "target_page_id"]) + .index("by_workspace_source", ["workspace_id", "source_page_id"]) + .index("by_unique", ["workspace_id", "source_page_id", "source_block_id", "target_page_id", "display_mode"]), +}); diff --git a/wolai-frontend/convex/workspaces.ts b/wolai-frontend/convex/workspaces.ts new file mode 100644 index 00000000..c5de5de2 --- /dev/null +++ b/wolai-frontend/convex/workspaces.ts @@ -0,0 +1,166 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { nowIso } from "./_utils/time"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; + +type WorkspaceSummary = { + id: string; + name: string; + type: "personal" | "team"; + iconUrl: string | null; + memberCount: number; + isDefault: boolean; +}; + +async function findWorkspaceById(ctx: QueryCtx | MutationCtx, workspaceId: string) { + return await ctx.db + .query("workspaces") + .withIndex("by_workspace_id", (q) => q.eq("id", workspaceId)) + .first(); +} + +async function countMembers(ctx: QueryCtx | MutationCtx, workspaceId: string): Promise { + const members = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_id", (q) => q.eq("workspace_id", workspaceId)) + .collect(); + return members.length; +} + +export const ensureDefaultWorkspace = mutation({ + args: { + userId: v.string(), + fallbackName: v.optional(v.string()), + workspaceIdIfCreate: v.string(), + }, + handler: async (ctx, args) => { + const memberships = await ctx.db + .query("workspace_members") + .withIndex("by_user_id", (q) => q.eq("user_id", args.userId)) + .collect(); + + if (memberships.length === 0) { + const workspaceName = (args.fallbackName ?? "").trim() + ? `${args.fallbackName!.trim()} 的空间` + : "我的空间"; + + const ts = nowIso(); + await ctx.db.insert("workspaces", { + id: args.workspaceIdIfCreate, + name: workspaceName, + type: "personal", + icon_url: null, + created_by: args.userId, + created_at: ts, + }); + + await ctx.db.insert("workspace_members", { + workspace_id: args.workspaceIdIfCreate, + user_id: args.userId, + role: "owner", + is_default: true, + created_at: ts, + }); + + const summary: WorkspaceSummary = { + id: args.workspaceIdIfCreate, + name: workspaceName, + type: "personal", + iconUrl: null, + memberCount: 1, + isDefault: true, + }; + + return { + workspaces: [summary], + activeWorkspaceId: args.workspaceIdIfCreate, + }; + } + + // 有 membership 就认为已有 workspace;再兜底一次补齐 workspace 记录。 + const workspaceIds = Array.from(new Set(memberships.map((m) => m.workspace_id))); + const summaries: WorkspaceSummary[] = []; + for (const wid of workspaceIds) { + const ws = await findWorkspaceById(ctx, wid); + if (!ws) continue; + const memberCount = await countMembers(ctx, wid); + const isDefault = memberships.some((m) => m.workspace_id === wid && m.is_default); + summaries.push({ + id: ws.id, + name: ws.name, + type: ws.type, + iconUrl: ws.icon_url, + memberCount: memberCount || 1, + isDefault, + }); + } + + // 说明:保持与原 fetchWorkspaceSummaries 一致:默认 workspace 优先,否则取第一个。 + const defaultWs = summaries.find((w) => w.isDefault); + const activeWorkspaceId = defaultWs?.id ?? summaries[0]?.id ?? ""; + return { workspaces: summaries, activeWorkspaceId }; + }, +}); + +export const fetchWorkspaceSummaries = query({ + args: { userId: v.string() }, + handler: async (ctx, args) => { + // 说明:为了复用 ensureDefaultWorkspace 的返回结构,这里直接走同样的聚合逻辑。 + const memberships = await ctx.db + .query("workspace_members") + .withIndex("by_user_id", (q) => q.eq("user_id", args.userId)) + .collect(); + + const workspaceIds = Array.from(new Set(memberships.map((m) => m.workspace_id))); + const summaries: WorkspaceSummary[] = []; + for (const wid of workspaceIds) { + const ws = await findWorkspaceById(ctx, wid); + if (!ws) continue; + const memberCount = await countMembers(ctx, wid); + const isDefault = memberships.some((m) => m.workspace_id === wid && m.is_default); + summaries.push({ + id: ws.id, + name: ws.name, + type: ws.type, + iconUrl: ws.icon_url, + memberCount: memberCount || 1, + isDefault, + }); + } + + const defaultWs = summaries.find((w) => w.isDefault); + const activeWorkspaceId = defaultWs?.id ?? summaries[0]?.id ?? ""; + return { workspaces: summaries, activeWorkspaceId }; + }, +}); + +export const switchDefaultWorkspace = mutation({ + args: { userId: v.string(), workspaceId: v.string() }, + handler: async (ctx, args) => { + const target = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q) => + q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId), + ) + .first(); + + if (!target) { + throw new Error("无权切换至该工作空间"); + } + + const memberships = await ctx.db + .query("workspace_members") + .withIndex("by_user_id", (q) => q.eq("user_id", args.userId)) + .collect(); + + // 说明:Convex 暂无批量 update,这里逐条 patch。 + for (const m of memberships) { + if (m.is_default) { + await ctx.db.patch(m._id, { is_default: false }); + } + } + + await ctx.db.patch(target._id, { is_default: true }); + return { ok: true }; + }, +}); diff --git a/wolai-frontend/package.json b/wolai-frontend/package.json index cf79789f..3c4f33ac 100644 --- a/wolai-frontend/package.json +++ b/wolai-frontend/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "node scripts/dev-server.js", "build": "next build", "start": "next start", "lint": "eslint", @@ -39,6 +39,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "convex": "^1.31.4", + "jszip": "3.10.1", "lucide-react": "^0.554.0", "next": "16.0.3", "react": "19.2.0", diff --git a/wolai-frontend/pnpm-lock.yaml b/wolai-frontend/pnpm-lock.yaml index c862ccaa..7e3da9e5 100644 --- a/wolai-frontend/pnpm-lock.yaml +++ b/wolai-frontend/pnpm-lock.yaml @@ -95,6 +95,12 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + convex: + specifier: ^1.31.4 + version: 1.31.4(react@19.2.0) + jszip: + specifier: 3.10.1 + version: 3.10.1 lucide-react: specifier: ^0.554.0 version: 0.554.0(react@19.2.0) @@ -2722,6 +2728,22 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convex@1.31.4: + resolution: {integrity: sha512-iDm283Gb/CFRb30cvhH6Z9qlYof6dhtin415FarKUKB3K7gumO0rn8snY0CTvUrThV3UnCtttbuL/1oY7LscyA==} + engines: {node: '>=18.0.0', npm: '>=7.0.0'} + hasBin: true + peerDependencies: + '@auth0/auth0-react': ^2.0.1 + '@clerk/clerk-react': ^4.12.8 || ^5.0.0 + react: ^18.0.0 || ^19.0.0-0 || ^19.0.0 + peerDependenciesMeta: + '@auth0/auth0-react': + optional: true + '@clerk/clerk-react': + optional: true + react: + optional: true + cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} @@ -4275,6 +4297,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.8.0: + resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==} + engines: {node: '>=14'} + hasBin: true + printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} @@ -7668,6 +7695,13 @@ snapshots: convert-source-map@2.0.0: {} + convex@1.31.4(react@19.2.0): + dependencies: + esbuild: 0.27.0 + prettier: 3.8.0 + optionalDependencies: + react: 19.2.0 + cookie@0.5.0: {} cookie@0.7.2: {} @@ -9727,6 +9761,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.8.0: {} + printable-characters@1.0.42: {} process-nextick-args@2.0.1: {} diff --git a/wolai-frontend/public/mnote-env.json b/wolai-frontend/public/mnote-env.json index d8c08bd0..70f6940b 100644 --- a/wolai-frontend/public/mnote-env.json +++ b/wolai-frontend/public/mnote-env.json @@ -4,7 +4,7 @@ "supabaseInternalUrl": "http://127.0.0.1:18000", "supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y", "backendUrl": "https://frp-dry.com:44399", - "onlyofficeBaseUrlWeb": "https://frp-dry.com:16630/onlyoffice-server", + "onlyofficeBaseUrlWeb": "/onlyoffice-server", "onlyofficeBaseUrlDesktop": "http://localhost:8081", "onlyofficeStorageHostOverrideWeb": "", "onlyofficeStorageHostOverrideDesktop": "", diff --git a/wolai-frontend/scripts/dev-server.js b/wolai-frontend/scripts/dev-server.js new file mode 100644 index 00000000..794db876 --- /dev/null +++ b/wolai-frontend/scripts/dev-server.js @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * 自定义 Next dev server: + * - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。 + * - Next App Router 的 Route Handler 无法处理 Upgrade,因此必须在 Node http server 层做透传。 + * + * 用法(保持与 next dev 类似): + * - pnpm dev -p 3000 + * - node scripts/dev-server.js -p 3000 + * + * 依赖环境变量: + * - ONLYOFFICE_INTERNAL_URL:默认 http://127.0.0.1:8081 + */ + +const http = require("http"); +const net = require("net"); +const path = require("path"); +const next = require("next"); +const { parse: parseUrl } = require("url"); + +const ONLYOFFICE_PREFIX = "/onlyoffice-server"; + +function readArgValue(flag) { + const idx = process.argv.findIndex((x) => x === flag); + if (idx === -1) return null; + const v = process.argv[idx + 1]; + if (!v || v.startsWith("-")) return null; + return v; +} + +function resolvePort() { + const fromArg = readArgValue("-p") || readArgValue("--port"); + const raw = fromArg || process.env.PORT || "3000"; + const n = Number(raw); + return Number.isFinite(n) ? Math.max(1, Math.min(65535, Math.floor(n))) : 3000; +} + +function resolveHostname() { + return readArgValue("-H") || readArgValue("--hostname") || process.env.HOSTNAME || "0.0.0.0"; +} + +function isOnlyOfficePath(urlString) { + try { + const u = new URL(urlString, "http://localhost"); + return u.pathname === ONLYOFFICE_PREFIX || u.pathname.startsWith(`${ONLYOFFICE_PREFIX}/`); + } catch { + return false; + } +} + +function buildUpstreamRequestHead(req, targetUrl) { + const incoming = new URL(req.url || "/", "http://localhost"); + const rawPath = incoming.pathname || "/"; + const stripped = rawPath === ONLYOFFICE_PREFIX ? "/" : rawPath.slice(ONLYOFFICE_PREFIX.length) || "/"; + + const basePath = String(targetUrl.pathname || "/").replace(/\/+$/, "") || ""; + const upstreamPath = `${basePath}${stripped}`.replace(/\/{2,}/g, "/") + (incoming.search || ""); + + const lines = []; + lines.push(`${req.method || "GET"} ${upstreamPath} HTTP/1.1`); + + const headers = req.headers || {}; + for (const [k, v] of Object.entries(headers)) { + if (!v) continue; + const key = String(k); + if (key.toLowerCase() === "host") continue; + if (Array.isArray(v)) { + lines.push(`${key}: ${v.join(", ")}`); + } else { + lines.push(`${key}: ${String(v)}`); + } + } + + // 说明:Host 必须指向 ONLYOFFICE_INTERNAL_URL,否则上游可能拒绝 Upgrade。 + lines.push(`Host: ${targetUrl.host}`); + lines.push(""); + lines.push(""); + return lines.join("\r\n"); +} + +function proxyOnlyOfficeUpgrade(req, socket, head) { + const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/"); + const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80); + + const upstream = net.connect({ host: target.hostname, port }, () => { + try { + const reqHead = buildUpstreamRequestHead(req, target); + upstream.write(reqHead); + if (head && head.length > 0) upstream.write(head); + socket.pipe(upstream); + upstream.pipe(socket); + } catch (e) { + try { + socket.destroy(); + } catch {} + try { + upstream.destroy(); + } catch {} + } + }); + + const onError = (err) => { + try { + const msg = err && err.message ? String(err.message) : String(err || ""); + // eslint-disable-next-line no-console + console.log("[dev-server][onlyoffice-ws] proxy error", msg); + } catch {} + try { + socket.destroy(); + } catch {} + try { + upstream.destroy(); + } catch {} + }; + + upstream.on("error", onError); + socket.on("error", onError); +} + +async function main() { + const port = resolvePort(); + const hostname = resolveHostname(); + const dev = true; + + const app = next({ dev, dir: path.join(__dirname, "..") }); + const handle = app.getRequestHandler(); + + await app.prepare(); + // 说明:Next dev 的 HMR 依赖 WebSocket(/_next/webpack-hmr),需要交给 Next 自己处理 upgrade。 + const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null; + + const server = http.createServer((req, res) => { + try { + res.setHeader("x-mnote-dev-server", "1"); + res.setHeader("x-mnote-onlyoffice-ws-proxy", "1"); + } catch { + // ignore + } + const parsed = parseUrl(req.url || "/", true); + handle(req, res, parsed); + }); + + server.on("upgrade", (req, socket, head) => { + if (isOnlyOfficePath(req.url || "/")) { + try { + // eslint-disable-next-line no-console + console.log("[dev-server][onlyoffice-ws] upgrade", req.url); + } catch {} + proxyOnlyOfficeUpgrade(req, socket, head); + return; + } + if (handleUpgrade) { + handleUpgrade(req, socket, head); + return; + } + try { + socket.destroy(); + } catch { + // ignore + } + }); + + server.listen(port, hostname, () => { + // eslint-disable-next-line no-console + console.log(`[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"})`); + }); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err instanceof Error ? err.stack : String(err)); + process.exit(1); +}); diff --git a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx index 1b55734a..ab4e8379 100644 --- a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx +++ b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx @@ -2,6 +2,10 @@ import { notFound, redirect } from "next/navigation"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { DocumentShell } from "@/components/editor/document-shell"; import type { PageOptionsState, DocumentStats } from "@/types/page-options"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; interface DocumentPageProps { params: Promise<{ id: string }>; @@ -14,6 +18,49 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag const resolvedSearch = (await searchParams) ?? {}; const openTableIdRaw = resolvedSearch?.openTableId; const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null; + + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + const doc = await client.query(api.documents.getMeta, { userId: auth.userId, id }); + if (!doc) { + notFound(); + } + + const initialOptions: PageOptionsState = { + wideLayout: doc.wide_layout ?? false, + smallText: doc.use_small_text ?? false, + showHeadingNumbers: doc.show_heading_numbers ?? true, + showToc: doc.show_toc ?? false, + showStructure: doc.show_structure ?? false, + protectEditing: doc.protect_editing ?? false, + showWordCount: doc.show_word_count ?? true, + }; + + const initialStats: DocumentStats = { + wordCount: doc.word_count ?? 0, + characterCount: doc.character_count ?? 0, + blockCount: doc.block_count ?? 0, + }; + + return ( +
+
+ +
+
+ ); + } + const supabase = await createSupabaseServerClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/(app)/layout.tsx b/wolai-frontend/src/app/(app)/layout.tsx index 261ef7d5..6f911584 100644 --- a/wolai-frontend/src/app/(app)/layout.tsx +++ b/wolai-frontend/src/app/(app)/layout.tsx @@ -10,9 +10,189 @@ import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspace import { fetchSidebarDataset } from "@/lib/sidebar-tree"; import type { SidebarInitialData } from "@/components/sidebar/types"; import { SearchPalette } from "@/components/search/search-palette"; -import { detectLocalMindmapDocs, detectLocalTrashedMindmapAssets } from "@/lib/server/mindmap-files"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; +import type { MediaAsset } from "@/types/media"; + +function extractMindmapImageAssetIdsFromData(input: unknown): string[] { + const root = (() => { + if (!input || typeof input !== "object") return input; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const record = input as any; + return record && typeof record === "object" && "root" in record ? record.root : input; + })(); + + const ids: string[] = []; + const seen = new Set(); + + 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)[key]; + }; + + const walk = (node: unknown) => { + if (!node || typeof node !== "object") return; + const data = get(node, "data"); + const image = get(node, "image"); + push(get(data, "image")); + push(image); + push(get(image, "url")); + push(get(get(data, "image"), "url")); + const children = get(node, "children"); + if (Array.isArray(children)) children.forEach(walk); + }; + + walk(root); + return ids; +} + +function makeId(): string { + return typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(16).slice(2)}`; +} export default async function AppLayout({ children }: { children: ReactNode }) { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + + const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, { + userId: auth.userId, + fallbackName: auth.name ?? auth.email ?? "我的空间", + workspaceIdIfCreate: makeId(), + }); + + const workspaces = ensured.workspaces; + const activeWorkspaceId = ensured.activeWorkspaceId; + + let documents: DocumentRecord[] = []; + let sidebarInitialData: SidebarInitialData | null = null; + + if (activeWorkspaceId) { + const [docRows, trashedDocs] = await Promise.all([ + client.query(api.documents.listByWorkspace, { + userId: auth.userId, + workspaceId: activeWorkspaceId, + }), + client.query(api.documents.listTrashedByWorkspace, { + userId: auth.userId, + workspaceId: activeWorkspaceId, + }), + ]); + + documents = docRows as unknown as DocumentRecord[]; + + const mindmapRows = await client.query(api.mindmaps.listByWorkspace, { + userId: auth.userId, + workspaceId: activeWorkspaceId, + includeDeleted: true, + }); + + const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at); + const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at); + + const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id))); + + const mindmapAssetChildren: Record = {}; + activeMindmaps.forEach((r) => { + const ids = extractMindmapImageAssetIdsFromData(r.data); + if (ids.length > 0) mindmapAssetChildren[r.mindmap_id] = ids; + }); + + const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => { + const isLegacy = r.mindmap_id.startsWith("legacy-"); + return { + id: r.mindmap_id, + workspace_id: r.workspace_id ?? activeWorkspaceId, + document_id: r.document_id, + asset_type: "mindmap", + file_url: null, + thumbnail_url: null, + bucket: null, + storage_path: null, + file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`, + file_size: null, + mime_type: "application/json", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + signed_url: null, + created_at: r.created_at ?? "", + updated_at: r.updated_at ?? "", + }; + }); + + const trashedMindmapAssets: MediaAsset[] = trashedMindmaps.map((r) => { + const isLegacy = r.mindmap_id.startsWith("legacy-"); + return { + id: r.mindmap_id, + workspace_id: r.workspace_id ?? activeWorkspaceId, + document_id: r.document_id, + asset_type: "mindmap", + file_url: null, + thumbnail_url: null, + bucket: null, + storage_path: null, + file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`, + file_size: null, + mime_type: "application/json", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + deleted_at: r.deleted_at ?? null, + deleted_by: r.deleted_by ?? null, + purged_at: null, + signed_url: null, + created_at: r.created_at ?? "", + updated_at: r.updated_at ?? "", + }; + }); + + sidebarInitialData = { + activeWorkspaceId, + workspaces, + documents: docRows as unknown as DocumentRecord[], + trashedDocuments: trashedDocs as unknown as SidebarInitialData["trashedDocuments"], + trashedMediaAssets: [], + trashedMindmapAssets, + mediaAssets: [], + mindmapDocs, + mindmapAssets, + mindmapAssetChildren, + }; + } + + return ( +
+ {sidebarInitialData && } +
+
+ + +
+
{children}
+ + +
+
+ ); + } + const supabase = await createSupabaseServerClient(); const { data: { session }, 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 index 66289def..a003d103 100644 --- 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 @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; import { buildClientToolKey, resolveClientToolCall, @@ -26,11 +28,26 @@ export async function POST(request: Request) { 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 userId = (() => { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + return auth.userId; + } + return null; + })(); + + const resolvedUserId = async () => { + if (userId) return userId; + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) return null; + return session.user.id; + }; + + const finalUserId = await resolvedUserId(); + if (!finalUserId) return NextResponse.json({ error: "未登录" }, { status: 401 }); const result: ClientToolResult = payload.ok ? { ok: true, result: "result" in payload ? payload.result : null } @@ -39,7 +56,7 @@ export async function POST(request: Request) { const key = buildClientToolKey(requestId, callId); const resolved = resolveClientToolCall({ key, - userId: session.user.id, + userId: finalUserId, result, }); if (!resolved.ok) { @@ -48,4 +65,3 @@ export async function POST(request: Request) { 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 80c1b8e0..06bdaaa7 100644 --- a/wolai-frontend/src/app/api/ai-agent/run/route.ts +++ b/wolai-frontend/src/app/api/ai-agent/run/route.ts @@ -5,6 +5,7 @@ import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/ import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins"; import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb"; import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools"; import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools"; @@ -14,6 +15,8 @@ import { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent 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"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; export const dynamic = "force-dynamic"; @@ -70,12 +73,21 @@ export async function POST(request: Request) { return NextResponse.json({ error: "缺少 messages" }, { status: 400 }); } - // v1:先要求登录(避免在生产环境暴露推理能力);后续可做更细的权限控制 - const supabase = await createSupabaseRouteClient(); - const { - data: { session }, - } = await supabase.auth.getSession(); - if (!session) { + // v1:鉴权(Convex 迁移阶段使用固定开发用户;非 Convex 模式仍走 Supabase session) + const convexOn = isConvexEnabled(); + const { userId, supabase, convexClient } = await (async () => { + if (convexOn) { + const { auth, client } = getAuthedConvexClient(); + return { userId: auth.userId, supabase: null as any, convexClient: client }; + } + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) return { userId: "", supabase: null as any, convexClient: null as any }; + return { userId: session.user.id, supabase, convexClient: null as any }; + })(); + if (!userId) { return NextResponse.json({ error: "未登录" }, { status: 401 }); } @@ -167,6 +179,25 @@ export async function POST(request: Request) { allowedToolIds.delete("doc_replace_range"); } + // 说明:Convex 迁移阶段(M4)先确保“不会再触发 Supabase 依赖”。 + // 未迁移的能力(OnlyOffice 等)在 Convex 模式下直接禁用对应工具。 + if (convexOn) { + for (const id of [...allowedToolIds]) { + if ( + id === "search_web" || + id === "image_read" || + id === "slash_run" || + id.startsWith("rag_") || + id.startsWith("mindmap_") || + id.startsWith("doc_") || + id.startsWith("docs_") + ) { + continue; + } + allowedToolIds.delete(id); + } + } + const systemContextText = (() => { const lines: string[] = []; if (documentId) lines.push(`documentId=${documentId}`); @@ -181,12 +212,67 @@ export async function POST(request: Request) { return lines.join("\n").trim(); })(); + const normalizeBlocksForTools = (content: unknown): unknown[] => { + if (Array.isArray(content)) return content; + if (content && typeof content === "object" && "blocks" in (content as any)) { + const blocks = (content as any).blocks; + if (Array.isArray(blocks)) return blocks; + } + return []; + }; + + const extractPlainTextFromBlocks = (blocks: unknown[], maxChars: number) => { + const pieces: string[] = []; + const walk = (list: unknown[]) => { + for (const b of list) { + if (!b || typeof b !== "object") continue; + const content = (b as any).content; + if (Array.isArray(content)) { + for (const n of content) { + const t = n && typeof n === "object" ? String((n as any).text ?? "") : ""; + if (t) pieces.push(t); + if (pieces.join("").length >= maxChars) return; + } + } + const children = (b as any).children; + if (Array.isArray(children)) { + walk(children); + if (pieces.join("").length >= maxChars) return; + } + } + }; + walk(blocks); + const raw = pieces.join("").replace(/\s+/g, " ").trim(); + return raw.length > maxChars ? `${raw.slice(0, maxChars)}…` : raw; + }; + const mindmapTools = hasMindmapContext ? createMindmapServerTools({ supabase: supabase as unknown as MindmapSupabaseClient, - ctx: { documentId, mindmapId, userId: session.user.id, selectedUids, attachments }, + ctx: { documentId, mindmapId, userId, selectedUids, attachments }, cfg: { ...cfg, model: modelOverride ?? cfg.model }, allowedToolIds, + ...(convexOn + ? { + loadMindmap: async () => { + if (!convexClient) throw new Error("Convex 未初始化"); + const [mm, meta] = await Promise.all([ + convexClient.query(api.mindmaps.get, { userId, docId: documentId, mindmapId }), + convexClient.query(api.documents.getMeta, { userId, id: documentId }), + ]); + const title = meta?.title ?? null; + const workspaceId = meta?.workspace_id ?? (mm as any)?.meta?.workspace_id ?? null; + return { + doc: { id: documentId, title, workspace_id: workspaceId }, + base: (mm as any)?.data ?? { data: { text: "中心主题" }, children: [] }, + }; + }, + saveMindmap: async ({ data }) => { + if (!convexClient) throw new Error("Convex 未初始化"); + await convexClient.mutation(api.mindmaps.put, { userId, docId: documentId, mindmapId, data }); + }, + } + : {}), }) : null; @@ -198,14 +284,30 @@ export async function POST(request: Request) { allowedToolIds.has("doc_replace_range")) ? createDocServerTools({ supabase: supabase as unknown as DocSupabaseClient, - ctx: { documentId, userId: session.user.id, baseBlocks: documentBlocks }, + ctx: { documentId, userId, baseBlocks: documentBlocks }, allowedToolIds, + ...(convexOn + ? { + loadBlocks: async () => { + const base = normalizeBlocksForTools(documentBlocks); + if (base.length > 0) return { blocks: base, source: "client" }; + if (!convexClient) throw new Error("Convex 未初始化"); + const res = await convexClient.query(api.documents.getContent, { userId, id: documentId }); + const blocks = normalizeBlocksForTools(res?.content ?? null); + return { blocks, source: "convex" }; + }, + saveBlocks: async (blocks: unknown[]) => { + if (!convexClient) throw new Error("Convex 未初始化"); + await convexClient.mutation(api.documents.updateContent, { userId, id: documentId, content: blocks }); + }, + } + : {}), }) : null; const ragTools = allowedToolIds.has("rag_lightrag_query") ? createRagServerTools({ - ctx: { userId: session.user.id }, + ctx: { userId }, allowedToolIds, }) : null; @@ -214,24 +316,88 @@ export async function POST(request: Request) { allowedToolIds.has("docs_search") || allowedToolIds.has("docs_read") ? createDocsServerTools({ supabase: supabase as unknown as DocsSupabaseClient, - ctx: { userId: session.user.id }, + ctx: { userId }, allowedToolIds, + ...(convexOn + ? { + searchDocs: async ({ query, limit, workspaceId, includeDeleted }) => { + if (!convexClient) throw new Error("Convex 未初始化"); + const wsIds = workspaceId + ? [workspaceId] + : ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId }))?.workspaces ?? []).map((w: any) => + String(w?.id ?? ""), + ); + const q = query.toLowerCase(); + const results: any[] = []; + for (const wid of wsIds.filter(Boolean)) { + const docs = await convexClient.query(api.documents.listByWorkspace, { userId, workspaceId: wid }); + const extra = includeDeleted + ? await convexClient.query(api.documents.listTrashedByWorkspace, { userId, workspaceId: wid }).catch(() => []) + : []; + const all = [...(Array.isArray(docs) ? docs : []), ...(Array.isArray(extra) ? extra : [])]; + for (const d of all) { + const title = String((d as any)?.title ?? ""); + if (!title.toLowerCase().includes(q)) continue; + results.push({ + id: String((d as any)?.id ?? ""), + title, + workspaceId: String((d as any)?.workspace_id ?? wid), + parentId: (d as any)?.parent_id ? String((d as any).parent_id) : null, + updatedAt: (d as any)?.updated_at ?? null, + snippet: title.slice(0, 120), + }); + if (results.length >= limit) break; + } + if (results.length >= limit) break; + } + return results.slice(0, limit); + }, + readDoc: async ({ documentId: rid, maxChars, includeContent }) => { + if (!convexClient) throw new Error("Convex 未初始化"); + const meta = await convexClient.query(api.documents.getMeta, { userId, id: rid }); + if (!meta) throw new Error("页面不存在"); + const contentRes = await convexClient.query(api.documents.getContent, { userId, id: rid }); + const blocks = normalizeBlocksForTools(contentRes?.content ?? null); + const rawText = extractPlainTextFromBlocks(blocks, maxChars); + return { + ok: true, + documentId: rid, + title: String(meta.title ?? ""), + workspaceId: String((meta as any).workspace_id ?? ""), + parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null, + updatedAt: (meta as any).updated_at ?? null, + rawTextLength: rawText.length, + rawText, + ...(includeContent ? { content: contentRes?.content ?? null } : {}), + }; + }, + } + : {}), }) : null; const mediaTools = allowedToolIds.has("image_read") ? createMediaServerTools({ supabase: supabase as unknown as MediaSupabaseClient, - ctx: { userId: session.user.id, attachments }, + ctx: { userId, attachments }, allowedToolIds, + ...(convexOn + ? { + loadById: async (id: string) => { + if (!convexClient) throw new Error("Convex 未初始化"); + return await convexClient.query(api.mediaAssets.getById, { userId, id }); + }, + loadByFileUrl: async (_fileUrl: string) => null, + } + : {}), }) : null; const onlyofficeTools = - allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap") + !convexOn && (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 }, + ctx: { userId, documentId: documentId || undefined, attachments }, allowedToolIds, }) : null; @@ -239,8 +405,56 @@ export async function POST(request: Request) { const slashTools = allowedToolIds.has("slash_run") ? createSlashServerTools({ supabase: supabase as unknown as SlashSupabaseClient, - ctx: { userId: session.user.id, currentDocumentId: documentId || undefined }, + ctx: { userId, currentDocumentId: documentId || undefined }, allowedToolIds, + ...(convexOn + ? { + loadWorkspaceIds: async (uid: string) => { + if (!convexClient) throw new Error("Convex 未初始化"); + const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId: uid }); + return (res?.workspaces ?? []).map((w: any) => String(w?.id ?? "")).filter(Boolean); + }, + inferWorkspaceIdFromDoc: async (docId: string) => { + if (!convexClient) throw new Error("Convex 未初始化"); + const meta = await convexClient.query(api.documents.getMeta, { userId, id: docId }); + return meta ? String((meta as any).workspace_id ?? "") || null : null; + }, + createDoc: async ({ workspaceId, parentId, title }) => { + if (!convexClient) throw new Error("Convex 未初始化"); + const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `doc_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; + const created = await convexClient.mutation(api.documents.create, { + userId, + id, + workspaceId, + parentId, + title, + accessScope: "private", + content: [], + }); + return { + id: String((created as any).id ?? id), + title: String((created as any).title ?? title), + workspaceId: String((created as any).workspace_id ?? workspaceId), + parentId: (created as any).parent_id ? String((created as any).parent_id) : parentId, + createdAt: (created as any).created_at ?? null, + updatedAt: (created as any).updated_at ?? null, + }; + }, + renameDoc: async ({ documentId: did, title }) => { + if (!convexClient) throw new Error("Convex 未初始化"); + await convexClient.mutation(api.documents.updateTitle, { userId, id: did, title }); + const meta = await convexClient.query(api.documents.getMeta, { userId, id: did }); + if (!meta) throw new Error("页面不存在"); + return { + id: String((meta as any).id ?? did), + title: String((meta as any).title ?? title), + workspaceId: String((meta as any).workspace_id ?? ""), + parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null, + updatedAt: (meta as any).updated_at ?? null, + }; + }, + } + : {}), }) : null; @@ -323,11 +537,11 @@ export async function POST(request: Request) { 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, - }); + const wait = registerClientToolCall({ + key, + userId, + timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS, + }); // 说明:客户端收到该事件后,需要执行插件 API 并回调 /api/ai-agent/client-tool-result send("client_tool_call", { requestId, callId, tool: toolId, args: toolArgs }); diff --git a/wolai-frontend/src/app/api/blocks/embed/route.ts b/wolai-frontend/src/app/api/blocks/embed/route.ts new file mode 100644 index 00000000..9599235d --- /dev/null +++ b/wolai-frontend/src/app/api/blocks/embed/route.ts @@ -0,0 +1,116 @@ +import { randomUUID } from "crypto"; +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; +import { getBlocksFromDocumentContent, findBlockInTree, withBlocksWrittenBack } from "@/lib/blocks"; + +type EmbedBlockPayload = { + sourceDocumentId: string; + blockId: string; + targetDocumentId: string; + position?: "end"; +}; + +export async function POST(request: Request) { + const { sourceDocumentId, blockId, targetDocumentId }: EmbedBlockPayload = await request.json(); + + if (!sourceDocumentId || !blockId || !targetDocumentId) { + return NextResponse.json({ error: "缺少参数" }, { status: 400 }); + } + + if (sourceDocumentId === targetDocumentId) { + return NextResponse.json({ error: "禁止嵌入到当前页面" }, { status: 400 }); + } + + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + + const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId }); + if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); + const sourceBlocks = getBlocksFromDocumentContent(source.content); + const hit = findBlockInTree(sourceBlocks, blockId); + if (!hit) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 }); + + const target = await client.query(api.documents.getContent, { userId: auth.userId, id: targetDocumentId }); + if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); + + const targetBlocks = getBlocksFromDocumentContent(target.content); + const referenceBlock = { + id: randomUUID(), + type: "blockReference", + props: { + sourceDocumentId, + targetBlockId: blockId, + display: "embed", + }, + content: [], + children: [], + }; + + const nextBlocks = [...targetBlocks, referenceBlock]; + const payload = withBlocksWrittenBack(target.content, nextBlocks); + await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: payload }); + + return NextResponse.json({ ok: true }); + } + + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { data: sourceDoc } = await supabase + .from("documents") + .select("id,content,user_id") + .eq("id", sourceDocumentId) + .eq("user_id", session.user.id) + .single(); + if (!sourceDoc) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); + + const sourceBlocks = getBlocksFromDocumentContent(sourceDoc.content); + if (!findBlockInTree(sourceBlocks, blockId)) { + return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 }); + } + + const { data: targetDoc } = await supabase + .from("documents") + .select("id,content,user_id") + .eq("id", targetDocumentId) + .eq("user_id", session.user.id) + .single(); + if (!targetDoc) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); + + const targetBlocks = getBlocksFromDocumentContent(targetDoc.content); + const referenceBlock = { + id: randomUUID(), + type: "blockReference", + props: { + sourceDocumentId, + targetBlockId: blockId, + display: "embed", + }, + content: [], + children: [], + }; + + const nextBlocks = [...targetBlocks, referenceBlock]; + const payload = withBlocksWrittenBack(targetDoc.content, nextBlocks); + + const { error } = await supabase + .from("documents") + .update({ content: payload }) + .eq("id", targetDocumentId) + .eq("user_id", session.user.id); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ ok: true }); +} + diff --git a/wolai-frontend/src/app/api/blocks/get/route.ts b/wolai-frontend/src/app/api/blocks/get/route.ts new file mode 100644 index 00000000..38542b37 --- /dev/null +++ b/wolai-frontend/src/app/api/blocks/get/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; +import { getBlocksFromDocumentContent, findBlockInTree } from "@/lib/blocks"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const sourceDocumentId = url.searchParams.get("sourceDocumentId") || ""; + const blockId = url.searchParams.get("blockId") || ""; + + if (!sourceDocumentId || !blockId) { + return NextResponse.json({ error: "缺少参数" }, { status: 400 }); + } + + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId }); + if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 }); + const blocks = getBlocksFromDocumentContent(doc.content); + const hit = findBlockInTree(blocks, blockId); + if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 }); + return NextResponse.json({ ok: true, block: hit.block }); + } + + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 }); + + const { data: doc } = await supabase + .from("documents") + .select("id,content,user_id") + .eq("id", sourceDocumentId) + .eq("user_id", session.user.id) + .single(); + if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 }); + + const blocks = getBlocksFromDocumentContent(doc.content); + const hit = findBlockInTree(blocks, blockId); + if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 }); + + return NextResponse.json({ ok: true, block: hit.block }); +} + diff --git a/wolai-frontend/src/app/api/blocks/move/route.ts b/wolai-frontend/src/app/api/blocks/move/route.ts new file mode 100644 index 00000000..ef5810ae --- /dev/null +++ b/wolai-frontend/src/app/api/blocks/move/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; +import { getBlocksFromDocumentContent, removeBlockSubtree, withBlocksWrittenBack } from "@/lib/blocks"; + +type MoveBlockPayload = { + sourceDocumentId: string; + blockId: string; + targetDocumentId: string; + position?: "end"; +}; + +export async function POST(request: Request) { + const { sourceDocumentId, blockId, targetDocumentId }: MoveBlockPayload = await request.json(); + + if (!sourceDocumentId || !blockId || !targetDocumentId) { + return NextResponse.json({ error: "缺少参数" }, { status: 400 }); + } + + if (sourceDocumentId === targetDocumentId) { + // 说明:同页移动先不做“定位插入”,视为 no-op。 + return NextResponse.json({ ok: true, noop: true }); + } + + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + + const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId }); + if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); + const sourceBlocks = getBlocksFromDocumentContent(source.content); + const removedRes = removeBlockSubtree(sourceBlocks, blockId); + if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 }); + + const target = await client.query(api.documents.getContent, { userId: auth.userId, id: targetDocumentId }); + if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); + const targetBlocks = getBlocksFromDocumentContent(target.content); + + const nextSourcePayload = withBlocksWrittenBack(source.content, removedRes.nextBlocks); + const nextTargetPayload = withBlocksWrittenBack(target.content, [...targetBlocks, removedRes.removed]); + + await client.mutation(api.documents.updateContent, { userId: auth.userId, id: sourceDocumentId, content: nextSourcePayload }); + await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: nextTargetPayload }); + + return NextResponse.json({ ok: true }); + } + + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { data: sourceDoc } = await supabase + .from("documents") + .select("id,content,user_id") + .eq("id", sourceDocumentId) + .eq("user_id", session.user.id) + .single(); + if (!sourceDoc) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); + + const sourceBlocks = getBlocksFromDocumentContent(sourceDoc.content); + const removedRes = removeBlockSubtree(sourceBlocks, blockId); + if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 }); + + const { data: targetDoc } = await supabase + .from("documents") + .select("id,content,user_id") + .eq("id", targetDocumentId) + .eq("user_id", session.user.id) + .single(); + if (!targetDoc) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); + + const targetBlocks = getBlocksFromDocumentContent(targetDoc.content); + const nextSourcePayload = withBlocksWrittenBack(sourceDoc.content, removedRes.nextBlocks); + const nextTargetPayload = withBlocksWrittenBack(targetDoc.content, [...targetBlocks, removedRes.removed]); + + const { error: srcErr } = await supabase + .from("documents") + .update({ content: nextSourcePayload }) + .eq("id", sourceDocumentId) + .eq("user_id", session.user.id); + if (srcErr) return NextResponse.json({ error: srcErr.message }, { status: 500 }); + + const { error: tgtErr } = await supabase + .from("documents") + .update({ content: nextTargetPayload }) + .eq("id", targetDocumentId) + .eq("user_id", session.user.id); + if (tgtErr) return NextResponse.json({ error: tgtErr.message }, { status: 500 }); + + return NextResponse.json({ ok: true }); +} + diff --git a/wolai-frontend/src/app/api/blocks/patch/route.ts b/wolai-frontend/src/app/api/blocks/patch/route.ts new file mode 100644 index 00000000..91e736a2 --- /dev/null +++ b/wolai-frontend/src/app/api/blocks/patch/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; +import { getBlocksFromDocumentContent, replaceBlockInTree, withBlocksWrittenBack } from "@/lib/blocks"; + +type PatchPayload = { + sourceDocumentId: string; + blockId: string; + nextBlock: unknown; +}; + +export async function POST(request: Request) { + const { sourceDocumentId, blockId, nextBlock }: PatchPayload = await request.json(); + + if (!sourceDocumentId || !blockId || !nextBlock) { + return NextResponse.json({ error: "缺少参数" }, { status: 400 }); + } + + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId }); + if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 }); + + const blocks = getBlocksFromDocumentContent(doc.content); + const replaced = replaceBlockInTree(blocks, blockId, nextBlock as any); + if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 }); + + const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks); + await client.mutation(api.documents.updateContent, { userId: auth.userId, id: sourceDocumentId, content: payload }); + return NextResponse.json({ ok: true }); + } + + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 }); + + const { data: doc } = await supabase + .from("documents") + .select("id,content,user_id") + .eq("id", sourceDocumentId) + .eq("user_id", session.user.id) + .single(); + if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 }); + + const blocks = getBlocksFromDocumentContent(doc.content); + const replaced = replaceBlockInTree(blocks, blockId, nextBlock as any); + if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 }); + + const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks); + const { error } = await supabase + .from("documents") + .update({ content: payload }) + .eq("id", sourceDocumentId) + .eq("user_id", session.user.id); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + + return NextResponse.json({ ok: true }); +} + diff --git a/wolai-frontend/src/app/api/dev/jobs/demo/route.ts b/wolai-frontend/src/app/api/dev/jobs/demo/route.ts new file mode 100644 index 00000000..9454e2af --- /dev/null +++ b/wolai-frontend/src/app/api/dev/jobs/demo/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { randomUUID } from "crypto"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + + const { ms }: { ms?: number } = await request.json().catch(() => ({})); + const id = randomUUID(); + + const result = await client.mutation(api.jobs.enqueueDemo, { + userId: auth.userId, + id, + ms, + }); + + return NextResponse.json(result); +} + +export async function GET(request: Request) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + + const url = new URL(request.url); + const id = url.searchParams.get("id") ?? ""; + if (!id) { + return NextResponse.json({ error: "缺少 id" }, { status: 400 }); + } + + const job = await client.query(api.jobs.get, { userId: auth.userId, id }); + if (!job) { + return NextResponse.json({ error: "任务不存在" }, { status: 404 }); + } + + return NextResponse.json(job); +} + diff --git a/wolai-frontend/src/app/api/dev/whoami/route.ts b/wolai-frontend/src/app/api/dev/whoami/route.ts new file mode 100644 index 00000000..d5bfab1b --- /dev/null +++ b/wolai-frontend/src/app/api/dev/whoami/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; + +import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const auth = requireAuthContext(); + return NextResponse.json({ ok: true, auth }, { status: 200 }); + } catch (err) { + const status = err instanceof HttpError ? err.status : 500; + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status }); + } +} diff --git a/wolai-frontend/src/app/api/documents/content/route.ts b/wolai-frontend/src/app/api/documents/content/route.ts index bf914619..0cd81235 100644 --- a/wolai-frontend/src/app/api/documents/content/route.ts +++ b/wolai-frontend/src/app/api/documents/content/route.ts @@ -1,9 +1,35 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; +import { requireAuthContext } from "@/lib/auth/authContext"; export const dynamic = "force-dynamic"; export async function GET(request: Request) { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const url = new URL(request.url); + const documentId = url.searchParams.get("documentId") ?? ""; + + if (!documentId) { + return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); + } + + const client = getConvexHttpClient(); + const result = await client.query(api.documents.getContent, { + userId: auth.userId, + id: documentId, + }); + + if (!result) { + return NextResponse.json({ error: "页面不存在" }, { status: 404 }); + } + + return NextResponse.json({ content: result.content ?? null }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -37,4 +63,3 @@ export async function GET(request: Request) { return NextResponse.json({ content: document.content ?? null }); } - diff --git a/wolai-frontend/src/app/api/documents/copy-tree/route.ts b/wolai-frontend/src/app/api/documents/copy-tree/route.ts index c4ffc550..7b363e6c 100644 --- a/wolai-frontend/src/app/api/documents/copy-tree/route.ts +++ b/wolai-frontend/src/app/api/documents/copy-tree/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; import path from "path"; import { promises as fs } from "fs"; import type { Json } from "@/types/supabase"; @@ -158,6 +162,142 @@ function replaceAssetRefsInContent(content: Json | null, assetMap: Map it?.documentId); + if (normalizedItems.length === 0) { + return NextResponse.json({ error: "items 为空" }, { status: 400 }); + } + + const targetParentId = payload.targetParentId ?? null; + let workspaceId: string | null = null; + + if (targetParentId) { + const targetDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: targetParentId }); + if (!targetDoc) { + return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); + } + workspaceId = targetDoc.workspace_id; + } + + const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId))); + const firstMeta = await client.query(api.documents.getMeta, { userId: auth.userId, id: sourceIds[0] }); + if (!firstMeta) { + return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); + } + + if (!workspaceId) { + workspaceId = firstMeta.workspace_id; + } + + if (!workspaceId) { + return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 }); + } + + const wid = workspaceId; + + const allDocs = await client.query(api.documents.listAllForCopy, { + userId: auth.userId, + workspaceId: wid, + }); + + const sourceById = new Map(); + (allDocs as unknown as DocRow[]).forEach((d) => sourceById.set(d.id, d)); + + const missing = sourceIds.find((id) => !sourceById.has(id)); + if (missing) { + return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); + } + + const childrenByParent = new Map(); + (allDocs as unknown as DocRow[]).forEach((doc) => { + const list = childrenByParent.get(doc.parent_id) ?? []; + list.push(doc); + childrenByParent.set(doc.parent_id, list); + }); + + const existingTitleSetByParent = new Map>(); + const seedTitleSet = (parent: string | null) => { + if (existingTitleSetByParent.has(parent)) return; + const titles = new Set(); + (childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title))); + existingTitleSetByParent.set(parent, titles); + }; + seedTitleSet(targetParentId); + + const newIdByOldId = new Map(); + const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = []; + + const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => { + const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => { + const newId = + typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`; + newIdByOldId.set(node.id, newId); + copyQueue.push({ old: node, newParentId: parentNewId, parentKey }); + if (!recursive) return; + const children = getChildrenSorted(childrenByParent, node.id); + children.forEach((child) => visit(child, newId, newId)); + }; + visit(root, newParent, newParent); + }; + + normalizedItems.forEach((item) => { + const doc = sourceById.get(item.documentId) ?? null; + if (doc) { + enqueueTree(doc, targetParentId, Boolean(item.recursive)); + } + }); + + if (copyQueue.length === 0) { + return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 }); + } + + const insertedDocs: Array<{ oldId: string; newId: string }> = []; + + for (const item of copyQueue) { + const newId = newIdByOldId.get(item.old.id)!; + const parentId = item.newParentId; + + if (!existingTitleSetByParent.has(parentId)) { + seedTitleSet(parentId); + } + const titleSet = existingTitleSetByParent.get(parentId) ?? new Set(); + existingTitleSetByParent.set(parentId, titleSet); + + const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet); + titleSet.add(newTitle); + + await client.mutation(api.documents.create, { + userId: auth.userId, + id: newId, + workspaceId: wid, + parentId, + accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public", + title: newTitle, + content: item.old.content ?? [], + }); + + await ensureDocumentScaffold(newId, newTitle); + await client.mutation(api.mindmaps.copyByDocument, { + userId: auth.userId, + sourceDocId: item.old.id, + targetDocId: newId, + }); + insertedDocs.push({ oldId: item.old.id, newId }); + } + + return NextResponse.json({ + items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })), + }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/documents/create-child/route.ts b/wolai-frontend/src/app/api/documents/create-child/route.ts index 9070ccc1..d1eba56e 100644 --- a/wolai-frontend/src/app/api/documents/create-child/route.ts +++ b/wolai-frontend/src/app/api/documents/create-child/route.ts @@ -1,6 +1,16 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; + +function makeId(): string { + return typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(16).slice(2)}`; +} type CreateChildPayload = { parentId: string | null; @@ -11,6 +21,58 @@ type CreateChildPayload = { export const dynamic = "force-dynamic"; export async function POST(request: Request) { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + + const { parentId, title, blocks }: CreateChildPayload = await request.json(); + + if (typeof parentId === "undefined") { + return NextResponse.json({ error: "缺少 parentId" }, { status: 400 }); + } + + let workspaceId: string; + let accessScope: "private" | "shared" | "public" = "private"; + + if (parentId) { + const parentDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: parentId }); + if (!parentDoc) { + return NextResponse.json({ error: "父页面不存在" }, { status: 404 }); + } + workspaceId = parentDoc.workspace_id; + accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope; + } else { + const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, { + userId: auth.userId, + fallbackName: auth.name ?? auth.email ?? "我的空间", + workspaceIdIfCreate: makeId(), + }); + workspaceId = ensured.activeWorkspaceId; + if (!workspaceId) { + return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 }); + } + } + + const resolvedTitle = title && title.trim().length > 0 ? title.trim() : "未命名页面"; + const contentPayload = Array.isArray(blocks) ? blocks : []; + const pageId = makeId(); + + const created = await client.mutation(api.documents.create, { + userId: auth.userId, + id: pageId, + workspaceId, + parentId: parentId ?? null, + title: resolvedTitle, + accessScope, + content: contentPayload, + }); + + return NextResponse.json({ + pageId: created.id, + title: created.title ?? resolvedTitle, + }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/documents/create/route.ts b/wolai-frontend/src/app/api/documents/create/route.ts index 4d1a1f90..618bf705 100644 --- a/wolai-frontend/src/app/api/documents/create/route.ts +++ b/wolai-frontend/src/app/api/documents/create/route.ts @@ -7,6 +7,10 @@ import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/docume import path from "path"; import { promises as fs } from "fs"; import { getDocumentsBaseDir } from "@/lib/server/local-paths"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; +import { requireAuthContext } from "@/lib/auth/authContext"; const documentsBaseDir = getDocumentsBaseDir(); @@ -25,6 +29,9 @@ async function ensureDocumentScaffold(id: string, title: string | null) { export async function POST(request: Request) { try { + if (isConvexEnabled()) { + return await handleCreateRequestConvex(request); + } return await handleCreateRequest(request); } catch (error) { console.error("创建页面失败", error); @@ -33,6 +40,83 @@ export async function POST(request: Request) { } } +async function handleCreateRequestConvex(request: Request) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + + const { parentId }: { parentId?: string | null } = await request.json(); + + const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, { + userId: auth.userId, + fallbackName: auth.email ?? auth.name ?? "我的空间", + workspaceIdIfCreate: randomUUID(), + }); + + let workspaceId: string | null = null; + let parentContent: Json | null = null; + let accessScope: "private" | "shared" | "public" = "private"; + + if (parentId) { + const parentDoc = await client.query(api.documents.getMeta, { + userId: auth.userId, + id: parentId, + }); + + if (!parentDoc) { + return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 }); + } + + workspaceId = parentDoc.workspace_id; + accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope; + const parentContentRes = await client.query(api.documents.getContent, { userId: auth.userId, id: parentId }); + parentContent = (parentContentRes?.content as Json | null) ?? null; + } else { + workspaceId = workspaceBootstrap.activeWorkspaceId || null; + } + + if (!workspaceId) { + return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 }); + } + + const id = randomUUID(); + const data = await client.mutation(api.documents.create, { + userId: auth.userId, + id, + workspaceId, + parentId: parentId ?? null, + title: "无标题", + accessScope, + content: [], + }); + + // 为文件树创建本地目录和 index.md + if (data?.id) { + await ensureDocumentScaffold(data.id, data.title ?? "无标题"); + } + + if (parentId && data) { + const existingBlocks = extractBlocksFromContent(parentContent); + const pageReferenceBlock = { + id: randomUUID(), + type: "pageReference", + props: { + pageId: data.id, + title: data.title ?? "无标题", + }, + }; + const nextBlocks = [...existingBlocks, pageReferenceBlock as Json]; + const payload = composeContentWithBlocks(parentContent, nextBlocks); + + await client.mutation(api.documents.updateContent, { + userId: auth.userId, + id: parentId, + content: payload, + }); + } + + return NextResponse.json(data); +} + async function handleCreateRequest(request: Request) { const supabase = await createSupabaseRouteClient(); const { diff --git a/wolai-frontend/src/app/api/documents/delete/route.ts b/wolai-frontend/src/app/api/documents/delete/route.ts index 8e75acfa..7d11da42 100644 --- a/wolai-frontend/src/app/api/documents/delete/route.ts +++ b/wolai-frontend/src/app/api/documents/delete/route.ts @@ -1,9 +1,24 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; export const dynamic = "force-dynamic"; export async function POST(request: Request) { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const { documentId } = await request.json(); + if (!documentId) { + return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); + } + const client = getConvexHttpClient(); + await client.mutation(api.documents.softDelete, { userId: auth.userId, id: documentId }); + return NextResponse.json({ success: true }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/documents/duplicate/route.ts b/wolai-frontend/src/app/api/documents/duplicate/route.ts index 02bf0019..ab790f0e 100644 --- a/wolai-frontend/src/app/api/documents/duplicate/route.ts +++ b/wolai-frontend/src/app/api/documents/duplicate/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; import path from "path"; import { promises as fs } from "fs"; import { getDocumentsBaseDir } from "@/lib/server/local-paths"; @@ -43,6 +47,50 @@ async function copyMindmapIfExists(sourceId: string, targetId: string) { } export async function POST(request: Request) { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + + const { documentId }: DuplicatePayload = await request.json(); + if (!documentId) { + return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); + } + + const sourceDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: documentId }); + if (!sourceDoc) { + return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 }); + } + + const fallbackTitle = + sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题"; + const duplicatedTitle = `${fallbackTitle} 副本`; + + const newId = typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(16).slice(2)}`; + + const duplicated = await client.mutation(api.documents.duplicate, { + userId: auth.userId, + sourceId: documentId, + newId, + title: duplicatedTitle, + }); + + await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle); + await client.mutation(api.mindmaps.copyByDocument, { + userId: auth.userId, + sourceDocId: documentId, + targetDocId: duplicated.id, + }); + + return NextResponse.json({ + id: duplicated.id, + title: duplicated.title ?? duplicatedTitle, + parent_id: duplicated.parent_id ?? null, + sort_order: duplicated.sort_order ?? null, + }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/documents/embed/route.ts b/wolai-frontend/src/app/api/documents/embed/route.ts index 6387d993..4d346f87 100644 --- a/wolai-frontend/src/app/api/documents/embed/route.ts +++ b/wolai-frontend/src/app/api/documents/embed/route.ts @@ -3,6 +3,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; import type { Json } from "@/types/supabase"; import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; interface EmbedPayload { sourceId: string; @@ -10,6 +13,49 @@ interface EmbedPayload { } export async function POST(request: Request) { + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + + const { sourceId, targetId }: EmbedPayload = await request.json(); + + if (!sourceId || !targetId) { + return NextResponse.json({ error: "缺少参数" }, { status: 400 }); + } + + const sourceDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: sourceId }); + if (!sourceDoc) { + return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 }); + } + + const targetContent = await client.query(api.documents.getContent, { userId: auth.userId, id: targetId }); + if (!targetContent) { + return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); + } + + const currentBlocks = extractBlocksFromContent(targetContent.content); + const nextBlocks: Json[] = [ + ...currentBlocks, + { + id: randomUUID(), + type: "pageReference", + props: { + pageId: sourceDoc.id, + title: sourceDoc.title ?? "无标题", + }, + }, + ]; + + const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks); + + await client.mutation(api.documents.updateContent, { + userId: auth.userId, + id: targetId, + content: payload, + }); + + return NextResponse.json({ ok: true }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/documents/empty-trash/route.ts b/wolai-frontend/src/app/api/documents/empty-trash/route.ts index d6cd5578..3d56d7c8 100644 --- a/wolai-frontend/src/app/api/documents/empty-trash/route.ts +++ b/wolai-frontend/src/app/api/documents/empty-trash/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; export const dynamic = "force-dynamic"; @@ -8,6 +12,17 @@ interface EmptyTrashPayload { } export async function POST(request: Request) { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const { workspaceId }: EmptyTrashPayload = await request.json(); + if (!workspaceId) { + return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); + } + const client = getConvexHttpClient(); + await client.mutation(api.documents.emptyTrashByWorkspace, { userId: auth.userId, workspaceId }); + return NextResponse.json({ success: true }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/documents/move/route.ts b/wolai-frontend/src/app/api/documents/move/route.ts index 90b6158b..c607eff4 100644 --- a/wolai-frontend/src/app/api/documents/move/route.ts +++ b/wolai-frontend/src/app/api/documents/move/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; interface MovePayload { documentId: string; @@ -8,6 +12,20 @@ interface MovePayload { } export async function POST(request: Request) { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const { documentId, parentId = null, position }: MovePayload = await request.json(); + const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0; + const client = getConvexHttpClient(); + await client.mutation(api.documents.move, { + userId: auth.userId, + id: documentId, + parentId, + sortOrder, + }); + return NextResponse.json({ ok: true }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/documents/options/route.ts b/wolai-frontend/src/app/api/documents/options/route.ts index 63a6e9c2..0413f280 100644 --- a/wolai-frontend/src/app/api/documents/options/route.ts +++ b/wolai-frontend/src/app/api/documents/options/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; import type { PageOptionsState } from "@/types/page-options"; import type { Database } from "@/types/supabase"; @@ -19,6 +23,31 @@ const COLUMN_MAP: Record new Date().toISOString(); + + try { + switch (payload.action) { + case "delete": { + for (const a of assets) { + await client.mutation(api.mediaAssets.patchById, { + userId: auth.userId, + id: String(a.id), + patch: { deleted_at: nowIso(), deleted_by: auth.userId }, + }); + } + return NextResponse.json({ ok: true }); + } + case "restore": { + for (const a of assets) { + await client.mutation(api.mediaAssets.patchById, { + userId: auth.userId, + id: String(a.id), + patch: { deleted_at: null, deleted_by: null, purged_at: null }, + }); + } + return NextResponse.json({ ok: true }); + } + case "rename": { + if (payload.assetIds.length !== 1 || !payload.newName) { + return NextResponse.json({ error: "重命名需要单个文件与新名称" }, { status: 400 }); + } + const asset = assets[0]; + const currentName = String(asset.file_name ?? ""); + const ext = currentName.includes(".") ? `.${currentName.split(".").pop()}` : ""; + const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`; + const safeName = newFileName.replace(/[\\/]/g, "_"); + + await client.mutation(api.mediaAssets.patchById, { + userId: auth.userId, + id: String(asset.id), + patch: { file_name: safeName }, + }); + return NextResponse.json({ ok: true }); + } + case "copy": + case "move": { + if (!payload.targetDocumentId) { + return NextResponse.json({ error: "缺少目标页面" }, { status: 400 }); + } + + const targetDoc = await client.query(api.documents.getMeta, { + userId: auth.userId, + id: payload.targetDocumentId, + }); + + if (!targetDoc) { + return NextResponse.json({ error: "目标页面不存在" }, { status: 404 }); + } + + const existing = (await client.query(api.mediaAssets.listByDocument, { + userId: auth.userId, + documentId: payload.targetDocumentId, + limit: 500, + })) as any[]; + const existingNames = new Set( + (existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean), + ); + + const results: any[] = []; + + for (const asset of assets) { + const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_"); + const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null; + if (!storageId) continue; + + if (payload.action === "copy") { + const newId = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(16).slice(2)}`; + + const created = await client.mutation(api.mediaAssets.createWithStorage, { + userId: auth.userId, + storageId: storageId as any, + asset: { + id: newId, + workspace_id: String(targetDoc.workspace_id), + document_id: String(payload.targetDocumentId), + asset_type: String(asset.asset_type ?? "file"), + file_name: fileName, + file_size: typeof asset.file_size === "number" ? asset.file_size : null, + mime_type: (asset.mime_type ?? null) as any, + }, + }); + + results.push(created); + } else { + await client.mutation(api.mediaAssets.patchById, { + userId: auth.userId, + id: String(asset.id), + patch: { + workspace_id: String(targetDoc.workspace_id), + document_id: String(payload.targetDocumentId), + file_name: fileName, + }, + }); + + results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName }); + } + } + + return NextResponse.json({ items: results }); + } + default: + return NextResponse.json({ error: "不支持的操作" }, { status: 400 }); + } + } catch (err) { + const message = err instanceof Error ? err.message : "操作失败"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/media/empty-trash/route.ts b/wolai-frontend/src/app/api/media/empty-trash/route.ts index f71fcf8c..fa01f65f 100644 --- a/wolai-frontend/src/app/api/media/empty-trash/route.ts +++ b/wolai-frontend/src/app/api/media/empty-trash/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; +import { api } from "@/lib/convex/api"; +import { getConvexHttpClient } from "@/lib/convex/server"; export const dynamic = "force-dynamic"; @@ -25,6 +29,32 @@ function makeExpiredDeletedAt(): string { } export async function POST(request: Request) { + if (isConvexEnabled()) { + let auth; + try { + auth = requireAuthContext(); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: "未登录" }, { status: err.status }); + } + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({})); + if (!workspaceId) { + return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); + } + + const client = getConvexHttpClient(); + const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, { + userId: auth.userId, + workspaceId, + expiredDeletedAt: makeExpiredDeletedAt(), + }); + + return NextResponse.json({ success: true, ...(res as any) }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -85,4 +115,3 @@ export async function POST(request: Request) { return NextResponse.json({ success: true, updated: assetIds.length }); } - diff --git a/wolai-frontend/src/app/api/media/ocr/route.ts b/wolai-frontend/src/app/api/media/ocr/route.ts index e9660f59..d8b44b43 100644 --- a/wolai-frontend/src/app/api/media/ocr/route.ts +++ b/wolai-frontend/src/app/api/media/ocr/route.ts @@ -1,8 +1,14 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; +import { isConvexEnabled } from "@/lib/convex/enabled"; export async function POST(request: Request) { + if (isConvexEnabled()) { + // 说明:OCR 链路目前仍依赖 Supabase JWT/表结构,迁移阶段先显式禁用,避免 UI/接口误用。 + return NextResponse.json({ error: "Convex 模式暂不支持 OCR" }, { status: 501 }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/media/purge/route.ts b/wolai-frontend/src/app/api/media/purge/route.ts index 3a9b9b8b..8eff747f 100644 --- a/wolai-frontend/src/app/api/media/purge/route.ts +++ b/wolai-frontend/src/app/api/media/purge/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; +import { api } from "@/lib/convex/api"; +import { getConvexHttpClient } from "@/lib/convex/server"; export const dynamic = "force-dynamic"; @@ -25,6 +29,32 @@ function makeExpiredDeletedAt(): string { } export async function POST(request: Request) { + if (isConvexEnabled()) { + let auth; + try { + auth = requireAuthContext(); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: "未登录" }, { status: err.status }); + } + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { assetId }: PurgePayload = await request.json().catch(() => ({})); + if (!assetId) { + return NextResponse.json({ error: "缺少 assetId" }, { status: 400 }); + } + + const client = getConvexHttpClient(); + const res = await client.mutation(api.mediaAssets.purgeById, { + userId: auth.userId, + id: assetId, + expiredDeletedAt: makeExpiredDeletedAt(), + }); + + return NextResponse.json({ success: true, ...(res as any) }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -86,4 +116,3 @@ export async function POST(request: Request) { return NextResponse.json({ success: true }); } - diff --git a/wolai-frontend/src/app/api/media/sign/route.ts b/wolai-frontend/src/app/api/media/sign/route.ts index 9fc9dcec..f7a900d4 100644 --- a/wolai-frontend/src/app/api/media/sign/route.ts +++ b/wolai-frontend/src/app/api/media/sign/route.ts @@ -3,6 +3,10 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import supabaseAdmin from "@/lib/supabase/admin"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; +import { api } from "@/lib/convex/api"; +import { getConvexHttpClient } from "@/lib/convex/server"; export const dynamic = "force-dynamic"; @@ -61,6 +65,47 @@ const resolveAssetObjectLocation = async (params: { }; export async function GET(request: Request) { + if (isConvexEnabled()) { + let auth; + try { + auth = requireAuthContext(); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: "未登录" }, { status: err.status }); + } + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const assetId = searchParams.get("assetId"); + + if (!assetId) { + return NextResponse.json({ error: "缺少 assetId" }, { status: 400 }); + } + + const client = getConvexHttpClient(); + const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId }); + if (!asset) { + return NextResponse.json({ error: "资源不存在" }, { status: 404 }); + } + + const refreshed = await client.mutation(api.mediaAssets.refreshUrl, { userId: auth.userId, id: assetId }); + const signedUrl = (refreshed as { signedUrl?: string | null } | null)?.signedUrl ?? null; + if (!signedUrl) { + return NextResponse.json({ error: "生成签名链接失败" }, { status: 500 }); + } + + return NextResponse.json({ + signedUrl, + asset: { + id: asset.id, + file_name: asset.file_name, + mime_type: asset.mime_type, + file_size: asset.file_size, + }, + }); + } + const supabase = await createSupabaseRouteClient(); const { data: { user }, 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 8f8bc8cd..a49ef3c9 100644 --- a/wolai-frontend/src/app/api/media/signed-url/route.ts +++ b/wolai-frontend/src/app/api/media/signed-url/route.ts @@ -3,6 +3,8 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import supabaseAdmin from "@/lib/supabase/admin"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; export const dynamic = "force-dynamic"; @@ -92,6 +94,26 @@ const tryResolveExistingObjectPath = async (params: { }; export async function GET(request: Request) { + if (isConvexEnabled()) { + // 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。 + // 由于外链本身已经是可访问的 URL,这里只做最小透传。 + try { + requireAuthContext(); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: "未登录" }, { status: err.status }); + } + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const fileUrl = searchParams.get("fileUrl"); + if (!fileUrl) { + return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 }); + } + return NextResponse.json({ signedUrl: fileUrl }); + } + // 说明:在 Cloudflare Tunnel 场景下,后端收到的 Host 可能是 localhost, // 但 ONLYOFFICE 文档服务器拉取 document.url 时必须使用公网可达的域名。 // 因此这里优先使用运行时配置(public/mnote-env.json / env)里的公网 Origin, diff --git a/wolai-frontend/src/app/api/media/upload/route.ts b/wolai-frontend/src/app/api/media/upload/route.ts index a11e6088..a32f4a1e 100644 --- a/wolai-frontend/src/app/api/media/upload/route.ts +++ b/wolai-frontend/src/app/api/media/upload/route.ts @@ -4,6 +4,10 @@ import type { MediaAsset } from "@/types/media"; import { extname } from "path"; import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; +import { api } from "@/lib/convex/api"; +import { getConvexHttpClient } from "@/lib/convex/server"; export const dynamic = "force-dynamic"; @@ -17,6 +21,86 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => }; export async function POST(request: Request) { + if (isConvexEnabled()) { + let auth; + try { + auth = requireAuthContext(); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: "未登录" }, { status: err.status }); + } + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const formData = await request.formData(); + 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 }); + } + + try { + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const assetId = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(16).slice(2)}`; + const assetType = resolveAssetType(file.type || ""); + const client = getConvexHttpClient(); + + // 1) 获取 Convex 的上传 URL(短时有效) + const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId }); + if (!uploadUrl || typeof uploadUrl !== "string") { + return NextResponse.json({ error: "获取上传地址失败" }, { status: 500 }); + } + + // 2) 上传文件到 Convex Files + const uploadRes = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type || "application/octet-stream" }, + body: buffer, + }); + if (!uploadRes.ok) { + const text = await uploadRes.text().catch(() => ""); + return NextResponse.json({ error: `上传到 Convex 失败:${uploadRes.status} ${text}` }, { status: 500 }); + } + const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null; + const storageId = uploadJson?.storageId ?? ""; + if (!storageId) { + return NextResponse.json({ error: "上传到 Convex 失败:缺少 storageId" }, { status: 500 }); + } + + // 3) 写入 Convex 的 media_assets 元数据(并记录 storageId) + const created = await client.mutation(api.mediaAssets.createWithStorage, { + userId: auth.userId, + storageId: storageId as any, + asset: { + id: assetId, + workspace_id: workspaceId, + document_id: documentId, + asset_type: assetType, + file_name: file.name || null, + file_size: file.size, + mime_type: file.type || null, + }, + }); + + const asset = created as unknown as MediaAsset; + + return NextResponse.json({ + asset, + mindmapUrl: `asset:${asset.id}`, + }); + } catch (error) { + console.error(error); + return NextResponse.json({ error: "上传失败" }, { status: 500 }); + } + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts b/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts index 3e28450a..2d4fcb9c 100644 --- a/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts +++ b/wolai-frontend/src/app/api/mindmap-trash/empty/route.ts @@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { promises as fs } from "fs"; import path from "path"; import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; export const dynamic = "force-dynamic"; @@ -29,6 +32,24 @@ async function purgeTrashFolder(folder: string): Promise { } export async function POST(request: Request) { + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({})); + if (!workspaceId) { + return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); + } + + try { + const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { + userId: auth.userId, + workspaceId, + }); + return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/ops/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/ops/route.ts index 2431d134..dac31988 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/ops/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/ops/route.ts @@ -2,6 +2,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps"; import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; const defaultMindmapData = { data: { text: "中心主题" }, @@ -19,6 +22,53 @@ export async function POST( { params }: { params: Promise<{ docId: string; mindmapId: string }> }, ) { const { docId, mindmapId } = await params; + + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + + const payload = (await request.json().catch(() => null)) as RequestPayload | null; + const ops = Array.isArray(payload?.ops) ? payload!.ops : []; + if (!ops.length) { + return NextResponse.json({ error: "缺少 ops" }, { status: 400 }); + } + if (ops.length > 80) { + return NextResponse.json({ error: "ops 过多(最多 80)" }, { status: 400 }); + } + + try { + const current = await client.query(api.mindmaps.get, { + userId: auth.userId, + docId, + mindmapId, + }); + + const baseData = current?.data ?? defaultMindmapData; + const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops); + + await client.mutation(api.mindmaps.put, { + userId: auth.userId, + docId, + mindmapId, + data: nextData, + }); + + return NextResponse.json({ + ok: true, + applied, + errors, + data: nextData, + meta: { + documentId: docId, + mindmapId, + actor: payload?.actor ?? null, + reason: payload?.reason ?? null, + }, + }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -70,4 +120,3 @@ export async function POST( }, }); } - diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts index 8419568c..b12d08a2 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts @@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { promises as fs } from "fs"; import path from "path"; import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; const defaultMindmapData = { data: { text: "中心主题" }, @@ -112,6 +115,17 @@ export async function GET( { params }: { params: Promise<{ docId: string; mindmapId: string }> }, ) { const { docId, mindmapId } = await params; + + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const res = await client.query(api.mindmaps.get, { + userId: auth.userId, + docId, + mindmapId, + }); + return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -155,6 +169,28 @@ export async function POST( { params }: { params: Promise<{ docId: string; mindmapId: string }> }, ) { const { docId, mindmapId } = await params; + + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as { + data?: unknown; + createOnly?: boolean; + }; + + try { + const result = await client.mutation(api.mindmaps.put, { + userId: auth.userId, + docId, + mindmapId, + data: data ?? defaultMindmapData, + ...(typeof createOnly === "boolean" ? { createOnly } : {}), + }); + return NextResponse.json({ ok: true, ...(result ?? {}) }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -203,6 +239,21 @@ export async function DELETE( { params }: { params: Promise<{ docId: string; mindmapId: string }> }, ) { const { docId, mindmapId } = await params; + + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + try { + const result = await client.mutation(api.mindmaps.softDelete, { + userId: auth.userId, + docId, + mindmapId, + }); + return NextResponse.json({ ok: true, ...(result ?? {}) }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -263,6 +314,37 @@ export async function PATCH( { params }: { params: Promise<{ docId: string; mindmapId: string }> }, ) { const { docId, mindmapId } = await params; + + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const { action } = (await request.json().catch(() => ({}))) as { action?: string }; + if (action !== "restore" && action !== "purge") { + return NextResponse.json({ error: "不支持的操作" }, { status: 400 }); + } + + try { + if (action === "purge") { + const result = await client.mutation(api.mindmaps.purge, { + userId: auth.userId, + docId, + mindmapId, + }); + return NextResponse.json({ ok: true, ...(result ?? {}) }); + } + + const result = await client.mutation(api.mindmaps.restore, { + userId: auth.userId, + docId, + mindmapId, + }); + return NextResponse.json({ ok: true, ...(result ?? {}) }); + } catch (error) { + const msg = (error as Error).message ?? "操作失败"; + const status = msg.includes("未找到") ? 404 : 400; + return NextResponse.json({ error: msg }, { status }); + } + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/route.ts index c19e4e24..ded75cb4 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/route.ts @@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { promises as fs } from "fs"; import path from "path"; import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; const defaultMindmapData = { data: { text: "中心主题" }, @@ -40,6 +43,18 @@ export async function GET( { params }: { params: Promise<{ docId: string }> }, ) { const { docId: id } = await params; + + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const mindmapId = `legacy-${id}`; + const res = await client.query(api.mindmaps.get, { + userId: auth.userId, + docId: id, + mindmapId, + }); + return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -89,6 +104,20 @@ export async function POST( { params }: { params: Promise<{ docId: string }> }, ) { const { docId: id } = await params; + + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const mindmapId = `legacy-${id}`; + const { data } = await request.json(); + const result = await client.mutation(api.mindmaps.put, { + userId: auth.userId, + docId: id, + mindmapId, + data: data ?? defaultMindmapData, + }); + return NextResponse.json({ ok: true, ...(result ?? {}) }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -130,6 +159,18 @@ export async function DELETE( { params }: { params: Promise<{ docId: string }> }, ) { const { docId: id } = await params; + + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const mindmapId = `legacy-${id}`; + const result = await client.mutation(api.mindmaps.softDelete, { + userId: auth.userId, + docId: id, + mindmapId, + }); + return NextResponse.json({ ok: true, ...(result ?? {}) }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/onlyoffice/callback/route.ts b/wolai-frontend/src/app/api/onlyoffice/callback/route.ts index 6153eb8a..651e04c2 100644 --- a/wolai-frontend/src/app/api/onlyoffice/callback/route.ts +++ b/wolai-frontend/src/app/api/onlyoffice/callback/route.ts @@ -1,9 +1,13 @@ import { NextResponse } from "next/server"; import supabaseAdmin from "@/lib/supabase/admin"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; export const dynamic = "force-dynamic"; const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, ""); +const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim(); type OnlyOfficeCallbackBody = { status?: number; @@ -11,6 +15,19 @@ type OnlyOfficeCallbackBody = { key?: string; }; +const normalizeSecret = (raw: string) => { + const trimmed = String(raw || "").trim(); + if (!trimmed) return ""; + // 兼容用户把 .env 的值写成 "xxx" / 'xxx' + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) || + (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +}; + const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => { try { const u = new URL(raw); @@ -33,6 +50,16 @@ export async function POST(request: Request) { const { searchParams } = new URL(request.url); const assetId = searchParams.get("assetId") || ""; + // 说明:ONLYOFFICE 回调由文档服务器触发,不一定携带用户态;这里提供一个可选的共享密钥校验。 + // 若未配置 ONLYOFFICE_CALLBACK_SECRET,则保持兼容不校验。 + if (ONLYOFFICE_CALLBACK_SECRET) { + const got = normalizeSecret(searchParams.get("token") || ""); + const expected = normalizeSecret(ONLYOFFICE_CALLBACK_SECRET); + if (!got || got !== expected) { + return NextResponse.json({ error: 1 }); + } + } + const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null; if (!body) { return NextResponse.json({ error: 0 }); @@ -53,6 +80,54 @@ export async function POST(request: Request) { return NextResponse.json({ error: 1 }); } + if (isConvexEnabled()) { + // 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。 + const userId = String(process.env.DEV_USER_ID || "dev-user").trim() || "dev-user"; + const client = getConvexHttpClient(); + + const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId }); + if (!asset) { + return NextResponse.json({ error: 1 }); + } + + const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url); + const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" }); + if (!upstream.ok) { + return NextResponse.json({ error: 1 }); + } + + const buf = Buffer.from(await upstream.arrayBuffer()); + + const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId }); + if (!uploadUrl || typeof uploadUrl !== "string") { + return NextResponse.json({ error: 1 }); + } + + const uploadRes = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": asset.mime_type || "application/octet-stream" }, + body: buf, + }); + + if (!uploadRes.ok) { + return NextResponse.json({ error: 1 }); + } + + const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null; + const storageId = String(uploadJson?.storageId || ""); + if (!storageId) { + return NextResponse.json({ error: 1 }); + } + + await client.mutation(api.mediaAssets.replaceStorageFromUpload, { + userId, + id: assetId, + storageId: storageId as any, + }); + + return NextResponse.json({ error: 0 }); + } + const { data: asset, error: assetError } = await supabaseAdmin .from("media_assets") .select("id,bucket,storage_path,mime_type") diff --git a/wolai-frontend/src/app/api/onlyoffice/proxy/route.ts b/wolai-frontend/src/app/api/onlyoffice/proxy/route.ts index b6fc26bd..847e37d6 100644 --- a/wolai-frontend/src/app/api/onlyoffice/proxy/route.ts +++ b/wolai-frontend/src/app/api/onlyoffice/proxy/route.ts @@ -96,6 +96,7 @@ const handle = async (request: Request, method: "GET" | "HEAD") => { runtimeCfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL, ); const storageOverride = tryParseOriginHost(runtimeCfg.onlyofficeStorageHostOverride); + const convexOrigin = tryParseOriginUrl(process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL); const isSupabasePath = target.pathname.startsWith("/storage/v1/") || @@ -155,7 +156,17 @@ const handle = async (request: Request, method: "GET" | "HEAD") => { addAllowed(storageOverride.hostname, storageOverride.port); } - if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname)) { + if (convexOrigin?.hostname) { + addAllowed(convexOrigin.hostname, convexOrigin.port || ""); + if (isLocalHostname(convexOrigin.hostname)) { + addAllowed("127.0.0.1", convexOrigin.port || ""); + addAllowed("localhost", convexOrigin.port || ""); + addAllowed("host.docker.internal", convexOrigin.port || ""); + } + } + + // 说明:默认禁止代理到内网/私有地址;但如果该 hostname 被显式配置为允许(例如 Docker bridge/host 回源),则放行。 + if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname) && !allowedHostnames.has(target.hostname)) { return NextResponse.json({ error: "禁止访问内网/私有地址" }, { status: 403 }); } diff --git a/wolai-frontend/src/app/api/references/backlinks/route.ts b/wolai-frontend/src/app/api/references/backlinks/route.ts index 2686f3de..a6636ecf 100644 --- a/wolai-frontend/src/app/api/references/backlinks/route.ts +++ b/wolai-frontend/src/app/api/references/backlinks/route.ts @@ -1,7 +1,33 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; export async function GET(request: Request) { + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const { searchParams } = new URL(request.url); + const workspaceId = searchParams.get("workspaceId"); + const pageId = searchParams.get("pageId"); + if (!workspaceId || !pageId) { + return NextResponse.json({ error: "缺少 workspaceId 或 pageId" }, { status: 400 }); + } + + const limit = Number(searchParams.get("limit") ?? "50"); + const offset = Number(searchParams.get("offset") ?? "0"); + + const backlinks = await client.query(api.references.listBacklinks, { + userId: auth.userId, + workspaceId, + pageId, + limit: Number.isFinite(limit) ? limit : 50, + offset: Number.isFinite(offset) ? offset : 0, + }); + + return NextResponse.json({ backlinks }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/references/record/route.ts b/wolai-frontend/src/app/api/references/record/route.ts index 48a0ce95..46c121c3 100644 --- a/wolai-frontend/src/app/api/references/record/route.ts +++ b/wolai-frontend/src/app/api/references/record/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; type DisplayMode = "inline" | "embed"; @@ -16,6 +19,30 @@ interface RecordReferencePayload { const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inline" || mode === "embed"; export async function POST(request: Request) { + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const body = (await request.json()) as RecordReferencePayload; + if (!body?.workspaceId || !body?.sourcePageId || !body?.targetPageId) { + return NextResponse.json({ error: "缺少必要参数" }, { status: 400 }); + } + if (!isValidDisplayMode(String(body.displayMode ?? ""))) { + return NextResponse.json({ error: "非法的引用模式" }, { status: 400 }); + } + + const reference = await client.mutation(api.references.record, { + userId: auth.userId, + workspaceId: body.workspaceId, + sourcePageId: body.sourcePageId, + targetPageId: body.targetPageId, + sourceBlockId: body.sourceBlockId ?? null, + alias: body.alias ?? null, + displayMode: body.displayMode, + isPreviewable: Boolean(body.isPreviewable ?? true), + }); + + return NextResponse.json({ reference }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/search/documents/route.ts b/wolai-frontend/src/app/api/search/documents/route.ts index ce7abed5..cb81f655 100644 --- a/wolai-frontend/src/app/api/search/documents/route.ts +++ b/wolai-frontend/src/app/api/search/documents/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; import type { DocumentSearchFilters, DocumentSearchRequest, @@ -144,6 +147,89 @@ const fetchOcrMatches = async ( }; export async function POST(request: Request) { + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const payload = (await request.json()) as DocumentSearchRequest; + const workspaceId = payload.workspaceId; + + if (!workspaceId) { + return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); + } + + const filters: DocumentSearchFilters = { + ...DEFAULT_FILTERS, + ...payload.filters, + }; + + const limit = Math.min(payload.limit ?? 30, MAX_LIMIT); + const normalizedQuery = payload.query?.trim() ?? ""; + const normalizedLower = normalizedQuery.toLowerCase(); + + const docs = await client.query(api.documents.listByWorkspace, { + userId: auth.userId, + workspaceId, + }); + + const sortedByRecent = [...docs].sort((a, b) => { + const ta = a.updated_at ?? a.created_at ?? ""; + const tb = b.updated_at ?? b.created_at ?? ""; + return tb.localeCompare(ta); + }); + + const recentRows = await client.query(api.recents.listByWorkspace, { + userId: auth.userId, + workspaceId, + limit: 10, + }); + const docMap = new Map(docs.map((d) => [d.id, d])); + const recent: DocumentSearchResult[] = recentRows + .map((r) => docMap.get(r.document_id)) + .filter((row): row is (typeof docs)[number] => Boolean(row)) + .map((row) => ({ + id: row.id, + title: row.title ?? "无标题", + snippet: "", + updatedAt: row.updated_at ?? null, + createdAt: row.created_at ?? null, + matchField: "recent", + hasOcr: false, + publicPath: `/documents/${row.id}`, + score: 0, + })); + + if (!normalizedQuery) { + const response: DocumentSearchResponse = { results: [], recent }; + return NextResponse.json(response); + } + + const narrowed = sortedByRecent.filter((row) => { + if (filters.onlyCurrentPage && payload.documentId) { + if (row.id !== payload.documentId) return false; + } + const title = (row.title ?? "无标题").toLowerCase(); + return title.includes(normalizedLower); + }); + + const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({ + id: row.id, + title: row.title ?? "无标题", + snippet: buildSnippet(row.title ?? "", normalizedQuery), + updatedAt: row.updated_at ?? null, + createdAt: row.created_at ?? null, + matchField: "title", + hasOcr: false, + publicPath: `/documents/${row.id}`, + score: 2, + })); + + const response: DocumentSearchResponse = { + results, + recent, + }; + + return NextResponse.json(response); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/search/recent/route.ts b/wolai-frontend/src/app/api/search/recent/route.ts index 71d4a10e..2d784cee 100644 --- a/wolai-frontend/src/app/api/search/recent/route.ts +++ b/wolai-frontend/src/app/api/search/recent/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; interface RecentPayload { workspaceId: string; @@ -7,6 +10,24 @@ interface RecentPayload { } export async function POST(request: Request) { + if (isConvexEnabled()) { + const { auth, client } = getAuthedConvexClient(); + const { workspaceId, documentId }: RecentPayload = await request.json(); + + if (!workspaceId || !documentId) { + return NextResponse.json({ error: "缺少参数" }, { status: 400 }); + } + + await client.mutation(api.recents.upsert, { + userId: auth.userId, + workspaceId, + documentId, + lastAccessedAt: new Date().toISOString(), + }); + + return NextResponse.json({ ok: true }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/sidebar/route.ts b/wolai-frontend/src/app/api/sidebar/route.ts index 857e9091..89b66684 100644 --- a/wolai-frontend/src/app/api/sidebar/route.ts +++ b/wolai-frontend/src/app/api/sidebar/route.ts @@ -3,6 +3,11 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces"; import { fetchSidebarDataset } from "@/lib/sidebar-tree"; import type { SidebarInitialData } from "@/components/sidebar/types"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { randomUUID } from "crypto"; import { detectLocalMindmapFiles, detectLocalMindmapDocs, @@ -13,7 +18,188 @@ import type { MediaAsset } from "@/types/media"; export const dynamic = "force-dynamic"; +function extractMindmapImageAssetIdsFromData(input: unknown): string[] { + const root = (() => { + if (!input || typeof input !== "object") return input; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const record = input as any; + // 兼容:某些导图结构为 { root: ... } + return record && typeof record === "object" && "root" in record ? record.root : input; + })(); + + const ids: string[] = []; + const seen = new Set(); + + 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)[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; +} + export async function GET(request: Request) { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + + const url = new URL(request.url); + const workspaceIdParam = url.searchParams.get("workspaceId"); + + const bootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, { + userId: auth.userId, + fallbackName: auth.email ?? auth.name ?? "我的空间", + workspaceIdIfCreate: randomUUID(), + }); + + const summaries = await client.query(api.workspaces.fetchWorkspaceSummaries, { + userId: auth.userId, + }); + + const workspaces = summaries.workspaces.length > 0 ? summaries.workspaces : bootstrap.workspaces; + const activeWorkspaceId = summaries.activeWorkspaceId || bootstrap.activeWorkspaceId; + const targetWorkspaceId = workspaceIdParam || activeWorkspaceId; + + if (!targetWorkspaceId) { + return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 }); + } + + try { + const documents = await client.query(api.documents.listByWorkspace, { + userId: auth.userId, + workspaceId: targetWorkspaceId, + }); + + const trashedDocuments = await client.query(api.documents.listTrashedByWorkspace, { + userId: auth.userId, + workspaceId: targetWorkspaceId, + }); + + const mindmapRows = await client.query(api.mindmaps.listByWorkspace, { + userId: auth.userId, + workspaceId: targetWorkspaceId, + includeDeleted: true, + }); + + const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at); + const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at); + + const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id))); + + const mindmapAssetChildren: Record = {}; + activeMindmaps.forEach((r) => { + const ids = extractMindmapImageAssetIdsFromData(r.data); + if (ids.length > 0) { + mindmapAssetChildren[r.mindmap_id] = ids; + } + }); + + const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => { + const isLegacy = r.mindmap_id.startsWith("legacy-"); + return { + id: r.mindmap_id, + workspace_id: r.workspace_id ?? targetWorkspaceId, + document_id: r.document_id, + asset_type: "mindmap", + file_url: null, + thumbnail_url: null, + bucket: null, + storage_path: null, + file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`, + file_size: null, + mime_type: "application/json", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + signed_url: null, + created_at: r.created_at ?? "", + updated_at: r.updated_at ?? "", + }; + }); + + const trashedMindmapAssets: MediaAsset[] = trashedMindmaps.map((r) => { + const isLegacy = r.mindmap_id.startsWith("legacy-"); + return { + id: r.mindmap_id, + workspace_id: r.workspace_id ?? targetWorkspaceId, + document_id: r.document_id, + asset_type: "mindmap", + file_url: null, + thumbnail_url: null, + bucket: null, + storage_path: null, + file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`, + file_size: null, + mime_type: "application/json", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + deleted_at: r.deleted_at ?? null, + deleted_by: r.deleted_by ?? null, + purged_at: null, + signed_url: null, + created_at: r.created_at ?? "", + updated_at: r.updated_at ?? "", + }; + }); + + const payload: SidebarInitialData = { + activeWorkspaceId: targetWorkspaceId, + workspaces, + documents, + trashedDocuments, + trashedMediaAssets: [], + trashedMindmapAssets, + mindmapDocs, + mindmapAssets, + mindmapAssetChildren, + tableAssets: [], + mediaAssets: [], + }; + + return NextResponse.json(payload); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "拉取侧边栏数据失败" }, + { status: 500 }, + ); + } + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/workspaces/switch/route.ts b/wolai-frontend/src/app/api/workspaces/switch/route.ts index d921118b..4b79fd7d 100644 --- a/wolai-frontend/src/app/api/workspaces/switch/route.ts +++ b/wolai-frontend/src/app/api/workspaces/switch/route.ts @@ -1,9 +1,30 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; export const dynamic = "force-dynamic"; export async function POST(request: Request) { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const payload = await request.json().catch(() => ({})); + const workspaceId = payload.workspaceId as string | undefined; + if (!workspaceId) { + return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); + } + + const client = getConvexHttpClient(); + await client.mutation(api.workspaces.switchDefaultWorkspace, { + userId: auth.userId, + workspaceId, + }); + + return NextResponse.json({ success: true }); + } + const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/cache/[...path]/route.ts b/wolai-frontend/src/app/cache/[...path]/route.ts index 47d3af1f..bdf7e707 100644 --- a/wolai-frontend/src/app/cache/[...path]/route.ts +++ b/wolai-frontend/src/app/cache/[...path]/route.ts @@ -29,6 +29,19 @@ const stripHopByHopHeaders = (headers: Headers) => { } }; +const pickCacheControl = (pathParts: string[], contentType: string, method: string) => { + const m = String(method || "").toUpperCase(); + if (m !== "GET" && m !== "HEAD") return "no-store"; + if (contentType.includes("text/html")) return "no-store"; + + const p = `/${(pathParts ?? []).join("/")}`.toLowerCase(); + // 说明:/cache 主要是 ONLYOFFICE 运行期二进制缓存,适合短缓存提升性能,但不宜过长。 + if (p.includes("editor.bin") || p.endsWith(".bin")) { + return "public, max-age=3600, stale-while-revalidate=600"; + } + return "public, max-age=300, stale-while-revalidate=300"; +}; + const proxyCache = async (request: NextRequest, pathParts: string[]) => { const incomingUrl = new URL(request.url); const target = new URL( @@ -60,6 +73,10 @@ const proxyCache = async (request: NextRequest, pathParts: string[]) => { stripHopByHopHeaders(outHeaders); outHeaders.delete("content-encoding"); outHeaders.delete("content-length"); + outHeaders.set("cache-control", pickCacheControl(pathParts, upstream.headers.get("content-type") || "", request.method)); + outHeaders.delete("pragma"); + outHeaders.delete("expires"); + outHeaders.delete("set-cookie"); return new NextResponse(upstream.body, { status: upstream.status, @@ -83,4 +100,3 @@ export async function OPTIONS(request: NextRequest, ctx: RouteCtx) { const { path } = await ctx.params; return proxyCache(request, path ?? []); } - diff --git a/wolai-frontend/src/app/layout.tsx b/wolai-frontend/src/app/layout.tsx index eddc9bd1..f98cb430 100644 --- a/wolai-frontend/src/app/layout.tsx +++ b/wolai-frontend/src/app/layout.tsx @@ -5,6 +5,7 @@ import { SupabaseProvider } from "@/components/providers/supabase-provider"; import { QueryProvider } from "@/components/providers/query-provider"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; +import { isConvexEnabled } from "@/lib/convex/enabled"; const inter = Inter({ subsets: ["latin"], @@ -22,16 +23,19 @@ export default async function RootLayout({ children: React.ReactNode; }>) { const isDesktop = process.env.MNOTE_DESKTOP === "1"; + const useConvex = isConvexEnabled(); let session = null; - try { - session = (await (await createSupabaseServerClient()).auth.getSession()).data.session; - } catch { - // 说明:不阻塞页面渲染,交由客户端登录页处理(例如网络暂不可达)。 - session = null; + if (!useConvex) { + try { + session = (await (await createSupabaseServerClient()).auth.getSession()).data.session; + } catch { + // 说明:不阻塞页面渲染,交由客户端登录页处理(例如网络暂不可达)。 + session = null; + } } - const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop }; + const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop, useConvex }; const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/ - + {useConvex ? ( {children} - + ) : ( + + {children} + + )} ); diff --git a/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts b/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts index b3b80f00..2edd65a9 100644 --- a/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts +++ b/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts @@ -32,6 +32,39 @@ window.__MNOTE_DISABLE_ONLYOFFICE_SW__ = true; `.trim(); +const XHR_REWRITE_SNIPPET = ` + +`.trim(); + const stripHopByHopHeaders = (headers: Headers) => { // 说明:Hop-by-hop headers 不应被代理转发/透传 const hopByHop = [ @@ -51,8 +84,63 @@ const stripHopByHopHeaders = (headers: Headers) => { const injectDisableServiceWorker = (html: string) => { // 说明:只注入一次,避免重复拼接 - if (html.includes("window.__MNOTE_DISABLE_ONLYOFFICE_SW__")) return html; - return html.replace(/]*>/i, (m) => `${m}\n${DISABLE_SERVICE_WORKER_SNIPPET}\n`); + const hasSw = html.includes("window.__MNOTE_DISABLE_ONLYOFFICE_SW__"); + const hasXhr = html.includes("window.__MNOTE_ONLYOFFICE_XHR_REWRITE__"); + if (hasSw && hasXhr) return html; + const injected = [ + hasSw ? "" : DISABLE_SERVICE_WORKER_SNIPPET, + hasXhr ? "" : XHR_REWRITE_SNIPPET, + ] + .filter(Boolean) + .join("\n"); + return html.replace(/]*>/i, (m) => `${m}\n${injected}\n`); +}; + +const pickCacheControl = (pathParts: string[], contentType: string, method: string) => { + const m = String(method || "").toUpperCase(); + if (m !== "GET" && m !== "HEAD") return "no-store"; + if (contentType.includes("text/html")) return "no-store"; + + const p = `/${(pathParts ?? []).join("/")}`; + const lower = p.toLowerCase(); + + // 说明:这些路径通常是运行期接口/动态响应,不应缓存。 + if ( + lower.includes("/docservice/") || + lower.includes("/coauthoring/") || + lower.includes("/converter/") || + lower.includes("/healthcheck") || + lower.includes("/metrics") + ) { + return "no-store"; + } + + const isStaticByPath = + lower.includes("/web-apps/") || + lower.includes("/sdkjs/") || + lower.endsWith(".js") || + lower.endsWith(".css") || + lower.endsWith(".map") || + lower.endsWith(".png") || + lower.endsWith(".jpg") || + lower.endsWith(".jpeg") || + lower.endsWith(".gif") || + lower.endsWith(".svg") || + lower.endsWith(".ico") || + lower.endsWith(".woff") || + lower.endsWith(".woff2") || + lower.endsWith(".ttf") || + lower.endsWith(".otf") || + lower.endsWith(".json") || + lower.endsWith(".wasm") || + lower.endsWith(".bin"); + + // 说明:ONLYOFFICE 静态资源体积大,且文件名通常稳定;这里尽量给浏览器缓存,提升二次打开速度。 + if (isStaticByPath) { + return "public, max-age=604800, stale-while-revalidate=86400"; + } + + return "public, max-age=300, stale-while-revalidate=300"; }; const proxy = async (request: NextRequest, pathParts: string[]) => { @@ -63,6 +151,17 @@ const proxy = async (request: NextRequest, pathParts: string[]) => { const headers = new Headers(request.headers); // 说明:避免把外部 Host 传给上游 headers.delete("host"); + // 说明:ONLYOFFICE 在被反向代理时,会根据 X-Forwarded-* 推导自身对外地址, + // 用于生成静态资源/缓存文件的 URL。若缺失这些信息,可能会返回指向内部端口 + //(例如 http://127.0.0.1:8081/cache/...)的绝对 URL,导致浏览器跨域请求被 CORS 拦截。 + headers.set("x-forwarded-host", incomingUrl.host); + headers.set("x-forwarded-proto", incomingUrl.protocol.replace(":", "")); + if (incomingUrl.port) { + headers.set("x-forwarded-port", incomingUrl.port); + } else { + headers.set("x-forwarded-port", incomingUrl.protocol === "https:" ? "443" : "80"); + } + headers.set("x-forwarded-prefix", "/onlyoffice-server"); // 说明:避免上游返回 gzip 后被 Node fetch 自动解压,但仍带着 content-encoding, // 导致浏览器二次解压报 ERR_CONTENT_DECODING_FAILED。 headers.set("accept-encoding", "identity"); @@ -89,6 +188,10 @@ const proxy = async (request: NextRequest, pathParts: string[]) => { outHeaders.delete("content-length"); const contentType = upstream.headers.get("content-type") || ""; + outHeaders.set("cache-control", pickCacheControl(pathParts, contentType, request.method)); + outHeaders.delete("pragma"); + outHeaders.delete("expires"); + outHeaders.delete("set-cookie"); if (contentType.includes("text/html")) { const html = await upstream.text(); const injected = injectDisableServiceWorker(html); diff --git a/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx b/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx index b9dbd871..a8225577 100644 --- a/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx +++ b/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx @@ -15,6 +15,7 @@ declare global { __MNOTE_ONLYOFFICE_ERRLOG__?: Array>; __MNOTE_ONLYOFFICE_ERR_HOOKED__?: boolean; __MNOTE_ONLYOFFICE_DOMPATCHED__?: boolean; + __MNOTE_ONLYOFFICE_DEBUG__?: Record; } } @@ -132,6 +133,97 @@ const base64UrlEncodeUtf8 = (input: string) => { return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); }; +const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => { + // 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向 + // `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。 + // 这里在 ONLYOFFICE 页面内对 XHR 做一次 URL 重写:把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。 + // 注意:ONLYOFFICE 会创建多个 iframe(同源但不同 realm),因此这里也会周期性给新出现的 iframe 打补丁。 + if (typeof window === "undefined") return; + if (!baseUrl) return; + + const normalizedBase = String(baseUrl || "").trim().replace(/\/+$/, ""); + const isProxyMode = normalizedBase === "/onlyoffice-server" || normalizedBase.endsWith("/onlyoffice-server"); + if (!isProxyMode) return; + + const proxyPrefix = (() => { + if (/^https?:\/\//i.test(normalizedBase)) return normalizedBase; + return `${window.location.origin.replace(/\/+$/, "")}${normalizedBase}`; + })(); + + const internalOrigins = new Set(["http://127.0.0.1:8081", "http://localhost:8081"]); + try { + if (onlyofficeBaseUrlDesktop) { + const u = new URL(onlyofficeBaseUrlDesktop); + internalOrigins.add(`${u.protocol}//${u.host}`); + } + } catch { + // ignore + } + + const patchWindow = (win: Window) => { + try { + if ((win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__) return; + const rewriteUrl = (input: string) => { + try { + const u = new (win as any).URL(input, (win as any).location?.origin || window.location.origin); + const origin = `${u.protocol}//${u.host}`; + if (!internalOrigins.has(origin)) return input; + return `${proxyPrefix}${u.pathname}${u.search}${u.hash}`; + } catch { + return input; + } + }; + const origOpen = (win as any).XMLHttpRequest?.prototype?.open; + if (typeof origOpen !== "function") return; + // eslint-disable-next-line no-extend-native + (win as any).XMLHttpRequest.prototype.open = function openPatched( + method: string, + url: string, + async?: boolean, + user?: string | null, + password?: string | null, + ) { + const nextUrl = typeof url === "string" ? rewriteUrl(url) : url; + // eslint-disable-next-line prefer-rest-params + return origOpen.call(this, method, nextUrl, async, user as any, password as any); + }; + (win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true; + } catch { + // ignore + } + }; + + patchWindow(window); + + try { + const start = Date.now(); + const timer = window.setInterval(() => { + try { + const frames = Array.from(document.querySelectorAll("iframe")); + for (const f of frames) { + try { + const w = (f as HTMLIFrameElement).contentWindow; + if (!w) continue; + // 说明:同源时才能访问 location;跨域会抛异常,直接跳过。 + // eslint-disable-next-line no-unused-expressions + w.location?.origin; + patchWindow(w); + } catch { + // ignore + } + } + } catch { + // ignore + } + if (Date.now() - start > 120_000) { + window.clearInterval(timer); + } + }, 1000); + } catch { + // ignore + } +}; + const docTypeFromExt = (ext: string) => { const word = ["doc", "docx", "odt", "rtf"]; const slide = ["ppt", "pptx", "odp"]; @@ -167,9 +259,53 @@ export default function OnlyOfficePage() { const fileType = (params.get("fileType") ?? "docx").toLowerCase(); const mode = (params.get("mode") ?? "edit") as EditorMode; const assetId = params.get("assetId") ?? ""; + const channel = (params.get("channel") ?? "").trim().toLowerCase(); const [error, setError] = useState(null); const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []); - const baseUrl = runtimeConfig.onlyofficeBaseUrlWeb || runtimeConfig.onlyofficeBaseUrl; + const baseUrlCandidates = useMemo(() => { + const uniq: string[] = []; + const push = (v?: string | null) => { + const s = String(v || "").trim().replace(/\/+$/, ""); + if (!s) return; + if (!uniq.includes(s)) uniq.push(s); + }; + + // 说明:网页端优先走同源 /onlyoffice-server(Next 代理到 ONLYOFFICE_INTERNAL_URL), + // 避免配置里误写成 https 自签证书域名导致浏览器报 ERR_CERT_AUTHORITY_INVALID。 + // 同时同源路径也更利于缓存与跨环境迁移(无需改域名/端口)。 + try { + push("/onlyoffice-server"); + push(`${window.location.origin.replace(/\/+$/, "")}/onlyoffice-server`); + } catch { + // ignore + } + + // 说明:默认优先使用运行期根据 isDesktop 归一化后的 onlyofficeBaseUrl; + // 如遇到端口转发/本机服务不可达,可自动回退到另一套配置。 + if (channel === "web") { + push(runtimeConfig.onlyofficeBaseUrlWeb); + push(runtimeConfig.onlyofficeBaseUrl); + push(runtimeConfig.onlyofficeBaseUrlDesktop); + return uniq; + } + if (channel === "desktop") { + push(runtimeConfig.onlyofficeBaseUrlDesktop); + push(runtimeConfig.onlyofficeBaseUrl); + push(runtimeConfig.onlyofficeBaseUrlWeb); + return uniq; + } + + push(runtimeConfig.onlyofficeBaseUrl); + if (runtimeConfig.isDesktop) { + push(runtimeConfig.onlyofficeBaseUrlWeb); + } else { + push(runtimeConfig.onlyofficeBaseUrlDesktop); + } + return uniq; + }, [channel, runtimeConfig]); + + const [baseUrlIndex, setBaseUrlIndex] = useState(0); + const baseUrl = baseUrlCandidates[baseUrlIndex] || ""; const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride; const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin; const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin; @@ -293,15 +429,33 @@ export default function OnlyOfficePage() { // 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网, // 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000, // 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。 - let base = storageHostOverride - ? fileUrl - : rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl); + const isConvexStorageUrl = (() => { + // 说明:Convex Files 的直链通常形如: + // - http://127.0.0.1:3210/api/storage/ + // - https:///api/storage/ + // 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000), + // 进而导致 ONLYOFFICE 报 “下载失败(-4)”。 + try { + const u = new URL(fileUrl); + return u.pathname.startsWith("/api/storage/"); + } catch { + return false; + } + })(); + + let base = + storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl + ? fileUrl + : rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl); try { // 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数 // 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报 // “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。 const raw = new URL(base); - const alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy"); + let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy"); + + const isLocalHost = + raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal"; // 关键修复:当 fileUrl 已经是 /api/onlyoffice/proxy,但来源是外网 https(例如 frp/隧道域名)时, // ONLYOFFICE 容器会去请求该 https 地址并因证书/自签失败,从而报“下载失败(-4)”。 @@ -312,11 +466,23 @@ export default function OnlyOfficePage() { raw.host = po.host; base = raw.toString(); } + + // 关键兜底:OnlyOffice 的 document.url 由“文档服务器容器”去拉取。 + // 如果这里是 localhost/127.0.0.1(对容器而言指向它自己),会导致“下载失败(-4)”。 + // 因此在配置了 proxyOrigin 时,强制走 /api/onlyoffice/proxy 把回源留给 Next 服务端完成。 + if (!alreadyProxy && proxyOrigin && isLocalHost) { + const proxyBase = proxyOrigin || window.location.origin; + const proxy = new URL("/api/onlyoffice/proxy", proxyBase); + proxy.searchParams.set("u", base64UrlEncodeUtf8(base)); + base = proxy.toString(); + alreadyProxy = true; + } if (!alreadyProxy && raw.searchParams.has("token")) { const proxyBase = proxyOrigin || window.location.origin; const proxy = new URL("/api/onlyoffice/proxy", proxyBase); proxy.searchParams.set("u", base64UrlEncodeUtf8(base)); base = proxy.toString(); + alreadyProxy = true; } const u = new URL(base); @@ -337,6 +503,25 @@ export default function OnlyOfficePage() { } }, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]); + useEffect(() => { + try { + window.__MNOTE_ONLYOFFICE_DEBUG__ = { + pageOrigin: window.location.origin, + baseUrl, + proxyOrigin, + callbackOrigin, + fileUrlInput: fileUrl, + resolvedFileUrl, + fileName, + fileType, + mode, + assetId, + }; + } catch { + // ignore + } + }, [assetId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]); + useEffect(() => { if (!baseUrl) { setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。"); @@ -346,6 +531,28 @@ export default function OnlyOfficePage() { setError("缺少 fileUrl 参数。"); return; } + + setupOnlyOfficeInternalRequestRewrite(baseUrl, runtimeConfig.onlyofficeBaseUrlDesktop); + + // 说明:外网访问(例如 frp/隧道)时,OnlyOffice 文档服务器运行在本机 Docker 容器内,无法直接访问 + // document.url 里的 127.0.0.1/localhost。此时必须把 document.url 指向一个“容器可访问”的 Next Origin + //(onlyofficeProxyOrigin / onlyofficeProxyOriginWeb),让 Next 服务端代为回源下载。 + try { + const pageHost = window.location.hostname; + const isPageLocal = pageHost === "127.0.0.1" || pageHost === "localhost"; + const isPageRemote = !isPageLocal; + const u = new URL(fileUrl); + const isFileLocal = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal"; + if (isPageRemote && isFileLocal && !proxyOrigin) { + setError( + "外网访问时检测到 fileUrl 为本机地址(127.0.0.1/localhost),但未配置 onlyofficeProxyOriginWeb。请在 public/mnote-env.json 配置 onlyofficeProxyOriginWeb/onlyofficeCallbackOriginWeb(例如 http://host.docker.internal:3000 或当前 Docker 可达的主机 IP)。", + ); + return; + } + } catch { + // ignore + } + const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`; loadScript(scriptUrl) .then(async () => { @@ -462,9 +669,15 @@ export default function OnlyOfficePage() { }); }) .catch((err: Error) => { + // 说明:优先“无感回退”到备选 baseUrl(常见于本机 8081 未启动/外网转发不可达)。 + const hasNext = baseUrlIndex + 1 < baseUrlCandidates.length; + if (hasNext) { + setBaseUrlIndex((i) => i + 1); + return; + } setError(err.message); }); - }, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]); + }, [baseUrl, baseUrlCandidates.length, baseUrlIndex, fileName, fileType, fileUrl, mode, resolvedFileUrl, targetDocType]); if (error) { return ( diff --git a/wolai-frontend/src/app/page.tsx b/wolai-frontend/src/app/page.tsx index 178b4195..6ff13e27 100644 --- a/wolai-frontend/src/app/page.tsx +++ b/wolai-frontend/src/app/page.tsx @@ -1,8 +1,51 @@ import { redirect } from "next/navigation"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; + +function makeId(): string { + return typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(16).slice(2)}`; +} export default async function Home() { + if (isConvexEnabled()) { + const auth = requireAuthContext(); + const client = getConvexHttpClient(); + + const { activeWorkspaceId } = await client.mutation(api.workspaces.ensureDefaultWorkspace, { + userId: auth.userId, + fallbackName: auth.name ?? auth.email ?? "我的空间", + workspaceIdIfCreate: makeId(), + }); + + const docs = await client.query(api.documents.listByWorkspace, { + userId: auth.userId, + workspaceId: activeWorkspaceId, + }); + + const firstDoc = [...docs].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? ""))[0]; + if (firstDoc?.id) { + redirect(`/documents/${firstDoc.id}`); + } + + const docId = makeId(); + await client.mutation(api.documents.create, { + userId: auth.userId, + id: docId, + workspaceId: activeWorkspaceId, + parentId: null, + title: "新页面", + accessScope: "private", + content: [], + }); + redirect(`/documents/${docId}`); + } + const supabase = await createSupabaseServerClient(); const session = (await supabase.auth.getSession()).data.session; diff --git a/wolai-frontend/src/components/document-task-panel.tsx b/wolai-frontend/src/components/document-task-panel.tsx index ab5ab072..12d287da 100644 --- a/wolai-frontend/src/components/document-task-panel.tsx +++ b/wolai-frontend/src/components/document-task-panel.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useSessionContext } from "@supabase/auth-helpers-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; @@ -18,56 +17,37 @@ interface Props { } export function DocumentTaskPanel({ documentId }: Props) { - const { session } = useSessionContext(); const [task, setTask] = useState(null); const [pending, setPending] = useState(false); - const backendUrl = useMemo(() => getMnoteRuntimeConfig().backendUrl, []); + const runtime = useMemo(() => getMnoteRuntimeConfig(), []); + const backendUrl = runtime.backendUrl; + const useConvex = Boolean(runtime.useConvex); const triggerTask = async () => { - if (!backendUrl || !session?.access_token) return; + // 说明:当前后端(FastAPI)仍使用 Supabase JWT 做鉴权;Convex 迁移阶段先不打通这一块。 + if (useConvex) return; + if (!backendUrl) return; setPending(true); try { - const response = await fetch(`${backendUrl}/api/v1/tasks/ocr`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${session.access_token}`, - }, - body: JSON.stringify({ - document_id: documentId, - file_url: "https://example.com/sample.pdf", - }), - }); - const data = await response.json(); - if (response.ok) { - setTask(data); - } + // TODO:如需恢复该能力,请在接入真实鉴权后,将 access_token 从 AuthContext 注入到这里。 + // 这里暂时保持 UI 可渲染,不发起请求。 + void documentId; } finally { setPending(false); } }; useEffect(() => { - if (!backendUrl || !session?.access_token || !task?.task_id) { + if (useConvex) return; + if (!backendUrl || !task?.task_id) { return; } const timer = setInterval(async () => { - const response = await fetch(`${backendUrl}/api/v1/tasks/${task.task_id}`, { - headers: { - Authorization: `Bearer ${session.access_token}`, - }, - }); - if (!response.ok) { - return; - } - const data = (await response.json()) as TaskResponse; - setTask(data); - if (data.status === "completed") { - clearInterval(timer); - } + // 说明:同上,暂不轮询。 + void timer; }, 2000); return () => clearInterval(timer); - }, [backendUrl, session?.access_token, task?.task_id]); + }, [backendUrl, task?.task_id, useConvex]); return ( @@ -77,9 +57,14 @@ export function DocumentTaskPanel({ documentId }: Props) {
状态:{task ? task.status : "未开始"} · 进度:{task ? `${task.progress}%` : "0%"}
+ {useConvex && ( +
+ 提示:Convex 迁移阶段暂未接入后端鉴权(Supabase JWT),该按钮仅用于占位。 +
+ )} {task?.message &&
提示:{task.message}
} - diff --git a/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx b/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx new file mode 100644 index 00000000..5cc12338 --- /dev/null +++ b/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Search } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { SidebarInitialData } from "@/components/sidebar/types"; +import { buildDocumentTree } from "@/lib/documents"; +import { cn } from "@/lib/utils"; +import { useDocumentSearch } from "@/hooks/use-document-search"; +import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search"; + +export type MoveEmbedMode = "move" | "embed"; + +const DEFAULT_FILTERS: DocumentSearchFilters = { + titleOnly: true, + exact: false, + onlyCurrentPage: false, + includeOcr: false, + timeRange: "any", + timeField: "updated", +}; + +type PickerItem = + | { kind: "root"; id: null; title: string; subtitle?: string } + | { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult }; + +async function fetchSidebarData(workspaceId: string): Promise { + const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, { + method: "GET", + credentials: "include", + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const message = payload?.error ?? "获取页面列表失败"; + throw new Error(message); + } + + return response.json(); +} + +interface MoveEmbedPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string | null; + defaultMode?: MoveEmbedMode; + modes?: MoveEmbedMode[]; + allowRoot?: boolean; + excludeIds?: string[]; + onPick: (mode: MoveEmbedMode, targetId: string | null) => void | Promise; +} + +export function MoveEmbedPickerDialog({ + open, + onOpenChange, + workspaceId, + defaultMode = "move", + modes = ["move", "embed"], + allowRoot = true, + excludeIds = [], + onPick, +}: MoveEmbedPickerDialogProps) { + const [mode, setMode] = useState(defaultMode); + const [query, setQuery] = useState(""); + const [highlighted, setHighlighted] = useState(0); + + useEffect(() => { + if (!open) { + setQuery(""); + setHighlighted(0); + return; + } + // 说明:每次打开对话框时,强制同步到调用方传入的默认模式(移动/嵌入)。 + setMode(defaultMode); + setQuery(""); + setHighlighted(0); + }, [defaultMode, open]); + + const payload = useMemo(() => { + if (!workspaceId) return null; + return { + workspaceId, + query, + filters: DEFAULT_FILTERS, + limit: 30, + }; + }, [query, workspaceId]); + + const trimmed = query.trim(); + const isEmptyQuery = trimmed.length === 0; + + const sidebarQuery = useQuery({ + queryKey: ["move-embed-picker-sidebar", workspaceId], + queryFn: () => { + if (!workspaceId) { + throw new Error("缺少 workspaceId"); + } + return fetchSidebarData(workspaceId); + }, + enabled: open && Boolean(workspaceId) && isEmptyQuery, + staleTime: 30_000, + gcTime: 60_000, + }); + + const { data, isLoading, error } = useDocumentSearch(payload, open && !isEmptyQuery); + + const items = useMemo(() => { + const excluded = new Set(excludeIds); + + const result: PickerItem[] = []; + + if (allowRoot && mode === "move") { + result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" }); + } + + if (isEmptyQuery) { + const docs = sidebarQuery.data?.documents ?? []; + const tree = buildDocumentTree(docs); + + const flattened: Array<{ id: string; title: string; depth: number }> = []; + const walk = (nodes: ReturnType, depth: number) => { + for (const node of nodes) { + flattened.push({ id: node.id, title: node.title ?? "无标题", depth }); + if (node.children?.length) { + walk(node.children, depth + 1); + } + } + }; + walk(tree, 0); + + for (const item of flattened) { + if (excluded.has(item.id)) continue; + result.push({ + kind: "doc", + id: item.id, + title: item.title, + depth: item.depth, + }); + } + + return result; + } + + const rawList = data?.results?.length ? data?.results : data?.recent ?? []; + for (const r of rawList) { + if (!r || excluded.has(r.id)) continue; + result.push({ + kind: "doc", + id: r.id, + title: r.title || "无标题", + subtitle: r.matchField === "recent" ? "最近打开" : undefined, + raw: r, + }); + } + + return result; + }, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.documents]); + + useEffect(() => { + setHighlighted(0); + }, [mode, query, open]); + + const placeholder = mode === "move" ? "移动到..." : "嵌入到..."; + + const handlePick = useCallback( + async (targetId: string | null) => { + await onPick(mode, targetId); + onOpenChange(false); + }, + [mode, onOpenChange, onPick], + ); + + return ( + + + 选择目标页面 +
+
+ setMode(v as MoveEmbedMode)}> + + {modes.includes("move") && ( + + 移动到 + + )} + {modes.includes("embed") && ( + + 嵌入到 + + )} + + +
+ + setQuery(e.target.value)} + placeholder={placeholder} + className="h-10 rounded-xl border-[#e2e8f0] pl-9 text-sm" + /> +
+
+
+
+ +
{ + if (!open) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setHighlighted((prev) => Math.min(items.length - 1, prev + 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setHighlighted((prev) => Math.max(0, prev - 1)); + } else if (event.key === "Enter") { + event.preventDefault(); + const picked = items[highlighted]; + if (!picked) return; + void handlePick(picked.id); + } + }} + > + {!workspaceId ? ( +
缺少 workspaceId,无法加载页面列表。
+ ) : (isEmptyQuery ? sidebarQuery.isLoading : isLoading) ? ( +
加载中...
+ ) : (isEmptyQuery ? sidebarQuery.error : error) ? ( +
{String(isEmptyQuery ? sidebarQuery.error : error)}
+ ) : items.length === 0 ? ( +
没有匹配结果
+ ) : ( +
+ {items.map((item, idx) => ( + + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/wolai-frontend/src/components/documents/move-embed-picker-host.tsx b/wolai-frontend/src/components/documents/move-embed-picker-host.tsx new file mode 100644 index 00000000..3ed36f11 --- /dev/null +++ b/wolai-frontend/src/components/documents/move-embed-picker-host.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { MoveEmbedPickerDialog } from "@/components/documents/move-embed-picker-dialog"; +import { useMoveEmbedPickerStore } from "@/store/move-embed-picker"; + +export function MoveEmbedPickerHost() { + const open = useMoveEmbedPickerStore((s) => s.open); + const workspaceId = useMoveEmbedPickerStore((s) => s.workspaceId); + const defaultMode = useMoveEmbedPickerStore((s) => s.defaultMode); + const modes = useMoveEmbedPickerStore((s) => s.modes); + const allowRoot = useMoveEmbedPickerStore((s) => s.allowRoot); + const excludeIds = useMoveEmbedPickerStore((s) => s.excludeIds); + const onPick = useMoveEmbedPickerStore((s) => s.onPick); + const setWorkspaceId = useMoveEmbedPickerStore((s) => s.setWorkspaceId); + const close = useMoveEmbedPickerStore((s) => s.close); + + useEffect(() => { + if (!open) return; + if (workspaceId) return; + let cancelled = false; + + const run = async () => { + try { + const res = await fetch("/api/sidebar"); + if (!res.ok) return; + const json = (await res.json().catch(() => null)) as { activeWorkspaceId?: string } | null; + const nextId = typeof json?.activeWorkspaceId === "string" ? json.activeWorkspaceId : null; + if (!cancelled) { + setWorkspaceId(nextId); + } + } catch { + // ignore + } + }; + + void run(); + + return () => { + cancelled = true; + }; + }, [open, setWorkspaceId, workspaceId]); + + const handlePick = useCallback( + async (mode: "move" | "embed", targetId: string | null) => { + if (onPick) { + await onPick(mode, targetId); + } + close(); + }, + [close, onPick], + ); + + return ( + { + if (!next) { + close(); + } + }} + workspaceId={workspaceId} + defaultMode={defaultMode} + modes={modes} + allowRoot={allowRoot} + excludeIds={excludeIds} + onPick={handlePick} + /> + ); +} diff --git a/wolai-frontend/src/components/editor/blocknote-editor.tsx b/wolai-frontend/src/components/editor/blocknote-editor.tsx index e9e8b6e8..9c141be8 100644 --- a/wolai-frontend/src/components/editor/blocknote-editor.tsx +++ b/wolai-frontend/src/components/editor/blocknote-editor.tsx @@ -21,6 +21,7 @@ import type { MediaAsset } from "@/types/media"; import { customBlockSchema, type CustomBlockSchema } from "./schema"; import { CustomSideMenu } from "./menus/CustomSideMenu"; import { CustomSlashMenu } from "./menus/CustomSlashMenu"; +import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { DocumentToc, type TocEntry } from "@/components/editor/document-toc"; import { useSearchPaletteStore } from "@/store/search-palette"; @@ -777,13 +778,13 @@ const computeDocumentStats = (blocks: Block[]): DocumentStats editable={!pageOptions.protectEditing} className={blocknoteClass} > - {!isFullScreenTableOpen && ( - ) => ( - - )} - /> - )} + {!isFullScreenTableOpen && ( + ) => ( + + )} + /> + )}
@@ -793,6 +794,8 @@ const computeDocumentStats = (blocks: Block[]): DocumentStats
+ + {/* 全屏表格编辑器 Modal */} {fullScreenTableId && ( ; + content?: unknown; + children?: unknown; +}; + +const isTextBlock = (block: RemoteBlock) => block.type === "paragraph" || block.type === "heading"; + +export const blockReferenceBlock = createReactBlockSpec( + { + type: "blockReference", + propSchema: { + sourceDocumentId: { default: "" }, + targetBlockId: { default: "" }, + display: { default: "embed" }, + }, + content: "none", + }, + () => ({ + render: ({ block }) => , + }), +)(); + +function BlockReferenceContent({ block }: { block: { props: { sourceDocumentId: string; targetBlockId: string } } }) { + const router = useRouter(); + const sourceDocumentId = block.props.sourceDocumentId; + const targetBlockId = block.props.targetBlockId; + + const [remote, setRemote] = useState(null); + const [textDraft, setTextDraft] = useState(""); + const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); + const [error, setError] = useState(""); + + const canEdit = useMemo(() => Boolean(remote && isTextBlock(remote)), [remote]); + + useEffect(() => { + if (!sourceDocumentId || !targetBlockId) { + setStatus("error"); + setError("引用信息不完整"); + return; + } + let cancelled = false; + setStatus("loading"); + setError(""); + setRemote(null); + + const run = async () => { + try { + const res = await fetch( + `/api/blocks/get?sourceDocumentId=${encodeURIComponent(sourceDocumentId)}&blockId=${encodeURIComponent(targetBlockId)}`, + { method: "GET", credentials: "include" }, + ); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.error ?? "获取引用块失败"); + } + const json = await res.json(); + const next = (json?.block ?? null) as RemoteBlock | null; + if (!cancelled) { + setRemote(next); + if (next && isTextBlock(next)) { + setTextDraft(extractBlockText(next as any)); + } + setStatus("idle"); + } + } catch (e) { + if (!cancelled) { + setStatus("error"); + setError(e instanceof Error ? e.message : "获取引用块失败"); + } + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [sourceDocumentId, targetBlockId]); + + const openSource = useCallback(() => { + if (sourceDocumentId) { + router.push(`/documents/${sourceDocumentId}`); + } + }, [router, sourceDocumentId]); + + const saveText = useCallback(async () => { + if (!remote || !canEdit) return; + const nextBlock: RemoteBlock = { + ...remote, + id: remote.id, + content: [{ type: "text", text: textDraft }], + }; + const res = await fetch("/api/blocks/patch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ sourceDocumentId, blockId: targetBlockId, nextBlock }), + }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + const msg = payload?.error ?? "同步编辑失败"; + if (typeof window !== "undefined") window.alert(msg); + return; + } + if (typeof window !== "undefined") window.alert("已同步编辑到原块"); + }, [canEdit, remote, sourceDocumentId, targetBlockId, textDraft]); + + return ( +
{ + // 说明:避免点击内部按钮/输入框时误触发编辑器的拖拽/选择。 + e.stopPropagation(); + }} + > +
+ 嵌入引用 + + + +
+ + {status === "loading" ? ( +
加载中...
+ ) : status === "error" ? ( +
{error}
+ ) : !remote ? ( +
引用块不存在
+ ) : canEdit ? ( +
+