Merge remote-tracking branch 'origin/main' into codex/scale-adjust-main-merge

This commit is contained in:
root 2026-07-07 22:28:09 +08:00
commit 6b25171ce1
22 changed files with 2012 additions and 290 deletions

View File

@ -250,18 +250,21 @@ import {
pruneTaskBoundaryContext,
pruneTaskBoundaryHistoryMessages,
} from "@/lib/ai/runtime/task-boundary-context-pruner";
import { buildRecentDialogueMemory } from "@/lib/ai/runtime/recent-dialogue-memory";
import {
resolveAgentModelSlotDescriptor,
upsertAgentModelSlot,
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,
recordProviderHealthSuccess,
shouldAvoidProvider,
} from "@/lib/ai/runtime/provider-health";
import { resolveCanvasAgentRequestPolicy } from "@/lib/ai/runtime/canvas-agent-kernel";
import { arbitrateAgentIntent } from "@/lib/ai/runtime/intent-arbiter";
import { buildAgentRoleIdentityInstruction } from "@/lib/ai/runtime/agent-role-profile";
import { getVideoStoryboardConfig } from "@/lib/video-agent/config";
@ -541,6 +544,65 @@ function writeCanvasOperationShadowChunk(params: {
);
}
const AGENT_EXECUTION_PREFACE_INTERNAL_PATTERN =
/(?:^|\n)\s*(?:#{1,6}\s*)?(?:需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)(?:\s|[::]|$)|【\s*(?:需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)\s*】|提示词|自动优化|模型|提交生成|回写|流程|步骤/;
function buildAgentExecutionPrefaceText(params: {
friendlyMessage?: string | null;
fallback: string;
}) {
const fallback = String(params.fallback || "").replace(/\s+/g, " ").trim();
const rawMessage = String(params.friendlyMessage || "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
if (!rawMessage || AGENT_EXECUTION_PREFACE_INTERNAL_PATTERN.test(rawMessage)) {
return fallback;
}
const candidate = rawMessage
.split(/\r?\n/)
.map((line) => line.replace(/^[-*]\s*/, "").trim())
.find(Boolean);
if (!candidate || AGENT_EXECUTION_PREFACE_INTERNAL_PATTERN.test(candidate)) {
return fallback;
}
const firstSentence = candidate.match(/^(.{1,80}?[。!?!?~~])/u)?.[1] || candidate;
const compact = firstSentence.replace(/\s+/g, " ").trim().slice(0, 80);
return /[。!?!?~~]$/.test(compact) ? compact : `${compact}。`;
}
async function writeAgentExecutionPrefaceMessage(params: {
writer: StreamWriter;
chatId: string;
label: string;
friendlyMessage?: string | null;
fallback: string;
}) {
const text = buildAgentExecutionPrefaceText({
friendlyMessage: params.friendlyMessage,
fallback: params.fallback,
});
if (!text) return null;
const messageId = generateUUID();
writeAssistantTextChunk(params.writer, text, params.label, messageId);
try {
await saveAssistantTextOnlyMessage({
chatId: params.chatId,
messageId,
text,
});
} catch (error) {
chatApiLogger.warn("[Chat API] Failed to persist agent execution preface:", error);
}
return messageId;
}
async function replayAssistantMessagesForDuplicateRequest({
chatId,
userMessageId,
@ -785,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));
@ -1025,6 +1100,10 @@ export async function POST(request: Request) {
let confirmedPromptForConversationState: string | undefined;
let smartAutoHistoryMessagesFromDb: DBMessage[] = [];
let smartAutoMediaHistoryMessagesFromDb: DBMessage[] = [];
let recentDialogueMemory = buildRecentDialogueMemory({
messages: [],
currentText: requestTextContent,
});
let preferredAgentModelId: string | undefined;
let configuredAgentConversationModelId: string | undefined;
let agentModelSlots: AgentModelSlots = {};
@ -1036,8 +1115,19 @@ export async function POST(request: Request) {
const shouldRespectDirectComposerMode =
composerMode === "image" || composerMode === "video" || composerMode === "detail";
const shouldIsolateDirectComposerContext = shouldRespectDirectComposerMode;
let shouldRunAgentConversation = finalModelId === "auto";
const requestPolicy = resolveCanvasAgentRequestPolicy({
composerMode,
agentEntryMode: agentPresetId ? "preset" : "auto",
selectedChatModel: finalModelId,
hasCanvasControlIntentFastPath,
});
finalModelId = requestPolicy.effectiveSelectedChatModel;
let shouldRunAgentConversation =
finalModelId === "auto" || requestPolicy.shouldForceToolOrchestration;
let shouldKeepSelectedModel = false;
if (requestPolicy.reason === "agent_auto_composer") {
chatApiLogger.info("[Smart Auto Mode] 🧭 Agent composer forces tool orchestration, ignoring stale selected model");
}
if (shouldRespectDirectComposerMode && shouldRunAgentConversation) {
shouldRunAgentConversation = false;
chatApiLogger.info(`[Smart Auto Mode] ⛳ Direct composer mode (${composerMode}) disables initial auto-agent routing`);
@ -1158,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();
@ -1169,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,
@ -1190,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: [
@ -1211,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,
@ -1418,6 +1523,17 @@ export async function POST(request: Request) {
taskType: taskBoundaryDecision.taskType,
taskMode: taskBoundaryDecision.taskMode,
});
recentDialogueMemory = buildRecentDialogueMemory({
messages: loadedHistoryMessages,
currentText: smartAutoInputText,
maxEntries: taskBoundaryDecision.taskType === "chat" ? 6 : 4,
maxTotalChars: taskBoundaryDecision.taskType === "chat" ? 1000 : 700,
});
chatApiLogger.info("[RecentDialogueMemory] Smart auto recall:", {
entries: recentDialogueMemory.entries.length,
isRecallRequest: recentDialogueMemory.isRecallRequest,
taskType: taskBoundaryDecision.taskType,
});
autoTextForFallback = smartAutoInputText;
if (!requestWorkingState && !shouldIsolateDirectComposerContext) {
@ -1492,23 +1608,12 @@ export async function POST(request: Request) {
resolutionConfidence: requestTargetResolution.confidence,
});
// 🆕 从历史消息中提取对话文本(用于上下文理解)
const historyMessages: { role: "user" | "assistant"; content: string }[] = [];
for (const msg of smartAutoHistoryMessagesFromDb.slice(-10)) { // 最多取最近10条
const parts = msg.parts as any[];
if (parts && parts.length > 0) {
// 提取文本内容
const textParts = parts.filter((p: any) => p.type === 'text');
const content = textParts.map((p: any) => p.text || '').join(' ').trim();
if (content) {
historyMessages.push({
role: msg.role as "user" | "assistant",
content: content.substring(0, 300), // 限制长度
});
}
}
}
chatApiLogger.info("[Smart Auto Mode] History context:", historyMessages.length, "messages");
const historyMessages: { role: "user" | "assistant"; content: string }[] =
recentDialogueMemory.entries.map((entry) => ({
role: entry.role,
content: entry.content,
}));
chatApiLogger.info("[Smart Auto Mode] History context:", historyMessages.length, "memory entries");
// 2. 使用 LangGraph 驱动的自动模式工作流
const { runAutoAgentGraph } = await import("@/lib/ai/auto-agent-graph");
@ -1541,8 +1646,15 @@ export async function POST(request: Request) {
workflowState: composedConversationState.threadState.workflowState,
memoryHints: composedConversationState.threadState.memoryHints,
contextSummary: composedConversationState.threadState.contextSummary,
recentDialogueMemory: recentDialogueMemory.contextSummary,
},
});
const agentGraphContext = [
composedConversationState.composedConversationMemory.contextSummary,
recentDialogueMemory.contextSummary,
]
.filter(Boolean)
.join("\n");
const autoGraphResult = await withOperationTimeout(
runAutoAgentGraph({
text: smartAutoInputText,
@ -1570,10 +1682,7 @@ export async function POST(request: Request) {
requestResolvedMediaInput.historyUserImageUrls.length,
currentUploadImageCount: requestResolvedMediaInput.currentMessageImageUrls.length,
historyMessages,
context:
composedConversationState.composedConversationMemory.contextSummary ||
effectiveAutoContextText ||
undefined,
context: agentGraphContext || effectiveAutoContextText || undefined,
preferredModelId: preferredAgentModelId,
threadState: composedConversationState.threadState,
}),
@ -2676,6 +2785,21 @@ export async function POST(request: Request) {
purpose: "media",
hasCurrentImageInput: hasImageInputForConversationAction,
});
recentDialogueMemory = shouldIsolateDirectComposerContext
? recentDialogueMemory
: buildRecentDialogueMemory({
messages: loadedMessagesForBoundary,
currentText: requestTextContent,
maxEntries: taskBoundaryDecision.taskType === "chat" ? 6 : 4,
maxTotalChars: taskBoundaryDecision.taskType === "chat" ? 1000 : 700,
});
if (recentDialogueMemory.entries.length > 0) {
chatApiLogger.info("[RecentDialogueMemory] Request recall:", {
entries: recentDialogueMemory.entries.length,
isRecallRequest: recentDialogueMemory.isRecallRequest,
taskType: taskBoundaryDecision.taskType,
});
}
if (!requestWorkingState && !shouldIsolateDirectComposerContext) {
requestWorkingState = await getConversationWorkingStateByChatId({
@ -7715,6 +7839,16 @@ export async function POST(request: Request) {
) {
const directImageStream = createUIMessageStream({
execute: async ({ writer }) => {
await writeAgentExecutionPrefaceMessage({
writer,
chatId: id,
label: "agent:generate_image:preface",
friendlyMessage: smartAutoFriendlyMessage,
fallback:
directAgentToolTargetImageUrls.length > 0
? "收到,我会参考你选的图片来生成新画面,稍等一下。"
: "好的,我按你的描述来做一版画面,稍等我处理一下。",
});
writeAgentProgressChunk(
writer,
buildDirectAgentImageContextProgress({
@ -7762,6 +7896,32 @@ export async function POST(request: Request) {
operationLabel: "agent-generate-image",
resultType: "image",
});
const directImageStatusPayload = {
providerType: requestedModelProviderType,
modelName: finalModelId,
imageCount:
effectiveImageParams?.imageCount ||
defaultImageQualityParams.imageCount ||
1,
};
const stopDirectImageKeepalive = startStreamKeepalive(
writer,
"agent:generate_image",
DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS,
);
const stopDirectImageStatus =
contextPayload?.source === "canvas"
? setInterval(() => {
writeImageGenerationStatusChunk(
writer,
{
phase: "processing",
...directImageStatusPayload,
},
"agent:generate_image:status-processing-keepalive",
);
}, DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS)
: null;
try {
const directImageResult = await executeDirectAgentImageGeneration({
requestTextContent,
@ -7903,6 +8063,11 @@ export async function POST(request: Request) {
"<!-- IMAGE_GENERATION_END -->",
"agent:generate_image:end-error",
);
} finally {
stopDirectImageKeepalive();
if (stopDirectImageStatus) {
clearInterval(stopDirectImageStatus);
}
}
},
});
@ -7917,6 +8082,16 @@ export async function POST(request: Request) {
) {
const directVideoStream = createUIMessageStream({
execute: async ({ writer }) => {
await writeAgentExecutionPrefaceMessage({
writer,
chatId: id,
label: "agent:generate_video:preface",
friendlyMessage: smartAutoFriendlyMessage,
fallback:
directAgentToolTargetImageUrls.length > 0
? "收到,我会参考你选的画面来生成视频,稍等一下。"
: "好的,我按你的需求来做这段视频,稍等我处理一下。",
});
const assistantMessageId = generateUUID();
writeAgentProgressChunk(
writer,
@ -8123,6 +8298,13 @@ export async function POST(request: Request) {
const directToolLabel = getDirectAgentToolLabel(directAgentToolCapability);
const directToolStream = createUIMessageStream({
execute: async ({ writer }) => {
await writeAgentExecutionPrefaceMessage({
writer,
chatId: id,
label: `agent:${directAgentToolCapability}:preface`,
friendlyMessage: smartAutoFriendlyMessage,
fallback: `好的,我来帮你${directToolLabel},稍等我处理一下。`,
});
writeAgentProgressChunk(
writer,
buildDirectAgentImageContextProgress({
@ -8493,16 +8675,39 @@ export async function POST(request: Request) {
resolvedRuntimeModelLookupId = actualModelId;
}
const effectiveConversationSystemPrompt = shouldRunAgentConversation
? `${await buildAgentRoleIdentityInstruction({
const recentDialogueSystemContext = recentDialogueMemory.contextSummary
? [
recentDialogueMemory.contextSummary,
...recentDialogueMemory.memoryHints,
].join("\n")
: "";
const baseConversationSystemPrompt = systemPrompt({
selectedChatModel: finalModelId,
requestHints,
});
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,
),
})}\n\n${systemPrompt({ selectedChatModel: finalModelId, requestHints })}`
: systemPrompt({ selectedChatModel: finalModelId, requestHints });
})
: "";
const effectiveConversationSystemPrompt = [
agentIdentityInstruction,
recentDialogueSystemContext,
baseConversationSystemPrompt,
]
.filter(Boolean)
.join("\n\n");
const activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
resolvedRuntimeModelLookupId,

View File

@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getClawdChatsByUserId } from "@/lib/db/queries";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function GET(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const limit = Math.max(
1,
Math.min(100, Math.round(Number(url.searchParams.get("limit") || 50)))
);
const chats = await getClawdChatsByUserId({
userId: session.user.id,
limit,
});
return NextResponse.json(
{
chats: chats.map((chat) => ({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt?.toISOString?.() || chat.createdAt,
updatedAt: chat.updatedAt?.toISOString?.() || chat.updatedAt,
messageCount: Number(chat.messageCount || 0),
})),
},
{
headers: {
"Cache-Control": "no-store, max-age=0",
},
}
);
}

View File

@ -3,6 +3,7 @@
import {
ChevronDown,
Clock3,
Download,
History as HistoryIcon,
ImageIcon,
Loader2,
@ -45,6 +46,7 @@ import {
type AiWashControlLevel,
type AiWashPreset,
} from "@/lib/ai-wash/config";
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils";
import { imageGenerationService } from "@/services/canvas";
@ -368,6 +370,11 @@ function getAiWashStatusLabel(status?: AiWashResultStatus) {
}
}
function buildAiWashDownloadName(result: AiWashResultCard) {
const createdAt = result.createdAt || Date.now();
return `ai-wash-${result.title || result.presetId || "result"}-${createdAt}`;
}
function composePresetPrompt(params: {
preset: AiWashPreset;
selectedLevelIndexByControl: Record<string, number>;
@ -456,6 +463,9 @@ export function AiWashWorkspace({
useState<Record<string, number>>({});
const [historySourceOverride, setHistorySourceOverride] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [downloadingResultId, setDownloadingResultId] = useState<string | null>(
null
);
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false);
const [lastErrorMessage, setLastErrorMessage] = useState("");
const [comparePositionById, setComparePositionById] = useState<
@ -977,6 +987,30 @@ export function AiWashWorkspace({
});
}, [compareSourceUrl, onApplyResult, selectedResult]);
const handleDownloadSelectedResult = useCallback(async () => {
if (!selectedResult?.imageUrl) {
toast.error("请先选择一张洗图结果");
return;
}
const downloadId = selectedResult.presetId || selectedResult.imageUrl;
setDownloadingResultId(downloadId);
try {
await downloadCanvasImageAsPng({
url: selectedResult.imageUrl,
name: buildAiWashDownloadName(selectedResult),
candidates: [selectedResult.imageUrl],
});
toast.success("图片已开始下载");
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
} finally {
setDownloadingResultId((current) =>
current === downloadId ? null : current
);
}
}, [selectedResult]);
const handleLoadHistoryBatch = useCallback((entry: AiWashHistoryBatch) => {
const loadedPresetId =
(entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || "";
@ -1430,6 +1464,25 @@ export function AiWashWorkspace({
</div>
</div>
{selectedResult?.imageUrl ? (
<button
className="absolute bottom-2 right-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/92 px-4 text-xs font-medium text-[#333333] shadow-[0_10px_24px_rgba(0,0,0,0.07)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111] disabled:cursor-not-allowed disabled:opacity-55"
disabled={Boolean(downloadingResultId)}
onClick={() => {
void handleDownloadSelectedResult();
}}
type="button"
>
{downloadingResultId ===
(selectedResult.presetId || selectedResult.imageUrl) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
下载
</button>
) : null}
<button
className="absolute bottom-2 left-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/90 px-4 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111]"
onClick={() => setIsHistoryExpanded((current) => !current)}

File diff suppressed because it is too large Load Diff

View File

@ -206,14 +206,26 @@ export type AgentProgressStreamPayload = {
capability?: string;
};
export type AgentProgressStepState =
| "upcoming"
| "current"
| "completed"
| "failed";
export type AgentProgressNodeStep = {
key: NonNullable<AgentProgressStreamPayload["currentStep"]>;
label: string;
state: AgentProgressStepState;
};
export const AGENT_PROGRESS_STEPS: Array<{
key: NonNullable<AgentProgressStreamPayload["currentStep"]>;
label: string;
}> = [
{ key: "lock_target", label: "整理素材" },
{ key: "analyze", label: "理解需求" },
{ key: "select_tool", label: "优化提示" },
{ key: "execute", label: "提交生成" },
{ key: "lock_target", label: "理解用户需求" },
{ key: "analyze", label: "分析使用工具" },
{ key: "select_tool", label: "确认执行工具" },
{ key: "execute", label: "执行中" },
{ key: "finalize", label: "整理结果" },
];
@ -260,3 +272,94 @@ export function getAgentProgressFallbackTitle(payload?: AgentProgressStreamPaylo
return "正在理解需求";
}
}
export function getAgentProgressActionLabel(
payload?: AgentProgressStreamPayload | null
) {
switch (payload?.capability) {
case "analyze":
return "图片分析";
case "generic_edit":
return "修图";
case "remove_background":
return "抠图";
case "remove_watermark":
return "去水印";
case "enhance":
return "图像增强";
case "resize":
return "尺寸调整";
case "extend":
return "扩图";
case "angle_variant":
return "多视角";
case "local_edit":
return "局部编辑";
case "box_cutout":
return "框选抠图";
case "generate_image":
return "生图";
case "generate_video":
return "视频生成";
default:
return "工具";
}
}
export function getAgentProgressStepLabel(
stepKey: NonNullable<AgentProgressStreamPayload["currentStep"]>,
payload?: AgentProgressStreamPayload | null
) {
const actionLabel = getAgentProgressActionLabel(payload);
switch (stepKey) {
case "lock_target":
return "理解用户需求";
case "analyze":
return payload?.capability === "analyze" ? "分析图片" : "分析使用工具";
case "select_tool":
return actionLabel === "工具" ? "确认执行工具" : `确认${actionLabel}工具`;
case "execute":
return actionLabel === "工具" ? "执行中" : `${actionLabel}中`;
case "finalize":
return "整理结果";
default:
return "处理中";
}
}
export function buildAgentProgressNodeSteps(
payload?: AgentProgressStreamPayload | null
): AgentProgressNodeStep[] {
const currentStep = payload?.currentStep || "lock_target";
const currentStepIndex = Math.max(
0,
AGENT_PROGRESS_STEPS.findIndex((step) => step.key === currentStep)
);
return AGENT_PROGRESS_STEPS.map((step, index) => {
let state: AgentProgressStepState = "upcoming";
if (payload?.stage === "failed") {
state =
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "failed"
: "upcoming";
} else if (payload?.stage === "completed") {
state = "completed";
} else {
state =
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "upcoming";
}
return {
key: step.key,
label: getAgentProgressStepLabel(step.key, payload),
state,
};
});
}

View File

@ -381,11 +381,15 @@ export function sortCanvasMessagesByCreatedAt(messages: ChatMessage[]): ChatMess
.sort((a, b) => {
const messageA = a.message;
const messageB = b.message;
const timeA = getMessageTimestamp(messageA) ?? 0;
const timeB = getMessageTimestamp(messageB) ?? 0;
if (timeA !== timeB) return timeA - timeB;
const timeA = getMessageTimestamp(messageA);
const timeB = getMessageTimestamp(messageB);
const hasTimeA = timeA !== null;
const hasTimeB = timeB !== null;
if (messageA.role !== messageB.role) {
if (hasTimeA && hasTimeB && timeA !== timeB) return timeA - timeB;
if (hasTimeA !== hasTimeB) return hasTimeA ? -1 : 1;
if (hasTimeA && hasTimeB && messageA.role !== messageB.role) {
if (messageA.role === "user") return -1;
if (messageB.role === "user") return 1;
}

View File

@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Clock3, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
import { ArrowLeft, Clock3, Download, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
import { signOut, useSession } from "next-auth/react";
import {
type CSSProperties,
@ -29,6 +29,7 @@ import {
SelectTrigger,
} from "@/components/ui/select";
import { imageGenerationService } from "@/services/canvas";
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download";
import { cn, fetcher } from "@/lib/utils";
import type {
PublicStyleProCategory,
@ -457,6 +458,15 @@ function formatStyleProHistoryDateTime(value?: string | null) {
return `${dateText} ${timeText}`;
}
function buildStyleProDownloadName(item: StyleProHistoryItem) {
const mode =
item.generationMode === "realistic" ? "style-pro-realistic" : "style-pro";
const createdAt = item.createdAt
? item.createdAt.replace(/[T:.]/g, "-").slice(0, 19)
: String(Date.now());
return `${mode}-${createdAt}`;
}
function formatStyleProEstimatedWait(estimatedWaitMs?: number | null) {
const numeric = Number(estimatedWaitMs || 0);
if (!Number.isFinite(numeric) || numeric <= 0) {
@ -817,10 +827,16 @@ function GeneratingImagePlaceholder({
function BeforeAfterSlider({
afterUrl,
beforeUrl,
downloadDisabled,
downloading,
onDownload,
taskState,
}: {
afterUrl?: string | null;
beforeUrl?: string | null;
downloadDisabled?: boolean;
downloading?: boolean;
onDownload?: () => void;
taskState?: StyleProTaskState | null;
}) {
const showTaskState =
@ -946,6 +962,24 @@ function BeforeAfterSlider({
<div className="absolute right-4 top-4 rounded-full bg-emerald-500/90 px-3 py-1 text-xs font-semibold text-black">
结果
</div>
{afterUrl ? (
<button
className="absolute bottom-4 right-4 z-20 inline-flex h-11 items-center gap-2 rounded-full border border-white/25 bg-black/72 px-4 text-[13px] font-semibold text-white shadow-[0_14px_32px_rgba(0,0,0,0.24)] backdrop-blur transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-55"
disabled={downloadDisabled || downloading}
onClick={(event) => {
event.stopPropagation();
onDownload?.();
}}
type="button"
>
{downloading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
下载
</button>
) : null}
</div>
);
}
@ -1021,6 +1055,9 @@ export function StyleProWorkspace({
useState<StyleProGenerationMode>("style");
const [selectedModelId, setSelectedModelId] = useState("");
const [uploading, setUploading] = useState(false);
const [downloadingImageId, setDownloadingImageId] = useState<string | null>(
null
);
const [generationTaskState, setGenerationTaskState] =
useState<StyleProTaskState | null>(null);
const generating =
@ -1458,6 +1495,34 @@ export function StyleProWorkspace({
[config]
);
const handleDownloadResult = useCallback(
async (item?: StyleProHistoryItem | null) => {
const target = item || currentResult;
if (!target?.resultImageUrl) {
toast.error("暂无可下载的结果图");
return;
}
const downloadId = target.id || target.resultImageUrl;
setDownloadingImageId(downloadId);
try {
await downloadCanvasImageAsPng({
url: target.resultImageUrl,
name: buildStyleProDownloadName(target),
candidates: [target.resultImageUrl],
});
toast.success("图片已开始下载");
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
} finally {
setDownloadingImageId((current) =>
current === downloadId ? null : current
);
}
},
[currentResult]
);
const disabledReason = !sourceImageUrl
? "请先上传图片"
: generationMode === "realistic" && config?.realisticModeAvailable === false
@ -1875,6 +1940,15 @@ export function StyleProWorkspace({
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
downloadDisabled={!currentResult?.resultImageUrl}
downloading={Boolean(
currentResult &&
downloadingImageId ===
(currentResult.id || currentResult.resultImageUrl)
)}
onDownload={() => {
void handleDownloadResult(currentResult);
}}
taskState={generationTaskState}
/>
</div>

View File

@ -308,6 +308,7 @@ export function resolveChatMediaInput(
resolvedImageRefs,
currentMessageImageUrls: currentMessageImageRefs.map((ref) => ref.url),
selectedImageUrls: selectedImageRefs.map((ref) => ref.url),
canvasImageUrls: canvasImageRefs.map((ref) => ref.url),
historyUserImageUrls: history.latestUser.map((ref) => ref.url),
historyAssistantImageUrls: history.latestAssistant.map((ref) => ref.url),
resolvedImageUrls: resolvedImageRefs.map((ref) => ref.url),

View File

@ -5,16 +5,17 @@ import type {
ResolvedImageRef,
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
import {
hasCanvasAgentActionableImageEditIntent,
hasCanvasAgentDeicticTarget,
resolveCanvasAgentTargetPreference,
} from "@/lib/ai/runtime/canvas-agent-kernel";
const ORIGINAL_IMAGE_REGEX =
/(原图|上传图|参考图|最初那张|第一张图|素材图|源图|原始图片|原始那张|我发的那张|上传的那张|原来的图)/i;
const LATEST_RESULT_REGEX =
/(上一张|上一轮|刚生成|最新生成|这个结果|该结果|按这个结果|继续在这张|基于这张结果|延续上一次结果|上一版|刚才那张结果|生成的图|生成图|结果图|出图结果|刚出的图|上一张生成图|上一轮结果图|现在这张生成图)/i;
const DEICTIC_TARGET_REGEX =
/(这张图|这只|这个|这款|这套|当前这张|当前这个|当前这只|这一张|这一只|这个产品|这个物体|这只猫|这只狗)/i;
const SELECTION_REGEX = /(选中的|当前这张|我选的|这几张|这些图|选中图)/i;
const ACTIONABLE_EDIT_REGEX =
/(改|调整|优化|换|替换|去掉|抠|重绘|增强|修|处理|按照|按|保留|统一|色差|背景|清晰|风格|亮一点|暗一点|白底|透明底)/i;
const ACTIONABLE_GENERATION_REGEX =
/(生图|生成|画一张|出图|来一张|再来一张|做一张|做图|做视频|视频生成|生成视频|动起来)/i;
const IMPLICIT_HISTORY_TARGET_REGEX =
@ -255,6 +256,7 @@ export function resolveTargetAssets(
const focusPolicy = params.contextPayload?.focusPolicy || "auto";
const workingStateGeneratedUrls = normalizeStringArray(workingState?.lastGeneratedImageUrls);
const workingStateUploadedUrls = normalizeStringArray(workingState?.lastUploadedImageUrls);
const workingStateResolvedUrls = normalizeStringArray(workingState?.lastResolvedTargetImageUrls);
const generatedRefs =
workingStateGeneratedUrls.length > 0 ? toWorkingMemoryRefs(workingStateGeneratedUrls) : assistantRefs;
const originalRefs =
@ -384,7 +386,39 @@ export function resolveTargetAssets(
});
}
if (DEICTIC_TARGET_REGEX.test(text)) {
const canvasAgentTargetPreference = resolveCanvasAgentTargetPreference({
text,
selectedImageCount: selectedRefs.length,
latestResultImageCount: generatedRefs.length,
workingMemoryTargetImageCount: workingStateResolvedUrls.length,
});
if (canvasAgentTargetPreference.shouldPreferLatestResult) {
return buildResolvedTargetAssets({
targetRefs: generatedRefs,
candidateRefs,
resolutionMode:
workingStateGeneratedUrls.length > 0 ? "working_memory" : "latest_result",
confidence: canvasAgentTargetPreference.confidence,
needsClarification: false,
reason: "用户使用指代表达并提出图片编辑意图,默认续接最近生成结果",
fromHistory: true,
});
}
if (canvasAgentTargetPreference.shouldPreferWorkingMemoryTarget) {
const refs = toWorkingMemoryRefs(workingStateResolvedUrls);
return buildResolvedTargetAssets({
targetRefs: refs,
candidateRefs,
resolutionMode: "working_memory",
confidence: canvasAgentTargetPreference.confidence,
needsClarification: false,
reason: "用户使用指代表达并提出图片编辑意图,使用工作记忆中的上一轮目标图",
fromHistory: true,
});
}
if (hasCanvasAgentDeicticTarget(text)) {
if (selectedRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: selectedRefs,
@ -421,7 +455,7 @@ export function resolveTargetAssets(
}
if (
ACTIONABLE_EDIT_REGEX.test(text) &&
hasCanvasAgentActionableImageEditIntent(text) &&
currentRefs.length === 0 &&
generatedRefs.length > 0 &&
selectedLooksLikeOriginal &&
@ -463,14 +497,28 @@ export function resolveTargetAssets(
});
}
const workingStateResolvedUrls = normalizeStringArray(workingState?.lastResolvedTargetImageUrls);
if (
canvasRefs.length === 1 &&
(hasCanvasAgentActionableImageEditIntent(text) || focusPolicy === "auto")
) {
return buildResolvedTargetAssets({
targetRefs: [canvasRefs[0]],
candidateRefs,
resolutionMode: "text_reference",
confidence: 84,
needsClarification: false,
reason: "用户使用画布/图片指代表达,且当前画布仅有一张可处理图片",
fromHistory: true,
});
}
const allowsImplicitHistoryTarget =
IMPLICIT_HISTORY_TARGET_REGEX.test(text) &&
(ACTIONABLE_EDIT_REGEX.test(text) ||
(hasCanvasAgentActionableImageEditIntent(text) ||
ACTIONABLE_GENERATION_REGEX.test(text) ||
LATEST_RESULT_REGEX.test(text) ||
ORIGINAL_IMAGE_REGEX.test(text) ||
DEICTIC_TARGET_REGEX.test(text) ||
hasCanvasAgentDeicticTarget(text) ||
SELECTION_REGEX.test(text));
if (allowsImplicitHistoryTarget) {
@ -525,7 +573,19 @@ export function resolveTargetAssets(
}
}
if (ACTIONABLE_EDIT_REGEX.test(text)) {
if (hasCanvasAgentActionableImageEditIntent(text)) {
if (canvasRefs.length === 1) {
return buildResolvedTargetAssets({
targetRefs: [canvasRefs[0]],
candidateRefs,
resolutionMode: "text_reference",
confidence: 82,
needsClarification: false,
reason: "用户表达了图片编辑意图,且当前画布仅有一张可处理图片",
fromHistory: true,
});
}
return buildResolvedTargetAssets({
targetRefs: [],
candidateRefs,

View File

@ -57,6 +57,7 @@ export interface ResolvedChatMediaInput {
resolvedImageRefs: ResolvedImageRef[];
currentMessageImageUrls: string[];
selectedImageUrls: string[];
canvasImageUrls: string[];
historyUserImageUrls: string[];
historyAssistantImageUrls: string[];
resolvedImageUrls: string[];

View File

@ -0,0 +1,156 @@
import type { DirectAgentToolCapability } from "@/lib/ai/runtime/direct-agent-execution";
export type CanvasAgentComposerMode = "agent" | "image" | "video" | "detail" | "clawd";
export type CanvasAgentEntryMode = "auto" | "preset";
export interface CanvasAgentRequestPolicy {
effectiveSelectedChatModel: string;
shouldForceToolOrchestration: boolean;
reason: string | null;
}
export interface CanvasAgentTargetPreference {
shouldPreferLatestResult: boolean;
shouldPreferWorkingMemoryTarget: boolean;
confidence: number;
reason: string | null;
}
const DEICTIC_TARGET_REGEX =
/(这张图|这只|这个|这款|这套|当前这张|当前这个|当前这只|这一张|这一只|这个产品|这个物体|这只猫|这只狗)/i;
const ACTIONABLE_IMAGE_EDIT_REGEX =
/(抠图|去背景|透明背景|去背|扣图|抠出|扣出|抠出来|扣出来|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|调色|调整颜色|亮度|对比度|饱和度|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调|处理)/i;
export function normalizeCanvasAgentRequestText(text: string) {
return String(text || "").toLowerCase().replace(/\s+/g, "").trim();
}
export function hasCanvasAgentDeicticTarget(text: string) {
return DEICTIC_TARGET_REGEX.test(String(text || ""));
}
export function hasCanvasAgentActionableImageEditIntent(text: string) {
return ACTIONABLE_IMAGE_EDIT_REGEX.test(String(text || ""));
}
export function inferCanvasAgentImageEditCapability(
text: string,
): DirectAgentToolCapability | null {
const normalizedText = normalizeCanvasAgentRequestText(text);
if (!normalizedText) return null;
if (/去水印|水印|去logo|移除logo|去除logo/.test(normalizedText)) {
return "remove_watermark";
}
if (/抠图|去背景|透明背景|去背|扣图|抠出|扣出|抠出来|扣出来|移除背景/.test(normalizedText)) {
return "remove_background";
}
if (/多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(normalizedText)) {
return "angle_variant";
}
if (/扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|比例|画幅|竖版|横版|竖图|横图|9:16|16:9|1:1/.test(normalizedText)) {
return "extend";
}
if (/调整尺寸|裁剪|resize|crop|缩放|放大|缩小/.test(normalizedText)) {
return "resize";
}
if (/局部|框选|选区|区域|涂抹|蒙版/.test(normalizedText)) {
return "local_edit";
}
if (/高清|清晰|锐化|降噪|提升质量|修复|清晰化/.test(normalizedText)) {
return "enhance";
}
if (hasCanvasAgentActionableImageEditIntent(text)) {
return "generic_edit";
}
return null;
}
export function resolveCanvasAgentRequestPolicy(params: {
composerMode?: string | null;
agentEntryMode?: string | null;
selectedChatModel?: string | null;
hasCanvasControlIntentFastPath?: boolean;
}): CanvasAgentRequestPolicy {
const composerMode = String(params.composerMode || "").trim();
const agentEntryMode = String(params.agentEntryMode || "").trim();
const selectedChatModel = String(params.selectedChatModel || "").trim() || "auto";
const isAgentAutoComposer = composerMode === "agent" && agentEntryMode !== "preset";
const shouldForceToolOrchestration =
isAgentAutoComposer || Boolean(params.hasCanvasControlIntentFastPath);
if (isAgentAutoComposer) {
return {
effectiveSelectedChatModel: "auto",
shouldForceToolOrchestration: true,
reason: "agent_auto_composer",
};
}
if (params.hasCanvasControlIntentFastPath) {
return {
effectiveSelectedChatModel: selectedChatModel,
shouldForceToolOrchestration: true,
reason: "canvas_control_fast_path",
};
}
return {
effectiveSelectedChatModel: selectedChatModel,
shouldForceToolOrchestration,
reason: null,
};
}
export function resolveCanvasAgentTargetPreference(params: {
text: string;
selectedImageCount: number;
latestResultImageCount: number;
workingMemoryTargetImageCount: number;
}): CanvasAgentTargetPreference {
const hasDeicticTarget = hasCanvasAgentDeicticTarget(params.text);
const hasEditIntent = hasCanvasAgentActionableImageEditIntent(params.text);
const selectedImageCount = Math.max(0, Math.round(params.selectedImageCount || 0));
const latestResultImageCount = Math.max(
0,
Math.round(params.latestResultImageCount || 0),
);
const workingMemoryTargetImageCount = Math.max(
0,
Math.round(params.workingMemoryTargetImageCount || 0),
);
if (!hasDeicticTarget || !hasEditIntent || selectedImageCount > 0) {
return {
shouldPreferLatestResult: false,
shouldPreferWorkingMemoryTarget: false,
confidence: 0,
reason: null,
};
}
if (latestResultImageCount > 0) {
return {
shouldPreferLatestResult: true,
shouldPreferWorkingMemoryTarget: false,
confidence: 90,
reason: "deictic_edit_prefers_latest_result",
};
}
if (workingMemoryTargetImageCount > 0) {
return {
shouldPreferLatestResult: false,
shouldPreferWorkingMemoryTarget: true,
confidence: 86,
reason: "deictic_edit_uses_working_memory_target",
};
}
return {
shouldPreferLatestResult: false,
shouldPreferWorkingMemoryTarget: false,
confidence: 0,
reason: null,
};
}

View File

@ -653,6 +653,7 @@ export function pickAgentSourceImageUrls(params: {
params.requestTargetResolution?.targetImageUrls || [],
params.requestResolvedMediaInput?.selectedImageUrls || [],
params.requestResolvedMediaInput?.currentMessageImageUrls || [],
params.requestResolvedMediaInput?.canvasImageUrls || [],
params.requestWorkingState?.lastResolvedTargetImageUrls || [],
params.requestWorkingState?.lastGeneratedImageUrls || [],
params.requestResolvedMediaInput?.historyAssistantImageUrls || [],

View File

@ -10,6 +10,56 @@ import {
type DirectAgentToolCapability,
} from "@/lib/ai/runtime/direct-agent-execution";
function normalizeDirectAgentResultSentence(value: string) {
const normalized = String(value || "")
.replace(/结果如下[::]?\s*$/u, "")
.replace(/[,,。::]\s*$/u, "")
.trim();
return normalized || "已完成处理";
}
function buildDirectAgentResultCard(params: {
result: string;
mediaMarkdown?: string | null;
followUp: string;
}) {
return [
"### 执行结果",
`- ${normalizeDirectAgentResultSentence(params.result)},结果如下。`,
params.mediaMarkdown,
"### 后续操作",
`- ${params.followUp}`,
]
.filter(Boolean)
.join("\n\n");
}
function getDirectAgentFollowUpMessage(capability: DirectAgentToolCapability) {
switch (capability) {
case "remove_background":
return "如需微调边缘、替换背景或继续合成,可以直接告诉我。";
case "remove_watermark":
return "如需继续修补局部痕迹或统一画面质感,可以直接告诉我。";
case "enhance":
return "如需继续调整清晰度、色彩或细节强度,可以直接告诉我。";
case "resize":
return "如需继续改尺寸、比例或输出规格,可以直接告诉我。";
case "extend":
return "如需继续扩展画面、调整构图或补充边缘内容,可以直接告诉我。";
case "angle_variant":
return "如需继续换角度、换机位或统一系列视角,可以直接告诉我。";
case "local_edit":
case "box_cutout":
return "如需继续修改局部、替换元素或细化边缘,可以直接告诉我。";
case "generate_video":
return "如需继续调整镜头、节奏、比例或时长,可以直接告诉我。";
case "generate_image":
case "generic_edit":
default:
return "如需继续调整风格、构图、主体细节或尺寸,可以直接告诉我。";
}
}
export function buildDirectAgentAnalysisAssistantText(params: {
summary: string;
answer?: string | null;
@ -32,12 +82,11 @@ export function buildDirectAgentImageGenerationAssistantText(params: {
modelDisplayName?: string | null;
}) {
return prependImageModelBadge(
[
getDirectAgentToolSuccessMessage("generate_image"),
params.imageMarkdown,
]
.filter(Boolean)
.join("\n\n"),
buildDirectAgentResultCard({
result: getDirectAgentToolSuccessMessage("generate_image"),
mediaMarkdown: params.imageMarkdown,
followUp: getDirectAgentFollowUpMessage("generate_image"),
}),
params.modelDisplayName,
);
}
@ -47,12 +96,11 @@ export function buildDirectAgentVideoGenerationAssistantText(params: {
modelDisplayName?: string | null;
}) {
return prependImageModelBadge(
[
getDirectAgentToolSuccessMessage("generate_video"),
buildAgentToolVideoMarkdown(params.videoUrl),
]
.filter(Boolean)
.join("\n\n"),
buildDirectAgentResultCard({
result: getDirectAgentToolSuccessMessage("generate_video"),
mediaMarkdown: buildAgentToolVideoMarkdown(params.videoUrl),
followUp: getDirectAgentFollowUpMessage("generate_video"),
}),
params.modelDisplayName,
);
}
@ -64,16 +112,15 @@ export function buildDirectAgentImageEditAssistantText(params: {
imageSize?: string;
}) {
return prependImageModelBadge(
[
getDirectAgentToolSuccessMessage(params.capability),
buildAgentToolImageMarkdown({
buildDirectAgentResultCard({
result: getDirectAgentToolSuccessMessage(params.capability),
mediaMarkdown: buildAgentToolImageMarkdown({
originalUrl: params.originalUrl,
thumbnailUrl: params.thumbnailUrl,
imageSize: params.imageSize,
}),
]
.filter(Boolean)
.join("\n\n"),
followUp: getDirectAgentFollowUpMessage(params.capability),
}),
getDirectAgentToolLabel(params.capability),
);
}
@ -87,12 +134,11 @@ export function buildDirectAgentBatchImageEditAssistantText(params: {
}>;
}) {
return prependImageModelBadge(
[
getDirectAgentToolSuccessMessage(params.capability),
buildAgentToolImagesMarkdown(params.results),
]
.filter(Boolean)
.join("\n\n"),
buildDirectAgentResultCard({
result: getDirectAgentToolSuccessMessage(params.capability),
mediaMarkdown: buildAgentToolImagesMarkdown(params.results),
followUp: getDirectAgentFollowUpMessage(params.capability),
}),
getDirectAgentToolLabel(params.capability),
);
}

View File

@ -5,6 +5,10 @@ import type { ResolvedTargetAssets } from "@/lib/ai/context-memory/types";
import type { AnalysisResult } from "@/lib/ai/smart-analyzer";
import type { TaskBoundaryDecision } from "@/lib/ai/runtime/task-boundary-classifier";
import { hasDirectAgentLocalEditIntent } from "@/lib/ai/runtime/local-edit-options";
import {
hasCanvasAgentActionableImageEditIntent,
inferCanvasAgentImageEditCapability,
} from "@/lib/ai/runtime/canvas-agent-kernel";
export type IntentArbiterMode =
| "respond"
@ -90,10 +94,10 @@ function isExplicitImageGenerationRequest(text: string): boolean {
const visualTarget =
"(?:图|图片|海报|头像|插画|场景|主图|产品图|商品图|电商图|壁纸|封面|logo|图标)";
const createVisualTarget = new RegExp(
`(?:生成|创作|创建|制作|设计|做|出|画|绘制)${countToken}?张?.{0,32}${visualTarget}`,
`(?:生成|创作|创建|制作|设计|做|出|画(?!布)|绘制)${countToken}?张?.{0,32}${visualTarget}`,
);
const drawSubject = new RegExp(
`(?:画|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`,
`(?:画(?!布)|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`,
);
const continueVariants = new RegExp(
`(?:再来|再出|再做|再给我|补|多来|继续生成|继续出)${countToken}?张`,
@ -116,23 +120,21 @@ function isExplicitImageGenerationRequest(text: string): boolean {
}
function isLikelyImageEditRequest(text: string): boolean {
const normalizedText = String(text || "").toLowerCase();
return hasDirectAgentLocalEditIntent(text) || /抠图|去背景|透明背景|去背|扣图|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
normalizedText,
);
return hasDirectAgentLocalEditIntent(text) || hasCanvasAgentActionableImageEditIntent(text);
}
function inferDefaultImageEditSubIntent(text: string): string {
const lowerText = String(text || "").toLowerCase();
if (/去水印|水印|去logo|去\s*logo|移除logo|去除logo/.test(lowerText)) return "remove_watermark";
if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) return "remove_background";
if (/换背景|改背景|背景换成|背景改成|白底|透明底/.test(lowerText)) return "replace_background";
if (/多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(lowerText)) return "angle_variant";
const inferredCapability = inferCanvasAgentImageEditCapability(text);
if (
inferredCapability &&
inferredCapability !== "generic_edit" &&
inferredCapability !== "generate_image" &&
inferredCapability !== "generate_video" &&
inferredCapability !== "analyze"
) {
return inferredCapability;
}
if (hasDirectAgentLocalEditIntent(text)) return "local_edit";
if (/扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|比例|画幅|竖版|横版|竖图|横图|9:16|16:9|1:1/.test(lowerText)) return "extend";
if (/风格|卡通|动漫|油画|像素风|写实|插画/.test(lowerText)) return "style_transfer";
if (/颜色|调色|亮度|对比度|饱和度|改成.*色|换成.*色/.test(lowerText)) return "color_adjust";
if (/去除|消除|擦掉|删除|移除|p掉|重绘/.test(lowerText)) return "inpaint";
return "enhance";
}
@ -195,6 +197,9 @@ function buildExecutionFromAnalysis(params: {
targetSource: ResolvedTargetAssets["resolutionMode"];
}): IntentArbiterExecution {
const mode = mapIntentToMode(params.analysis.intent, params.analysis.needsClarification);
const shouldKeepPlannedToolCalls =
params.execution?.mode === mode &&
params.execution?.routeIntent === params.analysis.intent;
return {
mode,
@ -216,7 +221,7 @@ function buildExecutionFromAnalysis(params: {
shouldStayConversational:
mode === "respond" || mode === "analyze_image" || mode === "clarify",
shouldPublishToCanvas: params.execution?.shouldPublishToCanvas || false,
toolCalls: params.execution?.toolCalls || [],
toolCalls: shouldKeepPlannedToolCalls ? params.execution?.toolCalls || [] : [],
availableTools: params.execution?.availableTools || [],
};
}
@ -260,6 +265,14 @@ function resolvePolicyIntent(params: {
};
}
if (hasUsableImage && likelyEdit) {
return {
intent: "image_edit",
subIntent: params.analysis.subIntent || inferDefaultImageEditSubIntent(params.text),
reason: "image_edit_policy_override",
};
}
switch (params.actionResolution.kind) {
case "image_edit_followup":
return {
@ -290,13 +303,6 @@ function resolvePolicyIntent(params: {
reason: "task_boundary_execution_video",
};
case "execution_image":
if (hasUsableImage && likelyEdit && !explicitImageGen) {
return {
intent: "image_edit",
subIntent: params.analysis.subIntent || inferDefaultImageEditSubIntent(params.text),
reason: "task_boundary_execution_image_edit",
};
}
return {
intent: "image_gen",
reason: "task_boundary_execution_image_generate",
@ -327,14 +333,6 @@ function resolvePolicyIntent(params: {
};
}
if (hasUsableImage && likelyEdit && params.analysis.intent === "chat") {
return {
intent: "image_edit",
subIntent: params.analysis.subIntent || inferDefaultImageEditSubIntent(params.text),
reason: "image_edit_policy_override",
};
}
return {
intent: params.analysis.intent,
subIntent:

View File

@ -0,0 +1,146 @@
import type { DBMessage } from "@/lib/db/schema";
export interface RecentDialogueMemoryEntry {
role: "user" | "assistant";
content: string;
messageId?: string;
}
export interface RecentDialogueMemory {
entries: RecentDialogueMemoryEntry[];
contextSummary: string;
memoryHints: string[];
isRecallRequest: boolean;
}
const USER_RECALL_PATTERN =
/(?:我|用户)?(?:刚才|刚刚|前面|之前|上面|上一(?:句|条|轮)|最近).{0,12}(?:说|发|问|讲|提|输入|发送).{0,12}(?:什么|啥|哪句|哪些|内容)|(?:我说了什么|我刚才说啥|我前面说啥|我之前说啥|我发了什么|我问了什么|记得我.{0,12}说|没有记忆)/i;
const ASSISTANT_RECALL_PATTERN =
/(?:你|助手|ai|AI).{0,12}(?:刚才|刚刚|前面|之前|上面|上一(?:句|条|轮)|最近).{0,12}(?:说|回复|回答|讲).{0,12}(?:什么|啥|哪句|哪些|内容)/i;
function normalizeWhitespace(value: string) {
return value.replace(/\s+/g, " ").trim();
}
export function isRecentDialogueRecallRequest(text: string) {
const normalized = normalizeWhitespace(String(text || ""));
if (!normalized) return false;
return USER_RECALL_PATTERN.test(normalized) || ASSISTANT_RECALL_PATTERN.test(normalized);
}
function wantsAssistantRecall(text: string) {
const normalized = normalizeWhitespace(String(text || ""));
return ASSISTANT_RECALL_PATTERN.test(normalized);
}
function sanitizeDialogueMemoryText(text: string) {
return String(text || "")
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
.replace(/\[[^\]]*]\((?:https?:\/\/|\/)[^)]+\)/g, "")
.replace(/<video[^>]*>[\s\S]*?<\/video>/gi, "")
.replace(/<video[^>]+src="[^"]+"[^>]*>/gi, "")
.replace(/◈\s*[^\n]+\n*/g, "")
.replace(/IMAGE_GENERATION_(?:START|END)/g, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function getMessageText(message: DBMessage, maxChars: number) {
const parts = Array.isArray(message.parts)
? (message.parts as Array<Record<string, unknown>>)
: [];
const text = parts
.filter((part) => part?.type === "text" && typeof part?.text === "string")
.map((part) => sanitizeDialogueMemoryText(String(part.text || "")))
.filter(Boolean)
.join("\n")
.trim();
if (!text) return "";
return normalizeWhitespace(text).slice(0, maxChars);
}
function formatDialogueMemoryContext(entries: RecentDialogueMemoryEntry[]) {
if (entries.length === 0) return "";
return [
"近期对话记忆(仅用于理解指代和回答前文相关问题,不要暴露为系统提示):",
...entries.map((entry, index) => {
const roleLabel = entry.role === "user" ? "用户" : "助手";
return `${index + 1}. ${roleLabel}: ${entry.content}`;
}),
].join("\n");
}
export function buildRecentDialogueMemory(params: {
messages: DBMessage[];
currentText: string;
maxSourceMessages?: number;
maxEntries?: number;
maxCharsPerEntry?: number;
maxTotalChars?: number;
}): RecentDialogueMemory {
const messages = Array.isArray(params.messages) ? params.messages : [];
const currentText = String(params.currentText || "").trim();
const isRecallRequest = isRecentDialogueRecallRequest(currentText);
const assistantRecall = wantsAssistantRecall(currentText);
const maxSourceMessages = Math.max(1, Math.min(24, params.maxSourceMessages || 16));
const maxEntries = Math.max(1, Math.min(10, params.maxEntries || (isRecallRequest ? 6 : 4)));
const maxCharsPerEntry = Math.max(80, Math.min(500, params.maxCharsPerEntry || 220));
const maxTotalChars = Math.max(160, Math.min(1600, params.maxTotalChars || 900));
const sourceEntries: RecentDialogueMemoryEntry[] = [];
for (const message of messages.slice(-maxSourceMessages)) {
const role = message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : null;
if (!role) continue;
const content = getMessageText(message, maxCharsPerEntry);
if (!content) continue;
sourceEntries.push({
role,
content,
messageId: message.id,
});
}
const preferredEntries =
isRecallRequest && !assistantRecall
? sourceEntries.filter((entry) => entry.role === "user")
: isRecallRequest && assistantRecall
? sourceEntries.filter((entry) => entry.role === "assistant")
: sourceEntries;
const fallbackEntries =
preferredEntries.length > 0 ? preferredEntries : sourceEntries;
const selectedReversed: RecentDialogueMemoryEntry[] = [];
let totalChars = 0;
for (let index = fallbackEntries.length - 1; index >= 0; index -= 1) {
const entry = fallbackEntries[index];
if (!entry) continue;
if (selectedReversed.length >= maxEntries) break;
if (totalChars + entry.content.length > maxTotalChars && selectedReversed.length > 0) {
continue;
}
selectedReversed.push(entry);
totalChars += entry.content.length;
}
const entries = selectedReversed.reverse();
const memoryHints = entries.length
? [
isRecallRequest
? "用户正在询问前文或记忆,应优先根据近期对话记忆回答,不要声称没有上下文。"
: "近期对话记忆只用于理解指代和延续语境,不要逐字复述,除非用户明确询问前文。",
]
: [];
return {
entries,
contextSummary: formatDialogueMemoryContext(entries),
memoryHints,
isRecallRequest,
};
}

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

@ -33,7 +33,7 @@ export interface AnalysisResult {
function inferDefaultImageEditSubIntent(text: string): AnalysisResult["subIntent"] {
const lowerText = text.toLowerCase();
if (/去水印|水印|去logo|去\s*logo|移除logo|去除logo/.test(lowerText)) return "remove_watermark";
if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) return "remove_background";
if (/抠图|去背景|透明背景|去背|扣图|抠出|扣出|抠出来|扣出来|移除背景/.test(lowerText)) return "remove_background";
if (/换背景|改背景|背景换成|背景改成|白底|透明底/.test(lowerText)) return "replace_background";
if (/多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(lowerText)) return "angle_variant";
if (hasDirectAgentLocalEditIntent(text)) return "local_edit";
@ -112,10 +112,10 @@ export function isExplicitImageGenerationRequest(text: string): boolean {
const visualTarget =
"(?:图|图片|海报|头像|插画|场景|主图|产品图|商品图|电商图|壁纸|封面|logo|图标)";
const createVisualTarget = new RegExp(
`(?:生成|创作|创建|制作|设计|做|出|画|绘制)${countToken}?张?.{0,32}${visualTarget}`,
`(?:生成|创作|创建|制作|设计|做|出|画(?!布)|绘制)${countToken}?张?.{0,32}${visualTarget}`,
);
const drawSubject = new RegExp(
`(?:画|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`,
`(?:画(?!布)|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`,
);
const continueVariants = new RegExp(
`(?:再来|再出|再做|再给我|补|多来|继续生成|继续出)${countToken}?张`,
@ -141,7 +141,7 @@ export function isExplicitImageGenerationRequest(text: string): boolean {
function isLikelyImageEditRequest(text: string): boolean {
const normalizedText = text.toLowerCase();
return hasDirectAgentLocalEditIntent(text) || /抠图|去背景|透明背景|去背|扣图|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
return hasDirectAgentLocalEditIntent(text) || /抠图|去背景|透明背景|去背|扣图|抠出|扣出|抠出来|扣出来|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
normalizedText,
);
}
@ -2331,7 +2331,7 @@ export function fallbackKeywordAnalysis(
}
// 抠图/去背景
if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) {
if (/抠图|去背景|透明背景|去背|扣图|抠出|扣出|抠出来|扣出来|移除背景/.test(lowerText)) {
return {
intent: "image_edit",
subIntent: "remove_background",

View File

@ -250,7 +250,7 @@ function normalizeEditCapability(
subIntent: "remove_watermark",
};
}
if (subIntent === "remove_background" || /抠图|去背|去背景|透明底/.test(normalizedText)) {
if (subIntent === "remove_background" || /抠图|去背|去背景|透明底|抠出|扣出|抠出来|扣出来/.test(normalizedText)) {
return {
capability: "remove_background",
toolName: "removeBackground",

View File

@ -29,6 +29,13 @@ export function isCanvasImageReadyForAi(node: CanvasNode | null | undefined) {
return false;
}
const src = String(((node.data as Record<string, any>)?.src || "")).trim();
const data = (node.data || {}) as Record<string, any>;
const src = String(
data.src ||
data.previewImage ||
data.imageUrl ||
data.renderArtifacts?.screenshotUrl ||
""
).trim();
return /^https?:\/\//i.test(src);
}

View File

@ -186,6 +186,44 @@ export async function saveChat({
}
}
export async function getClawdChatsByUserId({
userId,
limit = 50,
}: {
userId: string;
limit?: number;
}) {
const safeLimit = Math.max(1, Math.min(100, Math.round(limit) || 50));
const latestMessageAt = sql<Date>`max(${message.createdAt})`;
try {
return await db
.select({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: latestMessageAt,
messageCount: sql<number>`count(${message.id})::int`,
})
.from(chat)
.innerJoin(message, eq(message.chatId, chat.id))
.where(
and(
eq(chat.userId, userId),
sql`coalesce(${message.videoMetadata}->>'composerMode', '') = 'clawd'`
)
)
.groupBy(chat.id, chat.title, chat.createdAt)
.orderBy(desc(latestMessageAt))
.limit(safeLimit);
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get Claw chats by user id"
);
}
}
export async function deleteChatById({ id }: { id: string }) {
try {
await db.delete(vote).where(eq(vote.chatId, id));

View File

@ -4,12 +4,15 @@ import {
extractAssistantImageUrls,
extractVideoUrls,
normalizeMediaUrl,
sortCanvasMessagesByCreatedAt,
stripMediaMarkup,
} from "@/components/canvas/chat-panel/canvas-chat-media";
import {
filterCanvasMediaPayloadForUnhandledItems,
buildAgentProgressNodeSteps,
getCanvasMediaPayloadHandledItemIds,
getCanvasOperationHandledMediaItemIds,
getAgentProgressStepLabel,
isCanvasAgentViewOperationClientEnabled,
normalizeCanvasAgentClientBoolean,
shouldRetryCanvasViewOperationAfterRefresh,
@ -44,7 +47,18 @@ import {
} from "@/lib/canvas-toolbar-config";
import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification";
import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing";
import {
inferCanvasAgentImageEditCapability,
resolveCanvasAgentRequestPolicy,
resolveCanvasAgentTargetPreference,
} from "@/lib/ai/runtime/canvas-agent-kernel";
import { isExplicitImageGenerationRequest } from "@/lib/ai/smart-analyzer";
import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent";
import {
buildRecentDialogueMemory,
isRecentDialogueRecallRequest,
} from "@/lib/ai/runtime/recent-dialogue-memory";
import { isCapabilityQuestionTurnRequest } from "@/lib/ai/runtime/task-boundary-text";
import {
isAgentModeScopedChatId,
isScopedEmptyHistoryRequest,
@ -127,9 +141,14 @@ import {
getDirectAgentToolSuccessMessage,
hasExplicitDirectAgentBatchImageEditIntent,
isDirectAgentBatchImageEditCapability,
resolveDirectAgentToolContext,
resolveDirectAgentToolExecution,
shouldRunDirectAgentBatchImageEdit,
} from "@/lib/ai/runtime/direct-agent-execution";
import {
buildDirectAgentImageEditAssistantText,
buildDirectAgentImageGenerationAssistantText,
} from "@/lib/ai/runtime/direct-agent-response-builders";
import { inferAngleEditOptionsFromText } from "@/lib/ai/runtime/angle-edit-options";
import {
hasDirectAgentLocalEditIntent,
@ -209,8 +228,69 @@ import {
import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session";
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
import type { ChatMessage } from "@/lib/types";
import type { DBMessage } from "@/lib/db/schema";
function dbTextMessage(
id: string,
role: "user" | "assistant",
text: string,
): DBMessage {
return {
id,
role,
parts: [{ type: "text", text }],
} as unknown as DBMessage;
}
test.describe("canvas chat pure helpers", () => {
test("keeps optimistic messages without timestamps after persisted history", () => {
const messages = [
{
id: "optimistic-latest-user",
role: "user",
parts: [{ type: "text", text: "你能为我做什么?列出表给我" }],
},
{
id: "persisted-first-user",
role: "user",
createdAt: "2026-07-07T01:00:00.000Z",
parts: [{ type: "text", text: "你好" }],
},
{
id: "persisted-first-assistant",
role: "assistant",
createdAt: "2026-07-07T01:00:01.000Z",
parts: [{ type: "text", text: "你好,我在。" }],
},
] as ChatMessage[];
expect(sortCanvasMessagesByCreatedAt(messages).map((message) => message.id)).toEqual([
"persisted-first-user",
"persisted-first-assistant",
"optimistic-latest-user",
]);
});
test("preserves relative order among messages without timestamps", () => {
const messages = [
{
id: "pending-user",
role: "user",
parts: [{ type: "text", text: "生成一张图" }],
},
{
id: "pending-assistant",
role: "assistant",
parts: [{ type: "text", text: "分析中" }],
},
] as ChatMessage[];
expect(sortCanvasMessagesByCreatedAt(messages).map((message) => message.id)).toEqual([
"pending-user",
"pending-assistant",
]);
});
test("formats uploaded spreadsheet rows as safe markdown tables for OpenClaw", () => {
const markdown = rowsToOpenClawMarkdownTable([
["名称", "备注"],
@ -249,6 +329,52 @@ 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: [
dbTextMessage("m1", "user", "你好"),
dbTextMessage("m2", "assistant", "你好,我在。"),
dbTextMessage("m3", "user", "你是一个小可爱"),
dbTextMessage("m4", "assistant", "嘿嘿,谢谢你的夸奖。"),
],
currentText: "我前面说了什么?",
});
expect(isRecentDialogueRecallRequest("我前面说了什么?")).toBe(true);
expect(memory.isRecallRequest).toBe(true);
expect(memory.entries.map((entry) => entry.role)).toEqual(["user", "user"]);
expect(memory.entries.at(-1)?.content).toBe("你是一个小可爱");
expect(memory.contextSummary).toContain("用户: 你是一个小可爱");
expect(memory.memoryHints.join(" ")).toContain("不要声称没有上下文");
});
test("sanitizes media markup from recent dialogue memory", () => {
const memory = buildRecentDialogueMemory({
messages: [
dbTextMessage("m1", "user", "生成一只小猫"),
dbTextMessage(
"m2",
"assistant",
"<!-- IMAGE_GENERATION_START -->\n![原图](https://example.com/cat.png)\n已完成本次生成"
),
],
currentText: "你刚才回复了什么?",
});
expect(memory.entries).toHaveLength(1);
expect(memory.entries[0].role).toBe("assistant");
expect(memory.entries[0].content).toBe("已完成本次生成");
expect(memory.contextSummary).not.toContain("https://example.com");
expect(memory.contextSummary).not.toContain("IMAGE_GENERATION_START");
});
test("infers explicit agent image counts from user text", () => {
expect(inferAgentImageCountFromText("再出三版")).toBe(3);
expect(inferAgentImageCountFromText("基于这张图再来 3 张")).toBe(3);
@ -1130,6 +1256,273 @@ test.describe("canvas chat pure helpers", () => {
);
});
test("formats direct agent image results as structured markdown cards", () => {
const generatedText = buildDirectAgentImageGenerationAssistantText({
imageMarkdown: "![生成结果](https://cdn.example.com/generated.png)",
modelDisplayName: "Test Image Model",
});
expect(generatedText).toContain("### 执行结果");
expect(generatedText).toContain("- 已完成图片生成,结果如下。");
expect(generatedText).toContain("![生成结果](https://cdn.example.com/generated.png)");
expect(generatedText).toContain("### 后续操作");
const editText = buildDirectAgentImageEditAssistantText({
capability: "remove_background",
originalUrl: "https://cdn.example.com/cutout.png",
});
expect(editText).toContain("### 执行结果");
expect(editText).toContain("- 已完成抠图处理,结果如下。");
expect(editText).toContain("https://cdn.example.com/cutout.png");
expect(editText).toContain("替换背景");
});
test("builds capability-aware agent progress node labels", () => {
const selectingSteps = buildAgentProgressNodeSteps({
stage: "planning",
currentStep: "select_tool",
capability: "remove_background",
});
expect(selectingSteps.map((step) => step.label)).toEqual([
"理解用户需求",
"分析使用工具",
"确认抠图工具",
"抠图中",
"整理结果",
]);
expect(selectingSteps.map((step) => step.state)).toEqual([
"completed",
"completed",
"current",
"upcoming",
"upcoming",
]);
const executingSteps = buildAgentProgressNodeSteps({
stage: "executing",
currentStep: "execute",
capability: "remove_background",
});
expect(executingSteps[2]?.state).toBe("completed");
expect(executingSteps[3]).toMatchObject({
label: "抠图中",
state: "current",
});
expect(getAgentProgressStepLabel("execute", { capability: "generate_video" })).toBe(
"视频生成中"
);
});
test("resolves deictic canvas edit requests to latest results and direct tools", () => {
const latestResultUrl = "https://cdn.example.com/latest-cat-result.png";
const requestPolicy = resolveCanvasAgentRequestPolicy({
composerMode: "agent",
selectedChatModel: "conversation-model-id",
});
expect(requestPolicy.effectiveSelectedChatModel).toBe("auto");
expect(requestPolicy.shouldForceToolOrchestration).toBe(true);
const targetPreference = resolveCanvasAgentTargetPreference({
text: "将这只猫从图中扣图扣出来",
selectedImageCount: 0,
latestResultImageCount: 1,
workingMemoryTargetImageCount: 1,
});
expect(targetPreference.shouldPreferLatestResult).toBe(true);
const mediaInput = {
imageRefs: [],
currentMessageImageRefs: [],
selectedImageRefs: [],
canvasImageRefs: [],
historyUserImageRefs: [],
historyAssistantImageRefs: [
{
url: latestResultUrl,
normalizedUrl: latestResultUrl,
source: "history_assistant_result",
role: "history_result",
},
],
resolvedImageRefs: [],
currentMessageImageUrls: [],
selectedImageUrls: [],
historyUserImageUrls: [],
historyAssistantImageUrls: [latestResultUrl],
resolvedImageUrls: [latestResultUrl],
selectedAssetIds: [],
candidateAssetIds: [],
uploadedAssetIds: [],
hasImage: true,
source: "history",
fromHistory: true,
} as any;
const workingState = {
lastGeneratedImageUrls: [latestResultUrl],
lastResolvedTargetImageUrls: [latestResultUrl],
};
const targetResolution = {
targetRefs: [],
candidateRefs: [],
targetImageUrls: [latestResultUrl],
targetAssetIds: [],
candidateImageUrls: [],
candidateAssetIds: [],
resolutionMode: "working_memory",
confidence: targetPreference.confidence,
needsClarification: false,
reason: targetPreference.reason,
fromHistory: true,
} as any;
const directContext = resolveDirectAgentToolContext({
execution: {
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "remove_background",
targetSource: targetResolution.resolutionMode,
preferredCapability: "remove_background",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [],
availableTools: ["removeBackground"],
} as any,
requestResolvedMediaInput: mediaInput,
requestTargetResolution: targetResolution,
requestWorkingState: workingState,
});
expect(directContext?.execution.toolName).toBe("removeBackground");
expect(directContext?.targetImageUrl).toBe(latestResultUrl);
});
test("falls back to canvas images for direct agent edit tools", () => {
const canvasImageUrl = "https://cdn.example.com/canvas-cat.png";
const mediaInput = {
imageRefs: [],
currentMessageImageRefs: [],
selectedImageRefs: [],
canvasImageRefs: [
{
url: canvasImageUrl,
normalizedUrl: canvasImageUrl,
nodeId: "cat-node",
source: "canvas_context",
role: "reference",
x: 120,
y: 80,
width: 640,
height: 640,
isSelected: false,
},
],
historyUserImageRefs: [],
historyAssistantImageRefs: [],
resolvedImageRefs: [],
currentMessageImageUrls: [],
selectedImageUrls: [],
canvasImageUrls: [canvasImageUrl],
historyUserImageUrls: [],
historyAssistantImageUrls: [],
resolvedImageUrls: [],
selectedAssetIds: [],
candidateAssetIds: [],
uploadedAssetIds: [],
hasImage: false,
source: "none",
fromHistory: false,
} as any;
const directContext = resolveDirectAgentToolContext({
execution: {
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "remove_background",
targetSource: "text_reference",
preferredCapability: "remove_background",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [],
availableTools: ["removeBackground"],
} as any,
requestResolvedMediaInput: mediaInput,
});
expect(directContext?.execution.toolName).toBe("removeBackground");
expect(directContext?.targetImageUrl).toBe(canvasImageUrl);
});
test("routes canvas subject cutout wording to remove background instead of image generation", () => {
const canvasImageUrl = "https://cdn.example.com/canvas-dog.png";
const userText = "帮我将画布中的狗的图扣出来";
expect(isExplicitImageGenerationRequest(userText)).toBe(false);
expect(inferCanvasAgentImageEditCapability(userText)).toBe("remove_background");
const directContext = resolveDirectAgentToolContext({
execution: {
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "remove_background",
targetSource: "text_reference",
preferredCapability: "remove_background",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [],
availableTools: ["resolveTargetImage", "removeBackground"],
} as any,
requestResolvedMediaInput: {
imageRefs: [],
currentMessageImageRefs: [],
selectedImageRefs: [],
canvasImageRefs: [
{
url: canvasImageUrl,
normalizedUrl: canvasImageUrl,
nodeId: "dog-node",
source: "canvas_context",
role: "reference",
},
],
historyUserImageRefs: [],
historyAssistantImageRefs: [],
resolvedImageRefs: [],
currentMessageImageUrls: [],
selectedImageUrls: [],
canvasImageUrls: [canvasImageUrl],
historyUserImageUrls: [],
historyAssistantImageUrls: [],
resolvedImageUrls: [],
selectedAssetIds: [],
candidateAssetIds: [],
uploadedAssetIds: [],
hasImage: true,
source: "canvas_context",
fromHistory: false,
} as any,
});
expect(directContext?.execution.toolName).toBe("removeBackground");
expect(directContext?.targetImageUrl).toBe(canvasImageUrl);
});
test("only runs direct image edit batches for explicit safe batch requests", () => {
expect(
shouldRunDirectAgentBatchImageEdit({