fix: persist Claw spreadsheet attachments

This commit is contained in:
134 2026-07-28 21:09:24 +08:00
parent f13b4e8909
commit 000e5c75c9
2 changed files with 535 additions and 103 deletions

View File

@ -56,6 +56,15 @@ type ParsedClawdChatRequest = Omit<
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 对话";
@ -198,6 +207,160 @@ 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);
@ -273,6 +436,23 @@ export async function POST(request: Request) {
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();
@ -354,6 +534,7 @@ export async function POST(request: Request) {
spreadsheetMarkdownResult?.truncated || undefined,
...parsed.metadata,
},
stream_progress: true,
}),
signal: controller.signal,
}).finally(() => {
@ -363,6 +544,102 @@ export async function POST(request: Request) {
});
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) {
@ -420,107 +697,18 @@ export async function POST(request: Request) {
return createCanceledResponse();
}
const existingMessages = await getMessagesByChatId({ id: parsed.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,
const persistedResponse = await persistClawdResponse({
assistantMessageId,
attachments: parsed.attachments,
chatId: parsed.id,
role: "user",
parts: [{ type: "text", text: displayMessage }],
attachments: parsed.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: parsed.attachments.filter(
(attachment) => attachment.type === "image"
).length,
videoAttachmentCount: parsed.attachments.filter(
(attachment) => attachment.type === "video"
).length,
skillTagId: skillTagId || null,
skillTagName: skillTagName || null,
},
};
const assistantMessage = {
id: assistantMessageId,
chatId: parsed.id,
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 && typeof upstreamPayload === "object"
? (upstreamPayload as Record<string, unknown>).request_id || null
: null,
},
};
await saveMessages({ messages: [userMessage, assistantMessage] });
clearClawdRequestCancellation(cancelKey);
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(),
composerMode: "clawd",
},
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,
},
],
displayMessage,
skillTagId,
skillTagName,
spreadsheetAttachments,
upstreamPayload: upstreamPayload as Record<string, unknown>,
userMessageId,
});
clearClawdRequestCancellation(cancelKey);
return NextResponse.json(persistedResponse);
}

View File

