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 { normalizeOpenClawChatMediaAttachments, OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS, type OpenClawChatMediaAttachment, } from "@/lib/openclaw-chat/media-attachments"; 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(), attachments: z .array(z.unknown()) .max(OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS) .optional() .default([]), }); type ParsedClawdChatRequest = Omit< z.infer, "attachments" > & { attachments: OpenClawChatMediaAttachment[]; tableFiles: File[]; }; type ClawdSpreadsheetAttachment = { name: string; size: number; contentType: string; sheetCount: number; rowCount: number; truncated: boolean; }; 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)); if (!parsed.success) return null; const attachments = normalizeOpenClawChatMediaAttachments( parsed.data.attachments ); if (attachments.length !== parsed.data.attachments.length) { return null; } return { ...parsed.data, attachments, tableFiles: [] }; } const formData = await request.formData().catch(() => null); if (!formData) return null; const metadataValue = formData.get("metadata"); const attachmentsValue = formData.get("attachments"); let metadata: Record | undefined; let attachments: unknown[] = []; 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; } } if (typeof attachmentsValue === "string" && attachmentsValue.trim()) { try { const parsedAttachments = JSON.parse(attachmentsValue); if (!Array.isArray(parsedAttachments)) { return null; } attachments = parsedAttachments; } 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, attachments, }); if (!parsed.success) return null; const normalizedAttachments = normalizeOpenClawChatMediaAttachments( parsed.data.attachments ); if (normalizedAttachments.length !== parsed.data.attachments.length) { return null; } const tableFiles = formData .getAll("tableFiles") .filter((entry): entry is File => isRequestFile(entry) && entry.size > 0); return { ...parsed.data, attachments: normalizedAttachments, 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 }); } function encodeSseEvent(event: string, payload: unknown) { return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; } function parseSseBlock(block: string) { let event = "message"; const dataLines: string[] = []; for (const line of block.split(/\r?\n/)) { if (line.startsWith("event:")) { event = line.slice(6).trim() || "message"; } else if (line.startsWith("data:")) { dataLines.push(line.slice(5).trimStart()); } } if (dataLines.length === 0) return null; try { return { event, data: JSON.parse(dataLines.join("\n")) as unknown, }; } catch { return null; } } type OpenClawBackgroundTask = { jobId: string; state: string; stage: string; phase: string; displayText: string; updatedAt: string; completed: number; total: number | null; progressPercent: number | null; pollEndpoint: string; pollAfterMs: number; maxWaitSeconds: number; autoNotify: boolean; }; function parseOpenClawBackgroundTask( payload: unknown ): OpenClawBackgroundTask | null { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return null; } const rawPayload = payload as Record; const rawTask = rawPayload.background_task && typeof rawPayload.background_task === "object" && !Array.isArray(rawPayload.background_task) ? (rawPayload.background_task as Record) : null; if (!rawTask) return null; const jobId = String(rawTask.job_id || "").trim(); const state = String(rawTask.state || "").trim(); const pollEndpoint = String(rawTask.poll_endpoint || "").trim(); if (!jobId || !state) return null; const completed = Math.max(0, Number(rawTask.completed) || 0); const parsedTotal = Number(rawTask.total); const total = Number.isFinite(parsedTotal) && parsedTotal > 0 ? parsedTotal : null; const parsedProgressPercent = Number(rawTask.progress_percent); const progressPercent = rawTask.progress_percent != null && Number.isFinite(parsedProgressPercent) ? Math.max(0, Math.min(100, parsedProgressPercent)) : total ? Math.round((completed * 100) / total) : null; return { jobId, state, stage: String(rawTask.stage || "").trim(), phase: String(rawTask.phase || "").trim(), displayText: String( rawTask.display_text || rawTask.phase || rawPayload.answer || "" ).trim(), updatedAt: String(rawTask.updated_at || "").trim(), completed, total, progressPercent, pollEndpoint, pollAfterMs: Math.max( 800, Math.min(10_000, Number(rawTask.poll_after_ms) || 1500) ), maxWaitSeconds: Math.max( 1, Math.min(25, Number(rawTask.max_wait_seconds) || 20) ), autoNotify: rawTask.auto_notify === true, }; } function buildOpenClawTaskProgress(task: OpenClawBackgroundTask) { return { step_code: task.stage || "background_task", step_state: task.state === "failed" ? "failed" : task.state === "processing" ? "started" : "completed", message: task.displayText || task.phase || (task.state === "processing" ? "任务正在处理" : "任务状态已更新"), display: task.displayText || task.phase || (task.state === "processing" ? "任务正在处理" : "任务状态已更新"), completed: task.completed, ...(task.total != null ? { total: task.total } : {}), ...(task.progressPercent != null ? { progress_percent: task.progressPercent } : {}), stage: task.stage, state: task.state, updated_at: task.updatedAt, }; } function buildOpenClawTaskStatusUrl(params: { baseUrl: string; task: OpenClawBackgroundTask; userId: string; conversationId: string; }) { const base = new URL(params.baseUrl); const endpoint = params.task.pollEndpoint.startsWith("/") ? params.task.pollEndpoint : "/api/chat/task-status"; const url = new URL(endpoint, base.origin); url.searchParams.set("job_id", params.task.jobId); url.searchParams.set("user_id", params.userId); url.searchParams.set("conversation_id", params.conversationId); url.searchParams.set("wait_seconds", String(params.task.maxWaitSeconds)); if (params.task.updatedAt) { url.searchParams.set("known_updated_at", params.task.updatedAt); } return url; } async function persistClawdResponse({ assistantMessageId, attachments, chatId, conversationId, displayMessage, skillTagId, skillTagName, spreadsheetAttachments, upstreamPayload, userMessageId, }: { assistantMessageId: string; attachments: OpenClawChatMediaAttachment[]; chatId: string; conversationId: string; displayMessage: string; skillTagId: string; skillTagName: string; spreadsheetAttachments: ClawdSpreadsheetAttachment[]; upstreamPayload: Record; userMessageId: string; }) { const answer = String(upstreamPayload.answer || "").trim(); if (!answer) { throw new Error("OpenClaw returned an empty answer"); } const existingMessages = await getMessagesByChatId({ id: chatId }).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, role: "user", parts: [{ type: "text", text: displayMessage }], attachments: attachments.map((attachment) => ({ name: attachment.name || (attachment.type === "video" ? "视频附件" : "图片附件"), url: attachment.url, contentType: attachment.content_type || (attachment.type === "video" ? "video/mp4" : "image/jpeg"), })), createdAt: userCreatedAt, videoStorageStatus: null, temporaryVideoUrl: null, permanentVideoUrl: null, archivedAt: null, videoMetadata: { composerMode: "clawd", source: "openclaw", imageAttachmentCount: attachments.filter( (attachment) => attachment.type === "image" ).length, videoAttachmentCount: attachments.filter( (attachment) => attachment.type === "video" ).length, spreadsheetAttachmentCount: spreadsheetAttachments.length, spreadsheetAttachments, skillTagId: skillTagId || null, skillTagName: skillTagName || null, }, }; const assistantMessage = { id: assistantMessageId, chatId, 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.request_id || null, }, }; await saveMessages({ messages: [userMessage, assistantMessage] }); return { conversationId, requestId: upstreamPayload.request_id || null, messages: [ { id: userMessage.id, role: "user", parts: userMessage.parts, createdAt: userMessage.createdAt.toISOString(), metadata: { createdAt: userMessage.createdAt.toISOString(), composerMode: "clawd", spreadsheetAttachments, }, attachments: userMessage.attachments, 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, }, ], }; } function streamOpenClawBackgroundTask(params: { request: Request; cancelKey: string; config: { baseUrl: string; token: string; timeoutMs: number }; initialPayload: Record; initialTask: OpenClawBackgroundTask; assistantMessageId: string; attachments: OpenClawChatMediaAttachment[]; chatId: string; conversationId: string; displayMessage: string; skillTagId: string; skillTagName: string; spreadsheetAttachments: ClawdSpreadsheetAttachment[]; userId: string; userMessageId: string; }) { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(streamController) { const controller = new AbortController(); const handleRequestAbort = () => controller.abort(); params.request.signal.addEventListener("abort", handleRequestAbort, { once: true, }); registerActiveClawdRequest( params.cancelKey, controller, Math.max(params.config.timeoutMs, 60 * 60 * 1000) ); let task = params.initialTask; let finalPayload = params.initialPayload; let lastProgressKey = ""; let consecutiveFailures = 0; const emitProgress = (nextTask: OpenClawBackgroundTask) => { const progress = buildOpenClawTaskProgress(nextTask); const key = [ nextTask.state, nextTask.stage, nextTask.updatedAt, nextTask.completed, nextTask.total ?? "", progress.message, ].join("|"); if (key === lastProgressKey) return; lastProgressKey = key; streamController.enqueue( encoder.encode(encodeSseEvent("progress", progress)) ); }; try { emitProgress(task); while (task.state === "processing" && task.autoNotify) { if ( controller.signal.aborted || isClawdRequestCanceled(params.cancelKey) ) { throw new DOMException("Claw request canceled", "AbortError"); } const statusUrl = buildOpenClawTaskStatusUrl({ baseUrl: params.config.baseUrl, task, userId: params.userId, conversationId: params.conversationId, }); let statusResponse: Response; try { statusResponse = await fetch(statusUrl, { headers: { Authorization: `Bearer ${params.config.token}`, Accept: "application/json", }, cache: "no-store", signal: controller.signal, }); } catch (error) { if (controller.signal.aborted) throw error; consecutiveFailures += 1; if (consecutiveFailures >= 5) throw error; streamController.enqueue( encoder.encode( encodeSseEvent("progress", { step_code: task.stage || "background_task", step_state: "waiting", message: "任务仍在运行,正在重新连接进度服务", display: "任务仍在运行,正在重新连接进度服务", completed: task.completed, ...(task.total != null ? { total: task.total } : {}), ...(task.progressPercent != null ? { progress_percent: task.progressPercent } : {}), }) ) ); await new Promise((resolve) => setTimeout(resolve, task.pollAfterMs) ); continue; } if (!statusResponse.ok) { throw new Error(`OpenClaw task status failed: ${statusResponse.status}`); } const statusPayload = (await statusResponse.json().catch(() => null)) as | Record | null; const nextTask = parseOpenClawBackgroundTask(statusPayload); if (!statusPayload || !nextTask) { throw new Error("OpenClaw task status payload is invalid"); } consecutiveFailures = 0; finalPayload = { ...statusPayload, request_id: statusPayload.request_id || params.initialPayload.request_id || null, }; task = nextTask; emitProgress(task); } const persisted = await persistClawdResponse({ assistantMessageId: params.assistantMessageId, attachments: params.attachments, chatId: params.chatId, conversationId: params.conversationId, displayMessage: params.displayMessage, skillTagId: params.skillTagId, skillTagName: params.skillTagName, spreadsheetAttachments: params.spreadsheetAttachments, upstreamPayload: finalPayload, userMessageId: params.userMessageId, }); clearClawdRequestCancellation(params.cancelKey); streamController.enqueue( encoder.encode(encodeSseEvent("final", persisted)) ); } catch (error) { const wasCanceled = controller.signal.aborted || isClawdRequestCanceled(params.cancelKey) || (error instanceof Error && error.name === "AbortError"); streamController.enqueue( encoder.encode( encodeSseEvent( wasCanceled ? "final" : "error", wasCanceled ? { canceled: true } : { error: CLAW_UPGRADE_RETRY_MESSAGE } ) ) ); } finally { params.request.signal.removeEventListener("abort", handleRequestAbort); unregisterActiveClawdRequest(params.cancelKey); try { streamController.close(); } catch { // The browser may already have closed the stream. } } }, }); return new Response(stream, { headers: { "Cache-Control": "no-cache, no-store", "Content-Type": "text/event-stream; charset=utf-8", "X-Accel-Buffering": "no", }, }); } 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 && parsed.attachments.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 || "", }); const spreadsheetAttachments: ClawdSpreadsheetAttachment[] = ( spreadsheetMarkdownResult?.files || [] ).map((summary, index) => { const sourceFile = parsed.tableFiles[index]; return { name: summary.fileName, size: sourceFile?.size || 0, contentType: sourceFile?.type || (summary.fileName.toLowerCase().endsWith(".xlsx") ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv"), sheetCount: summary.sheetCount, rowCount: summary.rowCount, truncated: summary.truncated, }; }); 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", Accept: "text/event-stream", }, 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, attachments: parsed.attachments.length > 0 ? parsed.attachments : undefined, 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, media_attachments: parsed.attachments.length > 0 ? parsed.attachments : undefined, spreadsheet_files: spreadsheetMarkdownResult?.files || undefined, spreadsheet_markdown_truncated: spreadsheetMarkdownResult?.truncated || undefined, ...parsed.metadata, }, stream_progress: true, }), signal: controller.signal, }).finally(() => { clearTimeout(timeout); request.signal.removeEventListener("abort", handleRequestAbort); unregisterActiveClawdRequest(cancelKey); }); upstreamStatus = response.status; const upstreamContentType = response.headers.get("content-type") || ""; if ( response.ok && upstreamContentType.includes("text/event-stream") && response.body ) { const encoder = new TextEncoder(); const decoder = new TextDecoder(); const stream = new ReadableStream({ async start(streamController) { const reader = response.body!.getReader(); let buffer = ""; let finalReceived = false; try { while (true) { const { done, value } = await reader.read(); buffer += decoder.decode(value || new Uint8Array(), { stream: !done, }); const blocks = buffer.split(/\r?\n\r?\n/); buffer = blocks.pop() || ""; for (const block of blocks) { const parsedEvent = parseSseBlock(block); if (!parsedEvent) continue; if (parsedEvent.event === "progress") { streamController.enqueue( encoder.encode(encodeSseEvent("progress", parsedEvent.data)) ); continue; } if (parsedEvent.event !== "final") continue; const finalPayload = parsedEvent.data && typeof parsedEvent.data === "object" && !Array.isArray(parsedEvent.data) ? (parsedEvent.data as Record) : {}; const persisted = await persistClawdResponse({ assistantMessageId, attachments: parsed.attachments, chatId: parsed.id, conversationId, displayMessage, skillTagId, skillTagName, spreadsheetAttachments, upstreamPayload: finalPayload, userMessageId, }); clearClawdRequestCancellation(cancelKey); streamController.enqueue( encoder.encode(encodeSseEvent("final", persisted)) ); finalReceived = true; } if (done) break; } if (!finalReceived) { streamController.enqueue( encoder.encode( encodeSseEvent("error", { error: CLAW_UPGRADE_RETRY_MESSAGE, }) ) ); } } catch (error) { console.error("[ClawdChat API] OpenClaw stream failed:", error); try { streamController.enqueue( encoder.encode( encodeSseEvent("error", { error: CLAW_UPGRADE_RETRY_MESSAGE, }) ) ); } catch { // The browser may have closed while the upstream task keeps running. } } finally { try { streamController.close(); } catch { // The stream may already be canceled by the browser. } } }, }); return new Response(stream, { headers: { "Cache-Control": "no-cache, no-store", "Content-Type": "text/event-stream; charset=utf-8", "X-Accel-Buffering": "no", }, }); } 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 } ); } const backgroundTask = parseOpenClawBackgroundTask(upstreamPayload); if ( backgroundTask?.state === "processing" && backgroundTask.autoNotify && backgroundTask.pollEndpoint ) { return streamOpenClawBackgroundTask({ request, cancelKey, config, initialPayload: upstreamPayload as Record, initialTask: backgroundTask, assistantMessageId, attachments: parsed.attachments, chatId: parsed.id, conversationId, displayMessage, skillTagId, skillTagName, spreadsheetAttachments, userId: session.user.id, userMessageId, }); } } 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 persistedResponse = await persistClawdResponse({ assistantMessageId, attachments: parsed.attachments, chatId: parsed.id, conversationId, displayMessage, skillTagId, skillTagName, spreadsheetAttachments, upstreamPayload: upstreamPayload as Record, userMessageId, }); clearClawdRequestCancellation(cancelKey); return NextResponse.json(persistedResponse); }