fix: restore canvas pending feedback and scoped capabilities

This commit is contained in:
134 2026-07-07 20:36:18 +08:00
parent f7676716bb
commit 654829c4fd
5 changed files with 93 additions and 41 deletions

View File

@ -257,6 +257,7 @@ import {
type AgentModelSlots,
} from "@/lib/ai/runtime/agent-model-slots";
import { isPureGreetingTurnRequest } from "@/lib/ai/runtime/task-boundary-classifier";
import { isCapabilityQuestionTurnRequest } from "@/lib/ai/runtime/task-boundary-text";
import {
getProviderHealthSnapshot,
recordProviderHealthFailure,
@ -846,6 +847,19 @@ const DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS = 12_000;
const CHAT_CREATION_LOOKUP_RETRY_DELAYS_MS = [60, 180, 360] as const;
const DETAIL_REPLACEMENT_PROMPT_PACK_REQUIRED_MESSAGE =
"请先在「风格化提示词配置」里选择提示词组合,再执行详情场景替换;缺少提示词组时系统不会处理。";
const CANVAS_AGENT_GREETING_MESSAGE =
"你好,我在。你可以直接告诉我想做什么:生图、修图、生成视频、分析图片都可以。";
const CANVAS_AGENT_CAPABILITY_MESSAGE = [
"我主要帮你完成画布里的视觉创作任务:",
"",
"- 生图:按文字、参考图或画布选中内容生成图片。",
"- 修图:换背景、去元素、局部重绘、扩图、增强、抠图、改尺寸。",
"- 视频:文生视频、图生视频、分镜/镜头视频需求整理与生成。",
"- 图片分析:识别主体、风格、构图、材质、光线,并整理可执行提示词。",
"- 画布协作:读取当前画布选择、槽位、最近结果,继续生成、修改或写回画布。",
"",
"你可以直接说:把这张图换成白底、参考左边图片生成 3 张主图、让这张图动起来。",
].join("\n");
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@ -1234,9 +1248,20 @@ export async function POST(request: Request) {
const hasCurrentMessageMediaInput = message.parts.some((part: any) =>
part?.type === "file" || part?.type === "image" || part?.type === "image_url"
);
const isGreetingTurnRequest = isPureGreetingTurnRequest(requestTextContent);
const isCapabilityQuestionRequest =
isCapabilityQuestionTurnRequest(requestTextContent);
const canvasFastPathMessage = isCapabilityQuestionRequest
? CANVAS_AGENT_CAPABILITY_MESSAGE
: isGreetingTurnRequest
? CANVAS_AGENT_GREETING_MESSAGE
: "";
if (
shouldRunAgentConversation &&
isPureGreetingTurnRequest(requestTextContent) &&
(shouldRunAgentConversation ||
isCanvasPageRequest ||
contextPayload?.source === "canvas" ||
Boolean(requestCanvasAgentContext)) &&
canvasFastPathMessage &&
!hasCurrentMessageMediaInput
) {
const session = await auth();
@ -1245,7 +1270,6 @@ export async function POST(request: Request) {
return new ChatSDKError("unauthorized:chat").toResponse();
}
const greetingMessage = "你好,我在。你可以直接告诉我想做什么:生图、修图、生成视频、分析图片都可以。";
const fastPathTitle = requestTextContent.trim().slice(0, 80) || "新对话";
await saveChat({
id,
@ -1266,7 +1290,12 @@ export async function POST(request: Request) {
const greetingStream = createUIMessageStream({
execute: async ({ writer }) => {
const messageId = generateUUID();
writeAssistantTextChunk(writer, greetingMessage, "greeting-fast-path", messageId);
writeAssistantTextChunk(
writer,
canvasFastPathMessage,
isCapabilityQuestionRequest ? "capability-fast-path" : "greeting-fast-path",
messageId
);
await saveMessages({
messages: [
@ -1287,7 +1316,7 @@ export async function POST(request: Request) {
id: messageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: greetingMessage }],
parts: [{ type: "text", text: canvasFastPathMessage }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
@ -8625,10 +8654,16 @@ export async function POST(request: Request) {
selectedChatModel: finalModelId,
requestHints,
});
const agentIdentityInstruction = shouldRunAgentConversation
const shouldUseProductIdentityInstruction =
shouldRunAgentConversation ||
isCanvasPageRequest ||
contextPayload?.source === "canvas" ||
Boolean(requestCanvasAgentContext);
const agentIdentityInstruction = shouldUseProductIdentityInstruction
? await buildAgentRoleIdentityInstruction({
allowVideoGeneration: allowAgentVideoGeneration,
hasCanvasContext: Boolean(
isCanvasPageRequest ||
effectiveContextPayload?.source === "canvas" ||
effectiveContextPayload?.selectedNodeIds?.length ||
effectiveContextPayload?.canvasImageNodes?.length,

View File

@ -1595,21 +1595,15 @@ function OpenClawQuickActions({
);
}
function CanvasAssistantTypingPlaceholder({ hint }: { hint?: string | null }) {
function CanvasAssistantTypingPlaceholder({
hint,
label = "分析中",
}: {
hint?: string | null;
label?: string;
}) {
return (
<div className="flex w-full justify-start">
<style>{`
@keyframes clawdTypingDotBounce {
0%, 80%, 100% {
transform: translateY(0) scale(0.96);
opacity: 0.62;
}
40% {
transform: translateY(-2px) scale(1);
opacity: 0.92;
}
}
`}</style>
<div className="flex max-w-[min(78%,680px)] flex-col items-start gap-2">
{hint ? (
<div className="rounded-[18px] bg-[#f4f5f7] px-4 py-3 text-[15px] leading-6 text-[#2f343d] shadow-sm dark:bg-white/8 dark:text-zinc-200">
@ -1617,19 +1611,11 @@ function CanvasAssistantTypingPlaceholder({ hint }: { hint?: string | null }) {
</div>
) : null}
<div
aria-label={`${CLAW_MODE_LABEL} 正在处理`}
className="inline-flex h-7 items-center gap-1 rounded-[14px] bg-[#f4f5f7] px-3 text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400"
aria-label={label}
className="inline-flex h-8 items-center gap-2 rounded-[16px] bg-[#f4f5f7] px-3.5 text-[15px] font-medium text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400"
>
{[0, 1, 2].map((index) => (
<span
key={index}
className="h-1.5 w-1.5 rounded-full bg-current"
style={{
animation: "clawdTypingDotBounce 0.9s ease-in-out infinite",
animationDelay: `${index * 140}ms`,
}}
/>
))}
<Loader2 className="h-4 w-4 animate-spin" />
<span>{label}</span>
</div>
</div>
</div>
@ -11910,9 +11896,11 @@ export function CanvasChatPanel({
);
const isLoading = status === 'streaming' || status === 'submitted';
const activeChatSendGuard = getSendGuard(activeChatId);
const isActiveChatPending = isLoading || activeChatSendGuard.inFlight;
const isComposerUploading = Boolean(composerUploadProgress);
const isSubmitting =
isLoading || isDetailGenerating || isClawdSubmitting || isComposerUploading;
isActiveChatPending || isDetailGenerating || isClawdSubmitting || isComposerUploading;
const hasInputOrAttachments = Boolean(
input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag)
);
@ -12287,15 +12275,23 @@ export function CanvasChatPanel({
const isDetailEmptyState =
isDetailGenerationMode &&
renderableVisibleMessages.length === 0 &&
!isLoading &&
!isActiveChatPending &&
!shouldShowImageLoading;
const isPlainCanvasEmptyState =
!isWorkspacePresentation &&
!isDetailGenerationMode &&
!isVideoWorkspace &&
renderableVisibleMessages.length === 0 &&
!isLoading &&
!isActiveChatPending &&
!shouldShowImageLoading;
const latestRenderableMessage = renderableVisibleMessages.at(-1);
const shouldRenderAssistantTypingPlaceholder =
!isClawdMode &&
!isDetailGenerationMode &&
!shouldRenderImageLoading &&
!hasActiveTypewriter &&
isActiveChatPending &&
latestRenderableMessage?.role === "user";
const workspaceEmptyTitle =
isClawdMode
? `开始 ${CLAW_MODE_LABEL} 对话`
@ -13490,9 +13486,7 @@ export function CanvasChatPanel({
shouldAnimateAnalysis ||
shouldAnimatePlainText ||
isClawdSubmitting ||
getSendGuard(activeChatId).inFlight ||
status === "streaming" ||
status === "submitted"
isActiveChatPending
}
onAction={handleOpenClawQuickAction}
/>
@ -13564,9 +13558,13 @@ export function CanvasChatPanel({
estimatedWaitMs={loadingEstimatedWaitMs}
/>
)}
{shouldRenderAssistantTypingPlaceholder ? (
<CanvasAssistantTypingPlaceholder label="分析中" />
) : null}
{isClawdMode && isClawdSubmitting ? (
<CanvasAssistantTypingPlaceholder hint={clawdProcessingHint} />
<CanvasAssistantTypingPlaceholder hint={clawdProcessingHint} label="分析中" />
) : null}
<div ref={messagesEndRef} />

View File

@ -5,6 +5,7 @@ import type {
PendingGridConfirmation,
} from "@/lib/ai/conversation-action-resolver";
import { isExplicitPromptOptimizationRequest } from "@/lib/ai/smart-analyzer";
import { isCapabilityQuestionTurnRequest } from "@/lib/ai/runtime/task-boundary-text";
import { detectStoryboardCommandIntent } from "@/lib/video-agent/storyboard-command-interpreter";
export type TaskBoundaryType =
@ -88,9 +89,7 @@ function isLikelyChatOnlyRequest(text: string): boolean {
const normalized = normalizeCompactText(text);
if (!normalized) return false;
return /你能做什么|你会什么|你可以做什么|你能帮我做什么|你能干什么|你可以帮我做什么|介绍一下你自己|介绍一下自己|说说你的能力|你有哪些功能|你有什么功能|怎么用|怎么玩|如何使用|帮助说明|帮助手册|功能介绍/.test(
normalized,
);
return isCapabilityQuestionTurnRequest(normalized);
}
function isLikelyAnalysisOnlyRequest(text: string, hasImageContext: boolean): boolean {

View File

@ -0,0 +1,12 @@
export function normalizeCompactTaskBoundaryText(text: string) {
return String(text || "").toLowerCase().replace(/\s+/g, "").trim();
}
export function isCapabilityQuestionTurnRequest(text: string): boolean {
const normalized = normalizeCompactTaskBoundaryText(text);
if (!normalized) return false;
return /你.*(?:能|会|可以|可|嫩)?.*(?:为我做什么|帮我做什么|帮我什么|做什么|干什么)|你.*(?:有哪些|有什么).*(?:功能|能力)|(?:介绍|说说).*(?:功能|能力|你自己|自己)|怎么用|怎么玩|如何使用|帮助说明|帮助手册|功能介绍/.test(
normalized,
);
}

View File

@ -56,6 +56,7 @@ import {
buildRecentDialogueMemory,
isRecentDialogueRecallRequest,
} from "@/lib/ai/runtime/recent-dialogue-memory";
import { isCapabilityQuestionTurnRequest } from "@/lib/ai/runtime/task-boundary-text";
import {
isAgentModeScopedChatId,
isScopedEmptyHistoryRequest,
@ -326,6 +327,13 @@ test.describe("canvas chat pure helpers", () => {
expect(upstream).toContain("| SKU | 销量 |");
});
test("recognizes canvas product capability questions with typo tolerance", () => {
expect(isCapabilityQuestionTurnRequest("你能为我做什么?")).toBe(true);
expect(isCapabilityQuestionTurnRequest("你嫩为我做什么?")).toBe(true);
expect(isCapabilityQuestionTurnRequest("你有哪些功能")).toBe(true);
expect(isCapabilityQuestionTurnRequest("帮我把这张图换成白底")).toBe(false);
});
test("builds bounded recent dialogue memory for user recall requests", () => {
const memory = buildRecentDialogueMemory({
messages: [