From b99eb6cf7a84c9ff2f70f727d5e348e1cb5b1c37 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 21 Mar 2026 22:27:00 +0800 Subject: [PATCH] fix: enable local moonshot detail agent option --- .../settings/admin-settings-client.tsx | 191 ++++++------------ app/api/admin/settings/route.ts | 154 ++++++++++++++ app/api/canvas/detail-plan/route.ts | 79 +++++--- app/api/canvas/detail-review/route.ts | 67 ++++-- lib/ai/detail-agent-model-config.ts | 41 ++++ lib/ai/detail-agent-model-runtime.ts | 73 +++++++ 6 files changed, 427 insertions(+), 178 deletions(-) create mode 100644 lib/ai/detail-agent-model-config.ts create mode 100644 lib/ai/detail-agent-model-runtime.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 09bb0ce63..23244dc83 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -22,6 +22,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL, + DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID, + DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID, + DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY, + normalizeDetailAgentLocalMoonshotConfig, +} from "@/lib/ai/detail-agent-model-config"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Separator } from "@/components/ui/separator"; import { @@ -511,9 +518,6 @@ const PROVIDER_DEFAULT_BASE_URL: Record = { }; const LEMON_OPENAI_BASE_URL = "https://new.lemonapi.site/v1"; -const MOONSHOT_OFFICIAL_BASE_URL = "https://api.moonshot.cn/v1"; -const MOONSHOT_KIMI_K25_MODEL_ID = "kimi-k2.5"; - const LEMON_PROVIDER_PRESET = { name: "LemonAPI Gemini", type: "openai" as const, @@ -545,7 +549,10 @@ function isLemonApiBaseUrl(value?: string | null) { } function isMoonshotOfficialBaseUrl(value?: string | null) { - return String(value || "").trim().replace(/\/+$/, "") === MOONSHOT_OFFICIAL_BASE_URL; + return ( + String(value || "").trim().replace(/\/+$/, "") === + DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL + ); } export function AdminSettingsClient() { @@ -605,6 +612,11 @@ export function AdminSettingsClient() { "/api/auth-settings", fetcher, ); + const { data: moonshotDetailAgentSettingData, mutate: mutateMoonshotDetailAgentSetting } = + useSWR<{ setting?: { value?: unknown } }>( + `/api/admin/settings?key=${DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY}`, + fetcher, + ); // 🆕 生图记录数据 const imageQueryParams = new URLSearchParams({ @@ -870,48 +882,20 @@ export function AdminSettingsClient() { }, [siteSettingsData]); useEffect(() => { - const configuredModelId = siteSettings.canvasDetailAgentModelId; - const providerList = providersData?.providers || []; - const modelList = modelsData?.models || []; - - if (!configuredModelId) { - const moonshotProvider = providerList.find( - (provider) => provider.type === "openai" && isMoonshotOfficialBaseUrl(provider.baseUrl), - ); - setMoonshotDetailAgentConfig((prev) => ({ - ...prev, - providerId: moonshotProvider?.id || "", - providerName: moonshotProvider?.name || "Moonshot AI (Official)", - baseUrl: moonshotProvider?.baseUrl || MOONSHOT_OFFICIAL_BASE_URL, - modelId: prev.modelId || MOONSHOT_KIMI_K25_MODEL_ID, - })); - return; - } - - const configuredModel = modelList.find((model) => model.id === configuredModelId) || null; - const configuredProvider = configuredModel - ? providerList.find((provider) => provider.id === configuredModel.providerId) - : null; - - if (!configuredModel || !configuredProvider || !isMoonshotOfficialBaseUrl(configuredProvider.baseUrl)) { - return; - } - - setMoonshotDetailAgentConfig((prev) => ({ - ...prev, - providerId: configuredProvider.id, - modelEntryId: configuredModel.id, - providerName: configuredProvider.name || "Moonshot AI (Official)", - baseUrl: configuredProvider.baseUrl || MOONSHOT_OFFICIAL_BASE_URL, - modelId: configuredModel.modelId || "", - displayName: configuredModel.displayName || "官方 Kimi K2.5", - isEnabled: configuredModel.isEnabled !== false && configuredProvider.isEnabled !== false, - })); - }, [ - modelsData?.models, - providersData?.providers, - siteSettings.canvasDetailAgentModelId, - ]); + const normalized = normalizeDetailAgentLocalMoonshotConfig( + moonshotDetailAgentSettingData?.setting?.value, + ); + setMoonshotDetailAgentConfig({ + providerId: "", + modelEntryId: "", + providerName: normalized.providerName, + baseUrl: normalized.baseUrl, + apiKey: "", + modelId: normalized.modelId, + displayName: normalized.displayName, + isEnabled: normalized.enabled, + }); + }, [moonshotDetailAgentSettingData]); const featuredCategoryOptions = useMemo(() => { const values = (siteSettings.surpriseCategories || []) @@ -1022,9 +1006,9 @@ export function AdminSettingsClient() { providerId: "", modelEntryId: "", providerName: "Moonshot AI (Official)", - baseUrl: MOONSHOT_OFFICIAL_BASE_URL, + baseUrl: DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL, apiKey: "", - modelId: MOONSHOT_KIMI_K25_MODEL_ID, + modelId: DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID, displayName: "官方 Kimi K2.5", isEnabled: true, }); @@ -2697,9 +2681,11 @@ export function AdminSettingsClient() { const handleSaveMoonshotDetailAgentConfig = async () => { try { const trimmedBaseUrl = - moonshotDetailAgentConfig.baseUrl.trim() || MOONSHOT_OFFICIAL_BASE_URL; + moonshotDetailAgentConfig.baseUrl.trim() || + DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL; const trimmedModelId = - moonshotDetailAgentConfig.modelId.trim() || MOONSHOT_KIMI_K25_MODEL_ID; + moonshotDetailAgentConfig.modelId.trim() || + DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID; const trimmedDisplayName = moonshotDetailAgentConfig.displayName.trim() || "官方 Kimi K2.5"; const trimmedProviderName = @@ -2720,90 +2706,31 @@ export function AdminSettingsClient() { return; } - const providerPayload: Record = moonshotDetailAgentConfig.providerId - ? { - id: moonshotDetailAgentConfig.providerId, - name: trimmedProviderName, - type: "openai", - baseUrl: trimmedBaseUrl, - isEnabled: moonshotDetailAgentConfig.isEnabled, - } - : { - name: trimmedProviderName, - type: "openai", + const settingResponse = await fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY, + value: { + enabled: moonshotDetailAgentConfig.isEnabled, + providerName: trimmedProviderName, baseUrl: trimmedBaseUrl, apiKey: moonshotDetailAgentConfig.apiKey.trim(), - isEnabled: moonshotDetailAgentConfig.isEnabled, - }; - - if (moonshotDetailAgentConfig.providerId && moonshotDetailAgentConfig.apiKey.trim()) { - providerPayload.apiKey = moonshotDetailAgentConfig.apiKey.trim(); - } - - const providerResponse = await fetch("/api/admin/ai-providers", { - method: moonshotDetailAgentConfig.providerId ? "PUT" : "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(providerPayload), - }); - const providerResult = await providerResponse.json().catch(() => ({})); - if (!providerResponse.ok || !providerResult?.provider?.id) { - throw new Error(String(providerResult?.error || "保存 Moonshot 提供商失败")); - } - - const providerId = String(providerResult.provider.id); - const existingModel = - (moonshotDetailAgentConfig.modelEntryId && - modelsData?.models?.find((model) => model.id === moonshotDetailAgentConfig.modelEntryId)) || - modelsData?.models?.find( - (model) => model.providerId === providerId && model.modelId === trimmedModelId, - ) || - null; - - const modelPayload: Record = existingModel - ? { - id: existingModel.id, - providerId, modelId: trimmedModelId, displayName: trimmedDisplayName, - description: "Moonshot 官方 Kimi K2.5,专用于 AI详情生成 的规划与排版复审", - supportsVision: true, - supportsImageGen: false, - isEnabled: moonshotDetailAgentConfig.isEnabled, - isHidden: false, - isDefault: false, - isAgentModel: false, - isMultimodal: true, - } - : { - providerId, - modelId: trimmedModelId, - displayName: trimmedDisplayName, - description: "Moonshot 官方 Kimi K2.5,专用于 AI详情生成 的规划与排版复审", - supportsVision: true, - supportsImageGen: false, - isEnabled: moonshotDetailAgentConfig.isEnabled, - isHidden: false, - isDefault: false, - isAgentModel: false, - isMultimodal: true, - }; - - const modelResponse = await fetch("/api/admin/ai-models", { - method: existingModel ? "PUT" : "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(modelPayload), + }, + }), }); - const modelResult = await modelResponse.json().catch(() => ({})); - const savedModel = modelResult?.model; - if (!modelResponse.ok || !savedModel?.id) { - throw new Error(String(modelResult?.error || "保存 Kimi K2.5 模型失败")); + const settingResult = await settingResponse.json().catch(() => ({})); + if (!settingResponse.ok) { + throw new Error(String(settingResult?.message || "保存 Moonshot 本地AI配置失败")); } const settingsResponse = await fetch("/api/site-settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - canvasDetailAgentModelId: savedModel.id, + canvasDetailAgentModelId: DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID, }), }); if (!settingsResponse.ok) { @@ -2812,12 +2739,10 @@ export function AdminSettingsClient() { setSiteSettings((prev) => ({ ...prev, - canvasDetailAgentModelId: savedModel.id, + canvasDetailAgentModelId: DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID, })); setMoonshotDetailAgentConfig((prev) => ({ ...prev, - providerId, - modelEntryId: String(savedModel.id), providerName: trimmedProviderName, baseUrl: trimmedBaseUrl, modelId: trimmedModelId, @@ -2825,8 +2750,7 @@ export function AdminSettingsClient() { apiKey: "", })); - mutateProviders(); - mutateModels(); + mutateMoonshotDetailAgentSetting(); mutateSiteSettings(); toast.success("Moonshot 官方 Kimi K2.5 已绑定为 AI详情 Agent 专用模型"); } catch (error) { @@ -5756,6 +5680,11 @@ export function AdminSettingsClient() { 沿用Agent对话默认模型 + {moonshotDetailAgentConfig.isEnabled ? ( + + {moonshotDetailAgentConfig.displayName} · 本地AI配置 + + ) : null} {detailAgentModelOptions.map((model) => ( {model.displayName} @@ -8310,10 +8239,10 @@ export function AdminSettingsClient() { baseUrl: e.target.value, })) } - placeholder={MOONSHOT_OFFICIAL_BASE_URL} + placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL} />
- 根据 Moonshot 官方文档,OpenAI 兼容接口地址为 `{MOONSHOT_OFFICIAL_BASE_URL}` + 根据 Moonshot 官方文档,OpenAI 兼容接口地址为 `{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
@@ -8350,7 +8279,7 @@ export function AdminSettingsClient() { modelId: e.target.value, })) } - placeholder={MOONSHOT_KIMI_K25_MODEL_ID} + placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID} />
已按你提供的 Moonshot 官方文档默认填写 `kimi-k2.5`;保存后系统会自动创建/更新对应模型记录。 diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index 18f87b7a2..b857f29de 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -1,9 +1,16 @@ import { z } from "zod"; import { requireAdmin } from "@/lib/middleware/admin"; import { + createAiModel, + createAiProvider, + getAiModelById, + getAiProviderById, + getAllAiModels, getAllSystemSettings, getSystemSetting, upsertSystemSetting, + updateAiModel, + updateAiProvider, } from "@/lib/db/queries"; import { ChatSDKError } from "@/lib/errors"; import { probeBflModelSupport } from "@/lib/realtime/bfl-capability"; @@ -12,12 +19,33 @@ import { normalizeRealtimeCanvasConfigForStorage, resolveRealtimeProviders, } from "@/lib/realtime/config"; +import { db } from "@/lib/db"; +import { siteSettings } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; const settingSchema = z.object({ key: z.string(), value: z.any(), }); +const moonshotDetailAgentConfigSchema = z.object({ + providerId: z.string().uuid().optional(), + modelEntryId: z.string().uuid().optional(), + providerName: z.string().trim().min(1).max(128).optional(), + baseUrl: z.string().trim().min(1), + apiKey: z.string().optional(), + modelId: z.string().trim().min(1).max(128).optional(), + displayName: z.string().trim().min(1).max(128).optional(), + isEnabled: z.boolean().optional(), +}); + +const MOONSHOT_OFFICIAL_BASE_URL = "https://api.moonshot.cn/v1"; +const MOONSHOT_KIMI_K25_MODEL_ID = "kimi-k2.5"; + +function isMoonshotOfficialBaseUrl(value: string) { + return String(value || "").trim().replace(/\/+$/, "") === MOONSHOT_OFFICIAL_BASE_URL; +} + function isObjectLike(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -58,6 +86,132 @@ export async function POST(request: Request) { let nextValue = value; + if (key === "moonshot_detail_agent_config") { + const config = moonshotDetailAgentConfigSchema.parse(value || {}); + const normalizedBaseUrl = String(config.baseUrl || "").trim() || MOONSHOT_OFFICIAL_BASE_URL; + const normalizedModelId = String(config.modelId || "").trim() || MOONSHOT_KIMI_K25_MODEL_ID; + const normalizedDisplayName = String(config.displayName || "").trim() || "官方 Kimi K2.5"; + const normalizedProviderName = + String(config.providerName || "").trim() || "Moonshot AI (Official)"; + const normalizedApiKey = String(config.apiKey || "").trim(); + const enabled = config.isEnabled !== false; + + if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) { + return new ChatSDKError( + "bad_request:api", + `Moonshot 官方地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`, + ).toResponse(); + } + + if (!config.providerId && !normalizedApiKey) { + return new ChatSDKError("bad_request:api", "缺少 Moonshot 官方 API Key").toResponse(); + } + + const [existingSiteSettings] = await db.select().from(siteSettings).limit(1); + + const providerRows = config.providerId + ? await updateAiProvider({ + id: config.providerId, + name: normalizedProviderName, + type: "openai", + baseUrl: normalizedBaseUrl, + apiKey: normalizedApiKey || undefined, + isEnabled: enabled, + updatedBy: session.user.id, + }) + : await createAiProvider({ + name: normalizedProviderName, + type: "openai", + baseUrl: normalizedBaseUrl, + apiKey: normalizedApiKey, + isEnabled: enabled, + updatedBy: session.user.id, + }); + + const provider = providerRows[0]; + if (!provider) { + return new ChatSDKError("bad_request:database", "保存 Moonshot 提供商失败").toResponse(); + } + + let existingModel = + config.modelEntryId ? await getAiModelById({ id: config.modelEntryId }) : undefined; + + if (!existingModel) { + const allModels = await getAllAiModels(); + existingModel = + allModels.find( + (model) => model.providerId === provider.id && model.modelId === normalizedModelId, + ) || undefined; + } + + const modelRows = existingModel + ? await updateAiModel({ + id: existingModel.id, + providerId: provider.id, + modelId: normalizedModelId, + displayName: normalizedDisplayName, + description: "Moonshot 官方 Kimi K2.5,专用于 AI详情生成 的规划与排版复审", + supportsVision: true, + supportsImageGen: false, + isEnabled: enabled, + isHidden: false, + isDefault: false, + isAgentModel: false, + isMultimodal: true, + }) + : await createAiModel({ + providerId: provider.id, + modelId: normalizedModelId, + displayName: normalizedDisplayName, + description: "Moonshot 官方 Kimi K2.5,专用于 AI详情生成 的规划与排版复审", + supportsVision: true, + supportsImageGen: false, + isEnabled: enabled, + isHidden: false, + isDefault: false, + isAgentModel: false, + isMultimodal: true, + }); + + const model = modelRows[0]; + if (!model) { + return new ChatSDKError("bad_request:database", "保存 Kimi K2.5 模型失败").toResponse(); + } + + if (existingSiteSettings) { + await db + .update(siteSettings) + .set({ + canvasDetailAgentModelId: model.id, + updatedAt: new Date(), + updatedBy: session.user.id, + }) + .where(eq(siteSettings.id, existingSiteSettings.id)); + } else { + await db.insert(siteSettings).values({ + siteName: "AI Chatbot", + canvasDetailAgentModelId: model.id, + updatedBy: session.user.id, + }); + } + + return Response.json({ + setting: { + key: "moonshot_detail_agent_config", + value: { + providerId: provider.id, + modelEntryId: model.id, + providerName: provider.name, + baseUrl: provider.baseUrl, + modelId: model.modelId, + displayName: model.displayName, + isEnabled: model.isEnabled, + canvasDetailAgentModelId: model.id, + }, + }, + }); + } + if (key === "realtime_canvas_config") { if (!isObjectLike(value)) { return new ChatSDKError("bad_request:api", "realtime_canvas_config value 必须为对象").toResponse(); diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index 521dcf51d..b0fd21733 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -11,6 +11,7 @@ import { isAiModelMultimodal, } from "@/lib/db/ai-model-flags"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; +import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime"; import { inlineVisionImages } from "@/lib/ai/vision-inline"; import { filterReachableRemoteUrls } from "@/lib/net/url-access"; import { @@ -1806,6 +1807,9 @@ export async function POST(request: Request) { getAgentAiModelWithProvider(), getSiteSettings(), ]); + const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({ + siteSettingModelId: currentSiteSettings?.canvasDetailAgentModelId, + }); const reasoningCompatible = allModels.filter( (item) => !item.model.isSubModel && @@ -1829,45 +1833,65 @@ export async function POST(request: Request) { const preferredAgentConfig = configuredDetailAgentConfig || fallbackAgentConfig; let plannerConfig = preferredAgentConfig; - if (referenceImages.length > 0) { + if (!localDetailAgentResolved && referenceImages.length > 0) { if (!plannerConfig || !supportsVisionPlanning(plannerConfig.model)) { plannerConfig = reasoningCompatible.find((item) => supportsVisionPlanning(item.model)); } } - if (!plannerConfig) { + if (!localDetailAgentResolved && !plannerConfig) { plannerConfig = reasoningCompatible[0]; } - if (!plannerConfig) { + if (!localDetailAgentResolved && !plannerConfig) { return NextResponse.json({ success: true, data: fallback }); } + const selectedModelRef = localDetailAgentResolved + ? localDetailAgentResolved.modelRef + : plannerConfig!.model; + const selectedProviderRef = localDetailAgentResolved + ? localDetailAgentResolved.providerRef + : { + id: plannerConfig!.provider.id, + name: plannerConfig!.provider.name, + type: plannerConfig!.provider.type, + baseUrl: plannerConfig!.provider.baseUrl, + }; + console.log("[Canvas Detail Plan] Planner model selected:", { - id: plannerConfig.model.id, - displayName: plannerConfig.model.displayName, - modelId: plannerConfig.model.modelId, - codingModelId: getAiModelAgentCodingModelId(plannerConfig.model), - providerType: plannerConfig.provider.type, - supportsVision: plannerConfig.model.supportsVision, + id: selectedModelRef.id, + displayName: selectedModelRef.displayName, + modelId: selectedModelRef.modelId, + codingModelId: localDetailAgentResolved + ? null + : getAiModelAgentCodingModelId(plannerConfig!.model), + providerType: selectedProviderRef.type, + supportsVision: selectedModelRef.supportsVision, detailAgentSettingModelId: currentSiteSettings?.canvasDetailAgentModelId || null, selectionSource: - configuredDetailAgentConfig?.model.id === plannerConfig.model.id + localDetailAgentResolved + ? "detail-agent-local-config" + : configuredDetailAgentConfig?.model.id === plannerConfig?.model.id ? "detail-agent-setting" - : fallbackAgentConfig?.model.id === plannerConfig.model.id + : fallbackAgentConfig?.model.id === plannerConfig?.model.id ? "agent-default" : "vision-fallback", multimodalFlag: isAiModelMultimodal({ - capabilities: plannerConfig.model.capabilities, - defaultParams: plannerConfig.model.defaultParams, + capabilities: selectedModelRef.capabilities, + defaultParams: selectedModelRef.defaultParams, }), hasReferenceImages: referenceImages.length > 0, }); - const codingModelId = getAiModelAgentCodingModelId(plannerConfig.model); - const model = await getLanguageModelById( - plannerConfig.model.id, - codingModelId - ); + const codingModelId = localDetailAgentResolved + ? null + : getAiModelAgentCodingModelId(plannerConfig!.model); + const model = localDetailAgentResolved + ? localDetailAgentResolved.model + : await getLanguageModelById( + plannerConfig!.model.id, + codingModelId + ); if (!model) { return NextResponse.json({ success: true, data: fallback }); } @@ -1898,9 +1922,12 @@ export async function POST(request: Request) { .join("\n"); let imageInputs: Array<{ type: "image"; image: string }> = []; - if (supportsVisionPlanning(plannerConfig.model) && referenceImages.length > 0) { + if ( + (localDetailAgentResolved || supportsVisionPlanning(plannerConfig!.model)) && + referenceImages.length > 0 + ) { try { - if (isVolcanoResponsesPlanner(plannerConfig)) { + if (!localDetailAgentResolved && isVolcanoResponsesPlanner(plannerConfig!)) { imageInputs = referenceImages .slice(0, 6) .map((url) => ({ type: "image" as const, image: url })); @@ -2042,9 +2069,9 @@ export async function POST(request: Request) { issues: [fallbackReason], }, model: { - id: plannerConfig.model.id, - displayName: plannerConfig.model.displayName, - providerType: plannerConfig.provider.type, + id: selectedModelRef.id, + displayName: selectedModelRef.displayName, + providerType: selectedProviderRef.type, }, preflight: { passed: false, @@ -2224,9 +2251,9 @@ export async function POST(request: Request) { }) : undefined, model: { - id: plannerConfig.model.id, - displayName: plannerConfig.model.displayName, - providerType: plannerConfig.provider.type, + id: selectedModelRef.id, + displayName: selectedModelRef.displayName, + providerType: selectedProviderRef.type, }, }; diff --git a/app/api/canvas/detail-review/route.ts b/app/api/canvas/detail-review/route.ts index a4bd2f502..05135da3a 100644 --- a/app/api/canvas/detail-review/route.ts +++ b/app/api/canvas/detail-review/route.ts @@ -11,6 +11,7 @@ import { isAiModelMultimodal, } from "@/lib/db/ai-model-flags"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; +import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime"; import { inlineVisionImages } from "@/lib/ai/vision-inline"; import { buildDetailUiProtocol, @@ -195,6 +196,9 @@ export async function POST(request: Request) { getAgentAiModelWithProvider(), getSiteSettings(), ]); + const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({ + siteSettingModelId: currentSiteSettings?.canvasDetailAgentModelId, + }); const reviewCompatible = allModels.filter( (item) => !item.model.isSubModel && @@ -217,7 +221,7 @@ export async function POST(request: Request) { ? reviewCompatible.find((item) => item.model.id === agentModel.model.id) : undefined; let reviewerConfig = configuredDetailAgentConfig || fallbackAgentConfig; - if (!reviewerConfig || !supportsVisionReview(reviewerConfig.model)) { + if (!localDetailAgentResolved && (!reviewerConfig || !supportsVisionReview(reviewerConfig.model))) { reviewerConfig = reviewCompatible.find((item) => supportsVisionReview(item.model)); } @@ -253,7 +257,7 @@ export async function POST(request: Request) { }); const currentUiValidation = validateDetailUiProtocol(currentUiProtocol); - if (!reviewerConfig) { + if (!localDetailAgentResolved && !reviewerConfig) { return NextResponse.json({ success: true, data: { @@ -272,23 +276,40 @@ export async function POST(request: Request) { }); } - const codingModelId = getAiModelAgentCodingModelId(reviewerConfig.model); - const model = await getLanguageModelById( - reviewerConfig.model.id, - codingModelId - ); + const selectedModelRef = localDetailAgentResolved + ? localDetailAgentResolved.modelRef + : reviewerConfig!.model; + const selectedProviderRef = localDetailAgentResolved + ? localDetailAgentResolved.providerRef + : { + id: reviewerConfig!.provider.id, + name: reviewerConfig!.provider.name, + type: reviewerConfig!.provider.type, + baseUrl: reviewerConfig!.provider.baseUrl, + }; + const codingModelId = localDetailAgentResolved + ? null + : getAiModelAgentCodingModelId(reviewerConfig!.model); + const model = localDetailAgentResolved + ? localDetailAgentResolved.model + : await getLanguageModelById( + reviewerConfig!.model.id, + codingModelId + ); console.log("[Canvas Detail Review] Reviewer model selected:", { - id: reviewerConfig.model.id, - displayName: reviewerConfig.model.displayName, - modelId: reviewerConfig.model.modelId, + id: selectedModelRef.id, + displayName: selectedModelRef.displayName, + modelId: selectedModelRef.modelId, codingModelId, - providerType: reviewerConfig.provider.type, - supportsVision: reviewerConfig.model.supportsVision, + providerType: selectedProviderRef.type, + supportsVision: selectedModelRef.supportsVision, detailAgentSettingModelId: currentSiteSettings?.canvasDetailAgentModelId || null, selectionSource: - configuredDetailAgentConfig?.model.id === reviewerConfig.model.id + localDetailAgentResolved + ? "detail-agent-local-config" + : configuredDetailAgentConfig?.model.id === reviewerConfig?.model.id ? "detail-agent-setting" - : fallbackAgentConfig?.model.id === reviewerConfig.model.id + : fallbackAgentConfig?.model.id === reviewerConfig?.model.id ? "agent-default" : "vision-fallback", }); @@ -355,7 +376,11 @@ export async function POST(request: Request) { const reviewImageCandidates = [imageUrl, ...referenceImages].slice(0, 6); let effectiveReviewImages = reviewImageCandidates; - if (!isVolcanoResponsesReviewer(reviewerConfig)) { + if ( + !localDetailAgentResolved && + reviewerConfig && + !isVolcanoResponsesReviewer(reviewerConfig) + ) { const { inlineDataUrls } = await inlineVisionImages(reviewImageCandidates, { maxImages: 6, maxSide: 900, @@ -409,9 +434,9 @@ export async function POST(request: Request) { uiPatch: undefined, uiSurfacePatch: undefined, model: { - id: reviewerConfig.model.id, - displayName: reviewerConfig.model.displayName, - providerType: reviewerConfig.provider.type, + id: selectedModelRef.id, + displayName: selectedModelRef.displayName, + providerType: selectedProviderRef.type, }, }, }); @@ -565,9 +590,9 @@ export async function POST(request: Request) { issues: uiValidation.issues.map((item) => item.message), }, model: { - id: reviewerConfig.model.id, - displayName: reviewerConfig.model.displayName, - providerType: reviewerConfig.provider.type, + id: selectedModelRef.id, + displayName: selectedModelRef.displayName, + providerType: selectedProviderRef.type, }, }, }); diff --git a/lib/ai/detail-agent-model-config.ts b/lib/ai/detail-agent-model-config.ts new file mode 100644 index 000000000..b71b62c6a --- /dev/null +++ b/lib/ai/detail-agent-model-config.ts @@ -0,0 +1,41 @@ +export const DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY = + "canvas_detail_agent_moonshot_config"; +export const DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID = + "__moonshot_kimi_k25_local__"; +export const DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL = + "https://api.moonshot.cn/v1"; +export const DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID = "kimi-k2.5"; + +export type DetailAgentLocalMoonshotConfig = { + enabled: boolean; + providerName: string; + baseUrl: string; + apiKey: string; + modelId: string; + displayName: string; +}; + +export function isDetailAgentLocalMoonshotOption(value: unknown) { + return String(value || "").trim() === DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID; +} + +export function normalizeDetailAgentLocalMoonshotConfig( + value: unknown, +): DetailAgentLocalMoonshotConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + + return { + enabled: raw.enabled !== false, + providerName: + String(raw.providerName || "").trim() || "Moonshot AI (Official)", + baseUrl: + String(raw.baseUrl || "").trim() || DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL, + apiKey: String(raw.apiKey || "").trim(), + modelId: + String(raw.modelId || "").trim() || DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID, + displayName: String(raw.displayName || "").trim() || "官方 Kimi K2.5", + }; +} diff --git a/lib/ai/detail-agent-model-runtime.ts b/lib/ai/detail-agent-model-runtime.ts new file mode 100644 index 000000000..e2627b34e --- /dev/null +++ b/lib/ai/detail-agent-model-runtime.ts @@ -0,0 +1,73 @@ +import "server-only"; + +import { createOpenAI } from "@ai-sdk/openai"; +import type { LanguageModel } from "ai"; +import { getSystemSetting } from "@/lib/db/queries"; +import { + DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL, + DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID, + DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY, + isDetailAgentLocalMoonshotOption, + normalizeDetailAgentLocalMoonshotConfig, +} from "@/lib/ai/detail-agent-model-config"; + +export async function resolveDetailAgentLocalMoonshotModel(params: { + siteSettingModelId?: string | null; +}) { + if (!isDetailAgentLocalMoonshotOption(params.siteSettingModelId)) { + return null; + } + + const setting = await getSystemSetting({ + key: DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY, + }).catch(() => null); + const config = normalizeDetailAgentLocalMoonshotConfig(setting?.value); + + if (!config.enabled || !config.apiKey.trim()) { + return null; + } + + const provider = createOpenAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl || DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL, + }); + const model = provider.chat(config.modelId); + + return { + kind: "local_moonshot" as const, + model, + modelRef: { + id: DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID, + displayName: config.displayName, + modelId: config.modelId, + supportsVision: true, + capabilities: { multimodalModel: true }, + defaultParams: null, + }, + providerRef: { + id: DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID, + name: config.providerName, + type: "openai", + baseUrl: config.baseUrl, + }, + }; +} + +export type DetailAgentResolvedModel = { + kind: "local_moonshot" | "db_model"; + model: LanguageModel; + modelRef: { + id: string; + displayName: string; + modelId: string; + supportsVision?: boolean | null; + capabilities?: unknown; + defaultParams?: unknown; + }; + providerRef: { + id: string; + name: string; + type: string; + baseUrl?: string | null; + }; +};