0.1.13 AI功能大改

This commit is contained in:
liaibo
2026-01-10 23:08:56 +08:00
parent 63050f3927
commit be71849aa5
37 changed files with 6400 additions and 160 deletions
@@ -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[];
// v1BlockNote 文档快照(前端可选传入,避免覆盖未落盘编辑)
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 });
}
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET() {
const backendUrl = process.env.BACKEND_URL ?? process.env.NEXT_PUBLIC_BACKEND_URL;
if (!backendUrl) {
return NextResponse.json({ status: "disabled" }, { status: 200 });
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2500);
try {
const response = await fetch(`${backendUrl}/health`, {
method: "GET",
signal: controller.signal,
});
return NextResponse.json({ status: response.ok ? "ok" : "error" }, { status: 200 });
} catch {
return NextResponse.json({ status: "error" }, { status: 200 });
} finally {
clearTimeout(timer);
}
}
@@ -7,10 +7,11 @@ export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (!session) {
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
@@ -47,10 +48,11 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (!session) {
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
@@ -80,7 +82,7 @@ export async function POST(request: Request) {
file_name: payload.fileName,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType ?? null,
created_by: session.user.id,
created_by: user.id,
})
.select("*")
.single();
@@ -27,7 +27,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
const backendUrl = process.env.BACKEND_URL ?? process.env.NEXT_PUBLIC_BACKEND_URL;
if (backendUrl) {
void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, {
method: "POST",
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const assetId = searchParams.get("assetId");
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
// 获取资源信息
const { data: asset, error: assetError } = await supabase
.from("media_assets")
.select("*")
.eq("id", assetId)
.single();
if (assetError || !asset) {
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
}
// 对于图片,添加高质量参数以获得更好的显示效果
// Supabase Storage 支持通过 URL 参数控制图片质量和尺寸
const isImage = asset.mime_type?.startsWith("image/");
let signedUrl: string;
if (isImage) {
// 使用 createSignedUrl 并添加高质量参数
// 注意:transform 参数需要在签名时指定
const { data: signedUrlData, error: signError } = await supabase.storage
.from(asset.bucket)
.createSignedUrl(asset.storage_path, 60 * 60, {
// 添加转换参数以获得高质量图片
transform: {
quality: 95, // 高质量
format: "origin", // 保持原始格式
},
});
if (signError || !signedUrlData) {
return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 });
}
signedUrl = signedUrlData.signedUrl;
} else {
// 非图片文件,直接生成签名 URL
const { data: signedUrlData, error: signError } = await supabase.storage
.from(asset.bucket)
.createSignedUrl(asset.storage_path, 60 * 60);
if (signError || !signedUrlData) {
return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 });
}
signedUrl = signedUrlData.signedUrl;
}
return NextResponse.json({
signedUrl,
asset: {
id: asset.id,
file_name: asset.file_name,
mime_type: asset.mime_type,
file_size: asset.file_size,
},
});
}
@@ -51,8 +51,20 @@ export async function POST(request: Request) {
}
// 为私有桶生成临时访问链接(7 天);前端可在需要时通过 /api/media/signed-url 刷新
const { data: signed } = await supabase.storage.from(DOC_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7);
const signedUrl = signed?.signedUrl ?? "";
// 对于图片,使用高质量参数以获得更好的显示效果
let signedUrl = "";
if (assetType === "image") {
const { data: signed } = await supabase.storage.from(DOC_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7, {
transform: {
quality: 95, // 高质量
format: "origin", // 保持原始格式
},
});
signedUrl = signed?.signedUrl ?? "";
} else {
const { data: signed } = await supabase.storage.from(DOC_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7);
signedUrl = signed?.signedUrl ?? "";
}
const { data: asset, error } = await supabase
.from("media_assets")
@@ -76,7 +88,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ asset: asset as MediaAsset });
// 返回 asset 和一个特殊的 asset:id 格式用于存储在思维导图中
return NextResponse.json({
asset: asset as MediaAsset,
mindmapUrl: `asset:${asset.id}`,
});
} catch (error) {
console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });
@@ -213,6 +213,7 @@ const answerFromSearchResults = async (args: {
const resultsText = formatSearchResults(args.results);
if (!q) return resultsText || "(无问题)";
let aiError: string | null = null;
try {
const { text } = await openAiCompatibleChat(
[
@@ -239,11 +240,12 @@ const answerFromSearchResults = async (args: {
);
const out = String(text ?? "").trim();
if (out) return out;
} catch {
// ignore
} catch (e) {
aiError = e instanceof Error ? e.message : String(e);
}
return resultsText || "(未检索到结果)";
const prefix = aiError ? `(在线 AI 总结失败:${aiError}\n\n` : "";
return `${prefix}${resultsText || "(未检索到结果)"}`.trim();
};
const buildFallbackExpandOps = async (args: {
@@ -511,6 +513,52 @@ export async function POST(request: Request) {
String(walkSummaries(mindmap, 1)?.[0]?.uid || "");
const defaultTargetUid = (selected[0] || rootUid || "").trim();
// 纯问答模式:不要求改导图时,直接“检索 → 总结”返回,避免模型反复输出工具 JSON 导致卡步数
if (!userIntentModify) {
const question = String(lastUser || "").trim();
const qaTrace: Array<{ step: number; call: ToolCall; toolResult?: unknown }> = [];
if (question && allowed.has("search_web")) {
const results = await searchSearxng(question, 6).catch(() => []);
qaTrace.push({
step: 1,
call: { type: "tool", tool: "search_web", args: { query: question, count: 6 } },
toolResult: results,
});
const answer = results.length
? await answerFromSearchResults({ cfg, modelOverride, question, results })
: "(未检索到结果)";
qaTrace.push({ step: 2, call: { type: "final", message: answer } });
return NextResponse.json({ ok: true, message: answer, data: mindmap, trace: qaTrace });
}
// 未开启检索或无问题:直接用 AI 回答(不改导图)
const { text } = await openAiCompatibleChat(
[
{
role: "system",
content: [
"你是一个严谨的中文助手。",
"用户提出问题,请直接回答。",
"如果你不确定,明确说明并建议以官方为准。",
"不要输出 JSON。",
].join("\n"),
},
{ role: "user", content: question || "(空)" },
],
{
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
model: modelOverride || cfg.model,
timeoutMs: 55_000,
maxTokens: 1200,
},
);
const answer = String(text || "").trim() || "(无输出)";
qaTrace.push({ step: 1, call: { type: "final", message: answer } });
return NextResponse.json({ ok: true, message: answer, data: mindmap, trace: qaTrace });
}
for (let step = 1; step <= maxSteps; step += 1) {
const { text } = await openAiCompatibleChat(
[