diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index 9d6f28879..0410e849e 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -158,6 +158,7 @@ type AiModel = {
isDefault: boolean;
sortOrder: number;
isAgentModel?: boolean;
+ agentCodingModelId?: string;
isMultimodal?: boolean;
imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置
};
@@ -1072,6 +1073,7 @@ export function AdminSettingsClient() {
isHidden: false,
isDefault: false,
isAgentModel: false,
+ agentCodingModelId: "",
isMultimodal: false,
sortOrder: 0,
fallbackModelId1: "", // 备用模型 ID 1
@@ -2007,6 +2009,7 @@ export function AdminSettingsClient() {
isHidden: false,
isDefault: false,
isAgentModel: false,
+ agentCodingModelId: "",
isMultimodal: false,
sortOrder: 0,
fallbackModelId1: "",
@@ -6138,6 +6141,24 @@ export function AdminSettingsClient() {
/>
+ {modelForm.isAgentModel && (
+
+
+
+ setModelForm({
+ ...modelForm,
+ agentCodingModelId: e.target.value,
+ })
+ }
+ placeholder="例如: doubao-seed-2-0-code-preview-260215"
+ />
+
+ Agent 需要结构化规划/编程输出时优先使用该模型 ID,留空则使用当前 Agent 模型。
+
+
+ )}
0,
});
- const model = await getLanguageModelById(plannerConfig.model.id);
+ const codingModelId = getAiModelAgentCodingModelId(plannerConfig.model);
+ const model = await getLanguageModelById(
+ plannerConfig.model.id,
+ codingModelId
+ );
if (!model) {
return NextResponse.json({ success: true, data: fallback });
}
@@ -1638,7 +1647,15 @@ export async function POST(request: Request) {
const imageInputs =
supportsVisionPlanning(plannerConfig.model) && referenceImages.length > 0
- ? referenceImages.map((url) => ({ type: "image" as const, image: url }))
+ ? (
+ await inlineVisionImages(referenceImages, {
+ maxImages: 6,
+ maxSide: 900,
+ timeoutMs: 12000,
+ retries: 2,
+ loggerPrefix: "[Canvas Detail Plan]",
+ })
+ ).inlineDataUrls.map((dataUrl) => ({ type: "image" as const, image: dataUrl }))
: [];
let text = "";
diff --git a/app/api/canvas/detail-review/route.ts b/app/api/canvas/detail-review/route.ts
index a90fd54f9..80c1e5ff2 100644
--- a/app/api/canvas/detail-review/route.ts
+++ b/app/api/canvas/detail-review/route.ts
@@ -5,8 +5,12 @@ import {
getAgentAiModelWithProvider,
getEnabledAiModelsWithProvider,
} from "@/lib/db/queries";
-import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
+import {
+ getAiModelAgentCodingModelId,
+ isAiModelMultimodal,
+} from "@/lib/db/ai-model-flags";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
+import { inlineVisionImages } from "@/lib/ai/vision-inline";
export const runtime = "nodejs";
export const maxDuration = 60;
@@ -186,7 +190,11 @@ export async function POST(request: Request) {
});
}
- const model = await getLanguageModelById(reviewerConfig.model.id);
+ const codingModelId = getAiModelAgentCodingModelId(reviewerConfig.model);
+ const model = await getLanguageModelById(
+ reviewerConfig.model.id,
+ codingModelId
+ );
if (!model) {
return NextResponse.json({
success: true,
@@ -238,10 +246,18 @@ export async function POST(request: Request) {
.filter(Boolean)
.join("\n");
+ const { inlineDataUrls } = await inlineVisionImages([imageUrl, ...referenceImages], {
+ maxImages: 6,
+ maxSide: 900,
+ timeoutMs: 12000,
+ retries: 2,
+ loggerPrefix: "[Canvas Detail Review]",
+ });
+ const effectiveReviewImages = inlineDataUrls.length > 0 ? inlineDataUrls : [imageUrl, ...referenceImages];
+
const content: Array<{ type: "text"; text: string } | { type: "image"; image: string }> = [
{ type: "text", text: promptParts },
- { type: "image", image: imageUrl },
- ...referenceImages.map((url) => ({ type: "image" as const, image: url })),
+ ...effectiveReviewImages.map((image) => ({ type: "image" as const, image })),
];
const { text } = await generateText({
diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx
index 4b890d8cd..6fedc5b9c 100644
--- a/components/canvas/CanvasChatPanel.tsx
+++ b/components/canvas/CanvasChatPanel.tsx
@@ -5397,10 +5397,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}, [activeSessionTitle, projectTitle]);
return (
-
+
{/* 全局遮罩提示 - 当处于画布选图模式时显示 */}
{detailSlotPickerIndex !== null && (
-
+
@@ -5463,7 +5463,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
{isSessionMenuOpen && (
-
+
聊天窗口记录
{chatSessions.length === 0 ? (
@@ -6052,7 +6052,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
)}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
-
+
-
+
已生成文件列表
diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx
index 20be63e55..8355e4783 100644
--- a/components/canvas/RealtimeCanvasDialog.tsx
+++ b/components/canvas/RealtimeCanvasDialog.tsx
@@ -956,7 +956,7 @@ export function RealtimeCanvasDialog({
{/* Top Header - Minimal */}
-
+
Realtime Canvas
diff --git a/docs/AI详情生成-AGENT可编程渲染开发文档.md b/docs/AI详情生成-AGENT可编程渲染开发文档.md
new file mode 100644
index 000000000..96da497a9
--- /dev/null
+++ b/docs/AI详情生成-AGENT可编程渲染开发文档.md
@@ -0,0 +1,523 @@
+# AI详情生成(AGENT + 生图模型 + 可编程渲染)开发文档
+
+## 1. 文档目的
+
+本文档用于落地一套稳定的 AI 详情生成系统:
+
+- 保留现有传统生图模式(快速产图路线)
+- 保留生图模型能力(画面创作、质感、场景)
+- 用 AGENT 负责策划、文案、结构和审稿
+- 用编程渲染保证版式统一和可编辑
+- 支持用户切换两种模式(传统生图 / AGENT排版)
+- 最终结果直接进入无限画布,支持手动改字、拖拽图标、单屏重生
+
+目标是解决当前主要问题:生图模型理解偏差、全套风格不统一、失败不可控、返工成本高。
+
+---
+
+## 2. 核心结论与原则
+
+### 2.1 核心结论
+
+不能只靠生图模型“整页一次生成”。推荐架构是:
+
+`AGENT 策划 -> 分屏生成 -> 程序化合成 -> 画布可编辑 -> 审核回路`
+
+同时保留现有传统生图链路,采用双路线并行:
+
+- 路线 A:`传统生图模式`(保持现状,快速出图)
+- 路线 B:`AGENT排版模式`(生图只负责画面,文字与排版交给程序)
+
+### 2.2 设计原则
+
+1. **模型出画面,代码定版式**
+2. **背景锁定,前景可生**(避免模型重画结构)
+3. **每屏可独立重试**(不整套重跑)
+4. **所有输入输出可追溯**(项目、版本、日志、成本)
+5. **画布内可编辑优先**(文字和图标非烤图)
+6. **双模式可切换且互不影响**(老链路稳定,新链路增量上线)
+
+---
+
+## 3. 业务范围
+
+## 3.1 本期范围
+
+- AI详情模式固定 14 屏流程(可配置)
+- 每屏固定尺寸 `790x1300`
+- 用户上传:
+ - 5 张产品图(必填)
+ - 1 张模特图(可用于卖家视角场景)
+ - 3 张搭配图(可选,触发关联销售屏)
+- 输入业务信息:人群、场景、核心需求、SPU 等
+- 自动生成详情文案与视觉版式
+- 结果进入画布后可修改文字、拖拽图标、替换素材
+- 自动存档、历史恢复、继续生成
+
+## 3.2 非本期范围
+
+- 移动端编辑体验优化
+- 多人协同编辑冲突合并
+- 自动电商平台一键上架
+
+## 3.3 模式策略(新增)
+
+- `传统生图模式(classic)`
+ - 使用现有生图流程
+ - 保留当前用户习惯与响应节奏
+ - 适合快速尝试和低编辑需求场景
+- `AGENT排版模式(agent_layout)`
+ - 生图模型只产出画面素材层,不产出文字排版
+ - AGENT + 程序完成文案结构、版式和 UI 统一
+ - 适合稳定量产、可编辑和可复用场景
+- 前端必须提供模式切换,并把模式写入任务上下文,后端按模式分流 pipeline
+
+---
+
+## 4. 技术架构
+
+```mermaid
+flowchart LR
+ A["用户输入(图片+业务信息)"] --> B["AGENT 规划器"]
+ B --> C["ScreenSpec 14屏结构化脚本"]
+ C --> D["生图编排器(逐屏调用模型)"]
+ D --> E["程序化合成器(背景/文案/图标)"]
+ E --> F["双总监审稿(设计+营销)"]
+ F -->|通过| G["写入S3/MinIO + DB版本"]
+ F -->|不通过| D
+ G --> H["画布落地(垂直排版)"]
+ H --> I["手动编辑(改字/拖拽/重生单屏)"]
+```
+
+双路线分流(逻辑):
+
+```mermaid
+flowchart LR
+ U["用户选择模式"] --> M{"generation_mode"}
+ M -->|classic| C1["传统生图Pipeline"]
+ M -->|agent_layout| C2["AGENT排版Pipeline"]
+ C1 --> R["统一写入画布与历史记录"]
+ C2 --> R
+```
+
+---
+
+## 5. 推荐开源技术选型
+
+## 5.1 画布编辑层
+
+- `react-konva + konva`:用于画布图层编辑、拖拽、缩放、导出
+- `Moveable` 或 `interact.js`:吸附对齐、参考线、间距控制
+
+## 5.2 渲染层
+
+- 主方案:HTML/CSS 模板 + Playwright 截图(最接近设计稿控制)
+- 备选:`Satori + resvg-js`(高一致性服务端矢量渲染)
+
+## 5.3 模型编排层
+
+- 继续使用当前生图供应商体系(支持路由和故障迁移)
+- AGENT 负责:
+ - 详情策略规划
+ - Prompt 生成与优化
+ - 结果审稿和重试建议
+
+## 5.4 可选参考(不直接引入)
+
+- Excalidraw:交互/白板体验参考
+- Penpot:组件化设计体系参考
+- tldraw:能力强,但许可证需谨慎评估
+
+---
+
+## 6. 数据模型设计(数据库)
+
+建议新增或扩展以下表(命名可按现有规范调整):
+
+## 6.1 `detail_projects`
+
+- `id`
+- `user_id`
+- `canvas_id`
+- `spu`
+- `demand_type`
+- `project_name`(默认 `SPU+需求类型`)
+- `status`(draft/planning/generating/reviewing/completed/failed)
+- `generation_mode`(classic/agent_layout,默认 classic)
+- `created_at` / `updated_at`
+
+## 6.2 `detail_project_inputs`
+
+- `id`
+- `project_id`
+- `core_audience`
+- `usage_scenarios`
+- `core_needs`
+- `selling_points`
+- `tone_style`
+- `extra_requirements`
+- `raw_form_json`
+
+## 6.3 `detail_assets`
+
+- `id`
+- `project_id`
+- `asset_type`(product/model/combo/reference)
+- `source_url`
+- `storage_url`
+- `width` / `height`
+- `meta_json`
+
+## 6.4 `detail_screen_specs`
+
+- `id`
+- `project_id`
+- `screen_index`(1..14)
+- `screen_type`
+- `spec_json`(可编辑图层结构)
+- `prompt_json`
+- `status`
+
+## 6.5 `detail_screen_renders`
+
+- `id`
+- `project_id`
+- `screen_index`
+- `version_no`
+- `image_url`
+- `thumb_url`
+- `model_provider`
+- `model_name`
+- `cost_credits`
+- `latency_ms`
+- `review_score_design`
+- `review_score_marketing`
+- `review_notes`
+
+## 6.6 `detail_generation_jobs`
+
+- `id`
+- `project_id`
+- `job_type`(plan/generate/review/retry)
+- `generation_mode`(classic/agent_layout)
+- `progress`(0~100)
+- `stage`(规划中/生成中/审稿中/完成)
+- `error_code`
+- `error_message`
+- `started_at` / `finished_at`
+
+## 6.7 现有聊天/消息表扩展(建议)
+
+- 在会话或任务表增加 `generation_mode`
+- 在消息元数据增加 `mode_snapshot`(防止切换模式导致历史重放错乱)
+- 在生成记录增加 `resolved_pipeline`(最终实际执行链路)
+
+---
+
+## 7. ScreenSpec(核心协议)
+
+AGENT 和渲染器之间只通过结构化协议通信,不直接拼字符串 UI。
+
+```json
+{
+ "projectId": "dp_xxx",
+ "canvasId": "cv_xxx",
+ "size": { "width": 790, "height": 1300 },
+ "globalStyle": {
+ "fontFamily": "PingFang SC",
+ "titleSize": 68,
+ "subtitleSize": 36,
+ "bodySize": 28,
+ "lineHeight": 1.35,
+ "palette": { "primary": "#5B4DFF", "text": "#111111" }
+ },
+ "screen": {
+ "index": 1,
+ "type": "hero_poster",
+ "copy": {
+ "name": "安敏裤",
+ "title": "安敏裤 | 敏宝整夜安睡不红屁屁",
+ "subtitle": "过敏体质宝宝专研,夜用安心不反渗"
+ },
+ "layers": [
+ { "id": "bg", "kind": "backgroundImage", "editable": false, "src": "https://..." },
+ { "id": "title", "kind": "text", "editable": true, "x": 56, "y": 120, "w": 678, "h": 180 },
+ { "id": "badge", "kind": "icon", "editable": true, "x": 56, "y": 340, "w": 160, "h": 52 }
+ ],
+ "gen": {
+ "needModelImage": true,
+ "prompt": "只生成产品前景和光效,不生成文字,不改背景结构",
+ "negativePrompt": "杂乱文字, 水印, logo"
+ }
+ }
+}
+```
+
+---
+
+## 8. 14屏生产流程(标准)
+
+1. 首屏海报(命名+核心需求一句话)
+2. 用户痛点
+3. 核心卖点机制
+4. 卖点证据/参数
+5. 场景 1(日常)
+6. 场景 2(高频痛点)
+7. 细节特写
+8. 对比页
+9. 使用方法
+10. 搭配销售(用户上传搭配图时启用)
+11. 买家评价 1(自动生成评价+3图)
+12. 买家评价 2(可再次生成新2条)
+13. 品牌背书/服务承诺
+14. 收口 CTA
+
+约束:必须完整到第 14 屏才能标记 `completed`。
+
+---
+
+## 9. AGENT 职责拆分
+
+## 9.1 Planner Agent(规划师)
+
+- 解析输入信息和图片素材
+- 生成 14 屏文案骨架与屏幕目标
+- 生成命名(大白话、可记忆、带卖点)
+
+## 9.2 Prompt Agent(生图导演)
+
+- 将每屏目标转换为生图 prompt
+- 强约束“仅出前景元素,不写文案”
+- 设定风格一致性关键词
+
+## 9.3 Review Agent(双总监审稿)
+
+- 设计总监评分:版式层级、视觉统一、可读性
+- 营销总监评分:卖点清晰、转化逻辑、购买驱动
+- 低于阈值自动重生单屏
+
+---
+
+## 10. 画布集成要求
+
+## 10.1 渲染落位
+
+- 14 屏按顺序垂直插入画布
+- 固定屏间距(建议 80px)
+- 自动居中对齐
+- 生成时先放占位卡(含进度)
+
+## 10.2 编辑能力
+
+- 文本层可直接双击编辑
+- 图标层可拖拽/缩放
+- 支持吸附对齐线(含最小间距,避免粘连)
+- 单屏“重生”按钮(保留版式,仅替换画面)
+
+## 10.3 存档与恢复
+
+- 自动保存当前编辑状态(去抖)
+- 恢复项目时还原全部输入、素材、屏幕版本和图层状态
+- 支持“另存为新版本”
+
+---
+
+## 11. API 设计(建议)
+
+## 11.1 项目接口
+
+- `POST /api/canvas/[id]/detail-projects`:创建项目
+- `GET /api/canvas/[id]/detail-projects`:项目列表
+- `GET /api/canvas/[id]/detail-projects/:projectId`:项目详情
+- `DELETE /api/canvas/[id]/detail-projects/:projectId`:删除存档
+
+## 11.2 生成接口
+
+- `POST /api/canvas/[id]/detail-projects/:projectId/plan`
+- `POST /api/canvas/[id]/detail-projects/:projectId/generate`
+- `POST /api/canvas/[id]/detail-projects/:projectId/retry-screen`
+- `POST /api/canvas/[id]/detail-projects/:projectId/review`
+- 请求体需带 `generation_mode`,或由项目默认模式继承
+
+## 11.3 进度接口
+
+- `GET /api/canvas/[id]/detail-projects/:projectId/progress`(SSE 推荐)
+- 前端展示阶段:规划中 -> 生成中 -> 审稿中 -> 完成/失败
+
+## 11.4 模式切换接口(新增)
+
+- `POST /api/canvas/[id]/generation-mode`
+ - 入参:`conversationId`、`generation_mode`
+ - 作用:更新当前会话默认模式
+- 模式切换只影响新任务,不回写旧任务执行链路
+
+---
+
+## 12. 失败与兜底策略
+
+1. 单屏失败优先重试,不整套失败
+2. 主模型失败自动故障迁移
+3. 审稿失败回到该屏 prompt 优化重生
+4. 图片 URL 拉取失败时切换缩略图/中转 URL
+5. 超时任务可恢复继续跑,不丢进度
+
+---
+
+## 13. 性能与成本控制
+
+- 生图并发建议:2~3(避免过载和限流)
+- 文字层和图标层走本地渲染,减少模型调用
+- 单屏缓存:输入未变则复用结果
+- 记录每屏耗时与积分,输出成本报表
+
+---
+
+## 13.1 排版遮挡与可读性自动质检(新增)
+
+为避免“文字压住产品主体、文案不可读、版式失衡”,在每屏渲染后新增 `Layout QA` 阶段。
+
+### A. 质检输入
+
+- 渲染结果图(790x1300)
+- `ScreenSpec` 图层信息(文本框、图标框、背景层)
+- 产品主体掩码 `product_mask`(由分割模型产出,建议 SAM2 或 YOLO Seg)
+- 图像显著区域 `saliency_map`(可选)
+
+### B. 核心判定指标
+
+- `occlusion_ratio = area(text_box ∩ product_mask) / area(product_mask)`
+- `text_intrusion = area(text_box ∩ product_mask) / area(text_box)`
+- 文本区域对比度(背景亮度与文字颜色对比)
+- 安全区越界(顶部标题区、边距、栅格)
+
+### C. 建议阈值(首版)
+
+- `occlusion_ratio <= 0.03`
+- `text_intrusion <= 0.10`
+- 主标题与产品主体最小距离 >= 24px
+- 正文对比度 >= 4.5:1;大标题对比度 >= 3.0:1
+- 文本不得覆盖显著区域 Top 20%
+
+### D. 自动修复策略
+
+1. 先移动文本框(上 -> 下 -> 侧)
+2. 再缩短文案(调用 AGENT copy shortener)
+3. 再调整字号和行高(保持全局层级)
+4. 最后加底板/描边增强可读性
+5. 超过重排次数仍失败 -> 标记人工介入
+
+### E. 结果落库
+
+- 每屏记录 `qa_passed`、`qa_score`、`qa_issues`、`qa_fix_actions`
+- 支持在画布中打开“质检报告”,可一键触发该屏重排/重生
+
+---
+
+## 14. 安全与合规
+
+- 所有 API Key 仅服务端读取(数据库加密存储)
+- 前端不暴露供应商密钥
+- 生成内容日志脱敏(URL、用户隐私)
+- 存储地址统一走可控域名,避免跨域拉图波动
+
+---
+
+## 15. 里程碑计划
+
+## 阶段 A(1 周):可跑通 MVP
+
+- 前端模式切换(传统生图 / AGENT排版)
+- 后端按 `generation_mode` 分流
+- ScreenSpec 协议
+- 14 屏生成状态机
+- 画布垂直落位 + 占位卡
+- 单屏重试
+
+## 阶段 B(1~2 周):可编辑与存档
+
+- 文本层编辑、图标拖拽、吸附线
+- 项目存档、恢复、版本化
+- 失败恢复和续跑
+
+## 阶段 C(1 周):质量增强
+
+- 双总监审稿自动回路
+- 屏幕质量评分
+- 成本和速度优化
+
+---
+
+## 16. 验收标准(必须满足)
+
+1. 14 屏完整生成成功率 >= 95%(可重试后)
+2. 每屏版式和字体风格一致,无明显错位
+3. 画布中可改字、可拖拽图标、可单屏重生
+4. 刷新页面后项目状态和结果不丢失
+5. 失败有明确原因并可一键重试
+
+---
+
+## 17. 风险与应对
+
+- **风险:模型仍偏题** -> 强化 prompt 约束 + 审稿回路
+- **风险:图片链路不稳定** -> 同域代理/中转 + 缩略图兜底
+- **风险:编辑体验复杂** -> 分层上线,先文本编辑再高级控件
+- **风险:成本高** -> 单屏缓存、只重跑失败屏、限制并发
+
+---
+
+## 18. 开发清单(执行版)
+
+- [ ] 新增详情项目数据表与迁移脚本
+- [ ] 实现 ScreenSpec 生成器(AGENT Planner)
+- [ ] 实现每屏生图编排器
+- [ ] 实现程序化合成器(固定 790x1300)
+- [ ] 实现 Layout QA(遮挡检测、可读性检测、自动重排)
+- [ ] 实现双总监审稿器和重试策略
+- [ ] 接入画布垂直落位与占位进度
+- [ ] 接入文本编辑/图标拖拽/吸附线
+- [ ] 接入存档、恢复、删除、另存版本
+- [ ] 接入监控日志与成本统计
+
+---
+
+## 19. 编程生成PPT技术路线参考(新增)
+
+本项目主渲染仍以“画布图层渲染”为核心,PPT 相关能力建议定位为“导出能力”。
+
+### 20.1 推荐路线(与当前栈匹配)
+
+- `PptxGenJS`(Node/TS 生态)
+ - 优点:与现有前后端栈一致、可编程布局能力强、便于把 `ScreenSpec` 映射为幻灯片对象
+ - 适用:把 14 屏详情导出为可编辑 PPTX 交付给运营团队
+
+### 20.2 可选路线
+
+- `python-pptx`
+ - 优点:批处理成熟,适合离线报告任务
+ - 缺点:需要额外 Python 服务,增加运维与调用链复杂度
+- `Open XML SDK`(.NET)/ `Apache POI`(Java)
+ - 适合企业系统深度集成,不作为当前首选
+
+### 20.3 参考价值判断
+
+- 有价值点:
+ - `ScreenSpec -> PPTX` 的结构化映射
+ - 模板化母版(Master)和统一字号体系
+ - 批量导出与版本归档
+- 无法替代点:
+ - PPT 方案本身不解决“文字遮挡主体”问题,仍需 Layout QA
+ - 不适合替代当前画布内精细编辑与逐屏重生流程
+
+### 20.4 落地建议
+
+1. 先完成画布内稳定生产(ScreenSpec + Layout QA + 自动重排)
+2. 再增加 `Export to PPTX`,作为交付层功能
+3. 导出时保留标题/副标题/正文可编辑,背景图锁定,避免误改布局
+
+---
+
+## 20. 结语
+
+这套方案不是“取消生图模型”,而是把生图模型放到最擅长的位置(视觉生成),把结构一致性、文案逻辑和生产稳定性交给 AGENT + 编程系统。
+对你当前业务目标(稳定出高转化详情、可持续迭代)是更可控、可扩展、可运营的路径。
diff --git a/lib/ai/auto-agent-graph.ts b/lib/ai/auto-agent-graph.ts
index 805174a7b..1402b117d 100644
--- a/lib/ai/auto-agent-graph.ts
+++ b/lib/ai/auto-agent-graph.ts
@@ -3,7 +3,10 @@ import { generateText } from "ai";
import sharp from "sharp";
import type { AnalysisResult } from "@/lib/ai/smart-analyzer";
import type { RouteResult } from "@/lib/ai/model-router";
-import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
+import {
+ getAiModelAgentCodingModelId,
+ isAiModelMultimodal,
+} from "@/lib/db/ai-model-flags";
interface HistoryMessage {
role: "user" | "assistant";
@@ -688,6 +691,26 @@ function parseJsonBlock>(text: string): T | null {
}
}
+function resolveAgentCodingModelOverride(
+ selectedModelId: string,
+ configuredAgentModel:
+ | {
+ model?: {
+ id?: string | null;
+ defaultParams?: unknown;
+ capabilities?: unknown;
+ } | null;
+ }
+ | null
+ | undefined,
+): string | null {
+ const configuredId = configuredAgentModel?.model?.id || null;
+ if (!configuredId || configuredId !== selectedModelId) {
+ return null;
+ }
+ return getAiModelAgentCodingModelId(configuredAgentModel?.model || {}) || null;
+}
+
async function generateAgentConversation(
analysis: AnalysisResult,
state: AutoAgentStateType
@@ -714,7 +737,10 @@ async function generateAgentConversation(
if (!reasoningModelConfig) {
return analysis.conversationalReply || null;
}
- const reasoningModel = await getLanguageModelById(reasoningModelConfig.model.id);
+ const reasoningModel = await getLanguageModelById(
+ reasoningModelConfig.model.id,
+ resolveAgentCodingModelOverride(reasoningModelConfig.model.id, agentModel),
+ );
if (!reasoningModel) {
return analysis.conversationalReply || null;
}
@@ -858,7 +884,10 @@ async function recommendImageParamsWithAgent(
if (!plannerModelConfig) {
return heuristicImageParams(state.text, analysis);
}
- const plannerModel = await getLanguageModelById(plannerModelConfig.model.id);
+ const plannerModel = await getLanguageModelById(
+ plannerModelConfig.model.id,
+ resolveAgentCodingModelOverride(plannerModelConfig.model.id, agentModel),
+ );
if (!plannerModel) {
return heuristicImageParams(state.text, analysis);
}
@@ -933,7 +962,10 @@ async function recommendVideoPlanWithAgent(
if (!plannerModelConfig) {
return heuristicVideoPlan(state);
}
- const plannerModel = await getLanguageModelById(plannerModelConfig.model.id);
+ const plannerModel = await getLanguageModelById(
+ plannerModelConfig.model.id,
+ resolveAgentCodingModelOverride(plannerModelConfig.model.id, agentModel),
+ );
if (!plannerModel) {
return heuristicVideoPlan(state);
}
@@ -1041,7 +1073,10 @@ async function refineRouteWithAgent(
});
const plannerModelConfig = pickAgentReasoningModel(primaryAgentModel, allModelsRaw);
if (!plannerModelConfig) return baseRoute;
- const plannerModel = await getLanguageModelById(plannerModelConfig.model.id);
+ const plannerModel = await getLanguageModelById(
+ plannerModelConfig.model.id,
+ resolveAgentCodingModelOverride(plannerModelConfig.model.id, agentModel),
+ );
if (!plannerModel) return baseRoute;
const candidatesText = imageCandidates
diff --git a/lib/ai/dynamic-provider.ts b/lib/ai/dynamic-provider.ts
index ce1781885..7e025aeee 100644
--- a/lib/ai/dynamic-provider.ts
+++ b/lib/ai/dynamic-provider.ts
@@ -228,7 +228,8 @@ function getOrCreateProvider(provider: Pick {
try {
const model = await getAiModelById({ id: modelId });
@@ -256,8 +257,10 @@ export async function getDynamicModel(
return null;
}
+ const resolvedModelId = String(overrideModelId || "").trim() || model.modelId;
+
// 创建基础模型
- console.log(`[getDynamicModel] Creating model: ${model.modelId} (${model.displayName}) using provider: ${provider.name} (${provider.type})`);
+ console.log(`[getDynamicModel] Creating model: ${resolvedModelId} (${model.displayName}) using provider: ${provider.name} (${provider.type})`);
// 根据 provider 类型选择正确的方法和模型ID
// customProvider (gemini, local) 使用 .languageModel()
@@ -266,56 +269,56 @@ export async function getDynamicModel(
if (provider.type === "gemini") {
// Gemini 图片生成使用 chat() 方法(与 local-qwen 相同的调用方式)
console.log("[getDynamicModel] Getting Gemini chat model..."); // 调试日志
- baseModel = providerInstance.chat(model.modelId);
+ baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Gemini model retrieved:", baseModel ? "success" : "failed"); // 调试日志
console.log("[getDynamicModel] Gemini model type:", typeof baseModel); // 调试
console.log("[getDynamicModel] Gemini model keys:", baseModel ? Object.keys(baseModel).join(', ') : 'N/A'); // 调试
} else if (provider.type === "local") {
// 本地模型使用 chat() 方法
console.log("[getDynamicModel] Getting local chat model..."); // 调试日志
- baseModel = providerInstance.chat(model.modelId);
+ baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Local model retrieved:", baseModel ? "success" : "failed"); // 调试日志
} else if (provider.type === "qianwen-video") {
// 千问视频生成使用 chat() 方法
console.log("[getDynamicModel] Getting Qianwen Video chat model..."); // 调试日志
- baseModel = providerInstance.chat(model.modelId);
+ baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Qianwen Video model retrieved:", baseModel ? "success" : "failed"); // 调试日志
} else if (provider.type === "fal-video") {
// FAL 视频生成使用 chat() 方法
console.log("[getDynamicModel] Getting Fal Video chat model..."); // 调试日志
- baseModel = providerInstance.chat(model.modelId);
+ baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Fal Video model retrieved:", baseModel ? "success" : "failed"); // 调试日志
} else if (provider.type === "volcano-vision") {
// 火山引擎视觉生成使用 chat() 方法
console.log("[getDynamicModel] Getting Volcano Vision chat model..."); // 调试日志
- baseModel = providerInstance.chat(model.modelId);
+ baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Volcano Vision model retrieved:", baseModel ? "success" : "failed"); // 调试日志
} else if (provider.type === "alibaba-image") {
// 阿里云千问图片生成使用 chat() 方法
console.log("[getDynamicModel] Getting Alibaba Image chat model..."); // 调试日志
- baseModel = providerInstance.chat(model.modelId);
+ baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Alibaba Image model retrieved:", baseModel ? "success" : "failed"); // 调试日志
} else if (provider.type === "ruxa-image") {
// RUXA 图片编辑生成使用 chat() 方法
console.log("[getDynamicModel] Getting Ruxa Image chat model..."); // 调试日志
- baseModel = providerInstance.chat(model.modelId);
+ baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Ruxa Image model retrieved:", baseModel ? "success" : "failed"); // 调试日志
} else if (provider.type === "fal-image") {
// FAL 图片编辑生成使用 chat() 方法
console.log("[getDynamicModel] Getting Fal Image chat model..."); // 调试日志
- baseModel = providerInstance.chat(model.modelId);
+ baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Fal Image model retrieved:", baseModel ? "success" : "failed"); // 调试日志
} else if (provider.type === "huanke-gemini") {
// 环科 Gemini 原生格式使用 chat() 方法
console.log("[getDynamicModel] Getting Huanke Gemini chat model..."); // 调试日志
- baseModel = providerInstance.chat(model.modelId);
+ baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Huanke Gemini model retrieved:", baseModel ? "success" : "failed"); // 调试日志
console.log("[getDynamicModel] Huanke Gemini model type:", typeof baseModel); // 调试
console.log("[getDynamicModel] Huanke Gemini model keys:", baseModel ? Object.keys(baseModel).join(', ') : 'N/A'); // 调试
} else {
// OpenAI-compatible providers
console.log("[getDynamicModel] Getting OpenAI-compatible model..."); // 调试日志
- baseModel = (providerInstance as any).chat(model.modelId);
+ baseModel = (providerInstance as any).chat(resolvedModelId);
console.log("[getDynamicModel] OpenAI model retrieved:", baseModel ? "success" : "failed"); // 调试日志
}
@@ -340,9 +343,10 @@ export async function getDynamicModel(
// 通过模型的数据库 ID 获取语言模型实例
export async function getLanguageModelById(
- modelId: string
+ modelId: string,
+ overrideModelId?: string | null
): Promise {
- return getDynamicModel(modelId);
+ return getDynamicModel(modelId, overrideModelId);
}
// 获取所有可用的模型列表(用于前端展示)
diff --git a/lib/ai/prompt-optimizer.ts b/lib/ai/prompt-optimizer.ts
index d69a33881..8d301afba 100644
--- a/lib/ai/prompt-optimizer.ts
+++ b/lib/ai/prompt-optimizer.ts
@@ -544,6 +544,7 @@ export async function optimizePromptWithLLM(params: {
getEnabledAiModelsWithProvider,
getAgentAiModelWithProvider,
} = await import("@/lib/db/queries");
+ const { getAiModelAgentCodingModelId } = await import("@/lib/db/ai-model-flags");
const { getLanguageModelById } = await import("./dynamic-provider");
// 提示词优化优先使用 Agent 模型(建议智普),失败时回退
@@ -562,13 +563,22 @@ export async function optimizePromptWithLLM(params: {
(m) => m.provider.type === "alibaba" && m.model.supportsVision
);
const selectedOptimizerModel = usableAgentModel || zhipuModel || qwenModel;
+ const agentCodingModelId =
+ selectedOptimizerModel &&
+ usableAgentModel &&
+ selectedOptimizerModel.model.id === usableAgentModel.model.id
+ ? getAiModelAgentCodingModelId(usableAgentModel.model)
+ : null;
if (!selectedOptimizerModel) {
console.log("[Prompt Optimizer LLM] No optimizer model available, falling back to template");
return optimizePrompt({ originalPrompt, intent, subIntent });
}
- const model = await getLanguageModelById(selectedOptimizerModel.model.id);
+ const model = await getLanguageModelById(
+ selectedOptimizerModel.model.id,
+ agentCodingModelId
+ );
if (!model) {
console.log("[Prompt Optimizer LLM] Failed to get model, falling back to template");
return optimizePrompt({ originalPrompt, intent, subIntent });
@@ -598,7 +608,8 @@ export async function optimizePromptWithLLM(params: {
console.log(
"[Prompt Optimizer LLM] Calling optimizer model:",
selectedOptimizerModel.model.displayName,
- `(${selectedOptimizerModel.provider.type})`
+ `(${selectedOptimizerModel.provider.type})`,
+ agentCodingModelId ? `override=${agentCodingModelId}` : ""
);
const { text: responseText } = await generateText({
diff --git a/lib/ai/vision-inline.ts b/lib/ai/vision-inline.ts
new file mode 100644
index 000000000..e8407fce9
--- /dev/null
+++ b/lib/ai/vision-inline.ts
@@ -0,0 +1,117 @@
+import sharp from "sharp";
+
+type InlineVisionOptions = {
+ maxImages?: number;
+ maxSide?: number;
+ timeoutMs?: number;
+ retries?: number;
+ loggerPrefix?: string;
+ jpegQuality?: number;
+};
+
+type InlineVisionResult = {
+ inlineDataUrls: string[];
+ skipped: Array<{ url: string; reason: string }>;
+};
+
+const DEFAULT_MAX_IMAGES = 6;
+const DEFAULT_MAX_SIDE = 900;
+const DEFAULT_TIMEOUT_MS = 12000;
+const DEFAULT_RETRIES = 2;
+const DEFAULT_JPEG_QUALITY = 82;
+
+async function fetchImageWithRetry(url: string, timeoutMs: number, retries: number) {
+ let lastError: unknown;
+
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetch(url, {
+ method: "GET",
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ return Buffer.from(arrayBuffer);
+ } catch (error) {
+ lastError = error;
+ if (attempt < retries) {
+ await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
+ }
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
+
+ throw lastError instanceof Error ? lastError : new Error("图片拉取失败");
+}
+
+async function downscaleForVision(
+ inputBuffer: Buffer,
+ maxSide: number,
+ jpegQuality: number,
+) {
+ const image = sharp(inputBuffer, { failOn: "none" });
+ const metadata = await image.metadata();
+ const hasAlpha = Boolean(metadata.hasAlpha);
+
+ const resized = image
+ .rotate()
+ .resize({
+ width: maxSide,
+ height: maxSide,
+ fit: "inside",
+ withoutEnlargement: true,
+ });
+
+ if (hasAlpha) {
+ const output = await resized.png({ compressionLevel: 9 }).toBuffer();
+ return { mime: "image/png", buffer: output };
+ }
+
+ const output = await resized.jpeg({ quality: jpegQuality, mozjpeg: true }).toBuffer();
+ return { mime: "image/jpeg", buffer: output };
+}
+
+export async function inlineVisionImages(
+ urls: string[],
+ options: InlineVisionOptions = {},
+): Promise {
+ const maxImages = Math.max(1, options.maxImages ?? DEFAULT_MAX_IMAGES);
+ const maxSide = Math.max(320, options.maxSide ?? DEFAULT_MAX_SIDE);
+ const timeoutMs = Math.max(3000, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
+ const retries = Math.max(1, options.retries ?? DEFAULT_RETRIES);
+ const jpegQuality = Math.max(55, Math.min(95, options.jpegQuality ?? DEFAULT_JPEG_QUALITY));
+ const loggerPrefix = options.loggerPrefix ?? "[Vision Inline]";
+
+ const inlineDataUrls: string[] = [];
+ const skipped: Array<{ url: string; reason: string }> = [];
+
+ const candidates = urls
+ .map((item) => String(item || "").trim())
+ .filter((url) => /^https?:\/\//i.test(url))
+ .slice(0, maxImages);
+
+ for (const url of candidates) {
+ try {
+ const buffer = await fetchImageWithRetry(url, timeoutMs, retries);
+ const processed = await downscaleForVision(buffer, maxSide, jpegQuality);
+ const dataUrl = `data:${processed.mime};base64,${processed.buffer.toString("base64")}`;
+ inlineDataUrls.push(dataUrl);
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : "unknown_error";
+ skipped.push({ url, reason });
+ console.warn(`${loggerPrefix} skip reference image:`, {
+ url,
+ reason,
+ });
+ }
+ }
+
+ return { inlineDataUrls, skipped };
+}
diff --git a/lib/db/ai-model-flags.ts b/lib/db/ai-model-flags.ts
index bcdb4fea9..c5c61202a 100644
--- a/lib/db/ai-model-flags.ts
+++ b/lib/db/ai-model-flags.ts
@@ -1,5 +1,6 @@
const AGENT_AUTO_MODEL_KEY = "agentAutoModel";
const MULTIMODAL_MODEL_KEY = "multimodalModel";
+const AGENT_CODING_MODEL_ID_KEY = "agentCodingModelId";
function toRecord(value: unknown): Record | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -62,6 +63,43 @@ export function withAgentAutoModelFlag(
return Object.keys(next).length > 0 ? next : null;
}
+export function getAiModelAgentCodingModelId(input: {
+ defaultParams?: unknown;
+ capabilities?: unknown;
+}): string | null {
+ const defaultParams = toRecord(input.defaultParams);
+ const defaultValue = defaultParams?.[AGENT_CODING_MODEL_ID_KEY];
+ if (typeof defaultValue === "string") {
+ const normalized = defaultValue.trim();
+ if (normalized) return normalized;
+ }
+
+ const capabilities = toRecord(input.capabilities);
+ const capabilityValue = capabilities?.[AGENT_CODING_MODEL_ID_KEY];
+ if (typeof capabilityValue === "string") {
+ const normalized = capabilityValue.trim();
+ if (normalized) return normalized;
+ }
+
+ return null;
+}
+
+export function withAgentCodingModelId(
+ defaultParams: unknown,
+ codingModelId?: string | null
+): Record | null {
+ const next = { ...(toRecord(defaultParams) || {}) };
+ const normalized = String(codingModelId || "").trim();
+
+ if (normalized) {
+ next[AGENT_CODING_MODEL_ID_KEY] = normalized;
+ } else {
+ delete next[AGENT_CODING_MODEL_ID_KEY];
+ }
+
+ return Object.keys(next).length > 0 ? next : null;
+}
+
export function isAiModelMultimodal(input: {
capabilities?: unknown;
defaultParams?: unknown;
diff --git a/lib/db/queries.ts b/lib/db/queries.ts
index ac97dfef1..3836b10b8 100644
--- a/lib/db/queries.ts
+++ b/lib/db/queries.ts
@@ -57,6 +57,7 @@ import { generateHashedPassword } from "./utils";
import {
isAiModelAgentAuto,
withAgentAutoModelFlag,
+ withAgentCodingModelId,
withMultimodalModelFlag,
} from "./ai-model-flags";
import {
@@ -1440,6 +1441,7 @@ export async function createAiModel({
imageGenConfig,
isAgentModel,
isMultimodal,
+ agentCodingModelId,
}: {
providerId: string;
modelId: string;
@@ -1457,6 +1459,7 @@ export async function createAiModel({
imageGenConfig?: ImageGenConfig;
isAgentModel?: boolean;
isMultimodal?: boolean;
+ agentCodingModelId?: string;
}): Promise {
try {
// 如果设置为默认,先取消其他默认
@@ -1467,6 +1470,16 @@ export async function createAiModel({
.where(eq(aiModel.isDefault, true));
}
+ const defaultParams =
+ agentCodingModelId !== undefined
+ ? withAgentCodingModelId(
+ isAgentModel === undefined ? null : withAgentAutoModelFlag(null, isAgentModel),
+ agentCodingModelId
+ )
+ : isAgentModel === undefined
+ ? null
+ : withAgentAutoModelFlag(null, isAgentModel);
+
return await db
.insert(aiModel)
.values({
@@ -1484,10 +1497,7 @@ export async function createAiModel({
fallbackModelId1: fallbackModelId1 || null,
fallbackModelId2: fallbackModelId2 || null,
imageGenConfig: imageGenConfig || null,
- defaultParams:
- isAgentModel === undefined
- ? null
- : withAgentAutoModelFlag(null, isAgentModel),
+ defaultParams,
capabilities:
isMultimodal === undefined
? null
@@ -1520,6 +1530,7 @@ export async function updateAiModel({
imageGenConfig,
isAgentModel,
isMultimodal,
+ agentCodingModelId,
}: {
id: string;
providerId?: string;
@@ -1538,6 +1549,7 @@ export async function updateAiModel({
imageGenConfig?: ImageGenConfig;
isAgentModel?: boolean;
isMultimodal?: boolean;
+ agentCodingModelId?: string;
}): Promise {
try {
// 如果设置为默认,先取消其他默认
@@ -1566,7 +1578,11 @@ export async function updateAiModel({
if (fallbackModelId2 !== undefined) updateData.fallbackModelId2 = fallbackModelId2 || null;
// 🆕 图片生成配置
if (imageGenConfig !== undefined) updateData.imageGenConfig = imageGenConfig || null;
- if (isAgentModel !== undefined || isMultimodal !== undefined) {
+ if (
+ isAgentModel !== undefined ||
+ isMultimodal !== undefined ||
+ agentCodingModelId !== undefined
+ ) {
const [existingModel] = await db
.select({
defaultParams: aiModel.defaultParams,
@@ -1580,12 +1596,22 @@ export async function updateAiModel({
return [];
}
+ let nextDefaultParams = existingModel.defaultParams;
if (isAgentModel !== undefined) {
- updateData.defaultParams = withAgentAutoModelFlag(
- existingModel.defaultParams,
+ nextDefaultParams = withAgentAutoModelFlag(
+ nextDefaultParams,
isAgentModel
);
}
+ if (agentCodingModelId !== undefined) {
+ nextDefaultParams = withAgentCodingModelId(
+ nextDefaultParams,
+ agentCodingModelId
+ );
+ }
+ if (isAgentModel !== undefined || agentCodingModelId !== undefined) {
+ updateData.defaultParams = nextDefaultParams;
+ }
if (isMultimodal !== undefined) {
updateData.capabilities = withMultimodalModelFlag(
diff --git a/lib/security/rate-limiter.ts b/lib/security/rate-limiter.ts
index 297488ea3..b557447e0 100644
--- a/lib/security/rate-limiter.ts
+++ b/lib/security/rate-limiter.ts
@@ -77,6 +77,9 @@ export const rateLimiters = {
// 通用API限制:每分钟100次请求
api: new RateLimiter(60000, 100),
+ // 高频只读接口(首页/画布轮询):每分钟1200次请求
+ uiPolling: new RateLimiter(60000, 1200),
+
// 画布保存接口:每分钟300次请求(含自动保存)
canvas: new RateLimiter(60000, 300),
diff --git a/lib/storage/service.ts b/lib/storage/service.ts
index 55b0b0116..1fac59d45 100644
--- a/lib/storage/service.ts
+++ b/lib/storage/service.ts
@@ -4,6 +4,17 @@ import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob";
import { getDefaultStorageConfig } from "@/lib/db/queries";
+type S3UploadConfig = {
+ endpoint: string;
+ region: string;
+ bucket: string;
+ accessKeyId: string;
+ secretAccessKey: string;
+ publicUrl?: string;
+};
+
+let lastKnownGoodDbS3Config: S3UploadConfig | null = null;
+
// 获取文件的 Content-Type
function getContentType(filename: string): string {
const ext = filename.toLowerCase().split(".").pop();
@@ -28,14 +39,7 @@ function getContentType(filename: string): string {
async function uploadToS3(
filename: string,
buffer: ArrayBuffer,
- config: {
- endpoint: string;
- region: string;
- bucket: string;
- accessKeyId: string;
- secretAccessKey: string;
- publicUrl?: string;
- }
+ config: S3UploadConfig
) {
const { endpoint, region, bucket, accessKeyId, secretAccessKey, publicUrl } = config;
@@ -101,6 +105,49 @@ async function uploadToS3(
}
}
+function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
+ const value = raw as Record | null | undefined;
+ if (
+ !value ||
+ value.type !== "s3" ||
+ !value.isEnabled ||
+ !value.endpoint ||
+ !value.bucket ||
+ !value.accessKeyId ||
+ !value.secretAccessKey
+ ) {
+ return null;
+ }
+
+ return {
+ endpoint: String(value.endpoint),
+ region: String(value.region || "auto"),
+ bucket: String(value.bucket),
+ accessKeyId: String(value.accessKeyId),
+ secretAccessKey: String(value.secretAccessKey),
+ publicUrl: value.publicUrl ? String(value.publicUrl) : undefined,
+ };
+}
+
+function buildEnvS3Config(): S3UploadConfig | null {
+ if (
+ process.env.S3_ENDPOINT &&
+ process.env.S3_BUCKET &&
+ process.env.S3_ACCESS_KEY_ID &&
+ process.env.S3_SECRET_ACCESS_KEY
+ ) {
+ return {
+ endpoint: process.env.S3_ENDPOINT,
+ region: process.env.S3_REGION ?? "auto",
+ bucket: process.env.S3_BUCKET,
+ accessKeyId: process.env.S3_ACCESS_KEY_ID,
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
+ publicUrl: process.env.S3_PUBLIC_URL,
+ };
+ }
+ return null;
+}
+
// 上传文件到存储服务
export async function uploadFile(
filename: string,
@@ -108,55 +155,41 @@ export async function uploadFile(
): Promise<{ url: string; pathname: string; contentType: string }> {
console.log(`[Upload] Starting upload for file: ${filename}`);
+ let dbS3Config: S3UploadConfig | null = null;
try {
- // 尝试从数据库获取存储配置
const storageConfig = await getDefaultStorageConfig();
-
- if (
- storageConfig &&
- storageConfig.type === "s3" &&
- storageConfig.endpoint &&
- storageConfig.bucket &&
- storageConfig.accessKeyId &&
- storageConfig.secretAccessKey &&
- storageConfig.isEnabled
- ) {
- console.log(`[Upload] Using database S3 config: ${storageConfig.name}`);
- console.log(`[Upload] Endpoint: ${storageConfig.endpoint}`);
- console.log(`[Upload] Bucket: ${storageConfig.bucket}`);
-
- // 使用数据库配置的 S3/MinIO 存储
- return await uploadToS3(filename, buffer, {
- endpoint: storageConfig.endpoint,
- region: storageConfig.region ?? "auto",
- bucket: storageConfig.bucket,
- accessKeyId: storageConfig.accessKeyId,
- secretAccessKey: storageConfig.secretAccessKey,
- publicUrl: storageConfig.publicUrl ?? undefined,
- });
- } else {
- console.warn("[Upload] No valid S3 config in database");
+ dbS3Config = toDbS3UploadConfig(storageConfig);
+ if (dbS3Config) {
+ console.log(`[Upload] Using database S3 config: ${storageConfig?.name}`);
+ console.log(`[Upload] Endpoint: ${dbS3Config.endpoint}`);
+ console.log(`[Upload] Bucket: ${dbS3Config.bucket}`);
+ const uploaded = await uploadToS3(filename, buffer, dbS3Config);
+ lastKnownGoodDbS3Config = dbS3Config;
+ return uploaded;
}
+ console.warn("[Upload] No valid S3 config in database");
} catch (error) {
- console.error("[Upload] Failed to get storage config from database:", error);
+ console.error("[Upload] Database storage path failed:", error);
+ if (lastKnownGoodDbS3Config) {
+ try {
+ console.log("[Upload] Retrying with last known good database S3 config");
+ return await uploadToS3(filename, buffer, lastKnownGoodDbS3Config);
+ } catch (retryError) {
+ console.error("[Upload] Last known S3 config upload failed:", retryError);
+ }
+ }
}
- // 回退到环境变量配置的 S3
- if (
- process.env.S3_ENDPOINT &&
- process.env.S3_BUCKET &&
- process.env.S3_ACCESS_KEY_ID &&
- process.env.S3_SECRET_ACCESS_KEY
- ) {
+ const envS3Config = buildEnvS3Config();
+ if (envS3Config) {
console.log("[Upload] Using environment variable S3 config");
- return await uploadToS3(filename, buffer, {
- endpoint: process.env.S3_ENDPOINT,
- region: process.env.S3_REGION ?? "auto",
- bucket: process.env.S3_BUCKET,
- accessKeyId: process.env.S3_ACCESS_KEY_ID,
- secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
- publicUrl: process.env.S3_PUBLIC_URL,
- });
+ return await uploadToS3(filename, buffer, envS3Config);
+ }
+
+ if (!process.env.BLOB_READ_WRITE_TOKEN) {
+ throw new Error(
+ "No available storage backend: database S3 failed, env S3 missing, and BLOB_READ_WRITE_TOKEN is not configured."
+ );
}
// 最终回退到 Vercel Blob
diff --git a/proxy.ts b/proxy.ts
index 894cc94cc..c4c9a0c58 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -4,6 +4,24 @@ import { isBlockedIP, getClientIP } from "@/lib/security/ip-blocklist";
import { checkRateLimit, rateLimiters } from "@/lib/security/rate-limiter";
import { isSuspiciousRequest, logSuspiciousRequest } from "@/lib/security/request-monitor";
+function isHighFrequencyReadApi(pathname: string, method: string) {
+ if (method !== "GET") return false;
+ if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true;
+
+ return [
+ "/api/models",
+ "/api/canvas",
+ "/api/nav-links",
+ "/api/site-settings",
+ "/api/messages",
+ "/api/history",
+ "/api/video-tasks",
+ "/api/tutorial-videos",
+ "/api/user/membership-summary",
+ "/api/user/account-center",
+ ].includes(pathname);
+}
+
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
@@ -67,6 +85,21 @@ export async function proxy(request: NextRequest) {
{ status: 429 }
);
}
+ } else if (isHighFrequencyReadApi(pathname, request.method)) {
+ const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling);
+ if (!pollingRateLimit.allowed) {
+ console.warn(`[Security] UI polling rate limit exceeded for IP: ${clientIP}`, {
+ pathname,
+ resetTime: new Date(pollingRateLimit.resetTime).toISOString(),
+ });
+ return NextResponse.json(
+ {
+ error: "Too many polling requests",
+ retryAfter: Math.ceil((pollingRateLimit.resetTime - Date.now()) / 1000),
+ },
+ { status: 429 }
+ );
+ }
} else if (pathname.startsWith("/api/")) {
const rateLimit = checkRateLimit(request, rateLimiters.api);
if (!rateLimit.allowed) {