feat: add Claw skill shortcuts and isolate mode chats

This commit is contained in:
134 2026-06-26 18:18:43 +08:00
parent 80f6827375
commit d8af4bb48d
4 changed files with 626 additions and 78 deletions

View File

@ -334,7 +334,10 @@ export async function POST(request: Request) {
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
videoMetadata: {
composerMode: "clawd",
source: "openclaw",
},
};
const assistantMessage = {
id: assistantMessageId,
@ -347,7 +350,14 @@ export async function POST(request: Request) {
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: 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] });
@ -364,14 +374,22 @@ export async function POST(request: Request) {
role: "user",
parts: userMessage.parts,
createdAt: userMessage.createdAt.toISOString(),
metadata: { createdAt: userMessage.createdAt.toISOString() },
metadata: {
createdAt: userMessage.createdAt.toISOString(),
composerMode: "clawd",
},
videoMetadata: userMessage.videoMetadata,
},
{
id: assistantMessage.id,
role: "assistant",
parts: assistantMessage.parts,
createdAt: assistantMessage.createdAt.toISOString(),
metadata: { createdAt: assistantMessage.createdAt.toISOString() },
metadata: {
createdAt: assistantMessage.createdAt.toISOString(),
composerMode: "clawd",
},
videoMetadata: assistantMessage.videoMetadata,
},
],
});

View File

@ -1133,8 +1133,7 @@ function normalizeImportedCanvasState(payload: unknown): {
}
const FLOATING_PANEL_WIDTH = 400;
const FLOATING_PANEL_MAX_WIDTH = 760;
const FLOATING_PANEL_MIN_CANVAS_WIDTH = 520;
const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75;
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
const FLOATING_PANEL_INSET = 16;
const DOCKED_PANEL_INSET = 0;
@ -1142,6 +1141,7 @@ const FLOATING_UI_GAP = 16;
const FLOATING_BOTTOM_INSET = 20;
const FLOATING_TOOLBAR_ESTIMATED_WIDTH = 680;
const FLOATING_TOOLBAR_SIDE_DOCK_CLEARANCE = 48;
const FLOATING_TOOLBAR_SIDE_DOCK_VISIBLE_CANVAS_RATIO = 0.58;
const WORKFLOW_AGENT_CONTEXT_START = "【视频画布同步上下文】";
const WORKFLOW_AGENT_CONTEXT_END = "【/视频画布同步上下文】";
const AGENT_RESULT_MODE_LABELS: Record<AgentGenerationResultModeType, string> =
@ -1154,17 +1154,19 @@ const AGENT_RESULT_MODE_LABELS: Record<AgentGenerationResultModeType, string> =
function getMaxFloatingPanelWidth(viewportWidth: number) {
if (viewportWidth <= 0) {
return FLOATING_PANEL_MAX_WIDTH;
return FLOATING_PANEL_WIDTH;
}
const viewportLimitedMax = Math.max(
FLOATING_PANEL_WIDTH,
viewportWidth - FLOATING_PANEL_MIN_CANVAS_WIDTH - DOCKED_PANEL_INSET
0,
viewportWidth - DOCKED_PANEL_INSET
);
const ratioLimitedMax =
viewportLimitedMax * FLOATING_PANEL_MAX_VIEWPORT_RATIO;
return Math.max(
FLOATING_PANEL_WIDTH,
Math.min(FLOATING_PANEL_MAX_WIDTH, viewportLimitedMax)
Math.min(viewportLimitedMax, ratioLimitedMax)
);
}
@ -3012,13 +3014,20 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
viewportWidth > 0 ? Math.max(viewportWidth - panelOcclusionRight, 0) : 0;
const horizontalFloatingToolbarWidthForDocking =
horizontalFloatingToolbarWidth || FLOATING_TOOLBAR_ESTIMATED_WIDTH;
const shouldDockFloatingToolbarToSide =
!isVideoMode &&
isFloatingSidebarVisible &&
const isFloatingToolbarWidthCrowded =
visibleCanvasWidth > 0 &&
visibleCanvasWidth <
horizontalFloatingToolbarWidthForDocking +
FLOATING_TOOLBAR_SIDE_DOCK_CLEARANCE * 2;
const isFloatingToolbarCanvasCrowded =
viewportWidth > 0 &&
visibleCanvasWidth > 0 &&
visibleCanvasWidth / viewportWidth <=
FLOATING_TOOLBAR_SIDE_DOCK_VISIBLE_CANVAS_RATIO;
const shouldDockFloatingToolbarToSide =
!isVideoMode &&
isFloatingSidebarVisible &&
(isFloatingToolbarWidthCrowded || isFloatingToolbarCanvasCrowded);
const floatingToolbarTooltipSide = shouldDockFloatingToolbarToSide
? "right"
: "top";

