0.3 增加登录模块

This commit is contained in:
liaibo
2026-01-18 05:13:53 +08:00
parent df2e9f5339
commit 90a38b48cf
86 changed files with 2041 additions and 148 deletions
+7
View File
@@ -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}`;
}
+125
View File
@@ -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 };
}
+47
View File
@@ -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 };
}
+101
View File
@@ -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;
}