import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { getChatById, getMessagesByChatId, getSystemSetting, saveChat, saveMessages, } from "@/lib/db/queries"; import { normalizeOpenClawChatConfig, OPENCLAW_CHAT_CONFIG_KEY, } from "@/lib/openclaw-chat/config"; import { buildOpenClawDisplayMessage, buildOpenClawMessageWithSpreadsheetMarkdown, buildOpenClawSpreadsheetMarkdown, } from "@/lib/openclaw-chat/spreadsheet-markdown"; import { generateUUID } from "@/lib/utils"; const requestSchema = z.object({ id: z.string().uuid(), message: z.string().trim().max(8000).optional().default(""), displayMessage: z.string().trim().max(8000).optional(), userMessageId: z.string().uuid().optional(), metadata: z.record(z.unknown()).optional(), }); type ParsedClawdChatRequest = z.infer & { tableFiles: File[]; }; function buildChatTitle(message: string) { const normalized = message.replace(/\s+/g, " ").trim(); if (!normalized) return "Clawd 对话"; return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized; } function createConversationId(userId: string, chatId: string) { return `site-user-${userId}-canvas-${chatId}`; } function isRequestFile(value: FormDataEntryValue): value is File { return ( typeof value === "object" && value !== null && "arrayBuffer" in value && "name" in value && "size" in value ); } async function parseClawdChatRequest( request: Request ): Promise { const contentType = request.headers.get("content-type") || ""; if (!contentType.includes("multipart/form-data")) { const parsed = requestSchema.safeParse(await request.json().catch(() => null)); return parsed.success ? { ...parsed.data, tableFiles: [] } : null; } const formData = await request.formData().catch(() => null); if (!formData) return null; const metadataValue = formData.get("metadata"); let metadata: Record | undefined; if (typeof metadataValue === "string" && metadataValue.trim()) { try { const parsedMetadata = JSON.parse(metadataValue); if (parsedMetadata && typeof parsedMetadata === "object") { metadata = parsedMetadata as Record; } else { return null; } } catch { return null; } } const parsed = requestSchema.safeParse({ id: formData.get("id"), message: formData.get("message") || "", displayMessage: formData.get("displayMessage") || undefined, userMessageId: formData.get("userMessageId") || undefined, metadata, }); if (!parsed.success) return null; const tableFiles = formData .getAll("tableFiles") .filter((entry): entry is File => isRequestFile(entry) && entry.size > 0); return { ...parsed.data, tableFiles }; } async function readOpenClawConfig() { const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch( () => null ); return normalizeOpenClawChatConfig(setting?.value); } function getOpenClawErrorMessage(payload: unknown, fallback: string) { if (payload && typeof payload === "object") { const raw = payload as Record; const nestedError = raw.error && typeof raw.error === "object" ? (raw.error as Record) : null; const message = nestedError?.message || raw.message || raw.error; if (typeof message === "string" && message.trim()) { return message.trim(); } } return fallback; } function getOpenClawErrorCode(payload: unknown) { if (!payload || typeof payload !== "object") return ""; const raw = payload as Record; const nestedError = raw.error && typeof raw.error === "object" ? (raw.error as Record) : null; const code = nestedError?.code || raw.code; return typeof code === "string" ? code.trim() : ""; } export async function POST(request: Request) { const session = await auth().catch(() => null); if (!session?.user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const parsed = await parseClawdChatRequest(request); if (!parsed) { return NextResponse.json({ error: "请求参数不正确" }, { status: 400 }); } if (!parsed.message && parsed.tableFiles.length === 0) { return NextResponse.json({ error: "请输入问题或上传表格" }, { status: 400 }); } const config = await readOpenClawConfig(); if (!config.enabled) { return NextResponse.json({ error: "Clawd 未启用" }, { status: 403 }); } if (!config.baseUrl || !config.token) { return NextResponse.json( { error: "Clawd 接口地址或 Token 未配置" }, { status: 500 } ); } const existingChat = await getChatById({ id: parsed.id }).catch(() => null); if (existingChat && existingChat.userId !== session.user.id) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } let spreadsheetMarkdownResult: Awaited< ReturnType > | null = null; if (parsed.tableFiles.length > 0) { try { spreadsheetMarkdownResult = await buildOpenClawSpreadsheetMarkdown( parsed.tableFiles ); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : "表格解析失败,请检查文件格式", }, { status: 400 } ); } } const displayMessage = buildOpenClawDisplayMessage({ message: parsed.displayMessage || parsed.message, files: spreadsheetMarkdownResult?.files || [], }); const upstreamMessage = buildOpenClawMessageWithSpreadsheetMarkdown({ message: parsed.message, spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "", }); if (!existingChat) { await saveChat({ id: parsed.id, userId: session.user.id, title: buildChatTitle(displayMessage), visibility: "private", }); } const userMessageId = parsed.userMessageId || generateUUID(); const assistantMessageId = generateUUID(); const conversationId = createConversationId(session.user.id, parsed.id); const endpoint = `${config.baseUrl}/api/chat`; let upstreamPayload: unknown = null; let upstreamStatus = 502; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeoutMs); const response = await fetch(endpoint, { method: "POST", headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ conversation_id: conversationId, user_id: session.user.id, user_name: session.user.name || session.user.email || "", message: upstreamMessage, metadata: { source: "ai_chatbot_canvas", page: "/canvas", tenant: "default", chat_id: parsed.id, spreadsheet_files: spreadsheetMarkdownResult?.files || undefined, spreadsheet_markdown_truncated: spreadsheetMarkdownResult?.truncated || undefined, ...parsed.metadata, }, }), signal: controller.signal, }).finally(() => clearTimeout(timeout)); upstreamStatus = response.status; upstreamPayload = await response.json().catch(() => null); if (!response.ok) { const errorCode = getOpenClawErrorCode(upstreamPayload); return NextResponse.json( { error: response.status === 429 ? "正在处理中,请稍后重试" : getOpenClawErrorMessage(upstreamPayload, "大龙虾聊天接口调用失败"), code: errorCode || undefined, }, { status: response.status } ); } } catch (error) { const isAbortError = error instanceof Error && error.name === "AbortError"; return NextResponse.json( { error: isAbortError ? "大龙虾聊天接口请求超时" : "大龙虾聊天接口暂时不可用", }, { status: isAbortError ? 504 : upstreamStatus } ); } const answer = upstreamPayload && typeof upstreamPayload === "object" ? String((upstreamPayload as Record).answer || "").trim() : ""; if (!answer) { return NextResponse.json( { error: "大龙虾聊天接口未返回有效回答" }, { status: 502 } ); } const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch( () => [] ); const latestMessageTimestamp = existingMessages.reduce((latest, item) => { const timestamp = item.createdAt instanceof Date ? item.createdAt.getTime() : Date.parse(String(item.createdAt || "")); return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest; }, 0); const userCreatedAt = new Date( Math.max(Date.now(), latestMessageTimestamp + 1) ); const assistantCreatedAt = new Date(userCreatedAt.getTime() + 1); const userMessage = { id: userMessageId, chatId: parsed.id, role: "user", parts: [{ type: "text", text: displayMessage }], attachments: [], createdAt: userCreatedAt, videoStorageStatus: null, temporaryVideoUrl: null, permanentVideoUrl: null, archivedAt: null, videoMetadata: null, }; const assistantMessage = { id: assistantMessageId, chatId: parsed.id, role: "assistant", parts: [{ type: "text", text: answer }], attachments: [], createdAt: assistantCreatedAt, videoStorageStatus: null, temporaryVideoUrl: null, permanentVideoUrl: null, archivedAt: null, videoMetadata: null, }; await saveMessages({ messages: [userMessage, assistantMessage] }); return NextResponse.json({ conversationId, requestId: upstreamPayload && typeof upstreamPayload === "object" ? (upstreamPayload as Record).request_id || null : null, messages: [ { id: userMessage.id, role: "user", parts: userMessage.parts, createdAt: userMessage.createdAt.toISOString(), metadata: { createdAt: userMessage.createdAt.toISOString() }, }, { id: assistantMessage.id, role: "assistant", parts: assistantMessage.parts, createdAt: assistantMessage.createdAt.toISOString(), metadata: { createdAt: assistantMessage.createdAt.toISOString() }, }, ], }); }