feat: strengthen agent runtime boundaries
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-16 11:45:00 +08:00
parent ea561418c4
commit 9ecd91376d
9 changed files with 1743 additions and 63 deletions

View File

@ -123,11 +123,26 @@ import type {
} from "@/lib/ai/context-memory/types"; } from "@/lib/ai/context-memory/types";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
import { extractImageUrlsFromText } from "@/lib/image-url"; import { extractImageUrlsFromText } from "@/lib/image-url";
import { isExplicitPromptOptimizationRequest } from "@/lib/ai/smart-analyzer";
import { import {
resolveClarificationBypass, resolveClarificationBypass,
resolveClarificationMessage, resolveClarificationMessage,
} from "@/lib/ai/runtime/clarification-interruption"; } from "@/lib/ai/runtime/clarification-interruption";
import {
pruneTaskBoundaryContext,
pruneTaskBoundaryHistoryMessages,
} from "@/lib/ai/runtime/task-boundary-context-pruner";
import {
resolveAgentModelSlotDescriptor,
upsertAgentModelSlot,
type AgentModelSlots,
} from "@/lib/ai/runtime/agent-model-slots";
import { isPureGreetingTurnRequest } from "@/lib/ai/runtime/task-boundary-classifier";
import {
getProviderHealthSnapshot,
recordProviderHealthFailure,
recordProviderHealthSuccess,
shouldAvoidProvider,
} from "@/lib/ai/runtime/provider-health";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { getVideoStoryboardConfig } from "@/lib/video-agent/config"; import { getVideoStoryboardConfig } from "@/lib/video-agent/config";
import { runRuxaNineGridPipeline } from "@/lib/video-agent/pipeline/ruxa-nine-grid-pipeline"; import { runRuxaNineGridPipeline } from "@/lib/video-agent/pipeline/ruxa-nine-grid-pipeline";
@ -1234,15 +1249,6 @@ function normalizeAssistantPartsForPersistence(parts: Array<Record<string, any>>
}); });
} }
function isPureGreetingTurnRequest(text: string): boolean {
const normalized = String(text || "").toLowerCase().replace(/\s+/g, "").trim();
if (!normalized) return false;
return /^(你好|您好|hello|hi|嗨|哈喽|在吗|早上好|中午好|下午好|晚上好|深夜好|谢谢|感谢|收到|好的|ok|okay|明白了|了解了)$/.test(
normalized,
);
}
function buildAgentToolImageMarkdown(params: { function buildAgentToolImageMarkdown(params: {
originalUrl: string; originalUrl: string;
thumbnailUrl?: string | null; thumbnailUrl?: string | null;
@ -3044,8 +3050,10 @@ export async function POST(request: Request) {
let reusablePromptMemory = null; let reusablePromptMemory = null;
let confirmedPromptForConversationState: string | undefined; let confirmedPromptForConversationState: string | undefined;
let smartAutoHistoryMessagesFromDb: DBMessage[] = []; let smartAutoHistoryMessagesFromDb: DBMessage[] = [];
let smartAutoMediaHistoryMessagesFromDb: DBMessage[] = [];
let preferredAgentModelId: string | undefined; let preferredAgentModelId: string | undefined;
let configuredAgentConversationModelId: string | undefined; let configuredAgentConversationModelId: string | undefined;
let agentModelSlots: AgentModelSlots = {};
let pendingGridConfirmation: let pendingGridConfirmation:
| PendingGridConfirmation | PendingGridConfirmation
| null = null; | null = null;
@ -3153,6 +3161,24 @@ export async function POST(request: Request) {
if (configuredAgentModel?.model?.id) { if (configuredAgentModel?.model?.id) {
preferredAgentModelId = configuredAgentModel.model.id; preferredAgentModelId = configuredAgentModel.model.id;
configuredAgentConversationModelId = configuredAgentModel.model.id; configuredAgentConversationModelId = configuredAgentModel.model.id;
agentModelSlots = upsertAgentModelSlot(agentModelSlots, "planner", {
modelId: configuredAgentModel.model.id,
displayName: configuredAgentModel.model.displayName || configuredAgentModel.model.modelId,
providerId: configuredAgentModel.model.providerId,
providerType: configuredAgentModel.provider.type,
providerKey: configuredAgentModel.model.providerId,
source: "configured_agent_default",
reason: "agent_default_model",
});
agentModelSlots = upsertAgentModelSlot(agentModelSlots, "analyzer", {
modelId: configuredAgentModel.model.id,
displayName: configuredAgentModel.model.displayName || configuredAgentModel.model.modelId,
providerId: configuredAgentModel.model.providerId,
providerType: configuredAgentModel.provider.type,
providerKey: configuredAgentModel.model.providerId,
source: "configured_agent_default",
reason: "agent_default_model",
});
console.log( console.log(
`[Smart Auto Mode] 🧠 Loaded configured agent conversation model: ${configuredAgentModel.model.displayName}`, `[Smart Auto Mode] 🧠 Loaded configured agent conversation model: ${configuredAgentModel.model.displayName}`,
); );
@ -3239,9 +3265,12 @@ export async function POST(request: Request) {
workingState: requestWorkingState, workingState: requestWorkingState,
activeStoryboardTask: activeStoryboardTaskForChat, activeStoryboardTask: activeStoryboardTaskForChat,
hasImageInputForPromptCarryForward: hasImageInputForConversationAction, hasImageInputForPromptCarryForward: hasImageInputForConversationAction,
hasCanvasSelection: Boolean(contextPayload?.selectedNodeIds?.length),
}); });
console.log("[ConversationStateProtocol] Bootstrap:", { console.log("[ConversationStateProtocol] Bootstrap:", {
action: conversationStateBootstrap.actionResolution.kind, action: conversationStateBootstrap.actionResolution.kind,
taskType: conversationStateBootstrap.taskBoundaryDecision.taskType,
taskMode: conversationStateBootstrap.taskBoundaryDecision.taskMode,
hasPromptMemory: Boolean(conversationStateBootstrap.promptMemory?.optimizedPrompt), hasPromptMemory: Boolean(conversationStateBootstrap.promptMemory?.optimizedPrompt),
hasImageWorkflowMemory: Boolean(conversationStateBootstrap.imageWorkflowMemory), hasImageWorkflowMemory: Boolean(conversationStateBootstrap.imageWorkflowMemory),
hasStoryboardPreferenceMemory: Boolean(conversationStateBootstrap.storyboardPreferenceMemory), hasStoryboardPreferenceMemory: Boolean(conversationStateBootstrap.storyboardPreferenceMemory),
@ -3252,6 +3281,7 @@ export async function POST(request: Request) {
phase: "bootstrap", phase: "bootstrap",
payload: { payload: {
action: conversationStateBootstrap.actionResolution.kind, action: conversationStateBootstrap.actionResolution.kind,
taskBoundary: conversationStateBootstrap.taskBoundaryDecision,
pendingGridConfirmation: conversationStateBootstrap.pendingGridConfirmation, pendingGridConfirmation: conversationStateBootstrap.pendingGridConfirmation,
hasPromptMemory: Boolean(conversationStateBootstrap.promptMemory?.optimizedPrompt), hasPromptMemory: Boolean(conversationStateBootstrap.promptMemory?.optimizedPrompt),
hasImageWorkflowMemory: Boolean(conversationStateBootstrap.imageWorkflowMemory), hasImageWorkflowMemory: Boolean(conversationStateBootstrap.imageWorkflowMemory),
@ -3265,6 +3295,17 @@ export async function POST(request: Request) {
pendingGridConfirmation = conversationStateBootstrap.pendingGridConfirmation; pendingGridConfirmation = conversationStateBootstrap.pendingGridConfirmation;
confirmedPromptForConversationState = conversationStateBootstrap.confirmedPrompt; confirmedPromptForConversationState = conversationStateBootstrap.confirmedPrompt;
const resolvedConversationAction = conversationStateBootstrap.actionResolution; const resolvedConversationAction = conversationStateBootstrap.actionResolution;
const taskBoundaryDecision = conversationStateBootstrap.taskBoundaryDecision;
const prunedTaskBoundaryContext = pruneTaskBoundaryContext({
decision: taskBoundaryDecision,
contextPayload: contextPayload || undefined,
autoContextText: autoContextText || undefined,
workingState: requestWorkingState || undefined,
hasCurrentImageInput: hasImageInputForConversationAction,
});
const effectiveContextPayload = prunedTaskBoundaryContext.contextPayload;
const effectiveAutoContextText = prunedTaskBoundaryContext.autoContextText || "";
const effectiveWorkingState = prunedTaskBoundaryContext.workingState;
if (resolvedConversationAction.forceAgentConversation && !shouldRunAgentConversation) { if (resolvedConversationAction.forceAgentConversation && !shouldRunAgentConversation) {
shouldRunAgentConversation = true; shouldRunAgentConversation = true;
@ -3288,13 +3329,13 @@ export async function POST(request: Request) {
} }
const shouldFastTrackPromptOnly = const shouldFastTrackPromptOnly =
shouldRunAgentConversation && isExplicitPromptOptimizationRequest(requestTextContent); shouldRunAgentConversation && taskBoundaryDecision.taskType === "prompt_only";
if (shouldFastTrackPromptOnly) { if (shouldFastTrackPromptOnly) {
if (!requestResolvedMediaInput) { if (!requestResolvedMediaInput) {
requestResolvedMediaInput = resolveChatMediaInput({ requestResolvedMediaInput = resolveChatMediaInput({
messageParts: message.parts as any[], messageParts: message.parts as any[],
historyMessages: [], historyMessages: [],
contextPayload: contextPayload || undefined, contextPayload: effectiveContextPayload,
requestOrigin, requestOrigin,
maxImages: 4, maxImages: 4,
}); });
@ -3304,8 +3345,8 @@ export async function POST(request: Request) {
requestTargetResolution = resolveTargetAssets({ requestTargetResolution = resolveTargetAssets({
text: requestTextContent, text: requestTextContent,
mediaInput: requestResolvedMediaInput, mediaInput: requestResolvedMediaInput,
contextPayload: contextPayload || undefined, contextPayload: effectiveContextPayload,
workingState: requestWorkingState || undefined, workingState: effectiveWorkingState || undefined,
}); });
} }
@ -3329,8 +3370,26 @@ export async function POST(request: Request) {
try { try {
// 🆕 首先获取历史消息(在 Smart Auto Mode 内部单独获取,避免依赖后面的 messagesFromDb) // 🆕 首先获取历史消息(在 Smart Auto Mode 内部单独获取,避免依赖后面的 messagesFromDb)
smartAutoHistoryMessagesFromDb = await getMessagesByChatId({ id }); const loadedHistoryMessages = await getMessagesByChatId({ id });
console.log("[Smart Auto Mode] Loaded", smartAutoHistoryMessagesFromDb.length, "messages from DB"); smartAutoHistoryMessagesFromDb = pruneTaskBoundaryHistoryMessages({
decision: taskBoundaryDecision,
messages: loadedHistoryMessages,
purpose: "model",
hasCurrentImageInput: hasImageInputForConversationAction,
});
smartAutoMediaHistoryMessagesFromDb = pruneTaskBoundaryHistoryMessages({
decision: taskBoundaryDecision,
messages: loadedHistoryMessages,
purpose: "media",
hasCurrentImageInput: hasImageInputForConversationAction,
});
console.log("[Smart Auto Mode] Loaded", loadedHistoryMessages.length, "messages from DB");
console.log("[Smart Auto Mode] Pruned history:", {
modelMessages: smartAutoHistoryMessagesFromDb.length,
mediaMessages: smartAutoMediaHistoryMessagesFromDb.length,
taskType: taskBoundaryDecision.taskType,
taskMode: taskBoundaryDecision.taskMode,
});
autoTextForFallback = smartAutoInputText; autoTextForFallback = smartAutoInputText;
if (!requestWorkingState) { if (!requestWorkingState) {
@ -3347,8 +3406,8 @@ export async function POST(request: Request) {
if (!requestResolvedMediaInput) { if (!requestResolvedMediaInput) {
requestResolvedMediaInput = resolveChatMediaInput({ requestResolvedMediaInput = resolveChatMediaInput({
messageParts: message.parts as any[], messageParts: message.parts as any[],
historyMessages: smartAutoHistoryMessagesFromDb, historyMessages: smartAutoMediaHistoryMessagesFromDb,
contextPayload: contextPayload || undefined, contextPayload: effectiveContextPayload,
requestOrigin, requestOrigin,
maxImages: 4, maxImages: 4,
}); });
@ -3357,14 +3416,14 @@ export async function POST(request: Request) {
requestTargetResolution = resolveTargetAssets({ requestTargetResolution = resolveTargetAssets({
text: requestTextContent, text: requestTextContent,
mediaInput: requestResolvedMediaInput, mediaInput: requestResolvedMediaInput,
contextPayload: contextPayload || undefined, contextPayload: effectiveContextPayload,
workingState: requestWorkingState || undefined, workingState: effectiveWorkingState || undefined,
}); });
} }
const hasSelectedImageHint = const hasSelectedImageHint =
Boolean(contextPayload?.selectedNodeIds?.length) || Boolean(effectiveContextPayload?.selectedNodeIds?.length) ||
/用户已选中\s*\d+\s*张图片/.test(autoContextText); /用户已选中\s*\d+\s*张图片/.test(effectiveAutoContextText);
const hasExplicitTargetHint = const hasExplicitTargetHint =
requestTargetResolution.resolutionMode === "text_reference" || requestTargetResolution.resolutionMode === "text_reference" ||
/第[一二三四五六七八九十\d]+张|左边|左侧|左上|左下|右边|右侧|右上|右下|上面|上方|下面|下方|中间|居中|中央|选中/.test(requestTextContent); /第[一二三四五六七八九十\d]+张|左边|左侧|左上|左下|右边|右侧|右上|右下|上面|上方|下面|下方|中间|居中|中央|选中/.test(requestTextContent);
@ -3438,7 +3497,7 @@ export async function POST(request: Request) {
} }
const composedConversationState = composeConversationStateProtocol({ const composedConversationState = composeConversationStateProtocol({
bootstrap: conversationStateBootstrap, bootstrap: conversationStateBootstrap,
autoContextText: autoContextText || undefined, autoContextText: effectiveAutoContextText || undefined,
resolvedMediaInput: requestResolvedMediaInput, resolvedMediaInput: requestResolvedMediaInput,
targetResolution: requestTargetResolution, targetResolution: requestTargetResolution,
}); });
@ -3462,16 +3521,16 @@ export async function POST(request: Request) {
hasImage, hasImage,
imageUrls, imageUrls,
assetIds: Array.from( assetIds: Array.from(
new Set([ new Set([
...extractMessageImageAssetIds(message.parts as any[]), ...extractMessageImageAssetIds(message.parts as any[]),
...((contextPayload?.selectedAssetIds || []).filter(Boolean) as string[]), ...((effectiveContextPayload?.selectedAssetIds || []).filter(Boolean) as string[]),
]), ]),
), ),
imageFromHistory: smartAutoHistoryImages.length > 0, imageFromHistory: smartAutoHistoryImages.length > 0,
targetResolutionMode: requestTargetResolution.resolutionMode, targetResolutionMode: requestTargetResolution.resolutionMode,
mediaSource: requestResolvedMediaInput.source, mediaSource: requestResolvedMediaInput.source,
hasCanvasSelection: hasCanvasSelection:
Boolean(contextPayload?.selectedNodeIds?.length) || Boolean(effectiveContextPayload?.selectedNodeIds?.length) ||
requestResolvedMediaInput.selectedImageUrls.length > 0, requestResolvedMediaInput.selectedImageUrls.length > 0,
selectedImageCount: requestResolvedMediaInput.selectedImageUrls.length, selectedImageCount: requestResolvedMediaInput.selectedImageUrls.length,
historyImageCount: historyImageCount:
@ -3481,7 +3540,7 @@ export async function POST(request: Request) {
historyMessages, historyMessages,
context: context:
composedConversationState.composedConversationMemory.contextSummary || composedConversationState.composedConversationMemory.contextSummary ||
autoContextText || effectiveAutoContextText ||
undefined, undefined,
preferredModelId: preferredAgentModelId, preferredModelId: preferredAgentModelId,
threadState: composedConversationState.threadState, threadState: composedConversationState.threadState,
@ -3838,7 +3897,7 @@ export async function POST(request: Request) {
const rescueAssetIds = Array.from( const rescueAssetIds = Array.from(
new Set([ new Set([
...extractMessageImageAssetIds(message.parts as any[]), ...extractMessageImageAssetIds(message.parts as any[]),
...((contextPayload?.selectedAssetIds || []).filter(Boolean) as string[]), ...((effectiveContextPayload?.selectedAssetIds || []).filter(Boolean) as string[]),
]), ]),
); );
const rescueTimeoutMs = getBudgetedTimeout({ const rescueTimeoutMs = getBudgetedTimeout({
@ -4043,7 +4102,7 @@ export async function POST(request: Request) {
const summaryAssetIds = Array.from( const summaryAssetIds = Array.from(
new Set([ new Set([
...extractMessageImageAssetIds(message.parts as any[]), ...extractMessageImageAssetIds(message.parts as any[]),
...((contextPayload?.selectedAssetIds || []).filter(Boolean) as string[]), ...((effectiveContextPayload?.selectedAssetIds || []).filter(Boolean) as string[]),
]), ]),
); );
const fallbackSummaryTimeoutMs = getBudgetedTimeout({ const fallbackSummaryTimeoutMs = getBudgetedTimeout({
@ -4056,7 +4115,7 @@ export async function POST(request: Request) {
summarizeImageForExecutionReport({ summarizeImageForExecutionReport({
text: autoTextForFallback, text: autoTextForFallback,
imageUrls: summaryImageUrls, imageUrls: summaryImageUrls,
context: typeof autoContext === "string" ? autoContext : undefined, context: effectiveAutoContextText || undefined,
preferredModelId: preferredAgentModelId, preferredModelId: preferredAgentModelId,
assetIds: summaryAssetIds, assetIds: summaryAssetIds,
}), }),
@ -4335,11 +4394,11 @@ export async function POST(request: Request) {
.map((part) => part.text) .map((part) => part.text)
.join(" "); .join(" ");
const summaryAssetIds = Array.from( const summaryAssetIds = Array.from(
new Set([ new Set([
...extractMessageImageAssetIds(message.parts as any[]), ...extractMessageImageAssetIds(message.parts as any[]),
...((contextPayload?.selectedAssetIds || []).filter(Boolean) as string[]), ...((effectiveContextPayload?.selectedAssetIds || []).filter(Boolean) as string[]),
]), ]),
); );
let summary: string | null = null; let summary: string | null = null;
const summaryTimeoutMs = getBudgetedTimeout({ const summaryTimeoutMs = getBudgetedTimeout({
@ -4352,7 +4411,7 @@ export async function POST(request: Request) {
summarizeImageForExecutionReport({ summarizeImageForExecutionReport({
text: latestUserText, text: latestUserText,
imageUrls: summaryImageUrls, imageUrls: summaryImageUrls,
context: typeof autoContext === "string" ? autoContext : undefined, context: effectiveAutoContextText || undefined,
preferredModelId: preferredAgentModelId, preferredModelId: preferredAgentModelId,
assetIds: summaryAssetIds, assetIds: summaryAssetIds,
}), }),
@ -4421,6 +4480,86 @@ export async function POST(request: Request) {
} }
} }
if (shouldRunAgentConversation && smartAutoExecution?.shouldStayConversational) {
const candidateReplyDescriptor = await resolveAgentModelSlotDescriptor(finalModelId).catch(
() => null,
);
if (candidateReplyDescriptor) {
agentModelSlots = upsertAgentModelSlot(agentModelSlots, "reply", {
...candidateReplyDescriptor,
source: "candidate_reply_model",
reason: "post_agent_routing",
locked: shouldKeepSelectedModel,
});
const providerHealth = getProviderHealthSnapshot(candidateReplyDescriptor.providerKey);
if (
!shouldKeepSelectedModel &&
shouldAvoidProvider(providerHealth)
) {
let fallbackReplyModelId: string | undefined;
if (
preferredAgentModelId &&
preferredAgentModelId !== finalModelId
) {
const preferredDescriptor = await resolveAgentModelSlotDescriptor(
preferredAgentModelId,
).catch(() => null);
if (
preferredDescriptor &&
!shouldAvoidProvider(getProviderHealthSnapshot(preferredDescriptor.providerKey))
) {
fallbackReplyModelId = preferredAgentModelId;
}
}
if (!fallbackReplyModelId) {
try {
const { routeToModel } = await import("@/lib/ai/model-router");
const replyFallbackRoute = await routeToModel({
intent: "chat",
subIntent: undefined,
hasImage: false,
});
const fallbackDescriptor = await resolveAgentModelSlotDescriptor(
replyFallbackRoute.id,
).catch(() => null);
if (
fallbackDescriptor &&
!shouldAvoidProvider(getProviderHealthSnapshot(fallbackDescriptor.providerKey))
) {
fallbackReplyModelId = replyFallbackRoute.id;
}
} catch (replyRouteError) {
console.warn(
"[Smart Auto Mode] Failed to resolve reply fallback route after provider degradation:",
replyRouteError,
);
}
}
if (fallbackReplyModelId && fallbackReplyModelId !== finalModelId) {
finalModelId = fallbackReplyModelId;
const fallbackDescriptor = await resolveAgentModelSlotDescriptor(finalModelId).catch(
() => null,
);
if (fallbackDescriptor) {
agentModelSlots = upsertAgentModelSlot(agentModelSlots, "reply", {
...fallbackDescriptor,
source: "provider_health_fallback",
reason: providerHealth?.lastEventType || "degraded_provider",
locked: false,
});
}
console.warn(
`[Smart Auto Mode] ♻️ Switched reply model due provider health: ${candidateReplyDescriptor.displayName || candidateReplyDescriptor.modelId} -> ${fallbackDescriptor?.displayName || fallbackReplyModelId}`,
);
}
}
}
}
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {
@ -4474,6 +4613,7 @@ export async function POST(request: Request) {
let chat = await getChatById({ id }); let chat = await getChatById({ id });
let messagesFromDb: DBMessage[] = []; let messagesFromDb: DBMessage[] = [];
let messagesForMediaInput: DBMessage[] = [];
if (chat) { if (chat) {
if (chat.userId !== session.user.id) { if (chat.userId !== session.user.id) {
@ -4522,6 +4662,20 @@ export async function POST(request: Request) {
messagesFromDb = await getMessagesByChatId({ id }); messagesFromDb = await getMessagesByChatId({ id });
} }
const loadedMessagesForBoundary = messagesFromDb;
messagesFromDb = pruneTaskBoundaryHistoryMessages({
decision: taskBoundaryDecision,
messages: loadedMessagesForBoundary,
purpose: "model",
hasCurrentImageInput: hasImageInputForConversationAction,
});
messagesForMediaInput = pruneTaskBoundaryHistoryMessages({
decision: taskBoundaryDecision,
messages: loadedMessagesForBoundary,
purpose: "media",
hasCurrentImageInput: hasImageInputForConversationAction,
});
if (!requestWorkingState) { if (!requestWorkingState) {
requestWorkingState = await getConversationWorkingStateByChatId({ requestWorkingState = await getConversationWorkingStateByChatId({
chatId: id, chatId: id,
@ -4530,8 +4684,8 @@ export async function POST(request: Request) {
if (!requestResolvedMediaInput) { if (!requestResolvedMediaInput) {
requestResolvedMediaInput = resolveChatMediaInput({ requestResolvedMediaInput = resolveChatMediaInput({
messageParts: message.parts as any[], messageParts: message.parts as any[],
historyMessages: messagesFromDb, historyMessages: messagesForMediaInput,
contextPayload: contextPayload || undefined, contextPayload: effectiveContextPayload,
requestOrigin, requestOrigin,
maxImages: 4, maxImages: 4,
}); });
@ -4540,8 +4694,8 @@ export async function POST(request: Request) {
requestTargetResolution = resolveTargetAssets({ requestTargetResolution = resolveTargetAssets({
text: requestTextContent, text: requestTextContent,
mediaInput: requestResolvedMediaInput, mediaInput: requestResolvedMediaInput,
contextPayload: contextPayload || undefined, contextPayload: effectiveContextPayload,
workingState: requestWorkingState || undefined, workingState: effectiveWorkingState || undefined,
}); });
} }
if ( if (
@ -4688,7 +4842,7 @@ export async function POST(request: Request) {
shouldRunAgentConversation && shouldRunAgentConversation &&
!smartAutoNeedsClarification && !smartAutoNeedsClarification &&
promptOnlySourceImageUrls.length > 0 && promptOnlySourceImageUrls.length > 0 &&
isExplicitPromptOptimizationRequest(requestTextContent); taskBoundaryDecision.taskType === "prompt_only";
if (wantsPromptOnlyFromImageContext) { if (wantsPromptOnlyFromImageContext) {
let promptOnlyImageSummary = ""; let promptOnlyImageSummary = "";
@ -4701,16 +4855,16 @@ export async function POST(request: Request) {
}); });
if (summaryTimeoutMs) { if (summaryTimeoutMs) {
const summaryAssetIds = Array.from( const summaryAssetIds = Array.from(
new Set([ new Set([
...extractMessageImageAssetIds(message.parts as any[]), ...extractMessageImageAssetIds(message.parts as any[]),
...((contextPayload?.selectedAssetIds || []).filter(Boolean) as string[]), ...((effectiveContextPayload?.selectedAssetIds || []).filter(Boolean) as string[]),
]), ]),
); );
const summary = await withOperationTimeout( const summary = await withOperationTimeout(
summarizeImageForExecutionReport({ summarizeImageForExecutionReport({
text: requestTextContent, text: requestTextContent,
imageUrls: promptOnlySourceImageUrls, imageUrls: promptOnlySourceImageUrls,
context: typeof autoContext === "string" ? autoContext : undefined, context: effectiveAutoContextText || undefined,
preferredModelId: preferredAgentModelId, preferredModelId: preferredAgentModelId,
assetIds: assetIds:
promptOnlySourceAssetIds.length > 0 ? promptOnlySourceAssetIds : summaryAssetIds, promptOnlySourceAssetIds.length > 0 ? promptOnlySourceAssetIds : summaryAssetIds,
@ -4752,7 +4906,7 @@ export async function POST(request: Request) {
}; };
}) })
.filter(Boolean) as Array<{ role: "user" | "assistant"; content: string }>, .filter(Boolean) as Array<{ role: "user" | "assistant"; content: string }>,
context: typeof autoContext === "string" ? autoContext : undefined, context: effectiveAutoContextText || undefined,
}).catch(() => }).catch(() =>
optimizePrompt({ optimizePrompt({
originalPrompt: promptSeed, originalPrompt: promptSeed,
@ -4832,7 +4986,7 @@ export async function POST(request: Request) {
part?.type === "file" || part?.type === "image" || part?.type === "image_url" part?.type === "file" || part?.type === "image" || part?.type === "image_url"
), ),
hasHistoryImageInput: smartAutoHistoryImages.length > 0, hasHistoryImageInput: smartAutoHistoryImages.length > 0,
hasCanvasSelection: Boolean(contextPayload?.selectedNodeIds?.length), hasCanvasSelection: Boolean(effectiveContextPayload?.selectedNodeIds?.length),
targetResolutionMode: requestTargetResolution?.resolutionMode, targetResolutionMode: requestTargetResolution?.resolutionMode,
executionMode: smartAutoExecution?.mode, executionMode: smartAutoExecution?.mode,
friendlyMessage: smartAutoFriendlyMessage, friendlyMessage: smartAutoFriendlyMessage,
@ -6495,6 +6649,7 @@ export async function POST(request: Request) {
let actualModelId: string; let actualModelId: string;
let resolvedModelSupportsImageGen = false; let resolvedModelSupportsImageGen = false;
let resolvedProviderType: string | undefined; let resolvedProviderType: string | undefined;
let resolvedRuntimeModelLookupId = finalModelId;
if (isUUID) { if (isUUID) {
// 从数据库获取动态配置的模型 // 从数据库获取动态配置的模型
@ -6542,6 +6697,7 @@ export async function POST(request: Request) {
).toResponse(); ).toResponse();
} }
languageModel = dynamicModel; languageModel = dynamicModel;
resolvedRuntimeModelLookupId = targetModelId;
// 对于动态模型,我们需要从数据库获取 modelId // 对于动态模型,我们需要从数据库获取 modelId
const finalModelData = await getAiModelById({ id: targetModelId }); const finalModelData = await getAiModelById({ id: targetModelId });
actualModelId = finalModelData?.modelId ?? targetModelId; actualModelId = finalModelData?.modelId ?? targetModelId;
@ -6556,6 +6712,38 @@ export async function POST(request: Request) {
languageModel = myProvider.languageModel(finalModelId); languageModel = myProvider.languageModel(finalModelId);
actualModelId = languageModel.modelId; actualModelId = languageModel.modelId;
resolvedProviderType = (languageModel as any)?.provider; resolvedProviderType = (languageModel as any)?.provider;
resolvedRuntimeModelLookupId = actualModelId;
}
const activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
resolvedRuntimeModelLookupId,
).catch(() => null);
if (shouldRunAgentConversation && activeRuntimeModelDescriptor) {
const slotName =
smartAutoExecution?.shouldApplyImagePipeline ||
smartAutoExecution?.shouldApplyVideoPipeline
? "execution"
: "reply";
agentModelSlots = upsertAgentModelSlot(agentModelSlots, slotName, {
...activeRuntimeModelDescriptor,
source: "runtime_selected_model",
reason:
slotName === "reply"
? smartAutoExecution?.shouldStayConversational
? "conversational_reply_runtime"
: "default_reply_runtime"
: "execution_runtime_model",
locked: slotName === "reply" ? shouldKeepSelectedModel : false,
});
pendingProtocolDebugEvents.push({
phase: "modelSlots",
payload: {
modelSlots: agentModelSlots,
providerHealth: activeRuntimeModelDescriptor.providerKey
? getProviderHealthSnapshot(activeRuntimeModelDescriptor.providerKey)
: null,
},
});
} }
// ========== 图片生成配额检查 ========== // ========== 图片生成配额检查 ==========
@ -9301,6 +9489,12 @@ export async function POST(request: Request) {
functionId: "stream-text", functionId: "stream-text",
}, },
onFinish: async ({ usage }) => { onFinish: async ({ usage }) => {
if (activeRuntimeModelDescriptor?.providerKey) {
recordProviderHealthSuccess({
providerKey: activeRuntimeModelDescriptor.providerKey,
providerType: activeRuntimeModelDescriptor.providerType,
});
}
try { try {
const providers = await getTokenlensCatalog(); const providers = await getTokenlensCatalog();
if (!actualModelId) { if (!actualModelId) {
@ -9343,6 +9537,13 @@ export async function POST(request: Request) {
result.consumeStream({ result.consumeStream({
onError: (error) => { onError: (error) => {
console.error("[Chat API] streamText consume error:", error); console.error("[Chat API] streamText consume error:", error);
if (activeRuntimeModelDescriptor?.providerKey) {
recordProviderHealthFailure({
providerKey: activeRuntimeModelDescriptor.providerKey,
providerType: activeRuntimeModelDescriptor.providerType,
error,
});
}
}, },
}); });
@ -9351,6 +9552,13 @@ export async function POST(request: Request) {
sendReasoning: true, sendReasoning: true,
onError: (error) => { onError: (error) => {
console.error("[Chat API] streamText ui stream error:", error); console.error("[Chat API] streamText ui stream error:", error);
if (activeRuntimeModelDescriptor?.providerKey) {
recordProviderHealthFailure({
providerKey: activeRuntimeModelDescriptor.providerKey,
providerType: activeRuntimeModelDescriptor.providerType,
error,
});
}
return getChatStreamErrorMessage(error); return getChatStreamErrorMessage(error);
}, },
}) })

View File

@ -0,0 +1,667 @@
# Agent 真正变聪明:系统级整改清单(2026-03-16)
> 目标:不是继续给 Agent 打零散补丁,而是把它从“很多聪明 patch 的组合体”,升级成“统一、稳定、可解释、可扩展的对话智能系统”。
>
> 适用范围:
> - `app/(chat)/api/chat/route.ts`
> - `lib/ai/auto-agent-graph.ts`
> - `lib/ai/smart-analyzer.ts`
> - `lib/ai/conversation-state-protocol.ts`
> - `lib/ai/conversation-*`
> - `lib/ai/runtime/*`
> - `lib/video-agent/*`
> - 画布上下文、图片工作流、视频工作流
---
## 0. 先说结论
### 0.1 现在 Agent 为什么“看起来不聪明”
不是单纯模型不够强,而是系统层面还有 5 个根问题:
1. **任务边界不稳定**
- 新任务、继续任务、只聊天、只看图、只要提示词、直接执行,经常互相串。
2. **上下文污染严重**
- 当前输入、历史图片、画布图片、上一轮 workflow、长期记忆,经常一起灌给模型。
- 结果就是用户说一句“你好”,系统也可能把它理解成“继续上一轮修图”。
3. **理解层和执行层没完全解耦**
- 一部分逻辑已经进 protocol / subagent / runtime。
- 但还有不少判断仍然散在 `chat/route.ts`,导致行为不稳定、修改成本高。
4. **模型链路不够统一**
- planner、analyzer、最终回复、图片执行、视频执行并不是同一套稳定规则。
- 之前就出现过:Agent 默认模型是豆包,但最终对话回复走了别的供应商。
5. **超时退化不够“保正确”**
- 一旦 `runAutoAgentGraph`、图像总结、Provider 流式返回超时,系统容易退化成“还能跑,但不对题”。
### 0.2 我们这次整改的目标
这次不是“继续修 10 个 bug”,而是把 Agent 升级成:
- **先判断任务,再选择上下文**
- **先选正确路线,再决定模型和工具**
- **宁可简单,也不跑偏**
- **即使失败,也要明确告诉用户为什么失败**
---
## 1. 这次整改的总原则
### 1.1 一个原则:先判任务,再喂上下文
以后不能再用“把所有上下文先塞给模型,再让模型自己判断”的方式。
要改成:
1. 先判断这轮是:
- 新任务
- 连续任务
- 只分析
- 只要提示词
- 直接执行
- 聊天寒暄
2. 再按这个任务类型,裁剪要喂给模型的上下文
### 1.2 一个原则:执行型和对话型必须分开
以后要明确分两类:
- **对话型回合**
- 聊天
- 看图分析
- 提示词整理
- 澄清确认
- **执行型回合**
- 生图
- 修图
- 生视频
- 分镜确认执行
对话型回合不能轻易被旧工作流拖进执行型链路。
### 1.3 一个原则:失败也要“可解释”
以后不允许再出现这几种体验:
- 用户发了消息,看起来“没反应”
- 数据库里只存了 keepalive
- 模型报错了,但前端没显示
- 回退了,但用户不知道系统为什么回退
---
## 2. 总体架构目标
目标架构:
1. **Task Boundary Layer**
2. **Context Pruning Layer**
3. **Conversation State Protocol**
4. **Planner / Subagent Layer**
5. **Execution Gate Layer**
6. **Persistence / Event Layer**
7. **Frontend Projection Layer**
一句话解释:
- **边界层**决定“你这轮到底要干嘛”
- **裁剪层**决定“该给模型看哪些上下文”
- **协议层**负责统一状态读写
- **子代理层**负责专业领域判断
- **执行门控层**负责确认、取消、修改、超时回退
- **事件层**负责可追踪
- **前端层**负责把系统理解结果明确展示给用户
---
## 3. 五大整改块
---
## 3.1 任务理解层
### 3.1.1 目标
让 Agent 先稳定判断:
- 这是新任务还是继续任务
- 这是对话型还是执行型
- 这是只分析、只要提示词、还是直接执行
### 3.1.2 当前问题
当前系统虽然已经有:
- `conversation-action-resolver`
- `smart-analyzer`
- `skill-router`
- `auto-agent-graph`
但这几层对“任务边界”的定义还不完全统一。
典型问题:
1. “你好”被旧图片 workflow 拖成连续修图
2. “帮我写这张图的提示词”被拖成生图/修图流程
3. 用户只是问“这个图怎么写提示词”,系统却提前进入执行链
### 3.1.3 必做改造
#### A. 新增统一任务边界分类器
建议新增:
- `lib/ai/runtime/task-boundary-classifier.ts`
输出统一结构:
```ts
type TaskBoundaryDecision = {
taskType:
| "greeting"
| "chat"
| "analysis_only"
| "prompt_only"
| "execution_image"
| "execution_video"
| "workflow_followup";
taskMode: "new" | "continue" | "revise" | "confirm" | "cancel";
shouldUseCanvasContext: boolean;
shouldUseHistoryImages: boolean;
shouldUseWorkflowMemory: boolean;
shouldAllowExecution: boolean;
confidence: number;
reason: string;
};
```
#### B. 明确任务优先级规则
优先级必须写死在系统里,不能全靠模型猜:
1. `prompt_only` > `workflow_followup`
2. `analysis_only` > `execution_image`
3. `greeting` > 任意旧 workflow
4. `confirm/cancel/revise` > 普通分析
5. 当前上传图 > 历史图 > 画布背景图
#### C. 给“非执行任务”开快路径
以下场景应该直接走快路径:
- 纯打招呼
- 只要提示词
- 只看图分析
- 只问建议
这些回合不应该再先跑完整 `runAutoAgentGraph`。
### 3.1.4 落点文件
- `app/(chat)/api/chat/route.ts`
- `lib/ai/conversation-action-resolver.ts`
- `lib/ai/smart-analyzer.ts`
- `lib/ai/runtime/task-boundary-classifier.ts`(新增)
### 3.1.5 验收标准
- “你好”永远不被识别为连续修图/连续生图
- “帮我写这张图的提示词”永远不直接触发执行
- “按照刚才那个提示词,再生成 4 张”会稳定识别为连续生图执行
---
## 3.2 记忆层
### 3.2.1 目标
让 Agent 的记忆从“混在一起”变成“分层可控”。
### 3.2.2 当前问题
现在虽然已经有:
- `promptMemory`
- `imageWorkflowMemory`
- `storyboardPreferenceMemory`
- `threadState`
- `workingState`
但问题是:
1. 历史 workflow 容易污染当前回合
2. 画布上下文太大,容易把简单任务拖重
3. “当前相关记忆”和“长期偏好记忆”还没完全分层
### 3.2.3 必做改造
#### A. 拆成 4 层记忆
1. **Turn Memory**
- 当前回合输入
- 当前上传图片
- 当前画布选中
2. **Task Memory**
- 当前任务工作流状态
- 当前任务目标图
- 当前任务优化提示词
3. **Session Memory**
- 当前会话最近 3~5 个高相关任务摘要
4. **Preference Memory**
- 用户长期偏好
- 常用比例
- 常用宫格
- 常用确认习惯
#### B. 增加“记忆衰减/降权”机制
如果旧 workflow 满足任一条件,应自动降权:
- 超过 N 轮未继续
- 当前上传了新图
- 当前任务类型和旧任务类型冲突
- 用户显式说“重新来 / 新开一个 / 别按刚才那个”
#### C. 建立 relevance score
建议新增:
- `lib/ai/runtime/memory-relevance.ts`
对候选记忆打分:
- 当前上传图命中:+100
- 当前选中图命中:+90
- 显式引用“上一张 / 第三张 / 这个提示词”:+80
- 同类任务:+40
- 同会话最近 3 轮:+20
- 超过 10 轮未使用:-50
以后只把 top-k 记忆喂给 Planner。
### 3.2.4 落点文件
- `lib/ai/conversation-memory-composer.ts`
- `lib/ai/conversation-thread-state-builder.ts`
- `lib/ai/runtime/agent-thread-state.ts`
- `lib/ai/runtime/memory-relevance.ts`(新增)
### 3.2.5 验收标准
- 同一会话里,旧图不再轻易干扰新上传图任务
- 简单聊天不再带上大段无关画布上下文
- “继续上一个提示词”仍然能稳定命中
---
## 3.3 路由层
### 3.3.1 目标
让 Planner、Analyzer、最终回复模型、执行模型之间的职责彻底清晰。
### 3.3.2 当前问题
现在的问题不是“没有路由”,而是“路由规则不够统一”:
1. planner 模型和 reply 模型曾经不一致
2. analyzer 有时会被别的 provider 抢走
3. 生图/修图/视频执行模型与对话模型混杂
4. provider 报错或额度不足时,回退不够统一
### 3.3.3 必做改造
#### A. 明确 4 类模型槽位
统一定义:
1. `plannerModel`
2. `analyzerModel`
3. `replyModel`
4. `executionModel`
而不是所有地方都混着叫“当前模型”。
#### B. Agent 模式默认规则
在 `selectedChatModel = auto` 或进入 Agent 模式时:
- `plannerModel` = Agent 默认模型
- `analyzerModel` = Agent 默认模型优先,多模态能力不足时再回退
- `replyModel` = Agent 默认模型
- `executionModel` = 按任务类型路由
#### C. Provider 健康与额度状态缓存
建议新增:
- `lib/ai/runtime/provider-health.ts`
缓存内容:
- 最近错误类型
- 最近额度不足
- 最近超时率
- 最近 5 分钟成功率
如果某 provider 连续报:
- `quota not enough`
- `403`
- `timeout`
则在短时间内直接降级,避免用户连续踩坑。
### 3.3.4 落点文件
- `app/(chat)/api/chat/route.ts`
- `lib/ai/model-router.ts`
- `lib/ai/smart-analyzer.ts`
- `lib/ai/auto-agent-graph.ts`
- `lib/ai/runtime/provider-health.ts`(新增)
### 3.3.5 验收标准
- Agent 模式不会再出现“规划走豆包、回复走别家”的错位
- 上游 provider 额度不足时,能快速回退或明确报错
- 对话模型、执行模型职责清晰
---
## 3.4 执行层
### 3.4.1 目标
让执行系统遵守:
- 该确认时必须确认
- 该取消时必须停
- 该提示时只提示,不执行
- 该失败时明确失败
### 3.4.2 当前问题
虽然我们已经做了很多 protocol / workflow 事件:
- clarification
- storyboard confirm
- revision
- cancel
- execution started/completed
- video generation started/completed/failed
但执行层还存在 3 个结构问题:
1. 快路径不够多
2. timeout fallback 仍偏重
3. “对话型输出”和“执行型输出”没有完全剥离
### 3.4.3 必做改造
#### A. 建立 execution policy gate
建议新增:
- `lib/ai/runtime/execution-policy-gate.ts`
统一做这些判定:
- 当前回合是否允许执行
- 当前回合是否必须先确认
- 当前回合是否应该只输出提示词
- 当前回合是否应该只对话
#### B. 扩展 fast-path 列表
必须内建以下快路径:
1. greeting fast-path
2. prompt-only fast-path
3. analysis-only fast-path
4. confirmation fast-path
5. cancel fast-path
#### C. timeout 退化策略升级
以后 timeout 退化规则应改成:
- `execute` 超时 → 不自动换任务类型
- `planner` 超时 → 退到简单规则,但不跨任务边界
- `provider` 超时 → 直接明确告诉用户该 provider 超时
一句话:
**退化后可以更笨,但不能更错。**
### 3.4.4 落点文件
- `lib/ai/runtime/middlewares/human-confirmation-gate.ts`
- `lib/ai/runtime/execution-policy-gate.ts`(新增)
- `app/(chat)/api/chat/route.ts`
- `lib/video-agent/storyboard-chat-followup.ts`
### 3.4.5 验收标准
- “只要提示词”绝不执行生图
- “确认开始生成”绝不只回复聊天
- provider 报错时前端总能看到明确原因
---
## 3.5 前端交互层
### 3.5.1 目标
让用户知道 Agent 当前理解成了什么,而不是靠猜。
### 3.5.2 当前问题
现在前端可见性还不够:
1. 用户不知道系统把这轮理解成聊天、分析还是执行
2. 用户不知道系统到底引用了哪张图
3. 用户不知道系统为什么突然继续上一轮任务
4. 用户不知道系统为什么没执行
### 3.5.3 必做改造
#### A. 增加“本轮理解摘要”
建议在输入框附近或 assistant 第一段里展示:
- 本轮类型:`聊天 / 分析 / 提示词 / 生图 / 修图 / 视频`
- 目标来源:`当前上传 / 画布选中 / 历史结果 / 上一轮提示词`
- 是否会执行:`仅分析 / 待确认 / 直接执行`
#### B. 增加“任务切换提示”
当系统从旧 workflow 切到新任务时,前端明确显示:
- “已识别为新任务,不继续沿用上一轮修图流程”
当系统继续旧任务时,显示:
- “已继续上一轮修图流程”
#### C. 协议调试面板继续升级
当前已有 `ProtocolDebugPanel`,建议继续加:
- 当前任务边界摘要
- 当前记忆裁剪摘要
- 当前模型槽位摘要(planner / reply / execution)
- 当前 provider 健康状态
### 3.5.4 落点文件
- `components/chat.tsx`
- `components/protocol-debug-panel.tsx`
- `components/canvas/CanvasChatPanel.tsx`
### 3.5.5 验收标准
- 用户能看见“系统理解成什么”
- 用户能看见“为什么这轮不执行”
- debug 时能看见模型和 workflow 生命周期
---
## 4. 实施顺序(必须按这个顺序)
### P0:先止血
1. greeting fast-path
2. prompt-only fast-path
3. stream provider 错误明确回显
4. Agent 模式 replyModel 跟随 Agent 默认模型
> 这几项已经部分落地,但要继续收边并补测试。
### P1:统一任务边界
1. `task-boundary-classifier`
2. 统一 taskType / taskMode / allowExecution
3. 对 route 中旧 heuristics 收口
### P2:统一记忆裁剪
1. relevance scoring
2. session/task/preference 记忆分层
3. 旧 workflow 自动降权
### P3:统一模型链路
1. planner / analyzer / reply / execution 四槽位
2. provider health cache
3. 额度不足/超时自动降级
### P4:统一前端解释性
1. 本轮理解摘要
2. 任务切换提示
3. 调试面板生命周期摘要
---
## 5. 开发清单(可直接排期)
### 5.1 任务理解层
- [ ] 新增 `lib/ai/runtime/task-boundary-classifier.ts`
- [ ] 把 `conversation-action-resolver` 和 `smart-analyzer` 结果统一映射到 `taskType`
- [ ] 增加 `taskMode`:`new/continue/revise/confirm/cancel`
- [ ] 增加 fast-path 优先级表
### 5.2 记忆层
- [ ] 新增 `memory-relevance.ts`
- [ ] `composeConversationMemory` 支持 top-k 裁剪
- [ ] 为 workflow memory 增加过期/降权规则
- [ ] 为 prompt-only / analysis-only 场景建立独立上下文模板
### 5.3 路由层
- [ ] 统一 planner / analyzer / reply / execution 槽位
- [ ] Agent replyModel 跟随 Agent 默认模型
- [ ] provider health 状态缓存
- [ ] 上游 quota / timeout 短时熔断
### 5.4 执行层
- [ ] 新增 `execution-policy-gate.ts`
- [ ] clarification / prompt-only / analysis-only 全部走快路径
- [ ] timeout fallback 改成“保正确优先”
- [ ] 错误统一映射为用户可理解消息
### 5.5 前端层
- [ ] 增加“本轮理解摘要”
- [ ] 增加“任务切换提示”
- [ ] Protocol Debug 增加模型槽位摘要
- [ ] Protocol Debug 增加上下文裁剪摘要
---
## 6. 这次整改完成后,用户体验应该变成什么样
### 场景 1:用户说“你好”
系统行为:
- 识别为 `greeting`
- 不读取旧修图 workflow
- 不做大规模图像分析
- 1 秒内稳定回复
### 场景 2:用户上传图片,说“帮我写这张图的提示词”
系统行为:
- 识别为 `prompt_only`
- 只分析当前图片
- 不进入生图/修图执行链
- 输出提示词
- 把提示词写入 prompt memory
### 场景 3:用户说“按刚才那个提示词再来 4 张”
系统行为:
- 识别为 `image_generation_followup`
- 复用上一轮提示词和参数
- 明确出图数量为 4
- 进入执行链
### 场景 4:用户说“修改第 3 镜头,再确认”
系统行为:
- 识别为 `workflow_followup + revise`
- 只进入 storyboard revision 子链路
- 不误切普通生图
---
## 7. 最后一句话
我们现在已经有:
- protocol
- subagent
- workflow event
- image/video memory
- debug stream
所以真正差的不是“没有 Agent”,而是:
> **还没有把任务边界、记忆裁剪、模型链路、执行门控做成统一系统。**
这份整改清单的目标,就是把 Agent 模式从:
**很多聪明 patch**
升级成:
**真正稳定的统一 Agent Runtime**
---
## 8. 建议的下一步执行顺序
如果马上开工,建议按下面顺序推进:
1. 先做 **`task-boundary-classifier`**
2. 再做 **memory relevance / context pruning**
3. 再做 **provider health + model slot unification**
4. 最后做 **前端理解摘要**
因为只有先把任务边界做对,后面的“更聪明”才不会继续建立在错误理解之上。

View File

@ -3,6 +3,11 @@ import "server-only";
import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state"; import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state";
import type { ImageWorkflowMemory } from "@/lib/ai/image-workflow-memory"; import type { ImageWorkflowMemory } from "@/lib/ai/image-workflow-memory";
import type { PromptMemoryEntry } from "@/lib/ai/prompt-memory"; import type { PromptMemoryEntry } from "@/lib/ai/prompt-memory";
import {
applyContextLineLimit,
applyMemoryHintLimit,
type MemoryRelevancePolicy,
} from "@/lib/ai/runtime/memory-relevance";
import type { StoryboardPreferenceMemory } from "@/lib/video-agent/storyboard-preference-memory"; import type { StoryboardPreferenceMemory } from "@/lib/video-agent/storyboard-preference-memory";
import type { import type {
ResolvedChatMediaInput, ResolvedChatMediaInput,
@ -164,6 +169,7 @@ export function composeConversationMemory(params: {
imageWorkflowMemory?: ImageWorkflowMemory | null; imageWorkflowMemory?: ImageWorkflowMemory | null;
storyboardPreferenceMemory?: StoryboardPreferenceMemory | null; storyboardPreferenceMemory?: StoryboardPreferenceMemory | null;
threadState?: AgentThreadState | null; threadState?: AgentThreadState | null;
memoryRelevancePolicy?: MemoryRelevancePolicy;
}) { }) {
const targetSummary = summarizeTargetResolution(params.targetResolution); const targetSummary = summarizeTargetResolution(params.targetResolution);
const mediaSummary = summarizeResolvedMedia(params.resolvedMediaInput); const mediaSummary = summarizeResolvedMedia(params.resolvedMediaInput);
@ -197,8 +203,12 @@ export function composeConversationMemory(params: {
}; };
return { return {
contextSummary, contextSummary: params.memoryRelevancePolicy
memoryHints, ? applyContextLineLimit(contextSummary, params.memoryRelevancePolicy.maxContextLines)
: contextSummary,
memoryHints: params.memoryRelevancePolicy
? applyMemoryHintLimit(memoryHints, params.memoryRelevancePolicy.maxMemoryHints)
: memoryHints,
metadata, metadata,
} satisfies ComposedConversationMemory; } satisfies ComposedConversationMemory;
} }

View File

@ -22,6 +22,11 @@ import {
resolveConversationAction, resolveConversationAction,
extractPendingGridConfirmationFromWorkingState, extractPendingGridConfirmationFromWorkingState,
} from "@/lib/ai/conversation-action-resolver"; } from "@/lib/ai/conversation-action-resolver";
import {
applyContextLineLimit,
applyMemoryHintLimit,
resolveMemoryRelevancePolicy,
} from "@/lib/ai/runtime/memory-relevance";
import { import {
composeConversationMemory, composeConversationMemory,
type ComposedConversationMemory, type ComposedConversationMemory,
@ -37,6 +42,10 @@ import {
buildPromptMemoryUpdate, buildPromptMemoryUpdate,
} from "@/lib/ai/prompt-memory"; } from "@/lib/ai/prompt-memory";
import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state"; import type { AgentThreadState } from "@/lib/ai/runtime/agent-thread-state";
import {
classifyTaskBoundary,
type TaskBoundaryDecision,
} from "@/lib/ai/runtime/task-boundary-classifier";
import type { StoryboardPreferenceMemory } from "@/lib/video-agent/storyboard-preference-memory"; import type { StoryboardPreferenceMemory } from "@/lib/video-agent/storyboard-preference-memory";
import { import {
extractStoryboardPreferenceMemory, extractStoryboardPreferenceMemory,
@ -56,6 +65,7 @@ export interface ConversationStateProtocolBootstrap {
pendingGridConfirmation: PendingGridConfirmation | null; pendingGridConfirmation: PendingGridConfirmation | null;
confirmedPrompt: string | undefined; confirmedPrompt: string | undefined;
actionResolution: ReturnType<typeof resolveConversationAction>; actionResolution: ReturnType<typeof resolveConversationAction>;
taskBoundaryDecision: TaskBoundaryDecision;
} }
export interface ConversationStateProtocolComposed { export interface ConversationStateProtocolComposed {
@ -181,6 +191,7 @@ export function bootstrapConversationStateProtocol(params: {
} | null; } | null;
activeStoryboardTask?: StoryboardTaskDetail | null; activeStoryboardTask?: StoryboardTaskDetail | null;
hasImageInputForPromptCarryForward: boolean; hasImageInputForPromptCarryForward: boolean;
hasCanvasSelection?: boolean;
}) { }) {
const storyboardPreferenceMemory = extractStoryboardPreferenceMemory(params.workingState); const storyboardPreferenceMemory = extractStoryboardPreferenceMemory(params.workingState);
const imageWorkflowMemory = extractImageWorkflowMemory(params.workingState); const imageWorkflowMemory = extractImageWorkflowMemory(params.workingState);
@ -197,6 +208,14 @@ export function bootstrapConversationStateProtocol(params: {
confirmedPrompt, confirmedPrompt,
hasImageInputForPromptCarryForward: params.hasImageInputForPromptCarryForward, hasImageInputForPromptCarryForward: params.hasImageInputForPromptCarryForward,
}); });
const taskBoundaryDecision = classifyTaskBoundary({
text: params.text,
actionResolution,
hasCurrentImageInput: params.hasImageInputForPromptCarryForward,
hasCanvasSelection: params.hasCanvasSelection === true,
pendingGridConfirmation,
hasActiveStoryboardTask: Boolean(params.activeStoryboardTask?.task?.id),
});
return { return {
workingState: params.workingState || null, workingState: params.workingState || null,
@ -207,6 +226,7 @@ export function bootstrapConversationStateProtocol(params: {
pendingGridConfirmation, pendingGridConfirmation,
confirmedPrompt, confirmedPrompt,
actionResolution, actionResolution,
taskBoundaryDecision,
} satisfies ConversationStateProtocolBootstrap; } satisfies ConversationStateProtocolBootstrap;
} }
@ -216,33 +236,62 @@ export function composeConversationStateProtocol(params: {
resolvedMediaInput: ResolvedChatMediaInput; resolvedMediaInput: ResolvedChatMediaInput;
targetResolution: ResolvedTargetAssets; targetResolution: ResolvedTargetAssets;
}) { }) {
const memoryRelevancePolicy = resolveMemoryRelevancePolicy({
taskBoundaryDecision: params.bootstrap.taskBoundaryDecision,
actionResolution: params.bootstrap.actionResolution,
hasActiveStoryboardTask: Boolean(params.bootstrap.activeStoryboardTask?.task?.id),
hasPendingGridConfirmation: Boolean(params.bootstrap.pendingGridConfirmation),
hasCurrentImageInput: params.resolvedMediaInput.currentMessageImageUrls.length > 0,
hasCanvasSelection: params.resolvedMediaInput.selectedImageUrls.length > 0,
});
const baseThreadState = buildConversationThreadState({ const baseThreadState = buildConversationThreadState({
activeStoryboardTask: params.bootstrap.activeStoryboardTask, activeStoryboardTask: memoryRelevancePolicy.includeWorkflowState
storyboardPreferenceMemory: params.bootstrap.storyboardPreferenceMemory, ? params.bootstrap.activeStoryboardTask
promptMemory: params.bootstrap.promptMemory, : null,
imageWorkflowMemory: params.bootstrap.imageWorkflowMemory, storyboardPreferenceMemory: memoryRelevancePolicy.includeStoryboardPreferenceMemory
? params.bootstrap.storyboardPreferenceMemory
: null,
promptMemory: memoryRelevancePolicy.includePromptMemory
? params.bootstrap.promptMemory
: null,
imageWorkflowMemory: memoryRelevancePolicy.includeImageWorkflowMemory
? params.bootstrap.imageWorkflowMemory
: null,
}); });
const composedConversationMemory = composeConversationMemory({ const composedConversationMemory = composeConversationMemory({
autoContextText: params.autoContextText || undefined, autoContextText: params.autoContextText || undefined,
resolvedMediaInput: params.resolvedMediaInput, resolvedMediaInput: params.resolvedMediaInput,
targetResolution: params.targetResolution, targetResolution: params.targetResolution,
promptMemory: params.bootstrap.promptMemory, promptMemory: memoryRelevancePolicy.includePromptMemory
imageWorkflowMemory: params.bootstrap.imageWorkflowMemory, ? params.bootstrap.promptMemory
storyboardPreferenceMemory: params.bootstrap.storyboardPreferenceMemory, : null,
imageWorkflowMemory: memoryRelevancePolicy.includeImageWorkflowMemory
? params.bootstrap.imageWorkflowMemory
: null,
storyboardPreferenceMemory: memoryRelevancePolicy.includeStoryboardPreferenceMemory
? params.bootstrap.storyboardPreferenceMemory
: null,
threadState: baseThreadState, threadState: baseThreadState,
memoryRelevancePolicy,
}); });
const threadState: AgentThreadState = { const threadState: AgentThreadState = {
workflowState: baseThreadState?.workflowState || null, workflowState: baseThreadState?.workflowState || null,
memoryHints: Array.from( memoryHints: applyMemoryHintLimit(
Array.from(
new Set([ new Set([
...(baseThreadState?.memoryHints || []), ...(baseThreadState?.memoryHints || []),
...composedConversationMemory.memoryHints, ...composedConversationMemory.memoryHints,
]) ])
), ),
contextSummary: memoryRelevancePolicy.maxMemoryHints,
),
contextSummary: applyContextLineLimit(
composedConversationMemory.contextSummary || baseThreadState?.contextSummary, composedConversationMemory.contextSummary || baseThreadState?.contextSummary,
memoryRelevancePolicy.maxContextLines,
),
metadata: { metadata: {
...(baseThreadState?.metadata || {}), ...(baseThreadState?.metadata || {}),
...composedConversationMemory.metadata, ...composedConversationMemory.metadata,

View File

@ -0,0 +1,108 @@
import "server-only";
import {
getAiModelById,
getAiProviderById,
getEnabledAiModelsWithProvider,
} from "@/lib/db/queries";
export type AgentModelSlotName = "planner" | "analyzer" | "reply" | "execution";
export interface AgentModelSlotDescriptor {
modelId: string;
displayName?: string;
providerId?: string;
providerType?: string;
providerKey?: string;
}
export interface AgentModelSlot extends AgentModelSlotDescriptor {
slot: AgentModelSlotName;
source: string;
reason?: string;
locked?: boolean;
}
export interface AgentModelSlots {
planner?: AgentModelSlot;
analyzer?: AgentModelSlot;
reply?: AgentModelSlot;
execution?: AgentModelSlot;
}
function buildProviderKey(params: {
providerId?: string;
providerType?: string;
modelId: string;
}) {
if (params.providerId) return params.providerId;
if (params.providerType) return `${params.providerType}:${params.modelId}`;
return `model:${params.modelId}`;
}
export async function resolveAgentModelSlotDescriptor(modelId: string) {
const normalizedModelId = String(modelId || "").trim();
if (!normalizedModelId) return null;
const isUuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalizedModelId);
if (isUuid) {
const model = await getAiModelById({ id: normalizedModelId }).catch(() => null);
if (!model) {
return {
modelId: normalizedModelId,
providerKey: `model:${normalizedModelId}`,
} satisfies AgentModelSlotDescriptor;
}
const provider = await getAiProviderById({ id: model.providerId }).catch(() => null);
return {
modelId: model.id,
displayName: model.displayName || model.modelId,
providerId: model.providerId,
providerType: provider?.type || undefined,
providerKey: buildProviderKey({
providerId: model.providerId,
providerType: provider?.type || undefined,
modelId: model.id,
}),
} satisfies AgentModelSlotDescriptor;
}
const enabledModels = await getEnabledAiModelsWithProvider(false).catch(() => []);
const matched = enabledModels.find(
({ model }) => model.id === normalizedModelId || model.modelId === normalizedModelId,
);
if (!matched) {
return {
modelId: normalizedModelId,
providerKey: `model:${normalizedModelId}`,
} satisfies AgentModelSlotDescriptor;
}
return {
modelId: matched.model.id,
displayName: matched.model.displayName || matched.model.modelId,
providerId: matched.model.providerId,
providerType: matched.provider?.type || undefined,
providerKey: buildProviderKey({
providerId: matched.model.providerId,
providerType: matched.provider?.type || undefined,
modelId: matched.model.id,
}),
} satisfies AgentModelSlotDescriptor;
}
export function upsertAgentModelSlot(
slots: AgentModelSlots,
slot: AgentModelSlotName,
value: Omit<AgentModelSlot, "slot">,
) {
return {
...slots,
[slot]: {
slot,
...value,
},
} satisfies AgentModelSlots;
}

View File

@ -0,0 +1,134 @@
import "server-only";
import type { ConversationActionResolution } from "@/lib/ai/conversation-action-resolver";
import type { TaskBoundaryDecision } from "@/lib/ai/runtime/task-boundary-classifier";
export interface MemoryRelevancePolicy {
includePromptMemory: boolean;
includeImageWorkflowMemory: boolean;
includeStoryboardPreferenceMemory: boolean;
includeWorkflowState: boolean;
maxMemoryHints: number;
maxContextLines: number;
reason: string;
}
export function resolveMemoryRelevancePolicy(params: {
taskBoundaryDecision: TaskBoundaryDecision;
actionResolution: ConversationActionResolution;
hasActiveStoryboardTask: boolean;
hasPendingGridConfirmation: boolean;
hasCurrentImageInput: boolean;
hasCanvasSelection: boolean;
}) {
const { taskBoundaryDecision, actionResolution } = params;
switch (taskBoundaryDecision.taskType) {
case "greeting":
return {
includePromptMemory: false,
includeImageWorkflowMemory: false,
includeStoryboardPreferenceMemory: false,
includeWorkflowState: false,
maxMemoryHints: 0,
maxContextLines: 2,
reason: "greeting_turn_ignores_task_memory",
} satisfies MemoryRelevancePolicy;
case "chat":
return {
includePromptMemory: false,
includeImageWorkflowMemory: false,
includeStoryboardPreferenceMemory: false,
includeWorkflowState: false,
maxMemoryHints: 1,
maxContextLines: 3,
reason: "chat_turn_prefers_clean_context",
} satisfies MemoryRelevancePolicy;
case "prompt_only":
return {
includePromptMemory: false,
includeImageWorkflowMemory: false,
includeStoryboardPreferenceMemory: false,
includeWorkflowState: false,
maxMemoryHints: 2,
maxContextLines: 4,
reason: "prompt_only_turn_ignores_execution_memory",
} satisfies MemoryRelevancePolicy;
case "analysis_only":
return {
includePromptMemory: false,
includeImageWorkflowMemory: false,
includeStoryboardPreferenceMemory: false,
includeWorkflowState: false,
maxMemoryHints: 2,
maxContextLines: 4,
reason: "analysis_only_turn_ignores_workflow_memory",
} satisfies MemoryRelevancePolicy;
case "execution_video":
return {
includePromptMemory: false,
includeImageWorkflowMemory: false,
includeStoryboardPreferenceMemory: true,
includeWorkflowState:
params.hasActiveStoryboardTask || params.hasPendingGridConfirmation,
maxMemoryHints: 4,
maxContextLines: 6,
reason: "video_execution_prefers_storyboard_preferences",
} satisfies MemoryRelevancePolicy;
case "workflow_followup":
return {
includePromptMemory:
actionResolution.kind === "image_generation_followup" ||
actionResolution.kind === "prompt_carry_forward",
includeImageWorkflowMemory:
actionResolution.kind === "image_edit_followup" ||
actionResolution.kind === "image_generation_followup" ||
actionResolution.kind === "prompt_carry_forward",
includeStoryboardPreferenceMemory:
actionResolution.kind === "grid_confirmation_followup" ||
actionResolution.kind === "active_storyboard_followup",
includeWorkflowState: true,
maxMemoryHints: 6,
maxContextLines: 8,
reason: "workflow_followup_requires_workflow_memory",
} satisfies MemoryRelevancePolicy;
case "execution_image":
default:
return {
includePromptMemory:
actionResolution.usePromptCarryForward ||
taskBoundaryDecision.taskMode === "continue",
includeImageWorkflowMemory:
taskBoundaryDecision.taskMode === "continue" ||
(!params.hasCurrentImageInput && !params.hasCanvasSelection),
includeStoryboardPreferenceMemory: false,
includeWorkflowState:
taskBoundaryDecision.taskMode === "continue" &&
(actionResolution.kind === "image_edit_followup" ||
actionResolution.kind === "image_generation_followup" ||
actionResolution.kind === "prompt_carry_forward"),
maxMemoryHints: taskBoundaryDecision.taskMode === "continue" ? 5 : 3,
maxContextLines: taskBoundaryDecision.taskMode === "continue" ? 7 : 5,
reason:
taskBoundaryDecision.taskMode === "continue"
? "image_execution_continue_reuses_relevant_memory"
: "image_execution_new_task_limits_old_memory",
} satisfies MemoryRelevancePolicy;
}
}
export function applyMemoryHintLimit(hints: string[], maxHints: number) {
if (!Array.isArray(hints) || maxHints <= 0) return [] as string[];
return hints.slice(0, maxHints);
}
export function applyContextLineLimit(summary: string | undefined, maxLines: number) {
const normalized = String(summary || "").trim();
if (!normalized || maxLines <= 0) return "";
return normalized
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.slice(0, maxLines)
.join("\n");
}

View File

@ -0,0 +1,109 @@
import "server-only";
export type ProviderHealthEventType = "success" | "timeout" | "quota" | "error";
export interface ProviderHealthSnapshot {
providerKey: string;
providerType?: string;
lastEventType: ProviderHealthEventType;
lastError?: string;
consecutiveFailures: number;
lastSuccessAt?: number;
updatedAt: number;
}
const providerHealthMap = new Map<string, ProviderHealthSnapshot>();
function normalizeErrorMessage(error: unknown) {
return error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
}
export function classifyProviderHealthEvent(error: unknown): ProviderHealthEventType {
const normalized = normalizeErrorMessage(error).toLowerCase();
if (
normalized.includes("token quota is not enough") ||
normalized.includes("pre_consume_token_quota_failed") ||
normalized.includes("quota is not enough")
) {
return "quota";
}
if (
normalized.includes("timeout") ||
normalized.includes("timed out") ||
normalized.includes("body timeout") ||
normalized.includes("headers timeout")
) {
return "timeout";
}
return "error";
}
export function recordProviderHealthSuccess(params: {
providerKey?: string;
providerType?: string;
}) {
const providerKey = String(params.providerKey || "").trim();
if (!providerKey) return;
const existing = providerHealthMap.get(providerKey);
providerHealthMap.set(providerKey, {
providerKey,
providerType: params.providerType || existing?.providerType,
lastEventType: "success",
consecutiveFailures: 0,
lastError: undefined,
lastSuccessAt: Date.now(),
updatedAt: Date.now(),
});
}
export function recordProviderHealthFailure(params: {
providerKey?: string;
providerType?: string;
error: unknown;
}) {
const providerKey = String(params.providerKey || "").trim();
if (!providerKey) return;
const existing = providerHealthMap.get(providerKey);
providerHealthMap.set(providerKey, {
providerKey,
providerType: params.providerType || existing?.providerType,
lastEventType: classifyProviderHealthEvent(params.error),
lastError: normalizeErrorMessage(params.error).slice(0, 400),
consecutiveFailures:
existing?.lastEventType === "success"
? 1
: Math.max(1, (existing?.consecutiveFailures || 0) + 1),
lastSuccessAt: existing?.lastSuccessAt,
updatedAt: Date.now(),
});
}
export function getProviderHealthSnapshot(providerKey?: string) {
const normalized = String(providerKey || "").trim();
if (!normalized) return null;
return providerHealthMap.get(normalized) || null;
}
export function shouldAvoidProvider(snapshot?: ProviderHealthSnapshot | null) {
if (!snapshot) return false;
const ageMs = Date.now() - snapshot.updatedAt;
if (snapshot.lastEventType === "quota") {
return ageMs <= 15 * 60 * 1000;
}
if (snapshot.lastEventType === "timeout") {
return snapshot.consecutiveFailures >= 2 && ageMs <= 5 * 60 * 1000;
}
if (snapshot.lastEventType === "error") {
return snapshot.consecutiveFailures >= 3 && ageMs <= 5 * 60 * 1000;
}
return false;
}

View File

@ -0,0 +1,226 @@
import "server-only";
import type {
ConversationActionResolution,
PendingGridConfirmation,
} from "@/lib/ai/conversation-action-resolver";
import { isExplicitPromptOptimizationRequest } from "@/lib/ai/smart-analyzer";
import { detectStoryboardCommandIntent } from "@/lib/video-agent/storyboard-command-interpreter";
export type TaskBoundaryType =
| "greeting"
| "chat"
| "analysis_only"
| "prompt_only"
| "execution_image"
| "execution_video"
| "workflow_followup";
export type TaskBoundaryMode = "new" | "continue" | "revise" | "confirm" | "cancel";
export interface TaskBoundaryDecision {
taskType: TaskBoundaryType;
taskMode: TaskBoundaryMode;
shouldUseCanvasContext: boolean;
shouldUseHistoryImages: boolean;
shouldUseWorkflowMemory: boolean;
shouldAllowExecution: boolean;
confidence: number;
reason: string;
}
function normalizeCompactText(text: string) {
return String(text || "").toLowerCase().replace(/\s+/g, "").trim();
}
export function isPureGreetingTurnRequest(text: string): boolean {
const normalized = normalizeCompactText(text);
if (!normalized) return false;
return /^(你好|您好|hello|hi|嗨|哈喽|在吗|早上好|中午好|下午好|晚上好|深夜好|谢谢|感谢|收到|好的|ok|okay|明白了|了解了)$/.test(
normalized,
);
}
function isLikelyVideoExecutionRequest(text: string): boolean {
const normalized = normalizeCompactText(text);
if (!normalized) return false;
return /图生视频|文生视频|视频生成|生成视频|做视频|做成视频|开始生成视频|让.*动起来|变成视频|视频/.test(
normalized,
);
}
function isLikelyImageExecutionRequest(text: string): boolean {
const normalized = normalizeCompactText(text);
if (!normalized) return false;
return /生图|生成图片|出图|画一张|帮我画|做图|做一张图|重新生成|再来一张|执行生成|开始生成|生成出来/.test(
normalized,
);
}
function isLikelyAnalysisOnlyRequest(text: string, hasImageContext: boolean): boolean {
if (!hasImageContext) return false;
const normalized = normalizeCompactText(text);
if (!normalized) return false;
return /分析|看图|看看|识别|拆解|提炼|整理|归纳|总结|描述|解释|判断|帮我看看|帮我分析|帮我判断|是什么|适合吗|怎么样|评价/.test(
normalized,
);
}
function resolveStoryboardTaskMode(params: {
text: string;
hasPendingGridConfirmation: boolean;
hasActiveStoryboardTask: boolean;
}): TaskBoundaryMode {
if (!params.hasPendingGridConfirmation && !params.hasActiveStoryboardTask) {
return "continue";
}
const intent = detectStoryboardCommandIntent(params.text);
switch (intent) {
case "confirm":
return "confirm";
case "revise":
return "revise";
case "cancel":
return "cancel";
case "unclear":
default:
return "continue";
}
}
export function classifyTaskBoundary(params: {
text: string;
actionResolution: ConversationActionResolution;
hasCurrentImageInput: boolean;
hasCanvasSelection: boolean;
pendingGridConfirmation?: PendingGridConfirmation | null;
hasActiveStoryboardTask: boolean;
}) {
const hasImageContext = params.hasCurrentImageInput || params.hasCanvasSelection;
if (isPureGreetingTurnRequest(params.text) && !params.hasCurrentImageInput) {
return {
taskType: "greeting",
taskMode: "new",
shouldUseCanvasContext: false,
shouldUseHistoryImages: false,
shouldUseWorkflowMemory: false,
shouldAllowExecution: false,
confidence: 0.99,
reason: "pure_greeting_turn",
} satisfies TaskBoundaryDecision;
}
if (isExplicitPromptOptimizationRequest(params.text)) {
return {
taskType: "prompt_only",
taskMode: "new",
shouldUseCanvasContext: !params.hasCurrentImageInput && params.hasCanvasSelection,
shouldUseHistoryImages: !params.hasCurrentImageInput && !params.hasCanvasSelection,
shouldUseWorkflowMemory: false,
shouldAllowExecution: false,
confidence: 0.97,
reason: "explicit_prompt_only_request",
} satisfies TaskBoundaryDecision;
}
switch (params.actionResolution.kind) {
case "grid_confirmation_followup":
case "active_storyboard_followup":
return {
taskType: "workflow_followup",
taskMode: resolveStoryboardTaskMode({
text: params.text,
hasPendingGridConfirmation: Boolean(params.pendingGridConfirmation),
hasActiveStoryboardTask: params.hasActiveStoryboardTask,
}),
shouldUseCanvasContext: params.hasCanvasSelection,
shouldUseHistoryImages: !params.hasCurrentImageInput,
shouldUseWorkflowMemory: true,
shouldAllowExecution: true,
confidence: 0.95,
reason: params.actionResolution.kind,
} satisfies TaskBoundaryDecision;
case "image_edit_followup":
case "image_generation_followup":
return {
taskType: "workflow_followup",
taskMode: "continue",
shouldUseCanvasContext: params.hasCanvasSelection,
shouldUseHistoryImages: !params.hasCurrentImageInput,
shouldUseWorkflowMemory: true,
shouldAllowExecution: true,
confidence: 0.92,
reason: params.actionResolution.kind,
} satisfies TaskBoundaryDecision;
case "prompt_carry_forward":
return {
taskType: "execution_image",
taskMode: "continue",
shouldUseCanvasContext: params.hasCanvasSelection,
shouldUseHistoryImages: !params.hasCurrentImageInput,
shouldUseWorkflowMemory: true,
shouldAllowExecution: true,
confidence: 0.93,
reason: "prompt_carry_forward_execution",
} satisfies TaskBoundaryDecision;
case "default":
default:
break;
}
if (isLikelyVideoExecutionRequest(params.text)) {
return {
taskType: "execution_video",
taskMode: "new",
shouldUseCanvasContext: params.hasCanvasSelection,
shouldUseHistoryImages: false,
shouldUseWorkflowMemory: false,
shouldAllowExecution: true,
confidence: 0.88,
reason: "explicit_video_execution_request",
} satisfies TaskBoundaryDecision;
}
if (isLikelyImageExecutionRequest(params.text)) {
return {
taskType: "execution_image",
taskMode: "new",
shouldUseCanvasContext: params.hasCanvasSelection,
shouldUseHistoryImages: false,
shouldUseWorkflowMemory: false,
shouldAllowExecution: true,
confidence: 0.87,
reason: "explicit_image_execution_request",
} satisfies TaskBoundaryDecision;
}
if (isLikelyAnalysisOnlyRequest(params.text, hasImageContext)) {
return {
taskType: "analysis_only",
taskMode: "new",
shouldUseCanvasContext: params.hasCanvasSelection,
shouldUseHistoryImages: !params.hasCurrentImageInput && !params.hasCanvasSelection,
shouldUseWorkflowMemory: false,
shouldAllowExecution: false,
confidence: 0.9,
reason: "analysis_only_request",
} satisfies TaskBoundaryDecision;
}
return {
taskType: "chat",
taskMode: "new",
shouldUseCanvasContext: false,
shouldUseHistoryImages: false,
shouldUseWorkflowMemory: false,
shouldAllowExecution: false,
confidence: 0.7,
reason: "default_chat_turn",
} satisfies TaskBoundaryDecision;
}

View File

@ -0,0 +1,169 @@
import "server-only";
import type { DBMessage } from "@/lib/db/schema";
import type {
CanvasImageNodeContext,
ContextPayload,
WorkingStateImageMemory,
} from "@/lib/ai/context-memory/types";
import type { TaskBoundaryDecision } from "@/lib/ai/runtime/task-boundary-classifier";
function buildSelectedCanvasSubset(contextPayload?: ContextPayload) {
const selectedNodeIds = new Set(contextPayload?.selectedNodeIds || []);
const selectedAssetIds = new Set(contextPayload?.selectedAssetIds || []);
const selectedNodes = (contextPayload?.canvasImageNodes || []).filter((node) => {
if (!node) return false;
if (node.isSelected) return true;
if (node.nodeId && selectedNodeIds.has(node.nodeId)) return true;
if (node.assetId && selectedAssetIds.has(node.assetId)) return true;
return false;
});
return {
selectedNodes,
selectedNodeIds: selectedNodes.map((node) => node.nodeId).filter(Boolean),
selectedAssetIds: selectedNodes.map((node) => node.assetId).filter(Boolean) as string[],
};
}
function buildSelectedCanvasSummary(selectedNodes: CanvasImageNodeContext[]) {
if (selectedNodes.length === 0) return undefined;
const previewLines = selectedNodes.slice(0, 3).map((node, index) => {
const title = String(node.title || node.prompt || node.nodeId || `图片${index + 1}`).trim();
return `选中图片${index + 1}: ${title}`;
});
return [`用户已选中 ${selectedNodes.length} 张图片,优先处理这些`, ...previewLines].join("\n");
}
export function pruneTaskBoundaryContext(params: {
decision: TaskBoundaryDecision;
contextPayload?: ContextPayload;
autoContextText?: string;
workingState?: WorkingStateImageMemory | null;
hasCurrentImageInput: boolean;
}) {
if (!params.contextPayload && !params.autoContextText) {
return {
contextPayload: params.contextPayload,
autoContextText: params.autoContextText,
workingState: params.decision.shouldUseWorkflowMemory ? params.workingState || null : null,
};
}
const selectedSubset = buildSelectedCanvasSubset(params.contextPayload);
const shouldPreferSelectedOnly =
selectedSubset.selectedNodes.length > 0 &&
(params.decision.taskType === "prompt_only" ||
params.decision.taskType === "analysis_only" ||
params.decision.taskType === "execution_image" ||
params.decision.taskType === "execution_video" ||
params.hasCurrentImageInput);
let nextContextPayload = params.contextPayload;
let nextAutoContextText = params.autoContextText;
if (!params.decision.shouldUseCanvasContext) {
nextContextPayload = params.contextPayload
? {
source: params.contextPayload.source,
projectId: params.contextPayload.projectId,
focusPolicy: params.contextPayload.focusPolicy,
selectedNodeIds: [],
selectedAssetIds: [],
canvasImageNodes: [],
}
: undefined;
nextAutoContextText = undefined;
} else if (shouldPreferSelectedOnly) {
nextContextPayload = params.contextPayload
? {
...params.contextPayload,
focusPolicy: "selected",
selectedNodeIds: selectedSubset.selectedNodeIds,
selectedAssetIds: selectedSubset.selectedAssetIds,
canvasImageNodes: selectedSubset.selectedNodes,
}
: undefined;
nextAutoContextText = buildSelectedCanvasSummary(selectedSubset.selectedNodes);
}
return {
contextPayload: nextContextPayload,
autoContextText: nextAutoContextText,
workingState: params.decision.shouldUseWorkflowMemory ? params.workingState || null : null,
};
}
function sanitizeHistoryText(text: string) {
return String(text || "")
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
.replace(/<video[^>]*>[\s\S]*?<\/video>/gi, "")
.replace(/<video[^>]+src="[^"]+"[^>]*>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/◈\s*[^\n]+\n*/g, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function toTextOnlyHistoryMessages(messages: DBMessage[], maxMessages: number): DBMessage[] {
const sliced = messages.slice(-Math.max(1, maxMessages));
const result: DBMessage[] = [];
for (const message of sliced) {
const parts = Array.isArray(message.parts) ? (message.parts as Array<Record<string, unknown>>) : [];
const textParts = parts
.filter((part) => part?.type === "text" && typeof part?.text === "string")
.map((part) => ({
type: "text",
text: sanitizeHistoryText(String(part.text || "")),
}))
.filter((part) => part.text);
if (textParts.length === 0) continue;
result.push({
...message,
parts: textParts,
attachments: [],
} satisfies DBMessage);
}
return result;
}
export function pruneTaskBoundaryHistoryMessages(params: {
decision: TaskBoundaryDecision;
messages: DBMessage[];
purpose: "media" | "model";
hasCurrentImageInput: boolean;
}): DBMessage[] {
const messages = Array.isArray(params.messages) ? params.messages : [];
if (messages.length === 0) return [] as DBMessage[];
switch (params.decision.taskType) {
case "greeting":
return [];
case "prompt_only":
return params.purpose === "media"
? []
: toTextOnlyHistoryMessages(messages, params.hasCurrentImageInput ? 2 : 4);
case "analysis_only":
return params.purpose === "media"
? []
: toTextOnlyHistoryMessages(messages, 4);
case "chat":
return params.purpose === "media"
? []
: toTextOnlyHistoryMessages(messages, 6);
case "execution_image":
case "execution_video":
if (params.decision.shouldUseHistoryImages) {
return messages.slice(-8);
}
return params.purpose === "media" ? [] : toTextOnlyHistoryMessages(messages, 4);
case "workflow_followup":
return messages.slice(-(params.purpose === "media" ? 8 : 12));
default:
return params.purpose === "media" ? [] : toTextOnlyHistoryMessages(messages, 4);
}
}