0.1.13 AI功能大改
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
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";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
type AgentScope = "global" | "mindmap" | "document";
|
||||
|
||||
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;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// v1:先要求登录(避免在生产环境暴露推理能力);后续可做更细的权限控制
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
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();
|
||||
if (raw === "global" || raw === "mindmap" || raw === "document") return raw;
|
||||
// 兜底:有 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"]
|
||||
: ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.slash_write"];
|
||||
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");
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
|
||||
const mindmapTools = hasMindmapContext
|
||||
? createMindmapServerTools({
|
||||
supabase: supabase as unknown as MindmapSupabaseClient,
|
||||
ctx: { documentId, mindmapId, userId: session.user.id, selectedUids, attachments },
|
||||
cfg: { ...cfg, model: modelOverride ?? cfg.model },
|
||||
allowedToolIds,
|
||||
})
|
||||
: 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,
|
||||
ctx: { documentId, userId: session.user.id, baseBlocks: documentBlocks },
|
||||
allowedToolIds,
|
||||
})
|
||||
: null;
|
||||
|
||||
const ragTools = allowedToolIds.has("rag_lightrag_query")
|
||||
? createRagServerTools({
|
||||
ctx: { userId: session.user.id },
|
||||
allowedToolIds,
|
||||
})
|
||||
: null;
|
||||
|
||||
const docsTools =
|
||||
allowedToolIds.has("docs_search") || allowedToolIds.has("docs_read")
|
||||
? createDocsServerTools({
|
||||
supabase: supabase as unknown as DocsSupabaseClient,
|
||||
ctx: { userId: session.user.id },
|
||||
allowedToolIds,
|
||||
})
|
||||
: null;
|
||||
|
||||
const mediaTools = allowedToolIds.has("image_read")
|
||||
? createMediaServerTools({
|
||||
supabase: supabase as unknown as MediaSupabaseClient,
|
||||
ctx: { userId: session.user.id, attachments },
|
||||
allowedToolIds,
|
||||
})
|
||||
: null;
|
||||
|
||||
const slashTools = allowedToolIds.has("slash_run")
|
||||
? createSlashServerTools({
|
||||
supabase: supabase as unknown as SlashSupabaseClient,
|
||||
ctx: { userId: session.user.id, currentDocumentId: documentId || undefined },
|
||||
allowedToolIds,
|
||||
})
|
||||
: null;
|
||||
|
||||
const runTool = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
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") {
|
||||
if (!mediaTools) throw new Error(`工具未初始化:${toolId}`);
|
||||
return await mediaTools.run(toolId, toolArgs);
|
||||
}
|
||||
if (toolId === "slash_run") {
|
||||
if (!slashTools) throw new Error(`工具未初始化:${toolId}`);
|
||||
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,方便前端快速进入“流式模式”
|
||||
send("ready", { ok: true });
|
||||
|
||||
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,
|
||||
runTool,
|
||||
maxSteps,
|
||||
systemContextText,
|
||||
defaultMindmapTargetUid: selectedUids[0] ?? "",
|
||||
onEvent: (ev) => {
|
||||
if (!ev?.type) return;
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user