2026-06-23 22:24:44 +08:00

246 lines
7.3 KiB
TypeScript

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<string, unknown>;
const nestedError =
raw.error && typeof raw.error === "object"
? (raw.error as Record<string, unknown>)
: 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<string, unknown>;
const nestedError =
raw.error && typeof raw.error === "object"
? (raw.error as Record<string, unknown>)
: 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<string, unknown>).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<string, unknown>).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() },
},
],
});
}