feat: add Claw skill shortcuts and isolate mode chats
This commit is contained in:
parent
80f6827375
commit
d8af4bb48d
@ -334,7 +334,10 @@ export async function POST(request: Request) {
|
|||||||
temporaryVideoUrl: null,
|
temporaryVideoUrl: null,
|
||||||
permanentVideoUrl: null,
|
permanentVideoUrl: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
videoMetadata: null,
|
videoMetadata: {
|
||||||
|
composerMode: "clawd",
|
||||||
|
source: "openclaw",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const assistantMessage = {
|
const assistantMessage = {
|
||||||
id: assistantMessageId,
|
id: assistantMessageId,
|
||||||
@ -347,7 +350,14 @@ export async function POST(request: Request) {
|
|||||||
temporaryVideoUrl: null,
|
temporaryVideoUrl: null,
|
||||||
permanentVideoUrl: null,
|
permanentVideoUrl: null,
|
||||||
archivedAt: 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] });
|
await saveMessages({ messages: [userMessage, assistantMessage] });
|
||||||
@ -364,14 +374,22 @@ export async function POST(request: Request) {
|
|||||||
role: "user",
|
role: "user",
|
||||||
parts: userMessage.parts,
|
parts: userMessage.parts,
|
||||||
createdAt: userMessage.createdAt.toISOString(),
|
createdAt: userMessage.createdAt.toISOString(),
|
||||||
metadata: { createdAt: userMessage.createdAt.toISOString() },
|
metadata: {
|
||||||
|
createdAt: userMessage.createdAt.toISOString(),
|
||||||
|
composerMode: "clawd",
|
||||||
|
},
|
||||||
|
videoMetadata: userMessage.videoMetadata,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: assistantMessage.id,
|
id: assistantMessage.id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: assistantMessage.parts,
|
parts: assistantMessage.parts,
|
||||||
createdAt: assistantMessage.createdAt.toISOString(),
|
createdAt: assistantMessage.createdAt.toISOString(),
|
||||||
metadata: { createdAt: assistantMessage.createdAt.toISOString() },
|
metadata: {
|
||||||
|
createdAt: assistantMessage.createdAt.toISOString(),
|
||||||
|
composerMode: "clawd",
|
||||||
|
},
|
||||||
|
videoMetadata: assistantMessage.videoMetadata,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1133,8 +1133,7 @@ function normalizeImportedCanvasState(payload: unknown): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FLOATING_PANEL_WIDTH = 400;
|
const FLOATING_PANEL_WIDTH = 400;
|
||||||
const FLOATING_PANEL_MAX_WIDTH = 760;
|
const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75;
|
||||||
const FLOATING_PANEL_MIN_CANVAS_WIDTH = 520;
|
|
||||||
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
|
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
|
||||||
const FLOATING_PANEL_INSET = 16;
|
const FLOATING_PANEL_INSET = 16;
|
||||||
const DOCKED_PANEL_INSET = 0;
|
const DOCKED_PANEL_INSET = 0;
|
||||||
@ -1142,6 +1141,7 @@ const FLOATING_UI_GAP = 16;
|
|||||||
const FLOATING_BOTTOM_INSET = 20;
|
const FLOATING_BOTTOM_INSET = 20;
|
||||||
const FLOATING_TOOLBAR_ESTIMATED_WIDTH = 680;
|
const FLOATING_TOOLBAR_ESTIMATED_WIDTH = 680;
|
||||||
const FLOATING_TOOLBAR_SIDE_DOCK_CLEARANCE = 48;
|
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_START = "【视频画布同步上下文】";
|
||||||
const WORKFLOW_AGENT_CONTEXT_END = "【/视频画布同步上下文】";
|
const WORKFLOW_AGENT_CONTEXT_END = "【/视频画布同步上下文】";
|
||||||
const AGENT_RESULT_MODE_LABELS: Record<AgentGenerationResultModeType, string> =
|
const AGENT_RESULT_MODE_LABELS: Record<AgentGenerationResultModeType, string> =
|
||||||
@ -1154,17 +1154,19 @@ const AGENT_RESULT_MODE_LABELS: Record<AgentGenerationResultModeType, string> =
|
|||||||
|
|
||||||
function getMaxFloatingPanelWidth(viewportWidth: number) {
|
function getMaxFloatingPanelWidth(viewportWidth: number) {
|
||||||
if (viewportWidth <= 0) {
|
if (viewportWidth <= 0) {
|
||||||
return FLOATING_PANEL_MAX_WIDTH;
|
return FLOATING_PANEL_WIDTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewportLimitedMax = Math.max(
|
const viewportLimitedMax = Math.max(
|
||||||
FLOATING_PANEL_WIDTH,
|
0,
|
||||||
viewportWidth - FLOATING_PANEL_MIN_CANVAS_WIDTH - DOCKED_PANEL_INSET
|
viewportWidth - DOCKED_PANEL_INSET
|
||||||
);
|
);
|
||||||
|
const ratioLimitedMax =
|
||||||
|
viewportLimitedMax * FLOATING_PANEL_MAX_VIEWPORT_RATIO;
|
||||||
|
|
||||||
return Math.max(
|
return Math.max(
|
||||||
FLOATING_PANEL_WIDTH,
|
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;
|
viewportWidth > 0 ? Math.max(viewportWidth - panelOcclusionRight, 0) : 0;
|
||||||
const horizontalFloatingToolbarWidthForDocking =
|
const horizontalFloatingToolbarWidthForDocking =
|
||||||
horizontalFloatingToolbarWidth || FLOATING_TOOLBAR_ESTIMATED_WIDTH;
|
horizontalFloatingToolbarWidth || FLOATING_TOOLBAR_ESTIMATED_WIDTH;
|
||||||
const shouldDockFloatingToolbarToSide =
|
const isFloatingToolbarWidthCrowded =
|
||||||
!isVideoMode &&
|
|
||||||
isFloatingSidebarVisible &&
|
|
||||||
visibleCanvasWidth > 0 &&
|
visibleCanvasWidth > 0 &&
|
||||||
visibleCanvasWidth <
|
visibleCanvasWidth <
|
||||||
horizontalFloatingToolbarWidthForDocking +
|
horizontalFloatingToolbarWidthForDocking +
|
||||||
FLOATING_TOOLBAR_SIDE_DOCK_CLEARANCE * 2;
|
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
|
const floatingToolbarTooltipSide = shouldDockFloatingToolbarToSide
|
||||||
? "right"
|
? "right"
|
||||||
: "top";
|
: "top";
|
||||||
|
|||||||
@ -326,6 +326,47 @@ function getCanvasComposerModeSessionLabel(mode: CanvasComposerMode) {
|
|||||||
return "Agent";
|
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 =
|
const CLAWD_TABLE_ACCEPT =
|
||||||
".xlsx,.csv,.tsv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,text/tab-separated-values";
|
".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_ALLOWED_EXTENSIONS = new Set(["xlsx", "csv", "tsv"]);
|
||||||
@ -1600,6 +1641,7 @@ type MessageVote = {
|
|||||||
interface CanvasChatSession {
|
interface CanvasChatSession {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
mode: CanvasComposerMode;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
clawdSkillTagId?: string | null;
|
clawdSkillTagId?: string | null;
|
||||||
@ -1899,6 +1941,7 @@ export function CanvasChatPanel({
|
|||||||
const [downloadProgress, setDownloadProgress] =
|
const [downloadProgress, setDownloadProgress] =
|
||||||
useState<CanvasImageDownloadProgress | null>(null);
|
useState<CanvasImageDownloadProgress | null>(null);
|
||||||
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
|
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
|
||||||
|
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
|
||||||
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
const fullscreenPreviewSrc = useMemo(() => {
|
const fullscreenPreviewSrc = useMemo(() => {
|
||||||
if (!fullscreenPreview?.url) return "";
|
if (!fullscreenPreview?.url) return "";
|
||||||
@ -2045,13 +2088,29 @@ export function CanvasChatPanel({
|
|||||||
}, [getSendGuard]);
|
}, [getSendGuard]);
|
||||||
|
|
||||||
const startNewChatSession = useCallback(
|
const startNewChatSession = useCallback(
|
||||||
(options?: { title?: string; toastMessage?: string }) => {
|
(options?: {
|
||||||
|
mode?: CanvasComposerMode;
|
||||||
|
title?: string;
|
||||||
|
toastMessage?: string;
|
||||||
|
}) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const newChatSessionId = `canvas-${crypto.randomUUID()}`;
|
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 = {
|
const newSession: CanvasChatSession = {
|
||||||
id: newChatSessionId,
|
id: newChatSessionId,
|
||||||
title: options?.title || fallbackTitle,
|
title: options?.title || fallbackTitle,
|
||||||
|
mode: sessionMode,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@ -2064,7 +2123,7 @@ export function CanvasChatPanel({
|
|||||||
toast.success(options?.toastMessage || "已新建聊天窗口");
|
toast.success(options?.toastMessage || "已新建聊天窗口");
|
||||||
return newChatSessionId;
|
return newChatSessionId;
|
||||||
},
|
},
|
||||||
[activeChatId, chatSessions.length, releaseSendGuard, replaceChatSessionQuery]
|
[activeChatId, chatSessions, releaseSendGuard, replaceChatSessionQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSessionClawdSkillTag = useCallback(
|
const setSessionClawdSkillTag = useCallback(
|
||||||
@ -2390,6 +2449,7 @@ export function CanvasChatPanel({
|
|||||||
const fallbackSession: CanvasChatSession = {
|
const fallbackSession: CanvasChatSession = {
|
||||||
id: rootChatId,
|
id: rootChatId,
|
||||||
title: "对话 1",
|
title: "对话 1",
|
||||||
|
mode: "image",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@ -2398,6 +2458,7 @@ export function CanvasChatPanel({
|
|||||||
? {
|
? {
|
||||||
id: requestedChatSessionId,
|
id: requestedChatSessionId,
|
||||||
title: "新对话",
|
title: "新对话",
|
||||||
|
mode: "image",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
@ -2411,18 +2472,26 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
const sanitized: CanvasChatSession[] = parsed
|
const sanitized: CanvasChatSession[] = parsed
|
||||||
.filter((session) => session?.id)
|
.filter((session) => session?.id)
|
||||||
.map((session): CanvasChatSession => ({
|
.map((session): CanvasChatSession => {
|
||||||
id: session.id,
|
const clawdSkillTagId =
|
||||||
title: (session.title || "").trim() || "新对话",
|
|
||||||
createdAt: Number(session.createdAt) || now,
|
|
||||||
updatedAt: Number(session.updatedAt) || now,
|
|
||||||
clawdSkillTagId:
|
|
||||||
typeof (session as CanvasChatSession).clawdSkillTagId === "string"
|
typeof (session as CanvasChatSession).clawdSkillTagId === "string"
|
||||||
? (session as CanvasChatSession).clawdSkillTagId || null
|
? (session as CanvasChatSession).clawdSkillTagId || null
|
||||||
: null,
|
: null;
|
||||||
clawdSkillTagPromptApplied:
|
|
||||||
(session as CanvasChatSession).clawdSkillTagPromptApplied === true,
|
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;
|
let nextSessions = sanitized;
|
||||||
const requiredSessions = requestedSession
|
const requiredSessions = requestedSession
|
||||||
@ -2712,6 +2781,83 @@ export function CanvasChatPanel({
|
|||||||
[focusCanvasOnNode, setTransientViewport]
|
[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(
|
const animateCanvasAgentViewport = useCallback(
|
||||||
(
|
(
|
||||||
targetViewport: { x: number; y: number; zoom: number },
|
targetViewport: { x: number; y: number; zoom: number },
|
||||||
@ -3901,6 +4047,17 @@ export function CanvasChatPanel({
|
|||||||
const activeComposerMode = composerModeOverride || computedComposerMode;
|
const activeComposerMode = composerModeOverride || computedComposerMode;
|
||||||
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
|
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
|
||||||
const isClawdMode = activeComposerMode === "clawd" && isClawdModeEnabled;
|
const isClawdMode = activeComposerMode === "clawd" && isClawdModeEnabled;
|
||||||
|
const activeChatSessionMode = getCanvasChatSessionMode(
|
||||||
|
activeChatSession,
|
||||||
|
activeComposerMode
|
||||||
|
);
|
||||||
|
const currentModeChatSessions = useMemo(
|
||||||
|
() =>
|
||||||
|
chatSessions.filter(
|
||||||
|
(session) => getCanvasChatSessionMode(session) === activeComposerMode
|
||||||
|
),
|
||||||
|
[activeComposerMode, chatSessions]
|
||||||
|
);
|
||||||
const agentSelectableModels = useMemo(
|
const agentSelectableModels = useMemo(
|
||||||
() =>
|
() =>
|
||||||
effectiveAgentEnabled ? [SMART_AUTO_MODEL, ...conversationOnlyModels] : conversationOnlyModels,
|
effectiveAgentEnabled ? [SMART_AUTO_MODEL, ...conversationOnlyModels] : conversationOnlyModels,
|
||||||
@ -4319,22 +4476,6 @@ export function CanvasChatPanel({
|
|||||||
: isPresetAgentSelection
|
: isPresetAgentSelection
|
||||||
? getToolbarItem("chatModePresetAgent").label
|
? getToolbarItem("chatModePresetAgent").label
|
||||||
: "Agent";
|
: "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(
|
const getComposerModeMenuItemClass = useCallback(
|
||||||
(mode: CanvasComposerMode) =>
|
(mode: CanvasComposerMode) =>
|
||||||
cn(
|
cn(
|
||||||
@ -4692,7 +4833,12 @@ export function CanvasChatPanel({
|
|||||||
return null;
|
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;
|
let targetChatSessionId = activeChatId;
|
||||||
if (
|
if (
|
||||||
isSwitchingMode &&
|
isSwitchingMode &&
|
||||||
@ -4702,12 +4848,28 @@ export function CanvasChatPanel({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSwitchingMode && hasActiveChatContext()) {
|
if (isSwitchingMode) {
|
||||||
const modeLabel = getCanvasComposerModeSessionLabel(mode);
|
const modeLabel = getCanvasComposerModeSessionLabel(mode);
|
||||||
targetChatSessionId = startNewChatSession({
|
const targetSession = chatSessions
|
||||||
title: `${modeLabel} 对话`,
|
.filter((session) => getCanvasChatSessionMode(session) === mode)
|
||||||
toastMessage: `已为 ${modeLabel} 新建聊天窗口`,
|
.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;
|
activeComposerModeRef.current = mode;
|
||||||
@ -4764,7 +4926,6 @@ export function CanvasChatPanel({
|
|||||||
firstImageModelId,
|
firstImageModelId,
|
||||||
firstVideoModelId,
|
firstVideoModelId,
|
||||||
getSendGuard,
|
getSendGuard,
|
||||||
hasActiveChatContext,
|
|
||||||
isClawdSubmitting,
|
isClawdSubmitting,
|
||||||
isDetailGenerating,
|
isDetailGenerating,
|
||||||
lastConversationModelId,
|
lastConversationModelId,
|
||||||
@ -4773,6 +4934,9 @@ export function CanvasChatPanel({
|
|||||||
isCanvasImageModel,
|
isCanvasImageModel,
|
||||||
isCanvasVideoModel,
|
isCanvasVideoModel,
|
||||||
lastVideoModelId,
|
lastVideoModelId,
|
||||||
|
chatSessions,
|
||||||
|
releaseSendGuard,
|
||||||
|
replaceChatSessionQuery,
|
||||||
startNewChatSession,
|
startNewChatSession,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@ -6454,6 +6618,10 @@ export function CanvasChatPanel({
|
|||||||
const created: CanvasChatSession = {
|
const created: CanvasChatSession = {
|
||||||
id: activeChatId,
|
id: activeChatId,
|
||||||
title: derivedTitle || fallbackTitle,
|
title: derivedTitle || fallbackTitle,
|
||||||
|
mode: normalizeCanvasComposerSessionMode(
|
||||||
|
activeComposerModeRef.current,
|
||||||
|
"image"
|
||||||
|
),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@ -6479,6 +6647,7 @@ export function CanvasChatPanel({
|
|||||||
}, [activeChatId, messages]);
|
}, [activeChatId, messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeChatSessionMode === "clawd") return;
|
||||||
if (!hasHydratedHistoryRef.current) return;
|
if (!hasHydratedHistoryRef.current) return;
|
||||||
const activeStreamState = streamImageGenerationStateByChatId[activeChatId];
|
const activeStreamState = streamImageGenerationStateByChatId[activeChatId];
|
||||||
const shouldDeferAssistantMediaAutosync =
|
const shouldDeferAssistantMediaAutosync =
|
||||||
@ -6530,6 +6699,10 @@ export function CanvasChatPanel({
|
|||||||
if (message.role !== "assistant" || processedMessageIds.current.has(message.id)) {
|
if (message.role !== "assistant" || processedMessageIds.current.has(message.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isClawdChatMessage(message)) {
|
||||||
|
processedMessageIds.current.add(message.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const nearestUserImageKeys = getNearestUserImageUrlKeys(messages, messageIndex);
|
const nearestUserImageKeys = getNearestUserImageUrlKeys(messages, messageIndex);
|
||||||
const nearestUserImageUrls = getNearestUserImageUrls(messages, messageIndex);
|
const nearestUserImageUrls = getNearestUserImageUrls(messages, messageIndex);
|
||||||
@ -6827,6 +7000,7 @@ export function CanvasChatPanel({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeChatId,
|
activeChatId,
|
||||||
|
activeChatSessionMode,
|
||||||
messages,
|
messages,
|
||||||
addNode,
|
addNode,
|
||||||
nodes,
|
nodes,
|
||||||
@ -6865,11 +7039,13 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
// 为历史节点补全可编辑的原始提示词(旧数据里可能只有“对话生成”占位文案)
|
// 为历史节点补全可编辑的原始提示词(旧数据里可能只有“对话生成”占位文案)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeChatSessionMode === "clawd") return;
|
||||||
if (!messages.length || !nodes.length) return;
|
if (!messages.length || !nodes.length) return;
|
||||||
|
|
||||||
const promptByImageUrl = new Map<string, string>();
|
const promptByImageUrl = new Map<string, string>();
|
||||||
messages.forEach((message, messageIndex) => {
|
messages.forEach((message, messageIndex) => {
|
||||||
if (message.role !== "assistant") return;
|
if (message.role !== "assistant") return;
|
||||||
|
if (isClawdChatMessage(message)) return;
|
||||||
const content = message.parts?.map((p: any) => p.text || "").join("") || "";
|
const content = message.parts?.map((p: any) => p.text || "").join("") || "";
|
||||||
const imageUrls = extractImageUrls(content);
|
const imageUrls = extractImageUrls(content);
|
||||||
if (!imageUrls.length) return;
|
if (!imageUrls.length) return;
|
||||||
@ -6905,7 +7081,7 @@ export function CanvasChatPanel({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [messages, nodes, updateNode]);
|
}, [activeChatSessionMode, messages, nodes, updateNode]);
|
||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -9741,9 +9917,23 @@ export function CanvasChatPanel({
|
|||||||
role: "user",
|
role: "user",
|
||||||
parts: [{ type: "text", text: optimisticMessageText }],
|
parts: [{ type: "text", text: optimisticMessageText }],
|
||||||
createdAt: optimisticCreatedAt,
|
createdAt: optimisticCreatedAt,
|
||||||
metadata: { createdAt: optimisticCreatedAt.toISOString() },
|
metadata: {
|
||||||
|
createdAt: optimisticCreatedAt.toISOString(),
|
||||||
|
composerMode: "clawd",
|
||||||
|
},
|
||||||
} as ChatMessage;
|
} as ChatMessage;
|
||||||
|
|
||||||
|
setChatSessions((prevSessions) =>
|
||||||
|
prevSessions.map((session) =>
|
||||||
|
session.id === activeChatId
|
||||||
|
? {
|
||||||
|
...session,
|
||||||
|
mode: "clawd",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
: session
|
||||||
|
)
|
||||||
|
);
|
||||||
setSendGuard(activeChatId, {
|
setSendGuard(activeChatId, {
|
||||||
inFlight: true,
|
inFlight: true,
|
||||||
signature: sendSignature,
|
signature: sendSignature,
|
||||||
@ -10370,6 +10560,62 @@ export function CanvasChatPanel({
|
|||||||
}
|
}
|
||||||
}, [addNode]);
|
}, [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[]) => {
|
const handlePastedImages = useCallback(async (files: File[]) => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
@ -10377,6 +10623,44 @@ export function CanvasChatPanel({
|
|||||||
const baseNodeCount = nodes.length;
|
const baseNodeCount = nodes.length;
|
||||||
let uploadIndex = 0;
|
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) {
|
if (hasAgentUploadSlots) {
|
||||||
const slotCount = agentUploadSlots.length;
|
const slotCount = agentUploadSlots.length;
|
||||||
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
|
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
|
||||||
@ -10500,11 +10784,13 @@ export function CanvasChatPanel({
|
|||||||
attachments,
|
attachments,
|
||||||
hasAgentUploadSlots,
|
hasAgentUploadSlots,
|
||||||
insertImageTagInEditor,
|
insertImageTagInEditor,
|
||||||
|
isClawdMode,
|
||||||
isVideoModel,
|
isVideoModel,
|
||||||
maybeAutoSetCanvasTitle,
|
maybeAutoSetCanvasTitle,
|
||||||
nodes.length,
|
nodes.length,
|
||||||
updateAttachmentAtAgentSlot,
|
updateAttachmentAtAgentSlot,
|
||||||
updateAttachmentAtVideoSlot,
|
updateAttachmentAtVideoSlot,
|
||||||
|
uploadImageFileForComposerAttachment,
|
||||||
uploadImageFileAndAddToCanvas,
|
uploadImageFileAndAddToCanvas,
|
||||||
videoFrameSlots.length,
|
videoFrameSlots.length,
|
||||||
]);
|
]);
|
||||||
@ -10584,11 +10870,42 @@ export function CanvasChatPanel({
|
|||||||
addClawdTableFiles(tableFiles);
|
addClawdTableFiles(tableFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedFiles = imageFiles;
|
let handledImageCount = 0;
|
||||||
if (selectedFiles.length === 0) {
|
for (const file of imageFiles) {
|
||||||
e.target.value = "";
|
const uploaded = await uploadImageFileForComposerAttachment(file);
|
||||||
return;
|
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;
|
let firstImageUrl: string | null = null;
|
||||||
@ -11415,8 +11732,8 @@ export function CanvasChatPanel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const createNewChatSession = useCallback(() => {
|
const createNewChatSession = useCallback(() => {
|
||||||
startNewChatSession();
|
startNewChatSession({ mode: activeComposerMode });
|
||||||
}, [startNewChatSession]);
|
}, [activeComposerMode, startNewChatSession]);
|
||||||
|
|
||||||
const switchToSession = useCallback((sessionId: string) => {
|
const switchToSession = useCallback((sessionId: string) => {
|
||||||
if (!sessionId || sessionId === activeChatId) {
|
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="px-2 py-1 text-xs text-muted-foreground">聊天窗口记录</div>
|
||||||
<div className="max-h-56 overflow-y-auto space-y-1">
|
<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>
|
<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 isActive = session.id === activeChatId;
|
||||||
const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], {
|
const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@ -11824,7 +12141,7 @@ export function CanvasChatPanel({
|
|||||||
预设快捷功能
|
预设快捷功能
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
<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 isActive = selectedSkillTag?.id === tag.id;
|
||||||
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
|
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
|
||||||
return (
|
return (
|
||||||
@ -11930,6 +12247,9 @@ export function CanvasChatPanel({
|
|||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
!hasAssistantMedia &&
|
!hasAssistantMedia &&
|
||||||
Boolean(clawdTypewriterMessageIds[message.id]);
|
Boolean(clawdTypewriterMessageIds[message.id]);
|
||||||
|
const isClawAssistantMessage =
|
||||||
|
message.role === "assistant" &&
|
||||||
|
(isClawdMode || isClawdChatMessage(message));
|
||||||
|
|
||||||
const messageTimestamp = getMessageTimestamp(message);
|
const messageTimestamp = getMessageTimestamp(message);
|
||||||
const prevMessageTimestamp =
|
const prevMessageTimestamp =
|
||||||
@ -12136,6 +12456,7 @@ export function CanvasChatPanel({
|
|||||||
shouldTypeClawAssistantMessage);
|
shouldTypeClawAssistantMessage);
|
||||||
const shouldShowDoneSummary =
|
const shouldShowDoneSummary =
|
||||||
!isDetailGenerationMode &&
|
!isDetailGenerationMode &&
|
||||||
|
!isClawAssistantMessage &&
|
||||||
imageUrls.length > 0 &&
|
imageUrls.length > 0 &&
|
||||||
!plainText.includes(CANVAS_IMAGE_DONE_SUMMARY) &&
|
!plainText.includes(CANVAS_IMAGE_DONE_SUMMARY) &&
|
||||||
!isLatestAssistantDuringGeneration;
|
!isLatestAssistantDuringGeneration;
|
||||||
@ -12279,24 +12600,46 @@ export function CanvasChatPanel({
|
|||||||
setFullscreenPreview({ url });
|
setFullscreenPreview({ url });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
|
||||||
type="button"
|
{isClawAssistantMessage ? (
|
||||||
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"
|
<button
|
||||||
disabled={isDownloadingOriginalImage}
|
type="button"
|
||||||
onClick={(event) => {
|
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"
|
||||||
event.preventDefault();
|
disabled={addingClawdImageUrl === url}
|
||||||
event.stopPropagation();
|
onClick={(event) => {
|
||||||
void handleDownloadOriginalImage(url);
|
event.preventDefault();
|
||||||
}}
|
event.stopPropagation();
|
||||||
title="下载原图"
|
void handleAddClawdImageToCanvas(url);
|
||||||
>
|
}}
|
||||||
{downloadingOriginalUrl === url ? (
|
title="添加到画布"
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
>
|
||||||
) : (
|
{addingClawdImageUrl === url ? (
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
)}
|
) : (
|
||||||
{downloadingOriginalUrl === url ? "下载中" : "原图"}
|
<ImagePlus className="h-3.5 w-3.5" />
|
||||||
</button>
|
)}
|
||||||
|
{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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -13309,6 +13652,48 @@ export function CanvasChatPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 && (
|
{!isDetailGenerationMode && hasWorkflowContext && workflowContext && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -65,6 +65,142 @@ export const DEFAULT_CANVAS_SKILL_TAGS: CanvasSkillTag[] = [
|
|||||||
prompt:
|
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) {
|
function normalizeString(value: unknown) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user