feat: support claw quick reply actions

This commit is contained in:
134 2026-07-01 19:05:10 +08:00
parent e7f3df1f5d
commit 69f2f7957d

View File

@ -1120,6 +1120,19 @@ type CanvasTypewriterTextProps = {
onComplete?: (typingKey: string) => void;
};
type OpenClawQuickAction = {
label: string;
message: string;
intent?: string;
description?: string;
danger?: boolean;
};
type OpenClawActionsBlock = {
title?: string;
actions: OpenClawQuickAction[];
};
type CanvasComposerUploadProgress = {
title: string;
message: string;
@ -1128,6 +1141,87 @@ type CanvasComposerUploadProgress = {
fileName?: string;
};
const OPENCLAW_ACTIONS_CODE_BLOCK_REGEX =
/```openclaw-actions\s*([\s\S]*?)```/gi;
function normalizeOpenClawActionText(value: unknown, maxLength: number) {
return String(value || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, maxLength);
}
function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const row = payload as Record<string, unknown>;
if (
row.openclaw_actions !== true &&
row.type !== "quick_replies" &&
!Array.isArray(row.actions)
) {
return null;
}
const actions = Array.isArray(row.actions)
? row.actions
.map((item): OpenClawQuickAction | null => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return null;
}
const action = item as Record<string, unknown>;
const label = normalizeOpenClawActionText(action.label, 24);
const message = normalizeOpenClawActionText(action.message, 300);
if (!label || !message) return null;
const intent = normalizeOpenClawActionText(action.intent, 80);
const description = normalizeOpenClawActionText(action.description, 120);
return {
label,
message,
intent: intent || undefined,
description: description || undefined,
danger: action.danger === true,
};
})
.filter((item): item is OpenClawQuickAction => Boolean(item))
.slice(0, 10)
: [];
if (actions.length === 0) return null;
return {
title: normalizeOpenClawActionText(row.title, 40) || undefined,
actions,
};
}
function extractOpenClawActionsFromMarkdown(content: string) {
const blocks: OpenClawActionsBlock[] = [];
const markdown = String(content || "").replace(
OPENCLAW_ACTIONS_CODE_BLOCK_REGEX,
(_match, rawJson) => {
try {
const parsed = JSON.parse(String(rawJson || "").trim());
const block = parseOpenClawActionsPayload(parsed);
if (block) {
blocks.push(block);
}
} catch {
// Invalid action JSON should not break normal Markdown rendering.
}
return "\n";
}
);
return {
markdown: markdown.replace(/\n{3,}/g, "\n\n").trim(),
blocks,
};
}
type MarkdownTableBlockRange = {
start: number;
end: number;
@ -1324,6 +1418,58 @@ function CanvasTypewriterText({
);
}
function OpenClawQuickActions({
blocks,
disabled,
onAction,
}: {
blocks: OpenClawActionsBlock[];
disabled: boolean;
onAction: (message: string) => void;
}) {
if (blocks.length === 0) return null;
return (
<div className="mt-4 space-y-3">
{blocks.map((block, blockIndex) => (
<div key={`openclaw-actions-${blockIndex}`} className="space-y-2">
{block.title ? (
<div className="text-[12px] font-medium text-[#9aa3b2] dark:text-zinc-400">
{block.title}
</div>
) : null}
<div className="flex flex-wrap gap-2">
{block.actions.map((action, actionIndex) => (
<button
key={`${action.label}-${actionIndex}`}
type="button"
disabled={disabled}
title={action.description || action.message}
data-openclaw-action-intent={action.intent || undefined}
className={cn(
"inline-flex min-h-8 max-w-full items-center rounded-full border px-3 py-1.5 text-[13px] font-medium leading-tight transition-colors",
action.danger
? "border-rose-300/45 bg-rose-500/10 text-rose-700 hover:bg-rose-500/15 dark:border-rose-400/35 dark:text-rose-200"
: "border-[#d7dce6] bg-white text-[#29313d] hover:border-[#c7ceda] hover:bg-[#f6f7f9] dark:border-white/12 dark:bg-white/7 dark:text-zinc-100 dark:hover:border-white/24 dark:hover:bg-white/12",
disabled && "cursor-not-allowed opacity-55"
)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (disabled) return;
onAction(action.message);
}}
>
<span className="truncate">{action.label}</span>
</button>
))}
</div>
</div>
))}
</div>
);
}
function CanvasAssistantTypingPlaceholder({ hint }: { hint?: string | null }) {
return (
<div className="flex w-full justify-start">
@ -9937,11 +10083,13 @@ export function CanvasChatPanel({
]);
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async () => {
const handleSend = useCallback(async (overrideInput?: string) => {
const currentSendGuard = getSendGuard(activeChatId);
if (currentSendGuard.inFlight || status === "streaming" || status === "submitted") {
return;
}
const hasOverrideInput = typeof overrideInput === "string";
const composerInput = hasOverrideInput ? String(overrideInput || "") : input;
if (isDetailGenerationMode && canUseDetailGeneration) {
clearLatestGeneratedImageHighlights();
@ -9959,11 +10107,15 @@ export function CanvasChatPanel({
}
if (isClawdMode) {
const textContent = normalizeClawdComposerText(input);
const tableFilesSnapshot = [...clawdTableFiles];
const isProgrammaticClawdQuickSend = hasOverrideInput;
const textContent = normalizeClawdComposerText(composerInput);
const tableFilesSnapshot = isProgrammaticClawdQuickSend ? [] : [...clawdTableFiles];
const hasTableFiles = tableFilesSnapshot.length > 0;
const clawdSkillTagForRequest = selectedSkillTag || activeClawdSkillTag;
const clawdSkillTagForRequest = isProgrammaticClawdQuickSend
? activeClawdSkillTag
: selectedSkillTag || activeClawdSkillTag;
const shouldIncludeClawdSkillTagPrompt = Boolean(
!isProgrammaticClawdQuickSend &&
clawdSkillTagForRequest &&
(selectedSkillTag || !activeClawdSkillTagPromptApplied)
);
@ -9971,7 +10123,7 @@ export function CanvasChatPanel({
? buildClawdSkillTagPrompt(clawdSkillTagForRequest)
: "";
const hasPendingClawdSkillTagPrompt = Boolean(clawdSkillTagPrompt);
const attachmentsSnapshot = [...attachments];
const attachmentsSnapshot = isProgrammaticClawdQuickSend ? [] : [...attachments];
const imageAttachmentsSnapshot = attachmentsSnapshot
.map((attachment) => sanitizeChatAttachment(attachment))
.filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment));
@ -10103,12 +10255,14 @@ export function CanvasChatPanel({
optimisticUserMessage,
])
);
clearComposerEditor();
scheduleClearRestoredClawdComposerText();
setAttachments([]);
setClawdTableFiles([]);
setSelectedSkillTagId(null);
clearSelection();
if (!isProgrammaticClawdQuickSend) {
clearComposerEditor();
scheduleClearRestoredClawdComposerText();
setAttachments([]);
setClawdTableFiles([]);
setSelectedSkillTagId(null);
clearSelection();
}
try {
const formData = new FormData();
@ -10191,30 +10345,32 @@ export function CanvasChatPanel({
previousMessages.filter((message) => message.id !== userMessageId)
);
toast.error(CLAW_UPGRADE_RETRY_MESSAGE);
setClawdTableFiles(tableFilesSnapshot);
setAttachments(attachmentsSnapshot);
if (clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt) {
setSelectedSkillTagId(clawdSkillTagForRequest.id);
}
const editableDiv = textareaRef.current as HTMLDivElement | null;
const currentEditorText = (editableDiv?.textContent || "")
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!currentEditorText) {
setInput(textContent);
if (editableDiv) {
editableDiv.textContent = textContent;
window.setTimeout(() => {
attachmentsSnapshot.forEach((attachment, index) => {
if (!attachment.url) return;
insertImageTagInEditor(
attachment.url,
normalizeChatAttachmentName(attachment.name),
index
);
});
}, 0);
if (!isProgrammaticClawdQuickSend) {
setClawdTableFiles(tableFilesSnapshot);
setAttachments(attachmentsSnapshot);
if (clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt) {
setSelectedSkillTagId(clawdSkillTagForRequest.id);
}
const editableDiv = textareaRef.current as HTMLDivElement | null;
const currentEditorText = (editableDiv?.textContent || "")
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!currentEditorText) {
setInput(textContent);
if (editableDiv) {
editableDiv.textContent = textContent;
window.setTimeout(() => {
attachmentsSnapshot.forEach((attachment, index) => {
if (!attachment.url) return;
insertImageTagInEditor(
attachment.url,
normalizeChatAttachmentName(attachment.name),
index
);
});
}, 0);
}
}
}
} finally {
@ -10234,7 +10390,7 @@ export function CanvasChatPanel({
return;
}
if (!input.trim() && attachments.length === 0 && !selectedSkillTag && !hasWorkflowContext) {
if (!composerInput.trim() && attachments.length === 0 && !selectedSkillTag && !hasWorkflowContext) {
return;
}
const effectiveModelId = currentModelIdRef.current || selectedModelId;
@ -10324,7 +10480,7 @@ export function CanvasChatPanel({
});
// 添加文本部分(用户可见)
let textContent = input
let textContent = composerInput
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
@ -10574,6 +10730,22 @@ export function CanvasChatPanel({
workflowContext,
]);
const handleOpenClawQuickAction = useCallback(
(message: string) => {
const quickMessage = normalizeOpenClawActionText(message, 300);
if (!quickMessage) {
toast.error("快捷标签内容为空");
return;
}
if (!isClawdMode) {
toast.error(`请先切换到 ${CLAW_MODE_LABEL} 对话后再使用快捷标签`);
return;
}
void handleSend(quickMessage);
},
[handleSend, isClawdMode]
);
const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => {
if (!firstImageUrl || hasSetCanvasTitle.current) {
return;
@ -12630,8 +12802,13 @@ export function CanvasChatPanel({
const textWithoutImages = stripCanvasModelRoutingLines(
stripMediaMarkup(rawText)
);
const analysisSection = extractCanvasImageAnalysisSection(textWithoutImages);
const plainText = normalizeCanvasAgentText(textWithoutImages);
const openClawActionsResult = isClawAssistantMessage
? extractOpenClawActionsFromMarkdown(textWithoutImages)
: { markdown: textWithoutImages, blocks: [] };
const textWithoutActions = openClawActionsResult.markdown;
const openClawActionBlocks = openClawActionsResult.blocks;
const analysisSection = extractCanvasImageAnalysisSection(textWithoutActions);
const plainText = normalizeCanvasAgentText(textWithoutActions);
const analysisToggleKey = `${message.id}-${i}-analysis`;
const analysisOpen = analysisCollapseState[analysisToggleKey] ?? true;
const analysisTypingKey = `${message.id}-${i}-analysis-typing`;
@ -12870,6 +13047,20 @@ export function CanvasChatPanel({
))}
</div>
)}
{openClawActionBlocks.length > 0 ? (
<OpenClawQuickActions
blocks={openClawActionBlocks}
disabled={
shouldAnimateAnalysis ||
shouldAnimatePlainText ||
isClawdSubmitting ||
getSendGuard(activeChatId).inFlight ||
status === "streaming" ||
status === "submitted"
}
onAction={handleOpenClawQuickAction}
/>
) : null}
</div>
);
}
@ -14832,7 +15023,7 @@ export function CanvasChatPanel({
? "bg-[#eef0f3] text-[#20242a] shadow-sm ring-1 ring-inset ring-[#d5dae2] hover:bg-[#e4e7ec] dark:bg-white dark:text-zinc-950 dark:ring-white/15 dark:hover:bg-zinc-100"
: "bg-[#f1f1f2] text-[#b6bac2] hover:bg-[#ececee] dark:bg-[#242529] dark:text-[#777d88] dark:hover:bg-[#2c2d33]"
)}
onClick={handleSend}
onClick={() => void handleSend()}
disabled={isSubmitting || !canSend}
title={
isWorkspacePresentation || isDetailGenerationMode