142 lines
3.8 KiB
TypeScript
142 lines
3.8 KiB
TypeScript
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 {
|
|
isProductPlanningEnabled,
|
|
PRODUCT_PLANNING_DISABLED_MESSAGE,
|
|
} from "@/lib/product-planning/server-feature";
|
|
import { getProductPlanningToken } from "@/lib/product-planning/server-auth";
|
|
import type {
|
|
ProductPlanningAnalyzeRequest,
|
|
ProductPlanningAnalyzeResponse,
|
|
} from "@/lib/product-planning/types";
|
|
|
|
export const runtime = "nodejs";
|
|
export const maxDuration = 120;
|
|
|
|
const requestSchema = z.union([
|
|
z.object({
|
|
kind: z.literal("review"),
|
|
rows: z
|
|
.array(
|
|
z.object({
|
|
itemId: z.string(),
|
|
date: z.string(),
|
|
content: z.string(),
|
|
appendComment: z.string().optional(),
|
|
sku: z.string().optional(),
|
|
userId: z.string().optional(),
|
|
images: z.array(z.string()),
|
|
videoUrls: z.array(z.string()),
|
|
})
|
|
)
|
|
.min(1),
|
|
}),
|
|
z.object({
|
|
kind: z.literal("qa"),
|
|
rows: z
|
|
.array(
|
|
z.object({
|
|
itemId: z.string(),
|
|
question: z.string(),
|
|
questionDetail: z.string().optional(),
|
|
answer: z.string(),
|
|
answerUser: z.string().optional(),
|
|
answerDate: z.string().optional(),
|
|
isOfficial: z.boolean(),
|
|
})
|
|
)
|
|
.min(1),
|
|
}),
|
|
]);
|
|
|
|
function jsonError(status: number, error: string) {
|
|
const payload: ProductPlanningAnalyzeResponse = {
|
|
success: false,
|
|
error,
|
|
};
|
|
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) {
|
|
return jsonError(401, "未登录");
|
|
}
|
|
if (!(await isProductPlanningEnabled())) {
|
|
return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE);
|
|
}
|
|
|
|
let body: unknown;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return jsonError(400, "请求体不是合法 JSON");
|
|
}
|
|
|
|
const parsed = requestSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return jsonError(400, "请求参数不合法或 rows 为空");
|
|
}
|
|
|
|
const model = await resolvePlanningModel();
|
|
if (!model) {
|
|
return jsonError(500, "当前站点没有可用的文本分析模型");
|
|
}
|
|
|
|
try {
|
|
const analyzeRequest = parsed.data as ProductPlanningAnalyzeRequest;
|
|
const prompt = buildProductPlanningAnalyzePrompt(analyzeRequest);
|
|
const { text } = await generateText({
|
|
model,
|
|
prompt,
|
|
temperature: 0.3,
|
|
});
|
|
|
|
const payload: ProductPlanningAnalyzeResponse = {
|
|
success: true,
|
|
markdown: text,
|
|
};
|
|
return NextResponse.json(payload);
|
|
} catch (error) {
|
|
console.error("[ProductPlanning][Analyze] request failed:", error);
|
|
return jsonError(502, "AI 分析失败");
|
|
}
|
|
}
|