feat: 完成 rust cutover phase 8 收口
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createBlockSnapshotOpsAdapter as createDocBlockOpsAdapter,
|
||||
type BlockSnapshot as DocBlockSnapshot,
|
||||
} from "@/lib/blocks/block-ops-adapter";
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDocServerTools } from "./docServerTools";
|
||||
|
||||
describe("createDocServerTools", () => {
|
||||
it("doc_insert_blocks 和 doc_replace_range 只操作快照,不直连 loadBlocks", async () => {
|
||||
const baseBlocks = [
|
||||
{
|
||||
id: "block_1",
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
const loadBlocks = vi.fn(async () => ({ blocks: baseBlocks, source: "client" }));
|
||||
const tools = createDocServerTools({
|
||||
ctx: { documentId: "doc_1", userId: "user_1", baseBlocks },
|
||||
allowedToolIds: new Set(["doc_insert_blocks", "doc_replace_range", "doc_get"]),
|
||||
loadBlocks,
|
||||
});
|
||||
|
||||
const insertResult = await tools.run("doc_insert_blocks", {
|
||||
afterBlockId: "block_1",
|
||||
blocks: [{ type: "heading", text: "新标题", level: 2 }],
|
||||
});
|
||||
expect(insertResult.ok).toBe(true);
|
||||
expect(loadBlocks).not.toHaveBeenCalled();
|
||||
expect(Array.isArray((insertResult as any).data)).toBe(true);
|
||||
expect((insertResult as any).data).toHaveLength(2);
|
||||
|
||||
const replaceResult = await tools.run("doc_replace_range", {
|
||||
blockId: "block_1",
|
||||
text: " world",
|
||||
mode: "append",
|
||||
});
|
||||
expect(replaceResult.ok).toBe(true);
|
||||
expect((replaceResult as any).data).toHaveLength(1);
|
||||
expect(((replaceResult as any).data[0] as any).content?.[0]?.text).toBe("helloworld");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,14 @@
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { createDocBlockOpsAdapter } from "./blockOpsAdapter";
|
||||
|
||||
export const DOC_TOOL_IDS = ["doc_get", "doc_find", "doc_insert_blocks", "doc_replace_range"] as const;
|
||||
|
||||
export type DocToolId = (typeof DOC_TOOL_IDS)[number];
|
||||
|
||||
export const isDocToolId = (toolId: string): toolId is DocToolId =>
|
||||
(DOC_TOOL_IDS as readonly string[]).includes(toolId);
|
||||
|
||||
export const getDocToolInvocationKind = (toolId: DocToolId) =>
|
||||
toolId === "doc_get" || toolId === "doc_find" ? "query" : "command";
|
||||
|
||||
export type DocToolContext = {
|
||||
documentId: string;
|
||||
@@ -36,12 +46,6 @@ type DocBlockSummary = {
|
||||
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);
|
||||
|
||||
@@ -85,50 +89,6 @@ const walkSummaries = (rootBlocks: unknown[], maxNodes: number): DocBlockSummary
|
||||
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) {
|
||||
@@ -149,41 +109,24 @@ const loadDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolConte
|
||||
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>;
|
||||
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
|
||||
loadBlocks?: () => Promise<{ blocks: unknown[]; source: string }>;
|
||||
saveBlocks?: (blocks: unknown[]) => Promise<void>;
|
||||
}) => {
|
||||
const blockOps = createDocBlockOpsAdapter({
|
||||
baseBlocks: args.ctx.baseBlocks,
|
||||
loadBlocks: args.loadBlocks,
|
||||
});
|
||||
|
||||
const loadBlocks = async () => {
|
||||
if (args.loadBlocks) return await args.loadBlocks();
|
||||
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks)");
|
||||
return await loadDocumentBlocks(args.supabase, args.ctx);
|
||||
};
|
||||
|
||||
const saveBlocks = async (blocks: unknown[]) => {
|
||||
if (args.saveBlocks) {
|
||||
await args.saveBlocks(blocks);
|
||||
return;
|
||||
}
|
||||
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks)");
|
||||
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
||||
};
|
||||
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has(toolId)) {
|
||||
throw new Error(`工具未被允许:${toolId}`);
|
||||
@@ -216,35 +159,27 @@ export const createDocServerTools = (args: {
|
||||
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 specs = 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 };
|
||||
return {
|
||||
type: (t === "heading" ? "heading" : "paragraph") as "heading" | "paragraph",
|
||||
text,
|
||||
level,
|
||||
};
|
||||
});
|
||||
|
||||
const created = specs.map(buildBlockFromSpec);
|
||||
|
||||
const { blocks, source } = await loadBlocks();
|
||||
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 saveBlocks(blocks);
|
||||
const { data, inserted, source } = await blockOps.insertBlocks({
|
||||
afterBlockId: afterBlockId || undefined,
|
||||
beforeBlockId: beforeBlockId || undefined,
|
||||
blocks: specs,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
source,
|
||||
inserted: created.map((b) => String(b.id ?? "")),
|
||||
data: blocks,
|
||||
inserted,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,17 +191,12 @@ export const createDocServerTools = (args: {
|
||||
const modeRaw = String(toolArgs.mode ?? "replace").trim();
|
||||
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
|
||||
|
||||
const { blocks, source } = await loadBlocks();
|
||||
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 saveBlocks(blocks);
|
||||
return { ok: true, source, blockId, mode, data: blocks };
|
||||
const { data, source } = await blockOps.replaceRange({
|
||||
blockId,
|
||||
text,
|
||||
mode,
|
||||
});
|
||||
return { ok: true, source, blockId, mode, data };
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
|
||||
@@ -37,88 +37,83 @@ export const createMediaServerTools = (args: {
|
||||
loadById?: (id: string) => Promise<unknown | null>;
|
||||
loadByFileUrl?: (fileUrl: string) => Promise<unknown | null>;
|
||||
}) => {
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
||||
const resolveImageReadTransport = async (toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has("image_read")) throw new Error("工具未被允许:image_read");
|
||||
|
||||
if (toolId === "image_read") {
|
||||
const assetId = String(toolArgs.assetId ?? "").trim();
|
||||
const fileUrl = String(toolArgs.fileUrl ?? "").trim();
|
||||
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
||||
const assetId = String(toolArgs.assetId ?? "").trim();
|
||||
const fileUrl = String(toolArgs.fileUrl ?? "").trim();
|
||||
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
||||
|
||||
const resolved = attachmentRef ? resolveAttachment(args.ctx, attachmentRef) : null;
|
||||
const targetAssetId = assetId || resolved?.id || "";
|
||||
const targetUrl = fileUrl || resolved?.fileUrl || "";
|
||||
const resolved = attachmentRef ? resolveAttachment(args.ctx, attachmentRef) : null;
|
||||
const targetAssetId = assetId || resolved?.id || "";
|
||||
const targetUrl = fileUrl || resolved?.fileUrl || "";
|
||||
|
||||
let row: unknown = null;
|
||||
if (targetAssetId) {
|
||||
if (args.loadById) {
|
||||
row = await args.loadById(targetAssetId);
|
||||
} else {
|
||||
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadById/loadByFileUrl)");
|
||||
const { data, error } = await args.supabase
|
||||
.from("media_assets")
|
||||
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
||||
.eq("id", targetAssetId)
|
||||
.single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
||||
}
|
||||
row = data;
|
||||
}
|
||||
} else if (targetUrl) {
|
||||
if (args.loadByFileUrl) {
|
||||
row = await args.loadByFileUrl(targetUrl);
|
||||
} else {
|
||||
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadById/loadByFileUrl)");
|
||||
const { data, error } = await args.supabase
|
||||
.from("media_assets")
|
||||
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
||||
.eq("file_url", targetUrl)
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
||||
}
|
||||
row = data;
|
||||
}
|
||||
let row: unknown = null;
|
||||
if (targetAssetId) {
|
||||
if (args.loadById) {
|
||||
row = await args.loadById(targetAssetId);
|
||||
} else {
|
||||
throw new Error("缺少 assetId / fileUrl / attachmentRef");
|
||||
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadById/loadByFileUrl)");
|
||||
const { data, error } = await args.supabase
|
||||
.from("media_assets")
|
||||
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
||||
.eq("id", targetAssetId)
|
||||
.single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
||||
}
|
||||
row = data;
|
||||
}
|
||||
|
||||
if (!row) return { ok: true, found: false };
|
||||
|
||||
const ocrText = String(pick(row, "ocr_text") ?? "");
|
||||
const ocrStatus = String(pick(row, "ocr_status") ?? "");
|
||||
const mimeType = String(pick(row, "mime_type") ?? "");
|
||||
const result = {
|
||||
ok: true,
|
||||
found: true,
|
||||
asset: {
|
||||
id: String(pick(row, "id") ?? ""),
|
||||
fileName: String(pick(row, "file_name") ?? ""),
|
||||
fileUrl: String(pick(row, "file_url") ?? ""),
|
||||
mimeType,
|
||||
storagePath: pick(row, "storage_path") ? String(pick(row, "storage_path")) : null,
|
||||
bucket: pick(row, "bucket") ? String(pick(row, "bucket")) : null,
|
||||
documentId: pick(row, "document_id") ? String(pick(row, "document_id")) : null,
|
||||
workspaceId: pick(row, "workspace_id") ? String(pick(row, "workspace_id")) : null,
|
||||
deletedAt: pick(row, "deleted_at") ?? null,
|
||||
purgedAt: pick(row, "purged_at") ?? null,
|
||||
updatedAt: pick(row, "updated_at") ?? null,
|
||||
},
|
||||
ocrStatus,
|
||||
ocrText,
|
||||
hasOcrText: Boolean(ocrText.trim()),
|
||||
note: ocrText.trim()
|
||||
? "已返回 ocr_text。"
|
||||
: "该图片暂未生成 ocr_text(可等待后台 OCR,或后续再补一个 image_ocr 写工具)。",
|
||||
};
|
||||
return result;
|
||||
} else if (targetUrl) {
|
||||
if (args.loadByFileUrl) {
|
||||
row = await args.loadByFileUrl(targetUrl);
|
||||
} else {
|
||||
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadById/loadByFileUrl)");
|
||||
const { data, error } = await args.supabase
|
||||
.from("media_assets")
|
||||
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
||||
.eq("file_url", targetUrl)
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
||||
}
|
||||
row = data;
|
||||
}
|
||||
} else {
|
||||
throw new Error("缺少 assetId / fileUrl / attachmentRef");
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
if (!row) return { ok: true, found: false };
|
||||
|
||||
const ocrText = String(pick(row, "ocr_text") ?? "");
|
||||
const ocrStatus = String(pick(row, "ocr_status") ?? "");
|
||||
const mimeType = String(pick(row, "mime_type") ?? "");
|
||||
return {
|
||||
ok: true,
|
||||
found: true,
|
||||
asset: {
|
||||
id: String(pick(row, "id") ?? ""),
|
||||
fileName: String(pick(row, "file_name") ?? ""),
|
||||
fileUrl: String(pick(row, "file_url") ?? ""),
|
||||
mimeType,
|
||||
storagePath: pick(row, "storage_path") ? String(pick(row, "storage_path")) : null,
|
||||
bucket: pick(row, "bucket") ? String(pick(row, "bucket")) : null,
|
||||
documentId: pick(row, "document_id") ? String(pick(row, "document_id")) : null,
|
||||
workspaceId: pick(row, "workspace_id") ? String(pick(row, "workspace_id")) : null,
|
||||
deletedAt: pick(row, "deleted_at") ?? null,
|
||||
purgedAt: pick(row, "purged_at") ?? null,
|
||||
updatedAt: pick(row, "updated_at") ?? null,
|
||||
},
|
||||
ocrStatus,
|
||||
ocrText,
|
||||
hasOcrText: Boolean(ocrText.trim()),
|
||||
note: ocrText.trim()
|
||||
? "已返回 ocr_text。"
|
||||
: "该图片暂未生成 ocr_text(可等待后台 OCR,或后续再补一个 image_ocr 写工具)。",
|
||||
};
|
||||
};
|
||||
|
||||
return { run };
|
||||
return { resolveImageReadTransport };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||
import { DocumentBridgeError } from "@/lib/documents/bridge";
|
||||
import {
|
||||
applyMindmapOps,
|
||||
classifyMindmapOpErrors,
|
||||
ensureMindmapUids,
|
||||
type MindmapOp,
|
||||
type MindmapTreeNode,
|
||||
type NodeRef,
|
||||
} from "@/lib/mindmap/mindmapOps";
|
||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||
import { searchSearxng, type SearxResult } from "../searchWeb";
|
||||
|
||||
@@ -28,6 +36,16 @@ type SupabaseQuery = {
|
||||
|
||||
export type MindmapSupabaseClient = SupabaseRouteClient;
|
||||
|
||||
type MindmapRustToolRunner = (input: {
|
||||
toolId: string;
|
||||
invocationKind: "query" | "command" | "job";
|
||||
toolArgs: Record<string, unknown>;
|
||||
data: MindmapTreeNode;
|
||||
target?: { workspaceId?: string | null; pageId?: string | null; blockId?: string | null } | null;
|
||||
reason?: string | null;
|
||||
refs?: string[];
|
||||
}) => Promise<unknown>;
|
||||
|
||||
const defaultMindmapData: MindmapTreeNode = { data: { text: "中心主题" }, children: [] };
|
||||
|
||||
const safeUrlOrNull = (value: unknown) => {
|
||||
@@ -123,6 +141,23 @@ const mergeRefsUnique = (a: NodeRef[], b: NodeRef[]) => {
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
const throwMindmapOperationError = (
|
||||
message: string,
|
||||
kind: "rejected" | "failed",
|
||||
details?: Record<string, unknown>,
|
||||
) => {
|
||||
if (kind === "rejected") {
|
||||
throw new DocumentBridgeError(message, 409, "REJECTED", {
|
||||
reason: "mindmap_rejected",
|
||||
...details,
|
||||
});
|
||||
}
|
||||
throw new DocumentBridgeError(message, 500, "TRANSPORT_ERROR", {
|
||||
reason: "mindmap_failed",
|
||||
...details,
|
||||
});
|
||||
};
|
||||
|
||||
const sanitizeAddChildOps = (args: {
|
||||
targetUid: string;
|
||||
currentChildren: string[];
|
||||
@@ -192,6 +227,7 @@ export const createMindmapServerTools = (args: {
|
||||
ctx: MindmapToolContext;
|
||||
cfg: OpenAiCompatibleChatOptions;
|
||||
allowedToolIds: Set<string>;
|
||||
runRustTool?: MindmapRustToolRunner;
|
||||
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase/local 文件。
|
||||
loadMindmap?: () => Promise<{
|
||||
doc: { id: string; title: string | null; workspace_id: string | null };
|
||||
@@ -242,9 +278,38 @@ export const createMindmapServerTools = (args: {
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
};
|
||||
|
||||
const mindmapTarget = (doc: { id: string; title: string | null; workspace_id: string | null }) => ({
|
||||
pageId: doc.id,
|
||||
workspaceId: doc.workspace_id ?? null,
|
||||
blockId: args.ctx.mindmapId,
|
||||
});
|
||||
|
||||
const withMindmapIds = (toolArgs: Record<string, unknown>) => ({
|
||||
...toolArgs,
|
||||
documentId: args.ctx.documentId,
|
||||
mindmapId: args.ctx.mindmapId,
|
||||
});
|
||||
|
||||
const persistResultData = async (doc: { id: string; title: string | null; workspace_id: string | null }, result: unknown) => {
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) return;
|
||||
const nextData = (result as Record<string, unknown>).data;
|
||||
if (!nextData || typeof nextData !== "object" || Array.isArray(nextData)) return;
|
||||
await persistMindmap(doc, nextData as MindmapTreeNode);
|
||||
};
|
||||
|
||||
const mindmap_get = async (toolArgs: Record<string, unknown>) => {
|
||||
const maxNodes = Number(toolArgs.maxNodes ?? 120);
|
||||
const { base } = await loadMindmap();
|
||||
const loaded = await loadMindmap();
|
||||
const { base } = loaded;
|
||||
if (args.runRustTool) {
|
||||
return await args.runRustTool({
|
||||
toolId: "mindmap_get",
|
||||
invocationKind: "query",
|
||||
toolArgs: withMindmapIds({ maxNodes }),
|
||||
data: base,
|
||||
target: mindmapTarget(loaded.doc),
|
||||
});
|
||||
}
|
||||
const list = walkSummaries(base, Number.isFinite(maxNodes) ? Math.max(10, Math.min(300, Math.floor(maxNodes))) : 120);
|
||||
return { ok: true, documentId: args.ctx.documentId, mindmapId: args.ctx.mindmapId, nodes: list };
|
||||
};
|
||||
@@ -254,7 +319,17 @@ export const createMindmapServerTools = (args: {
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
const depth = Number(toolArgs.depth ?? 2);
|
||||
const maxNodes = Number(toolArgs.maxNodes ?? 60);
|
||||
const { base } = await loadMindmap();
|
||||
const loaded = await loadMindmap();
|
||||
const { base } = loaded;
|
||||
if (args.runRustTool) {
|
||||
return await args.runRustTool({
|
||||
toolId: "mindmap_get_subtree",
|
||||
invocationKind: "query",
|
||||
toolArgs: withMindmapIds({ uid, depth, maxNodes }),
|
||||
data: base,
|
||||
target: mindmapTarget(loaded.doc),
|
||||
});
|
||||
}
|
||||
const hit = findNodeByUid(base, uid);
|
||||
if (!hit) throw new Error(`未找到 uid=${uid}`);
|
||||
const list = summarizeSubtree(
|
||||
@@ -265,6 +340,33 @@ export const createMindmapServerTools = (args: {
|
||||
return { ok: true, uid, nodes: list };
|
||||
};
|
||||
|
||||
const resolveMindmapPutTree = (toolArgs: Record<string, unknown>) => {
|
||||
const raw = toolArgs.data ?? toolArgs.tree ?? toolArgs.mindmap ?? null;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new Error("缺少 data");
|
||||
}
|
||||
return raw as MindmapTreeNode;
|
||||
};
|
||||
|
||||
const mindmap_put = async (toolArgs: Record<string, unknown>) => {
|
||||
const loaded = await loadMindmap();
|
||||
const data = resolveMindmapPutTree(toolArgs);
|
||||
if (args.runRustTool) {
|
||||
const result = await args.runRustTool({
|
||||
toolId: "mindmap_put",
|
||||
invocationKind: "command",
|
||||
toolArgs: withMindmapIds({ ...toolArgs, data }),
|
||||
data: loaded.base,
|
||||
target: mindmapTarget(loaded.doc),
|
||||
});
|
||||
await persistResultData(loaded.doc, result);
|
||||
return result;
|
||||
}
|
||||
ensureMindmapUids(data);
|
||||
await persistMindmap(loaded.doc, data);
|
||||
return { ok: true, data };
|
||||
};
|
||||
|
||||
const mindmap_apply_ops = async (toolArgs: Record<string, unknown>) => {
|
||||
const ops = (toolArgs.ops ?? []) as unknown;
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
@@ -336,8 +438,28 @@ export const createMindmapServerTools = (args: {
|
||||
const normalized = ops.map((x) => normalizeOp(x)).filter(Boolean) as MindmapOp[];
|
||||
if (normalized.length === 0) throw new Error("ops 无有效操作(请使用 MindmapOp 协议或已支持的别名)");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const loaded = await loadMindmap();
|
||||
const { base } = loaded;
|
||||
if (args.runRustTool) {
|
||||
const result = await args.runRustTool({
|
||||
toolId: "mindmap_apply_ops",
|
||||
invocationKind: "command",
|
||||
toolArgs: withMindmapIds({ ops: normalized, reason }),
|
||||
data: base,
|
||||
target: mindmapTarget(doc),
|
||||
reason,
|
||||
});
|
||||
await persistResultData(doc, result);
|
||||
return result;
|
||||
}
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
||||
if (errors.length > 0) {
|
||||
throwMindmapOperationError("mindmap_apply_ops 存在冲突或非法操作", classifyMindmapOpErrors(errors) ?? "failed", {
|
||||
errors,
|
||||
applied,
|
||||
opCount: normalized.length,
|
||||
});
|
||||
}
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
@@ -352,15 +474,12 @@ export const createMindmapServerTools = (args: {
|
||||
if (!parentUid) throw new Error("缺少 parentUid");
|
||||
if (!text) throw new Error("缺少 text");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = {
|
||||
op: "addChild",
|
||||
parentUid,
|
||||
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||
};
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_add_sibling_after = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -373,15 +492,12 @@ export const createMindmapServerTools = (args: {
|
||||
if (!targetUid) throw new Error("缺少 targetUid");
|
||||
if (!text) throw new Error("缺少 text");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = {
|
||||
op: "addSiblingAfter",
|
||||
targetUid,
|
||||
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||
};
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_update_node_text = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -390,11 +506,8 @@ export const createMindmapServerTools = (args: {
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!text) throw new Error("缺少 text");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "updateText", uid, text };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_set_hyperlink = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -406,11 +519,8 @@ export const createMindmapServerTools = (args: {
|
||||
if (hyperlinkRaw !== null && hyperlinkRaw !== undefined && !hyperlink) {
|
||||
throw new Error("hyperlink 必须是 http(s) URL 或 null");
|
||||
}
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_append_note = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -419,11 +529,8 @@ export const createMindmapServerTools = (args: {
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!markdown) throw new Error("缺少 markdown");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_set_refs = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -432,22 +539,16 @@ export const createMindmapServerTools = (args: {
|
||||
const refs = Array.isArray(toolArgs.refs) ? (toolArgs.refs as NodeRef[]) : [];
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!refs.length) throw new Error("缺少 refs");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "setRefs", uid, refs };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_delete_node = async (toolArgs: Record<string, unknown>) => {
|
||||
const uid = String(toolArgs.uid ?? "").trim();
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "deleteNode", uid };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_add_attachment_ref = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -462,7 +563,8 @@ export const createMindmapServerTools = (args: {
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const loaded = await loadMindmap();
|
||||
const { base } = loaded;
|
||||
const node = findNodeByUid(base, uid);
|
||||
if (!node) throw new Error(`未找到 uid=${uid}`);
|
||||
|
||||
@@ -499,9 +601,7 @@ export const createMindmapServerTools = (args: {
|
||||
const prevRefs = Array.isArray(node.data?.refs) ? (node.data.refs as NodeRef[]) : [];
|
||||
const nextRefs = mode === "replace" ? [ref] : mergeRefsUnique(prevRefs, [ref]);
|
||||
const op: MindmapOp = { op: "setRefs", uid, refs: nextRefs };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_add_attachment_child = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -517,7 +617,6 @@ export const createMindmapServerTools = (args: {
|
||||
if (!parentUid) throw new Error("缺少 parentUid");
|
||||
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const resolved = attachmentId ? resolveAttachmentFromContext(args.ctx, attachmentId) : null;
|
||||
const finalUrl = String(resolved?.fileUrl || fileUrlDirect || "").trim();
|
||||
const finalTitle = String(titleOverride || resolved?.title || attachmentId || "附件").trim();
|
||||
@@ -552,9 +651,7 @@ export const createMindmapServerTools = (args: {
|
||||
parentUid,
|
||||
node: { text, ...(hyperlink ? { hyperlink } : {}), refs: [ref], ...(note ? { note } : {}) },
|
||||
};
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_add_image_child = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -583,11 +680,7 @@ export const createMindmapServerTools = (args: {
|
||||
refs: [{ kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title }],
|
||||
},
|
||||
};
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_append_image_note = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -604,16 +697,18 @@ export const createMindmapServerTools = (args: {
|
||||
if (!url) throw new Error("图片缺少 URL");
|
||||
|
||||
const markdown = ``;
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
const result = await mindmap_apply_ops({ ops: [op], reason });
|
||||
if (result && typeof result === "object") {
|
||||
return {
|
||||
...(result as Record<string, unknown>),
|
||||
meta: { reason, ref: { kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title } },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
applied,
|
||||
errors,
|
||||
data: nextData,
|
||||
meta: { reason, ref: { kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title } },
|
||||
result,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -623,7 +718,8 @@ export const createMindmapServerTools = (args: {
|
||||
const instruction = String(toolArgs.instruction ?? "").trim();
|
||||
const useSearx = args.allowedToolIds.has("search_web");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const loaded = await loadMindmap();
|
||||
const { base } = loaded;
|
||||
const target = findNodeByUid(base, targetUid);
|
||||
if (!target) throw new Error("未找到目标节点(uid 不存在)");
|
||||
|
||||
@@ -707,22 +803,26 @@ export const createMindmapServerTools = (args: {
|
||||
}
|
||||
|
||||
const fixed = sanitizeAddChildOps({ targetUid, currentChildren, ops, searxResults });
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, fixed);
|
||||
await persistMindmap(doc, nextData);
|
||||
|
||||
const result = await mindmap_apply_ops({ ops: fixed, reason: instruction || null });
|
||||
if (result && typeof result === "object") {
|
||||
return {
|
||||
...(result as Record<string, unknown>),
|
||||
ops: fixed,
|
||||
meta: { finishReason, searched: useSearx, searxCount: searxResults.length },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
applied,
|
||||
errors,
|
||||
ops: fixed,
|
||||
data: nextData,
|
||||
meta: { finishReason, searched: useSearx, searxCount: searxResults.length },
|
||||
result,
|
||||
};
|
||||
};
|
||||
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (toolId === "mindmap_get") return await mindmap_get(toolArgs);
|
||||
if (toolId === "mindmap_get_subtree") return await mindmap_get_subtree(toolArgs);
|
||||
if (toolId === "mindmap_put") return await mindmap_put(toolArgs);
|
||||
if (toolId === "mindmap_apply_ops") return await mindmap_apply_ops(toolArgs);
|
||||
if (toolId === "mindmap_add_child") return await mindmap_add_child(toolArgs);
|
||||
if (toolId === "mindmap_add_sibling_after") return await mindmap_add_sibling_after(toolArgs);
|
||||
|
||||
@@ -394,3 +394,43 @@ export const builtinToolSets: AiAgentToolSet[] = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export type BuiltinRustCutoverStatus = "rust" | "mixed" | "ts" | "transport";
|
||||
|
||||
export type BuiltinRustCutoverBinding = {
|
||||
rustToolsetId: string;
|
||||
rustToolName: string | null;
|
||||
status: BuiltinRustCutoverStatus;
|
||||
note: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 当前只把已经进入 Rust runtime 的 builtin tool 做成代码侧绑定。
|
||||
* 完整的一一对应矩阵见 `design/ai-tool-cutover-matrix.md`。
|
||||
*/
|
||||
export const builtinRustCutoverBindings: Record<string, BuiltinRustCutoverBinding> = {
|
||||
search_web: {
|
||||
rustToolsetId: "toolset.readonly",
|
||||
rustToolName: "search_web",
|
||||
status: "rust",
|
||||
note: "联网检索已切到 Rust runtime,TS 仅保留 route transport 壳。",
|
||||
},
|
||||
image_read: {
|
||||
rustToolsetId: "toolset.media_read",
|
||||
rustToolName: "image_read",
|
||||
status: "rust",
|
||||
note: "图片/附件 OCR 结果归一化已切到 Rust runtime,TS 只负责最小 transport 取数。",
|
||||
},
|
||||
slash_run: {
|
||||
rustToolsetId: "toolset.slash_write",
|
||||
rustToolName: "slash_run",
|
||||
status: "rust",
|
||||
note: "斜杠命令解析已切到 Rust runtime,TS 仅保留创建/重命名 transport 写壳。",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const builtinRustRuntimeToolIds = new Set<string>(
|
||||
Object.entries(builtinRustCutoverBindings)
|
||||
.filter(([, binding]) => binding.status === "rust" || binding.status === "mixed")
|
||||
.map(([toolId]) => toolId),
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ export const searchSearxng = async (q: string, count = 6): Promise<SearxResult[]
|
||||
const value = obj.results;
|
||||
return Array.isArray(value) ? (value as unknown[]) : [];
|
||||
})();
|
||||
return results
|
||||
const normalized = results
|
||||
.map((r: unknown) => {
|
||||
const obj = (typeof r === "object" && r ? (r as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||
return {
|
||||
@@ -48,6 +48,7 @@ export const searchSearxng = async (q: string, count = 6): Promise<SearxResult[]
|
||||
engine: String(obj.engine ?? "").trim(),
|
||||
};
|
||||
})
|
||||
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
|
||||
.slice(0, Math.max(1, Math.min(10, count)));
|
||||
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
|
||||
.slice(0, Math.max(1, Math.min(10, count)));
|
||||
return normalized;
|
||||
};
|
||||
|
||||
@@ -85,141 +85,112 @@ export const createSlashServerTools = (args: {
|
||||
updatedAt: unknown;
|
||||
}>;
|
||||
}) => {
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
||||
|
||||
if (toolId === "slash_run") {
|
||||
const text = String(toolArgs.text ?? "").trim();
|
||||
const command = String(toolArgs.command ?? "").trim();
|
||||
const params = isRecord(toolArgs.params) ? toolArgs.params : null;
|
||||
|
||||
const parsed: ParsedSlash =
|
||||
text && text.startsWith("/") ? parseSlash(text) : command === "new_doc"
|
||||
? {
|
||||
ok: true,
|
||||
command: "new_doc",
|
||||
params: {
|
||||
title: String(pick(params, "title") ?? "").trim(),
|
||||
parentId: pick(params, "parentId") ? String(pick(params, "parentId")) : null,
|
||||
workspaceId: pick(params, "workspaceId") ? String(pick(params, "workspaceId")) : null,
|
||||
},
|
||||
}
|
||||
: command === "rename_doc"
|
||||
? {
|
||||
ok: true,
|
||||
command: "rename_doc",
|
||||
params: {
|
||||
documentId: String(pick(params, "documentId") ?? "").trim(),
|
||||
title: String(pick(params, "title") ?? "").trim(),
|
||||
},
|
||||
}
|
||||
: { ok: false, error: "缺少 text(以 / 开头)或 command" };
|
||||
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
|
||||
if (parsed.command === "new_doc") {
|
||||
const title = String(parsed.params.title ?? "").trim();
|
||||
if (!title) throw new Error("缺少标题");
|
||||
const parentId = parsed.params.parentId ? String(parsed.params.parentId) : null;
|
||||
const workspaceIdFromParams = parsed.params.workspaceId ? String(parsed.params.workspaceId) : null;
|
||||
const workspaceId =
|
||||
workspaceIdFromParams ||
|
||||
(args.ctx.currentDocumentId
|
||||
? args.inferWorkspaceIdFromDoc
|
||||
? await args.inferWorkspaceIdFromDoc(args.ctx.currentDocumentId)
|
||||
: args.supabase
|
||||
? await inferWorkspaceIdFromDoc(args.supabase, args.ctx.currentDocumentId)
|
||||
: null
|
||||
: null) ||
|
||||
((args.loadWorkspaceIds
|
||||
? (await args.loadWorkspaceIds(args.ctx.userId))[0]
|
||||
: args.supabase
|
||||
? (await loadWorkspaceIds(args.supabase, args.ctx.userId))[0]
|
||||
: null) ?? null) ||
|
||||
null;
|
||||
if (!workspaceId) throw new Error("无法推断 workspaceId(请在 params.workspaceId 指定)");
|
||||
|
||||
if (args.createDoc) {
|
||||
const doc = await args.createDoc({
|
||||
userId: args.ctx.userId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
title,
|
||||
});
|
||||
return { ok: true, command: "new_doc", document: doc };
|
||||
}
|
||||
|
||||
if (!args.supabase) {
|
||||
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 createDoc/renameDoc)");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
workspace_id: workspaceId,
|
||||
user_id: args.ctx.userId,
|
||||
parent_id: parentId,
|
||||
title,
|
||||
content: [] as unknown[],
|
||||
raw_text: "",
|
||||
};
|
||||
const { data, error } = await args.supabase.from("documents").insert(payload).select("id,workspace_id,parent_id,title,created_at,updated_at").single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "创建文档失败");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "new_doc",
|
||||
document: {
|
||||
id: String(pick(data, "id") ?? ""),
|
||||
title: String(pick(data, "title") ?? ""),
|
||||
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||
createdAt: pick(data, "created_at") ?? null,
|
||||
updatedAt: pick(data, "updated_at") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.command === "rename_doc") {
|
||||
const documentId = String(parsed.params.documentId ?? "").trim();
|
||||
const title = String(parsed.params.title ?? "").trim();
|
||||
if (!documentId || !title) throw new Error("缺少 documentId 或 title");
|
||||
|
||||
if (args.renameDoc) {
|
||||
const doc = await args.renameDoc({ userId: args.ctx.userId, documentId, title });
|
||||
return { ok: true, command: "rename_doc", document: doc };
|
||||
}
|
||||
|
||||
if (!args.supabase) {
|
||||
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 createDoc/renameDoc)");
|
||||
}
|
||||
|
||||
const { data, error } = await args.supabase
|
||||
.from("documents")
|
||||
.update({ title })
|
||||
.eq("id", documentId)
|
||||
.select("id,workspace_id,parent_id,title,updated_at")
|
||||
.single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "重命名失败");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "rename_doc",
|
||||
document: {
|
||||
id: String(pick(data, "id") ?? ""),
|
||||
title: String(pick(data, "title") ?? ""),
|
||||
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||
updatedAt: pick(data, "updated_at") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`未实现命令:${(parsed as any).command}`);
|
||||
const executeSlashTransport = async (parsed: ParsedSlash) => {
|
||||
if (!args.allowedToolIds.has("slash_run")) throw new Error("工具未被允许:slash_run");
|
||||
if (!parsed.ok) {
|
||||
throw new Error("error" in parsed ? parsed.error : "缺少 text(以 / 开头)或 command");
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
if (parsed.command === "new_doc") {
|
||||
const title = String(parsed.params.title ?? "").trim();
|
||||
if (!title) throw new Error("缺少标题");
|
||||
const parentId = parsed.params.parentId ? String(parsed.params.parentId) : null;
|
||||
const workspaceIdFromParams = parsed.params.workspaceId ? String(parsed.params.workspaceId) : null;
|
||||
const workspaceId =
|
||||
workspaceIdFromParams ||
|
||||
(args.ctx.currentDocumentId
|
||||
? args.inferWorkspaceIdFromDoc
|
||||
? await args.inferWorkspaceIdFromDoc(args.ctx.currentDocumentId)
|
||||
: args.supabase
|
||||
? await inferWorkspaceIdFromDoc(args.supabase, args.ctx.currentDocumentId)
|
||||
: null
|
||||
: null) ||
|
||||
((args.loadWorkspaceIds
|
||||
? (await args.loadWorkspaceIds(args.ctx.userId))[0]
|
||||
: args.supabase
|
||||
? (await loadWorkspaceIds(args.supabase, args.ctx.userId))[0]
|
||||
: null) ?? null) ||
|
||||
null;
|
||||
if (!workspaceId) throw new Error("无法推断 workspaceId(请在 params.workspaceId 指定)");
|
||||
|
||||
if (args.createDoc) {
|
||||
const doc = await args.createDoc({
|
||||
userId: args.ctx.userId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
title,
|
||||
});
|
||||
return { ok: true, command: "new_doc", document: doc };
|
||||
}
|
||||
|
||||
if (!args.supabase) {
|
||||
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 createDoc/renameDoc)");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
workspace_id: workspaceId,
|
||||
user_id: args.ctx.userId,
|
||||
parent_id: parentId,
|
||||
title,
|
||||
content: [] as unknown[],
|
||||
raw_text: "",
|
||||
};
|
||||
const { data, error } = await args.supabase.from("documents").insert(payload).select("id,workspace_id,parent_id,title,created_at,updated_at").single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "创建文档失败");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "new_doc",
|
||||
document: {
|
||||
id: String(pick(data, "id") ?? ""),
|
||||
title: String(pick(data, "title") ?? ""),
|
||||
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||
createdAt: pick(data, "created_at") ?? null,
|
||||
updatedAt: pick(data, "updated_at") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.command === "rename_doc") {
|
||||
const documentId = String(parsed.params.documentId ?? "").trim();
|
||||
const title = String(parsed.params.title ?? "").trim();
|
||||
if (!documentId || !title) throw new Error("缺少 documentId 或 title");
|
||||
|
||||
if (args.renameDoc) {
|
||||
const doc = await args.renameDoc({ userId: args.ctx.userId, documentId, title });
|
||||
return { ok: true, command: "rename_doc", document: doc };
|
||||
}
|
||||
|
||||
if (!args.supabase) {
|
||||
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 createDoc/renameDoc)");
|
||||
}
|
||||
|
||||
const { data, error } = await args.supabase
|
||||
.from("documents")
|
||||
.update({ title })
|
||||
.eq("id", documentId)
|
||||
.select("id,workspace_id,parent_id,title,updated_at")
|
||||
.single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "重命名失败");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "rename_doc",
|
||||
document: {
|
||||
id: String(pick(data, "id") ?? ""),
|
||||
title: String(pick(data, "title") ?? ""),
|
||||
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||
updatedAt: pick(data, "updated_at") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`未实现命令:${(parsed as any).command}`);
|
||||
};
|
||||
|
||||
return { run };
|
||||
return { parseSlash, executeSlashTransport };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user