Compare commits

...

2 Commits

Author SHA1 Message Date
Your Name
dd4426ded7 fix: improve agent image intent handling
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
2026-05-11 20:31:17 +08:00
Your Name
3ebe7db37d feat: add ai model health recovery pool 2026-05-11 20:27:21 +08:00
16 changed files with 687 additions and 77 deletions

View File

@ -863,6 +863,8 @@ type AiModel = {
healthLastSuccessAt?: string | null;
healthAutoDisabledAt?: string | null;
healthAutoDisabledReason?: string | null;
healthRecoverySuccessStreak?: number;
healthObservationStartedAt?: string | null;
isAgentModel?: boolean;
agentCodingModelId?: string;
isMultimodal?: boolean;
@ -15677,6 +15679,19 @@ export function AdminSettingsClient() {
supportsImageGen: model.supportsImageGen,
});
const healthStatus = model.healthStatus || "healthy";
const isObservationPool =
healthStatus === "degraded" &&
Boolean(model.healthObservationStartedAt);
const recoverySuccessStreak = Math.max(
0,
Number(model.healthRecoverySuccessStreak || 0)
);
const autoDisableCooldownUntil = model.healthAutoDisabledAt
? new Date(
new Date(model.healthAutoDisabledAt).getTime() +
2 * 60 * 60 * 1000
)
: null;
const modelHealthEvents =
modelHealthEventsByModelId.get(model.id) || [];
const latestHealthEvent = modelHealthEvents[0];
@ -15756,7 +15771,9 @@ export function AdminSettingsClient() {
)}
{healthStatus === "degraded" && (
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded">
健康观察中
{isObservationPool
? `观察池 ${recoverySuccessStreak}/3`
: "健康观察中"}
</span>
)}
</div>
@ -15784,6 +15801,38 @@ export function AdminSettingsClient() {
})}
</span>
)}
{healthStatus === "auto_disabled" &&
autoDisableCooldownUntil && (
<span>
解冻时间:
{formatBeijingTime(autoDisableCooldownUntil, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
{isObservationPool && (
<span>
观察成功:{recoverySuccessStreak}/3
</span>
)}
{isObservationPool &&
model.healthObservationStartedAt && (
<span>
观察开始:
{formatBeijingTime(
model.healthObservationStartedAt,
{
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}
)}
</span>
)}
</div>
{(model.healthLastError || latestHealthEvent?.errorMessage) && (
<div className="line-clamp-2 max-w-[760px]">

View File

@ -6463,6 +6463,7 @@ export async function POST(request: Request) {
})
.filter(Boolean) as Array<{ role: "user" | "assistant"; content: string }>,
context: effectiveAutoContextText || undefined,
preferredModelId: preferredAgentModelId,
}).catch(() =>
optimizePrompt({
originalPrompt: promptSeed,

View File

@ -284,6 +284,30 @@ function defaultToolForAction(action: AgentPlanNextAction): AgentProjectToolName
}
}
function candidateToolsForAction(action: AgentPlanNextAction): AgentProjectToolName[] {
switch (action) {
case "analyze_image":
return ["analyzeTargetImage", "analyzeImage"];
case "edit_image":
return ["editTargetImage", "editImage"];
case "generate_image":
return ["generateNewImage", "generateImage"];
case "generate_video":
return ["generateVideo"];
case "clarify":
case "respond":
default:
return ["respondDirect"];
}
}
function pickAvailableToolForAction(
action: AgentPlanNextAction,
availableNames: Set<AgentProjectToolName>,
): AgentProjectToolName | undefined {
return candidateToolsForAction(action).find((name) => availableNames.has(name));
}
function mapIntentToAction(intent: AnalysisResult["intent"]): AgentPlanNextAction {
switch (intent) {
case "vision":
@ -395,9 +419,26 @@ function sanitizePlannedToolCalls(
});
}
if (sanitized.length > 0) return sanitized;
if (sanitized.length > 0) {
const hasPrimaryActionTool = sanitized.some((tool) =>
candidateToolsForAction(fallbackAction).includes(tool.name),
);
const fallbackTool = pickAvailableToolForAction(fallbackAction, availableNames);
if (
!hasPrimaryActionTool &&
fallbackTool &&
fallbackAction !== "respond" &&
fallbackAction !== "clarify"
) {
sanitized.push({
name: fallbackTool,
reason: "补齐本轮主执行工具,避免只解析目标图后停住。",
});
}
return sanitized;
}
const fallbackTool = defaultToolForAction(fallbackAction);
const fallbackTool = pickAvailableToolForAction(fallbackAction, availableNames) || defaultToolForAction(fallbackAction);
if (
(fallbackAction === "analyze_image" || fallbackAction === "edit_image") &&
options?.targetSource &&
@ -698,6 +739,59 @@ function normalizeAspectRatio(value?: string): string | undefined {
return allowed.has(raw) ? raw : undefined;
}
function parseChineseCountToken(token?: string): number | undefined {
const normalized = String(token || "").trim().toLowerCase();
if (!normalized) return undefined;
const numeric = Number(normalized);
if (Number.isFinite(numeric) && numeric >= 1) return Math.round(numeric);
const map: Record<string, number> = {
一: 1,
二: 2,
两: 2,
三: 3,
四: 4,
五: 5,
六: 6,
七: 7,
八: 8,
九: 9,
十: 10,
};
return map[normalized];
}
function inferExplicitImageCountFromText(text: string): number | undefined {
const normalized = String(text || "").toLowerCase().replace(/\s+/g, "");
const match =
normalized.match(/([1-9]\d?|一|二|两|三|四|五|六|七|八|九|十)张/) ||
normalized.match(/([1-9]\d?|一|二|两|三|四|五|六|七|八|九|十)(?:个|组)?(?:版本|方案|变体)/);
const parsed = parseChineseCountToken(match?.[1]);
if (parsed && parsed >= 1) return Math.min(parsed, 12);
if (/多张/.test(normalized)) return 3;
return undefined;
}
function inferExplicitAspectRatioFromText(text: string): string | undefined {
const normalized = String(text || "").toLowerCase().replace(/\s+/g, "");
const explicitRatio = normalized.match(/(21:9|16:9|9:16|5:4|4:5|4:3|3:4|3:2|2:3|1:1)/)?.[1];
if (explicitRatio) return explicitRatio;
if (/竖图|竖版|竖屏|手机壁纸|9比16/.test(normalized)) return "9:16";
if (/横图|横版|横屏|宽屏|banner|16比9/.test(normalized)) return "16:9";
if (/方图|方形|头像|logo|电商主图|1比1/.test(normalized)) return "1:1";
return undefined;
}
function extractExplicitImageParamsFromText(
text: string,
): Pick<AgentRecommendedImageParams, "aspectRatio" | "imageCount"> {
const output: Pick<AgentRecommendedImageParams, "aspectRatio" | "imageCount"> = {};
const aspectRatio = inferExplicitAspectRatioFromText(text);
const imageCount = inferExplicitImageCountFromText(text);
if (aspectRatio) output.aspectRatio = aspectRatio;
if (imageCount) output.imageCount = imageCount;
return output;
}
function normalizeImageSize(value?: string): string | undefined {
if (!value) return undefined;
const normalized = value.trim().toUpperCase();
@ -964,9 +1058,8 @@ function heuristicImageParams(
if (analysis.intent === "image_edit") {
params.imageCount = 1;
} else {
const explicitCountMatch = normalized.match(/([1-9]\d?)张/);
const explicitCount = explicitCountMatch ? Number(explicitCountMatch[1]) : 0;
if (explicitCount >= 1) {
const explicitCount = inferExplicitImageCountFromText(text);
if (explicitCount && explicitCount >= 1) {
params.imageCount = Math.min(explicitCount, 12);
} else if (/多张|四张|4张|三张|3张/.test(normalized)) {
params.imageCount = /四张|4张/.test(normalized) ? 4 : 3;
@ -977,7 +1070,10 @@ function heuristicImageParams(
}
}
if (/横图|横版|宽屏|封面|banner|海报/.test(normalized)) {
const explicitAspectRatio = inferExplicitAspectRatioFromText(text);
if (explicitAspectRatio) {
params.aspectRatio = explicitAspectRatio;
} else if (/横图|横版|宽屏|封面|banner|海报/.test(normalized)) {
params.aspectRatio = "16:9";
} else if (/竖图|竖版|手机壁纸|全身|海报竖版/.test(normalized)) {
params.aspectRatio = "9:16";
@ -1313,6 +1409,59 @@ function sanitizeAgentPlan(
};
}
async function refinePlanWithSkillRoute(params: {
text: string;
analysis: AnalysisResult;
plan: AgentExecutionPlan;
availableTools: AgentProjectToolDefinition[];
skillRoute?: AgentSkillRouteResult;
hasImage: boolean;
hasCanvasSelection: boolean;
targetResolutionMode: AgentTargetResolutionMode;
mediaSource: "current_message" | "canvas_selection" | "history" | "none";
selectedImageCount: number;
threadState: AgentThreadState | null;
}): Promise<AgentExecutionPlan> {
if (!params.skillRoute) return params.plan;
const { refinePlanWithSpecialist } = await import("./specialists");
let refined = params.plan;
for (const specialist of [params.skillRoute.primary, ...params.skillRoute.overlays]) {
refined = refinePlanWithSpecialist(specialist, {
text: params.text,
analysis: params.analysis,
plan: refined,
availableTools: params.availableTools,
hasImage: params.hasImage,
hasCanvasSelection: params.hasCanvasSelection,
targetResolutionMode: params.targetResolutionMode,
mediaSource: params.mediaSource,
selectedImageCount: params.selectedImageCount,
threadState: params.threadState,
});
}
return refined;
}
function shouldUseLightweightPlanner(
state: AutoAgentStateType,
analysis: AnalysisResult,
): boolean {
if (process.env.AGENT_FORCE_LLM_PLANNER === "true") return false;
if (analysis.needsClarification) return false;
if (analysis.confidence < 0.9) return false;
if (analysis.intent === "image_gen") return true;
if (analysis.intent === "video_gen") return true;
if (
analysis.intent === "image_edit" &&
state.targetResolutionMode !== "clarification" &&
(state.hasImage || state.imageFromHistory || state.historyImageCount > 0 || state.selectedImageCount > 0)
) {
return true;
}
return false;
}
async function buildExecutionPlanWithAgent(
state: AutoAgentStateType,
analysis: AnalysisResult,
@ -1328,6 +1477,28 @@ async function buildExecutionPlanWithAgent(
availableTools,
});
if (shouldUseLightweightPlanner(state, analysis)) {
const refinedFallbackPlan = await refinePlanWithSkillRoute({
text: state.text,
analysis,
plan: fallbackPlan,
availableTools,
skillRoute,
hasImage: state.hasImage,
hasCanvasSelection: state.hasCanvasSelection || state.selectedImageCount > 0,
targetResolutionMode: state.targetResolutionMode,
mediaSource: state.mediaSource,
selectedImageCount: state.selectedImageCount,
threadState: state.threadState,
});
console.log("[Auto Agent] Lightweight planner used:", {
intent: analysis.intent,
confidence: analysis.confidence,
nextAction: refinedFallbackPlan.nextAction,
});
return refinedFallbackPlan;
}
try {
const {
getEnabledAiModelsWithProvider,
@ -1445,23 +1616,19 @@ async function buildExecutionPlanWithAgent(
const parsed = parseJsonBlock<Record<string, unknown>>(text);
let planned = sanitizeAgentPlan(parsed, fallbackPlan, availableTools);
if (skillRoute) {
const { refinePlanWithSpecialist } = await import("./specialists");
for (const specialist of [skillRoute.primary, ...skillRoute.overlays]) {
planned = refinePlanWithSpecialist(specialist, {
text: state.text,
analysis,
plan: planned,
availableTools,
hasImage: state.hasImage,
hasCanvasSelection: state.hasCanvasSelection || state.selectedImageCount > 0,
targetResolutionMode: state.targetResolutionMode,
mediaSource: state.mediaSource,
selectedImageCount: state.selectedImageCount,
threadState: state.threadState,
});
}
}
planned = await refinePlanWithSkillRoute({
text: state.text,
analysis,
plan: planned,
availableTools,
skillRoute,
hasImage: state.hasImage,
hasCanvasSelection: state.hasCanvasSelection || state.selectedImageCount > 0,
targetResolutionMode: state.targetResolutionMode,
mediaSource: state.mediaSource,
selectedImageCount: state.selectedImageCount,
threadState: state.threadState,
});
console.log(
`[Auto Agent] Planner model: ${plannerModelConfig.model.displayName} (${plannerModelConfig.provider.type})`,
);
@ -2017,6 +2184,18 @@ const shouldOptimize = (state: AutoAgentStateType) => {
return state.execution.shouldOptimizePrompt ? "optimize" : "route";
};
function shouldUseLlmPromptOptimizer(state: AutoAgentStateType): boolean {
if (!state.analysis || !state.execution?.shouldApplyImagePipeline) return false;
if (process.env.AGENT_LLM_PROMPT_OPTIMIZER_ENABLED === "false") return false;
if (
state.analysis.intent === "image_gen" &&
process.env.AGENT_IMAGE_GEN_LLM_OPTIMIZER_ENABLED !== "true"
) {
return false;
}
return true;
}
const optimizeNode = async (state: AutoAgentStateType) => {
if (!state.analysis || !state.execution) {
return {};
@ -2053,19 +2232,20 @@ const optimizeNode = async (state: AutoAgentStateType) => {
return {};
}
const { optimizePrompt, optimizePromptWithLLM } = await import("./prompt-optimizer");
const optimizedFromLlm = await withTimeoutFallback<
Awaited<ReturnType<typeof optimizePromptWithLLM>> | null
>(
optimizePromptWithLLM({
originalPrompt: state.text,
intent: state.analysis.intent,
subIntent: state.analysis.subIntent,
historyMessages: state.historyMessages,
context: state.context || undefined,
}),
AGENT_OPERATION_TIMEOUT_MS,
null,
);
const optimizedFromLlm = shouldUseLlmPromptOptimizer(state)
? await withTimeoutFallback<Awaited<ReturnType<typeof optimizePromptWithLLM>> | null>(
optimizePromptWithLLM({
originalPrompt: state.text,
intent: state.analysis.intent,
subIntent: state.analysis.subIntent,
historyMessages: state.historyMessages,
context: state.context || undefined,
preferredModelId: state.preferredModelId || undefined,
}),
AGENT_OPERATION_TIMEOUT_MS,
null,
)
: null;
const optimized =
optimizedFromLlm ||
optimizePrompt({
@ -2073,16 +2253,20 @@ const optimizeNode = async (state: AutoAgentStateType) => {
intent: state.analysis.intent,
subIntent: state.analysis.subIntent,
});
const heuristicParams = heuristicImageParams(state.text, state.analysis);
const explicitTextParams = extractExplicitImageParamsFromText(state.text);
const mergedParams = sanitizeRecommendedImageParams(
{
...heuristicParams,
...optimized.recommendedImageParams,
...explicitTextParams,
negativePrompt:
optimized.negativePrompt && optimized.negativePrompt.trim().length > 0
? optimized.negativePrompt
: optimized.recommendedImageParams?.negativePrompt,
},
state.analysis.intent,
) ?? heuristicImageParams(state.text, state.analysis);
) ?? heuristicParams;
return {
optimizedPrompt: optimized.optimizedPrompt,

View File

@ -139,6 +139,7 @@ export function resolveConversationAction(params: {
confirmedPrompt?: string;
hasImageInputForPromptCarryForward: boolean;
}) {
const normalizedText = String(params.text || "").toLowerCase().replace(/\s+/g, "");
const requestedGrid = detectRequestedStoryboardGrid(params.text);
const shouldContinuePendingGrid =
Boolean(params.pendingGridConfirmation) &&
@ -163,8 +164,16 @@ export function resolveConversationAction(params: {
} satisfies ConversationActionResolution;
}
const mentionsGenerationFollowup = mentionsImageGenerationFollowup(params.text);
const explicitFreshImageGeneration =
!/不要生图|先别生图|不需要生图|不要生成|别生成/.test(normalizedText) &&
/生图|出图|生成.*(?:图|图片|海报|头像|插画|场景|主图|产品图)|画.*(?:图|图片|海报|头像|插画|小猫|猫咪|人物|角色)|做.*(?:图|图片|海报|头像|主图|产品图)/.test(
normalizedText,
);
const hasImageEditFollowup =
params.imageWorkflowMemory?.lastIntent === "image_edit" &&
!mentionsGenerationFollowup &&
!explicitFreshImageGeneration &&
(mentionsImageEditFollowup(params.text) ||
Boolean(params.imageWorkflowMemory?.lastResultImageUrls?.length));
@ -187,7 +196,7 @@ export function resolveConversationAction(params: {
const hasImageGenerationFollowup =
params.imageWorkflowMemory?.lastIntent === "image_gen" &&
(mentionsImageGenerationFollowup(params.text) || promptCarryForwardRequested);
(mentionsGenerationFollowup || promptCarryForwardRequested);
if (hasImageGenerationFollowup) {
return {

View File

@ -62,6 +62,8 @@ export function buildImageEditSpecialistRoute(params: {
planningHints,
toolAllowlist: [
"respondDirect",
"resolveTargetImage",
"editTargetImage",
"editImage",
"removeBackground",
"enhanceImage",
@ -71,6 +73,7 @@ export function buildImageEditSpecialistRoute(params: {
"useSelectedCanvasImage",
"useLatestResultImage",
"useOriginalUploadImage",
"publishResultToCanvas",
"publishToCanvas",
],
};
@ -111,7 +114,14 @@ export function refinePlanWithImageEditSubagent(params: {
}
let toolCalls = [...params.plan.toolCalls];
if (hasTool(params.availableTools, "useLatestResultImage")) {
if (hasTool(params.availableTools, "resolveTargetImage")) {
toolCalls = upsertTool(
toolCalls,
"resolveTargetImage",
"image-edit 子代理优先绑定上一轮结果图作为连续修图目标",
{ prepend: true }
);
} else if (hasTool(params.availableTools, "useLatestResultImage")) {
toolCalls = upsertTool(
toolCalls,
"useLatestResultImage",
@ -119,15 +129,26 @@ export function refinePlanWithImageEditSubagent(params: {
{ prepend: true }
);
}
if (hasTool(params.availableTools, "editImage")) {
if (hasTool(params.availableTools, "editTargetImage")) {
toolCalls = upsertTool(
toolCalls,
"editTargetImage",
"image-edit 子代理要求优先走连续修图执行链路"
);
} else if (hasTool(params.availableTools, "editImage")) {
toolCalls = upsertTool(
toolCalls,
"editImage",
"image-edit 子代理要求优先走连续修图执行链路",
{ prepend: true }
"image-edit 子代理要求优先走连续修图执行链路"
);
}
if (hasTool(params.availableTools, "publishToCanvas")) {
if (hasTool(params.availableTools, "publishResultToCanvas")) {
toolCalls = upsertTool(
toolCalls,
"publishResultToCanvas",
"image-edit 子代理建议把修图结果同步回画布"
);
} else if (hasTool(params.availableTools, "publishToCanvas")) {
toolCalls = upsertTool(
toolCalls,
"publishToCanvas",

View File

@ -85,10 +85,13 @@ export function buildImageGenerationSpecialistRoute(params: {
planningHints,
toolAllowlist: [
"respondDirect",
"resolveTargetImage",
"generateNewImage",
"generateImage",
"useSelectedCanvasImage",
"useLatestResultImage",
"useOriginalUploadImage",
"publishResultToCanvas",
"publishToCanvas",
],
};
@ -137,10 +140,29 @@ export function refinePlanWithImageGenerationSubagent(params: {
}
let toolCalls = [...params.plan.toolCalls];
if (hasTool(params.availableTools, "generateImage")) {
toolCalls = [{ name: "generateImage", reason: "image-generation 子代理要求优先走连续生图执行链路" }];
const targetResolverTools = toolCalls.filter((tool) =>
["resolveTargetImage", "useSelectedCanvasImage", "useLatestResultImage", "useOriginalUploadImage"].includes(
tool.name,
),
);
if (hasTool(params.availableTools, "generateNewImage")) {
toolCalls = [
...targetResolverTools,
{ name: "generateNewImage", reason: "image-generation 子代理要求优先走连续生图执行链路" },
];
} else if (hasTool(params.availableTools, "generateImage")) {
toolCalls = [
...targetResolverTools,
{ name: "generateImage", reason: "image-generation 子代理要求优先走连续生图执行链路" },
];
}
if (hasTool(params.availableTools, "publishToCanvas")) {
if (hasTool(params.availableTools, "publishResultToCanvas")) {
toolCalls = upsertTool(
toolCalls,
"publishResultToCanvas",
"image-generation 子代理建议把连续生图结果同步到画布"
);
} else if (hasTool(params.availableTools, "publishToCanvas")) {
toolCalls = upsertTool(
toolCalls,
"publishToCanvas",

View File

@ -579,8 +579,9 @@ export async function optimizePromptWithLLM(params: {
subIntent?: string;
historyMessages?: HistoryMessage[];
context?: string;
preferredModelId?: string;
}): Promise<OptimizeResult> {
const { originalPrompt, intent, subIntent, historyMessages, context } = params;
const { originalPrompt, intent, subIntent, historyMessages, context, preferredModelId } = params;
console.log("[Prompt Optimizer LLM] Input:", {
originalPrompt,
@ -611,6 +612,16 @@ export async function optimizePromptWithLLM(params: {
const agentModel = await getAgentAiModelWithProvider();
const isReasoningCompatibleProvider = (providerType: string) =>
["zhipu", "alibaba", "openai", "custom", "local", "volcano-multimodal", "volcano-vision"].includes(providerType);
const normalizedPreferredModelId = String(preferredModelId || "").trim();
const preferredOptimizerModel = normalizedPreferredModelId
? allModels.find(
(m) =>
(m.model.id === normalizedPreferredModelId || m.model.modelId === normalizedPreferredModelId) &&
m.model.isEnabled &&
!m.model.isSubModel &&
isReasoningCompatibleProvider(m.provider.type)
)
: undefined;
const usableAgentModel =
agentModel && isReasoningCompatibleProvider(agentModel.provider.type)
? agentModel
@ -621,7 +632,7 @@ export async function optimizePromptWithLLM(params: {
const qwenModel = allModels.find(
(m) => m.provider.type === "alibaba" && m.model.supportsVision
);
const selectedOptimizerModel = usableAgentModel || zhipuModel || qwenModel;
const selectedOptimizerModel = preferredOptimizerModel || usableAgentModel || zhipuModel || qwenModel;
const agentCodingModelId =
selectedOptimizerModel &&
usableAgentModel &&
@ -677,6 +688,7 @@ export async function optimizePromptWithLLM(params: {
prompt: finalUserMessage,
temperature: getCompatibleTextGenerationTemperature(selectedOptimizerModel, 0.2),
maxTokens: 200,
abortSignal: AbortSignal.timeout(12_000),
} as any);
// 解析 JSON 响应

View File

@ -78,14 +78,42 @@ function isExplicitVideoGenerationRequest(text: string): boolean {
function isExplicitImageGenerationRequest(text: string): boolean {
const normalizedText = normalizeCompactText(text);
if (!normalizedText || isLikelyAdvisoryOrPlanningRequest(text)) return false;
return /重新生成|再来一张|生成新图|新建图片|创作一张|生成一张|画一张|给我画|帮我画|出一张|做一张图|做一张海报|创建一张图片|参考.*生成|按这个风格生成|以这张图为灵感生成|仿照.*生成|生成一只|生成一个|生成一幅|生成一版|生成一套|生成一组|帮我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)|给我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)/.test(
normalizedText,
if (/不要生图|先别生图|不需要生图|不要生成|别生成|只要提示词|仅给提示词|先给提示词/.test(normalizedText)) {
return false;
}
const countToken = "(?:一|两|二|三|四|五|六|七|八|九|十|[1-9]\\d?)";
const visualTarget =
"(?:图|图片|海报|头像|插画|场景|主图|产品图|商品图|电商图|壁纸|封面|logo|图标)";
const createVisualTarget = new RegExp(
`(?:生成|创作|创建|制作|设计|做|出|画|绘制)${countToken}?张?.{0,32}${visualTarget}`,
);
const drawSubject = new RegExp(
`(?:画|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`,
);
const continueVariants = new RegExp(
`(?:再来|再出|再做|再给我|补|多来|继续生成|继续出)${countToken}?张`,
);
return (
/重新生成|生成新图|新建图片|创作一张|生成一张|画一张|给我画|帮我画|出一张|做一张图|做一张海报|创建一张图片|生成一只|生成一个|生成一幅|生成一版|生成一套|生成一组|生图|出图/.test(
normalizedText,
) ||
createVisualTarget.test(normalizedText) ||
drawSubject.test(normalizedText) ||
continueVariants.test(normalizedText) ||
/参考.*生成|按这个风格生成|以这张图为灵感生成|仿照.*生成|同风格再来|同样风格再来/.test(
normalizedText,
) ||
/帮我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)|给我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)/.test(
normalizedText,
)
);
}
function isLikelyImageEditRequest(text: string): boolean {
const normalizedText = String(text || "").toLowerCase();
return /抠图|去背景|透明背景|去背|扣图|移除背景|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
return /抠图|去背景|透明背景|去背|扣图|移除背景|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
normalizedText,
);
}
@ -94,6 +122,7 @@ function inferDefaultImageEditSubIntent(text: string): string {
const lowerText = String(text || "").toLowerCase();
if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) return "remove_background";
if (/换背景|改背景|背景换成|背景改成|白底|透明底/.test(lowerText)) return "replace_background";
if (/扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|比例|画幅|竖版|横版|竖图|横图|9:16|16:9|1:1/.test(lowerText)) return "extend";
if (/风格|卡通|动漫|油画|像素风|写实|插画/.test(lowerText)) return "style_transfer";
if (/颜色|调色|亮度|对比度|饱和度|改成.*色|换成.*色/.test(lowerText)) return "color_adjust";
if (/去除|消除|擦掉|删除|移除|p掉|重绘/.test(lowerText)) return "inpaint";

View File

@ -1,6 +1,6 @@
import "server-only";
import { and, desc, eq, sql } from "drizzle-orm";
import { and, desc, eq, inArray, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
aiModel,
@ -20,6 +20,8 @@ export type AiModelHealthEventType =
export type AiModelHealthSeverity = "info" | "warning" | "error" | "critical";
const AUTO_DISABLE_FAILURE_THRESHOLD = 3;
const AUTO_DISABLE_COOLDOWN_MS = 2 * 60 * 60 * 1000;
const OBSERVATION_RECOVERY_SUCCESS_THRESHOLD = 3;
const RECENT_EVENT_LIMIT = 80;
function normalizeText(value: unknown) {
@ -162,23 +164,157 @@ function buildMetadata(value?: Record<string, unknown>) {
}
}
function isObservationPoolModel(
model:
| Pick<
AiModel,
| "healthStatus"
| "healthObservationStartedAt"
>
| null
| undefined
) {
return (
model?.healthStatus === "degraded" &&
Boolean(model.healthObservationStartedAt)
);
}
function getAutoDisableCooldownCutoff(now = new Date()) {
return new Date(now.getTime() - AUTO_DISABLE_COOLDOWN_MS);
}
export async function reviveAiModelsFromCooldown(params?: { modelId?: string }) {
const now = new Date();
const cooldownCutoff = getAutoDisableCooldownCutoff(now);
const conditions = [
eq(aiModel.healthStatus, "auto_disabled"),
eq(aiModel.isEnabled, false),
eq(aiModel.excludeFromFailover, false),
lte(aiModel.healthAutoDisabledAt, cooldownCutoff),
];
if (params?.modelId) {
conditions.push(eq(aiModel.id, normalizeText(params.modelId)));
}
const dueModels = await db
.select({
id: aiModel.id,
modelId: aiModel.modelId,
displayName: aiModel.displayName,
})
.from(aiModel)
.where(and(...conditions));
if (dueModels.length === 0) {
return [];
}
const revived = await db
.update(aiModel)
.set({
isEnabled: true,
healthStatus: "degraded",
healthConsecutiveFailures: 0,
healthRecoverySuccessStreak: 0,
healthObservationStartedAt: now,
healthUpdatedAt: now,
updatedAt: now,
})
.where(
inArray(
aiModel.id,
dueModels.map((item) => item.id)
)
)
.returning();
if (revived.length > 0) {
console.info("[AI Model Health] Revived cooled-down models into observation pool:", {
count: revived.length,
models: revived.map((item) => ({
id: item.id,
modelId: item.modelId,
displayName: item.displayName,
})),
});
}
return revived;
}
export async function recordAiModelHealthSuccess(params: {
model?: Pick<AiModel, "id" | "healthStatus"> | null;
model?:
| Pick<
AiModel,
| "id"
| "healthStatus"
| "healthRecoverySuccessStreak"
| "healthObservationStartedAt"
>
| null;
}) {
const modelId = normalizeText(params.model?.id);
if (!modelId) return;
const [existing] = await db
.select({
id: aiModel.id,
healthStatus: aiModel.healthStatus,
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
healthObservationStartedAt: aiModel.healthObservationStartedAt,
})
.from(aiModel)
.where(eq(aiModel.id, modelId))
.limit(1);
if (!existing) return;
const now = new Date();
const isObservation = isObservationPoolModel(existing);
const nextRecoverySuccessStreak = isObservation
? (existing.healthRecoverySuccessStreak || 0) + 1
: 0;
const recoveredToHealthy =
isObservation &&
nextRecoverySuccessStreak >= OBSERVATION_RECOVERY_SUCCESS_THRESHOLD;
const updateData: Partial<AiModel> = {
healthStatus:
existing.healthStatus === "auto_disabled"
? "auto_disabled"
: recoveredToHealthy
? "healthy"
: isObservation
? "degraded"
: "healthy",
healthConsecutiveFailures: 0,
healthLastEventType: "success",
healthLastError: null,
healthLastSuccessAt: now,
healthRecoverySuccessStreak: recoveredToHealthy
? 0
: isObservation
? nextRecoverySuccessStreak
: 0,
healthObservationStartedAt: recoveredToHealthy
? null
: isObservation
? existing.healthObservationStartedAt
: null,
healthUpdatedAt: now,
updatedAt: now,
};
if (recoveredToHealthy) {
updateData.healthAutoDisabledAt = null;
updateData.healthAutoDisabledReason = null;
}
await db
.update(aiModel)
.set({
healthStatus:
params.model?.healthStatus === "auto_disabled" ? "auto_disabled" : "healthy",
healthConsecutiveFailures: 0,
healthLastEventType: "success",
healthLastError: null,
healthLastSuccessAt: new Date(),
healthUpdatedAt: new Date(),
})
.set(updateData)
.where(eq(aiModel.id, modelId));
}
@ -205,10 +341,15 @@ export async function recordAiModelHealthFailure(params: {
const [existing] = await db
.select({
id: aiModel.id,
modelId: aiModel.modelId,
displayName: aiModel.displayName,
isEnabled: aiModel.isEnabled,
excludeFromFailover: aiModel.excludeFromFailover,
healthStatus: aiModel.healthStatus,
healthConsecutiveFailures: aiModel.healthConsecutiveFailures,
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
healthObservationStartedAt: aiModel.healthObservationStartedAt,
healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
})
.from(aiModel)
.where(eq(aiModel.id, modelId))
@ -216,14 +357,19 @@ export async function recordAiModelHealthFailure(params: {
if (!existing) return;
const isObservation = isObservationPoolModel(existing);
const nextConsecutiveFailures = classification.counted
? Math.max(1, (existing.healthConsecutiveFailures || 0) + 1)
? Math.max(
1,
(isObservation ? 0 : existing.healthConsecutiveFailures || 0) + 1
)
: existing.healthConsecutiveFailures || 0;
const shouldAutoDisable =
classification.counted &&
!existing.excludeFromFailover &&
existing.healthStatus !== "auto_disabled" &&
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD;
((isObservation && existing.isEnabled) ||
(existing.healthStatus !== "auto_disabled" &&
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD));
const nextStatus = shouldAutoDisable
? "auto_disabled"
: classification.counted
@ -264,13 +410,23 @@ export async function recordAiModelHealthFailure(params: {
healthLastEventType: classification.eventType,
healthLastError: errorMessage,
healthLastFailureAt: now,
healthRecoverySuccessStreak: shouldAutoDisable
? 0
: classification.counted
? 0
: existing.healthRecoverySuccessStreak || 0,
healthObservationStartedAt: shouldAutoDisable
? null
: isObservation
? existing.healthObservationStartedAt
: null,
healthUpdatedAt: now,
updatedAt: now,
};
if (shouldAutoDisable) {
updateData.healthAutoDisabledAt = now;
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
} else if (existing.healthStatus !== "auto_disabled") {
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) {
updateData.healthAutoDisabledReason = null;
}
@ -304,6 +460,8 @@ export async function restoreAiModelHealth(modelId: string) {
healthLastFailureAt: null,
healthAutoDisabledAt: null,
healthAutoDisabledReason: null,
healthRecoverySuccessStreak: 0,
healthObservationStartedAt: null,
healthUpdatedAt: new Date(),
updatedAt: new Date(),
})

View File

@ -54,9 +54,33 @@ function isLikelyVideoExecutionRequest(text: string): boolean {
function isLikelyImageExecutionRequest(text: string): boolean {
const normalized = normalizeCompactText(text);
if (!normalized) return false;
if (/不要生图|先别生图|不需要生图|不要生成|别生成|只要提示词|仅给提示词|先给提示词/.test(normalized)) {
return false;
}
return /生图|生成图片|出图|画一张|帮我画|做图|做一张图|重新生成|再来一张|执行生成|开始生成|生成出来|生成一只|生成一个|生成一张|生成一幅|生成一版|帮我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)|给我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)/.test(
normalized,
const countToken = "(?:一|两|二|三|四|五|六|七|八|九|十|[1-9]\\d?)";
const visualTarget =
"(?:图|图片|海报|头像|插画|场景|主图|产品图|商品图|电商图|壁纸|封面|logo|图标)";
const createVisualTarget = new RegExp(
`(?:生成|创作|创建|制作|设计|做|出|画|绘制)${countToken}?张?.{0,32}${visualTarget}`,
);
const drawSubject = new RegExp(
`(?:画|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`,
);
const continueVariants = new RegExp(
`(?:再来|再出|再做|再给我|补|多来|继续生成|继续出)${countToken}?张`,
);
return (
/生图|生成图片|出图|画一张|帮我画|做图|做一张图|重新生成|再来一张|执行生成|开始生成|生成出来|生成一只|生成一个|生成一张|生成一幅|生成一版|帮我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)|给我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)/.test(
normalized,
) ||
createVisualTarget.test(normalized) ||
drawSubject.test(normalized) ||
continueVariants.test(normalized) ||
/参考.*生成|按这个风格生成|以这张图为灵感生成|仿照.*生成|同风格再来|同样风格再来/.test(
normalized,
)
);
}

View File

@ -33,6 +33,7 @@ function inferDefaultImageEditSubIntent(text: string): AnalysisResult["subIntent
const lowerText = text.toLowerCase();
if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) return "remove_background";
if (/换背景|改背景|背景换成|背景改成|白底|透明底/.test(lowerText)) return "replace_background";
if (/扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|比例|画幅|竖版|横版|竖图|横图|9:16|16:9|1:1/.test(lowerText)) return "extend";
if (/风格|卡通|动漫|油画|像素风|写实|插画/.test(lowerText)) return "style_transfer";
if (/颜色|调色|亮度|对比度|饱和度|改成.*色|换成.*色/.test(lowerText)) return "color_adjust";
if (/去除|消除|擦掉|删除|移除|p掉|重绘/.test(lowerText)) return "inpaint";
@ -65,11 +66,11 @@ export function isExplicitPromptOptimizationRequest(text: string): boolean {
if (alsoRequestsExecution) return false;
const asksAnalysis =
/分析|看图|看看|识别|拆解|提炼|整理|归纳|总结|描述|写出|给我|输出|优化|改写/.test(
/分析|看图|看看|识别|拆解|提炼|整理|归纳|总结|描述|写|撰写|写出|给我|输出|优化|改写/.test(
normalizedText,
);
const asksPromptQuestion =
/提示词应该是什么|什么提示词|提示词是什么|怎么写提示词|如何写提示词|怎么描述|如何描述|怎么表达|如何表达|给我一版提示词|给我一套提示词|适合.*提示词|用于生图的提示词|生成这样的图片.*提示词/.test(
/提示词应该是什么|什么提示词|提示词是什么|怎么写提示词|如何写提示词|怎么描述|如何描述|怎么表达|如何表达|给我一版提示词|给我一套提示词|帮我写.*提示词|写一版.*提示词|适合.*提示词|用于生图.*提示词|生成这样的图片.*提示词/.test(
normalizedText,
);
const blocksExecution =
@ -95,17 +96,48 @@ function isExplicitVideoGenerationRequest(text: string): boolean {
);
}
function isExplicitImageGenerationRequest(text: string): boolean {
export function isExplicitImageGenerationRequest(text: string): boolean {
const normalizedText = normalizeCompactText(text);
if (isLikelyAdvisoryOrPlanningRequest(text)) return false;
return /重新生成|再来一张|生成新图|新建图片|创作一张|生成一张|画一张|给我画|帮我画|出一张|做一张图|做一张海报|创建一张图片|参考.*生成|按这个风格生成|以这张图为灵感生成|仿照.*生成|生成一只|生成一个|生成一幅|生成一版|生成一套|生成一组|帮我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)|给我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)/.test(
normalizedText,
if (isExplicitPromptOptimizationRequest(text)) return false;
if (/不要生图|先别生图|不需要生图|不要生成|别生成|只要提示词|仅给提示词|先给提示词/.test(normalizedText)) {
return false;
}
const countToken = "(?:一|两|二|三|四|五|六|七|八|九|十|[1-9]\\d?)";
const visualTarget =
"(?:图|图片|海报|头像|插画|场景|主图|产品图|商品图|电商图|壁纸|封面|logo|图标)";
const createVisualTarget = new RegExp(
`(?:生成|创作|创建|制作|设计|做|出|画|绘制)${countToken}?张?.{0,32}${visualTarget}`,
);
const drawSubject = new RegExp(
`(?:画|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`,
);
const continueVariants = new RegExp(
`(?:再来|再出|再做|再给我|补|多来|继续生成|继续出)${countToken}?张`,
);
const referenceGeneration =
/参考.*生成|按这个风格生成|以这张图为灵感生成|仿照.*生成|同风格再来|同样风格再来/.test(
normalizedText,
);
return (
/重新生成|生成新图|新建图片|创作一张|生成一张|画一张|给我画|帮我画|出一张|做一张图|做一张海报|创建一张图片|生成一只|生成一个|生成一幅|生成一版|生成一套|生成一组|生图|出图/.test(
normalizedText,
) ||
createVisualTarget.test(normalizedText) ||
drawSubject.test(normalizedText) ||
continueVariants.test(normalizedText) ||
referenceGeneration ||
/帮我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)|给我生成.*(?:图|图片|海报|头像|插画|场景|小猫|猫咪|人物|产品图)/.test(
normalizedText,
)
);
}
function isLikelyImageEditRequest(text: string): boolean {
const normalizedText = text.toLowerCase();
return /抠图|去背景|透明背景|去背|扣图|移除背景|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
return /抠图|去背景|透明背景|去背|扣图|移除背景|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
normalizedText,
);
}
@ -1859,6 +1891,16 @@ export async function analyzeIntent(params: {
* 🆕 现在返回低置信度,强制走LLM分析以获取自然对话回复
*/
function quickKeywordMatch(text: string, hasImage: boolean): AnalysisResult {
if (isExplicitPromptOptimizationRequest(text)) {
return {
intent: hasImage ? "vision" : "chat",
confidence: 0.95,
conversationalReply: hasImage
? "收到,我先分析这张图并整理可直接使用的优化提示词,不会直接生图。"
: "收到,我先按你的要求整理提示词,不直接执行生成。",
};
}
if (isExplicitVideoGenerationRequest(text)) {
return {
intent: "video_gen",
@ -1876,6 +1918,15 @@ function quickKeywordMatch(text: string, hasImage: boolean): AnalysisResult {
conversationalReply: hasImage
? "收到,我会结合参考图直接帮你生成。"
: "好的,我来直接帮你生成这张图。",
};
}
if (hasImage && isLikelyImageEditRequest(text) && !isLikelyAdvisoryOrPlanningRequest(text)) {
return {
intent: "image_edit",
subIntent: inferDefaultImageEditSubIntent(text),
confidence: 0.92,
conversationalReply: "收到,我会按你的目标直接处理这张图。",
};
}
@ -2306,7 +2357,7 @@ export function fallbackKeywordAnalysis(
}
// 图像拓展
if (/扩展|拓展|延伸|扩图|outpainting|拓宽|扩大/.test(lowerText)) {
if (/扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版/.test(lowerText)) {
return {
intent: "image_edit",
subIntent: "extend",

View File

@ -110,6 +110,14 @@ const runMigrate = async () => {
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "isDefaultTextEditModel" boolean NOT NULL DEFAULT false;
`);
await db.execute(sql`
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "healthRecoverySuccessStreak" integer NOT NULL DEFAULT 0;
`);
await db.execute(sql`
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "healthObservationStartedAt" timestamp;
`);
for (const item of providerBaseUrlDefaults) {
await db.execute(sql`
UPDATE "AiProvider"

View File

@ -0,0 +1,5 @@
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "healthRecoverySuccessStreak" integer NOT NULL DEFAULT 0;
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "healthObservationStartedAt" timestamp;

View File

@ -463,6 +463,13 @@
"when": 1778252400000,
"tag": "0079_add_ai_model_health",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1778461200000,
"tag": "0080_add_ai_model_health_recovery",
"breakpoints": true
}
]
}

View File

@ -21,6 +21,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import type { ArtifactKind } from "@/components/artifact";
import type { VisibilityType } from "@/components/visibility-selector";
import { reviveAiModelsFromCooldown } from "@/lib/ai/runtime/model-health";
import { ChatSDKError } from "../errors";
import type { AppUsage } from "../usage";
import { generateUUID } from "../utils";
@ -3912,6 +3913,7 @@ export async function deleteAiProvider({ id }: { id: string }) {
export async function getAllAiModels(): Promise<AiModel[]> {
try {
await reviveAiModelsFromCooldown();
// 暂时移除排序以避免 sortOrder 类型问题
return await db.select().from(aiModel);
} catch (_error) {
@ -3925,6 +3927,7 @@ export async function getAllAiModels(): Promise<AiModel[]> {
export async function getEnabledAiModels(): Promise<AiModel[]> {
try {
await reviveAiModelsFromCooldown();
return await db
.select()
.from(aiModel)
@ -3943,6 +3946,7 @@ export async function getAiModelsByProviderId({
providerId: string;
}): Promise<AiModel[]> {
try {
await reviveAiModelsFromCooldown();
return await db
.select()
.from(aiModel)
@ -3961,6 +3965,7 @@ export async function getAiModelById({
id: string;
}): Promise<AiModel | undefined> {
try {
await reviveAiModelsFromCooldown({ modelId: id });
const models = await db
.select()
.from(aiModel)
@ -3977,6 +3982,7 @@ export async function getAiModelById({
export async function getDefaultAiModel(): Promise<AiModel | undefined> {
try {
await reviveAiModelsFromCooldown();
const models = await db
.select()
.from(aiModel)
@ -4194,6 +4200,8 @@ export async function updateAiModel({
updateData.healthLastFailureAt = null;
updateData.healthAutoDisabledAt = null;
updateData.healthAutoDisabledReason = null;
updateData.healthRecoverySuccessStreak = 0;
updateData.healthObservationStartedAt = null;
updateData.healthUpdatedAt = new Date();
}
}
@ -4477,6 +4485,7 @@ export async function getEnabledAiModelsWithProvider(
includeHidden: boolean = true
) {
try {
await reviveAiModelsFromCooldown();
const conditions = [
eq(aiModel.isEnabled, true),
eq(aiProvider.isEnabled, true),
@ -4499,7 +4508,23 @@ export async function getEnabledAiModelsWithProvider(
.innerJoin(aiProvider, eq(aiModel.providerId, aiProvider.id))
.where(and(...conditions));
return models;
const healthPriority = (status?: string | null) => {
if (status === "healthy") return 0;
if (status === "degraded") return 1;
if (status === "auto_disabled") return 2;
return 3;
};
return [...models].sort((left, right) => {
const byHealth =
healthPriority(left.model.healthStatus) -
healthPriority(right.model.healthStatus);
if (byHealth !== 0) return byHealth;
if (left.model.isDefault !== right.model.isDefault) {
return Number(right.model.isDefault) - Number(left.model.isDefault);
}
return (left.model.sortOrder ?? 0) - (right.model.sortOrder ?? 0);
});
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
@ -4528,6 +4553,7 @@ export async function getAgentAiModelWithProvider() {
*/
export async function getSubModelsByParentModelId(parentModelId: string) {
try {
await reviveAiModelsFromCooldown();
const subModels = await db
.select({
model: aiModel,

View File

@ -349,6 +349,10 @@ export const aiModel = pgTable("AiModel", {
healthLastSuccessAt: timestamp("healthLastSuccessAt"),
healthAutoDisabledAt: timestamp("healthAutoDisabledAt"),
healthAutoDisabledReason: text("healthAutoDisabledReason"),
healthRecoverySuccessStreak: integer("healthRecoverySuccessStreak")
.notNull()
.default(0),
healthObservationStartedAt: timestamp("healthObservationStartedAt"),
healthUpdatedAt: timestamp("healthUpdatedAt").notNull().defaultNow(),
// 🆕 图片生成模型特性配置(用于阿里生图、Gemini等)
imageGenConfig: jsonb("imageGenConfig"), // { maxImages: 6, supportsSize: true, supportsPromptExtend: true, supportsNegativePrompt: true, enableLoadingPlaceholder: true }