715 lines
22 KiB
TypeScript

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<typeof requestSchema>,
"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<ParsedClawdChatRequest | null> {
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<string, unknown> | 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<string, unknown>;
} 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<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() : "";
}
function getMetadataString(
metadata: Record<string, unknown> | 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;
}
}
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<string, unknown>;
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,
},
],
};
}
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<typeof buildOpenClawSpreadsheetMarkdown>
> | 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",
},
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<Uint8Array>({
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<string, unknown>)
: {};
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 }
);
}
} 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<string, unknown>).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<string, unknown>,
userMessageId,
});
clearClawdRequestCancellation(cancelKey);
return NextResponse.json(persistedResponse);
}