diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index 613dc2d00..9e907db6a 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -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; + 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({ + 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) + : {}; + 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).request_id || null - : null, - }, - }; - - await saveMessages({ messages: [userMessage, assistantMessage] }); - clearClawdRequestCancellation(cancelKey); - - return NextResponse.json({ conversationId, - requestId: - upstreamPayload && typeof upstreamPayload === "object" - ? (upstreamPayload as Record).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, + userMessageId, }); + clearClawdRequestCancellation(cancelKey); + return NextResponse.json(persistedResponse); } diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index b5c8cebd5..1e75817e1 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -594,6 +594,64 @@ function shouldShowClawdProcessingHint( ); } +async function readClawdResponse( + response: Response, + onProgress: (progress: Record) => 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); + } 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({
{(() => { 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( + + + + + {attachment.name} + + {details.length > 0 ? ( + + {details.join(" · ")} + {attachment.truncated ? " · 已截断" : ""} + + ) : null} + + + ); + } + ); 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( {text} @@ -14937,7 +15181,7 @@ export function CanvasChatPanel({ ) : null}