feat: support claw quick reply actions
This commit is contained in:
parent
e7f3df1f5d
commit
69f2f7957d
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user