@ -594,6 +594,64 @@ function shouldShowClawdProcessingHint(
);
}
async function readClawdResponse(
response: Response,
onProgress: (progress: Record<string, unknown>) => void
) {
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("text/event-stream") || !response.body) {
return response.json().catch(() => null);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let finalPayload: unknown = null;
let streamError = "";
const consumeBlock = (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;
let data: unknown;
try {
data = JSON.parse(dataLines.join("\n"));
} catch {
return;
}
if (event === "progress" && data && typeof data === "object") {
onProgress(data as Record<string, unknown>);
} else if (event === "final") {
finalPayload = data;
} else if (event === "error") {
streamError =
data && typeof data === "object" && "error" in data
? String((data as { error?: unknown }).error || "")
: CLAW_UPGRADE_RETRY_MESSAGE;
}
};
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() || "";
blocks.forEach(consumeBlock);
if (done) break;
}
if (buffer.trim()) consumeBlock(buffer);
if (streamError) throw new Error(streamError);
if (!finalPayload) throw new Error(CLAW_UPGRADE_RETRY_MESSAGE);
return finalPayload;
}
function formatClawdTableFileSize(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0KB";
@ -616,6 +674,126 @@ function buildClawdTableDisplayText(message: string, files: File[]) {
return `${prompt}\n\n已上传表格:\n${lines.join("\n")}`;
}
type ClawdSpreadsheetAttachment = {
name: string;
size?: number;
contentType?: string;
sheetCount?: number;
rowCount?: number;
truncated?: boolean;
};
function parseClawdSpreadsheetAttachmentsFromText(
message: ChatMessage
): ClawdSpreadsheetAttachment[] {
const text = (message.parts || [])
.filter((part: any) => part?.type === "text" && part?.text)
.map((part: any) => String(part.text))
.join("\n");
const summaryMatch = text.match(
/(?:^|\n)已上传表格:\n((?:- [^\n]+(?:\n|$))+)/m
);
if (!summaryMatch?.[1]) {
return [];
}
const attachments: ClawdSpreadsheetAttachment[] = [];
for (const line of summaryMatch[1].trim().split("\n")) {
const lineMatch = line.match(/^- (.+?)(?:((.+)))?$/);
const name = String(lineMatch?.[1] || "").trim();
const details = String(lineMatch?.[2] || "");
if (!name) continue;
const sizeMatch = details.match(/([\d.]+)\s*(KB|MB)/i);
const sheetCountMatch = details.match(/(\d+)\s*个工作表/);
const rowCountMatch = details.match(/(\d+)\s*行/);
const sizeValue = Number(sizeMatch?.[1]);
const sizeUnit = String(sizeMatch?.[2] || "").toUpperCase();
attachments.push({
name,
size:
Number.isFinite(sizeValue) && sizeValue > 0
? Math.round(
sizeValue * (sizeUnit === "MB" ? 1024 * 1024 : 1024)
)
: undefined,
sheetCount: sheetCountMatch ? Number(sheetCountMatch[1]) : undefined,
rowCount: rowCountMatch ? Number(rowCountMatch[1]) : undefined,
truncated: details.includes("已截断"),
});
}
return attachments;
}
function getClawdSpreadsheetAttachments(
message: ChatMessage
): ClawdSpreadsheetAttachment[] {
const metadata =
(message as any)?.metadata &&
typeof (message as any).metadata === "object"
? (message as any).metadata
: {};
const videoMetadata =
(message as any)?.videoMetadata &&
typeof (message as any).videoMetadata === "object"
? (message as any).videoMetadata
: {};
const rawAttachments = Array.isArray(metadata.spreadsheetAttachments)
? metadata.spreadsheetAttachments
: Array.isArray(videoMetadata.spreadsheetAttachments)
? videoMetadata.spreadsheetAttachments
: [];
if (rawAttachments.length === 0) {
return parseClawdSpreadsheetAttachmentsFromText(message);
}
return rawAttachments
.map((attachment: any) => {
const name = String(
attachment?.name || attachment?.fileName || ""
).trim();
if (!name) return null;
return {
name,
size:
Number.isFinite(Number(attachment?.size)) &&
Number(attachment.size) > 0
? Number(attachment.size)
: undefined,
contentType: String(
attachment?.contentType || attachment?.content_type || ""
).trim() || undefined,
sheetCount:
Number.isFinite(Number(attachment?.sheetCount)) &&
Number(attachment.sheetCount) >= 0
? Number(attachment.sheetCount)
: undefined,
rowCount:
Number.isFinite(Number(attachment?.rowCount)) &&
Number(attachment.rowCount) >= 0
? Number(attachment.rowCount)
: undefined,
truncated: attachment?.truncated === true,
} satisfies ClawdSpreadsheetAttachment;
})
.filter(
(
attachment: ClawdSpreadsheetAttachment | null
): attachment is ClawdSpreadsheetAttachment => Boolean(attachment)
);
}
function stripClawdSpreadsheetSummary(text: string) {
return text
.replace(
/(?:^|\n{2})已上传表格:\n(?:- [^\n]*(?:\n|$))+/g,
"\n"
)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function buildClawdImageDisplayText(attachments: PickedObjectAttachment[]) {
if (attachments.length === 0) {
return "";
@ -11275,6 +11453,12 @@ export function CanvasChatPanel({
const optimisticCreatedAt = new Date(
Math.max(Date.now(), latestMessageTimestamp + 1)
);
const optimisticSpreadsheetAttachments: ClawdSpreadsheetAttachment[] =
tableFilesSnapshot.map((file) => ({
name: file.name,
size: file.size,
contentType: file.type || undefined,
}));
const optimisticUserMessage: ChatMessage = {
id: userMessageId,
role: "user",
@ -11283,6 +11467,12 @@ export function CanvasChatPanel({
metadata: {
createdAt: optimisticCreatedAt.toISOString(),
composerMode: "clawd",
spreadsheetAttachments: optimisticSpreadsheetAttachments,
},
videoMetadata: {
composerMode: "clawd",
source: "openclaw",
spreadsheetAttachments: optimisticSpreadsheetAttachments,
},
} as ChatMessage;
@ -11387,7 +11577,14 @@ export function CanvasChatPanel({
body: formData,
signal: clawdAbortController.signal,
});
const responseData = await response.json().catch(() => null);
const responseData = await readClawdResponse(response, (progress) => {
const display = String(
progress.display || progress.message || ""
).trim();
if (display) {
setClawdProcessingHint(display);
}
});
if (response.status === 499 || responseData?.canceled === true) {
canceledClawdMessageIdsRef.current.add(userMessageId);
throw new DOMException("Claw request canceled", "AbortError");
@ -14448,6 +14645,49 @@ export function CanvasChatPanel({
<div className="flex flex-wrap items-center gap-1.5">
{(() => {
const renderedUserParts: React.ReactNode[] = [];
const spreadsheetAttachments =
getClawdSpreadsheetAttachments(message);
spreadsheetAttachments.forEach(
(attachment, attachmentIndex) => {
const details = [
attachment.size
? formatClawdTableFileSize(attachment.size)
: "",
typeof attachment.sheetCount === "number"
? `${attachment.sheetCount} 个工作表`
: "",
typeof attachment.rowCount === "number"
? `${attachment.rowCount} 行`
: "",
].filter(Boolean);
renderedUserParts.push(
<span
key={`spreadsheet-${attachmentIndex}`}
className={cn(
"inline-flex max-w-full items-center gap-2 rounded-xl border px-2.5 py-1.5 text-xs",
isVideoWorkspace
? "border-border/60 bg-background"
: "border-emerald-200/80 bg-emerald-50/90 text-emerald-950 dark:border-emerald-400/20 dark:bg-emerald-400/10 dark:text-emerald-100"
)}
title={attachment.name}
>
<FileSpreadsheet className="h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-300" />
<span className="min-w-0">
<span className="block max-w-[220px] truncate font-medium text-current">
{attachment.name}
</span>
{details.length > 0 ? (
<span className="block text-[10px] text-current opacity-65">
{details.join(" · ")}
{attachment.truncated ? " · 已截断" : ""}
</span>
) : null}
</span>
</span>
);
}
);
message.parts?.forEach((part: any, idx: number) => {
const fileUrl =
@ -14497,9 +14737,13 @@ export function CanvasChatPanel({
}
if (part.type === 'text' && part.text) {
const text = String(part.text)
const normalizedText = String(part.text)
.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '')
.trim();
const text =
spreadsheetAttachments.length > 0
? stripClawdSpreadsheetSummary(normalizedText)
: normalizedText;
if (!text) return;
renderedUserParts.push(
<span key={`part-${idx}`} className="text-sm text-current">{text}</span>
@ -14937,7 +15181,7 @@ export function CanvasChatPanel({
<CanvasAssistantTypingPlaceholder
cancelLabel="停止并编辑"
hint={clawdProcessingHint}
label="分析中"
label="处理中"
onCancel={handleCancelClawdSubmission}
/>
) : null}