810 lines
27 KiB
TypeScript
810 lines
27 KiB
TypeScript
import { internalAction, internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
|
import { v } from "convex/values";
|
|
import { nowIso } from "./_utils/time";
|
|
import { api, internal } from "./_generated/api";
|
|
import { lightragIngestText } from "./_utils/lightrag";
|
|
import { extractTextFromDocumentContent, extractTextFromMindmapData } from "./_utils/text";
|
|
import { enqueueIngestDocumentJob, enqueueIngestMediaAssetJob, enqueueIngestMindmapJob } from "./_utils/ingestJobs";
|
|
import { extractTextFromAttachment } from "./_utils/attachmentExtract";
|
|
|
|
type KernelAwareRefreshTarget = {
|
|
documentIds: string[];
|
|
mindmapRefs: Array<{ docId: string; mindmapId: string }>;
|
|
assetIds: string[];
|
|
};
|
|
|
|
function uniqueNonEmptyStrings(values: Iterable<string | null | undefined>, limit: number): string[] {
|
|
const seen = new Set<string>();
|
|
const output: string[] = [];
|
|
for (const value of values) {
|
|
const normalized = String(value ?? "").trim();
|
|
if (!normalized || seen.has(normalized)) {
|
|
continue;
|
|
}
|
|
seen.add(normalized);
|
|
output.push(normalized);
|
|
if (output.length >= limit) {
|
|
break;
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function truncateKernelText(value: string, limit = 800): string {
|
|
const normalized = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
return normalized.length > limit ? `${normalized.slice(0, Math.max(0, limit - 1))}…` : normalized;
|
|
}
|
|
|
|
function makeKernelAwareRefreshText(input: {
|
|
workspaceId: string;
|
|
documentRows: Array<{ id: string; title?: string | null; raw_text?: string | null; updated_at?: string | null }>;
|
|
mindmapRows: Array<{ document_id?: string | null; mindmap_id?: string | null; data?: unknown }>;
|
|
assetRows: Array<{ id: string; document_id?: string | null; file_name?: string | null; ocr_text?: string | null }>;
|
|
}): string {
|
|
const lines: string[] = [];
|
|
lines.push(`# kernel-aware refresh workspace ${input.workspaceId}`);
|
|
lines.push("");
|
|
|
|
for (const doc of input.documentRows) {
|
|
const title = String(doc.title ?? "").trim() || "无标题";
|
|
const updatedAt = String(doc.updated_at ?? "").trim() || "unknown";
|
|
const rawText = truncateKernelText(String(doc.raw_text ?? ""), 600);
|
|
lines.push(`## node:${doc.id}`);
|
|
lines.push(`title=${title}`);
|
|
lines.push(`subtreeRoot=${doc.id}`);
|
|
lines.push(`updatedAt=${updatedAt}`);
|
|
if (rawText) {
|
|
lines.push(`evidence=${rawText}`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
for (const mindmap of input.mindmapRows) {
|
|
const docId = String(mindmap.document_id ?? "").trim();
|
|
const mindmapId = String(mindmap.mindmap_id ?? "").trim();
|
|
if (!docId || !mindmapId) {
|
|
continue;
|
|
}
|
|
const text = truncateKernelText(extractTextFromMindmapData(mindmap.data ?? null), 400);
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
lines.push(`## subtree:${docId}:${mindmapId}`);
|
|
lines.push(`node=${docId}`);
|
|
lines.push(`subtreeRoot=${docId}`);
|
|
lines.push(`evidence=${text}`);
|
|
lines.push("");
|
|
}
|
|
|
|
for (const asset of input.assetRows) {
|
|
const assetId = String(asset.id ?? "").trim();
|
|
const docId = String(asset.document_id ?? "").trim();
|
|
if (!assetId || !docId) {
|
|
continue;
|
|
}
|
|
const title = String(asset.file_name ?? "").trim() || assetId;
|
|
const ocrText = truncateKernelText(String(asset.ocr_text ?? ""), 400);
|
|
if (!ocrText) {
|
|
continue;
|
|
}
|
|
lines.push(`## evidence:${assetId}`);
|
|
lines.push(`node=${docId}`);
|
|
lines.push(`subtreeRoot=${docId}`);
|
|
lines.push(`title=${title}`);
|
|
lines.push(`evidence=${ocrText}`);
|
|
lines.push("");
|
|
}
|
|
|
|
return lines.join("\n").trim();
|
|
}
|
|
|
|
async function selectKernelAwareRefreshTargets(ctx: any, args: { workspaceId: string; userId: string }): Promise<KernelAwareRefreshTarget> {
|
|
const [documents, mindmaps, assets] = await Promise.all([
|
|
ctx.db
|
|
.query("documents")
|
|
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
|
|
.collect(),
|
|
ctx.db
|
|
.query("mindmaps")
|
|
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
|
|
.collect(),
|
|
ctx.db
|
|
.query("media_assets")
|
|
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
|
|
.collect(),
|
|
]);
|
|
|
|
const aliveDocuments = documents
|
|
.filter((row: any) => row.deleted_at == null)
|
|
.filter((row: any) => String(row.user_id ?? "") === args.userId)
|
|
.sort((left: any, right: any) => String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")));
|
|
const ownedMindmaps = mindmaps
|
|
.filter((row: any) => row.deleted_at == null)
|
|
.filter((row: any) => String(row.user_id ?? "") === args.userId)
|
|
.sort((left: any, right: any) => String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")));
|
|
const aliveAssets = assets
|
|
.filter((row: any) => row.deleted_at == null && row.purged_at == null)
|
|
.sort((left: any, right: any) => String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")));
|
|
|
|
const documentIds = uniqueNonEmptyStrings(aliveDocuments.map((row: any) => row.id), 12);
|
|
const selectedDocumentIds = new Set(documentIds);
|
|
const mindmapRefs = ownedMindmaps
|
|
.filter((row: any) => selectedDocumentIds.has(String(row.document_id ?? "").trim()))
|
|
.slice(0, 8)
|
|
.map((row: any) => ({
|
|
docId: String(row.document_id ?? "").trim(),
|
|
mindmapId: String(row.mindmap_id ?? "").trim(),
|
|
}))
|
|
.filter((row: { docId: string; mindmapId: string }) => row.docId && row.mindmapId);
|
|
const assetIds = uniqueNonEmptyStrings(
|
|
aliveAssets
|
|
.filter((row: any) => selectedDocumentIds.has(String(row.document_id ?? "").trim()))
|
|
.map((row: any) => row.id),
|
|
10,
|
|
);
|
|
|
|
return { documentIds, mindmapRefs, assetIds };
|
|
}
|
|
|
|
async function buildKernelAwareRefreshPayload(ctx: any, args: {
|
|
workspaceId: string;
|
|
userId: string;
|
|
target: KernelAwareRefreshTarget;
|
|
}) {
|
|
const documentRows = await Promise.all(
|
|
args.target.documentIds.map(async (documentId) => {
|
|
const [meta, contentRes] = await Promise.all([
|
|
ctx.runQuery(internal.documents.getMetaForIngest, { userId: args.userId, id: documentId }),
|
|
ctx.runQuery(internal.documents.getContentForIngest, { userId: args.userId, id: documentId }),
|
|
]);
|
|
if (!meta) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: documentId,
|
|
title: meta.title ?? "无标题",
|
|
raw_text: extractTextFromDocumentContent(contentRes?.content ?? null),
|
|
updated_at: meta.updated_at ?? null,
|
|
};
|
|
}),
|
|
);
|
|
|
|
const mindmapRows = await Promise.all(
|
|
args.target.mindmapRefs.map(async (ref) => {
|
|
const result = await ctx.runQuery(internal.mindmaps.getForIngest, {
|
|
userId: args.userId,
|
|
docId: ref.docId,
|
|
mindmapId: ref.mindmapId,
|
|
});
|
|
if (!result?.ok || !result.meta?.exists) {
|
|
return null;
|
|
}
|
|
return {
|
|
document_id: ref.docId,
|
|
mindmap_id: ref.mindmapId,
|
|
data: result.data ?? null,
|
|
};
|
|
}),
|
|
);
|
|
|
|
const assetRows = await Promise.all(
|
|
args.target.assetIds.map(async (assetId) => {
|
|
const asset = await ctx.runQuery(api.mediaAssets.getById, { userId: args.userId, id: assetId });
|
|
if (!asset) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: assetId,
|
|
document_id: asset.document_id ?? null,
|
|
file_name: asset.file_name ?? null,
|
|
ocr_text: asset.ocr_text ?? null,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return {
|
|
documentRows: documentRows.filter((item): item is NonNullable<typeof item> => Boolean(item)),
|
|
mindmapRows: mindmapRows.filter((item): item is NonNullable<typeof item> => Boolean(item)),
|
|
assetRows: assetRows.filter((item): item is NonNullable<typeof item> => Boolean(item)),
|
|
};
|
|
}
|
|
|
|
export const get = query({
|
|
args: { userId: v.string(), id: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const job = await ctx.db
|
|
.query("jobs")
|
|
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
|
.first();
|
|
if (!job) return null;
|
|
if (job.user_id !== args.userId) return null;
|
|
return {
|
|
id: job.id,
|
|
type: job.type,
|
|
status: job.status,
|
|
payload: job.payload,
|
|
result: job.result,
|
|
error: job.error,
|
|
created_at: job.created_at,
|
|
updated_at: job.updated_at,
|
|
started_at: job.started_at,
|
|
finished_at: job.finished_at,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const enqueueDemo = mutation({
|
|
args: { userId: v.string(), id: v.string(), ms: v.optional(v.number()) },
|
|
handler: async (ctx, args) => {
|
|
const ts = nowIso();
|
|
const payload = { ms: args.ms ?? 800 };
|
|
await ctx.db.insert("jobs", {
|
|
id: args.id,
|
|
user_id: args.userId,
|
|
type: "demo.sleep",
|
|
status: "queued",
|
|
payload,
|
|
result: null,
|
|
error: null,
|
|
created_at: ts,
|
|
updated_at: ts,
|
|
started_at: null,
|
|
finished_at: null,
|
|
});
|
|
|
|
// 说明:阶段 5 骨架——用 scheduler 触发内部 mutation,再由内部 action 执行耗时逻辑。
|
|
await ctx.scheduler.runAfter(0, internal.jobs.start, { id: args.id });
|
|
return { ok: true, id: args.id };
|
|
},
|
|
});
|
|
|
|
export const 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 enqueueKernelAwareRefresh = mutation({
|
|
args: { userId: v.string(), workspaceId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const workspaceId = String(args.workspaceId ?? "").trim();
|
|
if (!workspaceId) {
|
|
throw new Error("缺少 workspaceId");
|
|
}
|
|
const membership = await ctx.db
|
|
.query("workspace_members")
|
|
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", args.userId))
|
|
.first();
|
|
if (!membership) {
|
|
throw new Error("无权访问该工作空间");
|
|
}
|
|
const ts = nowIso();
|
|
const id = `refresh:kernel-aware:${workspaceId}`;
|
|
const payload = {
|
|
workspaceId,
|
|
enqueuedAt: ts,
|
|
trigger: "manual",
|
|
};
|
|
const existing = await ctx.db
|
|
.query("jobs")
|
|
.withIndex("by_job_id", (q) => q.eq("id", id))
|
|
.first();
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
user_id: args.userId,
|
|
type: "refresh.kernel_aware_transition",
|
|
status: "queued",
|
|
payload,
|
|
result: null,
|
|
error: null,
|
|
updated_at: ts,
|
|
started_at: null,
|
|
finished_at: null,
|
|
});
|
|
} else {
|
|
await ctx.db.insert("jobs", {
|
|
id,
|
|
user_id: args.userId,
|
|
type: "refresh.kernel_aware_transition",
|
|
status: "queued",
|
|
payload,
|
|
result: null,
|
|
error: null,
|
|
created_at: ts,
|
|
updated_at: ts,
|
|
started_at: null,
|
|
finished_at: null,
|
|
});
|
|
}
|
|
await ctx.scheduler.runAfter(0, internal.jobs.start, { id });
|
|
return { ok: true, id };
|
|
},
|
|
});
|
|
|
|
export const enqueueKernelAwareRefreshSweep = internalMutation({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const memberships = await ctx.db.query("workspace_members").collect();
|
|
const ownersByWorkspace = new Map<string, string>();
|
|
for (const membership of memberships) {
|
|
if (membership.role !== "owner") {
|
|
continue;
|
|
}
|
|
if (!ownersByWorkspace.has(membership.workspace_id)) {
|
|
ownersByWorkspace.set(membership.workspace_id, membership.user_id);
|
|
}
|
|
}
|
|
|
|
const scheduled: string[] = [];
|
|
for (const [workspaceId, userId] of ownersByWorkspace.entries()) {
|
|
const id = `refresh:kernel-aware:${workspaceId}`;
|
|
const ts = nowIso();
|
|
const payload = {
|
|
workspaceId,
|
|
enqueuedAt: ts,
|
|
trigger: "cron",
|
|
};
|
|
const existing = await ctx.db
|
|
.query("jobs")
|
|
.withIndex("by_job_id", (q) => q.eq("id", id))
|
|
.first();
|
|
if (existing && (existing.status === "queued" || existing.status === "running")) {
|
|
scheduled.push(id);
|
|
continue;
|
|
}
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
user_id: userId,
|
|
type: "refresh.kernel_aware_transition",
|
|
status: "queued",
|
|
payload,
|
|
result: null,
|
|
error: null,
|
|
updated_at: ts,
|
|
started_at: null,
|
|
finished_at: null,
|
|
});
|
|
} else {
|
|
await ctx.db.insert("jobs", {
|
|
id,
|
|
user_id: userId,
|
|
type: "refresh.kernel_aware_transition",
|
|
status: "queued",
|
|
payload,
|
|
result: null,
|
|
error: null,
|
|
created_at: ts,
|
|
updated_at: ts,
|
|
started_at: null,
|
|
finished_at: null,
|
|
});
|
|
}
|
|
await ctx.scheduler.runAfter(0, internal.jobs.start, { id });
|
|
scheduled.push(id);
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
scheduledCount: scheduled.length,
|
|
jobIds: scheduled,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const start = internalMutation({
|
|
args: { id: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const job = await ctx.db
|
|
.query("jobs")
|
|
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
|
.first();
|
|
if (!job) return;
|
|
if (job.status !== "queued") return;
|
|
const ts = nowIso();
|
|
await ctx.db.patch(job._id, { status: "running", started_at: ts, updated_at: ts });
|
|
await ctx.scheduler.runAfter(0, internal.jobs.run, { id: args.id });
|
|
},
|
|
});
|
|
|
|
export const run = internalAction({
|
|
args: { id: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const job = await ctx.runQuery(internal.jobs._getInternal, { id: args.id });
|
|
if (!job) return;
|
|
if (job.status !== "running") return;
|
|
|
|
try {
|
|
if (job.type === "demo.sleep") {
|
|
const ms = typeof job.payload?.ms === "number" ? job.payload.ms : 800;
|
|
await new Promise((r) => setTimeout(r, ms));
|
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
|
id: args.id,
|
|
result: { ok: true, sleptMs: ms },
|
|
});
|
|
return;
|
|
}
|
|
|
|
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(internal.documents.getMetaForIngest, { userId: job.user_id, id: documentId }),
|
|
ctx.runQuery(internal.documents.getContentForIngest, { 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 ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
|
if (ingest.skipped) {
|
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
|
id: args.id,
|
|
result: { ok: true, kind: "document", documentId, skipped: true, reason: ingest.reason ?? "skipped" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
|
id: args.id,
|
|
result: { ok: true, kind: "document", documentId, trackId: ingest.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(internal.documents.getMetaForIngest, { userId: job.user_id, id: docId }),
|
|
ctx.runQuery(internal.mindmaps.getForIngest, { 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 ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
|
if (ingest.skipped) {
|
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
|
id: args.id,
|
|
result: { ok: true, kind: "mindmap", docId, mindmapId, skipped: true, reason: ingest.reason ?? "skipped" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
|
id: args.id,
|
|
result: { ok: true, kind: "mindmap", docId, mindmapId, trackId: ingest.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 ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
|
if (ingest.skipped) {
|
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
|
id: args.id,
|
|
result: { ok: true, kind: "media_asset", assetId, skipped: true, reason: ingest.reason ?? "skipped" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
|
id: args.id,
|
|
result: { ok: true, kind: "media_asset", assetId, trackId: ingest.trackId },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (job.type === "extract.media_asset_text") {
|
|
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("资源不存在或无权限");
|
|
|
|
// 说明:只处理 file 类型的常见附件(pdf/docx/pptx/xlsx)。
|
|
if (String(asset.asset_type ?? "") !== "file") {
|
|
await ctx.runMutation(api.mediaAssets.patchById, {
|
|
userId: job.user_id,
|
|
id: assetId,
|
|
patch: {
|
|
ocr_status: "skipped",
|
|
ocr_payload: { reason: "non_file_asset" },
|
|
ocr_strategy: "attachment_extract",
|
|
},
|
|
});
|
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
|
id: args.id,
|
|
result: { ok: true, kind: "extract_media_asset_text", assetId, skipped: true, reason: "non_file_asset" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const fileSize = typeof asset.file_size === "number" ? asset.file_size : null;
|
|
const maxBytes = 25 * 1024 * 1024;
|
|
if (typeof fileSize === "number" && fileSize > maxBytes) {
|
|
await ctx.runMutation(api.mediaAssets.patchById, {
|
|
userId: job.user_id,
|
|
id: assetId,
|
|
patch: {
|
|
ocr_status: "failed",
|
|
ocr_payload: { error: `文件过大(${fileSize} bytes),暂不解析`, maxBytes },
|
|
ocr_strategy: "attachment_extract",
|
|
},
|
|
});
|
|
await ctx.runMutation(internal.jobs.finishFailure, {
|
|
id: args.id,
|
|
error: "文件过大,暂不解析",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 说明:Convex Files 的 getUrl 可能过期,先刷新并获取当前可用链接。
|
|
const refreshed = await ctx.runMutation(api.mediaAssets.refreshUrl, { userId: job.user_id, id: assetId });
|
|
const url = String((refreshed as any)?.signedUrl ?? asset.file_url ?? "").trim();
|
|
if (!url) throw new Error("缺少可用文件链接");
|
|
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
throw new Error(`下载附件失败:${res.status}`);
|
|
}
|
|
const bytes = await res.arrayBuffer();
|
|
|
|
const extracted = await extractTextFromAttachment({
|
|
mimeType: (asset as any).mime_type ?? null,
|
|
fileName: (asset as any).file_name ?? null,
|
|
bytes,
|
|
});
|
|
|
|
if (!extracted.ok) {
|
|
await ctx.runMutation(api.mediaAssets.patchById, {
|
|
userId: job.user_id,
|
|
id: assetId,
|
|
patch: {
|
|
ocr_status: "failed",
|
|
ocr_payload: { error: extracted.reason, meta: extracted.meta ?? null },
|
|
ocr_strategy: extracted.strategy,
|
|
},
|
|
});
|
|
await ctx.runMutation(internal.jobs.finishFailure, { id: args.id, error: extracted.reason });
|
|
return;
|
|
}
|
|
|
|
await ctx.runMutation(api.mediaAssets.patchById, {
|
|
userId: job.user_id,
|
|
id: assetId,
|
|
patch: {
|
|
ocr_text: extracted.text,
|
|
ocr_status: "completed",
|
|
ocr_payload: extracted.meta ?? null,
|
|
ocr_strategy: extracted.strategy,
|
|
},
|
|
});
|
|
|
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
|
id: args.id,
|
|
result: {
|
|
ok: true,
|
|
kind: "extract_media_asset_text",
|
|
assetId,
|
|
strategy: extracted.strategy,
|
|
chars: extracted.text.length,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (job.type === "refresh.kernel_aware_transition") {
|
|
const workspaceId = String(job.payload?.workspaceId ?? "").trim();
|
|
if (!workspaceId) throw new Error("缺少 workspaceId");
|
|
|
|
const membership = await ctx.runQuery((internal as any).jobs._getWorkspaceMembership, {
|
|
workspaceId,
|
|
userId: job.user_id,
|
|
});
|
|
if (!membership) {
|
|
throw new Error("工作空间不存在或无权限");
|
|
}
|
|
|
|
const target = await ctx.runQuery((internal as any).jobs._selectKernelAwareRefreshTargets, {
|
|
workspaceId,
|
|
userId: job.user_id,
|
|
});
|
|
const payload = await buildKernelAwareRefreshPayload(ctx, {
|
|
workspaceId,
|
|
userId: job.user_id,
|
|
target,
|
|
});
|
|
const text = makeKernelAwareRefreshText({
|
|
workspaceId,
|
|
documentRows: payload.documentRows,
|
|
mindmapRows: payload.mindmapRows,
|
|
assetRows: payload.assetRows,
|
|
});
|
|
const ingest = await lightragIngestText({
|
|
fileSource: `kernel-aware-refresh:${workspaceId}`,
|
|
text,
|
|
});
|
|
|
|
await Promise.all([
|
|
...target.documentIds.slice(0, 6).map((documentId: string) =>
|
|
ctx.runMutation(api.jobs.enqueueRagIndexDocument, { userId: job.user_id, documentId }).catch(() => null),
|
|
),
|
|
...target.mindmapRefs.slice(0, 4).map((item: { docId: string; mindmapId: string }) =>
|
|
ctx.runMutation(api.jobs.enqueueRagIndexMindmap, {
|
|
userId: job.user_id,
|
|
docId: item.docId,
|
|
mindmapId: item.mindmapId,
|
|
}).catch(() => null),
|
|
),
|
|
...target.assetIds.slice(0, 4).map((assetId: string) =>
|
|
ctx.runMutation(api.jobs.enqueueRagIndexMediaAsset, { userId: job.user_id, assetId }).catch(() => null),
|
|
),
|
|
]);
|
|
|
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
|
id: args.id,
|
|
result: {
|
|
ok: true,
|
|
kind: "kernel_aware_refresh",
|
|
workspaceId,
|
|
refreshMode: "kernel_aware_transition",
|
|
bridge: {
|
|
backend: "lightrag",
|
|
fileSource: `kernel-aware-refresh:${workspaceId}`,
|
|
skipped: Boolean(ingest.skipped),
|
|
reason: ingest.reason ?? null,
|
|
trackId: ingest.trackId,
|
|
},
|
|
refreshedDocuments: target.documentIds.length,
|
|
refreshedMindmaps: target.mindmapRefs.length,
|
|
refreshedAssets: target.assetIds.length,
|
|
kernelPreview: {
|
|
nodeIds: payload.documentRows.map((item) => item.id).slice(0, 8),
|
|
subtreeRootIds: payload.documentRows.map((item) => item.id).slice(0, 8),
|
|
evidenceAssetIds: payload.assetRows.map((item) => item.id).slice(0, 8),
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
throw new Error(`未知任务类型:${job.type}`);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
await ctx.runMutation(internal.jobs.finishFailure, { id: args.id, error: message });
|
|
}
|
|
},
|
|
});
|
|
|
|
export const _getInternal = internalQuery({
|
|
args: { id: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const job = await ctx.db
|
|
.query("jobs")
|
|
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
|
.first();
|
|
if (!job) return null;
|
|
return {
|
|
id: job.id,
|
|
user_id: job.user_id,
|
|
type: job.type,
|
|
status: job.status,
|
|
payload: job.payload,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const _getWorkspaceMembership = internalQuery({
|
|
args: { workspaceId: v.string(), userId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db
|
|
.query("workspace_members")
|
|
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
|
|
.first();
|
|
},
|
|
});
|
|
|
|
export const _selectKernelAwareRefreshTargets = internalQuery({
|
|
args: { workspaceId: v.string(), userId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
return await selectKernelAwareRefreshTargets(ctx, args);
|
|
},
|
|
});
|
|
|
|
export const finishSuccess = internalMutation({
|
|
args: { id: v.string(), result: v.any() },
|
|
handler: async (ctx, args) => {
|
|
const job = await ctx.db
|
|
.query("jobs")
|
|
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
|
.first();
|
|
if (!job) return;
|
|
const ts = nowIso();
|
|
await ctx.db.patch(job._id, {
|
|
status: "succeeded",
|
|
result: args.result,
|
|
error: null,
|
|
finished_at: ts,
|
|
updated_at: ts,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const finishFailure = internalMutation({
|
|
args: { id: v.string(), error: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const job = await ctx.db
|
|
.query("jobs")
|
|
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
|
.first();
|
|
if (!job) return;
|
|
const ts = nowIso();
|
|
await ctx.db.patch(job._id, {
|
|
status: "failed",
|
|
result: null,
|
|
error: args.error,
|
|
finished_at: ts,
|
|
updated_at: ts,
|
|
});
|
|
},
|
|
});
|