fix: persist Claw spreadsheet attachments
This commit is contained in:
parent
f13b4e8909
commit
000e5c75c9
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user