View File

@ -326,6 +326,47 @@ function getCanvasComposerModeSessionLabel(mode: CanvasComposerMode) {
return "Agent";
}
function normalizeCanvasComposerSessionMode(
value: unknown,
fallback: CanvasComposerMode = "image"
): CanvasComposerMode {
return value === "agent" ||
value === "image" ||
value === "video" ||
value === "detail" ||
value === "clawd"
? value
: fallback;
}
function getCanvasChatSessionMode(
session: Pick<CanvasChatSession, "mode" | "clawdSkillTagId"> | null | undefined,
fallback: CanvasComposerMode = "image"
) {
if (!session) return fallback;
if (session.mode) {
return normalizeCanvasComposerSessionMode(session.mode, fallback);
}
return session.clawdSkillTagId ? "clawd" : fallback;
}
function isClawdChatMessage(message: unknown) {
if (!message || typeof message !== "object") return false;
const raw = message as Record<string, any>;
const metadata =
raw.metadata && typeof raw.metadata === "object" && !Array.isArray(raw.metadata)
? raw.metadata
: {};
const videoMetadata =
raw.videoMetadata &&
typeof raw.videoMetadata === "object" &&
!Array.isArray(raw.videoMetadata)
? raw.videoMetadata
: {};
return metadata.composerMode === "clawd" || videoMetadata.composerMode === "clawd";
}
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"]);
@ -1600,6 +1641,7 @@ type MessageVote = {
interface CanvasChatSession {
id: string;
title: string;
mode: CanvasComposerMode;
createdAt: number;
updatedAt: number;
clawdSkillTagId?: string | null;
@ -1899,6 +1941,7 @@ export function CanvasChatPanel({
const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null);
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
const downloadAbortControllerRef = useRef<AbortController | null>(null);
const fullscreenPreviewSrc = useMemo(() => {
if (!fullscreenPreview?.url) return "";
@ -2045,13 +2088,29 @@ export function CanvasChatPanel({
}, [getSendGuard]);
const startNewChatSession = useCallback(
(options?: { title?: string; toastMessage?: string }) => {
(options?: {
mode?: CanvasComposerMode;
title?: string;
toastMessage?: string;
}) => {
const now = Date.now();
const newChatSessionId = `canvas-${crypto.randomUUID()}`;
const fallbackTitle = `对话 ${chatSessions.length + 1}`;
const sessionMode = normalizeCanvasComposerSessionMode(
options?.mode,
"image"
);
const sameModeSessionCount = chatSessions.filter(
(session) => getCanvasChatSessionMode(session) === sessionMode
).length;
const modeLabel = getCanvasComposerModeSessionLabel(sessionMode);
const fallbackTitle =
sessionMode === "image"
? `对话 ${sameModeSessionCount + 1}`
: `${modeLabel} 对话 ${sameModeSessionCount + 1}`;
const newSession: CanvasChatSession = {
id: newChatSessionId,
title: options?.title || fallbackTitle,
mode: sessionMode,
createdAt: now,
updatedAt: now,
};
@ -2064,7 +2123,7 @@ export function CanvasChatPanel({
toast.success(options?.toastMessage || "已新建聊天窗口");
return newChatSessionId;
},
[activeChatId, chatSessions.length, releaseSendGuard, replaceChatSessionQuery]
[activeChatId, chatSessions, releaseSendGuard, replaceChatSessionQuery]
);
const setSessionClawdSkillTag = useCallback(
@ -2390,6 +2449,7 @@ export function CanvasChatPanel({
const fallbackSession: CanvasChatSession = {
id: rootChatId,
title: "对话 1",
mode: "image",
createdAt: now,
updatedAt: now,
};
@ -2398,6 +2458,7 @@ export function CanvasChatPanel({
? {
id: requestedChatSessionId,
title: "新对话",
mode: "image",
createdAt: now,
updatedAt: now,
}
@ -2411,18 +2472,26 @@ export function CanvasChatPanel({
const sanitized: CanvasChatSession[] = parsed
.filter((session) => session?.id)
.map((session): CanvasChatSession => ({
id: session.id,
title: (session.title || "").trim() || "新对话",
createdAt: Number(session.createdAt) || now,
updatedAt: Number(session.updatedAt) || now,
clawdSkillTagId:
.map((session): CanvasChatSession => {
const clawdSkillTagId =
typeof (session as CanvasChatSession).clawdSkillTagId === "string"
? (session as CanvasChatSession).clawdSkillTagId || null
: null,
clawdSkillTagPromptApplied:
(session as CanvasChatSession).clawdSkillTagPromptApplied === true,
}));
: null;
return {
id: session.id,
title: (session.title || "").trim() || "新对话",
mode: normalizeCanvasComposerSessionMode(
(session as CanvasChatSession).mode,
clawdSkillTagId ? "clawd" : "image"
),
createdAt: Number(session.createdAt) || now,
updatedAt: Number(session.updatedAt) || now,
clawdSkillTagId,
clawdSkillTagPromptApplied:
(session as CanvasChatSession).clawdSkillTagPromptApplied === true,
};
});
let nextSessions = sanitized;
const requiredSessions = requestedSession
@ -2712,6 +2781,83 @@ export function CanvasChatPanel({
[focusCanvasOnNode, setTransientViewport]
);
const handleAddClawdImageToCanvas = useCallback(
async (imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) {
toast.error("图片地址无效");
return;
}
const imageKey = normalizeImageIdentityUrl(normalizedUrl);
const existingNode = useCanvasStore.getState().nodes.find((node) => {
if (node.type !== "image") return false;
const src = String(((node.data as any) || {}).src || "").trim();
return imageKey && normalizeImageIdentityUrl(src) === imageKey;
});
if (existingNode) {
selectNodes([existingNode.id]);
focusCanvasOnNode(existingNode);
toast.message("这张图片已在画布中,已为你定位");
return;
}
setAddingClawdImageUrl(normalizedUrl);
try {
const dimensions = await getImageDimensions(normalizedUrl);
const currentNodes = useCanvasStore.getState().nodes;
const anchorNode = findCanvasImageAnchorNode(currentNodes, {
selectedNodes:
generationAnchorNodesRef.current.length > 0
? generationAnchorNodesRef.current
: selectedImageNodesRef.current,
});
const size = { width: dimensions.width, height: dimensions.height };
const position = getAnchoredGenerationPosition({
anchorNode,
existingNodes: currentNodes,
fallbackNodeCount: currentNodes.length,
size,
viewport: useCanvasStore.getState().viewport,
});
const nodeId = addNode({
type: "image",
position,
size,
data: {
src: normalizedUrl,
status: "completed",
title: "Claw 图片",
prompt: "Claw 对话图片",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
source: "clawd-chat",
sourceChatId: activeChatId,
sourceImageKey: imageKey || undefined,
placementMode: "manual-clawd-image",
manuallyAddedAt: Date.now(),
},
},
connections: anchorNode?.id ? [anchorNode.id] : [],
});
selectNodes([nodeId]);
focusCanvasOnNode({ position, size });
void autoNameGeneratedImageNode(nodeId, normalizedUrl);
toast.success("已添加到画布");
} catch (error) {
console.error("[CanvasChat] Failed to add Claw image to canvas:", error);
toast.error("添加到画布失败");
} finally {
setAddingClawdImageUrl("");
}
},
[activeChatId, addNode, autoNameGeneratedImageNode, focusCanvasOnNode, selectNodes]
);
const animateCanvasAgentViewport = useCallback(
(
targetViewport: { x: number; y: number; zoom: number },
@ -3901,6 +4047,17 @@ export function CanvasChatPanel({
const activeComposerMode = composerModeOverride || computedComposerMode;
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
const isClawdMode = activeComposerMode === "clawd" && isClawdModeEnabled;
const activeChatSessionMode = getCanvasChatSessionMode(
activeChatSession,
activeComposerMode
);
const currentModeChatSessions = useMemo(
() =>
chatSessions.filter(
(session) => getCanvasChatSessionMode(session) === activeComposerMode
),
[activeComposerMode, chatSessions]
);
const agentSelectableModels = useMemo(
() =>
effectiveAgentEnabled ? [SMART_AUTO_MODEL, ...conversationOnlyModels] : conversationOnlyModels,
@ -4319,22 +4476,6 @@ export function CanvasChatPanel({
: isPresetAgentSelection
? getToolbarItem("chatModePresetAgent").label
: "Agent";
const hasActiveChatContext = useCallback(() => {
const editorText = (textareaRef.current?.textContent || "")
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
const hasDetailSlotContent = detailSlots.some((slot) => Boolean(slot?.url));
return (
activeMessagesRef.current.length > 0 ||
Boolean(input.trim()) ||
Boolean(editorText) ||
attachments.length > 0 ||
clawdTableFiles.length > 0 ||
Boolean(selectedSkillTagId) ||
hasDetailSlotContent
);
}, [attachments.length, clawdTableFiles.length, detailSlots, input, selectedSkillTagId]);
const getComposerModeMenuItemClass = useCallback(
(mode: CanvasComposerMode) =>
cn(
@ -4692,7 +4833,12 @@ export function CanvasChatPanel({
return null;
}
const isSwitchingMode = mode !== activeComposerMode;
const activeSessionMode = getCanvasChatSessionMode(
chatSessions.find((session) => session.id === activeChatId) || null,
activeComposerMode
);
const isSwitchingMode =
mode !== activeComposerMode || activeSessionMode !== mode;
let targetChatSessionId = activeChatId;
if (
isSwitchingMode &&
@ -4702,12 +4848,28 @@ export function CanvasChatPanel({
return null;
}
if (isSwitchingMode && hasActiveChatContext()) {
if (isSwitchingMode) {
const modeLabel = getCanvasComposerModeSessionLabel(mode);
targetChatSessionId = startNewChatSession({
title: `${modeLabel} 对话`,
toastMessage: `已为 ${modeLabel} 新建聊天窗口`,
});
const targetSession = chatSessions
.filter((session) => getCanvasChatSessionMode(session) === mode)
.sort((a, b) => b.updatedAt - a.updatedAt)[0];
if (targetSession) {
targetChatSessionId = targetSession.id;
if (targetSession.id !== activeChatId) {
forceHistoryHydrationRef.current = toRawCanvasChatId(targetSession.id);
releaseSendGuard(activeChatId);
setActiveChatId(targetSession.id);
replaceChatSessionQuery(targetSession.id);
toast.success(`已切换到 ${modeLabel} 对话`);
}
} else {
targetChatSessionId = startNewChatSession({
mode,
title: `${modeLabel} 对话`,
toastMessage: `已为 ${modeLabel} 新建聊天窗口`,
});
}
}
activeComposerModeRef.current = mode;
@ -4764,7 +4926,6 @@ export function CanvasChatPanel({
firstImageModelId,
firstVideoModelId,
getSendGuard,
hasActiveChatContext,
isClawdSubmitting,
isDetailGenerating,
lastConversationModelId,
@ -4773,6 +4934,9 @@ export function CanvasChatPanel({
isCanvasImageModel,
isCanvasVideoModel,
lastVideoModelId,
chatSessions,
releaseSendGuard,
replaceChatSessionQuery,
startNewChatSession,
]
);
@ -6454,6 +6618,10 @@ export function CanvasChatPanel({
const created: CanvasChatSession = {
id: activeChatId,
title: derivedTitle || fallbackTitle,
mode: normalizeCanvasComposerSessionMode(
activeComposerModeRef.current,
"image"
),
createdAt: now,
updatedAt: now,
};
@ -6479,6 +6647,7 @@ export function CanvasChatPanel({
}, [activeChatId, messages]);
useEffect(() => {
if (activeChatSessionMode === "clawd") return;
if (!hasHydratedHistoryRef.current) return;
const activeStreamState = streamImageGenerationStateByChatId[activeChatId];
const shouldDeferAssistantMediaAutosync =
@ -6530,6 +6699,10 @@ export function CanvasChatPanel({
if (message.role !== "assistant" || processedMessageIds.current.has(message.id)) {
continue;
}
if (isClawdChatMessage(message)) {
processedMessageIds.current.add(message.id);
continue;
}
const nearestUserImageKeys = getNearestUserImageUrlKeys(messages, messageIndex);
const nearestUserImageUrls = getNearestUserImageUrls(messages, messageIndex);
@ -6827,6 +7000,7 @@ export function CanvasChatPanel({
};
}, [
activeChatId,
activeChatSessionMode,
messages,
addNode,
nodes,
@ -6865,11 +7039,13 @@ export function CanvasChatPanel({
// 为历史节点补全可编辑的原始提示词(旧数据里可能只有“对话生成”占位文案)
useEffect(() => {
if (activeChatSessionMode === "clawd") return;
if (!messages.length || !nodes.length) return;
const promptByImageUrl = new Map<string, string>();
messages.forEach((message, messageIndex) => {
if (message.role !== "assistant") return;
if (isClawdChatMessage(message)) return;
const content = message.parts?.map((p: any) => p.text || "").join("") || "";
const imageUrls = extractImageUrls(content);
if (!imageUrls.length) return;
@ -6905,7 +7081,7 @@ export function CanvasChatPanel({
},
});
});
}, [messages, nodes, updateNode]);
}, [activeChatSessionMode, messages, nodes, updateNode]);
// 滚动到底部
useEffect(() => {
@ -9741,9 +9917,23 @@ export function CanvasChatPanel({
role: "user",
parts: [{ type: "text", text: optimisticMessageText }],
createdAt: optimisticCreatedAt,
metadata: { createdAt: optimisticCreatedAt.toISOString() },
metadata: {
createdAt: optimisticCreatedAt.toISOString(),
composerMode: "clawd",
},
} as ChatMessage;
setChatSessions((prevSessions) =>
prevSessions.map((session) =>
session.id === activeChatId
? {
...session,
mode: "clawd",
updatedAt: Date.now(),
}
: session
)
);
setSendGuard(activeChatId, {
inFlight: true,
signature: sendSignature,
@ -10370,6 +10560,62 @@ export function CanvasChatPanel({
}
}, [addNode]);
const uploadImageFileForComposerAttachment = useCallback(async (
file: File
): Promise<{
imageUrl: string;
name: string;
contentType: string;
assetId?: string;
width?: number;
height?: number;
} | null> => {
if (!file.type.startsWith("image/")) {
toast.error("请选择图片文件");
return null;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
return null;
}
const ext = file.type.split("/")[1] || "png";
const fileName =
(file.name || "").trim() || `上传图片-${Date.now()}.${ext}`;
try {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData);
if (!response.ok) {
throw new Error("上传失败");
}
const data = await response.json();
const imageUrl = data.url as string;
const assetId =
typeof data.assetId === "string" && data.assetId.trim()
? data.assetId.trim()
: undefined;
const dimensions = await getImageDimensions(imageUrl);
return {
imageUrl,
name: fileName,
contentType: file.type || "image/jpeg",
assetId,
width: dimensions.originalWidth || dimensions.width,
height: dimensions.originalHeight || dimensions.height,
};
} catch (_error) {
toast.error("图片上传失败");
return null;
}
}, []);
const handlePastedImages = useCallback(async (files: File[]) => {
if (files.length === 0) return;
@ -10377,6 +10623,44 @@ export function CanvasChatPanel({
const baseNodeCount = nodes.length;
let uploadIndex = 0;
if (isClawdMode) {
let handledCount = 0;
for (const file of files) {
const uploaded = await uploadImageFileForComposerAttachment(file);
if (!uploaded) continue;
handledCount += 1;
setAttachments((prev) => {
const newIndex = prev.length;
setTimeout(() => {
insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [
...prev,
{
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
width: uploaded.width,
height: uploaded.height,
},
];
});
}
if (handledCount > 0) {
toast.success(
handledCount === 1
? `图片已添加为 ${CLAW_MODE_LABEL} 附件`
: `已添加 ${handledCount} 张图片为 ${CLAW_MODE_LABEL} 附件`
);
}
return;
}
if (hasAgentUploadSlots) {
const slotCount = agentUploadSlots.length;
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
@ -10500,11 +10784,13 @@ export function CanvasChatPanel({
attachments,
hasAgentUploadSlots,
insertImageTagInEditor,
isClawdMode,
isVideoModel,
maybeAutoSetCanvasTitle,
nodes.length,
updateAttachmentAtAgentSlot,
updateAttachmentAtVideoSlot,
uploadImageFileForComposerAttachment,
uploadImageFileAndAddToCanvas,
videoFrameSlots.length,
]);
@ -10584,11 +10870,42 @@ export function CanvasChatPanel({
addClawdTableFiles(tableFiles);
}
selectedFiles = imageFiles;
if (selectedFiles.length === 0) {
e.target.value = "";
return;
let handledImageCount = 0;
for (const file of imageFiles) {
const uploaded = await uploadImageFileForComposerAttachment(file);
if (!uploaded) continue;
handledImageCount += 1;
setAttachments((prev) => {
const newIndex = prev.length;
setTimeout(() => {
insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [
...prev,
{
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
width: uploaded.width,
height: uploaded.height,
},
];
});
}
if (handledImageCount > 0) {
toast.success(
handledImageCount === 1
? `图片已添加为 ${CLAW_MODE_LABEL} 附件`
: `已添加 ${handledImageCount} 张图片为 ${CLAW_MODE_LABEL} 附件`
);
}
e.target.value = "";
return;
}
let firstImageUrl: string | null = null;
@ -11415,8 +11732,8 @@ export function CanvasChatPanel({
);
const createNewChatSession = useCallback(() => {
startNewChatSession();
}, [startNewChatSession]);
startNewChatSession({ mode: activeComposerMode });
}, [activeComposerMode, startNewChatSession]);
const switchToSession = useCallback((sessionId: string) => {
if (!sessionId || sessionId === activeChatId) {
@ -11582,10 +11899,10 @@ export function CanvasChatPanel({
>
<div className="px-2 py-1 text-xs text-muted-foreground">聊天窗口记录</div>
<div className="max-h-56 overflow-y-auto space-y-1">
{chatSessions.length === 0 ? (
{currentModeChatSessions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">暂无记录</div>
) : (
chatSessions.map((session, index) => {
currentModeChatSessions.map((session, index) => {
const isActive = session.id === activeChatId;
const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], {
hour: "2-digit",
@ -11824,7 +12141,7 @@ export function CanvasChatPanel({
预设快捷功能
</h3>
<div className="mt-6 flex flex-wrap justify-center gap-2">
{canvasSkillTags.slice(0, 4).map((tag) => {
{canvasSkillTags.map((tag) => {
const isActive = selectedSkillTag?.id === tag.id;
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
return (
@ -11930,6 +12247,9 @@ export function CanvasChatPanel({
message.role === "assistant" &&
!hasAssistantMedia &&
Boolean(clawdTypewriterMessageIds[message.id]);
const isClawAssistantMessage =
message.role === "assistant" &&
(isClawdMode || isClawdChatMessage(message));
const messageTimestamp = getMessageTimestamp(message);
const prevMessageTimestamp =
@ -12136,6 +12456,7 @@ export function CanvasChatPanel({
shouldTypeClawAssistantMessage);
const shouldShowDoneSummary =
!isDetailGenerationMode &&
!isClawAssistantMessage &&
imageUrls.length > 0 &&
!plainText.includes(CANVAS_IMAGE_DONE_SUMMARY) &&
!isLatestAssistantDuringGeneration;
@ -12279,24 +12600,46 @@ export function CanvasChatPanel({
setFullscreenPreview({ url });
}}
/>
<button
type="button"
className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white hover:bg-black/80 disabled:cursor-not-allowed disabled:opacity-65"
disabled={isDownloadingOriginalImage}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDownloadOriginalImage(url);
}}
title="下载原图"
>
{downloadingOriginalUrl === url ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{downloadingOriginalUrl === url ? "下载中" : "原图"}
</button>
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
{isClawAssistantMessage ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white hover:bg-black/80 disabled:cursor-not-allowed disabled:opacity-65"
disabled={addingClawdImageUrl === url}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleAddClawdImageToCanvas(url);
}}
title="添加到画布"
>
{addingClawdImageUrl === url ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlus className="h-3.5 w-3.5" />
)}
{addingClawdImageUrl === url ? "添加中" : "画布"}
</button>
) : null}
<button
type="button"
className="inline-flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white hover:bg-black/80 disabled:cursor-not-allowed disabled:opacity-65"
disabled={isDownloadingOriginalImage}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDownloadOriginalImage(url);
}}
title="下载原图"
>
{downloadingOriginalUrl === url ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{downloadingOriginalUrl === url ? "下载中" : "原图"}
</button>
</div>
</div>
))}
</div>
@ -13309,6 +13652,48 @@ export function CanvasChatPanel({
</button>
</div>
) : null}
{isClawdMode && activeClawdSkillTag && !activeClawdSkillTagPromptApplied ? (
<div className="mb-2 flex px-1 animate-in fade-in-0 slide-in-from-bottom-2 zoom-in-95 duration-300">
<div
className="inline-flex max-w-full items-center gap-2 rounded-full border border-violet-200 bg-violet-50 px-3 py-1.5 text-sm font-medium text-violet-700 shadow-sm dark:border-violet-400/25 dark:bg-violet-500/15 dark:text-violet-100"
title={`首次发送会带入「${activeClawdSkillTag.name}」快捷标签提示词`}
>
{activeClawdSkillTag.iconUrl ? (
<img
src={buildPreviewImageUrl(activeClawdSkillTag.iconUrl, 40)}
alt={activeClawdSkillTag.name}
className="h-4 w-4 shrink-0 rounded-sm object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
}
}}
/>
) : (
<ActiveClawdSkillTagIcon className="h-4 w-4 shrink-0 text-violet-600 dark:text-violet-200" />
)}
<span className="truncate">{activeClawdSkillTag.name}</span>
<span className="shrink-0 rounded-full bg-white/70 px-1.5 py-0.5 text-[10px] font-semibold text-violet-500 dark:bg-white/12 dark:text-violet-100">
待发送
</span>
<button
type="button"
className="rounded-full p-0.5 text-violet-400 transition-colors hover:bg-violet-100 hover:text-violet-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-violet-200 dark:hover:bg-white/12 dark:hover:text-white"
onClick={() => {
setSessionClawdSkillTag(activeChatId, null);
setSelectedSkillTagId(null);
}}
disabled={isSubmitting}
aria-label={`移除 ${activeClawdSkillTag.name} 快捷标签`}
title="取消本次快捷标签"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
) : null}
{!isDetailGenerationMode && hasWorkflowContext && workflowContext && (
<div
className={cn(

View File

@ -65,6 +65,142 @@ export const DEFAULT_CANVAS_SKILL_TAGS: CanvasSkillTag[] = [
prompt:
"请按电商平台高转化标准输出主图+副图方案,强调产品主体清晰、卖点可视化、场景可信和对比信息明确。保持产品外观一致,不虚构结构细节。",
},
{
id: "video-gateway",
name: "视频理解",
iconName: "clapperboard",
placeholder: "例如:分析这个短视频链接,总结分镜、口播脚本和可复用卖点",
prompt:
"【技能路由:video-gateway】用途:视频链接/附件理解。请优先按视频理解任务处理,支持 mp4/mov/webm/m3u8 和抖音/小红书/B站等链接。主要触发关键词:视频、短视频、总结、分析、拆解、分镜、口播、脚本。",
},
{
id: "image-search-api",
name: "以图搜图",
iconName: "image",
placeholder: "例如:用这张图片找市场同款,并识别是否为公司 SKU",
prompt:
"【技能路由:image-search-api】用途:以图搜图/公司 SKU 图片识别。请优先按图片搜索任务处理。主要触发关键词:以图搜图、图片搜索、找同款、相似款、市场同款、公司SKU、内部SKU、货号。需要图片 URL、附件或 base64。",
},
{
id: "product-asset-api",
name: "商品素材下载",
iconName: "shopping-bag",
placeholder: "例如:SKU: ABC123,下载主图、白底图、SKU 图和详情页素材",
prompt:
"【技能路由:product-asset-api】用途:商品详情页/主图/SKU图/白底图下载。请优先按商品素材查询下载任务处理。主要触发关键词:详情页、详情素材、任务详情、sku图、主图、白底图、白底。需要 SKU: 或 SPU:。",
},
{
id: "install-media-db",
name: "安装资料查询",
iconName: "book-open",
placeholder: "例如:帮我查这个订单/货号的安装图纸、安装视频和说明书",
prompt:
"【技能路由:install-media-db】用途:安装图纸/安装视频/说明书查询。请优先按安装资料查询任务处理。主要触发关键词:安装图纸、安装视频、安装说明书、安装资料、图纸地址、视频地址、订单号查安装。",
},
{
id: "product-region-logistics-db",
name: "仓配快递策略",
iconName: "message-circle",
placeholder: "例如:货号 ABC123 发浙江省默认走什么快递、哪个仓库",
prompt:
"【技能路由:product-region-logistics-db】用途:货号 + 区域仓配/快递策略。请优先按仓配物流策略查询任务处理。主要触发关键词:发什么快递、什么快递、物流策略、仓库策略、仓配策略、默认快递、默认仓库。需要货号和省份。",
},
{
id: "product-retention-db",
name: "退补留客决策",
iconName: "badge-percent",
placeholder: "例如:这个售后场景应该退货还是补偿,补偿区间多少",
prompt:
"【技能路由:product-retention-db】用途:退货/补偿/留客决策。请优先按退补留客决策任务处理。主要触发关键词:退货还是补偿、退回还是补偿、补偿区间、优惠留客、留客、挽留、放货、退补决策。",
},
{
id: "company-aftersale-db",
name: "售后退款分析",
iconName: "message-circle",
placeholder: "例如:汇总 6 月售后分析,拆解退款原因和风险类型",
prompt:
"【技能路由:company-aftersale-db】用途:公司售后/退款原因分析。请优先按售后退款原因分析任务处理。主要触发关键词:售后分析、退款原因、售后原因、退货退款风险、售后类型、售后结果。",
},
{
id: "business-db",
name: "销售业绩汇总",
iconName: "badge-percent",
placeholder: "例如:汇总 2026 年 6 月全平台整体销售业绩和 GMV",
prompt:
"【技能路由:business-db】用途:整体销售业绩汇总。请优先按销售业绩汇总任务处理。触发条件:销售/业绩/GMV/成交/营业额 + 总/整体/全平台/汇总 + 日期。",
},
{
id: "market-db",
name: "市场大盘分析",
iconName: "layout-grid",
placeholder: "例如:分析书桌类目 2026 年 6 月市场大盘、价格带和品牌榜",
prompt:
"【技能路由:market-db】用途:市场大盘分析。请优先按市场大盘/行业类目分析任务处理。主要触发关键词:市场分析、市场大盘、行业分析、类目分析、市场趋势、价格带、品牌榜、店铺榜、热搜词等 + 类目词。",
},
{
id: "market-insight-db",
name: "市场洞察表",
iconName: "layout-grid",
placeholder: "例如:生成鞋柜市场洞察表,并支持完整 Excel 导出",
prompt:
"【技能路由:market-insight-db】用途:市场洞察表/完整 Excel 导出。请优先按市场洞察表任务处理。主要触发关键词:市场洞察、洞察表、洞察报告、洞察分析;后续支持按推荐继续、看SKU、看关键词、确认导出完整表。",
},
{
id: "rank-board-db",
name: "市场榜单分析",
iconName: "layout-grid",
placeholder: "例如:分析目标商品 ID 的市场榜单结构、价格带和材质分布",
prompt:
"【技能路由:rank-board-db】用途:市场榜单/商品ID榜单分析。请优先按榜单分析任务处理。主要触发关键词:榜单、排行榜、渠道榜、目标ID、市场ID、商品ID计算、ID明细、价格带、材质、店铺、榜单结构。",
},
{
id: "market-id-detail-db",
name: "市场ID详情",
iconName: "shopping-bag",
placeholder: "例如:补全这些市场商品 ID 的主图、SKU 图、详情页、标题和价格",
prompt:
"【技能路由:market-id-detail-db】用途:市场商品 ID 详情补全。请优先按市场商品 ID 详情任务处理。主要触发关键词:市场ID详情、市场商品ID、市场商品详情、商品ID + 主图/sku图/详情页/标题/价格/链接。",
},
{
id: "pdd-goods-db",
name: "拼多多经营分析",
iconName: "shopping-bag",
placeholder: "例如:分析这个拼多多商品 ID 的经营表现、排行、转化和价格",
prompt:
"【技能路由:pdd-goods-db】用途:拼多多商品经营分析。请优先按拼多多商品经营分析任务处理。主要触发关键词:拼多多、PDD、datagoods + 商品ID或经营/表现/数据/排行/转化/推广/价格/SKU 等意图。",
},
{
id: "channel-id-health-db",
name: "渠道ID健康诊断",
iconName: "badge-percent",
placeholder: "例如:诊断这个京东/天猫商品 ID 的费比、SKU 利润和优化动作",
prompt:
"【技能路由:channel-id-health-db】用途:京东/天猫商品ID健康诊断。请优先按渠道商品 ID 健康诊断任务处理。主要触发关键词:京东/JD/天猫/tmall + 健康诊断、ID健康、商品经营、费比、SKU利润、放大、控投、优化。",
},
{
id: "operator-health-db",
name: "运营健康诊断",
iconName: "badge-percent",
placeholder: "例如:给我的团队做运营健康诊断,包含业绩、费比、SKU 和优化建议",
prompt:
"【技能路由:operator-health-db】用途:运营健康诊断。请优先按运营健康诊断任务处理。主要触发关键词:运营健康、健康诊断、健康分析、放大、控投、优化 + 我的/团队/运营/商品ID/SKU/费比/业绩。",
},
{
id: "business-operator-dimension-db",
name: "运营维度排行",
iconName: "layout-grid",
placeholder: "例如:按运营维度汇总 6 月销售数据,输出排行和差异",
prompt:
"【技能路由:business-operator-dimension-db】用途:团队/运营维度销售排行。请优先按运营维度销售排行任务处理。主要触发关键词:运营维度、按运营、各运营、每个运营、运营排行、运营排名、运营汇总、运营销售数据。",
},
{
id: "business-operator-db",
name: "单运营业绩费比",
iconName: "badge-percent",
placeholder: "例如:张三 2026 年 6 月业绩和费比情况",
prompt:
"【技能路由:business-operator-db】用途:单个运营业绩与费比。请优先按单个运营业绩费比任务处理。触发条件:人名 + 业绩/费比/销售/成交/GMV + 月份,例如“张三2026年6月业绩”。",
},
];
function normalizeString(value: unknown) {