diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 633cd0c26..29a3db670 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -39,6 +39,14 @@ import { PRODUCT_PLANNING_API_CONFIG_KEY, type ProductPlanningApiConfig, } from "@/lib/product-planning/config"; +import { + defaultProductPlanningAiModelConfig, + normalizeProductPlanningAiModelConfig, + PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY, + PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL, + PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID, + type ProductPlanningAiModelConfig, +} from "@/lib/product-planning/model-config"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Separator } from "@/components/ui/separator"; import { HomeAnimationEmbed } from "@/components/home/home-animation-embed"; @@ -641,6 +649,13 @@ export function AdminSettingsClient() { `/api/admin/settings?key=${DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY}`, fetcher, ); + const { + data: productPlanningAiModelConfigData, + mutate: mutateProductPlanningAiModelConfig, + } = useSWR<{ setting?: { value?: unknown } }>( + `/api/admin/settings?key=${PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY}`, + fetcher, + ); const { data: homeAnimationSettingData, mutate: mutateHomeAnimationSetting } = useSWR<{ setting?: { value?: unknown } }>( `/api/admin/settings?key=${HOME_CHAT_ANIMATION_CONFIG_KEY}`, @@ -1091,6 +1106,8 @@ export function AdminSettingsClient() { const [isTestingThirdPartySqlServer, setIsTestingThirdPartySqlServer] = useState(false); const [productPlanningApiConfig, setProductPlanningApiConfig] = useState(defaultProductPlanningApiConfig()); + const [productPlanningAiModelConfig, setProductPlanningAiModelConfig] = + useState(defaultProductPlanningAiModelConfig()); // 加载抠图API配置 const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher); @@ -1195,6 +1212,12 @@ export function AdminSettingsClient() { ); }, [productPlanningApiConfigData]); + useEffect(() => { + setProductPlanningAiModelConfig( + normalizeProductPlanningAiModelConfig(productPlanningAiModelConfigData?.setting?.value) + ); + }, [productPlanningAiModelConfigData]); + // 加载角度编辑API配置 const { data: angleEditConfigData } = useSWR("/api/admin/settings?key=angle_edit_config", fetcher); useEffect(() => { @@ -2519,19 +2542,80 @@ export function AdminSettingsClient() { const handleSaveProductPlanningApiConfig = async () => { try { + const [configResponse, featureResponse] = await Promise.all([ + fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: PRODUCT_PLANNING_API_CONFIG_KEY, + value: { + oneboundBaseUrl: productPlanningApiConfig.oneboundBaseUrl.trim(), + taobaoApiKey: productPlanningApiConfig.taobaoApiKey.trim(), + taobaoApiSecret: productPlanningApiConfig.taobaoApiSecret.trim(), + qaApiUrl: productPlanningApiConfig.qaApiUrl.trim(), + qaToken: productPlanningApiConfig.qaToken.trim(), + saleApiUrl: productPlanningApiConfig.saleApiUrl.trim(), + saleToken: productPlanningApiConfig.saleToken.trim(), + }, + }), + }), + fetch("/api/site-settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + productPlanningEnabled: siteSettings.productPlanningEnabled, + }), + }), + ]); + + const configPayload = await configResponse.json().catch(() => null); + if (!configResponse.ok) { + throw new Error( + configPayload?.cause || configPayload?.message || configPayload?.error || "保存失败" + ); + } + + const featurePayload = await featureResponse.json().catch(() => null); + if (!featureResponse.ok) { + throw new Error( + featurePayload?.cause || featurePayload?.message || featurePayload?.error || "保存失败" + ); + } + + toast.success("商品策划工具设置保存成功"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存商品策划工具设置失败"); + } + }; + + const handleSaveProductPlanningAiModelConfig = async () => { + try { + const trimmedBaseUrl = + productPlanningAiModelConfig.baseUrl.trim() || PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL; + const trimmedModelId = + productPlanningAiModelConfig.modelId.trim() || PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID; + const trimmedDisplayName = + productPlanningAiModelConfig.displayName.trim() || "商品策划专用 Qwen3-VL"; + const trimmedProviderName = + productPlanningAiModelConfig.providerName.trim() || "Product Planning Qwen3-VL"; + + if (productPlanningAiModelConfig.enabled && !productPlanningAiModelConfig.apiKey.trim()) { + toast.error("启用商品策划专用模型时,请填写 API Key"); + return; + } + const response = await fetch("/api/admin/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - key: PRODUCT_PLANNING_API_CONFIG_KEY, + key: PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY, value: { - oneboundBaseUrl: productPlanningApiConfig.oneboundBaseUrl.trim(), - taobaoApiKey: productPlanningApiConfig.taobaoApiKey.trim(), - taobaoApiSecret: productPlanningApiConfig.taobaoApiSecret.trim(), - qaApiUrl: productPlanningApiConfig.qaApiUrl.trim(), - qaToken: productPlanningApiConfig.qaToken.trim(), - saleApiUrl: productPlanningApiConfig.saleApiUrl.trim(), - saleToken: productPlanningApiConfig.saleToken.trim(), + enabled: productPlanningAiModelConfig.enabled, + providerName: trimmedProviderName, + baseUrl: trimmedBaseUrl, + apiKey: productPlanningAiModelConfig.apiKey.trim(), + modelId: trimmedModelId, + displayName: trimmedDisplayName, }, }), }); @@ -2541,9 +2625,17 @@ export function AdminSettingsClient() { throw new Error(payload?.cause || payload?.message || payload?.error || "保存失败"); } - toast.success("商品策划工具 API 配置保存成功"); + setProductPlanningAiModelConfig((prev) => ({ + ...prev, + providerName: trimmedProviderName, + baseUrl: trimmedBaseUrl, + modelId: trimmedModelId, + displayName: trimmedDisplayName, + })); + mutateProductPlanningAiModelConfig(); + toast.success("商品策划专用 AI 模型配置保存成功"); } catch (error) { - toast.error(error instanceof Error ? error.message : "保存商品策划工具配置失败"); + toast.error(error instanceof Error ? error.message : "保存商品策划专用 AI 模型配置失败"); } }; @@ -6367,21 +6459,6 @@ export function AdminSettingsClient() { /> -
-
- -
- 控制是否在左侧导航显示商品策划入口;关闭后商品策划页面和相关接口也会一并禁用 -
-
- - setSiteSettings({ ...siteSettings, productPlanningEnabled: checked }) - } - /> -
- {/* 功能教程设置 */}
@@ -8738,6 +8815,21 @@ export function AdminSettingsClient() { +
+
+ +
+ 关闭后左侧导航入口会隐藏,商品策划页面和相关接口也会一并禁用。 +
+
+ + setSiteSettings({ ...siteSettings, productPlanningEnabled: checked }) + } + /> +
+
@@ -8859,6 +8951,10 @@ export function AdminSettingsClient() {
+

+ • 当前功能状态: + {siteSettings.productPlanningEnabled ? "已启用,左侧导航和接口开放中。" : "已关闭,入口、页面和接口都会被禁用。"} +

• 商品策划工具的评价、关键词洞察、淘宝/京东详情、问大家和销量接口都会优先使用这里的配置。

• 若某个字段留空,系统会继续尝试读取旧环境变量,避免你切换配置期间功能直接中断。

@@ -8866,7 +8962,131 @@ export function AdminSettingsClient() {
+
+ + + + + + 商品策划专用 AI 模型 + + 为商品策划工具单独绑定一套分析模型。开启后,评价分析、问大家分析、竞品图片分析和 SKU 卖点分析会优先走这里配置的 OpenAI 兼容模型,不再依赖站点默认模型。 + + + +
+
+ +
+ 推荐在商品策划需要固定分析口径时开启。关闭后会自动回退到站点当前可用的默认模型。 +
+
+ + setProductPlanningAiModelConfig((prev) => ({ + ...prev, + enabled: checked, + })) + } + /> +
+ +
+
+ + + setProductPlanningAiModelConfig((prev) => ({ + ...prev, + providerName: e.target.value, + })) + } + placeholder="Product Planning Qwen3-VL" + /> +
+ +
+ + + setProductPlanningAiModelConfig((prev) => ({ + ...prev, + displayName: e.target.value, + })) + } + placeholder="商品策划专用 Qwen3-VL" + /> +
+ +
+ + + setProductPlanningAiModelConfig((prev) => ({ + ...prev, + baseUrl: e.target.value, + })) + } + placeholder={PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL} + /> +
+ 支持填写服务根地址,例如 `http://103.118.245.6:6081`。系统会自动兼容补全 `/v1` 路径。 +
+
+ +
+ + + setProductPlanningAiModelConfig((prev) => ({ + ...prev, + modelId: e.target.value, + })) + } + placeholder={PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID} + /> +
+ 你的 Qwen3-VL OpenAI 兼容接口若使用默认模型名,可直接保留 `Qwen3-VL-4B-Instruct`。 +
+
+ +
+ + + setProductPlanningAiModelConfig((prev) => ({ + ...prev, + apiKey: e.target.value, + })) + } + placeholder="请输入商品策划专用模型 API Key" + /> +
+
+ +
+

