diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx
index cc450b929..313310ae1 100644
--- a/components/canvas/CanvasChatPanel.tsx
+++ b/components/canvas/CanvasChatPanel.tsx
@@ -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, FileSpreadsheet, Paperclip, Copy } 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, Paperclip, Copy, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@@ -58,7 +58,10 @@ import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
-import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
+import {
+ fetchUploadWithRetry,
+ type UploadProgress,
+} from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
type CanvasImageDownloadProgress,
@@ -458,6 +461,16 @@ type ClawdComposerSubmissionSnapshot = {
submittedTexts: string[];
};
+type PendingClawdRequestRestoreSnapshot = {
+ chatId: string;
+ userMessageId: string;
+ textContent: string;
+ tableFiles: File[];
+ attachments: PickedObjectAttachment[];
+ skillTag: CanvasSkillTag | null;
+ shouldRestoreSkillTag: boolean;
+};
+
function isClawdSubmittedComposerText(
snapshot: ClawdComposerSubmissionSnapshot,
value: string
@@ -717,6 +730,14 @@ const CANVAS_COMPOSER_PREVIEW_TEXTS_DEFAULT = [
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
];
+const CANVAS_IMAGE_DIMENSION_TIMEOUT_MS = 3500;
+
+const DEFAULT_CANVAS_IMAGE_DIMENSIONS = {
+ width: 300,
+ height: 300,
+ originalWidth: 0,
+ originalHeight: 0,
+};
function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
const trimmed = text.trim();
@@ -747,6 +768,24 @@ function getImageDimensions(url: string): Promise<{
}> {
return new Promise((resolve) => {
const img = new Image();
+ let settled = false;
+ const resolveOnce = (dimensions: {
+ width: number;
+ height: number;
+ originalWidth: number;
+ originalHeight: number;
+ }) => {
+ if (settled) return;
+ settled = true;
+ window.clearTimeout(timeout);
+ img.onload = null;
+ img.onerror = null;
+ resolve(dimensions);
+ };
+ const timeout = window.setTimeout(() => {
+ resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
+ }, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS);
+
img.onload = () => {
// 限制最大尺寸,避免节点过大
const maxSize = 500;
@@ -770,11 +809,11 @@ function getImageDimensions(url: string): Promise<{
height = Math.round(height * scale);
}
- resolve({ width, height, originalWidth, originalHeight });
+ resolveOnce({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
// 加载失败时使用默认尺寸
- resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
+ resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
};
img.src = url;
});
@@ -791,6 +830,8 @@ function buildPreviewImageUrl(url: string, width: number): string {
const CANVAS_IMAGE_ANALYSIS_TITLE = "图片分析";
const CANVAS_IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。";
const CANVAS_AGENT_SECTION_TITLES = new Set([
+ "意图分析",
+ "任务规划",
"需求理解",
CANVAS_IMAGE_ANALYSIS_TITLE,
"执行计划",
@@ -1184,8 +1225,78 @@ type CanvasComposerUploadProgress = {
current: number;
total: number;
fileName?: string;
+ filePercent?: number;
+ percent?: number;
+ phase?: UploadProgress["phase"];
+ target?: "composer-reference" | "canvas" | "slot";
};
+type CanvasUploadProgressOptions = {
+ title: string;
+ fileName?: string;
+ fileIndex?: number;
+ fileTotal?: number;
+ target?: CanvasComposerUploadProgress["target"];
+};
+
+function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) {
+ switch (phase) {
+ case "preparing":
+ return "正在准备上传";
+ case "direct":
+ return "正在上传到云储存";
+ case "multipart":
+ return "正在分片上传到云储存";
+ case "server":
+ return "正在通过服务器上传";
+ case "finalizing":
+ return "正在确认图片地址";
+ case "complete":
+ return "上传完成";
+ default:
+ return "正在上传图片";
+ }
+}
+
+function buildCanvasComposerUploadProgress(
+ options: CanvasUploadProgressOptions,
+ progress?: UploadProgress
+): CanvasComposerUploadProgress {
+ const total = Math.max(1, Math.round(options.fileTotal || 1));
+ const fileIndex = Math.min(
+ total - 1,
+ Math.max(0, Math.round(options.fileIndex || 0))
+ );
+ const current = fileIndex + 1;
+ const filePercent = Math.max(
+ 1,
+ Math.min(100, Math.round(progress?.percent || 1))
+ );
+ const percent =
+ total <= 1
+ ? filePercent
+ : Math.max(
+ 1,
+ Math.min(100, Math.round(((fileIndex + filePercent / 100) / total) * 100))
+ );
+ const phaseLabel = getCanvasUploadProgressPhaseLabel(progress?.phase);
+
+ return {
+ title: options.title,
+ message:
+ total > 1
+ ? `${phaseLabel},第 ${current}/${total} 张,当前 ${filePercent}%`
+ : `${phaseLabel},当前 ${filePercent}%`,
+ current,
+ total,
+ fileName: options.fileName,
+ filePercent,
+ percent,
+ phase: progress?.phase,
+ target: options.target,
+ };
+}
+
const OPENCLAW_ACTIONS_CODE_BLOCK_REGEX =
/```openclaw-actions\s*([\s\S]*?)```/gi;
@@ -1598,9 +1709,13 @@ function OpenClawQuickActions({
function CanvasAssistantTypingPlaceholder({
hint,
label = "分析中",
+ onCancel,
+ cancelLabel = "停止",
}: {
hint?: string | null;
label?: string;
+ onCancel?: () => void;
+ cancelLabel?: string;
}) {
return (
@@ -1616,6 +1731,19 @@ function CanvasAssistantTypingPlaceholder({
>
{label}
+ {onCancel ? (
+
+ ) : null}
@@ -1630,16 +1758,20 @@ function CanvasComposerUploadProgressDialog({
if (!progress) return null;
const percent =
- progress.total > 1
+ typeof progress.percent === "number"
+ ? Math.max(1, Math.min(100, Math.round(progress.percent)))
+ : progress.total > 1
? Math.max(
8,
Math.min(100, Math.round((progress.current / progress.total) * 100))
)
: 100;
- const isSingleFile = progress.total <= 1;
+ const isIndeterminate =
+ typeof progress.percent !== "number" && progress.total <= 1;
+ const phaseLabel = getCanvasUploadProgressPhaseLabel(progress.phase);
return createPortal(
-