From 90a38b48cf81a7ed67c2911827467adcc790e639 Mon Sep 17 00:00:00 2001 From: liaibo Date: Sun, 18 Jan 2026 05:13:53 +0800 Subject: [PATCH] =?UTF-8?q?0.3=20=E5=A2=9E=E5=8A=A0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/convex-mir-design | 24 +- infra/convex/README.md | 6 +- infra/convex/docker-compose.yml | 7 + services/ingest_service/app/core/config.py | 17 -- services/ingest_service/app/main.py | 15 +- services/ingest_service/app/models/job.py | 2 +- .../ingest_service/app/services/job_store.py | 2 +- .../ingest_service/app/services/runtime.py | 10 - wolai-frontend/convex/_generated/api.d.ts | 16 + wolai-frontend/convex/_utils/id.ts | 7 + wolai-frontend/convex/_utils/ingestJobs.ts | 125 ++++++++ wolai-frontend/convex/_utils/lightrag.ts | 47 +++ wolai-frontend/convex/_utils/text.ts | 101 ++++++ wolai-frontend/convex/auth.config.ts | 16 + wolai-frontend/convex/auth.ts | 32 ++ wolai-frontend/convex/documents.ts | 5 + wolai-frontend/convex/http.ts | 13 + wolai-frontend/convex/jobs.ts | 117 ++++++- wolai-frontend/convex/mediaAssets.ts | 10 + wolai-frontend/convex/mindmaps.ts | 4 +- wolai-frontend/convex/schema.ts | 52 +++- wolai-frontend/convex/tables.ts | 289 ++++++++++++++++++ wolai-frontend/convex/users.ts | 17 ++ wolai-frontend/package.json | 3 + wolai-frontend/pnpm-lock.yaml | 163 ++++++++++ wolai-frontend/scripts/fix-auth-env.js | 79 +++++ wolai-frontend/scripts/generateKeys.mjs | 13 + wolai-frontend/scripts/register-test-user.js | 60 ++++ wolai-frontend/scripts/set-auth-env.js | 57 ++++ .../src/app/(app)/documents/[id]/page.tsx | 2 +- wolai-frontend/src/app/(app)/layout.tsx | 2 +- wolai-frontend/src/app/(auth)/auth/page.tsx | 229 ++++++++++++++ .../api/ai-agent/client-tool-result/route.ts | 12 +- .../src/app/api/ai-agent/run/route.ts | 2 +- .../src/app/api/auth/signin/route.ts | 110 +++++++ .../src/app/api/blocks/embed/route.ts | 3 +- .../src/app/api/blocks/get/route.ts | 3 +- .../src/app/api/blocks/move/route.ts | 3 +- .../src/app/api/blocks/patch/route.ts | 3 +- .../src/app/api/dev/ingest/enqueue/route.ts | 46 +++ .../src/app/api/dev/jobs/demo/route.ts | 5 +- .../src/app/api/dev/whoami/route.ts | 2 +- .../src/app/api/documents/content/route.ts | 2 +- .../src/app/api/documents/copy-tree/route.ts | 2 +- .../app/api/documents/create-child/route.ts | 2 +- .../src/app/api/documents/create/route.ts | 2 +- .../src/app/api/documents/delete/route.ts | 2 +- .../src/app/api/documents/duplicate/route.ts | 2 +- .../src/app/api/documents/embed/route.ts | 2 +- .../app/api/documents/empty-trash/route.ts | 2 +- .../src/app/api/documents/move/route.ts | 2 +- .../src/app/api/documents/options/route.ts | 2 +- .../src/app/api/documents/purge/route.ts | 2 +- .../src/app/api/documents/restore/route.ts | 2 +- .../src/app/api/documents/save/route.ts | 2 +- .../src/app/api/documents/stats/route.ts | 2 +- .../src/app/api/documents/title/route.ts | 2 +- .../api/luckysheet/get-workerbook/route.ts | 25 ++ .../src/app/api/luckysheet/load/route.ts | 35 +++ .../src/app/api/media/assets/route.ts | 4 +- .../src/app/api/media/batch/route.ts | 2 +- .../src/app/api/media/empty-trash/route.ts | 2 +- .../src/app/api/media/purge/route.ts | 2 +- .../src/app/api/media/sign/route.ts | 2 +- .../src/app/api/media/signed-url/route.ts | 2 +- .../src/app/api/media/upload/route.ts | 2 +- .../src/app/api/mindmap-trash/empty/route.ts | 2 +- .../mindmap/[docId]/[mindmapId]/ops/route.ts | 2 +- .../api/mindmap/[docId]/[mindmapId]/route.ts | 8 +- .../src/app/api/mindmap/[docId]/route.ts | 6 +- .../src/app/api/references/backlinks/route.ts | 2 +- .../src/app/api/references/record/route.ts | 2 +- .../src/app/api/search/documents/route.ts | 2 +- .../src/app/api/search/recent/route.ts | 2 +- wolai-frontend/src/app/api/sidebar/route.ts | 2 +- .../src/app/api/tables/[tableId]/route.ts | 103 +++++-- .../src/app/api/tables/create/route.ts | 43 +++ .../src/app/api/workspaces/switch/route.ts | 2 +- wolai-frontend/src/app/layout.tsx | 8 +- wolai-frontend/src/app/page.tsx | 2 +- .../components/providers/convex-provider.tsx | 41 +++ wolai-frontend/src/lib/auth/authContext.ts | 76 ++++- wolai-frontend/src/lib/convex/enabled.ts | 2 +- wolai-frontend/src/lib/convex/route.ts | 5 +- wolai-frontend/src/lib/convex/server.ts | 5 +- wolai-frontend/src/middleware.ts | 37 +++ 86 files changed, 2041 insertions(+), 148 deletions(-) create mode 100644 wolai-frontend/convex/_utils/id.ts create mode 100644 wolai-frontend/convex/_utils/ingestJobs.ts create mode 100644 wolai-frontend/convex/_utils/lightrag.ts create mode 100644 wolai-frontend/convex/_utils/text.ts create mode 100644 wolai-frontend/convex/auth.config.ts create mode 100644 wolai-frontend/convex/auth.ts create mode 100644 wolai-frontend/convex/http.ts create mode 100644 wolai-frontend/convex/tables.ts create mode 100644 wolai-frontend/convex/users.ts create mode 100644 wolai-frontend/scripts/fix-auth-env.js create mode 100644 wolai-frontend/scripts/generateKeys.mjs create mode 100644 wolai-frontend/scripts/register-test-user.js create mode 100644 wolai-frontend/scripts/set-auth-env.js create mode 100644 wolai-frontend/src/app/(auth)/auth/page.tsx create mode 100644 wolai-frontend/src/app/api/auth/signin/route.ts create mode 100644 wolai-frontend/src/app/api/dev/ingest/enqueue/route.ts create mode 100644 wolai-frontend/src/components/providers/convex-provider.tsx create mode 100644 wolai-frontend/src/middleware.ts diff --git a/design/convex-mir-design b/design/convex-mir-design index c4d18b0f..08a7ab51 100644 --- a/design/convex-mir-design +++ b/design/convex-mir-design @@ -205,23 +205,29 @@ 径/目录视图”,仍建议用业务表字段(workspace_id、user_id、path)来实现管理与展示,这是最贴合你“一 体化 + 不引入外部对象存储”的路线。citeturn0search5 checklist: - M1(已完成):documents/workspaces/media/search 全链路 Convex 化 + 冒烟回归 - - M2:Mindmap 数据与接口迁移(/api/mindmap/**、/api/mindmap-trash/empty),落到 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 化(鉴权/读写文档/工 + - 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 模式下先禁用(给 + - 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 卷备份恢复、日志与健康检查、启动脚本收敛 + - 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)。 + - M7 进展:已把“自动入库/LightRAG 触发”迁到 Convex jobs/actions 的最小骨架(支持 document/mindmap/media 三类入库任务),并在 Convex 模式下默认关闭 services/ingest_service 的 Supabase 轮询入库(避免继续依赖 Supabase)。 + - 触发入库(开发用):POST wolai-frontend/src/app/api/dev/ingest/enqueue/route.ts:1 + - 查询任务:GET wolai-frontend/src/app/api/dev/jobs/demo/route.ts:1(传 id) + - Convex action 需要环境变量:LIGHTRAG_URL / LIGHTRAG_API_KEY(运行在 Convex 侧) + - 自动触发(Convex 侧):保存页面/导图会 debounce enqueue 入库任务(wolai-frontend/convex/_utils/ingestJobs.ts:1) + - 已移除 ingest_service 内与 Supabase 相关代码(不再包含 Supabase 客户端/配置/轮询入库实现) - “最近访问”已落库到 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- diff --git a/infra/convex/README.md b/infra/convex/README.md index d2de0b62..f510442d 100644 --- a/infra/convex/README.md +++ b/infra/convex/README.md @@ -38,10 +38,10 @@ npx convex dev --once --tail-logs disable --env-file .env.local --run ping:ping ### 2) 验证固定开发用户(阶段 3) -启动前端后访问:`http://localhost:3000/api/dev/whoami` +启动前端后访问:`http://127.0.0.1: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) +- `POST http://127.0.0.1:3000/api/dev/jobs/demo`(body 可选:`{ "ms": 800 }`)创建 demo job +- `GET http://127.0.0.1:3000/api/dev/jobs/demo?id=` 查询状态(queued/running/succeeded/failed) diff --git a/infra/convex/docker-compose.yml b/infra/convex/docker-compose.yml index f830c8fb..41d8a69f 100644 --- a/infra/convex/docker-compose.yml +++ b/infra/convex/docker-compose.yml @@ -14,6 +14,9 @@ services: - 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}} + # Convex Auth 依赖(站点地址用于 JWT issuer / redirect 校验等) + - CONVEX_SITE_URL=${CONVEX_SITE_URL:-http://127.0.0.1:3000} + - SITE_URL=${SITE_URL:-http://127.0.0.1:3000} - DATABASE_URL - DISABLE_BEACON - DOCUMENT_RETENTION_DELAY=${DOCUMENT_RETENTION_DELAY:-172800} # 降低默认保留到 2 天 @@ -21,11 +24,15 @@ services: - HTTP_SERVER_TIMEOUT_SECONDS - INSTANCE_NAME - INSTANCE_SECRET + - JWT_PRIVATE_KEY + - JWKS - MYSQL_URL - POSTGRES_URL - REDACT_LOGS_TO_CLIENT - RUST_BACKTRACE - RUST_LOG=${RUST_LOG:-info} + env_file: + - .env healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3210/version"] interval: 5s diff --git a/services/ingest_service/app/core/config.py b/services/ingest_service/app/core/config.py index cd5876d0..3a8e58ab 100644 --- a/services/ingest_service/app/core/config.py +++ b/services/ingest_service/app/core/config.py @@ -36,27 +36,10 @@ class Settings(BaseSettings): environment: str = Field("development", env="INGEST_ENV") api_prefix: str = "/api" - # Supabase(通过 Kong: http://127.0.0.1:18000) - supabase_url: str = Field("http://127.0.0.1:18000", env="SUPABASE_URL") - supabase_service_role_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY") - # LightRAG(独立服务 7777) lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL") lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY") - # 自动入库(rag_index_sources -> LightRAG) - auto_index_enabled: bool = Field(True, env="AUTO_INDEX_ENABLED") - auto_index_interval_seconds: int = Field(5, env="AUTO_INDEX_INTERVAL_SECONDS") - auto_index_batch_size: int = Field(5, env="AUTO_INDEX_BATCH_SIZE") - auto_index_max_attempts: int = Field(6, env="AUTO_INDEX_MAX_ATTEMPTS") - - # 删除宽限期(用于“可撤销删除”):例如用户误删后 10 分钟内可恢复,宽限期内不清理 OCR / Storage / LightRAG 资源 - delete_grace_seconds: int = Field(600, env="DELETE_GRACE_SECONDS") - - # MinerU(用于 OCR / PDF 结构化解析) - mineru_enabled: bool = Field(True, env="MINERU_ENABLED") - mineru_endpoint: str = Field("", env="MINERU_ENDPOINT") - @classmethod def settings_customise_sources( cls, diff --git a/services/ingest_service/app/main.py b/services/ingest_service/app/main.py index 2e7e0de2..66ddd4f0 100644 --- a/services/ingest_service/app/main.py +++ b/services/ingest_service/app/main.py @@ -3,7 +3,7 @@ from fastapi import FastAPI from app.api.routes import router as api_router from app.core.config import get_settings from app.core.logging import setup_logging -from app.services.runtime import auto_indexer, scheduler, worker +from app.services.runtime import worker settings = get_settings() @@ -15,22 +15,9 @@ app.include_router(api_router, prefix=settings.api_prefix) @app.on_event("startup") async def _startup() -> None: - scheduler.start() - if getattr(settings, "auto_index_enabled", False): - try: - await auto_indexer.startup() - except Exception: # noqa: BLE001 - # 启动阶段不阻塞服务:自动入库失败会在后续 tick 中继续重试/记录 - pass - scheduler.add_interval_job( - job_id="auto_index_tick", - func=auto_indexer.tick, - seconds=int(getattr(settings, "auto_index_interval_seconds", 5)), - ) await worker.start() @app.on_event("shutdown") async def _shutdown() -> None: - scheduler.shutdown() await worker.stop() diff --git a/services/ingest_service/app/models/job.py b/services/ingest_service/app/models/job.py index 5986c45e..b5d1d46e 100644 --- a/services/ingest_service/app/models/job.py +++ b/services/ingest_service/app/models/job.py @@ -5,7 +5,7 @@ from typing import Dict, List, Optional @dataclass class IngestJob: - """表示一次 ingest 任务,后续可持久化到 Supabase""" + """表示一次 ingest 任务,后续可持久化到数据库""" id: str notebook_id: Optional[str] diff --git a/services/ingest_service/app/services/job_store.py b/services/ingest_service/app/services/job_store.py index 3a95505d..bacf5e52 100644 --- a/services/ingest_service/app/services/job_store.py +++ b/services/ingest_service/app/services/job_store.py @@ -5,7 +5,7 @@ from app.models.job import IngestJob class InMemoryJobStore: - """简单的内存 JobStore,后续可替换为 Supabase 持久化""" + """简单的内存 JobStore,后续可替换为数据库持久化""" def __init__(self) -> None: self._jobs: Dict[str, IngestJob] = {} diff --git a/services/ingest_service/app/services/runtime.py b/services/ingest_service/app/services/runtime.py index 6e8b49cd..3385b947 100644 --- a/services/ingest_service/app/services/runtime.py +++ b/services/ingest_service/app/services/runtime.py @@ -1,21 +1,11 @@ from app.core.config import get_settings -from app.services.auto_indexer import AutoIndexService from app.services.job_store import InMemoryJobStore -from app.services.mineru_client import MinerUClient -from app.services.scheduler import SchedulerManager -from app.services.supabase_rest import SupabaseRestAsyncClient from app.services.tasks import IngestWorker from app.services.webhooks import LightRAGWebhook settings = get_settings() job_store = InMemoryJobStore() -scheduler = SchedulerManager() webhook = LightRAGWebhook() worker = IngestWorker(job_store=job_store, webhook=webhook) -# 自动入库依赖:Supabase + MinerU + LightRAG -supabase = SupabaseRestAsyncClient() -mineru = MinerUClient() -auto_indexer = AutoIndexService(supabase=supabase, webhook=webhook, mineru=mineru) - diff --git a/wolai-frontend/convex/_generated/api.d.ts b/wolai-frontend/convex/_generated/api.d.ts index f01d388d..5b0defcc 100644 --- a/wolai-frontend/convex/_generated/api.d.ts +++ b/wolai-frontend/convex/_generated/api.d.ts @@ -8,14 +8,22 @@ * @module */ +import type * as _utils_id from "../_utils/id.js"; +import type * as _utils_ingestJobs from "../_utils/ingestJobs.js"; +import type * as _utils_lightrag from "../_utils/lightrag.js"; +import type * as _utils_text from "../_utils/text.js"; import type * as _utils_time from "../_utils/time.js"; +import type * as auth from "../auth.js"; import type * as documents from "../documents.js"; +import type * as http from "../http.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 tables from "../tables.js"; +import type * as users from "../users.js"; import type * as workspaces from "../workspaces.js"; import type { @@ -25,14 +33,22 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + "_utils/id": typeof _utils_id; + "_utils/ingestJobs": typeof _utils_ingestJobs; + "_utils/lightrag": typeof _utils_lightrag; + "_utils/text": typeof _utils_text; "_utils/time": typeof _utils_time; + auth: typeof auth; documents: typeof documents; + http: typeof http; jobs: typeof jobs; mediaAssets: typeof mediaAssets; mindmaps: typeof mindmaps; ping: typeof ping; recents: typeof recents; references: typeof references; + tables: typeof tables; + users: typeof users; workspaces: typeof workspaces; }>; diff --git a/wolai-frontend/convex/_utils/id.ts b/wolai-frontend/convex/_utils/id.ts new file mode 100644 index 00000000..204f24c8 --- /dev/null +++ b/wolai-frontend/convex/_utils/id.ts @@ -0,0 +1,7 @@ +// 简单的 ID 生成器,用于生成类似 UUID 的字符串 +export function generateId(): string { + // 使用时间戳 + 随机数生成唯一 ID + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 15); + return `${timestamp}${random}`; +} diff --git a/wolai-frontend/convex/_utils/ingestJobs.ts b/wolai-frontend/convex/_utils/ingestJobs.ts new file mode 100644 index 00000000..da1afc0d --- /dev/null +++ b/wolai-frontend/convex/_utils/ingestJobs.ts @@ -0,0 +1,125 @@ +import { internal } from "../_generated/api"; +import { nowIso } from "./time"; +import type { MutationCtx } from "../_generated/server"; + +type JobStatus = "queued" | "running" | "succeeded" | "failed"; + +async function upsertJob( + ctx: MutationCtx, + args: { + id: string; + userId: string; + type: string; + payload: unknown; + debounceMs?: number; + }, +): Promise<{ id: string; status: JobStatus }> { + const debounceMs = Math.max(0, Math.floor(args.debounceMs ?? 1200)); + const ts = nowIso(); + + const existing = await ctx.db + .query("jobs") + .withIndex("by_job_id", (q) => q.eq("id", args.id)) + .first(); + + if (existing) { + if (existing.user_id !== args.userId) { + // 说明:避免不同用户复用同一 job id 导致“互相覆盖”。 + return { id: args.id, status: existing.status as JobStatus }; + } + + const status = String(existing.status) as JobStatus; + if (status === "running" || status === "queued") { + await ctx.db.patch(existing._id, { updated_at: ts }); + // 说明:start 内部会检查状态,重复 schedule 不会造成重复执行。 + await ctx.scheduler.runAfter(debounceMs, internal.jobs.start, { id: args.id }); + return { id: args.id, status }; + } + + await ctx.db.patch(existing._id, { + type: args.type, + status: "queued", + payload: args.payload, + result: null, + error: null, + updated_at: ts, + started_at: null, + finished_at: null, + }); + await ctx.scheduler.runAfter(debounceMs, internal.jobs.start, { id: args.id }); + return { id: args.id, status: "queued" }; + } + + await ctx.db.insert("jobs", { + id: args.id, + user_id: args.userId, + type: args.type, + status: "queued", + payload: args.payload, + result: null, + error: null, + created_at: ts, + updated_at: ts, + started_at: null, + finished_at: null, + }); + await ctx.scheduler.runAfter(debounceMs, internal.jobs.start, { id: args.id }); + return { id: args.id, status: "queued" }; +} + +export function ingestDocumentJobId(documentId: string): string { + return `ingest:document:${documentId}`; +} + +export function ingestMindmapJobId(docId: string, mindmapId: string): string { + return `ingest:mindmap:${docId}:${mindmapId}`; +} + +export function ingestMediaAssetJobId(assetId: string): string { + return `ingest:media:${assetId}`; +} + +export async function enqueueIngestDocumentJob( + ctx: MutationCtx, + args: { userId: string; documentId: string; debounceMs?: number }, +): Promise<{ id: string }> { + const id = ingestDocumentJobId(args.documentId); + await upsertJob(ctx, { + id, + userId: args.userId, + type: "ingest.rag_index_document", + payload: { documentId: args.documentId }, + debounceMs: args.debounceMs, + }); + return { id }; +} + +export async function enqueueIngestMindmapJob( + ctx: MutationCtx, + args: { userId: string; docId: string; mindmapId: string; debounceMs?: number }, +): Promise<{ id: string }> { + const id = ingestMindmapJobId(args.docId, args.mindmapId); + await upsertJob(ctx, { + id, + userId: args.userId, + type: "ingest.rag_index_mindmap", + payload: { docId: args.docId, mindmapId: args.mindmapId }, + debounceMs: args.debounceMs, + }); + return { id }; +} + +export async function enqueueIngestMediaAssetJob( + ctx: MutationCtx, + args: { userId: string; assetId: string; debounceMs?: number }, +): Promise<{ id: string }> { + const id = ingestMediaAssetJobId(args.assetId); + await upsertJob(ctx, { + id, + userId: args.userId, + type: "ingest.rag_index_media_asset", + payload: { assetId: args.assetId }, + debounceMs: args.debounceMs, + }); + return { id }; +} diff --git a/wolai-frontend/convex/_utils/lightrag.ts b/wolai-frontend/convex/_utils/lightrag.ts new file mode 100644 index 00000000..70952a46 --- /dev/null +++ b/wolai-frontend/convex/_utils/lightrag.ts @@ -0,0 +1,47 @@ +type IngestTextArgs = { + fileSource: string; + text: string; +}; + +export async function lightragIngestText(args: IngestTextArgs): Promise<{ trackId: string | null }> { + const baseUrl = (process.env.LIGHTRAG_URL || "").trim().replace(/\/+$/, ""); + if (!baseUrl) { + throw new Error("缺少环境变量:LIGHTRAG_URL"); + } + + const fileSource = String(args.fileSource ?? "").trim(); + if (!fileSource) { + throw new Error("缺少 fileSource"); + } + + const text = String(args.text ?? ""); + if (!text.trim()) { + return { trackId: null }; + } + + const apiKey = (process.env.LIGHTRAG_API_KEY || "").trim(); + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey) { + headers["X-API-Key"] = apiKey; + } + + // 说明:LightRAG(>=1.4.x) 使用 /documents/text 作为“文本入库”接口。 + const resp = await fetch(`${baseUrl}/documents/text`, { + method: "POST", + headers, + body: JSON.stringify({ text, file_source: fileSource }), + }); + + if (!resp.ok) { + const raw = await resp.text().catch(() => ""); + throw new Error(`LightRAG 入库失败:HTTP ${resp.status} ${raw.slice(0, 500)}`); + } + + const data = (await resp.json().catch(() => null)) as unknown; + const trackId = (() => { + if (!data || typeof data !== "object") return null; + const v = (data as Record).track_id; + return typeof v === "string" ? v : null; + })(); + return { trackId }; +} diff --git a/wolai-frontend/convex/_utils/text.ts b/wolai-frontend/convex/_utils/text.ts new file mode 100644 index 00000000..64ffe8db --- /dev/null +++ b/wolai-frontend/convex/_utils/text.ts @@ -0,0 +1,101 @@ +function pushText(out: string[], value: unknown) { + const s = typeof value === "string" ? value : null; + if (!s) return; + const trimmed = s.replace(/\s+/g, " ").trim(); + if (!trimmed) return; + out.push(trimmed); +} + +export function extractTextFromDocumentContent(content: unknown, maxChars = 60000): string { + const out: string[] = []; + + const readPropText = (value: unknown) => { + if (!value || typeof value !== "object") return null; + const t = (value as Record).text; + return typeof t === "string" ? t : null; + }; + + const walk = (node: unknown) => { + if (out.join("\n").length >= maxChars) return; + + if (node == null) return; + if (typeof node === "string") { + pushText(out, node); + return; + } + if (typeof node === "number" || typeof node === "boolean") return; + + if (Array.isArray(node)) { + for (const item of node) { + walk(item); + if (out.join("\n").length >= maxChars) return; + } + return; + } + + if (typeof node === "object") { + const obj = node as Record; + + // 兼容常见 BlockNote 结构: + // - block.props.text + // - block.content: [{ text: "..." }, ...] + // - block.children: [...] + pushText(out, obj.text); + pushText(out, readPropText(obj.props)); + + const maybeContent = obj.content; + if (Array.isArray(maybeContent)) { + for (const seg of maybeContent) { + if (typeof seg === "string") pushText(out, seg); + else if (seg && typeof seg === "object") pushText(out, readPropText(seg)); + if (out.join("\n").length >= maxChars) return; + } + } + + const children = obj.children; + if (Array.isArray(children)) { + for (const c of children) { + walk(c); + if (out.join("\n").length >= maxChars) return; + } + } + + // 兜底:遍历其它字段(避免遗漏 title/caption 等) + for (const [k, v] of Object.entries(obj)) { + if (k === "children" || k === "content" || k === "props" || k === "text") continue; + if (typeof v === "string") pushText(out, v); + else if (Array.isArray(v) || (v && typeof v === "object")) walk(v); + if (out.join("\n").length >= maxChars) return; + } + } + }; + + walk(content); + + const joined = out.join("\n").trim(); + return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined; +} + +type MindmapNode = { data?: { text?: unknown }; children?: unknown[] }; + +export function extractTextFromMindmapData(data: unknown, maxChars = 60000): string { + const out: string[] = []; + + const walk = (node: unknown) => { + if (out.join("\n").length >= maxChars) return; + if (!node || typeof node !== "object") return; + const n = node as MindmapNode; + pushText(out, n.data?.text); + if (Array.isArray(n.children)) { + for (const c of n.children) { + walk(c); + if (out.join("\n").length >= maxChars) return; + } + } + }; + + walk(data); + + const joined = out.join("\n").trim(); + return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined; +} diff --git a/wolai-frontend/convex/auth.config.ts b/wolai-frontend/convex/auth.config.ts new file mode 100644 index 00000000..41ba0988 --- /dev/null +++ b/wolai-frontend/convex/auth.config.ts @@ -0,0 +1,16 @@ +/** + * Convex Auth 配置文件 + * + * 说明: + * - 该文件由 @convex-dev/auth 约定读取,用于声明 “Convex” 这一内置 provider 的基本信息。 + * - domain 必须与站点对外访问的 Origin 一致(本地默认 http://127.0.0.1:3000)。 + */ +export default { + providers: [ + { + domain: process.env.CONVEX_SITE_URL, + applicationID: "convex", + }, + ], +}; + diff --git a/wolai-frontend/convex/auth.ts b/wolai-frontend/convex/auth.ts new file mode 100644 index 00000000..c17af75e --- /dev/null +++ b/wolai-frontend/convex/auth.ts @@ -0,0 +1,32 @@ +import { Password } from "@convex-dev/auth/providers/Password"; +import { convexAuth } from "@convex-dev/auth/server"; + +/** + * Convex Auth 配置 + * + * 配置密码认证方式,支持用户通过邮箱和密码注册/登录。 + */ +export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ + providers: [ + Password({ + /** + * 密码要求验证 + */ + validatePasswordRequirements: (password: string) => { + if (password.length < 8) { + throw new Error("密码至少需要 8 个字符"); + } + }, + + /** + * 用户资料配置 + */ + profile(params, _ctx) { + const email = typeof params.email === "string" ? params.email : String(params.email ?? ""); + const name = typeof params.name === "string" ? params.name : ""; + // 说明:Convex 的 Value 类型不允许 undefined;这里统一给 name 一个字符串占位。 + return { email, name: name.trim() ? name : "" }; + }, + }), + ], +}); diff --git a/wolai-frontend/convex/documents.ts b/wolai-frontend/convex/documents.ts index 3f48b426..84180cdb 100644 --- a/wolai-frontend/convex/documents.ts +++ b/wolai-frontend/convex/documents.ts @@ -1,6 +1,7 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { nowIso } from "./_utils/time"; +import { enqueueIngestDocumentJob } from "./_utils/ingestJobs"; const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public")); @@ -175,6 +176,10 @@ export const updateContent = mutation({ if (doc.user_id !== args.userId) throw new Error("无权限"); const ts = nowIso(); await ctx.db.patch(doc._id, { content: args.content, updated_at: ts }); + + // 说明:在 Convex 模式下,把“自动入库/LightRAG 触发”迁到 Convex jobs/actions。 + // 采用 debounce,避免频繁保存时触发过多任务。 + await enqueueIngestDocumentJob(ctx, { userId: args.userId, documentId: args.id, debounceMs: 1500 }); return { ok: true, updated_at: ts }; }, }); diff --git a/wolai-frontend/convex/http.ts b/wolai-frontend/convex/http.ts new file mode 100644 index 00000000..b4df88f1 --- /dev/null +++ b/wolai-frontend/convex/http.ts @@ -0,0 +1,13 @@ +import { httpRouter } from "convex/server"; +import { auth } from "./auth"; + +// 说明: +// - Convex Auth 需要通过 HTTP Routes 提供回调/授权等端点(即使你只用密码登录,也建议保持该配置)。 +// - 该文件是 Convex 的约定入口(convex/http.ts)。 + +const http = httpRouter(); + +auth.addHttpRoutes(http); + +export default http; + diff --git a/wolai-frontend/convex/jobs.ts b/wolai-frontend/convex/jobs.ts index 5fa350d5..7b6806b7 100644 --- a/wolai-frontend/convex/jobs.ts +++ b/wolai-frontend/convex/jobs.ts @@ -1,7 +1,10 @@ import { internalAction, internalMutation, internalQuery, mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { nowIso } from "./_utils/time"; -import { internal } from "./_generated/api"; +import { api, internal } from "./_generated/api"; +import { lightragIngestText } from "./_utils/lightrag"; +import { extractTextFromDocumentContent, extractTextFromMindmapData } from "./_utils/text"; +import { enqueueIngestDocumentJob, enqueueIngestMediaAssetJob, enqueueIngestMindmapJob } from "./_utils/ingestJobs"; export const get = query({ args: { userId: v.string(), id: v.string() }, @@ -52,6 +55,35 @@ export const enqueueDemo = mutation({ }, }); +export const enqueueRagIndexDocument = mutation({ + args: { userId: v.string(), documentId: v.string() }, + handler: async (ctx, args) => { + const { id } = await enqueueIngestDocumentJob(ctx, { userId: args.userId, documentId: args.documentId, debounceMs: 0 }); + return { ok: true, id }; + }, +}); + +export const enqueueRagIndexMindmap = mutation({ + args: { userId: v.string(), docId: v.string(), mindmapId: v.string() }, + handler: async (ctx, args) => { + const { id } = await enqueueIngestMindmapJob(ctx, { + userId: args.userId, + docId: args.docId, + mindmapId: args.mindmapId, + debounceMs: 0, + }); + return { ok: true, id }; + }, +}); + +export const enqueueRagIndexMediaAsset = mutation({ + args: { userId: v.string(), assetId: v.string() }, + handler: async (ctx, args) => { + const { id } = await enqueueIngestMediaAssetJob(ctx, { userId: args.userId, assetId: args.assetId, debounceMs: 0 }); + return { ok: true, id }; + }, +}); + export const start = internalMutation({ args: { id: v.string() }, handler: async (ctx, args) => { @@ -85,6 +117,88 @@ export const run = internalAction({ return; } + if (job.type === "ingest.rag_index_document") { + const documentId = String(job.payload?.documentId ?? "").trim(); + if (!documentId) throw new Error("缺少 documentId"); + + const [meta, contentRes] = await Promise.all([ + ctx.runQuery(api.documents.getMeta, { userId: job.user_id, id: documentId }), + ctx.runQuery(api.documents.getContent, { userId: job.user_id, id: documentId }), + ]); + + if (!meta) throw new Error("页面不存在或无权限"); + + const title = meta.title ?? "无标题"; + const text = extractTextFromDocumentContent(contentRes?.content ?? null); + const fileSource = `document:${documentId}`; + const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` }); + + await ctx.runMutation(internal.jobs.finishSuccess, { + id: args.id, + result: { ok: true, kind: "document", documentId, trackId }, + }); + return; + } + + if (job.type === "ingest.rag_index_mindmap") { + const docId = String(job.payload?.docId ?? "").trim(); + const mindmapId = String(job.payload?.mindmapId ?? "").trim(); + if (!docId) throw new Error("缺少 docId"); + if (!mindmapId) throw new Error("缺少 mindmapId"); + + const [docMeta, mindmapRes] = await Promise.all([ + ctx.runQuery(api.documents.getMeta, { userId: job.user_id, id: docId }), + ctx.runQuery(api.mindmaps.get, { userId: job.user_id, docId, mindmapId }), + ]); + + if (!docMeta) throw new Error("页面不存在或无权限"); + if (!mindmapRes?.ok) throw new Error("导图读取失败"); + if (!mindmapRes.meta?.exists) { + await ctx.runMutation(internal.jobs.finishSuccess, { + id: args.id, + result: { ok: true, kind: "mindmap", docId, mindmapId, skipped: true, reason: "mindmap_not_exists" }, + }); + return; + } + + const title = docMeta.title ?? "无标题"; + const text = extractTextFromMindmapData(mindmapRes.data ?? null); + const fileSource = `mindmap:${docId}:${mindmapId}`; + const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` }); + + await ctx.runMutation(internal.jobs.finishSuccess, { + id: args.id, + result: { ok: true, kind: "mindmap", docId, mindmapId, trackId }, + }); + return; + } + + if (job.type === "ingest.rag_index_media_asset") { + const assetId = String(job.payload?.assetId ?? "").trim(); + if (!assetId) throw new Error("缺少 assetId"); + + const asset = await ctx.runQuery(api.mediaAssets.getById, { userId: job.user_id, id: assetId }); + if (!asset) throw new Error("资源不存在或无权限"); + + const title = asset.file_name ?? asset.id; + const text = String(asset.ocr_text ?? "").trim(); + if (!text) { + await ctx.runMutation(internal.jobs.finishSuccess, { + id: args.id, + result: { ok: true, kind: "media_asset", assetId, skipped: true, reason: "empty_ocr_text" }, + }); + return; + } + const fileSource = `media:${assetId}`; + const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` }); + + await ctx.runMutation(internal.jobs.finishSuccess, { + id: args.id, + result: { ok: true, kind: "media_asset", assetId, trackId }, + }); + return; + } + throw new Error(`未知任务类型:${job.type}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -103,6 +217,7 @@ export const _getInternal = internalQuery({ if (!job) return null; return { id: job.id, + user_id: job.user_id, type: job.type, status: job.status, payload: job.payload, diff --git a/wolai-frontend/convex/mediaAssets.ts b/wolai-frontend/convex/mediaAssets.ts index 0a85a92c..63aecf73 100644 --- a/wolai-frontend/convex/mediaAssets.ts +++ b/wolai-frontend/convex/mediaAssets.ts @@ -1,6 +1,7 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { nowIso } from "./_utils/time"; +import { enqueueIngestMediaAssetJob } from "./_utils/ingestJobs"; import type { MutationCtx, QueryCtx } from "./_generated/server"; async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) { @@ -251,6 +252,15 @@ export const patchById = mutation({ const next = { ...(args.patch as Record), updated_at: nowIso() }; await ctx.db.patch(row._id, next); + + // 说明:当 OCR 写回完成时,自动触发 LightRAG 入库任务(Convex jobs/actions)。 + const patch = args.patch as Record; + const prev = row as Record; + const nextOcrStatus = typeof patch.ocr_status === "string" ? patch.ocr_status : prev.ocr_status; + const nextOcrText = typeof patch.ocr_text === "string" ? patch.ocr_text : prev.ocr_text; + if (nextOcrStatus === "completed" && typeof nextOcrText === "string" && nextOcrText.trim()) { + await enqueueIngestMediaAssetJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 1200 }); + } return { ok: true }; }, }); diff --git a/wolai-frontend/convex/mindmaps.ts b/wolai-frontend/convex/mindmaps.ts index 5bc0febb..89365e49 100644 --- a/wolai-frontend/convex/mindmaps.ts +++ b/wolai-frontend/convex/mindmaps.ts @@ -1,6 +1,7 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { nowIso } from "./_utils/time"; +import { enqueueIngestMindmapJob } from "./_utils/ingestJobs"; const defaultMindmapData = { data: { text: "中心主题" }, @@ -101,6 +102,7 @@ export const put = mutation({ deleted_at: null, deleted_by: null, }); + await enqueueIngestMindmapJob(ctx, { userId: args.userId, docId: args.docId, mindmapId, debounceMs: 1500 }); return { ok: true, created: false, skipped: false, updated_at: ts }; } @@ -117,6 +119,7 @@ export const put = mutation({ deleted_by: null, }); + await enqueueIngestMindmapJob(ctx, { userId: args.userId, docId: args.docId, mindmapId, debounceMs: 1500 }); return { ok: true, created: true, skipped: false, updated_at: ts }; }, }); @@ -291,4 +294,3 @@ export const emptyTrashByWorkspace = mutation({ return { ok: true, deletedCount: toDelete.length }; }, }); - diff --git a/wolai-frontend/convex/schema.ts b/wolai-frontend/convex/schema.ts index 08c91ab3..68596d26 100644 --- a/wolai-frontend/convex/schema.ts +++ b/wolai-frontend/convex/schema.ts @@ -1,11 +1,15 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +import { authTables } from "@convex-dev/auth/server"; // 说明: -// - 这里先按“兼容现有 Next API 返回结构”的思路设计字段:id/workspace_id/user_id 等命名保持与 Supabase 一致。 +// - 这里先按"兼容现有 Next API 返回结构"的思路设计字段:id/workspace_id/user_id 等命名保持与 Supabase 一致。 // - Convex 自带的 _id 仍然存在,但我们暂时不把它暴露给上层业务,便于逐步迁移与回退。 +// - M8: 集成 Convex Auth,authTables 提供用户认证所需的数据表 export default defineSchema({ + // Convex Auth 表(用户、账户、会话等) + ...authTables, workspaces: defineTable({ id: v.string(), name: v.string(), @@ -187,4 +191,50 @@ export default defineSchema({ .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"]), + + // M5:在线表格(替代 Supabase document_tables) + document_tables: defineTable({ + id: v.string(), + workspace_id: v.string(), + document_id: v.string(), + grid_key: v.string(), + title: v.string(), + schema: v.any(), // TableSchema: { columns, frozenRowCount, frozenColCount } + view_preferences: v.any(), + snapshot: v.optional(v.any()), // DocumentTableSnapshot: { rows?, luckysheet? } + is_archived: v.boolean(), + last_synced_at: v.optional(v.string()), + created_by: v.string(), + updated_by: v.optional(v.string()), + created_at: v.string(), + updated_at: v.string(), + }) + .index("by_table_id", ["id"]) + .index("by_grid_key", ["grid_key"]) + .index("by_document", ["document_id"]) + .index("by_workspace", ["workspace_id"]) + .index("by_workspace_archived", ["workspace_id", "is_archived"]), + + // M5:在线表格行数据(替代 Supabase document_table_rows) + document_table_rows: defineTable({ + id: v.string(), + workspace_id: v.string(), + document_id: v.string(), + table_id: v.string(), + row_index: v.number(), + row_data: v.any(), + row_hash: v.optional(v.string()), + is_deleted: v.boolean(), + updated_by: v.optional(v.string()), + created_at: v.string(), + updated_at: v.string(), + }) + .index("by_row_id", ["id"]) + .index("by_table", ["table_id"]) + .index("by_table_row", ["table_id", "row_index"]) + .index("by_workspace", ["workspace_id"]) + .searchIndex("by_table_row_full", { + searchField: "row_hash", + filterFields: ["table_id", "is_deleted"], + }), }); diff --git a/wolai-frontend/convex/tables.ts b/wolai-frontend/convex/tables.ts new file mode 100644 index 00000000..230311fd --- /dev/null +++ b/wolai-frontend/convex/tables.ts @@ -0,0 +1,289 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { nowIso } from "./_utils/time"; +import { generateId } from "./_utils/id"; + +// Query: 获取单个表格 +export const get = query({ + args: { userId: v.string(), tableId: v.string() }, + handler: async (ctx, args) => { + const table = await ctx.db + .query("document_tables") + .withIndex("by_table_id", (q) => q.eq("id", args.tableId)) + .first(); + if (!table) return null; + if (table.workspace_id) { + // 验证用户是否在工作区中(简化版:假设 userId 有效) + // TODO: 添加 workspace_members 验证 + } + return { + id: table.id, + workspace_id: table.workspace_id, + document_id: table.document_id, + grid_key: table.grid_key, + title: table.title, + schema: table.schema, + view_preferences: table.view_preferences, + snapshot: table.snapshot ?? null, + is_archived: table.is_archived, + last_synced_at: table.last_synced_at ?? null, + created_by: table.created_by, + updated_by: table.updated_by ?? null, + created_at: table.created_at, + updated_at: table.updated_at, + }; + }, +}); + +// Query: 通过 gridKey 获取表格(用于 Luckysheet get-workerbook) +export const getByGridKey = query({ + args: { gridKey: v.string() }, + handler: async (ctx, args) => { + const table = await ctx.db + .query("document_tables") + .withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey)) + .first(); + if (!table) return null; + + // 返回 Luckysheet 需要的格式 + return { + title: table.title, + gridKey: table.grid_key, + lang: "zh", + }; + }, +}); + +// Query: 通过 gridKey 获取完整表格数据(用于 Luckysheet load) +export const getByGridKeyFull = query({ + args: { gridKey: v.string() }, + handler: async (ctx, args) => { + const table = await ctx.db + .query("document_tables") + .withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey)) + .first(); + if (!table) return null; + + // 返回完整的表格数据,包括 schema 和 snapshot + return { + id: table.id, + grid_key: table.grid_key, + title: table.title, + schema: table.schema, + snapshot: table.snapshot ?? null, + }; + }, +}); + +// Query: 列出文档的所有表格 +export const listByDocument = query({ + args: { userId: v.string(), documentId: v.string() }, + handler: async (ctx, args) => { + const tables = await ctx.db + .query("document_tables") + .withIndex("by_document", (q) => q.eq("document_id", args.documentId)) + .collect(); + + return tables + .filter((t) => !t.is_archived) + .map((t) => ({ + id: t.id, + document_id: t.document_id, + grid_key: t.grid_key, + title: t.title, + schema: t.schema, + view_preferences: t.view_preferences, + snapshot: t.snapshot ?? null, + is_archived: t.is_archived, + last_synced_at: t.last_synced_at ?? null, + created_at: t.created_at, + updated_at: t.updated_at, + })); + }, +}); + +// Mutation: 创建表格 +export const create = mutation({ + args: { + userId: v.string(), + workspaceId: v.string(), + documentId: v.string(), + title: v.optional(v.string()), + schema: v.optional(v.any()), + snapshot: v.optional(v.any()), + }, + handler: async (ctx, args) => { + const now = nowIso(); + const tableId = generateId(); + const gridKey = generateId(); // 用于 WebSocket 协同标识 + + const table = { + id: tableId, + workspace_id: args.workspaceId, + document_id: args.documentId, + grid_key: gridKey, + title: args.title ?? "未命名表格", + schema: args.schema ?? {}, + view_preferences: {}, + snapshot: args.snapshot ?? null, + is_archived: false, + last_synced_at: now, + created_by: args.userId, + updated_by: args.userId, + created_at: now, + updated_at: now, + }; + + await ctx.db.insert("document_tables", table); + + return { + id: table.id, + grid_key: table.grid_key, + title: table.title, + schema: table.schema, + snapshot: table.snapshot, + }; + }, +}); + +// Mutation: 更新表格 +export const update = mutation({ + args: { + userId: v.string(), + tableId: v.string(), + title: v.optional(v.string()), + schema: v.optional(v.any()), + view_preferences: v.optional(v.any()), + snapshot: v.optional(v.any()), + rows: v.optional(v.any()), + }, + handler: async (ctx, args) => { + const table = await ctx.db + .query("document_tables") + .withIndex("by_table_id", (q) => q.eq("id", args.tableId)) + .first(); + if (!table) { + throw new Error("Table not found"); + } + + const now = nowIso(); + const updates: any = { + updated_at: now, + updated_by: args.userId, + last_synced_at: now, + }; + + if (args.title !== undefined) updates.title = args.title; + if (args.schema !== undefined) updates.schema = args.schema; + if (args.view_preferences !== undefined) updates.view_preferences = args.view_preferences; + if (args.snapshot !== undefined) updates.snapshot = args.snapshot; + + await ctx.db.patch(table._id, updates); + + // 如果提供了 rows,更新行数据 + if (args.rows && Array.isArray(args.rows)) { + // 先删除旧行 + const existingRows = await ctx.db + .query("document_table_rows") + .withIndex("by_table", (q) => q.eq("table_id", args.tableId)) + .collect(); + + for (const row of existingRows) { + await ctx.db.delete(row._id); + } + + // 插入新行 + for (const [index, rowData] of args.rows.entries()) { + await ctx.db.insert("document_table_rows", { + id: generateId(), + workspace_id: table.workspace_id, + document_id: table.document_id, + table_id: args.tableId, + row_index: index, + row_data: rowData, + row_hash: JSON.stringify(rowData), + is_deleted: false, + updated_by: args.userId, + created_at: now, + updated_at: now, + }); + } + } + + return { + id: table.id, + title: updates.title ?? table.title, + schema: updates.schema ?? table.schema, + view_preferences: updates.view_preferences ?? table.view_preferences, + snapshot: updates.snapshot ?? table.snapshot, + updated_at: now, + }; + }, +}); + +// Mutation: 删除表格(软删除) +export const remove = mutation({ + args: { userId: v.string(), tableId: v.string() }, + handler: async (ctx, args) => { + const table = await ctx.db + .query("document_tables") + .withIndex("by_table_id", (q) => q.eq("id", args.tableId)) + .first(); + if (!table) { + throw new Error("Table not found"); + } + + await ctx.db.patch(table._id, { + is_archived: true, + updated_at: nowIso(), + updated_by: args.userId, + }); + + return { success: true }; + }, +}); + +// Mutation: 永久删除表格 +export const purge = mutation({ + args: { userId: v.string(), tableId: v.string() }, + handler: async (ctx, args) => { + const table = await ctx.db + .query("document_tables") + .withIndex("by_table_id", (q) => q.eq("id", args.tableId)) + .first(); + if (!table) { + throw new Error("Table not found"); + } + + // 删除关联的行 + const rows = await ctx.db + .query("document_table_rows") + .withIndex("by_table", (q) => q.eq("table_id", args.tableId)) + .collect(); + + for (const row of rows) { + await ctx.db.delete(row._id); + } + + // 删除表格 + await ctx.db.delete(table._id); + + return { success: true }; + }, +}); + +// Query: 获取表格行数据 +export const getRows = query({ + args: { tableId: v.string() }, + handler: async (ctx, args) => { + const rows = await ctx.db + .query("document_table_rows") + .withIndex("by_table", (q) => q.eq("table_id", args.tableId)) + .collect(); + + return rows + .filter((r) => !r.is_deleted) + .sort((a, b) => a.row_index - b.row_index) + .map((r) => r.row_data); + }, +}); diff --git a/wolai-frontend/convex/users.ts b/wolai-frontend/convex/users.ts new file mode 100644 index 00000000..e48ae110 --- /dev/null +++ b/wolai-frontend/convex/users.ts @@ -0,0 +1,17 @@ +import { query } from "./_generated/server"; +import { getAuthUserId } from "@convex-dev/auth/server"; + +/** + * 获取当前登录用户 + */ +export const currentUser = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (userId === null) { + return null; + } + + return await ctx.db.get(userId); + }, +}); diff --git a/wolai-frontend/package.json b/wolai-frontend/package.json index 3c4f33ac..2bd7dfe5 100644 --- a/wolai-frontend/package.json +++ b/wolai-frontend/package.json @@ -10,9 +10,11 @@ "test": "vitest" }, "dependencies": { + "@auth/core": "0.37.0", "@blocknote/core": "^0.42.0", "@blocknote/mantine": "0.42.0", "@blocknote/react": "^0.42.0", + "@convex-dev/auth": "^0.0.90", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -65,6 +67,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.0.3", + "jose": "^6.1.3", "jsdom": "^27.2.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", diff --git a/wolai-frontend/pnpm-lock.yaml b/wolai-frontend/pnpm-lock.yaml index 7e3da9e5..e89cb808 100644 --- a/wolai-frontend/pnpm-lock.yaml +++ b/wolai-frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@auth/core': + specifier: 0.37.0 + version: 0.37.0 '@blocknote/core': specifier: ^0.42.0 version: 0.42.0(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4) @@ -17,6 +20,9 @@ importers: '@blocknote/react': specifier: ^0.42.0 version: 0.42.0(@floating-ui/dom@1.7.4)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@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-dev/auth': + specifier: ^0.0.90 + version: 0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -168,6 +174,9 @@ importers: eslint-config-next: specifier: 16.0.3 version: 16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + jose: + specifier: ^6.1.3 + version: 6.1.3 jsdom: specifier: ^27.2.0 version: 27.2.0 @@ -202,6 +211,20 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@auth/core@0.37.0': + resolution: {integrity: sha512-LybAgfFC5dta3Mu3al0UbnzMGVBpZRqLMvvXupQOfETtPNlL7rXgTO13EVRTCdvPqMQrVYjODUDvgVfQM1M3Qg==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -382,6 +405,17 @@ packages: cpu: [x64] os: [win32] + '@convex-dev/auth@0.0.90': + resolution: {integrity: sha512-aqw88EB042HvnaF4wcf/f/wTocmT2Bus2VDQRuV79cM0+8kORM0ICK/ByZ6XsHgQ9qr6TmidNbXm6QAgndrdpQ==} + hasBin: true + peerDependencies: + '@auth/core': ^0.37.0 + convex: ^1.17.0 + react: ^18.2.0 || ^19.0.0-0 + peerDependenciesMeta: + react: + optional: true + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1356,6 +1390,21 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + + '@oslojs/crypto@1.0.1': + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@pdf-lib/standard-fonts@1.0.0': resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} @@ -2191,6 +2240,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} @@ -2748,6 +2800,10 @@ packages: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -3611,6 +3667,10 @@ packages: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} + is-network-error@1.3.0: + resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==} + engines: {node: '>=16'} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -3685,6 +3745,12 @@ packages: jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3731,6 +3797,10 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + katex@0.16.27: resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} hasBin: true @@ -3868,6 +3938,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucia@3.2.2: + resolution: {integrity: sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA==} + deprecated: This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate. + lucide-react@0.554.0: resolution: {integrity: sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==} peerDependencies: @@ -4170,6 +4244,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + oauth4webapi@3.8.3: + resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -4293,6 +4370,14 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@5.2.3: + resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} + peerDependencies: + preact: '>=10' + + preact@10.11.3: + resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4302,6 +4387,9 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@3.8.0: + resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} @@ -4647,6 +4735,9 @@ packages: engines: {node: '>=10'} hasBin: true + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -5402,6 +5493,16 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@auth/core@0.37.0': + dependencies: + '@panva/hkdf': 1.2.1 + '@types/cookie': 0.6.0 + cookie: 0.7.1 + jose: 5.10.0 + oauth4webapi: 3.8.3 + preact: 10.11.3 + preact-render-to-string: 5.2.3(preact@10.11.3) + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5665,6 +5766,23 @@ snapshots: '@cloudflare/workerd-windows-64@1.20251210.0': optional: true + '@convex-dev/auth@0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0)': + dependencies: + '@auth/core': 0.37.0 + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + convex: 1.31.4(react@19.2.0) + cookie: 1.1.1 + is-network-error: 1.3.0 + jose: 5.10.0 + jwt-decode: 4.0.0 + lucia: 3.2.2 + oauth4webapi: 3.8.3 + path-to-regexp: 6.3.0 + server-only: 0.0.1 + optionalDependencies: + react: 19.2.0 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -6345,6 +6463,21 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/binary@1.0.0': {} + + '@oslojs/crypto@1.0.1': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/encoding@1.1.0': {} + + '@panva/hkdf@1.2.1': {} + '@pdf-lib/standard-fonts@1.0.0': dependencies: pako: 1.0.11 @@ -7124,6 +7257,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cookie@0.6.0': {} + '@types/d3-color@3.1.3': {} '@types/d3-drag@3.0.7': @@ -7704,6 +7839,8 @@ snapshots: cookie@0.5.0: {} + cookie@0.7.1: {} + cookie@0.7.2: {} cookie@1.1.1: {} @@ -8804,6 +8941,8 @@ snapshots: is-negative-zero@2.0.3: {} + is-network-error@1.3.0: {} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -8875,6 +9014,10 @@ snapshots: jose@4.15.9: {} + jose@5.10.0: {} + + jose@6.1.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -8936,6 +9079,8 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + jwt-decode@4.0.0: {} + katex@0.16.27: dependencies: commander: 8.3.0 @@ -9044,6 +9189,11 @@ snapshots: dependencies: yallist: 3.1.1 + lucia@3.2.2: + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + lucide-react@0.554.0(react@19.2.0): dependencies: react: 19.2.0 @@ -9626,6 +9776,8 @@ snapshots: normalize-path@3.0.0: {} + oauth4webapi@3.8.3: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -9759,10 +9911,19 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@5.2.3(preact@10.11.3): + dependencies: + preact: 10.11.3 + pretty-format: 3.8.0 + + preact@10.11.3: {} + prelude-ls@1.2.1: {} prettier@3.8.0: {} + pretty-format@3.8.0: {} + printable-characters@1.0.42: {} process-nextick-args@2.0.1: {} @@ -10189,6 +10350,8 @@ snapshots: semver@7.7.3: {} + server-only@0.0.1: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: diff --git a/wolai-frontend/scripts/fix-auth-env.js b/wolai-frontend/scripts/fix-auth-env.js new file mode 100644 index 00000000..92c873fa --- /dev/null +++ b/wolai-frontend/scripts/fix-auth-env.js @@ -0,0 +1,79 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +function setConvexEnv(name, value) { + // 说明:使用 stdin 传值,避免以 `-----BEGIN ...` 开头的内容被 CLI 误当作参数/选项。 + execSync(`npx convex env set ${name}`, { + input: String(value ?? ""), + stdio: ['pipe', 'inherit', 'inherit'], + cwd: process.cwd(), + }); +} + +const privateKey = `-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCPcrWDwjh74nu1 +gfYo9ywVIRMedfPwbNiDjHnovzHAIeToc1+Lw3ju/io9OME3KLWzIibUHX39LilK +sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d +/CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU +6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9 +/5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX +jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq +UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1 +yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN +3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwYx3gZA39sa8 +qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n +EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm +zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7 +GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK +dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb +oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO +0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8 +EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP +sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl +IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN +2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR +BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA +iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru +3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh +9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq +AT5PRKihrWbg63/rXaSCxw== +-----END PRIVATE KEY-----`; + +const jwks = '{"keys":[{"use":"sig","kty":"RSA","n":"j3K1g8I4e-J7tYH2KPcsFSETHnXz8GzYg4x56L8xwCHk6HNfi8N47v4qPTjBNyi1syIm1B19_S4pSrEkCooCd_ouyddyKriQ_vqM2PQvA6fz9-LcVWPhAkkEXSAWxEyycWAVlzsemLMP3fwlLgznRMNiITbzfe7E4eY1GXun_x-pnwqSd1-Uma6qyFE0y-q-C4_WUtz1F-Yr1OrHdq186Q7FEIKzYJrxjd8PZwB7ncQFL_8zhmuPNNmfs9eCwgSF-r-jyCu_WEOLPf-YR-9zHrAOr4M1koSXMlct4LsnXvnboaRAqNat3enSGXqq2s0MKgFt73KZhBSXV45KaHIg3w","e":"AQAB"}]}'; + +// 使用临时目录(跨平台兼容) +const tmpDir = process.env.TMPDIR || process.env.TEMP || '/tmp'; +const keyFile = path.join(tmpDir, 'convex_jwt_key.txt'); +const jwksFile = path.join(tmpDir, 'convex_jwks.txt'); + +// 写入临时文件 +fs.writeFileSync(keyFile, privateKey); +fs.writeFileSync(jwksFile, jwks); + +console.log('Setting JWT_PRIVATE_KEY from file...'); +const keyContent = fs.readFileSync(keyFile, 'utf8'); +try { + setConvexEnv("JWT_PRIVATE_KEY", keyContent); + console.log('✓ JWT_PRIVATE_KEY set'); +} catch (e) { + console.error('✗ Failed to set JWT_PRIVATE_KEY:', e.message); +} + +console.log('Setting JWKS...'); +try { + setConvexEnv("JWKS", jwks); + console.log('✓ JWKS set'); +} catch (e) { + console.error('✗ Failed to set JWKS:', e.message); +} + +// 清理临时文件 +try { + fs.unlinkSync(keyFile); + fs.unlinkSync(jwksFile); +} catch (e) { + // 忽略清理错误 +} + +console.log('Done! You can now test registration.'); diff --git a/wolai-frontend/scripts/generateKeys.mjs b/wolai-frontend/scripts/generateKeys.mjs new file mode 100644 index 00000000..1dc012b6 --- /dev/null +++ b/wolai-frontend/scripts/generateKeys.mjs @@ -0,0 +1,13 @@ +import { exportJWK, exportPKCS8, generateKeyPair } from "jose"; + +const keys = await generateKeyPair("RS256", { extractable: true }); +const privateKey = await exportPKCS8(keys.privateKey); +const publicKey = await exportJWK(keys.publicKey); +const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] }); + +process.stdout.write( + `JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, " ")}"`, +); +process.stdout.write("\n"); +process.stdout.write(`JWKS=${jwks}`); +process.stdout.write("\n"); diff --git a/wolai-frontend/scripts/register-test-user.js b/wolai-frontend/scripts/register-test-user.js new file mode 100644 index 00000000..ba630ade --- /dev/null +++ b/wolai-frontend/scripts/register-test-user.js @@ -0,0 +1,60 @@ +/** + * 注册测试账号脚本 + * + * 用途:快速注册测试账号,方便开发和测试 + * 运行:node scripts/register-test-user.js + */ + +const TEST_CREDENTIALS = { + email: "test@example.com", + password: "Test123456", + name: "测试用户", +}; + +async function registerTestUser() { + const baseUrl = "http://localhost:3000"; + + console.log("正在注册测试账号..."); + console.log(`邮箱: ${TEST_CREDENTIALS.email}`); + console.log(`密码: ${TEST_CREDENTIALS.password}`); + + try { + const response = await fetch(`${baseUrl}/api/auth/signin`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: TEST_CREDENTIALS.email, + password: TEST_CREDENTIALS.password, + name: TEST_CREDENTIALS.name, + flow: "signUp", + }), + }); + + const result = await response.json(); + + if (response.ok) { + console.log("✓ 测试账号注册成功!"); + console.log(`\n您现在可以使用以下凭据登录:`); + console.log(` 邮箱: ${TEST_CREDENTIALS.email}`); + console.log(` 密码: ${TEST_CREDENTIALS.password}`); + console.log(`\n或在登录页面点击"测试账号快速登录"按钮。`); + } else if (response.status === 501) { + console.log("ℹ API 路由暂未实现,请通过浏览器手动注册:"); + console.log(` 1. 访问 http://localhost:3000/auth`); + console.log(` 2. 点击"还没有账户?立即注册"`); + console.log(` 3. 填写:`); + console.log(` 邮箱: ${TEST_CREDENTIALS.email}`); + console.log(` 密码: ${TEST_CREDENTIALS.password}`); + console.log(` 姓名: ${TEST_CREDENTIALS.name}`); + } else { + console.error("✗ 注册失败:", result.error || result.message); + } + } catch (error) { + console.error("✗ 请求失败:", error.message); + console.log("\n请确保开发服务器正在运行 (pnpm dev)"); + } +} + +registerTestUser(); diff --git a/wolai-frontend/scripts/set-auth-env.js b/wolai-frontend/scripts/set-auth-env.js new file mode 100644 index 00000000..c5a2f753 --- /dev/null +++ b/wolai-frontend/scripts/set-auth-env.js @@ -0,0 +1,57 @@ +const { execSync } = require('child_process'); + +function setConvexEnv(name, value) { + // 说明:使用 stdin 传值,避免以 `-----BEGIN ...` 开头的内容被 CLI 误当作参数/选项。 + execSync(`npx convex env set ${name}`, { + input: String(value ?? ""), + stdio: ['pipe', 'inherit', 'inherit'], + cwd: process.cwd(), + }); +} + +const privateKey = `-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCPcrWDwjh74nu1 +gfYo9ywVIRMedfPwbNiDjHnovzHAIeToc1+Lw3ju/io9OME3KLWzIibUHX39LilK +sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d +/CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU +6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9 +/5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX +jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq +UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1 +yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN +3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwYx3gZA39sa8 +qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n +EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm +zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7 +GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK +dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb +oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO +0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8 +EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP +sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl +IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN +2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR +BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA +iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru +3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh +9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq +AT5PRKihrWbg63/rXaSCxw== +-----END PRIVATE KEY-----`; + +const jwks = '{"keys":[{"use":"sig","kty":"RSA","n":"j3K1g8I4e-J7tYH2KPcsFSETHnXz8GzYg4x56L8xwCHk6HNfi8N47v4qPTjBNyi1syIm1B19_S4pSrEkCooCd_ouyddyKriQ_vqM2PQvA6fz9-LcVWPhAkkEXSAWxEyycWAVlzsemLMP3fwlLgznRMNiITbzfe7E4eY1GXun_x-pnwqSd1-Uma6qyFE0y-q-C4_WUtz1F-Yr1OrHdq186Q7FEIKzYJrxjd8PZwB7ncQFL_8zhmuPNNmfs9eCwgSF-r-jyCu_WEOLPf-YR-9zHrAOr4M1koSXMlct4LsnXvnboaRAqNat3enSGXqq2s0MKgFt73KZhBSXV45KaHIg3w","e":"AQAB"}]}'; + +console.log('Setting JWT_PRIVATE_KEY...'); +try { + setConvexEnv("JWT_PRIVATE_KEY", privateKey); +} catch (e) { + console.error('Failed to set JWT_PRIVATE_KEY:', e.message); +} + +console.log('Setting JWKS...'); +try { + setConvexEnv("JWKS", jwks); +} catch (e) { + console.error('Failed to set JWKS:', e.message); +} + +console.log('Done!'); diff --git a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx index ab4e8379..4befa4e6 100644 --- a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx +++ b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx @@ -20,7 +20,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null; if (isConvexEnabled()) { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); const client = getConvexHttpClient(); const doc = await client.query(api.documents.getMeta, { userId: auth.userId, id }); if (!doc) { diff --git a/wolai-frontend/src/app/(app)/layout.tsx b/wolai-frontend/src/app/(app)/layout.tsx index 6f911584..760699f0 100644 --- a/wolai-frontend/src/app/(app)/layout.tsx +++ b/wolai-frontend/src/app/(app)/layout.tsx @@ -66,7 +66,7 @@ function makeId(): string { export default async function AppLayout({ children }: { children: ReactNode }) { if (isConvexEnabled()) { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); const client = getConvexHttpClient(); const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, { diff --git a/wolai-frontend/src/app/(auth)/auth/page.tsx b/wolai-frontend/src/app/(auth)/auth/page.tsx new file mode 100644 index 00000000..ff917d1b --- /dev/null +++ b/wolai-frontend/src/app/(auth)/auth/page.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useConvexAuth } from "convex/react"; +import { useAuthActions } from "@convex-dev/auth/react"; +import { useState, useCallback, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { isConvexEnabled } from "@/lib/convex/enabled"; + +type AuthStep = "signIn" | "signUp"; + +// 测试账号凭据常量 +const TEST_CREDENTIALS = { + email: "test@example.com", + password: "Test123456", +} as const; + +/** + * Convex Auth 登录/注册页面 + * + * 支持功能: + * - 邮箱密码登录 + * - 邮箱密码注册 + */ +export default function AuthPage() { + const { isLoading, isAuthenticated } = useConvexAuth(); + const { signIn } = useAuthActions(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && isAuthenticated) { + router.replace("/"); + } + }, [isAuthenticated, isLoading, router]); + + const [flow, setFlow] = useState("signIn"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [name, setName] = useState(""); + const [message, setMessage] = useState<{ + type: "success" | "error" | "info"; + text: string; + } | null>(null); + + // 执行登录的核心逻辑 + const performSignIn = useCallback(async (email: string, password: string, flow: AuthStep) => { + setMessage(null); + + try { + const formData = new FormData(); + formData.append("email", email); + formData.append("password", password); + if (flow === "signUp") { + formData.append("name", name); + } + formData.append("flow", flow); + + const result = await signIn("password", formData); + + if (result.signingIn) { + setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" }); + setTimeout(() => router.push("/"), 500); + return; + } + + // 说明:部分认证方式会返回 redirect(例如 OAuth);密码模式一般不会走到这里。 + if (result.redirect) { + setMessage({ type: "info", text: "正在跳转..." }); + router.push(result.redirect.toString()); + return; + } + + setMessage({ type: "error", text: "操作失败,请重试" }); + } catch (error: any) { + setMessage({ type: "error", text: error.message || "操作失败,请重试" }); + } + }, [name, signIn, router]); + + const handleSubmit = useCallback(async (event: React.FormEvent) => { + event.preventDefault(); + await performSignIn(email, password, flow); + }, [email, password, flow, performSignIn]); + + // 检查是否启用了 Convex + const isConvex = isConvexEnabled(); + if (!isConvex) { + return ( +
+
+

认证功能不可用

+

请启用 Convex 后端后再试

+
+
+ ); + } + + if (isLoading) { + return ( +
+
+
+

加载中...

+
+
+ ); + } + + if (isAuthenticated) { + return ( +
+
+
+

正在跳转...

+
+
+ ); + } + + return ( +
+
+
+

+ {flow === "signIn" ? "登录账户" : "创建账户"} +

+

+ 使用 Convex Auth +

+
+ + {message && ( +
+

{message.text}

+
+ )} + +
+
+
+ + setEmail(e.target.value)} + className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" + placeholder="邮箱地址" + /> +
+ {flow === "signUp" && ( +
+ + setName(e.target.value)} + className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" + placeholder="姓名(可选)" + /> +
+ )} +
+ + setPassword(e.target.value)} + className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" + placeholder="密码(至少 8 位)" + /> +
+
+ +
+ +
+ + {flow === "signIn" && ( +
+ +
+ )} + +
+ +
+
+
+
+ ); +} 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 a003d103..45d7c73b 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 @@ -28,13 +28,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: "缺少 requestId/callId" }, { status: 400 }); } - const userId = (() => { - if (isConvexEnabled()) { - const auth = requireAuthContext(); - return auth.userId; - } - return null; - })(); + let userId: string | null = null; + if (isConvexEnabled()) { + const auth = await requireAuthContext(); + userId = auth.userId; + } const resolvedUserId = async () => { if (userId) return userId; 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 06bdaaa7..2fe581e9 100644 --- a/wolai-frontend/src/app/api/ai-agent/run/route.ts +++ b/wolai-frontend/src/app/api/ai-agent/run/route.ts @@ -77,7 +77,7 @@ export async function POST(request: Request) { const convexOn = isConvexEnabled(); const { userId, supabase, convexClient } = await (async () => { if (convexOn) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); return { userId: auth.userId, supabase: null as any, convexClient: client }; } const supabase = await createSupabaseRouteClient(); diff --git a/wolai-frontend/src/app/api/auth/signin/route.ts b/wolai-frontend/src/app/api/auth/signin/route.ts new file mode 100644 index 00000000..8925b718 --- /dev/null +++ b/wolai-frontend/src/app/api/auth/signin/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from "next/server"; +import { isConvexEnabled } from "@/lib/convex/enabled"; + +interface SignInRequest { + email: string; + password: string; + name?: string; + flow: "signIn" | "signUp"; +} + +/** + * POST /api/auth/signin + * + * 处理登录/注册请求 + * - 在 Convex 模式下调用 Convex mutation + * - 在 Supabase 模式下调用 Supabase Auth + */ +export async function POST(request: Request) { + // Convex 模式 + if (isConvexEnabled()) { + try { + const body = await request.json() as SignInRequest; + const { email, password, name, flow } = body; + + // TODO: 调用 Convex Auth mutation 处理登录/注册 + // 当前暂时返回未实现错误 + return NextResponse.json( + { error: "Convex Auth API 路由暂未实现,请稍后实现" }, + { status: 501 } + ); + } catch (error) { + console.error("Convex Auth error:", error); + return NextResponse.json( + { error: "服务器错误" }, + { status: 500 } + ); + } + } + + // Supabase 模式(保留兼容) + try { + const { createSupabaseRouteClient } = await import("@/lib/supabase/server"); + const supabase = await createSupabaseRouteClient(); + + const body = await request.json() as SignInRequest; + const { email, password, name, flow } = body; + + if (flow === "signUp") { + // 注册 + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + name: name ?? "", + }, + }, + }); + + if (error) { + return NextResponse.json( + { error: error.message }, + { status: 400 } + ); + } + + // 注册成功后自动登录 + const { error: signInError } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (signInError) { + return NextResponse.json( + { error: signInError.message }, + { status: 401 } + ); + } + + return NextResponse.json( + { message: "注册并登录成功" }, + { status: 200 } + ); + } else { + // 登录 + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + return NextResponse.json( + { error: error.message }, + { status: 401 } + ); + } + + return NextResponse.json( + { message: "登录成功" }, + { status: 200 } + ); + } + } catch (error) { + console.error("Supabase Auth error:", error); + return NextResponse.json( + { error: "服务器错误" }, + { status: 500 } + ); + } +} diff --git a/wolai-frontend/src/app/api/blocks/embed/route.ts b/wolai-frontend/src/app/api/blocks/embed/route.ts index 9599235d..0ec1971a 100644 --- a/wolai-frontend/src/app/api/blocks/embed/route.ts +++ b/wolai-frontend/src/app/api/blocks/embed/route.ts @@ -26,7 +26,7 @@ export async function POST(request: Request) { } if (isConvexEnabled()) { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); const client = getConvexHttpClient(); const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId }); @@ -113,4 +113,3 @@ export async function POST(request: Request) { 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 index 38542b37..def1e065 100644 --- a/wolai-frontend/src/app/api/blocks/get/route.ts +++ b/wolai-frontend/src/app/api/blocks/get/route.ts @@ -16,7 +16,7 @@ export async function GET(request: Request) { } if (isConvexEnabled()) { - const auth = requireAuthContext(); + const auth = await 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 }); @@ -46,4 +46,3 @@ export async function GET(request: Request) { 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 index ef5810ae..95d58159 100644 --- a/wolai-frontend/src/app/api/blocks/move/route.ts +++ b/wolai-frontend/src/app/api/blocks/move/route.ts @@ -26,7 +26,7 @@ export async function POST(request: Request) { } if (isConvexEnabled()) { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); const client = getConvexHttpClient(); const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId }); @@ -97,4 +97,3 @@ export async function POST(request: Request) { 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 index 91e736a2..b4828169 100644 --- a/wolai-frontend/src/app/api/blocks/patch/route.ts +++ b/wolai-frontend/src/app/api/blocks/patch/route.ts @@ -20,7 +20,7 @@ export async function POST(request: Request) { } if (isConvexEnabled()) { - const auth = requireAuthContext(); + const auth = await 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 }); @@ -62,4 +62,3 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true }); } - diff --git a/wolai-frontend/src/app/api/dev/ingest/enqueue/route.ts b/wolai-frontend/src/app/api/dev/ingest/enqueue/route.ts new file mode 100644 index 00000000..4adda016 --- /dev/null +++ b/wolai-frontend/src/app/api/dev/ingest/enqueue/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { requireAuthContext } from "@/lib/auth/authContext"; +import { getConvexHttpClient } from "@/lib/convex/server"; +import { api } from "@/lib/convex/api"; + +export const dynamic = "force-dynamic"; + +type Payload = + | { type: "document"; documentId: string } + | { type: "mindmap"; docId: string; mindmapId: string } + | { type: "media"; assetId: string }; + +export async function POST(request: Request) { + const auth = await requireAuthContext(); + const client = getConvexHttpClient(); + + const body = (await request.json().catch(() => null)) as Payload | null; + if (!body) { + return NextResponse.json({ error: "缺少请求体" }, { status: 400 }); + } + + if (body.type === "document") { + const documentId = String(body.documentId ?? "").trim(); + if (!documentId) return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); + const result = await client.mutation(api.jobs.enqueueRagIndexDocument, { userId: auth.userId, documentId }); + return NextResponse.json(result); + } + + if (body.type === "mindmap") { + const docId = String(body.docId ?? "").trim(); + const mindmapId = String(body.mindmapId ?? "").trim(); + if (!docId) return NextResponse.json({ error: "缺少 docId" }, { status: 400 }); + if (!mindmapId) return NextResponse.json({ error: "缺少 mindmapId" }, { status: 400 }); + const result = await client.mutation(api.jobs.enqueueRagIndexMindmap, { userId: auth.userId, docId, mindmapId }); + return NextResponse.json(result); + } + + if (body.type === "media") { + const assetId = String(body.assetId ?? "").trim(); + if (!assetId) return NextResponse.json({ error: "缺少 assetId" }, { status: 400 }); + const result = await client.mutation(api.jobs.enqueueRagIndexMediaAsset, { userId: auth.userId, assetId }); + return NextResponse.json(result); + } + + return NextResponse.json({ error: "不支持的 type" }, { status: 400 }); +} diff --git a/wolai-frontend/src/app/api/dev/jobs/demo/route.ts b/wolai-frontend/src/app/api/dev/jobs/demo/route.ts index 9454e2af..00dc962c 100644 --- a/wolai-frontend/src/app/api/dev/jobs/demo/route.ts +++ b/wolai-frontend/src/app/api/dev/jobs/demo/route.ts @@ -7,7 +7,7 @@ import { api } from "@/lib/convex/api"; export const dynamic = "force-dynamic"; export async function POST(request: Request) { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); const client = getConvexHttpClient(); const { ms }: { ms?: number } = await request.json().catch(() => ({})); @@ -23,7 +23,7 @@ export async function POST(request: Request) { } export async function GET(request: Request) { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); const client = getConvexHttpClient(); const url = new URL(request.url); @@ -39,4 +39,3 @@ export async function GET(request: Request) { 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 index d5bfab1b..76bee864 100644 --- a/wolai-frontend/src/app/api/dev/whoami/route.ts +++ b/wolai-frontend/src/app/api/dev/whoami/route.ts @@ -6,7 +6,7 @@ export const dynamic = "force-dynamic"; export async function GET() { try { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); return NextResponse.json({ ok: true, auth }, { status: 200 }); } catch (err) { const status = err instanceof HttpError ? err.status : 500; diff --git a/wolai-frontend/src/app/api/documents/content/route.ts b/wolai-frontend/src/app/api/documents/content/route.ts index 0cd81235..e08ae025 100644 --- a/wolai-frontend/src/app/api/documents/content/route.ts +++ b/wolai-frontend/src/app/api/documents/content/route.ts @@ -9,7 +9,7 @@ export const dynamic = "force-dynamic"; export async function GET(request: Request) { if (isConvexEnabled()) { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); const url = new URL(request.url); const documentId = url.searchParams.get("documentId") ?? ""; 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 7b363e6c..a6d18982 100644 --- a/wolai-frontend/src/app/api/documents/copy-tree/route.ts +++ b/wolai-frontend/src/app/api/documents/copy-tree/route.ts @@ -163,7 +163,7 @@ function replaceAssetRefsInContent(content: Json | null, assetMap: Map { export async function POST(request: Request) { if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({})); if (!workspaceId) { return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); 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 dac31988..717242c3 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 @@ -24,7 +24,7 @@ export async function POST( const { docId, mindmapId } = await params; if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const payload = (await request.json().catch(() => null)) as RequestPayload | null; const ops = Array.isArray(payload?.ops) ? payload!.ops : []; 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 b12d08a2..3d05143a 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts @@ -117,7 +117,7 @@ export async function GET( const { docId, mindmapId } = await params; if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const res = await client.query(api.mindmaps.get, { userId: auth.userId, docId, @@ -171,7 +171,7 @@ export async function POST( const { docId, mindmapId } = await params; if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as { data?: unknown; createOnly?: boolean; @@ -241,7 +241,7 @@ export async function DELETE( const { docId, mindmapId } = await params; if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); try { const result = await client.mutation(api.mindmaps.softDelete, { userId: auth.userId, @@ -316,7 +316,7 @@ export async function PATCH( const { docId, mindmapId } = await params; if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const { action } = (await request.json().catch(() => ({}))) as { action?: string }; if (action !== "restore" && action !== "purge") { return NextResponse.json({ error: "不支持的操作" }, { status: 400 }); diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/route.ts index ded75cb4..907ced43 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/route.ts @@ -45,7 +45,7 @@ export async function GET( const { docId: id } = await params; if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const mindmapId = `legacy-${id}`; const res = await client.query(api.mindmaps.get, { userId: auth.userId, @@ -106,7 +106,7 @@ export async function POST( const { docId: id } = await params; if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const mindmapId = `legacy-${id}`; const { data } = await request.json(); const result = await client.mutation(api.mindmaps.put, { @@ -161,7 +161,7 @@ export async function DELETE( const { docId: id } = await params; if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const mindmapId = `legacy-${id}`; const result = await client.mutation(api.mindmaps.softDelete, { userId: auth.userId, diff --git a/wolai-frontend/src/app/api/references/backlinks/route.ts b/wolai-frontend/src/app/api/references/backlinks/route.ts index a6636ecf..7ab4ac20 100644 --- a/wolai-frontend/src/app/api/references/backlinks/route.ts +++ b/wolai-frontend/src/app/api/references/backlinks/route.ts @@ -6,7 +6,7 @@ import { api } from "@/lib/convex/api"; export async function GET(request: Request) { if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const { searchParams } = new URL(request.url); const workspaceId = searchParams.get("workspaceId"); const pageId = searchParams.get("pageId"); diff --git a/wolai-frontend/src/app/api/references/record/route.ts b/wolai-frontend/src/app/api/references/record/route.ts index 46c121c3..6a79f46f 100644 --- a/wolai-frontend/src/app/api/references/record/route.ts +++ b/wolai-frontend/src/app/api/references/record/route.ts @@ -20,7 +20,7 @@ const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inli export async function POST(request: Request) { if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const body = (await request.json()) as RecordReferencePayload; if (!body?.workspaceId || !body?.sourcePageId || !body?.targetPageId) { return NextResponse.json({ error: "缺少必要参数" }, { status: 400 }); diff --git a/wolai-frontend/src/app/api/search/documents/route.ts b/wolai-frontend/src/app/api/search/documents/route.ts index cb81f655..90d7072f 100644 --- a/wolai-frontend/src/app/api/search/documents/route.ts +++ b/wolai-frontend/src/app/api/search/documents/route.ts @@ -148,7 +148,7 @@ const fetchOcrMatches = async ( export async function POST(request: Request) { if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const payload = (await request.json()) as DocumentSearchRequest; const workspaceId = payload.workspaceId; diff --git a/wolai-frontend/src/app/api/search/recent/route.ts b/wolai-frontend/src/app/api/search/recent/route.ts index 2d784cee..e87734fd 100644 --- a/wolai-frontend/src/app/api/search/recent/route.ts +++ b/wolai-frontend/src/app/api/search/recent/route.ts @@ -11,7 +11,7 @@ interface RecentPayload { export async function POST(request: Request) { if (isConvexEnabled()) { - const { auth, client } = getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const { workspaceId, documentId }: RecentPayload = await request.json(); if (!workspaceId || !documentId) { diff --git a/wolai-frontend/src/app/api/sidebar/route.ts b/wolai-frontend/src/app/api/sidebar/route.ts index 89b66684..72360823 100644 --- a/wolai-frontend/src/app/api/sidebar/route.ts +++ b/wolai-frontend/src/app/api/sidebar/route.ts @@ -72,7 +72,7 @@ function extractMindmapImageAssetIdsFromData(input: unknown): string[] { export async function GET(request: Request) { if (isConvexEnabled()) { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); const client = getConvexHttpClient(); const url = new URL(request.url); diff --git a/wolai-frontend/src/app/api/tables/[tableId]/route.ts b/wolai-frontend/src/app/api/tables/[tableId]/route.ts index 0fa380cd..96a21e48 100644 --- a/wolai-frontend/src/app/api/tables/[tableId]/route.ts +++ b/wolai-frontend/src/app/api/tables/[tableId]/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; import type { DocumentTableSnapshot, TableRowData } from "@/types/online-table"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; interface RouteContext { params: Promise<{ @@ -35,19 +38,38 @@ const getAuthUser = async () => { }; export async function GET(request: Request, context: RouteContext) { + const tableId = await extractTableId(context); + if (!tableId) { + return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); + } + + // Convex 模式 + if (isConvexEnabled()) { + const { auth, client } = await getAuthedConvexClient(); + try { + const table = await client.query(api.tables.get, { + userId: auth.userId, + tableId, + }); + + if (!table) { + return NextResponse.json({ error: "Table not found" }, { status: 404 }); + } + + return NextResponse.json(table, { status: 200 }); + } catch (error) { + console.error("Convex API error:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } + } + + // Supabase 模式 const { supabase, user } = await getAuthUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // 强制 await params,以解决 Next.js 16/Turbopack 错误。 - const tableId = await extractTableId(context); - - if (!tableId) { - return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); - } - try { // 假设 RLS 确保了用户只能访问其有权限的表格 const { data: tableData, error } = await supabase @@ -74,17 +96,47 @@ export async function GET(request: Request, context: RouteContext) { } export async function PATCH(request: Request, context: RouteContext) { + const tableId = await extractTableId(context); + if (!tableId) { + return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); + } + + // Convex 模式 + if (isConvexEnabled()) { + const { auth, client } = await getAuthedConvexClient(); + try { + const body = await request.json() as UpdateTableRequest; + + const result = await client.mutation(api.tables.update, { + userId: auth.userId, + tableId, + title: body.title, + schema: body.schema, + view_preferences: body.viewPreferences, + snapshot: body.snapshot, + rows: body.rows, + }); + + // 重新获取更新后的表格数据 + const updated = await client.query(api.tables.get, { + userId: auth.userId, + tableId, + }); + + return NextResponse.json(updated, { status: 200 }); + } catch (error) { + console.error("Convex API error:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } + } + + // Supabase 模式 const { supabase, user } = await getAuthUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const tableId = await extractTableId(context); - if (!tableId) { - return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); - } - let body: UpdateTableRequest | null = null; try { body = await request.json() as UpdateTableRequest; @@ -191,17 +243,34 @@ export async function PATCH(request: Request, context: RouteContext) { } export async function DELETE(request: Request, context: RouteContext) { + const tableId = await extractTableId(context); + if (!tableId) { + return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); + } + + // Convex 模式 + if (isConvexEnabled()) { + const { auth, client } = await getAuthedConvexClient(); + try { + await client.mutation(api.tables.purge, { + userId: auth.userId, + tableId, + }); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + console.error("Convex API error:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } + } + + // Supabase 模式 const { supabase, user } = await getAuthUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const tableId = await extractTableId(context); - if (!tableId) { - return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); - } - try { const { error: deleteRowsError } = await supabase .from("document_table_rows") diff --git a/wolai-frontend/src/app/api/tables/create/route.ts b/wolai-frontend/src/app/api/tables/create/route.ts index 2c309728..823589c8 100644 --- a/wolai-frontend/src/app/api/tables/create/route.ts +++ b/wolai-frontend/src/app/api/tables/create/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { DocumentTableSnapshot, TableSchema } from "@/types/online-table"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { getAuthedConvexClient } from "@/lib/convex/route"; +import { api } from "@/lib/convex/api"; // 定义请求体类型 interface CreateTableRequestBody { @@ -11,6 +14,46 @@ interface CreateTableRequestBody { } export async function POST(request: Request) { + // Convex 模式 + if (isConvexEnabled()) { + const { auth, client } = await getAuthedConvexClient(); + + try { + const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody; + + if (!documentId || !title || !schema) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + + // 获取 document 所在的 workspace_id + const document = await client.query(api.documents.getMeta, { + userId: auth.userId, + id: documentId, + }); + + if (!document) { + return NextResponse.json({ error: "Document not found" }, { status: 404 }); + } + + // 创建表格 + const result = await client.mutation(api.tables.create, { + userId: auth.userId, + workspaceId: document.workspace_id, + documentId, + title, + schema, + snapshot, + }); + + return NextResponse.json(result, { status: 201 }); + + } catch (error) { + console.error("Convex API error:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } + } + + // Supabase 模式 const supabase = await createSupabaseRouteClient(); const { data: { user } } = await supabase.auth.getUser(); diff --git a/wolai-frontend/src/app/api/workspaces/switch/route.ts b/wolai-frontend/src/app/api/workspaces/switch/route.ts index 4b79fd7d..5b8637f1 100644 --- a/wolai-frontend/src/app/api/workspaces/switch/route.ts +++ b/wolai-frontend/src/app/api/workspaces/switch/route.ts @@ -9,7 +9,7 @@ export const dynamic = "force-dynamic"; export async function POST(request: Request) { if (isConvexEnabled()) { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); const payload = await request.json().catch(() => ({})); const workspaceId = payload.workspaceId as string | undefined; if (!workspaceId) { diff --git a/wolai-frontend/src/app/layout.tsx b/wolai-frontend/src/app/layout.tsx index f98cb430..ebb167e2 100644 --- a/wolai-frontend/src/app/layout.tsx +++ b/wolai-frontend/src/app/layout.tsx @@ -3,9 +3,11 @@ import { Inter } from "next/font/google"; import "./globals.css"; import { SupabaseProvider } from "@/components/providers/supabase-provider"; import { QueryProvider } from "@/components/providers/query-provider"; +import { ConvexClientProvider } from "@/components/providers/convex-provider"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { isConvexEnabled } from "@/lib/convex/enabled"; +import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server"; const inter = Inter({ subsets: ["latin"], @@ -51,7 +53,11 @@ export default async function RootLayout({ {useConvex ? ( - {children} + + + {children} + + ) : ( {children} diff --git a/wolai-frontend/src/app/page.tsx b/wolai-frontend/src/app/page.tsx index 6ff13e27..3e336438 100644 --- a/wolai-frontend/src/app/page.tsx +++ b/wolai-frontend/src/app/page.tsx @@ -14,7 +14,7 @@ function makeId(): string { export default async function Home() { if (isConvexEnabled()) { - const auth = requireAuthContext(); + const auth = await requireAuthContext(); const client = getConvexHttpClient(); const { activeWorkspaceId } = await client.mutation(api.workspaces.ensureDefaultWorkspace, { diff --git a/wolai-frontend/src/components/providers/convex-provider.tsx b/wolai-frontend/src/components/providers/convex-provider.tsx new file mode 100644 index 00000000..452e9a09 --- /dev/null +++ b/wolai-frontend/src/components/providers/convex-provider.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ReactNode, useState } from "react"; +import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs"; +import { ConvexReactClient } from "convex/react"; + +interface ConvexClientProviderProps { + children: ReactNode; +} + +/** + * Convex 客户端 Provider(包含 Auth) + */ +export function ConvexClientProvider({ children }: ConvexClientProviderProps) { + const [convex] = useState(() => { + // 动态获取 Convex URL,默认优先使用环境变量。 + // 说明:浏览器侧优先用当前 hostname(端口固定 3210),避免通过非 localhost/127 访问前端时出现跨域/白名单不匹配。 + const getConvexUrl = () => { + // 默认使用环境变量 + const envUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + if (typeof window === "undefined") { + return envUrl || "http://127.0.0.1:3210"; + } + // 在浏览器中,使用当前主机名,但端口改为 3210 + const hostname = window.location.hostname; + return `http://${hostname}:3210`; + }; + + const convexUrl = getConvexUrl(); + if (!convexUrl) { + throw new Error("NEXT_PUBLIC_CONVEX_URL is not defined"); + } + return new ConvexReactClient(convexUrl); + }); + + return ( + + {children} + + ); +} diff --git a/wolai-frontend/src/lib/auth/authContext.ts b/wolai-frontend/src/lib/auth/authContext.ts index b8c276c9..98d9a61f 100644 --- a/wolai-frontend/src/lib/auth/authContext.ts +++ b/wolai-frontend/src/lib/auth/authContext.ts @@ -1,5 +1,9 @@ import type { AuthContext } from "@/lib/auth/types"; import { getDevUser, isDevAuthEnabled } from "@/lib/auth/devUser"; +import { isConvexEnabled } from "@/lib/convex/enabled"; +import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server"; +import { fetchQuery } from "convex/nextjs"; +import { api } from "@/lib/convex/api"; export class HttpError extends Error { status: number; @@ -11,17 +15,79 @@ export class HttpError extends Error { } } -export function getAuthContext(): AuthContext { - if (isDevAuthEnabled()) return getDevUser(); - // 说明:后续接入真实鉴权时,在这里替换为 Supabase/Convex Auth 的校验逻辑。 +/** + * 获取认证上下文 + * + * 优先级: + * 1. Convex Auth(如果启用)- 服务端:从 Convex Auth cookies 获取并查询当前用户;客户端:从 hooks 获取 + * 2. 开发用户(如果启用) + * 3. 抛出错误(未配置) + */ +export async function getAuthContext(): Promise { + // 1. 检查是否启用 Convex Auth + if (isConvexEnabled()) { + // 说明:开发模式下允许使用固定用户快速跑通链路(例如迁移测试)。 + if (isDevAuthEnabled()) { + return getDevUser(); + } + + const token = await convexAuthNextjsToken(); + if (!token) { + throw new Error("未登录,请先登录"); + } + + // 说明:从 Convex Auth token 获取当前用户(users 表由 authTables 提供)。 + // 这里不强依赖具体字段结构,避免升级 authTables 后类型不兼容。 + const user = (await fetchQuery(api.users.currentUser, {}, { token })) as unknown; + if (!user || typeof user !== "object") { + throw new Error("未登录,请先登录"); + } + + const record = user as Record; + const id = record["_id"]; + const email = record["email"]; + const name = record["name"]; + + if (typeof id !== "string" || !id) { + throw new Error("未登录,请先登录"); + } + + return { + userId: id, + email: typeof email === "string" ? email : undefined, + name: typeof name === "string" ? name : undefined, + }; + } + + // 2. 检查是否启用开发用户模式 + if (isDevAuthEnabled()) { + return getDevUser(); + } + + // 3. 未配置认证 throw new Error("Auth is not configured"); } -export function requireAuthContext(): AuthContext { +/** + * 要求已认证的上下文 + * + * 如果未认证,抛出 401 错误 + */ +export async function requireAuthContext(): Promise { try { - return getAuthContext(); + return await getAuthContext(); } catch (err) { const message = err instanceof Error ? err.message : "Unauthorized"; throw new HttpError(401, message); } } + +/** + * 获取当前用户 ID + * + * 用于服务端组件和 API 路由 + */ +export async function getCurrentUserId(): Promise { + const auth = await requireAuthContext(); + return auth.userId; +} diff --git a/wolai-frontend/src/lib/convex/enabled.ts b/wolai-frontend/src/lib/convex/enabled.ts index bc88736c..6f3dc2ce 100644 --- a/wolai-frontend/src/lib/convex/enabled.ts +++ b/wolai-frontend/src/lib/convex/enabled.ts @@ -1,4 +1,4 @@ export function isConvexEnabled(): boolean { - return process.env.USE_CONVEX === "1"; + return process.env.NEXT_PUBLIC_USE_CONVEX === "1"; } diff --git a/wolai-frontend/src/lib/convex/route.ts b/wolai-frontend/src/lib/convex/route.ts index ead00842..1869474e 100644 --- a/wolai-frontend/src/lib/convex/route.ts +++ b/wolai-frontend/src/lib/convex/route.ts @@ -3,9 +3,8 @@ import type { AuthContext } from "@/lib/auth/types"; import { requireAuthContext } from "@/lib/auth/authContext"; import { getConvexHttpClient } from "@/lib/convex/server"; -export function getAuthedConvexClient(): { auth: AuthContext; client: ConvexHttpClient } { - const auth = requireAuthContext(); +export async function getAuthedConvexClient(): Promise<{ auth: AuthContext; client: ConvexHttpClient }> { + const auth = await requireAuthContext(); const client = getConvexHttpClient(); return { auth, client }; } - diff --git a/wolai-frontend/src/lib/convex/server.ts b/wolai-frontend/src/lib/convex/server.ts index d96e17f0..ecaf826e 100644 --- a/wolai-frontend/src/lib/convex/server.ts +++ b/wolai-frontend/src/lib/convex/server.ts @@ -13,10 +13,11 @@ export function getConvexHttpClient(): ConvexHttpClient { const client = new ConvexHttpClient(url); const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY; if (adminKey) { - client.setAdminAuth(adminKey); + // 说明:ConvexHttpClient 的类型声明可能未暴露 setAdminAuth(但运行期存在)。 + // 这里用最小侵入方式兼容自托管管理密钥。 + (client as any).setAdminAuth(adminKey); } cached = client; return client; } - diff --git a/wolai-frontend/src/middleware.ts b/wolai-frontend/src/middleware.ts new file mode 100644 index 00000000..a8c92175 --- /dev/null +++ b/wolai-frontend/src/middleware.ts @@ -0,0 +1,37 @@ +import { convexAuthNextjsMiddleware, createRouteMatcher, nextjsMiddlewareRedirect } from "@convex-dev/auth/nextjs/server"; + +// 说明: +// - 这里启用 Convex Auth 的 Next.js 中间件,负责: +// 1) 代理 /api/auth 到 Convex(用于 signIn / signOut 等动作) +// 2) 刷新/同步 auth cookies +// 3) 对需要登录的页面做统一拦截 +// - 默认将未登录用户重定向到 /auth(Convex Auth 登录页)。 + +const isPublicRoute = createRouteMatcher([ + "/auth", + "/login", + "/api/auth(.*)", + "/api/health(.*)", + "/_next(.*)", + "/favicon.ico", +]); + +export default convexAuthNextjsMiddleware(async (request, ctx) => { + // 公共路由不做拦截(但 middleware 仍会处理 token 刷新/代理等)。 + if (isPublicRoute(request)) return; + + // 只在启用 Convex 模式时做鉴权拦截;否则走 Supabase 逻辑(页面内部自行处理)。 + // 注意:middleware 运行在 Edge/Node 环境中,读取到的是运行期环境变量。 + if (process.env.NEXT_PUBLIC_USE_CONVEX !== "1") return; + + const authed = await ctx.convexAuth.isAuthenticated(); + if (!authed) { + return nextjsMiddlewareRedirect(request, "/auth"); + } +}); + +export const config = { + // 说明:排除静态资源,避免无意义的中间件开销。 + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; +