Your Name 478562c312
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: migrate product planning workspace
2026-03-26 16:38:48 +08:00

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 分析失败");
}
}