import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { getChatById, getMessagesByChatId, getSystemSetting, getUserById, saveChat, saveMessages, } from "@/lib/db/queries"; import { normalizeOpenClawChatConfig, OPENCLAW_CHAT_CONFIG_KEY, } from "@/lib/openclaw-chat/config"; import { buildClawdCancelKey, cancelClawdRequest, clearClawdRequestCancellation, isClawdRequestCanceled, registerActiveClawdRequest, unregisterActiveClawdRequest, } from "@/lib/openclaw-chat/cancel-registry"; import { buildOpenClawDisplayMessage, buildOpenClawMessageWithSpreadsheetMarkdown, buildOpenClawSpreadsheetMarkdown, } from "@/lib/openclaw-chat/spreadsheet-markdown"; import { generateUUID } from "@/lib/utils"; const CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试"; 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 "Claw 对话"; return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized; } function createConversationId(userId: string, chatId: string) { return `site-user-${userId}-canvas-${chatId}`; } function getOpenClawUserName(user: { id: string; name?: string | null; email?: string | null; }) { const name = String(user.name || "").trim(); if (name) return name; const email = String(user.email || "").trim(); if (email) return email; return user.id; } function getOpenClawDepartmentName(department?: string | null) { return String(department || "").trim(); } 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 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() : ""; } function getMetadataString( metadata: Record | undefined, key: string ) { const value = metadata?.[key]; return typeof value === "string" ? value.trim() : ""; } function createCanceledResponse() { return NextResponse.json({ canceled: true }, { status: 499 }); } 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: CLAW_UPGRADE_RETRY_MESSAGE }, { status: 503 }); } if (!config.baseUrl || !config.token) { return NextResponse.json( { error: CLAW_UPGRADE_RETRY_MESSAGE }, { status: 503 } ); } const existingChat = await getChatById({ id: parsed.id }).catch(() => null); if (existingChat && existingChat.userId !== session.user.id) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const userMessageId = parsed.userMessageId || generateUUID(); const cancelKey = buildClawdCancelKey({ userId: session.user.id, chatId: parsed.id, userMessageId, }); if (isClawdRequestCanceled(cancelKey)) { return createCanceledResponse(); } 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 (isClawdRequestCanceled(cancelKey)) { return createCanceledResponse(); } if (!existingChat) { await saveChat({ id: parsed.id, userId: session.user.id, title: buildChatTitle(displayMessage), visibility: "private", }); } const assistantMessageId = generateUUID(); const conversationId = createConversationId(session.user.id, parsed.id); const userProfile = await getUserById({ id: session.user.id }).catch(() => null); const openClawUserName = getOpenClawUserName({ id: session.user.id, name: userProfile?.name ?? session.user.name, email: userProfile?.email ?? session.user.email, }); const openClawDepartmentName = getOpenClawDepartmentName(userProfile?.department); const skillTagId = getMetadataString(parsed.metadata, "skillTagId"); const skillTagName = getMetadataString(parsed.metadata, "skillTagName"); const endpoint = `${config.baseUrl}/api/chat`; let upstreamPayload: unknown = null; let upstreamStatus = 502; if (isClawdRequestCanceled(cancelKey)) { return createCanceledResponse(); } try { const controller = new AbortController(); registerActiveClawdRequest(cancelKey, controller, config.timeoutMs); const timeout = setTimeout(() => controller.abort(), config.timeoutMs); const handleRequestAbort = () => { cancelClawdRequest(cancelKey); }; request.signal.addEventListener("abort", handleRequestAbort, { once: true }); 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: openClawUserName, username: openClawUserName, department: openClawDepartmentName, department_name: openClawDepartmentName, user_department: openClawDepartmentName, skill_tag_id: skillTagId || undefined, skill_tag_name: skillTagName || undefined, message: upstreamMessage, metadata: { source: "ai_chatbot_canvas", page: "/canvas", tenant: "default", chat_id: parsed.id, user_id: session.user.id, user_name: openClawUserName, username: openClawUserName, department: openClawDepartmentName, department_name: openClawDepartmentName, user_department: openClawDepartmentName, skill_tag_id: skillTagId || undefined, skill_tag_name: skillTagName || undefined, spreadsheet_files: spreadsheetMarkdownResult?.files || undefined, spreadsheet_markdown_truncated: spreadsheetMarkdownResult?.truncated || undefined, ...parsed.metadata, }, }), signal: controller.signal, }).finally(() => { clearTimeout(timeout); request.signal.removeEventListener("abort", handleRequestAbort); unregisterActiveClawdRequest(cancelKey); }); upstreamStatus = response.status; upstreamPayload = await response.json().catch(() => null); if (!response.ok) { const errorCode = getOpenClawErrorCode(upstreamPayload); console.error("[ClawdChat API] OpenClaw upstream error:", { status: response.status, code: errorCode || undefined, payload: upstreamPayload, }); return NextResponse.json( { error: CLAW_UPGRADE_RETRY_MESSAGE, code: errorCode || undefined, }, { status: response.status } ); } } catch (error) { unregisterActiveClawdRequest(cancelKey); const errorName = error && typeof error === "object" && "name" in error ? String((error as { name?: unknown }).name || "") : ""; const isAbortError = errorName === "AbortError"; if (isClawdRequestCanceled(cancelKey)) { console.info("[ClawdChat API] OpenClaw request canceled:", { chatId: parsed.id, userMessageId, }); return createCanceledResponse(); } console.error("[ClawdChat API] OpenClaw request failed:", error); return NextResponse.json( { error: CLAW_UPGRADE_RETRY_MESSAGE, }, { status: isAbortError ? 504 : upstreamStatus } ); } const answer = upstreamPayload && typeof upstreamPayload === "object" ? String((upstreamPayload as Record).answer || "").trim() : ""; if (!answer) { console.error("[ClawdChat API] OpenClaw empty answer:", upstreamPayload); return NextResponse.json( { error: CLAW_UPGRADE_RETRY_MESSAGE }, { status: 502 } ); } if (isClawdRequestCanceled(cancelKey)) { return createCanceledResponse(); } 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: { composerMode: "clawd", source: "openclaw", skillTagId: skillTagId || null, skillTagName: skillTagName || 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: { composerMode: "clawd", source: "openclaw", requestId: upstreamPayload && typeof upstreamPayload === "object" ? (upstreamPayload as Record).request_id || null : null, }, }; await saveMessages({ messages: [userMessage, assistantMessage] }); clearClawdRequestCancellation(cancelKey); 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(), composerMode: "clawd", }, videoMetadata: userMessage.videoMetadata, }, { id: assistantMessage.id, role: "assistant", parts: assistantMessage.parts, createdAt: assistantMessage.createdAt.toISOString(), metadata: { createdAt: assistantMessage.createdAt.toISOString(), composerMode: "clawd", }, videoMetadata: assistantMessage.videoMetadata, }, ], }); }