feat: add dedicated model for product planning
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-26 17:42:38 +08:00
parent 1721f53248
commit cd44deb8c4
8 changed files with 572 additions and 164 deletions

View File

@ -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<ProductPlanningApiConfig>(defaultProductPlanningApiConfig());
const [productPlanningAiModelConfig, setProductPlanningAiModelConfig] =
useState<ProductPlanningAiModelConfig>(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() {
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1">
<Label className="text-base">商品策划</Label>
<div className="text-sm text-muted-foreground">
控制是否在左侧导航显示商品策划入口;关闭后商品策划页面和相关接口也会一并禁用
</div>
</div>
<Switch
checked={siteSettings.productPlanningEnabled}
onCheckedChange={(checked) =>
setSiteSettings({ ...siteSettings, productPlanningEnabled: checked })
}
/>
</div>
{/* 功能教程设置 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1">
@ -8738,6 +8815,21 @@ export function AdminSettingsClient() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1">
<Label className="text-base">启用商品策划工具</Label>
<div className="text-sm text-muted-foreground">
关闭后左侧导航入口会隐藏,商品策划页面和相关接口也会一并禁用。
</div>
</div>
<Switch
checked={siteSettings.productPlanningEnabled}
onCheckedChange={(checked) =>
setSiteSettings({ ...siteSettings, productPlanningEnabled: checked })
}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="productPlanningOneboundBaseUrl">Onebound API 地址</Label>
@ -8859,6 +8951,10 @@ export function AdminSettingsClient() {
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-800 space-y-1">
<p>
• 当前功能状态:
{siteSettings.productPlanningEnabled ? "已启用,左侧导航和接口开放中。" : "已关闭,入口、页面和接口都会被禁用。"}
</p>
<p>• 商品策划工具的评价、关键词洞察、淘宝/京东详情、问大家和销量接口都会优先使用这里的配置。</p>
<p>• 若某个字段留空,系统会继续尝试读取旧环境变量,避免你切换配置期间功能直接中断。</p>
</div>
@ -8866,7 +8962,131 @@ export function AdminSettingsClient() {
<div className="flex justify-end">
<Button onClick={handleSaveProductPlanningApiConfig}>
<Save className="mr-2 h-4 w-4" />
保存商品策划工具配置
保存商品策划工具设置
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">商品策划专用 AI 模型</CardTitle>
<CardDescription>
为商品策划工具单独绑定一套分析模型。开启后,评价分析、问大家分析、竞品图片分析和 SKU 卖点分析会优先走这里配置的 OpenAI 兼容模型,不再依赖站点默认模型。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1">
<Label className="text-base">启用专用模型</Label>
<div className="text-sm text-muted-foreground">
推荐在商品策划需要固定分析口径时开启。关闭后会自动回退到站点当前可用的默认模型。
</div>
</div>
<Switch
checked={productPlanningAiModelConfig.enabled}
onCheckedChange={(checked) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
enabled: checked,
}))
}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="productPlanningAiProviderName">提供商名称</Label>
<Input
id="productPlanningAiProviderName"
value={productPlanningAiModelConfig.providerName}
onChange={(e) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
providerName: e.target.value,
}))
}
placeholder="Product Planning Qwen3-VL"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningAiDisplayName">显示名称</Label>
<Input
id="productPlanningAiDisplayName"
value={productPlanningAiModelConfig.displayName}
onChange={(e) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
displayName: e.target.value,
}))
}
placeholder="商品策划专用 Qwen3-VL"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningAiBaseUrl">OpenAI 兼容 API 地址</Label>
<Input
id="productPlanningAiBaseUrl"
value={productPlanningAiModelConfig.baseUrl}
onChange={(e) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
baseUrl: e.target.value,
}))
}
placeholder={PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL}
/>
<div className="text-xs text-muted-foreground">
支持填写服务根地址,例如 `http://103.118.245.6:6081`。系统会自动兼容补全 `/v1` 路径。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningAiModelId">模型 ID</Label>
<Input
id="productPlanningAiModelId"
value={productPlanningAiModelConfig.modelId}
onChange={(e) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
modelId: e.target.value,
}))
}
placeholder={PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID}
/>
<div className="text-xs text-muted-foreground">
你的 Qwen3-VL OpenAI 兼容接口若使用默认模型名,可直接保留 `Qwen3-VL-4B-Instruct`。
</div>
</div>
<div className="space-y-2 lg:col-span-2">
<Label htmlFor="productPlanningAiApiKey">API Key</Label>
<Input
id="productPlanningAiApiKey"
type="password"
value={productPlanningAiModelConfig.apiKey}
onChange={(e) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
apiKey: e.target.value,
}))
}
placeholder="请输入商品策划专用模型 API Key"
/>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-800 space-y-1">
<p>• 开启后,商品策划页的文本分析和图片分析都会优先使用这里配置的独立模型。</p>
<p>• 关闭后,系统会继续回退到站点默认文本/视觉模型,不会影响其他页面的模型设置。</p>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveProductPlanningAiModelConfig}>
<Save className="mr-2 h-4 w-4" />
保存商品策划专用模型
</Button>
</div>
</CardContent>

View File

@ -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,

View File

@ -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",

View File

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

View File

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

View File

@ -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>
): ProductPlanningAiModelConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
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`;
}

View File

@ -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<ProductPlanningModelResolution | null> {
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<ProductPlanningModelResolution> {
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<ProductPlanningModelResolution> {
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();
}

View File

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