{"repo": "changluei/pptgo", "files": {"app/api/ai/generate/route.ts": {"434156dd8f26ce3ac8b999676c2e8ce7cfbcc3cc": {"t": 1785745578.237695, "n": 4777, "h": "403c354fa6a3ab39", "c": "import { NextRequest, NextResponse } from \"next/server\";\n\ntype GenerateRequest = {\n title?: string;\n brief?: string;\n theme?: string;\n themePackage?: string;\n layouts?: string[];\n signatureLayouts?: string[];\n attachments?: Array<{ name?: string; type?: string; size?: number }>;\n};\n\nfunction stripCodeFence(content: string) {\n return content\n .replace(/^```(?:markdown|md)?\\s*/i, \"\")\n .replace(/\\s*```$/, \"\")\n .trim();\n}\n\nfunction isAllowedBaseUrl(value: string) {\n try {\n const url = new URL(value);\n const isLocalHttp =\n url.protocol === \"http:\" &&\n [\"localhost\", \"127.0.0.1\", \"::1\"].includes(url.hostname);\n return url.protocol === \"https:\" || isLocalHttp;\n } catch {\n return false;\n }\n}\n\nexport async function POST(request: NextRequest) {\n const body = (await request.json()) as GenerateRequest;\n const title = body.title?.trim();\n const brief = body.brief?.trim();\n\n if (!title || !brief) {\n return NextResponse.json({ error: \"title and brief are required\" }, { status: 400 });\n }\n\n const apiKey =\n request.headers.get(\"x-deepseek-api-key\")?.trim() ||\n process.env.DEEPSEEK_API_KEY;\n if (!apiKey) {\n return NextResponse.json({ error: \"DeepSeek API Key is required\" }, { status: 503 });\n }\n\n const baseUrl = (\n request.headers.get(\"x-deepseek-base-url\")?.trim() ||\n process.env.DEEPSEEK_BASE_URL ||\n \"https://api.deepseek.com\"\n ).replace(/\\/$/, \"\");\n const model =\n request.headers.get(\"x-deepseek-model\")?.trim() ||\n process.env.DEEPSEEK_MODEL ||\n \"deepseek-v4-pro\";\n\n if (!isAllowedBaseUrl(baseUrl)) {\n return NextResponse.json({ error: \"Invalid DeepSeek API URL\" }, { status: 400 });\n }\n\n const attachmentSummary = (body.attachments || [])\n .slice(0, 30)\n .map((item) => `- ${item.name || \"未命名文件\"}(${item.type || \"未知类型\"})`)\n .join(\"\\n\");\n const availableLayouts = (body.layouts || [])\n .filter((layout) => /^[a-zA-Z0-9_-]+$/.test(layout))\n .slice(0, 40);\n const layoutSummary = availableLayouts.length\n ? availableLayouts.join(\", \")\n : \"cover, default, center, section, fact, statement, quote, two-cols, image-left, image-right\";\n const signatureLayouts = (body.signatureLayouts || [])\n .filter((layout) => /^[a-zA-Z0-9_-]+$/.test(layout))\n .slice(0, 30);\n const signatureLayoutSummary = signatureLayouts.length\n ? signatureLayouts.join(\", \")\n : \"未检测到主题专属布局\";\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n model,\n temperature: 0.58,\n max_tokens: 8000,\n messages: [\n {\n role: \"system\",\n content: [\n \"你是资深演示文稿策略顾问、中文写作者和 Slidev 排版专家。\",\n \"先在内部完成受众、目标、核心结论、证据链和叙事节奏的规划,但最终只返回完整可运行的 Slidev Markdown,不解释、不包裹代码块。\",\n \"首部必须包含 theme、title、transition 和 layout 的 YAML headmatter;使用 --- 分页,并用每页 frontmatter 指定 layout。\",\n \"theme 值必须与用户提供的 Slidev 主题完全一致;若包含 @、/ 或其他符号,必须用 YAML 引号包裹。\",\n \"演示不是提纲。每一页都要推动一个明确判断:给出上下文、论据、含义或行动,避免只有标题加两三个空泛短句。\",\n \"默认生成 10–14 页;若用户明确指定页数则服从。内容页通常提供 3–5 个有信息量的要点,或一个完整图表/流程/对比结构。\",\n \"至少使用 5 种不同布局,连续两页不要使用相同布局。仅使用用户提供的布局列表,不发明主题组件。\",\n \"优先使用主题专属布局,让成稿明显体现所选模板,而不是所有页面都退化成通用 default/cover。封面优先采用主题专属的 intro/title 类布局。\",\n \"在封面、章节、事实数字、双栏对比、流程、引用和行动页之间形成节奏。two-cols 布局使用 ::right:: 分栏。\",\n \"每个重要内容页末尾加入 演讲者备注,写 2–4 句讲述提示、过渡或风险说明。\",\n \"可以使用 Mermaid 表达流程、时间线和关系。不要虚构来源中没有的数字、事实或引语;缺少证据时明确标为“待补充数据”或给出验证建议。\",\n \"图片附件使用 Markdown 引用 attachment://原文件名,不要猜测图片内容。\",\n ].join(\"\\n\"),\n },\n {\n role: \"user\",\n content: [\n `演示标题:${title}`,\n `Slidev 主题:${body.theme || \"default\"}`,\n `实际安装的主题包:${body.themePackage || \"未提供\"}`,\n `该主题可用布局:${layoutSummary}`,\n `该主题专属布局(优先使用):${signatureLayoutSummary}`,\n `用户要求:${brief}`,\n attachmentSummary\n ? `当前演示文稿的附件(仅提供文件名与类型,无法读取图片内容):\\n${attachmentSummary}`\n : \"当前没有附件。\",\n \"请把用户要求发展成一条完整叙事:为什么现在要讲、我们看到了什么、核心判断是什么、有哪些方案与取舍、接下来具体怎么做。不要为了简洁牺牲必要论证。\",\n ].join(\"\\n\\n\"),\n },\n ],\n }),\n });\n\n if (!response.ok) {\n return NextResponse.json(\n { error: `DeepSeek request failed: ${response.status}` },\n { status: 502 },\n );\n }\n\n const data = (await response.json()) as {\n choices?: Array<{ message?: { content?: string } }>;\n };\n const content = data.choices?.[0]?.message?.content;\n if (!content) {\n return NextResponse.json({ error: \"DeepSeek returned no content\" }, { status: 502 });\n }\n\n return NextResponse.json({ markdown: stripCodeFence(content) });\n}\n"}}}}