• 开启后,商品策划页的文本分析和图片分析都会优先使用这里配置的独立模型。

+

• 关闭后,系统会继续回退到站点默认文本/视觉模型,不会影响其他页面的模型设置。

+
+ +
+
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index 90db2241b..4c67b7abc 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -17,6 +17,10 @@ import { normalizeProductPlanningApiConfig, PRODUCT_PLANNING_API_CONFIG_KEY, } from "@/lib/product-planning/config"; +import { + normalizeProductPlanningAiModelConfig, + PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY, +} from "@/lib/product-planning/model-config"; import { normalizeThirdPartySqlServerConfig, THIRD_PARTY_SQLSERVER_CONFIG_KEY, @@ -209,6 +213,10 @@ export async function POST(request: Request) { nextValue = normalizeProductPlanningApiConfig(value); } + if (key === PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY) { + nextValue = normalizeProductPlanningAiModelConfig(value); + } + const setting = await upsertSystemSetting({ key, value: nextValue, diff --git a/app/api/product-planning/analyze-image/route.ts b/app/api/product-planning/analyze-image/route.ts index f5491e4be..b1fcbc14f 100644 --- a/app/api/product-planning/analyze-image/route.ts +++ b/app/api/product-planning/analyze-image/route.ts @@ -1,14 +1,8 @@ import { generateText } from "ai"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; -import { - isImageGenerationModelDescriptor, - isVideoModelDescriptor, -} from "@/lib/ai/model-classification"; import { inlineVisionImages } from "@/lib/ai/vision-inline"; -import { isAiModelMultimodal } from "@/lib/db/ai-model-flags"; -import { getDefaultAiModel, getEnabledAiModelsWithProvider } from "@/lib/db/queries"; +import { resolveProductPlanningVisionModel } from "@/lib/product-planning/model-runtime"; import { isProductPlanningEnabled, PRODUCT_PLANNING_DISABLED_MESSAGE, @@ -33,61 +27,6 @@ function jsonError(status: number, error: string) { return NextResponse.json(payload, { status }); } -function supportsVisionAnalysis(model: { - supportsVision?: boolean | null; - capabilities?: unknown; - defaultParams?: unknown; - supportsImageGen?: boolean | null; -}) { - return ( - (Boolean(model.supportsVision) || - isAiModelMultimodal({ - capabilities: model.capabilities, - defaultParams: model.defaultParams, - })) && - !model.supportsImageGen - ); -} - -async function resolvePlanningVisionModel() { - const [defaultModel, enabledModels] = await Promise.all([ - getDefaultAiModel().catch(() => undefined), - getEnabledAiModelsWithProvider(false), - ]); - - const candidates = enabledModels.filter(({ model, provider }) => { - const isVideoModel = isVideoModelDescriptor({ - providerType: provider.type, - modelId: model.modelId, - capabilities: model.capabilities, - supportsImageGen: model.supportsImageGen, - }); - const isImageModel = isImageGenerationModelDescriptor({ - providerType: provider.type, - modelId: model.modelId, - capabilities: model.capabilities, - supportsImageGen: model.supportsImageGen, - }); - - return !isVideoModel && !isImageModel && supportsVisionAnalysis(model); - }); - - const selected = - (defaultModel ? candidates.find(({ model }) => model.id === defaultModel.id) : undefined) || - candidates[0]; - - if (!selected) { - return null; - } - - const model = await getLanguageModelById(selected.model.id); - if (!model) { - return null; - } - - return model; -} - function buildImageAnalyzePrompt(params: { itemId: string; title?: string }) { return [ "你是一名资深电商视觉策划顾问,请分析这张竞品主图,并输出结构化 markdown。", @@ -131,9 +70,9 @@ export async function POST(request: NextRequest) { return jsonError(400, "请求参数不合法"); } - const model = await resolvePlanningVisionModel(); - if (!model) { - return jsonError(500, "当前站点没有可用的图片分析模型"); + const resolvedModel = await resolveProductPlanningVisionModel(); + if (!resolvedModel.model) { + return jsonError(500, resolvedModel.error || "当前站点没有可用的图片分析模型"); } try { @@ -147,7 +86,7 @@ export async function POST(request: NextRequest) { const effectiveImage = inlineDataUrls[0] || parsed.data.imageUrl; const { text } = await generateText({ - model, + model: resolvedModel.model, messages: [ { role: "user", diff --git a/app/api/product-planning/analyze-sku/route.ts b/app/api/product-planning/analyze-sku/route.ts index af616bc84..d2cbed9aa 100644 --- a/app/api/product-planning/analyze-sku/route.ts +++ b/app/api/product-planning/analyze-sku/route.ts @@ -1,10 +1,8 @@ import { generateText } from "ai"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; -import { isVideoModelDescriptor } from "@/lib/ai/model-classification"; -import { getDefaultAiModel, getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { buildProductPlanningSkuAnalyzePrompt } from "@/lib/product-planning/analyze"; +import { resolveProductPlanningTextModel } from "@/lib/product-planning/model-runtime"; import { isProductPlanningEnabled, PRODUCT_PLANNING_DISABLED_MESSAGE, @@ -40,32 +38,6 @@ function jsonError(status: number, error: string) { return NextResponse.json(payload, { status }); } -async function resolvePlanningModel() { - const [defaultModel, enabledModels] = await Promise.all([ - getDefaultAiModel().catch(() => undefined), - getEnabledAiModelsWithProvider(false), - ]); - - const candidates = enabledModels.filter(({ model, provider }) => { - const isVideoModel = isVideoModelDescriptor({ - providerType: provider.type, - modelId: model.modelId, - capabilities: model.capabilities, - supportsImageGen: model.supportsImageGen, - }); - return !isVideoModel && !model.supportsImageGen; - }); - - const selected = - (defaultModel ? candidates.find(({ model }) => model.id === defaultModel.id) : undefined) || - candidates[0]; - - if (!selected) return null; - - const model = await getLanguageModelById(selected.model.id); - return model || null; -} - export async function POST(request: NextRequest) { const token = await getProductPlanningToken(request); if (!token?.sub) { @@ -87,16 +59,16 @@ export async function POST(request: NextRequest) { return jsonError(400, "请求参数不合法或 skus 为空"); } - const model = await resolvePlanningModel(); - if (!model) { - return jsonError(500, "当前站点没有可用的文本分析模型"); + const resolvedModel = await resolveProductPlanningTextModel(); + if (!resolvedModel.model) { + return jsonError(500, resolvedModel.error || "当前站点没有可用的文本分析模型"); } try { const analyzeRequest = parsed.data as ProductPlanningSkuAnalyzeRequest; const prompt = buildProductPlanningSkuAnalyzePrompt(analyzeRequest); const { text } = await generateText({ - model, + model: resolvedModel.model, prompt, temperature: 0.3, }); diff --git a/app/api/product-planning/analyze/route.ts b/app/api/product-planning/analyze/route.ts index 22afddd4d..5380a4d29 100644 --- a/app/api/product-planning/analyze/route.ts +++ b/app/api/product-planning/analyze/route.ts @@ -1,10 +1,8 @@ import { generateText } from "ai"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; -import { isVideoModelDescriptor } from "@/lib/ai/model-classification"; -import { getDefaultAiModel, getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { buildProductPlanningAnalyzePrompt } from "@/lib/product-planning/analyze"; +import { resolveProductPlanningTextModel } from "@/lib/product-planning/model-runtime"; import { isProductPlanningEnabled, PRODUCT_PLANNING_DISABLED_MESSAGE, @@ -62,38 +60,6 @@ function jsonError(status: number, error: string) { return NextResponse.json(payload, { status }); } -async function resolvePlanningModel() { - const [defaultModel, enabledModels] = await Promise.all([ - getDefaultAiModel().catch(() => undefined), - getEnabledAiModelsWithProvider(false), - ]); - - const candidates = enabledModels.filter(({ model, provider }) => { - const isVideoModel = isVideoModelDescriptor({ - providerType: provider.type, - modelId: model.modelId, - capabilities: model.capabilities, - supportsImageGen: model.supportsImageGen, - }); - return !isVideoModel && !model.supportsImageGen; - }); - - const selected = - (defaultModel ? candidates.find(({ model }) => model.id === defaultModel.id) : undefined) || - candidates[0]; - - if (!selected) { - return null; - } - - const model = await getLanguageModelById(selected.model.id); - if (!model) { - return null; - } - - return model; -} - export async function POST(request: NextRequest) { const token = await getProductPlanningToken(request); if (!token?.sub) { @@ -115,16 +81,16 @@ export async function POST(request: NextRequest) { return jsonError(400, "请求参数不合法或 rows 为空"); } - const model = await resolvePlanningModel(); - if (!model) { - return jsonError(500, "当前站点没有可用的文本分析模型"); + const resolvedModel = await resolveProductPlanningTextModel(); + if (!resolvedModel.model) { + return jsonError(500, resolvedModel.error || "当前站点没有可用的文本分析模型"); } try { const analyzeRequest = parsed.data as ProductPlanningAnalyzeRequest; const prompt = buildProductPlanningAnalyzePrompt(analyzeRequest); const { text } = await generateText({ - model, + model: resolvedModel.model, prompt, temperature: 0.3, }); diff --git a/lib/product-planning/model-config.ts b/lib/product-planning/model-config.ts new file mode 100644 index 000000000..2cc2afe6a --- /dev/null +++ b/lib/product-planning/model-config.ts @@ -0,0 +1,77 @@ +export const PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY = "product_planning_ai_model_config"; +export const PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL = "http://103.118.245.6:6081"; +export const PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID = "Qwen3-VL-4B-Instruct"; + +export type ProductPlanningAiModelConfig = { + enabled: boolean; + providerName: string; + baseUrl: string; + apiKey: string; + modelId: string; + displayName: string; +}; + +function normalizeUrl(value: unknown, fallback: string) { + const normalized = String(value || "").trim().replace(/\/+$/, ""); + const normalizedFallback = String(fallback || "").trim().replace(/\/+$/, ""); + return normalized || normalizedFallback; +} + +function normalizeSecret(value: unknown, fallback = "") { + const normalized = String(value || "").trim(); + const normalizedFallback = String(fallback || "").trim(); + return normalized || normalizedFallback; +} + +export function defaultProductPlanningAiModelConfig(): ProductPlanningAiModelConfig { + return { + enabled: false, + providerName: "Product Planning Qwen3-VL", + baseUrl: PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL, + apiKey: "", + modelId: PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID, + displayName: "商品策划专用 Qwen3-VL", + }; +} + +export function normalizeProductPlanningAiModelConfig( + value: unknown, + fallback?: Partial +): ProductPlanningAiModelConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + + return { + enabled: raw.enabled === true, + providerName: + normalizeSecret( + raw.providerName, + fallback?.providerName || defaultProductPlanningAiModelConfig().providerName + ) || defaultProductPlanningAiModelConfig().providerName, + baseUrl: normalizeUrl( + raw.baseUrl, + fallback?.baseUrl || PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL + ), + apiKey: normalizeSecret(raw.apiKey, fallback?.apiKey || ""), + modelId: + normalizeSecret( + raw.modelId, + fallback?.modelId || PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID + ) || PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID, + displayName: + normalizeSecret( + raw.displayName, + fallback?.displayName || defaultProductPlanningAiModelConfig().displayName + ) || defaultProductPlanningAiModelConfig().displayName, + }; +} + +export function toProductPlanningOpenAIBaseUrl(baseUrl: string) { + const normalized = normalizeUrl(baseUrl, PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL); + if (/\/v1$/i.test(normalized)) { + return normalized; + } + return `${normalized}/v1`; +} diff --git a/lib/product-planning/model-runtime.ts b/lib/product-planning/model-runtime.ts new file mode 100644 index 000000000..8bcf7644a --- /dev/null +++ b/lib/product-planning/model-runtime.ts @@ -0,0 +1,179 @@ +import "server-only"; + +import { createOpenAI } from "@ai-sdk/openai"; +import type { LanguageModel } from "ai"; +import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; +import { + isImageGenerationModelDescriptor, + isVideoModelDescriptor, +} from "@/lib/ai/model-classification"; +import { isAiModelMultimodal } from "@/lib/db/ai-model-flags"; +import { + getDefaultAiModel, + getEnabledAiModelsWithProvider, + getSystemSetting, +} from "@/lib/db/queries"; +import { + normalizeProductPlanningAiModelConfig, + PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY, + toProductPlanningOpenAIBaseUrl, +} from "./model-config"; + +type ProductPlanningModelResolution = + | { + model: LanguageModel; + source: "dedicated" | "default"; + error?: undefined; + } + | { + model: null; + source: "dedicated" | "default"; + error: string; + }; + +async function resolveDedicatedProductPlanningModel(): Promise { + const setting = await getSystemSetting({ + key: PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY, + }).catch(() => null); + const config = normalizeProductPlanningAiModelConfig(setting?.value); + + if (!config.enabled) { + return null; + } + + if (!config.apiKey.trim()) { + return { + model: null, + source: "dedicated", + error: "商品策划专用 AI 模型已启用,但未配置 API Key,请前往系统设置填写", + }; + } + + const provider = createOpenAI({ + apiKey: config.apiKey.trim(), + baseURL: toProductPlanningOpenAIBaseUrl(config.baseUrl), + }); + + return { + model: provider.chat(config.modelId.trim()), + source: "dedicated", + }; +} + +async function resolveDefaultTextModel(): Promise { + const [defaultModel, enabledModels] = await Promise.all([ + getDefaultAiModel().catch(() => undefined), + getEnabledAiModelsWithProvider(false), + ]); + + const candidates = enabledModels.filter(({ model, provider }) => { + const isVideoModel = isVideoModelDescriptor({ + providerType: provider.type, + modelId: model.modelId, + capabilities: model.capabilities, + supportsImageGen: model.supportsImageGen, + }); + return !isVideoModel && !model.supportsImageGen; + }); + + const selected = + (defaultModel ? candidates.find(({ model }) => model.id === defaultModel.id) : undefined) || + candidates[0]; + + if (!selected) { + return { + model: null, + source: "default", + error: "当前站点没有可用的文本分析模型", + }; + } + + const model = await getLanguageModelById(selected.model.id); + if (!model) { + return { + model: null, + source: "default", + error: "当前站点没有可用的文本分析模型", + }; + } + + return { + model, + source: "default", + }; +} + +async function resolveDefaultVisionModel(): Promise { + const [defaultModel, enabledModels] = await Promise.all([ + getDefaultAiModel().catch(() => undefined), + getEnabledAiModelsWithProvider(false), + ]); + + const candidates = enabledModels.filter(({ model, provider }) => { + const isVideoModel = isVideoModelDescriptor({ + providerType: provider.type, + modelId: model.modelId, + capabilities: model.capabilities, + supportsImageGen: model.supportsImageGen, + }); + const isImageModel = isImageGenerationModelDescriptor({ + providerType: provider.type, + modelId: model.modelId, + capabilities: model.capabilities, + supportsImageGen: model.supportsImageGen, + }); + + return ( + !isVideoModel && + !isImageModel && + (Boolean(model.supportsVision) || + isAiModelMultimodal({ + capabilities: model.capabilities, + defaultParams: model.defaultParams, + })) && + !model.supportsImageGen + ); + }); + + const selected = + (defaultModel ? candidates.find(({ model }) => model.id === defaultModel.id) : undefined) || + candidates[0]; + + if (!selected) { + return { + model: null, + source: "default", + error: "当前站点没有可用的图片分析模型", + }; + } + + const model = await getLanguageModelById(selected.model.id); + if (!model) { + return { + model: null, + source: "default", + error: "当前站点没有可用的图片分析模型", + }; + } + + return { + model, + source: "default", + }; +} + +export async function resolveProductPlanningTextModel() { + const dedicated = await resolveDedicatedProductPlanningModel(); + if (dedicated) { + return dedicated; + } + return resolveDefaultTextModel(); +} + +export async function resolveProductPlanningVisionModel() { + const dedicated = await resolveDedicatedProductPlanningModel(); + if (dedicated) { + return dedicated; + } + return resolveDefaultVisionModel(); +} diff --git a/tests/routes/admin-settings.test.ts b/tests/routes/admin-settings.test.ts index 2f7681d90..a99579585 100644 --- a/tests/routes/admin-settings.test.ts +++ b/tests/routes/admin-settings.test.ts @@ -5,6 +5,12 @@ import { PRODUCT_PLANNING_API_CONFIG_KEY, type ProductPlanningApiConfig, } from "@/lib/product-planning/config"; +import { + defaultProductPlanningAiModelConfig, + normalizeProductPlanningAiModelConfig, + toProductPlanningOpenAIBaseUrl, + type ProductPlanningAiModelConfig, +} from "@/lib/product-planning/model-config"; test.describe.serial("/api/admin/settings", () => { test("guest cannot read product planning system config", async ({ request }) => { const response = await request.get( @@ -76,4 +82,45 @@ test.describe.serial("/api/admin/settings", () => { saleToken: "fallback-sale-token", } satisfies ProductPlanningApiConfig); }); + + test("product planning dedicated model config normalization trims values and keeps defaults", () => { + const defaults = defaultProductPlanningAiModelConfig(); + const normalized = normalizeProductPlanningAiModelConfig({ + enabled: true, + providerName: " Planning Qwen ", + baseUrl: " http://103.118.245.6:6081/// ", + apiKey: " secret-token ", + modelId: " custom-qwen3-vl ", + displayName: " 商品策划专用模型 ", + }); + + expect(normalized).toEqual({ + enabled: true, + providerName: "Planning Qwen", + baseUrl: "http://103.118.245.6:6081", + apiKey: "secret-token", + modelId: "custom-qwen3-vl", + displayName: "商品策划专用模型", + } satisfies ProductPlanningAiModelConfig); + + const fallbackNormalized = normalizeProductPlanningAiModelConfig({ + enabled: false, + providerName: " ", + baseUrl: " ", + apiKey: " ", + modelId: "", + displayName: "", + }); + + expect(fallbackNormalized).toEqual(defaults); + }); + + test("product planning dedicated model base url can auto-complete v1 path", () => { + expect(toProductPlanningOpenAIBaseUrl("http://103.118.245.6:6081")).toBe( + "http://103.118.245.6:6081/v1" + ); + expect(toProductPlanningOpenAIBaseUrl("http://103.118.245.6:6081/v1/")).toBe( + "http://103.118.245.6:6081/v1" + ); + }); });