259 lines
9.1 KiB
TypeScript
259 lines
9.1 KiB
TypeScript
import type { Json } from "@/types/supabase";
|
||||
|
|
|
|||
|
|
export type DocToolContext = {
|
|||
|
|
documentId: string;
|
|||
|
|
userId: string;
|
|||
|
|
/**
|
|||
|
|
* 来自前端的“最新文档快照”(优先使用,避免覆盖用户尚未落盘的编辑)。
|
|||
|
|
* 允许是 blocks 数组,或 { blocks } 结构。
|
|||
|
|
*/
|
|||
|
|
baseBlocks?: unknown;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type SupabaseRouteClient = {
|
|||
|
|
from: (table: string) => {
|
|||
|
|
select: (columns: string) => SupabaseQuery;
|
|||
|
|
update: (values: Record<string, unknown>) => SupabaseUpdateQuery;
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type SupabaseQuery = {
|
|||
|
|
eq: (column: string, value: unknown) => SupabaseQuery;
|
|||
|
|
single: () => Promise<{ data: unknown; error: unknown }>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type SupabaseUpdateQuery = {
|
|||
|
|
eq: (column: string, value: unknown) => SupabaseUpdateQuery;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export type DocSupabaseClient = SupabaseRouteClient;
|
|||
|
|
|
|||
|
|
type DocBlockSummary = {
|
|||
|
|
id: string;
|
|||
|
|
type: string;
|
|||
|
|
text: string;
|
|||
|
|
depth: number;
|
|||
|
|
childCount: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type DocBlockSpec = {
|
|||
|
|
type: "paragraph" | "heading";
|
|||
|
|
text: string;
|
|||
|
|
level?: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|||
|
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
|||
|
|
|
|||
|
|
const getValue = (obj: unknown, key: string): unknown => (isRecord(obj) ? obj[key] : undefined);
|
|||
|
|
|
|||
|
|
const normalizeBlocks = (content: unknown): unknown[] => {
|
|||
|
|
if (Array.isArray(content)) return content;
|
|||
|
|
const blocks = getValue(content, "blocks");
|
|||
|
|
if (Array.isArray(blocks)) return blocks;
|
|||
|
|
return [];
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const extractInlineText = (block: unknown): string => {
|
|||
|
|
const content = getValue(block, "content");
|
|||
|
|
const nodes = Array.isArray(content) ? content : [];
|
|||
|
|
const pieces: string[] = [];
|
|||
|
|
for (const n of nodes) {
|
|||
|
|
const t = getValue(n, "text");
|
|||
|
|
if (typeof t === "string") pieces.push(t);
|
|||
|
|
}
|
|||
|
|
return pieces.join("").trim();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const walkSummaries = (rootBlocks: unknown[], maxNodes: number): DocBlockSummary[] => {
|
|||
|
|
const list: DocBlockSummary[] = [];
|
|||
|
|
const queue: Array<{ block: unknown; depth: number }> = rootBlocks.map((b) => ({ block: b, depth: 0 }));
|
|||
|
|
while (queue.length > 0 && list.length < maxNodes) {
|
|||
|
|
const item = queue.shift();
|
|||
|
|
if (!item) break;
|
|||
|
|
const { block, depth } = item;
|
|||
|
|
const id = String(getValue(block, "id") ?? "").trim();
|
|||
|
|
const type = String(getValue(block, "type") ?? "").trim();
|
|||
|
|
const childrenRaw = getValue(block, "children");
|
|||
|
|
const children = Array.isArray(childrenRaw) ? childrenRaw : [];
|
|||
|
|
const text = extractInlineText(block);
|
|||
|
|
if (id) {
|
|||
|
|
list.push({ id, type: type || "unknown", text, depth, childCount: children.length });
|
|||
|
|
}
|
|||
|
|
children.forEach((c) => queue.push({ block: c, depth: depth + 1 }));
|
|||
|
|
}
|
|||
|
|
return list;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const findContainerById = (
|
|||
|
|
blocks: unknown[],
|
|||
|
|
targetId: string,
|
|||
|
|
): { container: unknown[]; index: number } | null => {
|
|||
|
|
const id = String(targetId || "").trim();
|
|||
|
|
if (!id) return null;
|
|||
|
|
for (let i = 0; i < blocks.length; i += 1) {
|
|||
|
|
const b = blocks[i];
|
|||
|
|
const bid = String(getValue(b, "id") ?? "").trim();
|
|||
|
|
if (bid === id) return { container: blocks, index: i };
|
|||
|
|
const childrenRaw = getValue(b, "children");
|
|||
|
|
const children = Array.isArray(childrenRaw) ? childrenRaw : [];
|
|||
|
|
const found = findContainerById(children, id);
|
|||
|
|
if (found) return found;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const createTextContent = (text: string): unknown[] => [{ type: "text", text }];
|
|||
|
|
|
|||
|
|
const generateId = () => {
|
|||
|
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|||
|
|
return crypto.randomUUID();
|
|||
|
|
}
|
|||
|
|
return `bn_${Math.random().toString(36).slice(2, 10)}`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const buildBlockFromSpec = (spec: DocBlockSpec): Record<string, unknown> => {
|
|||
|
|
const type = spec.type;
|
|||
|
|
const text = String(spec.text ?? "").trim();
|
|||
|
|
const base: Record<string, unknown> = {
|
|||
|
|
id: generateId(),
|
|||
|
|
type,
|
|||
|
|
props: {},
|
|||
|
|
content: createTextContent(text),
|
|||
|
|
children: [],
|
|||
|
|
};
|
|||
|
|
if (type === "heading") {
|
|||
|
|
const level = Number(spec.level ?? 2);
|
|||
|
|
base.props = { level: Math.max(1, Math.min(5, Number.isFinite(level) ? Math.floor(level) : 2)) };
|
|||
|
|
}
|
|||
|
|
return base;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const loadDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext) => {
|
|||
|
|
const base = normalizeBlocks(ctx.baseBlocks);
|
|||
|
|
if (base.length > 0) {
|
|||
|
|
return { blocks: base, source: "client" as const };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { data, error } = await supabase
|
|||
|
|
.from("documents")
|
|||
|
|
.select("id,content")
|
|||
|
|
.eq("id", ctx.documentId)
|
|||
|
|
.eq("user_id", ctx.userId)
|
|||
|
|
.single();
|
|||
|
|
if (error) {
|
|||
|
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取文档失败");
|
|||
|
|
}
|
|||
|
|
const content = isRecord(data) ? data.content : null;
|
|||
|
|
const blocks = normalizeBlocks(content);
|
|||
|
|
return { blocks, source: "db" as const };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const saveDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext, blocks: unknown[]) => {
|
|||
|
|
const resp = (await (supabase
|
|||
|
|
.from("documents")
|
|||
|
|
.update({ content: blocks as unknown as Json })
|
|||
|
|
.eq("id", ctx.documentId)
|
|||
|
|
.eq("user_id", ctx.userId) as unknown as Promise<{ error: unknown }>)) ?? { error: null };
|
|||
|
|
const error = resp.error;
|
|||
|
|
if (error) {
|
|||
|
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "保存文档失败");
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const createDocServerTools = (args: {
|
|||
|
|
supabase: DocSupabaseClient;
|
|||
|
|
ctx: DocToolContext;
|
|||
|
|
allowedToolIds: Set<string>;
|
|||
|
|
}) => {
|
|||
|
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
|||
|
|
if (!args.allowedToolIds.has(toolId)) {
|
|||
|
|
throw new Error(`工具未被允许:${toolId}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (toolId === "doc_get") {
|
|||
|
|
const maxNodesRaw = Number(toolArgs.maxBlocks ?? 80);
|
|||
|
|
const maxBlocks = Math.max(10, Math.min(240, Number.isFinite(maxNodesRaw) ? Math.floor(maxNodesRaw) : 80));
|
|||
|
|
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
|||
|
|
const summary = walkSummaries(blocks, maxBlocks);
|
|||
|
|
return { ok: true, source, totalTopLevelBlocks: blocks.length, blocks: summary };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (toolId === "doc_find") {
|
|||
|
|
const query = String(toolArgs.query ?? "").trim();
|
|||
|
|
if (!query) throw new Error("缺少 query");
|
|||
|
|
const maxRaw = Number(toolArgs.maxResults ?? 8);
|
|||
|
|
const maxResults = Math.max(1, Math.min(30, Number.isFinite(maxRaw) ? Math.floor(maxRaw) : 8));
|
|||
|
|
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
|||
|
|
const summary = walkSummaries(blocks, 400);
|
|||
|
|
const q = query.toLowerCase();
|
|||
|
|
const hits = summary.filter((x) => x.text.toLowerCase().includes(q)).slice(0, maxResults);
|
|||
|
|
return { ok: true, source, query, results: hits };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (toolId === "doc_insert_blocks") {
|
|||
|
|
const afterBlockId = String(toolArgs.afterBlockId ?? "").trim();
|
|||
|
|
const beforeBlockId = String(toolArgs.beforeBlockId ?? "").trim();
|
|||
|
|
const specsRaw = toolArgs.blocks;
|
|||
|
|
if (!Array.isArray(specsRaw) || specsRaw.length === 0) throw new Error("缺少 blocks");
|
|||
|
|
if (specsRaw.length > 20) throw new Error("blocks 过多(最多 20)");
|
|||
|
|
|
|||
|
|
const specs: DocBlockSpec[] = specsRaw.map((x) => {
|
|||
|
|
const t = isRecord(x) ? String(x.type ?? "paragraph") : "paragraph";
|
|||
|
|
const text = isRecord(x) ? String(x.text ?? "") : "";
|
|||
|
|
const level = isRecord(x) ? Number(x.level ?? 2) : 2;
|
|||
|
|
return { type: t === "heading" ? "heading" : "paragraph", text, level };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const created = specs.map(buildBlockFromSpec);
|
|||
|
|
|
|||
|
|
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
|||
|
|
const targetId = beforeBlockId || afterBlockId;
|
|||
|
|
const found = targetId ? findContainerById(blocks, targetId) : null;
|
|||
|
|
if (targetId && !found) {
|
|||
|
|
throw new Error(`未找到 blockId:${targetId}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!found) {
|
|||
|
|
blocks.push(...created);
|
|||
|
|
} else {
|
|||
|
|
const insertAt = beforeBlockId ? found.index : found.index + 1;
|
|||
|
|
found.container.splice(insertAt, 0, ...created);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
|||
|
|
return {
|
|||
|
|
ok: true,
|
|||
|
|
source,
|
|||
|
|
inserted: created.map((b) => String(b.id ?? "")),
|
|||
|
|
data: blocks,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (toolId === "doc_replace_range") {
|
|||
|
|
const blockId = String(toolArgs.blockId ?? "").trim();
|
|||
|
|
const text = String(toolArgs.text ?? "").trim();
|
|||
|
|
if (!blockId) throw new Error("缺少 blockId");
|
|||
|
|
if (!text) throw new Error("缺少 text");
|
|||
|
|
const modeRaw = String(toolArgs.mode ?? "replace").trim();
|
|||
|
|
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
|
|||
|
|
|
|||
|
|
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
|||
|
|
const found = findContainerById(blocks, blockId);
|
|||
|
|
if (!found) throw new Error(`未找到 blockId:${blockId}`);
|
|||
|
|
const block = found.container[found.index];
|
|||
|
|
if (!isRecord(block)) throw new Error(`block 数据异常:${blockId}`);
|
|||
|
|
const prevText = extractInlineText(block);
|
|||
|
|
const nextText = mode === "append" ? `${prevText}${text}` : mode === "prepend" ? `${text}${prevText}` : text;
|
|||
|
|
found.container[found.index] = { ...block, content: createTextContent(nextText) };
|
|||
|
|
|
|||
|
|
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
|||
|
|
return { ok: true, source, blockId, mode, data: blocks };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
throw new Error(`未知工具:${toolId}`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return { run };
|
|||
|
|
};
|