253 lines
6.7 KiB
TypeScript
253 lines
6.7 KiB
TypeScript
import type {
|
||
AgentProjectToolDefinition,
|
||
AgentProjectToolName,
|
||
} from "@/lib/ai/tools-registry";
|
||
import type {
|
||
AgentSpecialistContext,
|
||
AgentSpecialistRoute,
|
||
} from "@/lib/ai/specialists";
|
||
import { buildStoryboardSpecialistRoute } from "@/lib/video-agent/storyboard-subagent";
|
||
|
||
export interface AgentSkillRouteResult {
|
||
primary: AgentSpecialistRoute;
|
||
overlays: AgentSpecialistRoute[];
|
||
toolAllowlist: AgentProjectToolName[];
|
||
planningHints: string[];
|
||
reason: string;
|
||
}
|
||
|
||
function normalizeCompactText(text: string) {
|
||
return String(text || "").toLowerCase().replace(/\s+/g, "").trim();
|
||
}
|
||
|
||
function dedupeToolNames(toolNames: AgentProjectToolName[]) {
|
||
return Array.from(new Set(toolNames));
|
||
}
|
||
|
||
function isPureGreeting(text: string) {
|
||
const normalized = normalizeCompactText(text);
|
||
if (!normalized) return false;
|
||
return /^(你好|您好|hello|hi|嗨|哈喽|在吗|早上好|中午好|下午好|晚上好|深夜好|谢谢|感谢|收到|好的|ok|okay|明白了|了解了)$/.test(
|
||
normalized,
|
||
);
|
||
}
|
||
|
||
function hasCanvasContext(context: AgentSpecialistContext) {
|
||
return (
|
||
context.hasCanvasSelection ||
|
||
(context.selectedImageCount || 0) > 0 ||
|
||
context.mediaSource === "canvas_selection" ||
|
||
context.targetResolutionMode === "explicit_selection"
|
||
);
|
||
}
|
||
|
||
function mentionsCanvas(text: string) {
|
||
return /画布|发布到画布|同步到画布|发送到画布|放到画布/.test(
|
||
String(text || "").toLowerCase(),
|
||
);
|
||
}
|
||
|
||
function createRoute(
|
||
route: AgentSpecialistRoute,
|
||
toolAllowlist: AgentProjectToolName[],
|
||
planningHints: string[],
|
||
): AgentSpecialistRoute {
|
||
return {
|
||
...route,
|
||
toolAllowlist: dedupeToolNames(toolAllowlist),
|
||
planningHints,
|
||
};
|
||
}
|
||
|
||
export function routeAgentSkill(
|
||
context: AgentSpecialistContext,
|
||
): AgentSkillRouteResult {
|
||
const greetingTurn = isPureGreeting(context.text);
|
||
|
||
const conversationRoute = createRoute(
|
||
{
|
||
domain: "conversation",
|
||
specialistId: "conversation-coordinator",
|
||
reason: greetingTurn ? "纯打招呼/寒暄" : "普通对话回合",
|
||
planningHints: [],
|
||
},
|
||
["respondDirect"],
|
||
[
|
||
"当前回合是纯对话,不要调用生图、生视频或修图工具。",
|
||
"即使当前有图片,也只能先自然回复,不能直接执行。",
|
||
],
|
||
);
|
||
|
||
const visionRoute = createRoute(
|
||
{
|
||
domain: "vision",
|
||
specialistId: "vision-analyst",
|
||
reason: "当前回合以看图分析为主",
|
||
planningHints: [],
|
||
},
|
||
[
|
||
"analyzeImage",
|
||
"respondDirect",
|
||
"useSelectedCanvasImage",
|
||
"useLatestResultImage",
|
||
"useOriginalUploadImage",
|
||
],
|
||
[
|
||
"当前回合重点是识图、分析、解释或给建议,不要误切到生图。",
|
||
"如有目标图,优先围绕目标图做分析,再给用户结论。",
|
||
],
|
||
);
|
||
|
||
const imageEditRoute = createRoute(
|
||
{
|
||
domain: "image_edit",
|
||
specialistId: "image-edit-specialist",
|
||
reason: "当前回合是修图/抠图/增强类任务",
|
||
planningHints: [],
|
||
},
|
||
[
|
||
"editImage",
|
||
"removeBackground",
|
||
"enhanceImage",
|
||
"extendImage",
|
||
"resizeImage",
|
||
"boxCutout",
|
||
"useSelectedCanvasImage",
|
||
"useLatestResultImage",
|
||
"useOriginalUploadImage",
|
||
"publishToCanvas",
|
||
],
|
||
[
|
||
"当前回合必须收敛到修图链路,优先选择最具体的修图工具。",
|
||
"除非用户明确要求生成新图,否则不要把修图误判成生图。",
|
||
],
|
||
);
|
||
|
||
const imageGenerateRoute = createRoute(
|
||
{
|
||
domain: "image_generate",
|
||
specialistId: "image-generate-specialist",
|
||
reason: "当前回合是生图任务",
|
||
planningHints: [],
|
||
},
|
||
[
|
||
"generateImage",
|
||
"useSelectedCanvasImage",
|
||
"useLatestResultImage",
|
||
"useOriginalUploadImage",
|
||
"publishToCanvas",
|
||
],
|
||
[
|
||
"当前回合是生成新图,聚焦生成链路,不要退回修图工具。",
|
||
"如用户带参考图,仅把参考图作为风格/构图条件,不要误当纯分析。",
|
||
],
|
||
);
|
||
|
||
const videoRoute = createRoute(
|
||
{
|
||
domain: "video_generate",
|
||
specialistId: "video-specialist",
|
||
reason: "当前回合是视频生成任务",
|
||
planningHints: [],
|
||
},
|
||
[
|
||
"generateVideo",
|
||
"useSelectedCanvasImage",
|
||
"useLatestResultImage",
|
||
"useOriginalUploadImage",
|
||
],
|
||
[
|
||
"当前回合是视频任务,只规划视频生成相关动作。",
|
||
"如果信息不足可以先澄清,但不要误切回图片生成。",
|
||
],
|
||
);
|
||
|
||
const canvasOverlay = createRoute(
|
||
{
|
||
domain: "canvas",
|
||
specialistId: "canvas-specialist",
|
||
reason: "检测到画布上下文,需要优先绑定目标图并回传结果",
|
||
planningHints: [],
|
||
},
|
||
[
|
||
"useSelectedCanvasImage",
|
||
"useLatestResultImage",
|
||
"useOriginalUploadImage",
|
||
"publishToCanvas",
|
||
],
|
||
[
|
||
"如果用户在无限画布中操作,优先绑定选中图或最近结果图。",
|
||
"修图/生图结果优先同步回画布,避免只停留在聊天气泡。",
|
||
],
|
||
);
|
||
|
||
let primary = conversationRoute;
|
||
|
||
if (!greetingTurn) {
|
||
switch (context.analysis.intent) {
|
||
case "vision":
|
||
primary = visionRoute;
|
||
break;
|
||
case "image_edit":
|
||
primary = imageEditRoute;
|
||
break;
|
||
case "image_gen":
|
||
primary = imageGenerateRoute;
|
||
break;
|
||
case "video_gen":
|
||
primary = videoRoute;
|
||
break;
|
||
case "chat":
|
||
default:
|
||
primary = conversationRoute;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const overlays =
|
||
primary.domain === "conversation"
|
||
? []
|
||
: hasCanvasContext(context) || mentionsCanvas(context.text)
|
||
? [canvasOverlay]
|
||
: [];
|
||
|
||
const storyboardOverlay = buildStoryboardSpecialistRoute({
|
||
text: context.text,
|
||
threadState: context.threadState,
|
||
});
|
||
const mergedOverlays = storyboardOverlay
|
||
? [...overlays, storyboardOverlay]
|
||
: overlays;
|
||
|
||
const toolAllowlist = dedupeToolNames([
|
||
...(primary.toolAllowlist || []),
|
||
...mergedOverlays.flatMap((item) => item.toolAllowlist || []),
|
||
]);
|
||
|
||
const planningHints = [
|
||
...primary.planningHints,
|
||
...mergedOverlays.flatMap((item) => item.planningHints),
|
||
];
|
||
|
||
const reason = [primary.reason, ...mergedOverlays.map((item) => item.reason)]
|
||
.filter(Boolean)
|
||
.join(";");
|
||
|
||
return {
|
||
primary,
|
||
overlays: mergedOverlays,
|
||
toolAllowlist,
|
||
planningHints,
|
||
reason,
|
||
};
|
||
}
|
||
|
||
export function filterToolsForSkillRoute(
|
||
availableTools: AgentProjectToolDefinition[],
|
||
route: AgentSkillRouteResult,
|
||
) {
|
||
if (!route.toolAllowlist.length) return availableTools;
|
||
const allowSet = new Set(route.toolAllowlist);
|
||
return availableTools.filter((tool) => allowSet.has(tool.name));
|
||
}
|