2026-01-10 23:08:56 +08:00
|
|
|
|
import { NextResponse } from "next/server";
|
|
|
|
|
|
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
|
|
|
|
|
import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
|
|
|
|
|
|
import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/registry";
|
|
|
|
|
|
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
|
|
|
|
|
|
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
|
2026-01-17 10:12:53 +08:00
|
|
|
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
2026-01-10 23:08:56 +08:00
|
|
|
|
import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb";
|
|
|
|
|
|
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools";
|
|
|
|
|
|
import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools";
|
|
|
|
|
|
import { createRagServerTools } from "@/lib/ai-agent/tools/builtins/rag/lightragServerTools";
|
|
|
|
|
|
import { createDocsServerTools, type DocsSupabaseClient } from "@/lib/ai-agent/tools/builtins/docs/docsServerTools";
|
|
|
|
|
|
import { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent/tools/builtins/media/mediaServerTools";
|
|
|
|
|
|
import { createSlashServerTools, type SlashSupabaseClient } from "@/lib/ai-agent/tools/builtins/slash/slashServerTools";
|
2026-01-11 12:35:53 +08:00
|
|
|
|
import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools";
|
|
|
|
|
|
import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge";
|
2026-01-17 10:12:53 +08:00
|
|
|
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
|
|
|
|
|
import { api } from "@/lib/convex/api";
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
type AgentMessage = { role: "user" | "assistant"; content: string };
|
|
|
|
|
|
type AgentScope = "global" | "mindmap" | "document" | "onlyoffice";
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
type RequestPayload = {
|
|
|
|
|
|
stream?: boolean;
|
|
|
|
|
|
maxSteps?: number;
|
|
|
|
|
|
scope?: AgentScope;
|
|
|
|
|
|
messages: AgentMessage[];
|
|
|
|
|
|
attachments?: Array<{ id: string; title: string; fileUrl: string; mimeType?: string | null }>;
|
|
|
|
|
|
toolChoice?: { mode: "auto" | "manual"; toolSets?: string[]; tools?: string[] };
|
|
|
|
|
|
context?: {
|
|
|
|
|
|
documentId?: string;
|
|
|
|
|
|
mindmapId?: string;
|
|
|
|
|
|
selectedUids?: string[];
|
|
|
|
|
|
// v1:BlockNote 文档快照(前端可选传入,避免覆盖未落盘编辑)
|
|
|
|
|
|
documentBlocks?: unknown;
|
|
|
|
|
|
};
|
|
|
|
|
|
options?: { searxng?: boolean; ai?: { provider?: "online" | "local"; model?: string } };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_MAX_STEPS = 10;
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;
|
|
|
|
|
|
|
|
|
|
|
|
const makeRunId = () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto;
|
|
|
|
|
|
if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const isOnlyOfficeClientTool = (toolId: string) => toolId.startsWith("oo_");
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
const sseHeaders = {
|
|
|
|
|
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
|
|
|
|
"Cache-Control": "no-cache, no-transform",
|
|
|
|
|
|
Connection: "keep-alive",
|
|
|
|
|
|
"X-Accel-Buffering": "no",
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
|
|
const toSseFrame = (event: string, data: unknown) => {
|
|
|
|
|
|
const json = JSON.stringify(data ?? null);
|
|
|
|
|
|
return `event: ${event}\ndata: ${json}\n\n`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export async function POST(request: Request) {
|
|
|
|
|
|
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
|
|
|
|
|
|
if (!payload || !Array.isArray(payload.messages) || payload.messages.length === 0) {
|
|
|
|
|
|
return NextResponse.json({ error: "缺少 messages" }, { status: 400 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
|
// v1:鉴权(Convex 迁移阶段使用固定开发用户;非 Convex 模式仍走 Supabase session)
|
|
|
|
|
|
const convexOn = isConvexEnabled();
|
|
|
|
|
|
const { userId, supabase, convexClient } = await (async () => {
|
|
|
|
|
|
if (convexOn) {
|
2026-01-18 05:13:53 +08:00
|
|
|
|
const { auth, client } = await getAuthedConvexClient();
|
2026-01-17 10:12:53 +08:00
|
|
|
|
return { userId: auth.userId, supabase: null as any, convexClient: client };
|
|
|
|
|
|
}
|
2026-01-18 19:01:31 +08:00
|
|
|
|
return { userId: "", supabase: null as any, convexClient: null as any };
|
2026-01-17 10:12:53 +08:00
|
|
|
|
})();
|
|
|
|
|
|
if (!userId) {
|
2026-01-10 23:08:56 +08:00
|
|
|
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const provider = payload.options?.ai?.provider === "local" ? "local" : "online";
|
|
|
|
|
|
const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null;
|
|
|
|
|
|
const cfg =
|
|
|
|
|
|
provider === "local"
|
|
|
|
|
|
? await loadLocalAiConfig().catch(() => null)
|
|
|
|
|
|
: await loadOnlineAiConfig().catch(() => null);
|
|
|
|
|
|
if (!cfg) {
|
|
|
|
|
|
const tip =
|
|
|
|
|
|
provider === "local"
|
|
|
|
|
|
? "未找到本地 AI 配置(LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md)"
|
|
|
|
|
|
: "未找到在线 AI 配置(ai.md 或 ONLINE_AI_* 环境变量)";
|
|
|
|
|
|
return NextResponse.json({ error: tip }, { status: 500 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const registry = createToolRegistry({ tools: builtinTools, toolSets: builtinToolSets });
|
|
|
|
|
|
const allowedToolIds = resolveAllowedToolIds({
|
|
|
|
|
|
registry,
|
|
|
|
|
|
mode: payload.toolChoice?.mode === "manual" ? "manual" : "auto",
|
|
|
|
|
|
toolSetIds: payload.toolChoice?.toolSets,
|
|
|
|
|
|
toolIds: payload.toolChoice?.tools,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const documentId = String(payload.context?.documentId ?? "").trim();
|
|
|
|
|
|
const mindmapId = String(payload.context?.mindmapId ?? "").trim();
|
|
|
|
|
|
const scope: AgentScope = (() => {
|
|
|
|
|
|
const raw = String(payload.scope ?? "").trim();
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") return raw;
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 兜底:有 mindmapId 则认为在 mindmap 场景,否则视为全局场景
|
|
|
|
|
|
return mindmapId ? "mindmap" : "global";
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
// v1:按“使用位置”隔离工具,避免工具混淆/误调用(即使用户手动传入,也会被过滤)
|
|
|
|
|
|
const allowToolSetIds: string[] =
|
|
|
|
|
|
scope === "mindmap"
|
|
|
|
|
|
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.mindmap_read", "toolset.mindmap_write"]
|
|
|
|
|
|
: scope === "document"
|
|
|
|
|
|
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.doc_read", "toolset.doc_write", "toolset.slash_write"]
|
2026-01-11 12:35:53 +08:00
|
|
|
|
: scope === "onlyoffice"
|
|
|
|
|
|
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.onlyoffice_read", "toolset.onlyoffice_write", "toolset.onlyoffice_editor"]
|
|
|
|
|
|
: ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.slash_write"];
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const allowlist = new Set<string>();
|
|
|
|
|
|
for (const sid of allowToolSetIds) {
|
|
|
|
|
|
const s = registry.toolSetsById.get(sid);
|
|
|
|
|
|
(s?.toolIds ?? []).forEach((id) => allowlist.add(id));
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const id of [...allowedToolIds]) {
|
|
|
|
|
|
if (!allowlist.has(id)) allowedToolIds.delete(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// v1:允许通过 options.searxng 关闭联网检索(比如离线模型/内网)
|
|
|
|
|
|
if (payload.options?.searxng === false) allowedToolIds.delete("search_web");
|
|
|
|
|
|
|
|
|
|
|
|
// v1:mindmap 工具必须在提供上下文时才允许,避免模型盲调导致误操作
|
|
|
|
|
|
const selectedUids = Array.isArray(payload.context?.selectedUids)
|
|
|
|
|
|
? payload.context!.selectedUids!.map((x) => String(x)).filter(Boolean).slice(0, 6)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
const hasMindmapContext = Boolean(documentId && mindmapId);
|
|
|
|
|
|
const hasDocumentContext = Boolean(documentId);
|
|
|
|
|
|
const documentBlocks = payload.context?.documentBlocks ?? null;
|
|
|
|
|
|
const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, 12) : [];
|
|
|
|
|
|
const attachmentLines = attachments
|
|
|
|
|
|
.map((a, idx) => `${idx + 1}. id=${String(a.id)} title=${String(a.title)} mime=${String(a.mimeType ?? "")} url=${String(a.fileUrl)}`)
|
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
if (!hasMindmapContext) {
|
|
|
|
|
|
allowedToolIds.delete("mindmap_get");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_get_subtree");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_apply_ops");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_expand_node");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_add_child");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_add_sibling_after");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_update_node_text");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_set_hyperlink");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_append_note");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_set_refs");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_delete_node");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_add_attachment_ref");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_add_attachment_child");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_add_image_child");
|
|
|
|
|
|
allowedToolIds.delete("mindmap_append_image_note");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasDocumentContext) {
|
|
|
|
|
|
allowedToolIds.delete("doc_get");
|
|
|
|
|
|
allowedToolIds.delete("doc_find");
|
|
|
|
|
|
allowedToolIds.delete("doc_insert_blocks");
|
|
|
|
|
|
allowedToolIds.delete("doc_replace_range");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
|
// 说明:Convex 迁移阶段(M4)先确保“不会再触发 Supabase 依赖”。
|
|
|
|
|
|
// 未迁移的能力(OnlyOffice 等)在 Convex 模式下直接禁用对应工具。
|
|
|
|
|
|
if (convexOn) {
|
|
|
|
|
|
for (const id of [...allowedToolIds]) {
|
|
|
|
|
|
if (
|
|
|
|
|
|
id === "search_web" ||
|
|
|
|
|
|
id === "image_read" ||
|
|
|
|
|
|
id === "slash_run" ||
|
|
|
|
|
|
id.startsWith("rag_") ||
|
|
|
|
|
|
id.startsWith("mindmap_") ||
|
|
|
|
|
|
id.startsWith("doc_") ||
|
|
|
|
|
|
id.startsWith("docs_")
|
|
|
|
|
|
) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
allowedToolIds.delete(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const systemContextText = (() => {
|
|
|
|
|
|
const lines: string[] = [];
|
|
|
|
|
|
if (documentId) lines.push(`documentId=${documentId}`);
|
|
|
|
|
|
if (scope === "mindmap") {
|
|
|
|
|
|
if (mindmapId) lines.push(`mindmapId=${mindmapId}`);
|
|
|
|
|
|
if (selectedUids.length) lines.push(`selectedUids=${selectedUids.join(",")}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (scope === "document") {
|
|
|
|
|
|
if (documentBlocks) lines.push("documentBlocks=provided");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (attachments.length) lines.push(`attachments:\n${attachmentLines}`);
|
|
|
|
|
|
return lines.join("\n").trim();
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
|
const normalizeBlocksForTools = (content: unknown): unknown[] => {
|
|
|
|
|
|
if (Array.isArray(content)) return content;
|
|
|
|
|
|
if (content && typeof content === "object" && "blocks" in (content as any)) {
|
|
|
|
|
|
const blocks = (content as any).blocks;
|
|
|
|
|
|
if (Array.isArray(blocks)) return blocks;
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const extractPlainTextFromBlocks = (blocks: unknown[], maxChars: number) => {
|
|
|
|
|
|
const pieces: string[] = [];
|
|
|
|
|
|
const walk = (list: unknown[]) => {
|
|
|
|
|
|
for (const b of list) {
|
|
|
|
|
|
if (!b || typeof b !== "object") continue;
|
|
|
|
|
|
const content = (b as any).content;
|
|
|
|
|
|
if (Array.isArray(content)) {
|
|
|
|
|
|
for (const n of content) {
|
|
|
|
|
|
const t = n && typeof n === "object" ? String((n as any).text ?? "") : "";
|
|
|
|
|
|
if (t) pieces.push(t);
|
|
|
|
|
|
if (pieces.join("").length >= maxChars) return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const children = (b as any).children;
|
|
|
|
|
|
if (Array.isArray(children)) {
|
|
|
|
|
|
walk(children);
|
|
|
|
|
|
if (pieces.join("").length >= maxChars) return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
walk(blocks);
|
|
|
|
|
|
const raw = pieces.join("").replace(/\s+/g, " ").trim();
|
|
|
|
|
|
return raw.length > maxChars ? `${raw.slice(0, maxChars)}…` : raw;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const mindmapTools = hasMindmapContext
|
|
|
|
|
|
? createMindmapServerTools({
|
|
|
|
|
|
supabase: supabase as unknown as MindmapSupabaseClient,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
ctx: { documentId, mindmapId, userId, selectedUids, attachments },
|
2026-01-10 23:08:56 +08:00
|
|
|
|
cfg: { ...cfg, model: modelOverride ?? cfg.model },
|
|
|
|
|
|
allowedToolIds,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
...(convexOn
|
|
|
|
|
|
? {
|
|
|
|
|
|
loadMindmap: async () => {
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
const [mm, meta] = await Promise.all([
|
|
|
|
|
|
convexClient.query(api.mindmaps.get, { userId, docId: documentId, mindmapId }),
|
|
|
|
|
|
convexClient.query(api.documents.getMeta, { userId, id: documentId }),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const title = meta?.title ?? null;
|
|
|
|
|
|
const workspaceId = meta?.workspace_id ?? (mm as any)?.meta?.workspace_id ?? null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
doc: { id: documentId, title, workspace_id: workspaceId },
|
|
|
|
|
|
base: (mm as any)?.data ?? { data: { text: "中心主题" }, children: [] },
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
saveMindmap: async ({ data }) => {
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
await convexClient.mutation(api.mindmaps.put, { userId, docId: documentId, mindmapId, data });
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
: {}),
|
2026-01-10 23:08:56 +08:00
|
|
|
|
})
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
const docTools =
|
|
|
|
|
|
hasDocumentContext &&
|
|
|
|
|
|
(allowedToolIds.has("doc_get") ||
|
|
|
|
|
|
allowedToolIds.has("doc_find") ||
|
|
|
|
|
|
allowedToolIds.has("doc_insert_blocks") ||
|
|
|
|
|
|
allowedToolIds.has("doc_replace_range"))
|
|
|
|
|
|
? createDocServerTools({
|
|
|
|
|
|
supabase: supabase as unknown as DocSupabaseClient,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
ctx: { documentId, userId, baseBlocks: documentBlocks },
|
2026-01-10 23:08:56 +08:00
|
|
|
|
allowedToolIds,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
...(convexOn
|
|
|
|
|
|
? {
|
|
|
|
|
|
loadBlocks: async () => {
|
|
|
|
|
|
const base = normalizeBlocksForTools(documentBlocks);
|
|
|
|
|
|
if (base.length > 0) return { blocks: base, source: "client" };
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
const res = await convexClient.query(api.documents.getContent, { userId, id: documentId });
|
|
|
|
|
|
const blocks = normalizeBlocksForTools(res?.content ?? null);
|
|
|
|
|
|
return { blocks, source: "convex" };
|
|
|
|
|
|
},
|
|
|
|
|
|
saveBlocks: async (blocks: unknown[]) => {
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
await convexClient.mutation(api.documents.updateContent, { userId, id: documentId, content: blocks });
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
: {}),
|
2026-01-10 23:08:56 +08:00
|
|
|
|
})
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
const ragTools = allowedToolIds.has("rag_lightrag_query")
|
|
|
|
|
|
? createRagServerTools({
|
2026-01-17 10:12:53 +08:00
|
|
|
|
ctx: { userId },
|
2026-01-10 23:08:56 +08:00
|
|
|
|
allowedToolIds,
|
|
|
|
|
|
})
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
const docsTools =
|
|
|
|
|
|
allowedToolIds.has("docs_search") || allowedToolIds.has("docs_read")
|
|
|
|
|
|
? createDocsServerTools({
|
|
|
|
|
|
supabase: supabase as unknown as DocsSupabaseClient,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
ctx: { userId },
|
2026-01-10 23:08:56 +08:00
|
|
|
|
allowedToolIds,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
...(convexOn
|
|
|
|
|
|
? {
|
|
|
|
|
|
searchDocs: async ({ query, limit, workspaceId, includeDeleted }) => {
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
const wsIds = workspaceId
|
|
|
|
|
|
? [workspaceId]
|
|
|
|
|
|
: ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId }))?.workspaces ?? []).map((w: any) =>
|
|
|
|
|
|
String(w?.id ?? ""),
|
|
|
|
|
|
);
|
|
|
|
|
|
const q = query.toLowerCase();
|
|
|
|
|
|
const results: any[] = [];
|
|
|
|
|
|
for (const wid of wsIds.filter(Boolean)) {
|
|
|
|
|
|
const docs = await convexClient.query(api.documents.listByWorkspace, { userId, workspaceId: wid });
|
|
|
|
|
|
const extra = includeDeleted
|
|
|
|
|
|
? await convexClient.query(api.documents.listTrashedByWorkspace, { userId, workspaceId: wid }).catch(() => [])
|
|
|
|
|
|
: [];
|
|
|
|
|
|
const all = [...(Array.isArray(docs) ? docs : []), ...(Array.isArray(extra) ? extra : [])];
|
|
|
|
|
|
for (const d of all) {
|
|
|
|
|
|
const title = String((d as any)?.title ?? "");
|
|
|
|
|
|
if (!title.toLowerCase().includes(q)) continue;
|
|
|
|
|
|
results.push({
|
|
|
|
|
|
id: String((d as any)?.id ?? ""),
|
|
|
|
|
|
title,
|
|
|
|
|
|
workspaceId: String((d as any)?.workspace_id ?? wid),
|
|
|
|
|
|
parentId: (d as any)?.parent_id ? String((d as any).parent_id) : null,
|
|
|
|
|
|
updatedAt: (d as any)?.updated_at ?? null,
|
|
|
|
|
|
snippet: title.slice(0, 120),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (results.length >= limit) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (results.length >= limit) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
return results.slice(0, limit);
|
|
|
|
|
|
},
|
|
|
|
|
|
readDoc: async ({ documentId: rid, maxChars, includeContent }) => {
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
const meta = await convexClient.query(api.documents.getMeta, { userId, id: rid });
|
|
|
|
|
|
if (!meta) throw new Error("页面不存在");
|
|
|
|
|
|
const contentRes = await convexClient.query(api.documents.getContent, { userId, id: rid });
|
|
|
|
|
|
const blocks = normalizeBlocksForTools(contentRes?.content ?? null);
|
|
|
|
|
|
const rawText = extractPlainTextFromBlocks(blocks, maxChars);
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
documentId: rid,
|
|
|
|
|
|
title: String(meta.title ?? ""),
|
|
|
|
|
|
workspaceId: String((meta as any).workspace_id ?? ""),
|
|
|
|
|
|
parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null,
|
|
|
|
|
|
updatedAt: (meta as any).updated_at ?? null,
|
|
|
|
|
|
rawTextLength: rawText.length,
|
|
|
|
|
|
rawText,
|
|
|
|
|
|
...(includeContent ? { content: contentRes?.content ?? null } : {}),
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
: {}),
|
2026-01-10 23:08:56 +08:00
|
|
|
|
})
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
const mediaTools = allowedToolIds.has("image_read")
|
|
|
|
|
|
? createMediaServerTools({
|
|
|
|
|
|
supabase: supabase as unknown as MediaSupabaseClient,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
ctx: { userId, attachments },
|
2026-01-10 23:08:56 +08:00
|
|
|
|
allowedToolIds,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
...(convexOn
|
|
|
|
|
|
? {
|
|
|
|
|
|
loadById: async (id: string) => {
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
return await convexClient.query(api.mediaAssets.getById, { userId, id });
|
|
|
|
|
|
},
|
|
|
|
|
|
loadByFileUrl: async (_fileUrl: string) => null,
|
|
|
|
|
|
}
|
|
|
|
|
|
: {}),
|
2026-01-10 23:08:56 +08:00
|
|
|
|
})
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const onlyofficeTools =
|
2026-01-17 10:12:53 +08:00
|
|
|
|
!convexOn && (allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap"))
|
2026-01-11 12:35:53 +08:00
|
|
|
|
? createOnlyOfficeServerTools({
|
|
|
|
|
|
supabase: supabase as unknown as OnlyOfficeSupabaseClient,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
ctx: { userId, documentId: documentId || undefined, attachments },
|
2026-01-11 12:35:53 +08:00
|
|
|
|
allowedToolIds,
|
|
|
|
|
|
})
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const slashTools = allowedToolIds.has("slash_run")
|
|
|
|
|
|
? createSlashServerTools({
|
|
|
|
|
|
supabase: supabase as unknown as SlashSupabaseClient,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
ctx: { userId, currentDocumentId: documentId || undefined },
|
2026-01-10 23:08:56 +08:00
|
|
|
|
allowedToolIds,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
...(convexOn
|
|
|
|
|
|
? {
|
|
|
|
|
|
loadWorkspaceIds: async (uid: string) => {
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId: uid });
|
|
|
|
|
|
return (res?.workspaces ?? []).map((w: any) => String(w?.id ?? "")).filter(Boolean);
|
|
|
|
|
|
},
|
|
|
|
|
|
inferWorkspaceIdFromDoc: async (docId: string) => {
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
const meta = await convexClient.query(api.documents.getMeta, { userId, id: docId });
|
|
|
|
|
|
return meta ? String((meta as any).workspace_id ?? "") || null : null;
|
|
|
|
|
|
},
|
|
|
|
|
|
createDoc: async ({ workspaceId, parentId, title }) => {
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `doc_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
|
|
|
|
|
const created = await convexClient.mutation(api.documents.create, {
|
|
|
|
|
|
userId,
|
|
|
|
|
|
id,
|
|
|
|
|
|
workspaceId,
|
|
|
|
|
|
parentId,
|
|
|
|
|
|
title,
|
|
|
|
|
|
accessScope: "private",
|
|
|
|
|
|
content: [],
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: String((created as any).id ?? id),
|
|
|
|
|
|
title: String((created as any).title ?? title),
|
|
|
|
|
|
workspaceId: String((created as any).workspace_id ?? workspaceId),
|
|
|
|
|
|
parentId: (created as any).parent_id ? String((created as any).parent_id) : parentId,
|
|
|
|
|
|
createdAt: (created as any).created_at ?? null,
|
|
|
|
|
|
updatedAt: (created as any).updated_at ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
renameDoc: async ({ documentId: did, title }) => {
|
|
|
|
|
|
if (!convexClient) throw new Error("Convex 未初始化");
|
|
|
|
|
|
await convexClient.mutation(api.documents.updateTitle, { userId, id: did, title });
|
|
|
|
|
|
const meta = await convexClient.query(api.documents.getMeta, { userId, id: did });
|
|
|
|
|
|
if (!meta) throw new Error("页面不存在");
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: String((meta as any).id ?? did),
|
|
|
|
|
|
title: String((meta as any).title ?? title),
|
|
|
|
|
|
workspaceId: String((meta as any).workspace_id ?? ""),
|
|
|
|
|
|
parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null,
|
|
|
|
|
|
updatedAt: (meta as any).updated_at ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
: {}),
|
2026-01-10 23:08:56 +08:00
|
|
|
|
})
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
const runTool = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (isOnlyOfficeClientTool(toolId)) {
|
|
|
|
|
|
throw new Error("oo_* 属于客户端工具:必须使用 stream 模式并在 OnlyOffice 页面内执行");
|
|
|
|
|
|
}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
if (toolId === "search_web") {
|
|
|
|
|
|
const query = String(toolArgs.query ?? "").trim();
|
|
|
|
|
|
const count = Number(toolArgs.count ?? 6);
|
|
|
|
|
|
return await searchSearxng(query, Number.isFinite(count) ? count : 6);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (toolId.startsWith("rag_")) {
|
|
|
|
|
|
if (!ragTools) throw new Error(`工具未初始化:${toolId}`);
|
|
|
|
|
|
return await ragTools.run(toolId, toolArgs);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (toolId.startsWith("docs_")) {
|
|
|
|
|
|
if (!docsTools) throw new Error(`工具未初始化:${toolId}`);
|
|
|
|
|
|
return await docsTools.run(toolId, toolArgs);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (toolId === "image_read") {
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (!mediaTools) throw new Error(`工具未初始化:${toolId}`);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
return await mediaTools.run(toolId, toolArgs);
|
|
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (toolId.startsWith("asset_")) {
|
|
|
|
|
|
if (!onlyofficeTools) throw new Error(`工具未初始化:${toolId}`);
|
|
|
|
|
|
return await onlyofficeTools.run(toolId, toolArgs);
|
|
|
|
|
|
}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
if (toolId === "slash_run") {
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (!slashTools) throw new Error(`工具未初始化:${toolId}`);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
return await slashTools.run(toolId, toolArgs);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (toolId.startsWith("doc_")) {
|
|
|
|
|
|
if (!hasDocumentContext) throw new Error(`工具需要 document 上下文:${toolId}`);
|
|
|
|
|
|
if (!docTools) throw new Error(`工具需要 document 上下文:${toolId}`);
|
|
|
|
|
|
return await docTools.run(toolId, toolArgs);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!hasMindmapContext) throw new Error(`工具需要 mindmap 上下文:${toolId}`);
|
|
|
|
|
|
if (!mindmapTools) throw new Error(`工具需要 mindmap 上下文:${toolId}`);
|
|
|
|
|
|
return await mindmapTools.run(toolId, toolArgs);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const maxSteps = (() => {
|
|
|
|
|
|
const raw = Number(payload.maxSteps ?? DEFAULT_MAX_STEPS);
|
|
|
|
|
|
if (!Number.isFinite(raw)) return DEFAULT_MAX_STEPS;
|
|
|
|
|
|
return Math.max(1, Math.min(24, Math.floor(raw)));
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
const stream = payload.stream !== false;
|
|
|
|
|
|
if (!stream) {
|
|
|
|
|
|
const events: Array<{ type: string; data: unknown }> = [];
|
|
|
|
|
|
const result = await runAiAgent({
|
|
|
|
|
|
userMessages: payload.messages.slice(0, 50),
|
|
|
|
|
|
cfg: { ...cfg, model: modelOverride ?? cfg.model },
|
|
|
|
|
|
allowedToolIds,
|
|
|
|
|
|
runTool,
|
|
|
|
|
|
maxSteps,
|
|
|
|
|
|
systemContextText,
|
|
|
|
|
|
defaultMindmapTargetUid: selectedUids[0] ?? "",
|
|
|
|
|
|
onEvent: (ev) => events.push(ev),
|
|
|
|
|
|
}).catch((e) => ({ ok: false as const, error: e instanceof Error ? e.message : String(e) }));
|
|
|
|
|
|
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 500 });
|
|
|
|
|
|
return NextResponse.json({ text: result.text, steps: result.steps, events });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
|
|
const body = new ReadableStream<Uint8Array>({
|
|
|
|
|
|
start(controller) {
|
|
|
|
|
|
const send = (event: string, data: unknown) => {
|
|
|
|
|
|
controller.enqueue(encoder.encode(toSseFrame(event, data)));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 先发一个 ready,方便前端快速进入“流式模式”
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const requestId = makeRunId();
|
|
|
|
|
|
send("ready", { ok: true, requestId });
|
|
|
|
|
|
|
|
|
|
|
|
let lastToolCall: { id: string; tool: string; args: Record<string, unknown> } | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
const runToolStream = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
|
|
|
|
|
if (isOnlyOfficeClientTool(toolId)) {
|
|
|
|
|
|
const callId = lastToolCall?.tool === toolId ? lastToolCall.id : `call_${Date.now()}`;
|
|
|
|
|
|
const key = buildClientToolKey(requestId, callId);
|
2026-01-17 10:12:53 +08:00
|
|
|
|
const wait = registerClientToolCall({
|
|
|
|
|
|
key,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
|
|
|
|
|
|
});
|
2026-01-11 12:35:53 +08:00
|
|
|
|
|
|
|
|
|
|
// 说明:客户端收到该事件后,需要执行插件 API 并回调 /api/ai-agent/client-tool-result
|
|
|
|
|
|
send("client_tool_call", { requestId, callId, tool: toolId, args: toolArgs });
|
|
|
|
|
|
|
|
|
|
|
|
const result = await wait;
|
|
|
|
|
|
if (!result.ok) throw new Error(result.error);
|
|
|
|
|
|
return result.result;
|
|
|
|
|
|
}
|
|
|
|
|
|
return await runTool(toolId, toolArgs);
|
|
|
|
|
|
};
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
const ping = setInterval(() => {
|
|
|
|
|
|
// 避免某些代理/浏览器长连接超时
|
|
|
|
|
|
try {
|
|
|
|
|
|
controller.enqueue(encoder.encode(`: ping ${Date.now()}\n\n`));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 15_000);
|
|
|
|
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
|
|
const result = await runAiAgent({
|
|
|
|
|
|
userMessages: payload.messages.slice(0, 50),
|
|
|
|
|
|
cfg: { ...cfg, model: modelOverride ?? cfg.model },
|
|
|
|
|
|
allowedToolIds,
|
2026-01-11 12:35:53 +08:00
|
|
|
|
runTool: runToolStream,
|
2026-01-10 23:08:56 +08:00
|
|
|
|
maxSteps,
|
|
|
|
|
|
systemContextText,
|
|
|
|
|
|
defaultMindmapTargetUid: selectedUids[0] ?? "",
|
|
|
|
|
|
onEvent: (ev) => {
|
|
|
|
|
|
if (!ev?.type) return;
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (ev.type === "tool_call") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const d = ev.data as unknown;
|
|
|
|
|
|
const obj =
|
|
|
|
|
|
typeof d === "object" && d
|
|
|
|
|
|
? (d as Record<string, unknown>)
|
|
|
|
|
|
: ({} as Record<string, unknown>);
|
|
|
|
|
|
const id = String(obj.id ?? "").trim();
|
|
|
|
|
|
const tool = String(obj.tool ?? "").trim();
|
|
|
|
|
|
const args =
|
|
|
|
|
|
typeof obj.args === "object" && obj.args
|
|
|
|
|
|
? (obj.args as Record<string, unknown>)
|
|
|
|
|
|
: ({} as Record<string, unknown>);
|
|
|
|
|
|
if (id && tool) lastToolCall = { id, tool, args };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
send(ev.type, ev.data ?? null);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
|
send("error", { ok: false, message: result.error });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
send("completion", { ok: true, text: result.text, steps: result.steps });
|
|
|
|
|
|
})()
|
|
|
|
|
|
.catch((e) => {
|
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
|
|
|
|
send("error", { ok: false, message: msg });
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
clearInterval(ping);
|
|
|
|
|
|
controller.close();
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return new Response(body, { headers: sseHeaders });
|
|
|
|
|
|
}
|