1041 lines
32 KiB
TypeScript
1041 lines
32 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;
|
|
}
|
|
}
|
|
|
|
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<string, unknown>;
|
|
const rawTask =
|
|
rawPayload.background_task &&
|
|
typeof rawPayload.background_task === "object" &&
|
|
!Array.isArray(rawPayload.background_task)
|
|
? (rawPayload.background_task as Record<string, unknown>)
|
|
: 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<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,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function streamOpenClawBackgroundTask(params: {
|
|
request: Request;
|
|
cancelKey: string;
|
|
config: { baseUrl: string; token: string; timeoutMs: number };
|
|
initialPayload: Record<string, unknown>;
|
|
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<Uint8Array>({
|
|
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<string, unknown>
|
|
| 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<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",
|
|
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<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 }
|
|
);
|
|
}
|
|
|
|
const backgroundTask = parseOpenClawBackgroundTask(upstreamPayload);
|
|
if (
|
|
backgroundTask?.state === "processing" &&
|
|
backgroundTask.autoNotify &&
|
|
backgroundTask.pollEndpoint
|
|
) {
|
|
return streamOpenClawBackgroundTask({
|
|
request,
|
|
cancelKey,
|
|
config,
|
|
initialPayload: upstreamPayload as Record<string, unknown>,
|
|
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<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);
|
|
}
|