ai-chatbot/lib/ai/runtime/middlewares/canvas-agent-clarification-gate.ts
Your Name d34e66da25
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: add missing canvas clarification gate
2026-04-05 01:30:40 +08:00

281 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
import type { ConversationActionKind } from "@/lib/ai/conversation-action-resolver";
import type {
ContextPayload,
ResolvedChatMediaInput,
ResolvedImageRef,
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
import type { IntentArbiterMode } from "@/lib/ai/runtime/intent-arbiter";
import type { TaskBoundaryDecision } from "@/lib/ai/runtime/task-boundary-classifier";
export interface CanvasAgentClarificationGateDecision {
shouldClarify: boolean;
clarificationMessage?: string;
reason?: string;
}
const BATCH_EDIT_REGEX =
/(全部|都|一起|批量|这几张|这些图|所有|统一处理|都改|全部改|一起改|一起处理)/i;
const EXPLICIT_TARGET_HINT_REGEX =
/(第\s*[一二三四五六七八九十两\d]+\s*(?:张|个|幅|图)|左边|左侧|左上|左下|右边|右侧|右上|右下|上面|上方|下面|下方|中间|居中|中央|选中的|当前这张|原图|上传图|上一张|上一版|结果图|刚生成|最新生成)/i;
const EXPLICIT_CONTINUATION_TARGET_REGEX =
/(继续|上一张|上一版|结果图|刚生成|最新生成|原图|上传图|选中的|当前这张)/i;
const ACTIONABLE_EDIT_REGEX =
/(改|调整|优化|换|替换|去掉|抠|重绘|增强|修|处理|按|保留|统一|背景|清晰|风格|亮一点|暗一点|白底|透明底|去背|扩图|放大|缩小|裁剪|调色)/i;
const ACTIONABLE_GENERATION_REGEX =
/(生图|生成|画一张|出图|来一张|再来一张|做一张|做图|做视频|视频生成|生成视频|动起来)/i;
const ANALYSIS_ONLY_REGEX = /(分析|看图|看看|识别|描述|总结|解释|判断|评价)/i;
const VAGUE_EXECUTION_REGEX =
/^(按刚才那个做|按刚才的做|按这个做|按这个来|照这个来|照刚才那个|就这个|这个|来吧|开始吧|搞一下|弄一下|继续吧|就这样|按上一个来|按上一版来|按刚才那个来)$/i;
function normalizeCompactText(text: string) {
return String(text || "").replace(/\s+/g, "").trim();
}
function sortRefsForCanvasOrdering(refs: ResolvedImageRef[]) {
return [...refs].sort((left, right) => {
const leftY = Number(left.y ?? 0);
const rightY = Number(right.y ?? 0);
if (Math.abs(leftY - rightY) > 12) {
return leftY - rightY;
}
return Number(left.x ?? 0) - Number(right.x ?? 0);
});
}
function getPositionLabel(ref: ResolvedImageRef, refs: ResolvedImageRef[]): string {
if (refs.length <= 1) return "中间";
const xs = refs.map((item) => Number(item.x ?? 0));
const ys = refs.map((item) => Number(item.y ?? 0));
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
const relX = (Number(ref.x ?? 0) - minX) / spanX;
const relY = (Number(ref.y ?? 0) - minY) / spanY;
const horizontal = relX <= 0.33 ? "左" : relX >= 0.66 ? "右" : "中";
const vertical = relY <= 0.33 ? "上" : relY >= 0.66 ? "下" : "中";
if (horizontal === "中" && vertical === "中") return "中间";
return `${vertical}${horizontal}`;
}
function buildRefPreviewList(refs: ResolvedImageRef[]) {
const orderedRefs = sortRefsForCanvasOrdering(refs).slice(0, 4);
return orderedRefs.map((ref, index) => {
const position = getPositionLabel(ref, orderedRefs);
const title = String(ref.title || "").trim();
return `- 第${index + 1}张(${position})${title ? `:${title}` : ""}`;
});
}
function buildMultiSelectionClarificationMessage(selectedRefs: ResolvedImageRef[]) {
const previewLines = buildRefPreviewList(selectedRefs);
return [
`你当前选中了 ${selectedRefs.length} 张图,我还不能确定这一步是只处理其中一张,还是批量处理全部。`,
previewLines.length > 0 ? "当前选中候选:" : "",
...previewLines,
"请直接回复:",
"- 处理全部",
"- 只处理第2张 / 左边那张",
"",
"也可以先在画布里只保留一张选中,再让我继续。",
]
.filter(Boolean)
.join("\n");
}
function buildMissingTargetClarificationMessage(params: {
selectedRefs: ResolvedImageRef[];
candidateRefs: ResolvedImageRef[];
}) {
if (params.selectedRefs.length > 1) {
return buildMultiSelectionClarificationMessage(params.selectedRefs);
}
if (params.candidateRefs.length > 1) {
const previewLines = buildRefPreviewList(params.candidateRefs);
return [
"我这次还没锁定要处理哪张图。",
`当前画布里有 ${params.candidateRefs.length} 张候选图:`,
...previewLines,
"",
"请直接回复“处理第2张 / 左边那张 / 选中的这张 / 上一版结果图 / 原图”,或者先在画布里选中一张再继续。",
].join("\n");
}
return [
"我还没拿到可处理的目标图。",
"你可以先上传图片,或者先在画布里选中一张图,再告诉我要怎么处理。",
].join("\n");
}
function buildLowConfidenceTargetClarificationMessage(params: {
candidateRefs: ResolvedImageRef[];
targetResolution: ResolvedTargetAssets;
}) {
const previewLines = buildRefPreviewList(params.candidateRefs);
return [
"这一步我能继续执行,但当前目标图只是在低置信度下被系统猜中的,我不想替你处理错图。",
previewLines.length > 0 ? `当前画布里可参考的候选图有 ${params.candidateRefs.length} 张:` : "",
...previewLines,
"",
`系统当前偏向的是「${params.targetResolution.reason}」这一路径,但我建议你先明确回复“第2张 / 左边那张 / 上一版结果图 / 原图”。`,
]
.filter(Boolean)
.join("\n");
}
function buildActionClarificationMessage(mode: IntentArbiterMode) {
if (mode === "generate_image") {
return [
"我已经锁定目标图了,但还不清楚你这一步想让我怎么做。",
"你可以直接回复类似:",
"- 基于这张再生成 3 张",
"- 按这个主体重新生成一版海报",
"- 保留主体,换成白底产品图",
"- 先分析这张图",
].join("\n");
}
if (mode === "generate_video") {
return [
"我已经锁定参考图了,但这一步的视频目标还不够明确。",
"你可以直接回复类似:",
"- 让产品缓慢旋转 5 秒",
"- 做成首尾帧视频",
"- 先按这张图规划分镜,不直接生成",
].join("\n");
}
return [
"我已经锁定目标图了,但还不清楚你这一步想让我做什么。",
"你可以直接回复类似:",
"- 把背景改成白色",
"- 去掉桌上的杯子",
"- 提高清晰度并保留原构图",
"- 先分析这张图",
].join("\n");
}
function isCanvasSource(contextPayload?: ContextPayload | null) {
return (
contextPayload?.source === "canvas" || contextPayload?.source === "realtime-canvas"
);
}
function isExecutionMode(mode?: IntentArbiterMode) {
return mode === "edit_image" || mode === "generate_image" || mode === "generate_video";
}
export function resolveCanvasAgentClarificationGate(params: {
text: string;
composerMode?: "agent" | "image" | "video" | "detail";
contextPayload?: ContextPayload | null;
mediaInput?: ResolvedChatMediaInput | null;
targetResolution?: ResolvedTargetAssets | null;
taskBoundaryDecision: TaskBoundaryDecision;
executionMode?: IntentArbiterMode;
actionKind?: ConversationActionKind;
}) {
if (params.composerMode !== "agent") {
return { shouldClarify: false } satisfies CanvasAgentClarificationGateDecision;
}
if (!isCanvasSource(params.contextPayload)) {
return { shouldClarify: false } satisfies CanvasAgentClarificationGateDecision;
}
if (!isExecutionMode(params.executionMode)) {
return { shouldClarify: false } satisfies CanvasAgentClarificationGateDecision;
}
if (!params.taskBoundaryDecision.shouldAllowExecution) {
return { shouldClarify: false } satisfies CanvasAgentClarificationGateDecision;
}
const text = String(params.text || "").trim();
const compactText = normalizeCompactText(text);
const mediaInput = params.mediaInput;
const targetResolution = params.targetResolution;
const selectedRefs = mediaInput?.selectedImageRefs || [];
const candidateRefs = targetResolution?.candidateRefs || mediaInput?.imageRefs || [];
if (
params.executionMode === "edit_image" &&
selectedRefs.length > 1 &&
!BATCH_EDIT_REGEX.test(compactText) &&
!EXPLICIT_TARGET_HINT_REGEX.test(compactText)
) {
return {
shouldClarify: true,
clarificationMessage: buildMultiSelectionClarificationMessage(selectedRefs),
reason: "multiple_selected_canvas_targets_without_scope",
} satisfies CanvasAgentClarificationGateDecision;
}
if (targetResolution?.needsClarification) {
return {
shouldClarify: true,
clarificationMessage: buildMissingTargetClarificationMessage({
selectedRefs,
candidateRefs,
}),
reason: "target_resolution_requires_clarification",
} satisfies CanvasAgentClarificationGateDecision;
}
const shouldClarifyLowConfidenceCanvasTarget =
params.executionMode === "edit_image" &&
selectedRefs.length === 0 &&
(targetResolution?.resolutionMode === "working_memory" ||
targetResolution?.resolutionMode === "latest_result") &&
Number(targetResolution?.confidence || 0) < 85 &&
candidateRefs.length > 1 &&
!EXPLICIT_TARGET_HINT_REGEX.test(compactText) &&
!EXPLICIT_CONTINUATION_TARGET_REGEX.test(compactText);
if (shouldClarifyLowConfidenceCanvasTarget && targetResolution) {
return {
shouldClarify: true,
clarificationMessage: buildLowConfidenceTargetClarificationMessage({
candidateRefs,
targetResolution,
}),
reason: "low_confidence_canvas_target_guess",
} satisfies CanvasAgentClarificationGateDecision;
}
const lacksExplicitAction =
!ACTIONABLE_EDIT_REGEX.test(compactText) &&
!ACTIONABLE_GENERATION_REGEX.test(compactText) &&
!ANALYSIS_ONLY_REGEX.test(compactText);
const looksLikeVagueExecution =
VAGUE_EXECUTION_REGEX.test(compactText) ||
(compactText.length <= 8 &&
/^(这个|就这个|按这个|来吧|开始吧|继续吧|就这样)$/.test(compactText));
if (
targetResolution &&
targetResolution.targetRefs.length > 0 &&
params.actionKind === "default" &&
lacksExplicitAction &&
looksLikeVagueExecution
) {
return {
shouldClarify: true,
clarificationMessage: buildActionClarificationMessage(params.executionMode),
reason: "target_locked_but_action_is_too_vague",
} satisfies CanvasAgentClarificationGateDecision;
}
return {
shouldClarify: false,
} satisfies CanvasAgentClarificationGateDecision;
}