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 { generateUUID } from "@/lib/utils"; const requestSchema = z.object({ id: z.string().uuid(), message: z.string().trim().min(1).max(8000), userMessageId: z.string().uuid().optional(), metadata: z.record(z.unknown()).optional(), }); 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}`; } 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 = requestSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { 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.data.id }).catch(() => null); if (existingChat && existingChat.userId !== session.user.id) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } if (!existingChat) { await saveChat({ id: parsed.data.id, userId: session.user.id, title: buildChatTitle(parsed.data.message), visibility: "private", }); } const userMessageId = parsed.data.userMessageId || generateUUID(); const assistantMessageId = generateUUID(); const conversationId = createConversationId(session.user.id, parsed.data.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: parsed.data.message, metadata: { source: "ai_chatbot_canvas", page: "/canvas", tenant: "default", chat_id: parsed.data.id, ...parsed.data.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.data.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.data.id, role: "user", parts: [{ type: "text", text: parsed.data.message }], attachments: [], createdAt: userCreatedAt, videoStorageStatus: null, temporaryVideoUrl: null, permanentVideoUrl: null, archivedAt: null, videoMetadata: null, }; const assistantMessage = { id: assistantMessageId, chatId: parsed.data.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() }, }, ], }); }