ai-chatbot/lib/ai/runtime/task-boundary-context-pruner.ts
Your Name 9ecd91376d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: strengthen agent runtime boundaries
2026-03-16 11:45:00 +08:00

170 lines
5.8 KiB
TypeScript

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);
}
}