0.1.14 上线前更改

This commit is contained in:
liaibo
2026-01-11 12:35:53 +08:00
parent be71849aa5
commit 725a60d3aa
44 changed files with 3427 additions and 310 deletions
@@ -0,0 +1,74 @@
export type ClientToolResult =
| { ok: true; result: unknown }
| { ok: false; error: string };
type PendingClientTool = {
userId: string;
createdAt: number;
timeoutId: ReturnType<typeof setTimeout>;
resolve: (v: ClientToolResult) => void;
};
const nowMs = () => Date.now();
// 说明:这是一个“进程内”桥接(仅用于本地/单实例)。
// 若未来部署到多实例/Serverless,需要替换为 Redis / Realtime / WebSocket 等可共享通道。
const pendingCalls: Map<string, PendingClientTool> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((globalThis as any).__mnote_pending_client_tool_calls as Map<string, PendingClientTool> | undefined) ??
new Map<string, PendingClientTool>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).__mnote_pending_client_tool_calls = pendingCalls;
export const buildClientToolKey = (requestId: string, callId: string) =>
`${String(requestId)}:${String(callId)}`;
export const registerClientToolCall = ({
key,
userId,
timeoutMs = 60_000,
}: {
key: string;
userId: string;
timeoutMs?: number;
}): Promise<ClientToolResult> => {
const existing = pendingCalls.get(key);
if (existing) {
// 同一个 call 只能注册一次,避免重复等待导致难以清理。
return Promise.resolve({ ok: false, error: "客户端工具调用已注册(重复注册)" });
}
return new Promise<ClientToolResult>((resolve) => {
const timeoutId = setTimeout(() => {
pendingCalls.delete(key);
resolve({ ok: false, error: "客户端工具执行超时(未收到回调)" });
}, Math.max(1_000, timeoutMs));
pendingCalls.set(key, {
userId,
createdAt: nowMs(),
timeoutId,
resolve,
});
});
};
export const resolveClientToolCall = ({
key,
userId,
result,
}: {
key: string;
userId: string;
result: ClientToolResult;
}): { ok: true } | { ok: false; error: string } => {
const pending = pendingCalls.get(key);
if (!pending) return { ok: false, error: "未找到待回调的客户端工具调用(可能已超时)" };
if (pending.userId !== userId) return { ok: false, error: "无权限回调该工具调用" };
clearTimeout(pending.timeoutId);
pendingCalls.delete(key);
pending.resolve(result);
return { ok: true };
};
@@ -30,6 +30,7 @@ const safeParseJsonObject = (raw: string): Record<string, unknown> | null => {
const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string) => {
const toolLines: string[] = [];
const guideLines: string[] = [];
if (allowedTools.has("search_web")) {
toolLines.push("- search_web<search_web>{\"query\":\"...\",\"count\":6}</search_web>");
}
@@ -47,6 +48,45 @@ const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string
if (allowedTools.has("image_read")) {
toolLines.push("- image_read<image_read>{\"attachmentRef\":\"...\"}</image_read>");
}
if (allowedTools.has("asset_extract_outline")) {
toolLines.push(
"- asset_extract_outline<asset_extract_outline>{\"attachmentRef\":\"...\"}</asset_extract_outline>",
);
}
if (allowedTools.has("asset_to_mindmap")) {
toolLines.push(
"- asset_to_mindmap<asset_to_mindmap>{\"mindmapId\":\"...\",\"assetId\":\"...\",\"maxItems\":120,\"reason\":\"...\"}</asset_to_mindmap>",
);
}
if (allowedTools.has("oo_get_selection")) {
toolLines.push("- oo_get_selection<oo_get_selection>{\"format\":\"text\"}</oo_get_selection>");
}
if (allowedTools.has("oo_replace_selection")) {
toolLines.push(
"- oo_replace_selection<oo_replace_selection>{\"text\":\"...\",\"format\":\"text\",\"reason\":\"...\"}</oo_replace_selection>",
);
}
if (allowedTools.has("oo_insert_text")) {
toolLines.push("- oo_insert_text<oo_insert_text>{\"text\":\"...\",\"reason\":\"...\"}</oo_insert_text>");
}
if (allowedTools.has("oo_insert_html")) {
toolLines.push("- oo_insert_html<oo_insert_html>{\"html\":\"<p>...</p>\",\"reason\":\"...\"}</oo_insert_html>");
}
if (allowedTools.has("oo_insert_image")) {
toolLines.push(
"- oo_insert_image<oo_insert_image>{\"imageRef\":\"...\",\"width\":320,\"height\":180,\"reason\":\"...\"}</oo_insert_image>",
);
}
if (
allowedTools.has("oo_get_selection") ||
allowedTools.has("oo_replace_selection") ||
allowedTools.has("oo_insert_text") ||
allowedTools.has("oo_insert_html") ||
allowedTools.has("oo_insert_image")
) {
guideLines.push("- OnlyOffice 增/删/改:先 oo_get_selection 读选区,再用 oo_replace_selection / oo_insert_* 写回。");
guideLines.push("- oo_* 属于客户端插件工具:会直接修改当前 OnlyOffice 文档的选区/光标位置。");
}
if (allowedTools.has("slash_run")) {
toolLines.push("- slash_run<slash_run>{\"text\":\"/new 新页面标题\"}</slash_run>");
}
@@ -113,8 +153,6 @@ const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string
if (allowedTools.has("mindmap_expand_node")) {
toolLines.push("- mindmap_expand_node<mindmap_expand_node>{\"targetUid\":\"...\",\"instruction\":\"...\"}</mindmap_expand_node>");
}
const guideLines: string[] = [];
guideLines.push("- 先用只读工具定位(需要时再检索),再做最小范围写入。");
if (allowedTools.has("search_web")) {
guideLines.push("- 需要来源时先 search_web,再把 URL 放进最终回答或写入引用字段。");
@@ -128,6 +166,10 @@ const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string
if (allowedTools.has("image_read")) {
guideLines.push("- 需要读图:用 image_read 从 media_assets.ocr_text 获取文字(attachmentRef 可用附件 id/title/url 片段)。");
}
if (allowedTools.has("asset_extract_outline") || allowedTools.has("asset_to_mindmap")) {
guideLines.push("- PDF→导图(M3):先 asset_extract_outline 提取标题层级/页码,再根据需要调用 asset_to_mindmap 落盘到指定 mindmap。");
guideLines.push("- 注意:asset_to_mindmap 是写工具,只有在用户明确要求“生成/写入思维导图”时才调用。");
}
if (allowedTools.has("slash_run")) {
guideLines.push("- 需要创建/改名:用 slash_run 执行 /new 或 /rename(这是写工具,只有在用户明确要求时才调用)。");
}
@@ -160,6 +202,18 @@ const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string
if (allowedTools.has("slash_run")) {
hardRules.push("- slash_run 属于写工具:只有在用户明确要求“创建/改名/执行斜杠命令”时才调用;否则不要擅自创建新文档。");
}
if (allowedTools.has("asset_to_mindmap")) {
hardRules.push("- asset_to_mindmap 属于写工具:只有在用户明确要求“从附件生成/写入思维导图”时才调用;否则不要擅自写入导图。");
}
if (
allowedTools.has("oo_replace_selection") ||
allowedTools.has("oo_insert_text") ||
allowedTools.has("oo_insert_html") ||
allowedTools.has("oo_insert_image")
) {
hardRules.push("- oo_* 属于写工具:只有在用户明确要求“修改/补全/插入 OnlyOffice 文档内容”时才调用;否则不要擅自改文档。");
hardRules.push("- 删除选区:用 oo_replace_selection 且 text 传空字符串。");
}
if (allowedTools.has("mindmap_apply_ops") || allowedTools.has("mindmap_expand_node")) {
hardRules.push("- 涉及思维导图写入时:只能通过 mindmap_* 写工具落盘;不要输出整棵树覆盖。");
}
@@ -0,0 +1,384 @@
import supabaseAdmin from "@/lib/supabase/admin";
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
type SupabaseRouteClient = {
from: (table: string) => any;
};
export type OnlyOfficeSupabaseClient = SupabaseRouteClient;
export type OnlyOfficeToolContext = {
userId: string;
documentId?: string;
// 来自前端(@ 选择/上传)的附件列表,优先使用(避免额外查询)
attachments?: Array<{ id: string; title: string; fileUrl: string; mimeType?: string | null }>;
};
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
type ResolvedAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
const resolveAttachment = (ctx: OnlyOfficeToolContext, ref: string): ResolvedAttachment | null => {
const s = String(ref || "").trim();
if (!s) return null;
const list = Array.isArray(ctx.attachments) ? ctx.attachments : [];
const byId = list.find((a) => String(a.id) === s);
if (byId) return { id: String(byId.id), title: String(byId.title || byId.id), fileUrl: String(byId.fileUrl || ""), mimeType: byId.mimeType ?? null };
const exactTitle = list.find((a) => String(a.title) === s);
if (exactTitle)
return { id: String(exactTitle.id), title: String(exactTitle.title || exactTitle.id), fileUrl: String(exactTitle.fileUrl || ""), mimeType: exactTitle.mimeType ?? null };
const fuzzy = list.find((a) => String(a.title || "").includes(s) || String(a.fileUrl || "").includes(s));
if (fuzzy) return { id: String(fuzzy.id), title: String(fuzzy.title || fuzzy.id), fileUrl: String(fuzzy.fileUrl || ""), mimeType: fuzzy.mimeType ?? null };
return null;
};
const parseStoragePath = (fileUrl: string) => {
try {
const url = new URL(fileUrl);
const segments = url.pathname.split("/").filter(Boolean);
const objectIdx = segments.findIndex((seg) => seg === "object");
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
if (segments[objectIdx + 1] === "public") {
const bucket = segments[objectIdx + 2];
const path = segments.slice(objectIdx + 3).join("/");
return { bucket, path };
}
if (segments[objectIdx + 1] === "sign") {
const bucket = segments[objectIdx + 2];
const path = segments.slice(objectIdx + 3).join("/");
return { bucket, path };
}
return null;
} catch {
return null;
}
};
const guessExt = (fileNameOrUrl: string) => {
const s = String(fileNameOrUrl || "").trim();
if (!s) return "";
const cleaned = s.split("?")[0].split("#")[0];
const parts = cleaned.split(".");
if (parts.length < 2) return "";
return String(parts[parts.length - 1] || "").toLowerCase();
};
const isPdf = (mimeType: string, fileName: string) => {
const m = String(mimeType || "").toLowerCase();
if (m.includes("pdf")) return true;
return guessExt(fileName) === "pdf";
};
const signForDownload = async (row: Record<string, unknown>) => {
const bucket = typeof row.bucket === "string" ? row.bucket : "";
const storagePath = typeof row.storage_path === "string" ? row.storage_path : "";
const fileUrl = typeof row.file_url === "string" ? row.file_url : "";
const fileName = typeof row.file_name === "string" ? row.file_name : undefined;
if (bucket && storagePath) {
const { data, error } = await supabaseAdmin.storage.from(bucket).createSignedUrl(storagePath, 60 * 60, { download: fileName });
if (error || !data?.signedUrl) throw new Error(error?.message ?? "生成签名 URL 失败");
return data.signedUrl;
}
const parsed = fileUrl ? parseStoragePath(fileUrl) : null;
if (!parsed) return fileUrl;
const { data, error } = await supabaseAdmin.storage.from(parsed.bucket).createSignedUrl(parsed.path, 60 * 60, { download: fileName });
if (error || !data?.signedUrl) throw new Error(error?.message ?? "生成签名 URL 失败");
return data.signedUrl;
};
const downloadBytes = async (url: string) => {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`下载失败:${resp.status} ${resp.statusText}`);
return await resp.arrayBuffer();
};
const mineruParseContentList = async (args: { bytes: ArrayBuffer; filename: string; mimeType: string }) => {
const endpoint = (process.env.MINERU_ENDPOINT || "").trim().replace(/\/$/, "");
if (!endpoint) throw new Error("缺少 MINERU_ENDPOINT,无法解析 PDF 结构");
const form = new FormData();
form.append("files", new Blob([args.bytes], { type: args.mimeType || "application/octet-stream" }), args.filename || "file.pdf");
form.append("output_dir", "./output");
form.append("lang_list", "ch");
form.append("backend", "pipeline");
form.append("parse_method", "auto");
form.append("return_md", "false");
form.append("return_middle_json", "false");
form.append("return_model_output", "false");
form.append("return_content_list", "true");
form.append("return_images", "false");
form.append("response_format_zip", "false");
const resp = await fetch(`${endpoint}/file_parse`, { method: "POST", body: form });
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`MinerU 解析失败:${resp.status} ${resp.statusText}${text ? `${text.slice(0, 300)}` : ""}`);
}
const payload = (await resp.json().catch(() => null)) as unknown;
if (!isRecord(payload) || !isRecord(payload.results)) throw new Error("MinerU 返回格式不正确");
const keys = Object.keys(payload.results);
if (!keys.length) throw new Error("MinerU 返回为空");
const first = payload.results[keys[0]];
if (!isRecord(first)) throw new Error("MinerU 返回格式不正确(results.*");
const raw = first.content_list;
if (typeof raw !== "string" || !raw.trim()) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed as Array<Record<string, unknown>>;
};
type ExtractedOutlineItem = { level: number; title: string; page?: number };
const outlineFromContentList = (list: Array<Record<string, unknown>>) => {
const out: ExtractedOutlineItem[] = [];
const seen = new Set<string>();
for (const item of list) {
const levelRaw = item.text_level;
const textRaw = item.text;
const pageIdxRaw = item.page_idx;
if (typeof levelRaw !== "number" || !Number.isFinite(levelRaw)) continue;
const level = Math.max(1, Math.min(6, Math.floor(levelRaw)));
const title = String(textRaw ?? "").replace(/\s+/g, " ").trim();
if (!title) continue;
const pageIdx = typeof pageIdxRaw === "number" && Number.isFinite(pageIdxRaw) ? Math.max(0, Math.floor(pageIdxRaw)) : null;
const page = pageIdx === null ? undefined : pageIdx + 1;
const key = `${level}|${page ?? ""}|${title}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ level, title, page });
}
return out;
};
const findNodeByUid = (root: MindmapTreeNode, uid: string): MindmapTreeNode | null => {
const target = String(uid || "");
if (!target) return null;
const walk = (n: MindmapTreeNode): MindmapTreeNode | null => {
if (String(n?.data?.uid || "") === target) return n;
const children = Array.isArray(n.children) ? n.children : [];
for (const c of children) {
const hit = walk(c);
if (hit) return hit;
}
return null;
};
return walk(root);
};
const createUid = () => {
const anyCrypto = globalThis.crypto as unknown as { randomUUID?: () => string } | undefined;
if (anyCrypto?.randomUUID) return anyCrypto.randomUUID();
return Math.random().toString(36).slice(2);
};
const buildOutlineOps = (args: { parentUid: string; items: ExtractedOutlineItem[]; attachment: { assetId: string; fileUrl: string; title: string; mimeType?: string | null } }) => {
const ops: MindmapOp[] = [];
const parents: Record<number, string> = { 0: args.parentUid };
for (const item of args.items) {
const level = Math.max(1, Math.min(6, Math.floor(item.level)));
const parent = parents[level - 1] || args.parentUid;
const uid = createUid();
const page = item.page;
const refs: NodeRef[] = [
{
kind: "pdf",
assetId: args.attachment.assetId,
fileUrl: args.attachment.fileUrl,
page,
title: args.attachment.title,
snippet: page ? `${page}` : undefined,
},
];
const hyperlink = (() => {
const base = String(args.attachment.fileUrl || "").trim();
if (!base) return undefined;
if (page && isPdf(String(args.attachment.mimeType ?? ""), base)) return `${base}#page=${page}`;
return base;
})();
ops.push({
op: "addChild",
parentUid: parent,
node: {
uid,
text: item.title,
hyperlink,
refs,
},
});
parents[level] = uid;
for (let d = level + 1; d <= 10; d += 1) delete parents[d];
}
return ops;
};
export const createOnlyOfficeServerTools = (args: {
supabase: OnlyOfficeSupabaseClient;
ctx: OnlyOfficeToolContext;
allowedToolIds: Set<string>;
}) => {
const asset_extract_outline = async (toolArgs: Record<string, unknown>) => {
const assetId = String(toolArgs.assetId ?? "").trim();
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
const resolved = attachmentRef ? resolveAttachment(args.ctx, attachmentRef) : null;
const targetAssetId = assetId || resolved?.id || "";
if (!targetAssetId) throw new Error("缺少 assetId / attachmentRef");
const { data, error } = await args.supabase
.from("media_assets")
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,storage_path,bucket,updated_at,deleted_at,purged_at")
.eq("id", targetAssetId)
.is("deleted_at", null)
.limit(1)
.maybeSingle();
if (error) throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取附件失败");
const row = (data && typeof data === "object" ? (data as Record<string, unknown>) : null) ?? null;
if (!row) return { ok: true, found: false };
const fileName = String(row.file_name ?? "");
const fileUrl = String(row.file_url ?? "");
const mimeType = String(row.mime_type ?? "");
const ocrText = String(row.ocr_text ?? "");
const ocrStatus = String(row.ocr_status ?? "");
const textPreview = ocrText.trim().slice(0, 3000);
if (!isPdf(mimeType, fileName || fileUrl)) {
return {
ok: true,
found: true,
supported: false,
note: "当前仅优先支持 PDFM3PDF→大纲→导图)。Word/PPT 将在后续阶段通过 ONLYOFFICE /converter 或其它解析策略补齐。",
asset: { id: String(row.id ?? ""), fileName, fileUrl, mimeType, ocrStatus, updatedAt: row.updated_at ?? null },
hasOcrText: Boolean(ocrText.trim()),
textPreview,
};
}
// M3PDF 优先使用 MinerU 的 content_list(含 page_idx + text_level),用于做可跳转大纲
let outline: ExtractedOutlineItem[] = [];
let strategy: "mineru_content_list" | "fallback_ocr_text" = "mineru_content_list";
try {
const signed = await signForDownload(row);
const bytes = await downloadBytes(signed);
const list = await mineruParseContentList({ bytes, filename: fileName || "file.pdf", mimeType: mimeType || "application/pdf" });
outline = outlineFromContentList(list);
} catch (e) {
// 兜底:返回 OCR 文本的一部分,至少让模型能“基于文本生成大纲”(但没有页码保障)
strategy = "fallback_ocr_text";
outline = [];
if (textPreview) outline.push({ level: 1, title: "(未解析到结构化标题:请结合 textPreview 自行生成提纲)" });
}
return {
ok: true,
found: true,
supported: true,
strategy,
asset: {
id: String(row.id ?? ""),
fileName,
fileUrl,
mimeType,
ocrStatus,
updatedAt: row.updated_at ?? null,
},
hasOcrText: Boolean(ocrText.trim()),
textPreview,
outline,
note:
outline.length > 0
? `已提取大纲条目:${outline.length}`
: strategy === "fallback_ocr_text"
? "未能从 MinerU 获取结构化标题;请确认 MINERU_ENDPOINT 正常,或稍后重试。"
: "未提取到标题(该 PDF 可能没有明显标题结构)。",
};
};
const asset_to_mindmap = async (toolArgs: Record<string, unknown>) => {
const mindmapId = String(toolArgs.mindmapId ?? "").trim();
if (!mindmapId) throw new Error("缺少 mindmapId");
const parentUidArg = String(toolArgs.parentUid ?? "").trim();
const maxItemsRaw = Number(toolArgs.maxItems ?? 120);
const maxItems = Number.isFinite(maxItemsRaw) ? Math.max(10, Math.min(600, Math.floor(maxItemsRaw))) : 120;
const documentId = String(args.ctx.documentId ?? "").trim();
if (!documentId) throw new Error("缺少 documentId 上下文(OnlyOffice 工具需要落盘到指定文档)");
const outlineResult = (await asset_extract_outline(toolArgs)) as unknown;
if (!isRecord(outlineResult) || outlineResult.ok !== true) throw new Error("提取大纲失败");
if (outlineResult.found !== true) throw new Error("未找到附件");
if (outlineResult.supported !== true) throw new Error(String(outlineResult.note ?? "附件类型暂不支持"));
const outline = Array.isArray(outlineResult.outline) ? (outlineResult.outline as unknown[]) : [];
const items = outline
.map((x) =>
isRecord(x)
? {
level: Number(x.level ?? 1),
title: String(x.title ?? ""),
...(typeof x.page === "number" ? { page: x.page } : {}),
}
: null,
)
.filter((x): x is ExtractedOutlineItem => x !== null && x.title.trim().length > 0)
.slice(0, maxItems);
if (!items.length) throw new Error("未提取到可用大纲条目");
const asset = isRecord(outlineResult.asset) ? outlineResult.asset : null;
const attachment = {
assetId: String(asset?.id ?? ""),
title: String(asset?.fileName ?? "附件"),
fileUrl: String(asset?.fileUrl ?? ""),
mimeType: isRecord(asset) ? (asset.mimeType as string | null | undefined) : null,
};
if (!attachment.assetId) throw new Error("缺少附件 id");
const { data: baseRaw } = await readMindmapLocal(documentId, mindmapId);
const base = (baseRaw && typeof baseRaw === "object" ? (baseRaw as MindmapTreeNode) : null) ?? { data: { text: "中心主题" }, children: [] };
ensureMindmapUids(base);
const rootUid = String(base?.data?.uid || "");
const parentUid = parentUidArg || rootUid;
if (!parentUid) throw new Error("无法确定 parentUidmindmap 根节点缺少 uid");
if (!findNodeByUid(base, parentUid)) throw new Error("未找到 parentUid 对应节点");
const ops = buildOutlineOps({ parentUid, items, attachment });
const { data: nextData, applied, errors } = applyMindmapOps(base, ops);
await writeMindmapLocal(documentId, mindmapId, nextData, "OnlyOffice 生成导图");
return {
ok: true,
mindmapId,
parentUid,
applied,
errors,
ops,
meta: {
assetId: attachment.assetId,
fileName: attachment.title,
items: items.length,
strategy: String(outlineResult.strategy ?? ""),
},
};
};
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
if (toolId === "asset_extract_outline") return await asset_extract_outline(toolArgs);
if (toolId === "asset_to_mindmap") return await asset_to_mindmap(toolArgs);
throw new Error(`未知工具:${toolId}`);
};
return { run };
};
@@ -239,6 +239,77 @@ export const builtinTools: AiAgentTool[] = [
requiresConfirmation: true,
isWriteTool: true,
},
{
id: "asset_extract_outline",
displayName: "提取附件大纲(PDF 优先)",
modelDescription:
"从附件(当前优先 PDF)提取结构化大纲(含层级与页码)。用于“文档驱动导图/可跳转引用”(不写入)。",
inputSchemaText: `{ "assetId?": "string", "attachmentRef?": "string (可用附件 id/title/url 片段)" }`,
source: "builtin",
requiresConfirmation: false,
isWriteTool: false,
},
{
id: "asset_to_mindmap",
displayName: "附件生成思维导图(写入)",
modelDescription:
"把附件大纲转为思维导图节点并落盘(PDF 页码会写入 refs,并尽量生成可跳转 hyperlink)。这是写工具,执行前必须确认。",
inputSchemaText:
`{ "mindmapId": "string", "parentUid?": "string (默认根节点)", "assetId?": "string", "attachmentRef?": "string", "maxItems?": "number (10~600, 默认120)", "reason?": "string" }`,
source: "builtin",
requiresConfirmation: true,
isWriteTool: true,
},
{
id: "oo_get_selection",
displayName: "读取选区(OnlyOffice",
modelDescription:
"读取 OnlyOffice 当前选区(用于增/删/改/查的基础能力:查/改前先读)。注意:该工具在客户端插件内执行。",
inputSchemaText: `{ "format?": "\"text\"|\"html\" (默认 text)" }`,
source: "builtin",
requiresConfirmation: false,
isWriteTool: false,
},
{
id: "oo_replace_selection",
displayName: "替换选区(OnlyOffice",
modelDescription:
"用文本/HTML 替换 OnlyOffice 当前选区(基础改/删:删=传空字符串)。注意:该工具在客户端插件内执行。",
inputSchemaText: `{ "text": "string", "format?": "\"text\"|\"html\" (默认 text)", "reason?": "string" }`,
source: "builtin",
requiresConfirmation: true,
isWriteTool: true,
},
{
id: "oo_insert_text",
displayName: "插入文本(OnlyOffice",
modelDescription:
"在 OnlyOffice 光标/选区位置插入文本(若存在选区通常会覆盖选区)。注意:该工具在客户端插件内执行。",
inputSchemaText: `{ "text": "string", "reason?": "string" }`,
source: "builtin",
requiresConfirmation: true,
isWriteTool: true,
},
{
id: "oo_insert_html",
displayName: "插入 HTMLOnlyOffice",
modelDescription:
"在 OnlyOffice 光标/选区位置插入 HTML(用于保留简单格式)。注意:该工具在客户端插件内执行。",
inputSchemaText: `{ "html": "string", "reason?": "string" }`,
source: "builtin",
requiresConfirmation: true,
isWriteTool: true,
},
{
id: "oo_insert_image",
displayName: "插入图片(OnlyOffice",
modelDescription:
"在 OnlyOffice 光标/选区位置插入图片(imageRef 可为附件 id 或 URL)。注意:该工具在客户端插件内执行。",
inputSchemaText: `{ "imageRef": "string (attachmentId 或 URL)", "width?": "number", "height?": "number", "reason?": "string" }`,
source: "builtin",
requiresConfirmation: true,
isWriteTool: true,
},
];
export const builtinToolSets: AiAgentToolSet[] = [
@@ -262,6 +333,27 @@ export const builtinToolSets: AiAgentToolSet[] = [
displayName: "媒体读取(OCR/元信息)",
toolIds: ["image_read"],
},
{
id: "toolset.onlyoffice_read",
displayName: "OnlyOffice/附件结构化读取(M3",
toolIds: ["asset_extract_outline"],
},
{
id: "toolset.onlyoffice_write",
displayName: "OnlyOffice→思维导图(写入,M3",
toolIds: ["asset_to_mindmap"],
},
{
id: "toolset.onlyoffice_editor",
displayName: "OnlyOffice 编辑器(选区读写)",
toolIds: [
"oo_get_selection",
"oo_replace_selection",
"oo_insert_text",
"oo_insert_html",
"oo_insert_image",
],
},
{
id: "toolset.slash_write",
displayName: "斜杠命令(写入)",
@@ -103,6 +103,10 @@ export function inferPasteTargetDocId({
const parsed = parseFileTreeRowId(focusedRowId);
if (parsed?.kind === "doc") return parsed.docId;
if (parsed?.kind === "index") return parsed.docId;
if (parsed?.kind === "asset-folder") {
const row = rowById.get(focusedRowId);
return row?.kind === "asset-folder" ? row.docId : null;
}
if (parsed?.kind === "asset") {
const row = rowById.get(focusedRowId);
return row?.kind === "asset" ? row.docId : null;
+1 -1
View File
@@ -27,7 +27,7 @@ export function computeFileTreeDeleteTargets(args: {
continue;
}
if (row.kind === "asset") {
if (row.kind === "asset" || row.kind === "asset-folder") {
assetCandidates.push(row.asset.id);
assetDocIdByAssetId.set(row.asset.id, row.docId);
}
+34 -2
View File
@@ -3,16 +3,20 @@
import type { DocumentNode } from "@/lib/documents";
import type { MediaAsset } from "@/types/media";
import type { FileTreeRow } from "./types";
import { makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
export function buildVisibleRows({
nodes,
expanded,
assetsByDoc,
assetChildrenByAssetId,
expandedAssetFolderIds,
}: {
nodes: DocumentNode[];
expanded: Set<string>;
assetsByDoc: Record<string, MediaAsset[]>;
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
expandedAssetFolderIds?: Set<string>;
}): FileTreeRow[] {
const rows: FileTreeRow[] = [];
@@ -43,6 +47,35 @@ export function buildVisibleRows({
});
assets.forEach((asset) => {
if (asset.asset_type === "mindmap") {
const children = assetChildrenByAssetId?.[asset.id] ?? [];
const hasChildren = children.length > 0;
const isExpanded = expandedAssetFolderIds?.has(asset.id) ?? false;
rows.push({
kind: "asset-folder",
rowId: makeAssetFolderRowId(asset.id),
depth: depth + 1,
docId: node.id,
parentDocId: node.id,
asset,
hasChildren,
isExpanded,
});
if (hasChildren && isExpanded) {
children.forEach((child) => {
rows.push({
kind: "asset",
rowId: makeAssetRowId(child.id),
depth: depth + 2,
docId: node.id,
parentDocId: node.id,
asset: child,
});
});
}
return;
}
rows.push({
kind: "asset",
rowId: makeAssetRowId(asset.id),
@@ -59,4 +92,3 @@ export function buildVisibleRows({
nodes.forEach((node) => walk(node, 0));
return rows;
}
+30 -4
View File
@@ -3,14 +3,19 @@
import type { DocumentNode } from "@/lib/documents";
import type { MediaAsset } from "@/types/media";
export type FileTreeRowKind = "doc" | "index" | "asset";
export type FileTreeRowKind = "doc" | "index" | "asset" | "asset-folder";
export type FileTreeRowId = `doc:${string}` | `index:${string}` | `asset:${string}`;
export type FileTreeRowId =
| `doc:${string}`
| `index:${string}`
| `asset:${string}`
| `asset-folder:${string}`;
export type ParsedFileTreeRowId =
| { kind: "doc"; docId: string }
| { kind: "index"; docId: string }
| { kind: "asset"; assetId: string };
| { kind: "asset"; assetId: string }
| { kind: "asset-folder"; assetId: string };
export function makeDocRowId(docId: string): FileTreeRowId {
return `doc:${docId}`;
@@ -24,6 +29,10 @@ export function makeAssetRowId(assetId: string): FileTreeRowId {
return `asset:${assetId}`;
}
export function makeAssetFolderRowId(assetId: string): FileTreeRowId {
return `asset-folder:${assetId}`;
}
export function parseFileTreeRowId(rowId: string): ParsedFileTreeRowId | null {
const idx = rowId.indexOf(":");
if (idx <= 0) return null;
@@ -37,6 +46,8 @@ export function parseFileTreeRowId(rowId: string): ParsedFileTreeRowId | null {
return { kind: "index", docId: rest };
case "asset":
return { kind: "asset", assetId: rest };
case "asset-folder":
return { kind: "asset-folder", assetId: rest };
default:
return null;
}
@@ -61,6 +72,16 @@ export type FileTreeRow =
parentDocId: string;
node: DocumentNode;
}
| {
kind: "asset-folder";
rowId: FileTreeRowId;
depth: number;
docId: string;
parentDocId: string;
asset: MediaAsset;
hasChildren: boolean;
isExpanded: boolean;
}
| {
kind: "asset";
rowId: FileTreeRowId;
@@ -76,6 +97,11 @@ export function getFileTreeRowLabel(row: FileTreeRow): string {
return row.node.title || "无标题";
case "index":
return "index.md";
case "asset-folder": {
const name = row.asset.file_name || "附件";
// 思维导图展示为“文件夹”时,去掉 .json 结尾更直观
return row.asset.asset_type === "mindmap" ? name.replace(/\.json$/i, "") : name;
}
case "asset":
return row.asset.file_name || "附件";
}
@@ -86,8 +112,8 @@ export function getOwningDocId(row: FileTreeRow): string {
case "doc":
case "index":
return row.docId;
case "asset-folder":
case "asset":
return row.asset.document_id;
}
}
+72
View File
@@ -88,6 +88,59 @@ export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMi
return results;
}
function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
const root = (() => {
if (!input || typeof input !== "object") return input;
const record = input as Record<string, unknown>;
return "root" in record ? record.root : input;
})();
const ids: string[] = [];
const seen = new Set<string>();
const push = (value: unknown) => {
if (typeof value !== "string") return;
if (!value.startsWith("asset:")) return;
const id = value.slice("asset:".length).trim();
if (!id) return;
if (seen.has(id)) return;
seen.add(id);
ids.push(id);
};
const get = (obj: unknown, key: string): unknown => {
if (!obj || typeof obj !== "object") return undefined;
return (obj as Record<string, unknown>)[key];
};
const walk = (node: unknown) => {
if (!node || typeof node !== "object") return;
const data = get(node, "data");
const image = get(node, "image");
// 常见:node.data.image = "asset:xxx"
push(get(data, "image"));
// 兼容:node.image = "asset:xxx"
push(image);
// 兼容:node.image.url = "asset:xxx"
push(get(image, "url"));
// 兼容:node.data.image.url = "asset:xxx"
push(get(get(data, "image"), "url"));
const children = get(node, "children");
if (Array.isArray(children)) {
children.forEach(walk);
}
};
walk(root);
return ids;
}
type TrashedMindmapMeta = {
docId: string;
mindmapId: string;
@@ -106,6 +159,25 @@ async function tryReadJsonFile<T>(file: string): Promise<T | null> {
}
}
export async function detectLocalMindmapImageAssetIdsByMindmapId(
files: LocalMindmapFile[],
): Promise<Record<string, string[]>> {
const mapping: Record<string, string[]> = {};
for (const item of files) {
const baseDir = item.source === "legacy" ? legacyBaseDir : preferredBaseDir;
const filePath = path.join(baseDir, item.documentId, item.fileName);
const data = await tryReadJsonFile<unknown>(filePath);
if (!data) continue;
const ids = extractMindmapImageAssetIdsFromData(data);
if (ids.length > 0) {
mapping[item.mindmapId] = ids;
}
}
return mapping;
}
async function listTrashMetasForFolder(folder: string): Promise<TrashedMindmapMeta[]> {
const trashDir = path.join(folder, ".trash");
try {
+1 -1
View File
@@ -1,7 +1,7 @@
import { createClient } from "@supabase/supabase-js";
const supabaseAdmin = createClient(
process.env.SUPABASE_URL ?? "",
process.env.SUPABASE_INTERNAL_URL ?? process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
{
auth: {
@@ -0,0 +1,27 @@
export const rewriteToPublicOrigin = (rawUrl: string, publicBaseUrl?: string) => {
if (!publicBaseUrl) return rawUrl;
const input = String(rawUrl || "").trim();
if (!input) return input;
try {
const u = new URL(input);
const pub = new URL(publicBaseUrl);
const isLocalHost =
u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
const isLikelyInternalPort = u.port === "18000";
if (!isLocalHost && !isLikelyInternalPort) {
return input;
}
// 统一改为公网可达的 supabase origin(协议 + host + 端口)
u.protocol = pub.protocol;
u.host = pub.host;
return u.toString();
} catch {
return input;
}
};