Files
mnote/wolai-frontend/src/lib/ai-agent/tools/builtins/doc/docServerTools.ts
T
2026-01-10 23:08:56 +08:00

259 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
};