Update conversation memory and canvas persistence
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-13 12:31:13 +08:00
parent 67d834f0dd
commit e7812060b8
19 changed files with 1399 additions and 174 deletions

View File

@ -90,6 +90,16 @@ import {
message as messageTable,
} from "@/lib/db/schema";
import { generateCanvasImage } from "@/lib/canvas/generate-image";
import {
buildGridConfirmationMessage,
detectRequestedStoryboardGrid,
extractPendingGridConfirmationFromWorkingState,
isGridConfirmationReply,
type PendingGridConfirmation,
resolveConversationAction,
} from "@/lib/ai/conversation-action-resolver";
import { composeConversationMemory } from "@/lib/ai/conversation-memory-composer";
import { buildConversationThreadState } from "@/lib/ai/conversation-thread-state-builder";
import { resolveChatMediaInput } from "@/lib/ai/context-memory/media-input";
import { resolveTargetAssets } from "@/lib/ai/context-memory/target-resolution";
import type {
@ -98,10 +108,13 @@ import type {
} from "@/lib/ai/context-memory/types";
import { ChatSDKError } from "@/lib/errors";
import { extractImageUrlsFromText } from "@/lib/image-url";
import {
buildImageWorkflowMemoryUpdate,
extractImageWorkflowMemory,
} from "@/lib/ai/image-workflow-memory";
import {
buildPromptMemoryUpdate,
extractPromptMemoryFromWorkingState,
isPromptCarryForwardRequest,
} from "@/lib/ai/prompt-memory";
import { uploadFile } from "@/lib/storage/service";
import { getVideoStoryboardConfig } from "@/lib/video-agent/config";
@ -123,7 +136,6 @@ import {
markVideoStoryboardDraftForRevision,
cancelVideoStoryboardTaskByUser,
} from "@/lib/video-agent/task-store";
import { buildAgentThreadStateFromStoryboardTask } from "@/lib/video-agent/agent-thread-state";
import type { StoryboardModelCandidate } from "@/lib/video-agent/types";
import type { ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
@ -1792,102 +1804,6 @@ function extractConfirmedPromptFromWorkingState(
return optimizedPrompt;
}
type PendingGridConfirmation = {
gridType: "grid_2x2" | "grid_3x2" | "grid_3x3";
shotCount: 4 | 6 | 9;
label: string;
originalPrompt: string;
};
function detectRequestedStoryboardGrid(text: string): PendingGridConfirmation | null {
const normalized = String(text || "").toLowerCase().replace(/\s+/g, "");
if (!normalized) return null;
if (/4宫格|四宫格|2x2|2×2/.test(normalized)) {
return {
gridType: "grid_2x2",
shotCount: 4,
label: "4宫格",
originalPrompt: String(text || "").trim(),
};
}
if (/6宫格|六宫格|3x2|3×2|2x3|2×3/.test(normalized)) {
return {
gridType: "grid_3x2",
shotCount: 6,
label: "6宫格",
originalPrompt: String(text || "").trim(),
};
}
if (/9宫格|九宫格|3x3|3×3/.test(normalized)) {
return {
gridType: "grid_3x3",
shotCount: 9,
label: "9宫格",
originalPrompt: String(text || "").trim(),
};
}
return null;
}
function isGridConfirmationReply(text: string): boolean {
const normalized = String(text || "").toLowerCase().replace(/\s+/g, "");
if (!normalized) return false;
return /确认|就按这个|开始生成|可以开始了|没问题|对的|就这样/.test(normalized);
}
function buildGridConfirmationMessage(grid: PendingGridConfirmation): string {
return [
`我看到你希望按 ${grid.label} 来生成分镜图。`,
"为了确保后续自动切片稳定,我会按标准网格、统一格线、固定顺序来出图。",
`请确认是否按 ${grid.label} 标准版式开始生成。`,
"",
"如果确认,请回复:",
`- 确认按${grid.label}生成`,
"",
"如果要修改,也可以直接回复:",
"- 改成 9 宫格",
"- 改成 6 宫格",
"- 改成 4 宫格",
].join("\n");
}
function extractPendingGridConfirmationFromWorkingState(
workingState?: {
lastTurnSummary?: unknown;
} | null,
): PendingGridConfirmation | null {
const summary = workingState?.lastTurnSummary;
if (!summary || typeof summary !== "object") return null;
const summaryRecord = summary as Record<string, unknown>;
const pending = summaryRecord.pendingGridConfirmation;
if (!pending || typeof pending !== "object") return null;
const record = pending as Record<string, unknown>;
const gridType = String(record.gridType || "").trim();
const shotCount = Number(record.shotCount || 0);
const label = String(record.label || "").trim();
const originalPrompt = String(record.originalPrompt || "").trim();
if (
!originalPrompt ||
!label ||
!["grid_2x2", "grid_3x2", "grid_3x3"].includes(gridType) ||
![4, 6, 9].includes(shotCount)
) {
return null;
}
return {
gridType: gridType as PendingGridConfirmation["gridType"],
shotCount: shotCount as PendingGridConfirmation["shotCount"],
label,
originalPrompt,
};
}
function hasStructuredAgentSections(message?: string): boolean {
if (!message) return false;
return /(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
@ -2655,6 +2571,15 @@ async function saveGeneratedImages(
if (savedOriginalUrls.length > 0) {
try {
const existingWorkingState = await getConversationWorkingStateByChatId({
chatId,
}).catch(() => null);
const existingSummary =
existingWorkingState?.lastTurnSummary &&
typeof existingWorkingState.lastTurnSummary === "object"
? (existingWorkingState.lastTurnSummary as Record<string, unknown>)
: {};
const existingImageWorkflowMemory = extractImageWorkflowMemory(existingWorkingState);
await upsertConversationWorkingState({
userId,
chatId,
@ -2665,6 +2590,17 @@ async function saveGeneratedImages(
lastResolvedTargetAssetIds: savedAssetIds,
lastResolvedTargetImageUrls: savedOriginalUrls,
lastMessageId: messageId,
lastTurnSummary: {
...existingSummary,
imageWorkflowMemory: buildImageWorkflowMemoryUpdate({
existing: existingImageWorkflowMemory,
lastIntent: options?.relationType === "edited_from" ? "image_edit" : "image_gen",
lastUserPrompt: prompt,
lastRequestedImageCount: savedOriginalUrls.length,
lastSourceImageUrls: options?.sourceImageUrls || null,
lastResultImageUrls: savedOriginalUrls,
}),
},
});
} catch (memoryError) {
console.warn("[Gallery] Failed to update working state with generated images:", memoryError);
@ -3064,6 +3000,7 @@ export async function POST(request: Request) {
| Awaited<ReturnType<typeof getConversationWorkingStateByChatId>>
| null = null;
let storyboardPreferenceMemory = null;
let imageWorkflowMemory = null;
let reusablePromptMemory = null;
let smartAutoHistoryMessagesFromDb: DBMessage[] = [];
let preferredAgentModelId: string | undefined;
@ -3192,29 +3129,42 @@ export async function POST(request: Request) {
return null;
});
storyboardPreferenceMemory = extractStoryboardPreferenceMemory(requestWorkingState);
imageWorkflowMemory = extractImageWorkflowMemory(requestWorkingState);
reusablePromptMemory = extractPromptMemoryFromWorkingState(requestWorkingState);
pendingGridConfirmation =
extractPendingGridConfirmationFromWorkingState(requestWorkingState);
const hasImageInputForConversationAction = message.parts.some((part: any) =>
part?.type === "file" || part?.type === "image" || part?.type === "image_url"
);
const preloadedConfirmedPrompt =
extractConfirmedPromptFromWorkingState(requestWorkingState) ||
reusablePromptMemory?.optimizedPrompt;
const resolvedConversationAction = resolveConversationAction({
text: requestTextContent,
pendingGridConfirmation,
hasActiveStoryboardTask: Boolean(activeStoryboardTaskForChat?.task?.id),
imageWorkflowMemory,
confirmedPrompt: preloadedConfirmedPrompt,
hasImageInputForPromptCarryForward: hasImageInputForConversationAction,
});
if (
pendingGridConfirmation &&
(isGridConfirmationReply(requestTextContent) ||
detectRequestedStoryboardGrid(requestTextContent) !== null) &&
!shouldRunAgentConversation
) {
if (resolvedConversationAction.forceAgentConversation && !shouldRunAgentConversation) {
shouldRunAgentConversation = true;
shouldKeepSelectedModel = true;
console.log(
`[Smart Auto Mode] 🧩 Force agent conversation due to pending grid confirmation: ${pendingGridConfirmation.label}`
);
}
if (activeStoryboardTaskForChat?.task?.id && !shouldRunAgentConversation) {
shouldRunAgentConversation = true;
shouldKeepSelectedModel = true;
console.log(
`[Smart Auto Mode] 🔐 Force agent conversation due to active storyboard task: ${activeStoryboardTaskForChat.task.id}`
);
shouldKeepSelectedModel = resolvedConversationAction.shouldKeepSelectedModel;
if (resolvedConversationAction.kind === "grid_confirmation_followup" && pendingGridConfirmation) {
console.log(
`[Smart Auto Mode] 🧩 Force agent conversation due to pending grid confirmation: ${pendingGridConfirmation.label}`
);
} else if (
resolvedConversationAction.kind === "active_storyboard_followup" &&
activeStoryboardTaskForChat?.task?.id
) {
console.log(
`[Smart Auto Mode] 🔐 Force agent conversation due to active storyboard task: ${activeStoryboardTaskForChat.task.id}`
);
} else if (resolvedConversationAction.kind === "image_edit_followup") {
console.log("[Smart Auto Mode] 🖼️ Force agent conversation due to image edit follow-up workflow");
}
}
// ========== Smart Auto Mode Logic ==========
@ -3226,9 +3176,7 @@ export async function POST(request: Request) {
let confirmedPromptForGeneration: string | undefined;
let forcePromptDrivenImageGeneration = false;
const smartAutoInputText =
pendingGridConfirmation && isGridConfirmationReply(requestTextContent)
? pendingGridConfirmation.originalPrompt
: requestTextContent;
resolvedConversationAction.effectiveInputText || requestTextContent;
try {
// 🆕 首先获取历史消息(在 Smart Auto Mode 内部单独获取,避免依赖后面的 messagesFromDb)
@ -3241,22 +3189,11 @@ export async function POST(request: Request) {
chatId: id,
});
}
confirmedPromptForGeneration =
extractConfirmedPromptFromWorkingState(requestWorkingState) ||
reusablePromptMemory?.optimizedPrompt;
const hasImageInputForPromptCarryForward =
message.parts.some((part: any) =>
part?.type === "file" || part?.type === "image" || part?.type === "image_url"
);
confirmedPromptForGeneration = preloadedConfirmedPrompt;
const isPromptCarryForwardExecution =
Boolean(confirmedPromptForGeneration) &&
isPromptCarryForwardRequest({
text: requestTextContent,
hasImage: hasImageInputForPromptCarryForward,
});
resolvedConversationAction.kind === "prompt_carry_forward";
forcePromptDrivenImageGeneration =
(isConfirmedPromptGenerationRequest(requestTextContent) ||
isPromptCarryForwardExecution) &&
resolvedConversationAction.usePromptCarryForward &&
Boolean(confirmedPromptForGeneration);
if (!requestResolvedMediaInput) {
requestResolvedMediaInput = resolveChatMediaInput({
@ -3350,6 +3287,37 @@ export async function POST(request: Request) {
CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS
);
}
const baseThreadState = buildConversationThreadState({
activeStoryboardTask: activeStoryboardTaskForChat,
storyboardPreferenceMemory,
promptMemory: reusablePromptMemory,
imageWorkflowMemory,
});
const composedConversationMemory = composeConversationMemory({
autoContextText: autoContextText || undefined,
resolvedMediaInput: requestResolvedMediaInput,
targetResolution: requestTargetResolution,
promptMemory: reusablePromptMemory,
imageWorkflowMemory,
storyboardPreferenceMemory,
threadState: baseThreadState,
});
const composedThreadState = {
workflowState: baseThreadState?.workflowState || null,
memoryHints: Array.from(
new Set([
...(baseThreadState?.memoryHints || []),
...composedConversationMemory.memoryHints,
])
),
contextSummary:
composedConversationMemory.contextSummary ||
baseThreadState?.contextSummary,
metadata: {
...(baseThreadState?.metadata || {}),
...composedConversationMemory.metadata,
},
};
const autoGraphResult = await withOperationTimeout(
runAutoAgentGraph({
text: smartAutoInputText,
@ -3373,14 +3341,10 @@ export async function POST(request: Request) {
requestResolvedMediaInput.historyUserImageUrls.length,
currentUploadImageCount: requestResolvedMediaInput.currentMessageImageUrls.length,
historyMessages,
context: autoContextText || undefined,
context:
composedConversationMemory.contextSummary || autoContextText || undefined,
preferredModelId: preferredAgentModelId,
threadState: buildAgentThreadStateFromStoryboardTask(
activeStoryboardTaskForChat,
{
storyboardPreferenceMemory,
}
),
threadState: composedThreadState,
}),
"runAutoAgentGraph",
autoGraphTimeoutMs,
@ -4718,6 +4682,14 @@ export async function POST(request: Request) {
sourceText: requestTextContent.slice(0, 500),
reusable: true,
}),
imageWorkflowMemory: buildImageWorkflowMemoryUpdate({
existing: imageWorkflowMemory,
lastIntent: "vision",
lastOptimizedPrompt: promptOnlyOptimizedPrompt.slice(0, 1000),
lastUserPrompt: requestTextContent.slice(0, 500),
lastRequestedImageCount: 1,
lastSourceImageUrls: promptOnlySourceImageUrls,
}),
imageSummary: promptOnlyImageSummary.slice(0, 1200),
sourceImageUrls: promptOnlySourceImageUrls,
},
@ -6214,6 +6186,12 @@ export async function POST(request: Request) {
try {
const resultAssetIds = resultAssetId ? [resultAssetId] : null;
const resultImageUrls = resultOriginalUrl ? [resultOriginalUrl] : null;
const existingImageWorkflowMemory = extractImageWorkflowMemory(requestWorkingState);
const existingSummary =
requestWorkingState?.lastTurnSummary &&
typeof requestWorkingState.lastTurnSummary === "object"
? (requestWorkingState.lastTurnSummary as Record<string, unknown>)
: {};
await upsertConversationWorkingState({
userId: session.user.id,
chatId: id,
@ -6227,6 +6205,17 @@ export async function POST(request: Request) {
lastResolvedTargetAssetIds: resultAssetIds,
lastResolvedTargetImageUrls: resultImageUrls,
lastMessageId: assistantMessageId,
lastTurnSummary: {
...existingSummary,
imageWorkflowMemory: buildImageWorkflowMemoryUpdate({
existing: existingImageWorkflowMemory,
lastIntent: "image_edit",
lastUserPrompt: requestTextContent.slice(0, 500),
lastRequestedImageCount: resultImageUrls?.length || 1,
lastSourceImageUrls: directAgentToolTargetImageUrls,
lastResultImageUrls: resultImageUrls,
}),
},
});
} catch (memoryError) {
console.warn(

View File

@ -3523,6 +3523,11 @@ export function CanvasChatPanel({
}
const latestNodes = useCanvasStore.getState().nodes;
const suppressedMediaKeys = new Set(
(useCanvasStore.getState().getCanvasState().suppressedMediaKeys || [])
.map((item) => String(item || "").trim())
.filter(Boolean)
);
const existingImageUrlKeys = new Set(
latestNodes
.filter((node) => node.type === "image" && (node.data as any)?.src)
@ -3572,6 +3577,12 @@ export function CanvasChatPanel({
continue;
}
const messageImageKey = messageId ? `${messageId}::${sourceImageKey}` : "";
if (
suppressedMediaKeys.has(`image:${messageImageKey}`) ||
suppressedMediaKeys.has(`image-url:${url}`)
) {
continue;
}
if (messageImageKey && existingMessageImageKeys.has(messageImageKey)) {
continue;
}
@ -3642,6 +3653,9 @@ export function CanvasChatPanel({
if (!key || existingVideoUrlKeys.has(key)) {
continue;
}
if (suppressedMediaKeys.has(`video:${url}`)) {
continue;
}
existingVideoUrlKeys.add(key);
const currentNodes = useCanvasStore.getState().nodes;
const existingNodes = currentNodes.length + addedImageCount + addedVideoCount;
@ -4344,6 +4358,11 @@ export function CanvasChatPanel({
let cancelled = false;
const processNewAssistantMessages = async () => {
const suppressedMediaKeys = new Set(
(useCanvasStore.getState().getCanvasState().suppressedMediaKeys || [])
.map((item) => String(item || "").trim())
.filter(Boolean)
);
const existingImageUrlKeys = new Set(
nodes
.filter((n) => n.type === "image" && (n.data as any)?.src)
@ -4465,6 +4484,12 @@ export function CanvasChatPanel({
return acc;
}
const messageImageKey = `${message.id}::${sourceImageKey}`;
if (
suppressedMediaKeys.has(`image:${messageImageKey}`) ||
suppressedMediaKeys.has(`image-url:${url}`)
) {
return acc;
}
if (existingMessageImageKeys.has(messageImageKey)) {
return acc;
}
@ -4556,6 +4581,7 @@ export function CanvasChatPanel({
const newVideoUrls = videoUrls.filter((url) => {
const key = normalizeMediaUrl(url);
if (!key || existingVideoUrlKeys.has(key)) return false;
if (suppressedMediaKeys.has(`video:${url}`)) return false;
existingVideoUrlKeys.add(key);
return true;
});

View File

@ -861,7 +861,7 @@ function sanitizeRecommendedImageParams(
if (typeof params.imageCount === "number" && Number.isFinite(params.imageCount)) {
const min = 1;
const max = intent === "image_edit" ? 1 : 4;
const max = intent === "image_edit" ? 1 : 12;
safe.imageCount = Math.min(Math.max(Math.round(params.imageCount), min), max);
}
if (intent === "image_edit") {
@ -894,12 +894,18 @@ function heuristicImageParams(
if (analysis.intent === "image_edit") {
params.imageCount = 1;
} else if (/多张|四张|4张|三张|3张/.test(normalized)) {
params.imageCount = /四张|4张/.test(normalized) ? 4 : 3;
} else if (/两张|2张|双图/.test(normalized)) {
params.imageCount = 2;
} else {
params.imageCount = 1;
const explicitCountMatch = normalized.match(/([1-9]\d?)张/);
const explicitCount = explicitCountMatch ? Number(explicitCountMatch[1]) : 0;
if (explicitCount >= 1) {
params.imageCount = Math.min(explicitCount, 12);
} else if (/多张|四张|4张|三张|3张/.test(normalized)) {
params.imageCount = /四张|4张/.test(normalized) ? 4 : 3;
} else if (/两张|2张|双图/.test(normalized)) {
params.imageCount = 2;
} else {
params.imageCount = 1;
}
}
if (/横图|横版|宽屏|封面|banner|海报/.test(normalized)) {

View File

@ -0,0 +1,201 @@
import "server-only";
import type { ImageWorkflowMemory } from "@/lib/ai/image-workflow-memory";
import { mentionsImageEditFollowup } from "@/lib/ai/image-edit-subagent";
import { isPromptCarryForwardRequest } from "@/lib/ai/prompt-memory";
export interface PendingGridConfirmation {
gridType: "grid_2x2" | "grid_3x2" | "grid_3x3";
shotCount: 4 | 6 | 9;
label: string;
originalPrompt: string;
}
export type ConversationActionKind =
| "default"
| "grid_confirmation_followup"
| "active_storyboard_followup"
| "image_edit_followup"
| "prompt_carry_forward";
export interface ConversationActionResolution {
kind: ConversationActionKind;
forceAgentConversation: boolean;
shouldKeepSelectedModel: boolean;
effectiveInputText?: string;
usePromptCarryForward: boolean;
}
export function isConfirmedPromptGenerationRequest(text: string): boolean {
const normalized = String(text || "").toLowerCase().replace(/\s+/g, "");
if (!normalized) return false;
const refersPrompt =
/根据这个提示词|按这个提示词|就按这个提示词|按刚才的提示词|按上面的提示词|用这个提示词|用刚才的提示词|按该提示词/.test(
normalized,
);
const wantsImageGeneration =
/生图|生成图片|出图|画出来|生成一张|来一张|开始生成|按此生成|按这个生成/.test(normalized);
return refersPrompt && wantsImageGeneration;
}
export function detectRequestedStoryboardGrid(text: string): PendingGridConfirmation | null {
const normalized = String(text || "").toLowerCase().replace(/\s+/g, "");
if (!normalized) return null;
if (/4宫格|四宫格|2x2|2×2/.test(normalized)) {
return {
gridType: "grid_2x2",
shotCount: 4,
label: "4宫格",
originalPrompt: String(text || "").trim(),
};
}
if (/6宫格|六宫格|3x2|3×2|2x3|2×3/.test(normalized)) {
return {
gridType: "grid_3x2",
shotCount: 6,
label: "6宫格",
originalPrompt: String(text || "").trim(),
};
}
if (/9宫格|九宫格|3x3|3×3/.test(normalized)) {
return {
gridType: "grid_3x3",
shotCount: 9,
label: "9宫格",
originalPrompt: String(text || "").trim(),
};
}
return null;
}
export function isGridConfirmationReply(text: string): boolean {
const normalized = String(text || "").toLowerCase().replace(/\s+/g, "");
if (!normalized) return false;
return /确认|就按这个|开始生成|可以开始了|没问题|对的|就这样/.test(normalized);
}
export function extractPendingGridConfirmationFromWorkingState(
workingState?: {
lastTurnSummary?: unknown;
} | null,
): PendingGridConfirmation | null {
const summary = workingState?.lastTurnSummary;
if (!summary || typeof summary !== "object") return null;
const summaryRecord = summary as Record<string, unknown>;
const pending = summaryRecord.pendingGridConfirmation;
if (!pending || typeof pending !== "object") return null;
const record = pending as Record<string, unknown>;
const gridType = String(record.gridType || "").trim();
const shotCount = Number(record.shotCount || 0);
const label = String(record.label || "").trim();
const originalPrompt = String(record.originalPrompt || "").trim();
if (
!originalPrompt ||
!label ||
!["grid_2x2", "grid_3x2", "grid_3x3"].includes(gridType) ||
![4, 6, 9].includes(shotCount)
) {
return null;
}
return {
gridType: gridType as PendingGridConfirmation["gridType"],
shotCount: shotCount as PendingGridConfirmation["shotCount"],
label,
originalPrompt,
};
}
export function buildGridConfirmationMessage(grid: PendingGridConfirmation): string {
return [
`我看到你希望按 ${grid.label} 来生成分镜图。`,
"为了确保后续自动切片稳定,我会按标准网格、统一格线、固定顺序来出图。",
`请确认是否按 ${grid.label} 标准版式开始生成。`,
"",
"如果确认,请回复:",
`- 确认按${grid.label}生成`,
"",
"如果要修改,也可以直接回复:",
"- 改成 9 宫格",
"- 改成 6 宫格",
"- 改成 4 宫格",
].join("\n");
}
export function resolveConversationAction(params: {
text: string;
pendingGridConfirmation?: PendingGridConfirmation | null;
hasActiveStoryboardTask: boolean;
imageWorkflowMemory?: ImageWorkflowMemory | null;
confirmedPrompt?: string;
hasImageInputForPromptCarryForward: boolean;
}) {
const requestedGrid = detectRequestedStoryboardGrid(params.text);
const shouldContinuePendingGrid =
Boolean(params.pendingGridConfirmation) &&
(isGridConfirmationReply(params.text) || requestedGrid !== null);
if (shouldContinuePendingGrid) {
return {
kind: "grid_confirmation_followup",
forceAgentConversation: true,
shouldKeepSelectedModel: true,
effectiveInputText: params.pendingGridConfirmation?.originalPrompt,
usePromptCarryForward: false,
} satisfies ConversationActionResolution;
}
if (params.hasActiveStoryboardTask) {
return {
kind: "active_storyboard_followup",
forceAgentConversation: true,
shouldKeepSelectedModel: true,
usePromptCarryForward: false,
} satisfies ConversationActionResolution;
}
const hasImageEditFollowup =
params.imageWorkflowMemory?.lastIntent === "image_edit" &&
(mentionsImageEditFollowup(params.text) ||
Boolean(params.imageWorkflowMemory?.lastResultImageUrls?.length));
if (hasImageEditFollowup) {
return {
kind: "image_edit_followup",
forceAgentConversation: true,
shouldKeepSelectedModel: true,
usePromptCarryForward: false,
} satisfies ConversationActionResolution;
}
const usePromptCarryForward =
Boolean(params.confirmedPrompt) &&
(isConfirmedPromptGenerationRequest(params.text) ||
isPromptCarryForwardRequest({
text: params.text,
hasImage: params.hasImageInputForPromptCarryForward,
}));
if (usePromptCarryForward) {
return {
kind: "prompt_carry_forward",
forceAgentConversation: false,
shouldKeepSelectedModel: false,
usePromptCarryForward: true,
} satisfies ConversationActionResolution;
}
return {
kind: "default",
forceAgentConversation: false,
shouldKeepSelectedModel: false,
usePromptCarryForward: false,
} satisfies ConversationActionResolution;
}

View File

@ -0,0 +1,204 @@
import "server-only";
import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state";
import type { ImageWorkflowMemory } from "@/lib/ai/image-workflow-memory";
import type { PromptMemoryEntry } from "@/lib/ai/prompt-memory";
import type { StoryboardPreferenceMemory } from "@/lib/video-agent/storyboard-preference-memory";
import type {
ResolvedChatMediaInput,
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
export interface ComposedConversationMemory {
contextSummary: string;
memoryHints: string[];
metadata: Record<string, unknown>;
}
function normalizeText(value: unknown, max = 400): string | undefined {
const normalized = String(value || "").trim();
if (!normalized) return undefined;
return normalized.slice(0, max);
}
function uniqueStrings(values: Array<string | undefined>) {
return Array.from(
new Set(
values
.map((item) => normalizeText(item, 240))
.filter((item): item is string => Boolean(item))
)
);
}
function summarizeTargetResolution(target?: ResolvedTargetAssets | null) {
if (!target) return { lines: [] as string[], metadata: {} as Record<string, unknown> };
const lines: string[] = [];
lines.push(
`当前目标解析:mode=${target.resolutionMode}; confidence=${target.confidence}; fromHistory=${
target.fromHistory ? "yes" : "no"
}`
);
if (target.targetImageUrls.length > 0) {
lines.push(`当前命中的目标图片数:${target.targetImageUrls.length}`);
}
if (target.reason) {
lines.push(`目标解析原因:${target.reason}`);
}
return {
lines,
metadata: {
targetResolutionMode: target.resolutionMode,
targetResolutionConfidence: target.confidence,
targetImageCount: target.targetImageUrls.length,
targetReason: target.reason,
},
};
}
function summarizeResolvedMedia(media?: ResolvedChatMediaInput | null) {
if (!media) return { lines: [] as string[], metadata: {} as Record<string, unknown> };
return {
lines: [
`当前媒体输入:source=${media.source}; hasImage=${media.hasImage ? "yes" : "no"}`,
media.currentMessageImageUrls.length > 0
? `本轮上传图片数:${media.currentMessageImageUrls.length}`
: "",
media.selectedImageUrls.length > 0
? `当前选中图片数:${media.selectedImageUrls.length}`
: "",
media.historyAssistantImageUrls.length + media.historyUserImageUrls.length > 0
? `历史可参考图片数:${
media.historyAssistantImageUrls.length + media.historyUserImageUrls.length
}`
: "",
].filter(Boolean),
metadata: {
mediaSource: media.source,
hasImage: media.hasImage,
currentImageCount: media.currentMessageImageUrls.length,
selectedImageCount: media.selectedImageUrls.length,
historyImageCount:
media.historyAssistantImageUrls.length + media.historyUserImageUrls.length,
},
};
}
function summarizePromptMemory(promptMemory?: PromptMemoryEntry | null) {
if (!promptMemory?.optimizedPrompt) {
return { lines: [] as string[], hints: [] as string[], metadata: {} as Record<string, unknown> };
}
const preview = promptMemory.optimizedPrompt.replace(/\s+/g, " ").trim().slice(0, 220);
return {
lines: [`上一轮可复用提示词:${preview}`],
hints: [
"如果用户明确要求沿用上一轮提示词继续做图,优先把这段提示词作为本轮生成基础。",
],
metadata: {
promptMemory,
},
};
}
function summarizeImageWorkflow(memory?: ImageWorkflowMemory | null) {
if (!memory) {
return { lines: [] as string[], hints: [] as string[], metadata: {} as Record<string, unknown> };
}
const lines = [
memory.lastOptimizedPrompt
? `上一轮优化提示词摘要:${memory.lastOptimizedPrompt.replace(/\s+/g, " ").slice(0, 180)}`
: "",
memory.lastRequestedImageCount
? `上一轮目标出图数:${memory.lastRequestedImageCount} 张`
: "",
memory.lastAspectRatio ? `上一轮画幅:${memory.lastAspectRatio}` : "",
memory.lastImageSize ? `上一轮清晰度:${memory.lastImageSize}` : "",
memory.lastSourceImageUrls?.length
? `上一轮参考/目标图数量:${memory.lastSourceImageUrls.length}`
: "",
memory.lastResultImageUrls?.length
? `上一轮生成结果数量:${memory.lastResultImageUrls.length}`
: "",
].filter(Boolean);
const hints = memory.lastOptimizedPrompt
? ["如果用户要求沿用上一轮提示词继续做图,应优先复用这段提示词。"]
: [];
return {
lines,
hints,
metadata: {
imageWorkflowMemory: memory,
},
};
}
function summarizeStoryboardPreference(memory?: StoryboardPreferenceMemory | null) {
if (!memory) {
return { lines: [] as string[], hints: [] as string[], metadata: {} as Record<string, unknown> };
}
const lines = [
memory.preferredGridType ? `用户偏好宫格:${memory.preferredGridType}` : "",
memory.preferredShotCount ? `用户偏好镜头数:${memory.preferredShotCount}` : "",
memory.prefersConfirmation === true ? "用户偏好先确认再执行" : "",
memory.lastUsedGridType && memory.lastUsedShotCount
? `最近一次分镜任务:${memory.lastUsedGridType} / ${memory.lastUsedShotCount} 段`
: "",
].filter(Boolean);
const hints = memory.prefersConfirmation
? ["如果当前任务涉及分镜或高成本执行,优先先确认再推进。"]
: [];
return {
lines,
hints,
metadata: {
storyboardPreferenceMemory: memory,
},
};
}
export function composeConversationMemory(params: {
autoContextText?: string;
resolvedMediaInput?: ResolvedChatMediaInput | null;
targetResolution?: ResolvedTargetAssets | null;
promptMemory?: PromptMemoryEntry | null;
imageWorkflowMemory?: ImageWorkflowMemory | null;
storyboardPreferenceMemory?: StoryboardPreferenceMemory | null;
threadState?: AgentThreadState | null;
}) {
const targetSummary = summarizeTargetResolution(params.targetResolution);
const mediaSummary = summarizeResolvedMedia(params.resolvedMediaInput);
const promptSummary = summarizePromptMemory(params.promptMemory);
const imageWorkflowSummary = summarizeImageWorkflow(params.imageWorkflowMemory);
const storyboardSummary = summarizeStoryboardPreference(params.storyboardPreferenceMemory);
const contextSummary = uniqueStrings([
params.autoContextText,
...mediaSummary.lines,
...targetSummary.lines,
...promptSummary.lines,
...imageWorkflowSummary.lines,
...storyboardSummary.lines,
]).join("\n");
const memoryHints = uniqueStrings([
...(params.threadState?.memoryHints || []),
...promptSummary.hints,
...imageWorkflowSummary.hints,
...storyboardSummary.hints,
]);
const metadata: Record<string, unknown> = {
...(params.threadState?.metadata || {}),
...mediaSummary.metadata,
...targetSummary.metadata,
...promptSummary.metadata,
...imageWorkflowSummary.metadata,
...storyboardSummary.metadata,
};
return {
contextSummary,
memoryHints,
metadata,
} satisfies ComposedConversationMemory;
}

View File

@ -0,0 +1,133 @@
import "server-only";
import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state";
import type { PromptMemoryEntry } from "@/lib/ai/prompt-memory";
import type { ImageWorkflowMemory } from "@/lib/ai/image-workflow-memory";
import type { StoryboardPreferenceMemory } from "@/lib/video-agent/storyboard-preference-memory";
import { buildAgentThreadStateFromStoryboardTask } from "@/lib/video-agent/agent-thread-state";
type StoryboardTaskDetail = Parameters<typeof buildAgentThreadStateFromStoryboardTask>[0];
function normalizeText(value: unknown, max = 240): string | undefined {
const normalized = String(value || "").trim();
if (!normalized) return undefined;
return normalized.slice(0, max);
}
export function buildConversationThreadState(params: {
activeStoryboardTask?: StoryboardTaskDetail | null;
storyboardPreferenceMemory?: StoryboardPreferenceMemory | null;
promptMemory?: PromptMemoryEntry | null;
imageWorkflowMemory?: ImageWorkflowMemory | null;
}): AgentThreadState | null {
const storyboardState = buildAgentThreadStateFromStoryboardTask(
params.activeStoryboardTask,
{
storyboardPreferenceMemory: params.storyboardPreferenceMemory,
promptMemory: params.promptMemory,
},
);
if (params.activeStoryboardTask?.task?.id) {
return {
workflowState: storyboardState?.workflowState || null,
memoryHints: [
...(storyboardState?.memoryHints || []),
params.imageWorkflowMemory?.lastOptimizedPrompt
? "当前会话里也保留了最近一次图片工作流记忆,可辅助理解用户是在继续哪一轮生图。"
: "",
].filter(Boolean),
contextSummary:
storyboardState?.contextSummary ||
normalizeText(params.imageWorkflowMemory?.lastUserPrompt, 320),
metadata: {
...(storyboardState?.metadata || {}),
imageWorkflowMemory: params.imageWorkflowMemory || undefined,
},
};
}
const hasImageFollowup =
Boolean(params.promptMemory?.optimizedPrompt) ||
Boolean(params.imageWorkflowMemory?.lastOptimizedPrompt) ||
Boolean(params.imageWorkflowMemory?.lastResultImageUrls?.length) ||
Boolean(params.imageWorkflowMemory?.lastSourceImageUrls?.length);
if (!hasImageFollowup && !params.storyboardPreferenceMemory) {
return null;
}
const preferredCount = params.imageWorkflowMemory?.lastRequestedImageCount;
const preferredRatio = params.imageWorkflowMemory?.lastAspectRatio;
const preferredSize = params.imageWorkflowMemory?.lastImageSize;
const sourceCount = params.imageWorkflowMemory?.lastSourceImageUrls?.length || 0;
const resultCount = params.imageWorkflowMemory?.lastResultImageUrls?.length || 0;
const imageFollowupType =
params.imageWorkflowMemory?.lastIntent === "image_edit"
? "image_edit_followup"
: "image_generation_followup";
const imageFollowupTitle =
imageFollowupType === "image_edit_followup"
? "连续修图工作流"
: "连续生图工作流";
return {
workflowState: hasImageFollowup
? {
workflowId: "image-generation-followup",
type: imageFollowupType,
status: "draft",
title: imageFollowupTitle,
summary: [
params.promptMemory?.optimizedPrompt || params.imageWorkflowMemory?.lastOptimizedPrompt
? "当前会话中存在上一轮可复用提示词。"
: "",
preferredCount ? `上一轮目标出图数:${preferredCount} 张。` : "",
preferredRatio ? `上一轮画幅:${preferredRatio}。` : "",
preferredSize ? `上一轮清晰度:${preferredSize}。` : "",
sourceCount > 0 ? `上一轮参考图数:${sourceCount}。` : "",
resultCount > 0 ? `上一轮结果图数:${resultCount}。` : "",
]
.filter(Boolean)
.join(" "),
confirmPhrases: ["按上一个提示词生成", "继续生成", "就按这个来"],
revisionHints: ["换一版", "保持提示词不变再来一组", "沿用上一个提示词"],
pendingItems: [],
metadata: {
promptMemory: params.promptMemory || undefined,
imageWorkflowMemory: params.imageWorkflowMemory || undefined,
storyboardPreferenceMemory: params.storyboardPreferenceMemory || undefined,
},
}
: null,
memoryHints: [
params.promptMemory?.optimizedPrompt
? "当前会话存在上一轮可复用提示词;如果用户明确要求沿用它继续生成,应优先复用。"
: "",
params.imageWorkflowMemory?.lastResultImageUrls?.length
? "当前会话保留了上一轮生成结果图,可用于理解“继续做这张/沿用上一版”的指代。"
: "",
params.imageWorkflowMemory?.lastSourceImageUrls?.length
? "当前会话保留了上一轮参考图/目标图,可用于连续图生图理解。"
: "",
params.storyboardPreferenceMemory
? "当前会话也存在 storyboard 偏好记忆,后续如果转入分镜任务可继续复用。"
: "",
].filter(Boolean),
contextSummary: [
params.promptMemory?.optimizedPrompt
? `上一轮提示词摘要:${normalizeText(params.promptMemory.optimizedPrompt, 180)}`
: "",
params.imageWorkflowMemory?.lastUserPrompt
? `上一轮用户原始需求:${normalizeText(params.imageWorkflowMemory.lastUserPrompt, 180)}`
: "",
]
.filter(Boolean)
.join("\n"),
metadata: {
promptMemory: params.promptMemory || undefined,
imageWorkflowMemory: params.imageWorkflowMemory || undefined,
storyboardPreferenceMemory: params.storyboardPreferenceMemory || undefined,
},
};
}

View File

@ -0,0 +1,142 @@
import "server-only";
import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state";
import type { AgentExecutionPlan, AgentPlannedToolCall } from "@/lib/ai/auto-agent-graph";
import type { AgentSpecialistRoute } from "@/lib/ai/specialists";
import type { AgentProjectToolDefinition } from "@/lib/ai/tools-registry";
import { getImageWorkflowPlanningHints } from "@/lib/ai/image-workflow-memory";
function normalizeCompactText(text: string) {
return String(text || "").toLowerCase().replace(/\s+/g, "").trim();
}
export function mentionsImageEditFollowup(text: string): boolean {
return /继续改|继续调整|再改一下|再调一下|上一版|上一张结果|这张继续改|保留.*不变|只改|继续优化这张图|继续修这张图/.test(
normalizeCompactText(text)
);
}
function hasImageEditWorkflow(threadState?: AgentThreadState | null) {
return threadState?.workflowState?.type === "image_edit_followup";
}
export function buildImageEditSpecialistRoute(params: {
text: string;
threadState?: AgentThreadState | null;
hasImage: boolean;
}): AgentSpecialistRoute | null {
const normalized = normalizeCompactText(params.text);
const workflowHints = getImageWorkflowPlanningHints(params.threadState);
const hasWorkflow = hasImageEditWorkflow(params.threadState);
const mentionsFollowupEdit = mentionsImageEditFollowup(params.text);
if (!hasWorkflow && !mentionsFollowupEdit && workflowHints.length === 0) {
return null;
}
const planningHints = [
hasWorkflow
? "当前回合处于连续修图 workflow,应优先理解为对上一轮结果图或目标图继续编辑。"
: "",
mentionsFollowupEdit
? "用户当前表达的是继续修图或局部重调,不要误解成重新生图。"
: "",
params.hasImage
? "如果本轮带了图片,应优先把它理解为当前修图输入或替换后的参考图。"
: "",
...workflowHints,
].filter(Boolean);
return {
domain: "image_edit_memory",
specialistId: "image-edit-subagent",
reason: hasWorkflow ? "当前回合命中连续修图工作流" : "当前回合命中连续修图语义",
planningHints,
toolAllowlist: [
"respondDirect",
"editImage",
"removeBackground",
"enhanceImage",
"extendImage",
"resizeImage",
"boxCutout",
"useSelectedCanvasImage",
"useLatestResultImage",
"useOriginalUploadImage",
"publishToCanvas",
],
};
}
function hasTool(
availableTools: AgentProjectToolDefinition[],
name: AgentPlannedToolCall["name"],
) {
return availableTools.some((tool) => tool.name === name);
}
function upsertTool(
toolCalls: AgentPlannedToolCall[],
name: AgentPlannedToolCall["name"],
reason: string,
options?: { prepend?: boolean },
) {
const existing = toolCalls.find((tool) => tool.name === name);
if (existing) return toolCalls;
const nextTool = { name, reason } satisfies AgentPlannedToolCall;
return options?.prepend ? [nextTool, ...toolCalls] : [...toolCalls, nextTool];
}
export function refinePlanWithImageEditSubagent(params: {
text: string;
plan: AgentExecutionPlan;
availableTools: AgentProjectToolDefinition[];
threadState?: AgentThreadState | null;
hasImage: boolean;
}): AgentExecutionPlan {
const hasWorkflow = hasImageEditWorkflow(params.threadState);
const hasImageWorkflowHints = getImageWorkflowPlanningHints(params.threadState).length > 0;
const mentionsFollowupEdit = mentionsImageEditFollowup(params.text);
if (!hasWorkflow && !hasImageWorkflowHints && !mentionsFollowupEdit) {
return params.plan;
}
let toolCalls = [...params.plan.toolCalls];
if (hasTool(params.availableTools, "useLatestResultImage")) {
toolCalls = upsertTool(
toolCalls,
"useLatestResultImage",
"image-edit 子代理优先绑定上一轮结果图作为连续修图目标",
{ prepend: true }
);
}
if (hasTool(params.availableTools, "editImage")) {
toolCalls = upsertTool(
toolCalls,
"editImage",
"image-edit 子代理要求优先走连续修图执行链路",
{ prepend: true }
);
}
if (hasTool(params.availableTools, "publishToCanvas")) {
toolCalls = upsertTool(
toolCalls,
"publishToCanvas",
"image-edit 子代理建议把修图结果同步回画布"
);
}
return {
...params.plan,
nextAction: "edit_image",
intent: "image_edit",
responseMode: "tool_then_execute",
preferredCapability:
params.plan.preferredCapability === "respond"
? "generic_edit"
: params.plan.preferredCapability,
shouldUseStructuredReply: true,
toolCalls,
};
}

View File

@ -0,0 +1,131 @@
import "server-only";
import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state";
import type { AgentExecutionPlan, AgentPlannedToolCall } from "@/lib/ai/auto-agent-graph";
import type { AgentSpecialistRoute } from "@/lib/ai/specialists";
import type { AgentProjectToolDefinition } from "@/lib/ai/tools-registry";
import {
getImageWorkflowPlanningHints,
} from "@/lib/ai/image-workflow-memory";
import {
getPromptMemoryPlanningHints,
isPromptCarryForwardRequest,
} from "@/lib/ai/prompt-memory";
function normalizeCompactText(text: string) {
return String(text || "").toLowerCase().replace(/\s+/g, "").trim();
}
export function buildImageGenerationSpecialistRoute(params: {
text: string;
threadState?: AgentThreadState | null;
hasImage: boolean;
}): AgentSpecialistRoute | null {
const normalized = normalizeCompactText(params.text);
const promptHints = getPromptMemoryPlanningHints(params.threadState);
const workflowHints = getImageWorkflowPlanningHints(params.threadState);
const hasPromptMemory = promptHints.length > 0;
const hasImageWorkflowMemory = workflowHints.length > 0;
const carryForward = isPromptCarryForwardRequest({
text: params.text,
hasImage: params.hasImage,
});
const mentionsPromptExecution =
/提示词|prompt|关键词|咒语/.test(normalized) &&
/生成|生图|出图|继续做|继续生成|执行/.test(normalized);
if (!hasPromptMemory && !hasImageWorkflowMemory && !carryForward && !mentionsPromptExecution) {
return null;
}
const planningHints = [
carryForward
? "用户当前要求沿用上一轮优化提示词继续执行,应优先理解为连续生图执行。"
: "",
hasPromptMemory
? "当前会话里存在上一轮可复用提示词,如果用户在说“上一个提示词/刚才那个 prompt”,优先复用这段提示词。"
: "",
hasImageWorkflowMemory
? "当前会话里存在图片工作流记忆,优先参考上一轮参考图、结果图、数量和画幅偏好。"
: "",
params.hasImage
? "如果当前回合上传了新图片,并要求沿用上一轮提示词,应理解为“上一轮提示词 + 当前图片条件”的联合执行。"
: "",
...promptHints,
...workflowHints,
].filter(Boolean);
return {
domain: "image_generation_memory",
specialistId: "image-generation-subagent",
reason: carryForward ? "当前回合命中提示词接力生图" : "当前回合命中提示词相关的连续生图任务",
planningHints,
toolAllowlist: [
"respondDirect",
"generateImage",
"useSelectedCanvasImage",
"useLatestResultImage",
"useOriginalUploadImage",
"publishToCanvas",
],
};
}
function hasTool(
availableTools: AgentProjectToolDefinition[],
name: AgentPlannedToolCall["name"],
) {
return availableTools.some((tool) => tool.name === name);
}
function upsertTool(
toolCalls: AgentPlannedToolCall[],
name: AgentPlannedToolCall["name"],
reason: string,
options?: { prepend?: boolean },
) {
const existing = toolCalls.find((tool) => tool.name === name);
if (existing) return toolCalls;
const nextTool = { name, reason } satisfies AgentPlannedToolCall;
return options?.prepend ? [nextTool, ...toolCalls] : [...toolCalls, nextTool];
}
export function refinePlanWithImageGenerationSubagent(params: {
text: string;
plan: AgentExecutionPlan;
availableTools: AgentProjectToolDefinition[];
threadState?: AgentThreadState | null;
hasImage: boolean;
}): AgentExecutionPlan {
const carryForward = isPromptCarryForwardRequest({
text: params.text,
hasImage: params.hasImage,
});
const hasPromptMemory = getPromptMemoryPlanningHints(params.threadState).length > 0;
const hasImageWorkflowMemory = getImageWorkflowPlanningHints(params.threadState).length > 0;
if (!carryForward && !hasPromptMemory && !hasImageWorkflowMemory) {
return params.plan;
}
let toolCalls = [...params.plan.toolCalls];
if (hasTool(params.availableTools, "generateImage")) {
toolCalls = [{ name: "generateImage", reason: "image-generation 子代理要求优先走连续生图执行链路" }];
}
if (hasTool(params.availableTools, "publishToCanvas")) {
toolCalls = upsertTool(
toolCalls,
"publishToCanvas",
"image-generation 子代理建议把连续生图结果同步到画布"
);
}
return {
...params.plan,
nextAction: "generate_image",
intent: "image_gen",
responseMode: "tool_then_execute",
preferredCapability: "generate_image",
shouldUseStructuredReply: true,
toolCalls,
};
}

View File

@ -0,0 +1,159 @@
import "server-only";
import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state";
export interface ImageWorkflowMemory {
lastIntent?: "image_gen" | "image_edit" | "vision";
lastOptimizedPrompt?: string;
lastUserPrompt?: string;
lastRequestedImageCount?: number;
lastAspectRatio?: string;
lastImageSize?: string;
lastSourceImageUrls?: string[];
lastResultImageUrls?: string[];
}
function normalizeText(value: unknown, max = 2000): string | undefined {
const normalized = String(value || "").trim();
if (!normalized) return undefined;
return normalized.slice(0, max);
}
function normalizeImageUrls(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const urls = Array.from(
new Set(
value
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
);
return urls.length > 0 ? urls : undefined;
}
function normalizeImageCount(value: unknown): number | undefined {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return undefined;
const rounded = Math.round(parsed);
if (rounded < 1 || rounded > 12) return undefined;
return rounded;
}
export function extractImageWorkflowMemory(
workingState?: {
lastTurnSummary?: unknown;
lastResolvedTargetImageUrls?: string[] | null;
lastGeneratedImageUrls?: string[] | null;
} | null,
): ImageWorkflowMemory | null {
const summary = workingState?.lastTurnSummary;
let memory: ImageWorkflowMemory | null = null;
if (summary && typeof summary === "object") {
const summaryRecord = summary as Record<string, unknown>;
const imageWorkflowMemory = summaryRecord.imageWorkflowMemory;
if (imageWorkflowMemory && typeof imageWorkflowMemory === "object") {
const record = imageWorkflowMemory as Record<string, unknown>;
memory = {
lastIntent:
record.lastIntent === "image_gen" ||
record.lastIntent === "image_edit" ||
record.lastIntent === "vision"
? record.lastIntent
: undefined,
lastOptimizedPrompt: normalizeText(record.lastOptimizedPrompt, 4000),
lastUserPrompt: normalizeText(record.lastUserPrompt, 1000),
lastRequestedImageCount: normalizeImageCount(record.lastRequestedImageCount),
lastAspectRatio: normalizeText(record.lastAspectRatio, 32),
lastImageSize: normalizeText(record.lastImageSize, 32),
lastSourceImageUrls: normalizeImageUrls(record.lastSourceImageUrls),
lastResultImageUrls: normalizeImageUrls(record.lastResultImageUrls),
};
}
}
const fallbackTargetUrls = normalizeImageUrls(workingState?.lastResolvedTargetImageUrls);
const fallbackGeneratedUrls = normalizeImageUrls(workingState?.lastGeneratedImageUrls);
if (!memory) {
if (!fallbackTargetUrls && !fallbackGeneratedUrls) return null;
memory = {};
}
if (!memory.lastSourceImageUrls && fallbackTargetUrls) {
memory.lastSourceImageUrls = fallbackTargetUrls;
}
if (!memory.lastResultImageUrls && fallbackGeneratedUrls) {
memory.lastResultImageUrls = fallbackGeneratedUrls;
}
return memory;
}
export function buildImageWorkflowMemoryUpdate(params: {
existing?: ImageWorkflowMemory | null;
lastIntent?: "image_gen" | "image_edit" | "vision";
lastOptimizedPrompt?: string;
lastUserPrompt?: string;
lastRequestedImageCount?: number;
lastAspectRatio?: string;
lastImageSize?: string;
lastSourceImageUrls?: string[] | null;
lastResultImageUrls?: string[] | null;
}) {
return {
lastIntent: params.lastIntent ?? params.existing?.lastIntent,
lastOptimizedPrompt:
normalizeText(params.lastOptimizedPrompt, 4000) ??
params.existing?.lastOptimizedPrompt,
lastUserPrompt:
normalizeText(params.lastUserPrompt, 1000) ?? params.existing?.lastUserPrompt,
lastRequestedImageCount:
normalizeImageCount(params.lastRequestedImageCount) ??
params.existing?.lastRequestedImageCount,
lastAspectRatio:
normalizeText(params.lastAspectRatio, 32) ?? params.existing?.lastAspectRatio,
lastImageSize:
normalizeText(params.lastImageSize, 32) ?? params.existing?.lastImageSize,
lastSourceImageUrls:
normalizeImageUrls(params.lastSourceImageUrls) ??
params.existing?.lastSourceImageUrls,
lastResultImageUrls:
normalizeImageUrls(params.lastResultImageUrls) ??
params.existing?.lastResultImageUrls,
} satisfies ImageWorkflowMemory;
}
export function getImageWorkflowPlanningHints(
threadState?: AgentThreadState | null,
): string[] {
const metadata = threadState?.metadata;
const memory =
metadata && typeof metadata === "object" && !Array.isArray(metadata)
? ((metadata.imageWorkflowMemory as ImageWorkflowMemory | undefined) || undefined)
: undefined;
if (!memory) return [];
const hints: string[] = [];
if (memory.lastOptimizedPrompt) {
hints.push(
`上一轮可复用提示词摘要:${memory.lastOptimizedPrompt.replace(/\s+/g, " ").slice(0, 180)}`
);
}
if (memory.lastRequestedImageCount) {
hints.push(`用户上一轮期望出图数量:${memory.lastRequestedImageCount} 张。`);
}
if (memory.lastAspectRatio) {
hints.push(`用户上一轮常用画幅:${memory.lastAspectRatio}。`);
}
if (memory.lastImageSize) {
hints.push(`用户上一轮常用清晰度:${memory.lastImageSize}。`);
}
if (memory.lastSourceImageUrls?.length) {
hints.push(`上一轮存在 ${memory.lastSourceImageUrls.length} 张参考/目标图。`);
}
if (memory.lastResultImageUrls?.length) {
hints.push(`上一轮生成结果数:${memory.lastResultImageUrls.length} 张。`);
}
return hints;
}

View File

@ -1,5 +1,7 @@
import "server-only";
import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state";
export interface PromptMemoryEntry {
optimizedPrompt: string;
sourceIntent?: string;
@ -48,6 +50,24 @@ export function extractPromptMemoryFromWorkingState(
return null;
}
export function extractConfirmedPromptFromWorkingState(
workingState?: {
lastTurnSummary?: unknown;
} | null,
): string | undefined {
const summary = workingState?.lastTurnSummary;
if (!summary || typeof summary !== "object") return undefined;
const summaryRecord = summary as Record<string, unknown>;
const resolutionMode =
typeof summaryRecord.resolutionMode === "string" ? summaryRecord.resolutionMode.trim() : "";
const optimizedPrompt =
typeof summaryRecord.optimizedPrompt === "string" ? summaryRecord.optimizedPrompt.trim() : "";
if (resolutionMode !== "prompt_only" || !optimizedPrompt) return undefined;
return optimizedPrompt;
}
export function buildPromptMemoryUpdate(params: {
optimizedPrompt: string;
sourceIntent?: string;
@ -90,3 +110,24 @@ export function isPromptCarryForwardRequest(params: {
return false;
}
export function getPromptMemoryPlanningHints(
threadState?: AgentThreadState | null,
): string[] {
const metadata = threadState?.metadata;
const memory =
metadata && typeof metadata === "object" && !Array.isArray(metadata)
? ((metadata.promptMemory as PromptMemoryEntry | undefined) || undefined)
: undefined;
if (!memory?.optimizedPrompt) return [];
const preview = memory.optimizedPrompt.replace(/\s+/g, " ").trim().slice(0, 180);
const hints = [
"当前会话中存在上一轮可复用的优化提示词;如果用户明确要求沿用、根据上一个提示词继续做图,应优先复用这段提示词。",
`上一轮优化提示词摘要:${preview}`,
];
if (memory.sourceText) {
hints.push(`该提示词来源于用户上一轮需求:${memory.sourceText}`);
}
return hints;
}

View File

@ -394,7 +394,7 @@ function sanitizeSuggestedParams(input?: {
const normalizedSize = input.imageSize?.trim().toUpperCase() || undefined;
const normalizedCount =
typeof input.imageCount === "number" && Number.isFinite(input.imageCount)
? Math.min(Math.max(Math.round(input.imageCount), 1), 4)
? Math.min(Math.max(Math.round(input.imageCount), 1), 12)
: undefined;
const output: NonNullable<OptimizeResult["recommendedImageParams"]> = {};
if (normalizedAspectRatio && allowedAspectRatios.has(normalizedAspectRatio)) {

View File

@ -1,5 +1,7 @@
export type AgentWorkflowType =
| "storyboard_video"
| "image_generation_followup"
| "image_edit_followup"
| "high_cost_video"
| "image_batch"
| "custom";
@ -111,4 +113,3 @@ export function isAwaitingUserConfirmationWorkflow(
): boolean {
return workflowState?.status === "awaiting_user_confirmation";
}

View File

@ -6,6 +6,8 @@ import type {
AgentSpecialistContext,
AgentSpecialistRoute,
} from "@/lib/ai/specialists";
import { buildImageEditSpecialistRoute } from "@/lib/ai/image-edit-subagent";
import { buildImageGenerationSpecialistRoute } from "@/lib/ai/image-generation-subagent";
import { buildStoryboardSpecialistRoute } from "@/lib/video-agent/storyboard-subagent";
export interface AgentSkillRouteResult {
@ -215,9 +217,22 @@ export function routeAgentSkill(
text: context.text,
threadState: context.threadState,
});
const mergedOverlays = storyboardOverlay
? [...overlays, storyboardOverlay]
: overlays;
const imageEditOverlay = buildImageEditSpecialistRoute({
text: context.text,
threadState: context.threadState,
hasImage: context.hasImage,
});
const imageGenerationOverlay = buildImageGenerationSpecialistRoute({
text: context.text,
threadState: context.threadState,
hasImage: context.hasImage,
});
const mergedOverlays = [
...overlays,
...(imageEditOverlay ? [imageEditOverlay] : []),
...(imageGenerationOverlay ? [imageGenerationOverlay] : []),
...(storyboardOverlay ? [storyboardOverlay] : []),
];
const toolAllowlist = dedupeToolNames([
...(primary.toolAllowlist || []),

View File

@ -1,5 +1,7 @@
import type { AnalysisResult } from "@/lib/ai/smart-analyzer";
import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state";
import { refinePlanWithImageEditSubagent } from "@/lib/ai/image-edit-subagent";
import { refinePlanWithImageGenerationSubagent } from "@/lib/ai/image-generation-subagent";
import { refinePlanWithStoryboardSubagent } from "@/lib/video-agent/storyboard-subagent";
import type {
AgentExecutionPlan,
@ -18,7 +20,9 @@ export type AgentSpecialistDomain =
| "conversation"
| "vision"
| "image_edit"
| "image_edit_memory"
| "image_generate"
| "image_generation_memory"
| "video_generate"
| "storyboard"
| "canvas";
@ -27,7 +31,9 @@ export type AgentSpecialistId =
| "conversation-coordinator"
| "vision-analyst"
| "image-edit-specialist"
| "image-edit-subagent"
| "image-generate-specialist"
| "image-generation-subagent"
| "video-specialist"
| "storyboard-subagent"
| "canvas-specialist";
@ -235,6 +241,16 @@ export function refinePlanWithSpecialist(
});
}
if (specialist.domain === "image_edit_memory") {
return refinePlanWithImageEditSubagent({
text,
plan,
availableTools,
threadState: input.threadState,
hasImage: input.hasImage,
});
}
if (specialist.domain === "image_generate") {
if (hasTool(availableTools, "generateImage")) {
toolCalls = [{ name: "generateImage", reason: "生图专家执行图片生成" }];
@ -250,6 +266,16 @@ export function refinePlanWithSpecialist(
});
}
if (specialist.domain === "image_generation_memory") {
return refinePlanWithImageGenerationSubagent({
text,
plan,
availableTools,
threadState: input.threadState,
hasImage: input.hasImage,
});
}
if (specialist.domain === "video_generate") {
if (hasTool(availableTools, "generateVideo")) {
toolCalls = [{ name: "generateVideo", reason: "视频专家执行视频生成" }];

View File

@ -71,6 +71,22 @@ function parseImagesFromMarkdown(markdown: string): ParsedImage[] {
return parsed;
}
function getProviderBatchLimit(providerType: string) {
switch (providerType) {
case "ruxa-image":
case "openai-image":
case "midjourney":
return 1;
case "gemini":
return 2;
case "alibaba-image":
case "fal-image":
return 6;
default:
return 2;
}
}
export async function generateCanvasImage(
request: CanvasGenerateImageRequest
): Promise<CanvasGenerateImageResult> {
@ -92,7 +108,7 @@ export async function generateCanvasImage(
: "2K";
const imageCount =
typeof request.imageCount === "number" && Number.isFinite(request.imageCount)
? Math.max(1, Math.min(8, Math.floor(request.imageCount)))
? Math.max(1, Math.min(12, Math.floor(request.imageCount)))
: 1;
const referenceImage =
typeof request.referenceImage === "string" ? request.referenceImage : undefined;
@ -191,11 +207,13 @@ export async function generateCanvasImage(
const imageParams = {
aspectRatio,
imageSize,
imageCount,
imageCount: 1,
negativePrompt,
};
let imageMarkdown = "";
let executeGenerateBatch:
| ((batchImageCount: number) => Promise<string>)
| null = null;
if (provider.type === "gemini") {
const geminiProvider = createGeminiImage({
@ -203,61 +221,128 @@ export async function generateCanvasImage(
baseURL: provider.baseUrl,
});
const modelInstance = geminiProvider.chat(selectedModel.model.modelId);
const result = await modelInstance.doGenerate({ messages, imageParams });
imageMarkdown = result.text || "";
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "alibaba-image") {
const alibabaProvider = createAlibabaImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = alibabaProvider.chat(selectedModel.model.modelId);
const result = await modelInstance.doGenerate({ messages, imageParams });
imageMarkdown = result.text || "";
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "ruxa-image") {
const ruxaProvider = createRuxaImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = ruxaProvider.chat(selectedModel.model.modelId);
const result = await modelInstance.doGenerate({ messages, imageParams });
imageMarkdown = result.text || "";
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "fal-image") {
const falProvider = createFalImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = falProvider.chat(selectedModel.model.modelId);
const result = await modelInstance.doGenerate({ messages, imageParams });
imageMarkdown = result.text || "";
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "openai-image") {
const openAIImageProvider = createOpenAIImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = openAIImageProvider.chat(selectedModel.model.modelId);
const result = await modelInstance.doGenerate({ messages, imageParams });
imageMarkdown = result.text || "";
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "midjourney") {
const midjourneyProvider = createMidjourney({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = midjourneyProvider.chat(selectedModel.model.modelId);
const result = await modelInstance.doGenerate({ messages, imageParams });
imageMarkdown = result.text || "";
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else {
throw new Error(
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/阿里/RUXA/FAL/OpenAI/Midjourney 图片模型`
);
}
const parsedImages = parseImagesFromMarkdown(imageMarkdown);
if (parsedImages.length === 0) {
const providerBatchLimit = getProviderBatchLimit(provider.type);
const targetImageCount = Math.max(1, imageCount);
const collectedImages: ParsedImage[] = [];
const maxBatches = Math.max(1, Math.ceil(targetImageCount / providerBatchLimit));
for (let batchIndex = 0; batchIndex < maxBatches; batchIndex += 1) {
const remaining = targetImageCount - collectedImages.length;
if (remaining <= 0) break;
const batchImageCount = Math.min(providerBatchLimit, remaining);
const imageMarkdown = await executeGenerateBatch(batchImageCount);
const parsedImages = parseImagesFromMarkdown(imageMarkdown);
if (parsedImages.length === 0) {
if (collectedImages.length > 0) {
break;
}
throw new Error("图片生成失败");
}
collectedImages.push(...parsedImages.slice(0, batchImageCount));
}
if (collectedImages.length === 0) {
throw new Error("图片生成失败");
}
return {
images: parsedImages,
images: collectedImages.slice(0, targetImageCount),
model: selectedModel.model.displayName,
modelRef: selectedModel.model.id,
prompt,

View File

@ -596,8 +596,11 @@ export const canvasProject = pgTable("CanvasProject", {
// 存储完整画布状态(节点、边、视口等)
canvasData: jsonb("canvasData").$type<{
nodes: any[];
edges: any[];
viewport: { x: number; y: number; zoom: number };
edges?: any[];
selectedNodeIds?: string[];
viewport?: { x: number; y: number; zoom: number };
tool?: string;
suppressedMediaKeys?: string[];
}>(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),

View File

@ -1,5 +1,6 @@
import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state";
import type { AgentWorkflowStatus } from "@/lib/ai/runtime/agent-workflow-state";
import type { PromptMemoryEntry } from "@/lib/ai/prompt-memory";
import type { StoryboardPreferenceMemory } from "@/lib/video-agent/storyboard-preference-memory";
type StoryboardTaskDetail = {
@ -69,18 +70,25 @@ export function buildAgentThreadStateFromStoryboardTask(
detail: StoryboardTaskDetail | null | undefined,
options?: {
storyboardPreferenceMemory?: StoryboardPreferenceMemory | null;
promptMemory?: PromptMemoryEntry | null;
},
): AgentThreadState | null {
if (!detail?.task?.id) {
if (!options?.storyboardPreferenceMemory) return null;
if (!options?.storyboardPreferenceMemory && !options?.promptMemory) return null;
return {
workflowState: null,
memoryHints: [
"当前会话里存在 storyboard 偏好记忆,可在分镜/宫格任务里优先参考用户常用版式和确认习惯。",
],
options?.storyboardPreferenceMemory
? "当前会话里存在 storyboard 偏好记忆,可在分镜/宫格任务里优先参考用户常用版式和确认习惯。"
: "",
options?.promptMemory?.optimizedPrompt
? "当前会话里存在上一轮可复用提示词;如果用户明确要求沿用上一个提示词继续做图,应优先把它作为本轮生图提示词。"
: "",
].filter(Boolean),
metadata: {
workflowSource: "storyboard_preference_memory",
storyboardPreferenceMemory: options.storyboardPreferenceMemory,
promptMemory: options?.promptMemory || undefined,
},
};
}
@ -147,6 +155,7 @@ export function buildAgentThreadStateFromStoryboardTask(
taskId: detail.task.id,
shotCount: totalShots,
storyboardPreferenceMemory: options?.storyboardPreferenceMemory || undefined,
promptMemory: options?.promptMemory || undefined,
},
};
}

View File

@ -73,6 +73,7 @@ interface CanvasStore {
selectedNodeIds: string[];
viewport: Viewport;
tool: ToolType;
suppressedMediaKeys: string[];
// 历史记录(用于撤销/重做)
history: CanvasState[];
@ -105,6 +106,7 @@ interface CanvasStore {
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
deleteNode: (id: string) => void;
deleteSelectedNodes: () => void;
addSuppressedMediaKeys: (keys: string[]) => void;
// 选择操作
selectNode: (id: string, multi?: boolean) => void;
@ -179,6 +181,7 @@ export const useCanvasStore = create<CanvasStore>()(
selectedNodeIds: [],
viewport: defaultViewport,
tool: 'select',
suppressedMediaKeys: [],
history: [],
historyIndex: -1,
isPanelOpen: true,
@ -287,8 +290,36 @@ export const useCanvasStore = create<CanvasStore>()(
}
}),
addSuppressedMediaKeys: (keys) => set((state) => {
const normalized = Array.from(
new Set((keys || []).map((item) => String(item || "").trim()).filter(Boolean))
);
if (normalized.length === 0) return;
const existing = new Set(state.suppressedMediaKeys);
normalized.forEach((key) => existing.add(key));
state.suppressedMediaKeys = Array.from(existing);
}),
deleteNode: (id) => {
set((state) => {
const targetNode = state.nodes.find(n => n.id === id);
if (targetNode && (targetNode.type === 'image' || targetNode.type === 'video')) {
const data = ((targetNode.data as any) || {}) as Record<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
const sourceImageKey = String(metadata.sourceImageKey || "").trim();
const sourceMediaUrl = String(data.src || "").trim();
const suppressionKeys = [
sourceMessageId && sourceImageKey ? `image:${sourceMessageId}::${sourceImageKey}` : "",
targetNode.type === 'video' && sourceMediaUrl ? `video:${sourceMediaUrl}` : "",
targetNode.type === 'image' && sourceMediaUrl ? `image-url:${sourceMediaUrl}` : "",
].filter(Boolean);
if (suppressionKeys.length > 0) {
const existing = new Set(state.suppressedMediaKeys);
suppressionKeys.forEach((key) => existing.add(key));
state.suppressedMediaKeys = Array.from(existing);
}
}
state.nodes = state.nodes.filter(n => n.id !== id);
state.edges = state.edges.filter(e => e.source !== id && e.target !== id);
state.selectedNodeIds = state.selectedNodeIds.filter(nid => nid !== id);
@ -299,6 +330,23 @@ export const useCanvasStore = create<CanvasStore>()(
deleteSelectedNodes: () => {
const { selectedNodeIds } = get();
set((state) => {
const selectedNodes = state.nodes.filter(n => selectedNodeIds.includes(n.id));
const existing = new Set(state.suppressedMediaKeys);
selectedNodes.forEach((targetNode) => {
if (targetNode.type !== 'image' && targetNode.type !== 'video') return;
const data = ((targetNode.data as any) || {}) as Record<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
const sourceImageKey = String(metadata.sourceImageKey || "").trim();
const sourceMediaUrl = String(data.src || "").trim();
const suppressionKeys = [
sourceMessageId && sourceImageKey ? `image:${sourceMessageId}::${sourceImageKey}` : "",
targetNode.type === 'video' && sourceMediaUrl ? `video:${sourceMediaUrl}` : "",
targetNode.type === 'image' && sourceMediaUrl ? `image-url:${sourceMediaUrl}` : "",
].filter(Boolean);
suppressionKeys.forEach((key) => existing.add(key));
});
state.suppressedMediaKeys = Array.from(existing);
state.nodes = state.nodes.filter(n => !selectedNodeIds.includes(n.id));
state.edges = state.edges.filter(
e => !selectedNodeIds.includes(e.source) && !selectedNodeIds.includes(e.target)
@ -451,6 +499,9 @@ export const useCanvasStore = create<CanvasStore>()(
if (canvasState.selectedNodeIds) state.selectedNodeIds = canvasState.selectedNodeIds;
if (canvasState.viewport) state.viewport = canvasState.viewport;
if (canvasState.tool) state.tool = canvasState.tool;
state.suppressedMediaKeys = Array.isArray(canvasState.suppressedMediaKeys)
? canvasState.suppressedMediaKeys.map((item) => String(item || "").trim()).filter(Boolean)
: [];
}),
clearCanvas: () => set((state) => {
@ -458,6 +509,7 @@ export const useCanvasStore = create<CanvasStore>()(
state.edges = [];
state.selectedNodeIds = [];
state.viewport = defaultViewport;
state.suppressedMediaKeys = [];
state.history = [];
state.historyIndex = -1;
}),
@ -470,6 +522,7 @@ export const useCanvasStore = create<CanvasStore>()(
selectedNodeIds: state.selectedNodeIds,
viewport: state.viewport,
tool: state.tool,
suppressedMediaKeys: state.suppressedMediaKeys,
};
},

View File

@ -137,6 +137,7 @@ export interface CanvasState {
selectedNodeIds: string[];
viewport: Viewport;
tool: ToolType;
suppressedMediaKeys?: string[];
}
// 画布项目