0.3 增加登录模块
This commit is contained in:
+16
@@ -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;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string, string> = { "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<string, unknown>).track_id;
|
||||
return typeof v === "string" ? v : null;
|
||||
})();
|
||||
return { trackId };
|
||||
}
|
||||
@@ -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<string, unknown>).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<string, unknown>;
|
||||
|
||||
// 兼容常见 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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 : "" };
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>), updated_at: nowIso() };
|
||||
await ctx.db.patch(row._id, next);
|
||||
|
||||
// 说明:当 OCR 写回完成时,自动触发 LightRAG 入库任务(Convex jobs/actions)。
|
||||
const patch = args.patch as Record<string, unknown>;
|
||||
const prev = row as Record<string, unknown>;
|
||||
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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user