0.3.3 外网下载修复

This commit is contained in:
liaibo
2026-01-21 18:21:10 +08:00
parent f13de35321
commit 71de56850b
45 changed files with 2499 additions and 476 deletions
@@ -16,6 +16,18 @@ import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/li
import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import type { ConvexHttpClient } from "convex/browser";
import {
DEFAULT_AGENT_MAX_STEPS,
MAX_AGENT_STEPS,
MIN_AGENT_STEPS,
DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
MAX_MINDMAP_ATTACHMENTS,
MAX_SELECTED_NODES,
DEFAULT_SEARCH_COUNT,
} from "@/lib/constants";
import { safeGetJsonBody, errorResponses, validateRequestBody } from "@/lib/api-utils";
import { isPlainObject, hasProperty, toRecord } from "@/lib/type-guards";
export const dynamic = "force-dynamic";
@@ -39,8 +51,46 @@ type RequestPayload = {
options?: { searxng?: boolean; ai?: { provider?: "online" | "local"; model?: string } };
};
const DEFAULT_MAX_STEPS = 10;
const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;
/** 按作用域分组的工具集 ID 映射 */
const SCOPE_TOOLSET_MAP: Record<AgentScope, string[]> = {
mindmap: [
"toolset.readonly",
"toolset.rag_read",
"toolset.media_read",
"toolset.mindmap_read",
"toolset.mindmap_write",
],
document: [
"toolset.readonly",
"toolset.rag_read",
"toolset.media_read",
"toolset.docs_read",
"toolset.doc_read",
"toolset.doc_write",
"toolset.slash_write",
],
onlyoffice: [
"toolset.readonly",
"toolset.rag_read",
"toolset.media_read",
"toolset.docs_read",
"toolset.onlyoffice_read",
"toolset.onlyoffice_write",
"toolset.onlyoffice_editor",
],
global: [
"toolset.readonly",
"toolset.rag_read",
"toolset.media_read",
"toolset.docs_read",
"toolset.slash_write",
],
};
/** 获取指定作用域允许的工具集 ID */
const getToolSetIdsForScope = (scope: AgentScope): string[] => {
return SCOPE_TOOLSET_MAP[scope] ?? SCOPE_TOOLSET_MAP.global;
};
const makeRunId = () => {
try {
@@ -67,22 +117,31 @@ const toSseFrame = (event: string, data: unknown) => {
};
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 });
const payload = await safeGetJsonBody<RequestPayload>(request);
if (!payload) {
return errorResponses.badRequest("请求体不能为空");
}
const validationError = validateRequestBody(payload, ["messages"] as const);
if (validationError) {
return validationError;
}
if (!Array.isArray(payload.messages) || payload.messages.length === 0) {
return errorResponses.badRequest("缺少 messages");
}
// v1:鉴权(Convex 迁移阶段使用固定开发用户;非 Convex 模式仍走 Supabase session
const convexOn = isConvexEnabled();
const { userId, supabase, convexClient } = await (async () => {
if (convexOn) {
const { auth, client } = await getAuthedConvexClient();
return { userId: auth.userId, supabase: null as any, convexClient: client };
}
return { userId: "", supabase: null as any, convexClient: null as any };
})();
const supabase = null as unknown;
let userId = "";
let convexClient: ConvexHttpClient | null = null;
if (convexOn) {
const { auth, client } = await getAuthedConvexClient();
userId = auth.userId ?? "";
convexClient = client;
}
if (!userId) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
return errorResponses.unauthorized();
}
const provider = payload.options?.ai?.provider === "local" ? "local" : "online";
@@ -92,11 +151,7 @@ export async function POST(request: Request) {
? 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 });
return errorResponses.aiConfigError(provider);
}
const registry = createToolRegistry({ tools: builtinTools, toolSets: builtinToolSets });
@@ -116,15 +171,8 @@ export async function POST(request: Request) {
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"]
: 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"];
// v1:按"使用位置"隔离工具,避免工具混淆/误调用(即使用户手动传入,也会被过滤)
const allowToolSetIds = getToolSetIdsForScope(scope);
const allowlist = new Set<string>();
for (const sid of allowToolSetIds) {
const s = registry.toolSetsById.get(sid);
@@ -139,12 +187,12 @@ export async function POST(request: Request) {
// v1:mindmap 工具必须在提供上下文时才允许,避免模型盲调导致误操作
const selectedUids = Array.isArray(payload.context?.selectedUids)
? payload.context!.selectedUids!.map((x) => String(x)).filter(Boolean).slice(0, 6)
? payload.context!.selectedUids!.map((x) => String(x)).filter(Boolean).slice(0, MAX_SELECTED_NODES)
: [];
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 attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, MAX_MINDMAP_ATTACHMENTS) : [];
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");
@@ -251,8 +299,8 @@ export async function POST(request: Request) {
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 }),
convexClient.query(api.mindmaps.get, { docId: documentId, mindmapId }),
convexClient.query(api.documents.getMeta, { id: documentId }),
]);
const title = meta?.title ?? null;
const workspaceId = meta?.workspace_id ?? (mm as any)?.meta?.workspace_id ?? null;
@@ -263,7 +311,7 @@ export async function POST(request: Request) {
},
saveMindmap: async ({ data }) => {
if (!convexClient) throw new Error("Convex 未初始化");
await convexClient.mutation(api.mindmaps.put, { userId, docId: documentId, mindmapId, data });
await convexClient.mutation(api.mindmaps.put, { docId: documentId, mindmapId, data });
},
}
: {}),
@@ -286,13 +334,13 @@ export async function POST(request: Request) {
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 res = await convexClient.query(api.documents.getContent, { 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 });
await convexClient.mutation(api.documents.updateContent, { id: documentId, content: blocks });
},
}
: {}),
@@ -318,15 +366,15 @@ export async function POST(request: Request) {
if (!convexClient) throw new Error("Convex 未初始化");
const wsIds = workspaceId
? [workspaceId]
: ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId }))?.workspaces ?? []).map((w: any) =>
: ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, {}))?.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 docs = await convexClient.query(api.documents.listByWorkspace, { workspaceId: wid });
const extra = includeDeleted
? await convexClient.query(api.documents.listTrashedByWorkspace, { userId, workspaceId: wid }).catch(() => [])
? await convexClient.query(api.documents.listTrashedByWorkspace, { workspaceId: wid }).catch(() => [])
: [];
const all = [...(Array.isArray(docs) ? docs : []), ...(Array.isArray(extra) ? extra : [])];
for (const d of all) {
@@ -348,9 +396,9 @@ export async function POST(request: Request) {
},
readDoc: async ({ documentId: rid, maxChars, includeContent }) => {
if (!convexClient) throw new Error("Convex 未初始化");
const meta = await convexClient.query(api.documents.getMeta, { userId, id: rid });
const meta = await convexClient.query(api.documents.getMeta, { id: rid });
if (!meta) throw new Error("页面不存在");
const contentRes = await convexClient.query(api.documents.getContent, { userId, id: rid });
const contentRes = await convexClient.query(api.documents.getContent, { id: rid });
const blocks = normalizeBlocksForTools(contentRes?.content ?? null);
const rawText = extractPlainTextFromBlocks(blocks, maxChars);
return {
@@ -403,21 +451,20 @@ export async function POST(request: Request) {
allowedToolIds,
...(convexOn
? {
loadWorkspaceIds: async (uid: string) => {
loadWorkspaceIds: async (_uid: string) => {
if (!convexClient) throw new Error("Convex 未初始化");
const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId: uid });
const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, {});
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 });
const meta = await convexClient.query(api.documents.getMeta, { 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,
@@ -436,8 +483,8 @@ export async function POST(request: Request) {
},
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 });
await convexClient.mutation(api.documents.updateTitle, { id: did, title });
const meta = await convexClient.query(api.documents.getMeta, { id: did });
if (!meta) throw new Error("页面不存在");
return {
id: String((meta as any).id ?? did),
@@ -458,8 +505,8 @@ export async function POST(request: Request) {
}
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);
const count = Number(toolArgs.count ?? DEFAULT_SEARCH_COUNT);
return await searchSearxng(query, Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT);
}
if (toolId.startsWith("rag_")) {
if (!ragTools) throw new Error(`工具未初始化:${toolId}`);
@@ -492,9 +539,9 @@ export async function POST(request: Request) {
};
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 raw = Number(payload.maxSteps ?? DEFAULT_AGENT_MAX_STEPS);
if (!Number.isFinite(raw)) return DEFAULT_AGENT_MAX_STEPS;
return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(raw)));
})();
const stream = payload.stream !== false;