feat: support Clawd spreadsheet uploads
This commit is contained in:
parent
deacac6e8f
commit
16f734e9c5
@ -12,15 +12,24 @@ import {
|
||||
normalizeOpenClawChatConfig,
|
||||
OPENCLAW_CHAT_CONFIG_KEY,
|
||||
} from "@/lib/openclaw-chat/config";
|
||||
import {
|
||||
buildOpenClawDisplayMessage,
|
||||
buildOpenClawMessageWithSpreadsheetMarkdown,
|
||||
buildOpenClawSpreadsheetMarkdown,
|
||||
} from "@/lib/openclaw-chat/spreadsheet-markdown";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
const requestSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
message: z.string().trim().min(1).max(8000),
|
||||
message: z.string().trim().max(8000).optional().default(""),
|
||||
userMessageId: z.string().uuid().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
type ParsedClawdChatRequest = z.infer<typeof requestSchema> & {
|
||||
tableFiles: File[];
|
||||
};
|
||||
|
||||
function buildChatTitle(message: string) {
|
||||
const normalized = message.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return "Clawd 对话";
|
||||
@ -31,6 +40,58 @@ function createConversationId(userId: string, chatId: string) {
|
||||
return `site-user-${userId}-canvas-${chatId}`;
|
||||
}
|
||||
|
||||
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));
|
||||
return parsed.success ? { ...parsed.data, tableFiles: [] } : null;
|
||||
}
|
||||
|
||||
const formData = await request.formData().catch(() => null);
|
||||
if (!formData) return null;
|
||||
|
||||
const metadataValue = formData.get("metadata");
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse({
|
||||
id: formData.get("id"),
|
||||
message: formData.get("message") || "",
|
||||
userMessageId: formData.get("userMessageId") || undefined,
|
||||
metadata,
|
||||
});
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const tableFiles = formData
|
||||
.getAll("tableFiles")
|
||||
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0);
|
||||
|
||||
return { ...parsed.data, tableFiles };
|
||||
}
|
||||
|
||||
async function readOpenClawConfig() {
|
||||
const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
|
||||
() => null
|
||||
@ -71,10 +132,13 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
const parsed = await parseClawdChatRequest(request);
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
||||
}
|
||||
if (!parsed.message && parsed.tableFiles.length === 0) {
|
||||
return NextResponse.json({ error: "请输入问题或上传表格" }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await readOpenClawConfig();
|
||||
if (!config.enabled) {
|
||||
@ -87,23 +151,51 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const existingChat = await getChatById({ id: parsed.data.id }).catch(() => null);
|
||||
const existingChat = await getChatById({ id: parsed.id }).catch(() => null);
|
||||
if (existingChat && existingChat.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
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.message,
|
||||
files: spreadsheetMarkdownResult?.files || [],
|
||||
});
|
||||
const upstreamMessage = buildOpenClawMessageWithSpreadsheetMarkdown({
|
||||
message: parsed.message,
|
||||
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
||||
});
|
||||
|
||||
if (!existingChat) {
|
||||
await saveChat({
|
||||
id: parsed.data.id,
|
||||
id: parsed.id,
|
||||
userId: session.user.id,
|
||||
title: buildChatTitle(parsed.data.message),
|
||||
title: buildChatTitle(displayMessage),
|
||||
visibility: "private",
|
||||
});
|
||||
}
|
||||
|
||||
const userMessageId = parsed.data.userMessageId || generateUUID();
|
||||
const userMessageId = parsed.userMessageId || generateUUID();
|
||||
const assistantMessageId = generateUUID();
|
||||
const conversationId = createConversationId(session.user.id, parsed.data.id);
|
||||
const conversationId = createConversationId(session.user.id, parsed.id);
|
||||
const endpoint = `${config.baseUrl}/api/chat`;
|
||||
|
||||
let upstreamPayload: unknown = null;
|
||||
@ -122,13 +214,16 @@ export async function POST(request: Request) {
|
||||
conversation_id: conversationId,
|
||||
user_id: session.user.id,
|
||||
user_name: session.user.name || session.user.email || "",
|
||||
message: parsed.data.message,
|
||||
message: upstreamMessage,
|
||||
metadata: {
|
||||
source: "ai_chatbot_canvas",
|
||||
page: "/canvas",
|
||||
tenant: "default",
|
||||
chat_id: parsed.data.id,
|
||||
...parsed.data.metadata,
|
||||
chat_id: parsed.id,
|
||||
spreadsheet_files: spreadsheetMarkdownResult?.files || undefined,
|
||||
spreadsheet_markdown_truncated:
|
||||
spreadsheetMarkdownResult?.truncated || undefined,
|
||||
...parsed.metadata,
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
@ -175,7 +270,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const existingMessages = await getMessagesByChatId({ id: parsed.data.id }).catch(
|
||||
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
|
||||
() => []
|
||||
);
|
||||
const latestMessageTimestamp = existingMessages.reduce((latest, item) => {
|
||||
@ -192,9 +287,9 @@ export async function POST(request: Request) {
|
||||
|
||||
const userMessage = {
|
||||
id: userMessageId,
|
||||
chatId: parsed.data.id,
|
||||
chatId: parsed.id,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: parsed.data.message }],
|
||||
parts: [{ type: "text", text: displayMessage }],
|
||||
attachments: [],
|
||||
createdAt: userCreatedAt,
|
||||
videoStorageStatus: null,
|
||||
@ -205,7 +300,7 @@ export async function POST(request: Request) {
|
||||
};
|
||||
const assistantMessage = {
|
||||
id: assistantMessageId,
|
||||
chatId: parsed.data.id,
|
||||
chatId: parsed.id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: answer }],
|
||||
attachments: [],
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
type UiSurfaceSnapshot,
|
||||
} from "@/lib/ui-surface-protocol";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot } from "lucide-react";
|
||||
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
@ -319,6 +319,48 @@ function getCanvasComposerModeSessionLabel(mode: CanvasComposerMode) {
|
||||
return "Agent";
|
||||
}
|
||||
|
||||
const CLAWD_TABLE_ACCEPT =
|
||||
".xlsx,.csv,.tsv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,text/tab-separated-values";
|
||||
const CLAWD_TABLE_ALLOWED_EXTENSIONS = new Set(["xlsx", "csv", "tsv"]);
|
||||
const CLAWD_TABLE_MAX_FILES = 3;
|
||||
const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function getClawdTableFileExtension(fileName: string) {
|
||||
return String(fileName || "")
|
||||
.split(".")
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
.trim() || "";
|
||||
}
|
||||
|
||||
function isClawdTableFile(file: File) {
|
||||
return CLAWD_TABLE_ALLOWED_EXTENSIONS.has(
|
||||
getClawdTableFileExtension(file.name)
|
||||
);
|
||||
}
|
||||
|
||||
function formatClawdTableFileSize(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return "0KB";
|
||||
}
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)}MB`;
|
||||
}
|
||||
return `${Math.max(1, Math.round(bytes / 1024))}KB`;
|
||||
}
|
||||
|
||||
function buildClawdTableDisplayText(message: string, files: File[]) {
|
||||
const prompt = message.trim() || "请分析上传的表格内容";
|
||||
if (files.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
const lines = files.map(
|
||||
(file) => `- ${file.name}(${formatClawdTableFileSize(file.size)})`
|
||||
);
|
||||
return `${prompt}\n\n已上传表格:\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
type VideoStoryboardStreamData =
|
||||
| {
|
||||
stage: "detecting";
|
||||
@ -912,13 +954,16 @@ function CanvasAssistantTypingPlaceholder() {
|
||||
<div className="flex w-full justify-start">
|
||||
<div
|
||||
aria-label="Clawd 正在处理"
|
||||
className="inline-flex h-10 items-center gap-1.5 rounded-2xl bg-[#f4f5f7] px-4 text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400"
|
||||
className="inline-flex h-7 items-center gap-1 rounded-[14px] bg-[#f4f5f7] px-3 text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400"
|
||||
>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="h-2 w-2 rounded-full bg-current opacity-70 animate-pulse"
|
||||
style={{ animationDelay: `${index * 140}ms` }}
|
||||
className="h-1.5 w-1.5 animate-bounce rounded-full bg-current opacity-75"
|
||||
style={{
|
||||
animationDelay: `${index * 120}ms`,
|
||||
animationDuration: "0.9s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -1339,9 +1384,11 @@ export function CanvasChatPanel({
|
||||
const [messageViewportKey, setMessageViewportKey] = useState(0);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const clawdTableInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const detailModeSwitchRef = useRef(false);
|
||||
const hiddenPromptRef = useRef<string | null>(null);
|
||||
@ -3522,10 +3569,11 @@ export function CanvasChatPanel({
|
||||
Boolean(input.trim()) ||
|
||||
Boolean(editorText) ||
|
||||
attachments.length > 0 ||
|
||||
clawdTableFiles.length > 0 ||
|
||||
Boolean(selectedSkillTagId) ||
|
||||
hasDetailSlotContent
|
||||
);
|
||||
}, [attachments.length, detailSlots, input, selectedSkillTagId]);
|
||||
}, [attachments.length, clawdTableFiles.length, detailSlots, input, selectedSkillTagId]);
|
||||
const getComposerModeMenuItemClass = useCallback(
|
||||
(mode: CanvasComposerMode) =>
|
||||
cn(
|
||||
@ -5399,6 +5447,7 @@ export function CanvasChatPanel({
|
||||
pendingLaunchRef.current = null;
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
setClawdTableFiles([]);
|
||||
clearSelection();
|
||||
|
||||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||||
@ -5536,6 +5585,7 @@ export function CanvasChatPanel({
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
setClawdTableFiles([]);
|
||||
setIsSessionMenuOpen(false);
|
||||
setDetailSlotPickerIndex(null);
|
||||
|
||||
@ -7214,6 +7264,7 @@ export function CanvasChatPanel({
|
||||
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
setClawdTableFiles([]);
|
||||
clearSelection();
|
||||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||||
if (editableDiv) {
|
||||
@ -8741,19 +8792,29 @@ export function CanvasChatPanel({
|
||||
.replace(/\u200b/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const tableFilesSnapshot = [...clawdTableFiles];
|
||||
const hasTableFiles = tableFilesSnapshot.length > 0;
|
||||
|
||||
if (!textContent) {
|
||||
if (!textContent && !hasTableFiles) {
|
||||
return;
|
||||
}
|
||||
if (attachments.length > 0) {
|
||||
toast.error("Clawd 当前仅支持文本对话");
|
||||
toast.error("Clawd 当前仅支持文本和表格文件");
|
||||
return;
|
||||
}
|
||||
|
||||
const requestMessage = textContent || "请分析上传的表格内容";
|
||||
const optimisticMessageText = buildClawdTableDisplayText(
|
||||
requestMessage,
|
||||
tableFilesSnapshot
|
||||
);
|
||||
const sendSignature = JSON.stringify({
|
||||
chatId: activeChatId,
|
||||
mode: "clawd",
|
||||
text: textContent,
|
||||
text: requestMessage,
|
||||
tableFiles: tableFilesSnapshot
|
||||
.map((file) => `${file.name}:${file.size}:${file.lastModified}`)
|
||||
.sort(),
|
||||
});
|
||||
|
||||
if (shouldBlockDuplicateSend(activeChatId, sendSignature)) {
|
||||
@ -8772,7 +8833,7 @@ export function CanvasChatPanel({
|
||||
const optimisticUserMessage: ChatMessage = {
|
||||
id: userMessageId,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: textContent }],
|
||||
parts: [{ type: "text", text: optimisticMessageText }],
|
||||
createdAt: optimisticCreatedAt,
|
||||
metadata: { createdAt: optimisticCreatedAt.toISOString() },
|
||||
} as ChatMessage;
|
||||
@ -8791,6 +8852,7 @@ export function CanvasChatPanel({
|
||||
);
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
setClawdTableFiles([]);
|
||||
clearSelection();
|
||||
|
||||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||||
@ -8799,18 +8861,24 @@ export function CanvasChatPanel({
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("id", rawChatId);
|
||||
formData.append("userMessageId", userMessageId);
|
||||
formData.append("message", requestMessage);
|
||||
formData.append(
|
||||
"metadata",
|
||||
JSON.stringify({
|
||||
composerMode: "clawd",
|
||||
activeChatId,
|
||||
})
|
||||
);
|
||||
tableFilesSnapshot.forEach((file) => {
|
||||
formData.append("tableFiles", file, file.name);
|
||||
});
|
||||
|
||||
const response = await fetch("/api/canvas/clawd-chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: rawChatId,
|
||||
userMessageId,
|
||||
message: textContent,
|
||||
metadata: {
|
||||
composerMode: "clawd",
|
||||
activeChatId,
|
||||
},
|
||||
}),
|
||||
body: formData,
|
||||
});
|
||||
const responseData = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
@ -8842,6 +8910,7 @@ export function CanvasChatPanel({
|
||||
previousMessages.filter((message) => message.id !== userMessageId)
|
||||
);
|
||||
toast.error(error instanceof Error ? error.message : "Clawd 请求失败");
|
||||
setClawdTableFiles(tableFilesSnapshot);
|
||||
const currentEditorText = (editableDiv?.textContent || "")
|
||||
.replace(/\u200b/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
@ -9151,6 +9220,7 @@ export function CanvasChatPanel({
|
||||
rawChatId,
|
||||
input,
|
||||
attachments,
|
||||
clawdTableFiles,
|
||||
hasAgentUploadSlots,
|
||||
hasWorkflowContext,
|
||||
selectedSkillTag,
|
||||
@ -9608,6 +9678,69 @@ export function CanvasChatPanel({
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleClawdTableFileSelect = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(event.currentTarget.files || []);
|
||||
event.currentTarget.value = "";
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const remainingSlots = CLAWD_TABLE_MAX_FILES - clawdTableFiles.length;
|
||||
if (remainingSlots <= 0) {
|
||||
toast.error(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格`);
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptedFiles: File[] = [];
|
||||
for (const file of selectedFiles) {
|
||||
if (!isClawdTableFile(file)) {
|
||||
toast.error(`${file.name} 格式不支持,请上传 .xlsx、.csv 或 .tsv`);
|
||||
continue;
|
||||
}
|
||||
if (file.size <= 0) {
|
||||
toast.error(`${file.name} 是空文件`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > CLAWD_TABLE_MAX_BYTES) {
|
||||
toast.error(`${file.name} 超过 10MB,请先拆分或压缩`);
|
||||
continue;
|
||||
}
|
||||
acceptedFiles.push(file);
|
||||
}
|
||||
|
||||
if (acceptedFiles.length === 0) return;
|
||||
|
||||
setClawdTableFiles((currentFiles) => {
|
||||
const existingSignatures = new Set(
|
||||
currentFiles.map(
|
||||
(file) => `${file.name}:${file.size}:${file.lastModified}`
|
||||
)
|
||||
);
|
||||
const nextFiles = [...currentFiles];
|
||||
for (const file of acceptedFiles) {
|
||||
if (nextFiles.length >= CLAWD_TABLE_MAX_FILES) break;
|
||||
const signature = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (existingSignatures.has(signature)) {
|
||||
continue;
|
||||
}
|
||||
existingSignatures.add(signature);
|
||||
nextFiles.push(file);
|
||||
}
|
||||
return nextFiles;
|
||||
});
|
||||
|
||||
if (acceptedFiles.length > remainingSlots) {
|
||||
toast.info(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格,已自动保留前 ${remainingSlots} 个`);
|
||||
}
|
||||
},
|
||||
[clawdTableFiles.length]
|
||||
);
|
||||
|
||||
const removeClawdTableFile = useCallback((index: number) => {
|
||||
setClawdTableFiles((currentFiles) =>
|
||||
currentFiles.filter((_, fileIndex) => fileIndex !== index)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 移除附件(通过标签元素删除,支持同图多物品)
|
||||
const removeAttachmentByTagElement = (tagEl: HTMLElement) => {
|
||||
const url = tagEl.getAttribute('data-attachment-url') || '';
|
||||
@ -9682,7 +9815,7 @@ export function CanvasChatPanel({
|
||||
const hasEnoughAgentUploads = !hasAgentUploadSlots || attachments.length >= agentRequiredUploadCount;
|
||||
const composerPlaceholder = isWorkspacePresentation
|
||||
? isClawdMode
|
||||
? "输入要交给 Clawd 的问题"
|
||||
? "输入要交给 Clawd 的问题,可上传表格"
|
||||
: isDetailGenerationMode
|
||||
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
|
||||
: isVideoWorkspace
|
||||
@ -9693,7 +9826,7 @@ export function CanvasChatPanel({
|
||||
? "可选补充本次生图要求,例如:高级感、暖灰色、近景特写、生成3张。"
|
||||
: "可选补充本次生成要求,例如:更高级、更生活化、暖调灯光。"
|
||||
: isClawdMode
|
||||
? "输入要交给 Clawd 的问题"
|
||||
? "输入要交给 Clawd 的问题,可上传表格"
|
||||
: isDetailGenerationMode
|
||||
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
|
||||
: isVideoWorkspace
|
||||
@ -9718,7 +9851,7 @@ export function CanvasChatPanel({
|
||||
? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100))
|
||||
: 0;
|
||||
const canSend = isClawdMode
|
||||
? Boolean(input.trim()) && !isClawdSubmitting
|
||||
? Boolean(input.trim() || clawdTableFiles.length > 0) && !isClawdSubmitting
|
||||
: isDetailGenerationMode
|
||||
? detailValidationIssues.length === 0 && !isDetailGenerating
|
||||
: hasInputOrAttachments &&
|
||||
@ -12343,6 +12476,34 @@ export function CanvasChatPanel({
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{isClawdMode && clawdTableFiles.length > 0 ? (
|
||||
<div className="mx-4 mt-3 flex flex-wrap gap-2">
|
||||
{clawdTableFiles.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
|
||||
className="flex max-w-full items-center gap-2 rounded-[12px] border border-[#d8dce3] bg-[#f4f5f7] px-2.5 py-1.5 text-[#20242a] dark:border-white/10 dark:bg-white/8 dark:text-zinc-100"
|
||||
title={file.name}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-300" />
|
||||
<span className="max-w-[180px] truncate text-[13px] font-medium">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-[#7b8391] dark:text-zinc-400">
|
||||
{formatClawdTableFileSize(file.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 text-[#8b93a1] transition-colors hover:bg-black/5 hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
onClick={() => removeClawdTableFile(index)}
|
||||
disabled={isSubmitting}
|
||||
aria-label={`移除 ${file.name}`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
|
||||
@ -12580,6 +12741,14 @@ export function CanvasChatPanel({
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<input
|
||||
ref={clawdTableInputRef}
|
||||
type="file"
|
||||
accept={CLAWD_TABLE_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleClawdTableFileSelect}
|
||||
/>
|
||||
{videoFrameSlots.map((_, index) => (
|
||||
<input
|
||||
key={`canvas-video-slot-input-${index}`}
|
||||
@ -12629,6 +12798,21 @@ export function CanvasChatPanel({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{!isWorkspacePresentation && !isDetailGenerationMode && isClawdMode ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 rounded-full border border-transparent bg-[#eeeeef] text-[#20242a] transition-colors hover:bg-[#e7e7e8] hover:text-[#111418] disabled:opacity-50 dark:bg-white/10 dark:text-zinc-100 dark:hover:bg-white/14 dark:hover:text-white"
|
||||
onClick={() => clawdTableInputRef.current?.click()}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
clawdTableFiles.length >= CLAWD_TABLE_MAX_FILES
|
||||
}
|
||||
title="上传表格"
|
||||
>
|
||||
<FileSpreadsheet className="h-4.5 w-4.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{!isWorkspacePresentation ? (
|
||||
<DropdownMenu
|
||||
open={isComposerModeMenuOpen}
|
||||
|
||||
415
lib/openclaw-chat/spreadsheet-markdown.ts
Normal file
415
lib/openclaw-chat/spreadsheet-markdown.ts
Normal file
@ -0,0 +1,415 @@
|
||||
import Papa from "papaparse";
|
||||
|
||||
export const OPENCLAW_SPREADSHEET_MAX_FILES = 3;
|
||||
export const OPENCLAW_SPREADSHEET_MAX_BYTES = 10 * 1024 * 1024;
|
||||
export const OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET = 200;
|
||||
export const OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET = 30;
|
||||
export const OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE = 5;
|
||||
export const OPENCLAW_SPREADSHEET_MAX_MARKDOWN_CHARS = 60_000;
|
||||
|
||||
const SUPPORTED_EXTENSIONS = new Set(["csv", "tsv", "xlsx"]);
|
||||
|
||||
export type OpenClawSpreadsheetFileSummary = {
|
||||
fileName: string;
|
||||
sheetCount: number;
|
||||
rowCount: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type OpenClawSpreadsheetMarkdownResult = {
|
||||
markdown: string;
|
||||
files: OpenClawSpreadsheetFileSummary[];
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
type WorksheetMarkdown = {
|
||||
markdown: string;
|
||||
rowCount: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
async function createExcelWorkbook() {
|
||||
const exceljs = await import("exceljs");
|
||||
const WorkbookConstructor =
|
||||
exceljs.Workbook ||
|
||||
(exceljs as unknown as {
|
||||
default?: { Workbook?: typeof exceljs.Workbook };
|
||||
}).default?.Workbook;
|
||||
|
||||
if (!WorkbookConstructor) {
|
||||
throw new Error("Excel 解析器加载失败");
|
||||
}
|
||||
|
||||
return new WorkbookConstructor();
|
||||
}
|
||||
|
||||
function getSpreadsheetExtension(fileName: string) {
|
||||
return String(fileName || "")
|
||||
.split(".")
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
.trim() || "";
|
||||
}
|
||||
|
||||
export function isOpenClawSpreadsheetFileName(fileName: string) {
|
||||
return SUPPORTED_EXTENSIONS.has(getSpreadsheetExtension(fileName));
|
||||
}
|
||||
|
||||
function getSafeFileName(fileName: string) {
|
||||
return String(fileName || "未命名表格")
|
||||
.replace(/[\\/\u0000-\u001f]/g, "_")
|
||||
.trim()
|
||||
.slice(0, 160) || "未命名表格";
|
||||
}
|
||||
|
||||
function normalizeSpreadsheetCellValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? "" : value.toISOString();
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const cellObject = value as {
|
||||
error?: unknown;
|
||||
formula?: unknown;
|
||||
hyperlink?: unknown;
|
||||
result?: unknown;
|
||||
richText?: Array<{ text?: unknown }>;
|
||||
text?: unknown;
|
||||
};
|
||||
|
||||
if (Array.isArray(cellObject.richText)) {
|
||||
return cellObject.richText
|
||||
.map((part) => normalizeSpreadsheetCellValue(part.text))
|
||||
.join("");
|
||||
}
|
||||
if (cellObject.result != null) {
|
||||
return normalizeSpreadsheetCellValue(cellObject.result);
|
||||
}
|
||||
if (cellObject.text != null) {
|
||||
return normalizeSpreadsheetCellValue(cellObject.text);
|
||||
}
|
||||
if (cellObject.hyperlink != null) {
|
||||
return normalizeSpreadsheetCellValue(cellObject.hyperlink);
|
||||
}
|
||||
if (cellObject.error != null) {
|
||||
return normalizeSpreadsheetCellValue(cellObject.error);
|
||||
}
|
||||
if (cellObject.formula != null) {
|
||||
return `=${normalizeSpreadsheetCellValue(cellObject.formula)}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeMarkdownCell(value: unknown) {
|
||||
const normalized = normalizeSpreadsheetCellValue(value)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.replace(/\n+/g, "<br>")
|
||||
.replace(/[ \t\u00a0]+/g, " ")
|
||||
.trim();
|
||||
|
||||
return normalized.replace(/\|/g, "\\|");
|
||||
}
|
||||
|
||||
function normalizeRows(rows: unknown[][]) {
|
||||
const normalizedRows = rows
|
||||
.map((row) => row.map((cell) => normalizeMarkdownCell(cell)))
|
||||
.filter((row) => row.some((cell) => cell.length > 0));
|
||||
|
||||
let columnCount = 0;
|
||||
for (const row of normalizedRows) {
|
||||
for (let index = row.length - 1; index >= 0; index -= 1) {
|
||||
if (row[index]) {
|
||||
columnCount = Math.max(columnCount, index + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
columnCount = Math.min(columnCount, OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET);
|
||||
return normalizedRows.map((row) =>
|
||||
Array.from({ length: columnCount }, (_, index) => row[index] || "")
|
||||
);
|
||||
}
|
||||
|
||||
export function rowsToOpenClawMarkdownTable(rows: unknown[][]) {
|
||||
const normalizedRows = normalizeRows(rows);
|
||||
if (normalizedRows.length === 0 || normalizedRows[0].length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const columnCount = normalizedRows[0].length;
|
||||
const firstRow = normalizedRows[0];
|
||||
const header = firstRow.map((cell, index) => cell || `列${index + 1}`);
|
||||
const bodyRows = normalizedRows.length > 1 ? normalizedRows.slice(1) : [];
|
||||
|
||||
return [
|
||||
`| ${header.join(" | ")} |`,
|
||||
`| ${Array.from({ length: columnCount }, () => "---").join(" | ")} |`,
|
||||
...bodyRows.map((row) => `| ${row.join(" | ")} |`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildWorksheetMarkdown(params: {
|
||||
rows: unknown[][];
|
||||
sheetName: string;
|
||||
totalRows: number;
|
||||
totalCols: number;
|
||||
truncatedBySheetLimit?: boolean;
|
||||
}): WorksheetMarkdown {
|
||||
const limitedRows = params.rows.slice(0, OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET);
|
||||
const markdownTable = rowsToOpenClawMarkdownTable(limitedRows);
|
||||
if (!markdownTable) {
|
||||
return { markdown: "", rowCount: 0, truncated: false };
|
||||
}
|
||||
|
||||
const truncated =
|
||||
params.truncatedBySheetLimit === true ||
|
||||
params.totalRows > OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET ||
|
||||
params.totalCols > OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET;
|
||||
const visibleRows = Math.min(
|
||||
params.totalRows,
|
||||
OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET
|
||||
);
|
||||
const visibleCols = Math.min(
|
||||
params.totalCols,
|
||||
OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET
|
||||
);
|
||||
const suffix = truncated
|
||||
? `(展示前 ${visibleRows} 行 x ${visibleCols} 列,内容已截断)`
|
||||
: `(${params.totalRows} 行 x ${params.totalCols} 列)`;
|
||||
|
||||
return {
|
||||
markdown: `### 工作表:${params.sheetName || "Sheet1"}${suffix}\n\n${markdownTable}`,
|
||||
rowCount: params.totalRows,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
async function parseCsvOrTsvFile(file: File, delimiter: "," | "\t") {
|
||||
const text = (await file.text()).replace(/^\uFEFF/, "");
|
||||
const parsed = Papa.parse<string[]>(text, {
|
||||
delimiter,
|
||||
skipEmptyLines: "greedy",
|
||||
});
|
||||
|
||||
if (parsed.errors.length > 0) {
|
||||
throw new Error(
|
||||
`${getSafeFileName(file.name)} 解析失败:${parsed.errors[0]?.message || "未知错误"}`
|
||||
);
|
||||
}
|
||||
|
||||
const rows = parsed.data;
|
||||
const totalRows = rows.filter((row) =>
|
||||
row.some((cell) => normalizeMarkdownCell(cell).length > 0)
|
||||
).length;
|
||||
const totalCols = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
||||
const sheet = buildWorksheetMarkdown({
|
||||
rows,
|
||||
sheetName: delimiter === "\t" ? "TSV" : "CSV",
|
||||
totalRows,
|
||||
totalCols,
|
||||
});
|
||||
if (!sheet.markdown) {
|
||||
throw new Error(`${getSafeFileName(file.name)} 中没有可读取的表格内容`);
|
||||
}
|
||||
|
||||
return {
|
||||
markdown: `## 附件表格:${getSafeFileName(file.name)}\n\n${sheet.markdown}`.trim(),
|
||||
summary: {
|
||||
fileName: getSafeFileName(file.name),
|
||||
rowCount: sheet.rowCount,
|
||||
sheetCount: sheet.markdown ? 1 : 0,
|
||||
truncated: sheet.truncated,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function parseXlsxFile(file: File) {
|
||||
const workbook = await createExcelWorkbook();
|
||||
const buffer = await file.arrayBuffer();
|
||||
await workbook.xlsx.load(buffer);
|
||||
|
||||
const worksheets = workbook.worksheets.filter((worksheet) => {
|
||||
if (!worksheet) return false;
|
||||
let hasValue = false;
|
||||
worksheet.eachRow({ includeEmpty: false }, (row) => {
|
||||
row.eachCell({ includeEmpty: false }, (cell) => {
|
||||
if (normalizeMarkdownCell(cell.value).length > 0) {
|
||||
hasValue = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
return hasValue;
|
||||
});
|
||||
|
||||
if (worksheets.length === 0) {
|
||||
throw new Error(`${getSafeFileName(file.name)} 中没有可读取的表格内容`);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let rowCount = 0;
|
||||
let truncated = worksheets.length > OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE;
|
||||
const visibleWorksheets = worksheets.slice(
|
||||
0,
|
||||
OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE
|
||||
);
|
||||
|
||||
for (const worksheet of visibleWorksheets) {
|
||||
let totalRows = 0;
|
||||
let totalCols = 0;
|
||||
worksheet.eachRow({ includeEmpty: false }, (row) => {
|
||||
let rowHasValue = false;
|
||||
row.eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
||||
if (normalizeMarkdownCell(cell.value).length > 0) {
|
||||
rowHasValue = true;
|
||||
totalCols = Math.max(totalCols, colNumber);
|
||||
}
|
||||
});
|
||||
if (rowHasValue) {
|
||||
totalRows += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const rows: unknown[][] = [];
|
||||
for (
|
||||
let rowNumber = 1;
|
||||
rowNumber <= worksheet.rowCount &&
|
||||
rows.length < OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET;
|
||||
rowNumber += 1
|
||||
) {
|
||||
const row = worksheet.getRow(rowNumber);
|
||||
const values = Array.from(
|
||||
{ length: Math.min(totalCols, OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET) },
|
||||
(_, index) => row.getCell(index + 1).value
|
||||
);
|
||||
if (values.some((value) => normalizeMarkdownCell(value).length > 0)) {
|
||||
rows.push(values);
|
||||
}
|
||||
}
|
||||
|
||||
const sheet = buildWorksheetMarkdown({
|
||||
rows,
|
||||
sheetName: worksheet.name,
|
||||
totalRows,
|
||||
totalCols,
|
||||
truncatedBySheetLimit:
|
||||
worksheets.length > OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE,
|
||||
});
|
||||
if (sheet.markdown) {
|
||||
parts.push(sheet.markdown);
|
||||
rowCount += sheet.rowCount;
|
||||
truncated = truncated || sheet.truncated;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
markdown: `## 附件表格:${getSafeFileName(file.name)}\n\n${parts.join("\n\n")}`.trim(),
|
||||
summary: {
|
||||
fileName: getSafeFileName(file.name),
|
||||
rowCount,
|
||||
sheetCount: visibleWorksheets.length,
|
||||
truncated,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function spreadsheetFileToOpenClawMarkdown(file: File) {
|
||||
if (file.size <= 0) {
|
||||
throw new Error(`${getSafeFileName(file.name)} 是空文件`);
|
||||
}
|
||||
if (file.size > OPENCLAW_SPREADSHEET_MAX_BYTES) {
|
||||
throw new Error(`${getSafeFileName(file.name)} 超过 10MB,请先拆分或压缩`);
|
||||
}
|
||||
|
||||
const extension = getSpreadsheetExtension(file.name);
|
||||
if (!SUPPORTED_EXTENSIONS.has(extension)) {
|
||||
throw new Error("Clawd 目前支持 .xlsx、.csv、.tsv 表格文件");
|
||||
}
|
||||
|
||||
if (extension === "csv") {
|
||||
return parseCsvOrTsvFile(file, ",");
|
||||
}
|
||||
if (extension === "tsv") {
|
||||
return parseCsvOrTsvFile(file, "\t");
|
||||
}
|
||||
return parseXlsxFile(file);
|
||||
}
|
||||
|
||||
export async function buildOpenClawSpreadsheetMarkdown(
|
||||
files: File[]
|
||||
): Promise<OpenClawSpreadsheetMarkdownResult> {
|
||||
const usableFiles = files.filter((file) => file.size > 0);
|
||||
if (usableFiles.length === 0) {
|
||||
return { markdown: "", files: [], truncated: false };
|
||||
}
|
||||
if (usableFiles.length > OPENCLAW_SPREADSHEET_MAX_FILES) {
|
||||
throw new Error(`一次最多上传 ${OPENCLAW_SPREADSHEET_MAX_FILES} 个表格文件`);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const summaries: OpenClawSpreadsheetFileSummary[] = [];
|
||||
let truncated = false;
|
||||
|
||||
for (const file of usableFiles) {
|
||||
const parsed = await spreadsheetFileToOpenClawMarkdown(file);
|
||||
if (parsed.markdown) {
|
||||
parts.push(parsed.markdown);
|
||||
}
|
||||
summaries.push(parsed.summary);
|
||||
truncated = truncated || parsed.summary.truncated;
|
||||
}
|
||||
|
||||
let markdown = parts.join("\n\n---\n\n").trim();
|
||||
if (markdown.length > OPENCLAW_SPREADSHEET_MAX_MARKDOWN_CHARS) {
|
||||
markdown = `${markdown.slice(0, OPENCLAW_SPREADSHEET_MAX_MARKDOWN_CHARS)}\n\n> 表格 Markdown 内容过长,已在本系统中截断。`;
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
return { markdown, files: summaries, truncated };
|
||||
}
|
||||
|
||||
export function buildOpenClawMessageWithSpreadsheetMarkdown(params: {
|
||||
message: string;
|
||||
spreadsheetMarkdown: string;
|
||||
}) {
|
||||
const prompt = params.message.trim() || "请分析上传的表格内容。";
|
||||
if (!params.spreadsheetMarkdown.trim()) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
return [
|
||||
prompt,
|
||||
"以下是用户上传表格转换后的 Markdown 内容,请基于这些表格内容回答:",
|
||||
params.spreadsheetMarkdown.trim(),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
export function buildOpenClawDisplayMessage(params: {
|
||||
message: string;
|
||||
files: OpenClawSpreadsheetFileSummary[];
|
||||
}) {
|
||||
const prompt = params.message.trim() || "请分析上传的表格内容";
|
||||
if (params.files.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
const fileLines = params.files.map((file) => {
|
||||
const suffix = file.truncated ? ",已截断" : "";
|
||||
return `- ${file.fileName}(${file.sheetCount} 个工作表,${file.rowCount} 行${suffix})`;
|
||||
});
|
||||
|
||||
return `${prompt}\n\n已上传表格:\n${fileLines.join("\n")}`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user