From 160552b45e96db60a69f80eaf10b2fa6bebed408 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Tue, 2 Jun 2026 15:20:23 +0800 Subject: [PATCH 1/8] fix(ecommerce): 502 bug - vision model upgrade + MIME normalization + fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade VISION_MODEL to qwen3.7-plus (latest, confirmed working with image_url) - Add VISION_FALLBACK_MODEL = qwen-vl-plus for retry on "image format" errors - Normalize upload MIME types: unsupported formats (HEIC/AVIF) fall back to image/png to prevent server saving as .bin which DashScope can't read - Server-side: add image/avif, image/heic, image/heif to MIME_EXTENSIONS Root cause: DashScope returned "image format is illegal" when uploaded images had unrecognized MIME types → saved as .bin → DashScope couldn't decode. Co-Authored-By: Claude Opus 4.7 --- src/api/adVideoPlanClient.ts | 60 ++++++++++++++---------- src/features/ecommerce/EcommercePage.tsx | 4 +- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts index ae700ea..fc29983 100644 --- a/src/api/adVideoPlanClient.ts +++ b/src/api/adVideoPlanClient.ts @@ -1,7 +1,8 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; const TEXT_MODEL = "qwen-max"; -const VISION_MODEL = "qwen3.6-plus"; +const VISION_MODEL = "qwen3.7-plus"; +const VISION_FALLBACK_MODEL = "qwen-vl-plus"; export interface AdVideoUserConfig { platform: string; @@ -149,30 +150,39 @@ async function visionChat( ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })), { type: "text", text }, ]; - const timeoutSignal = AbortSignal.timeout(60000); - const combinedSignal = signal - ? AbortSignal.any([signal, timeoutSignal]) - : timeoutSignal; - const res = await fetch(buildApiUrl("ai/chat"), { - method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ - model: VISION_MODEL, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content }, - ], - stream: false, - temperature: 0.3, - }), - signal: combinedSignal, - }); - if (!res.ok) throw new Error(`图片理解调用失败 (${res.status})`); - const payload = await res.json(); - const out: string = - payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; - if (!out) throw new Error("图片理解未返回有效内容"); - return out; + const messages = [ + { role: "system", content: systemPrompt }, + { role: "user", content }, + ]; + + for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) { + const timeoutSignal = AbortSignal.timeout(60000); + const combinedSignal = signal + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal; + try { + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }), + signal: combinedSignal, + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + if (model === VISION_MODEL && errBody.includes("image format")) continue; + throw new Error(`图片理解调用失败 (${res.status})`); + } + const payload = await res.json(); + const out: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!out) throw new Error("图片理解未返回有效内容"); + return out; + } catch (err) { + if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue; + throw err; + } + } + throw new Error("图片理解调用失败,所有模型均不可用"); } const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 1f561ba..9774b8a 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -1321,18 +1321,20 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); const uploadProductImages = async (): Promise => { + const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); const urls: string[] = []; for (const item of productImages) { try { const resp = await fetch(item.src); const blob = await resp.blob(); + const mimeType = SUPPORTED_IMAGE_TYPES.has(blob.type) ? blob.type : "image/png"; const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); }); - const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType: blob.type }); + const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType }); urls.push(url); } catch { // skip images that fail to upload From 44c748b0dc7fc2e18589de2f90486ea40e6660e5 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Tue, 2 Jun 2026 16:03:50 +0800 Subject: [PATCH 2/8] feat(ecommerce): use FormData binary upload instead of base64 dataUrl - Add uploadAssetBinary method to aiGenerationClient (FormData + busboy) - Replace base64 dataUrl upload in uploadProductImages with direct blob upload via /oss/upload-binary multipart endpoint - This eliminates the DATA_URL_PATTERN regex parsing bug that produced 44-byte corrupt files on OSS, causing DashScope "image format illegal" errors Co-Authored-By: Claude Opus 4.7 --- src/api/aiGenerationClient.ts | 16 ++++++++++++++++ src/features/ecommerce/EcommercePage.tsx | 13 ++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index f580b5a..032ef9f 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -403,6 +403,22 @@ export const aiGenerationClient = { return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed"); }, + async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { + const form = new FormData(); + form.append("file", blob, options?.name || "upload.png"); + if (options?.scope) form.append("scope", options.scope); + if (options?.mimeType) form.append("mimeType", options.mimeType); + const res = await fetch(buildApiUrl("oss/upload-binary"), { + method: "POST", + headers: { ...buildAuthHeaders() }, + body: form, + }); + if (!res.ok) { + await throwResponseError(res, "Binary asset upload failed"); + } + return readJsonResponse<{ url: string; ossKey?: string }>(res, "Binary asset upload response failed"); + }, + async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { const res = await fetch(buildApiUrl("oss/upload-by-url"), { method: "POST", diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 9774b8a..3c500ba 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -1326,15 +1326,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { for (const item of productImages) { try { const resp = await fetch(item.src); - const blob = await resp.blob(); - const mimeType = SUPPORTED_IMAGE_TYPES.has(blob.type) ? blob.type : "image/png"; - const dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result)); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(blob); - }); - const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType }); + const rawBlob = await resp.blob(); + const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png"; + const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); + const { url } = await aiGenerationClient.uploadAssetBinary(blob, { name: item.name, mimeType, scope: "ecommerce-product" }); urls.push(url); } catch { // skip images that fail to upload From d1b5d64bc88831c2fbcb5b538b3711ad4b1ca66f Mon Sep 17 00:00:00 2001 From: ludan <251918489@qq.com> Date: Tue, 2 Jun 2026 16:04:26 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E5=89=A7?= =?UTF-8?q?=E6=9C=AC=E8=AF=84=E5=88=86=E7=B3=BB=E7=BB=9F=E4=B8=BA=20DeepSe?= =?UTF-8?q?ek=20V4=20=E5=A4=9A=E7=BB=B4=E8=AF=84=E5=88=86=E4=BD=93?= =?UTF-8?q?=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 变更内容: - scriptEvalClient: 替换系统提示词为 DeepSeek V4 完整版(六维评分细则、 评分类别、评分铁律、等级标准),维度满分调整为 hook 20/plot 20/ character 18/dialogue 15/visual 15/content 12,总分算法改为直接累加, 模型使用 qwen3.7-max,增加请求链路 console 日志 - ScriptTokensPage: 同步维度满分和描述(角色塑造 18、内容深度 12), 增加页面挂载和评测流程的 console 日志 - vite.config: esbuild.drop 移除 "console",保留 "debugger", 确保开发环境 console 日志正常输出 --- src/api/scriptEvalClient.ts | 67 ++++++++++++------- .../script-tokens/ScriptTokensPage.tsx | 30 +++++++-- vite.config.ts | 2 +- 3 files changed, 68 insertions(+), 31 deletions(-) diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts index fe64beb..e930e79 100644 --- a/src/api/scriptEvalClient.ts +++ b/src/api/scriptEvalClient.ts @@ -10,39 +10,50 @@ export interface ScriptEvalResult { suggestions: string[]; } -const EVAL_SYSTEM_PROMPT = `你是一位专业的剧本评测专家。请对用户提供的剧本进行六维评分分析,并以严格的 JSON 格式返回结果。 +const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 -六个评分维度: -1. hook(钩子设计,满分20):开篇吸引力、悬念设置、黄金三秒法则 -2. character(角色塑造,满分15):人物立体度、动机合理性、弧光设计 -3. plot(剧情结构,满分20):起承转合、节奏把控、冲突设计 -4. dialogue(台词对白,满分15):语言质感、角色差异化、潜台词 -5. visual(画面表现,满分15):镜头感、空间层次、视觉冲击力 -6. content(内容深度,满分15):主题表达、情感共鸣、思想内核 +【剧本类型识别】 +收到剧本后,首先判断类型:AIGC短剧/漫剧(单集5-30分钟,竖屏平台,高密度反转、强节奏)或传统影视剧本(单集40分钟以上,长视频平台,完整起承转合)。类型判定将影响各维度的评价侧重点。 -请严格按以下 JSON 格式返回(不要包含任何其他文字): +【评分体系(100分制,六个维度)】 +1. hook 钩子设计(20分):开篇钩子、集末钩子、场景内钩子、悬念链完整性。短剧前3秒须有即时爆点;长剧第一幕结束前须建立核心悬念。 +2. plot 剧情结构(20分):结构框架、节奏控制、冲突设计、逻辑自洽。短剧"每分钟有事件",反转密度加分;长剧需处理好B线C线与主线交织。 +3. character 角色塑造(18分):主角弧光、角色辨识度、角色动机、配角质量。短剧角色须在前2分钟建立;长剧需要内在矛盾和多阶段成长。 +4. dialogue 台词对白(15分):角色语言区分度、信息传递效率、潜台词与留白、金句与记忆点。 +5. visual 画面表现(15分):场景描写质量、视觉叙事技巧、镜头感与节奏、制作可行性。AIGC需考虑AI生成技术边界与一致性。 +6. content 内容深度(12分):主题表达、情感共鸣、社会/人性洞察。 + +【评分铁律】 +- 扣分必须明确指出剧本中的具体段落/场景/台词。 +- 严禁给出任何维度的满分,必须有扣分理由。 +- 优缺点都要充分展开,不可只批不夸或只夸不批。 +- 不因题材类型偏见降低评分,不因某一方面出色而抬高其他维度(避免光环效应)。 +- 敢于拉开各维度分数差距,避免全部给中等分数。 + +【等级标准】按总分百分比:S≥90 | A 80-89 | B 70-79 | C 60-69 | D<60。 + +请严格按以下 JSON 格式返回(不要包含任何其他文字,不要用代码块包裹以外的说明): { - "dimensionScores": { "hook": 数字, "character": 数字, "plot": 数字, "dialogue": 数字, "visual": 数字, "content": 数字 }, - "summary": "一句话总结评价", - "issues": ["问题1", "问题2", ...], - "highlights": ["亮点1", "亮点2", ...], - "suggestions": ["建议1", "建议2", ...] + "dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "dialogue": 数字, "visual": 数字, "content": 数字 }, + "summary": "200-300字综合评价,概括整体质量、市场潜力与目标受众匹配度", + "issues": ["每条指出具体维度的扣分点并引用剧本原文位置", ...], + "highlights": ["核心亮点,引用剧本具体场景", ...], + "suggestions": ["按优先级排列的改进建议(最优先/次优先/可优化)", ...] }`; -const DIMENSION_WEIGHTS: Record = { - hook: { maxScore: 20, weight: 0.2 }, - character: { maxScore: 15, weight: 0.15 }, - plot: { maxScore: 20, weight: 0.2 }, - dialogue: { maxScore: 15, weight: 0.15 }, - visual: { maxScore: 15, weight: 0.15 }, - content: { maxScore: 15, weight: 0.15 }, +const DIMENSION_WEIGHTS: Record = { + hook: { maxScore: 20 }, + plot: { maxScore: 20 }, + character: { maxScore: 18 }, + dialogue: { maxScore: 15 }, + visual: { maxScore: 15 }, + content: { maxScore: 12 }, }; function computeTotalAndGrade(scores: Record): { totalScore: number; grade: string } { const totalScore = Math.round( Object.entries(DIMENSION_WEIGHTS).reduce((sum, [key, dim]) => { - const score = Math.max(0, Math.min(dim.maxScore, scores[key] ?? 0)); - return sum + (score / dim.maxScore) * 100 * dim.weight; + return sum + Math.max(0, Math.min(dim.maxScore, scores[key] ?? 0)); }, 0), ); const grade = totalScore >= 90 ? "S" : totalScore >= 80 ? "A" : totalScore >= 70 ? "B" : totalScore >= 60 ? "C" : "D"; @@ -56,6 +67,7 @@ function extractJson(text: string): unknown { } export async function evaluateScript(script: string, signal?: AbortSignal): Promise { + console.log("[API] 发送评测请求,剧本长度:", script.slice(0, 8000).length, "字符"); const res = await fetch(buildApiUrl("ai/chat"), { method: "POST", headers: buildAuthHeaders(), @@ -71,11 +83,15 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom signal, }); + console.log("[API] 响应状态:", res.status, res.statusText); + if (!res.ok) { throw new Error(`评测请求失败 (${res.status})`); } const payload = await res.json(); + console.log("[API] 原始响应体:", payload); + const content: string = payload?.choices?.[0]?.message?.content ?? payload?.result?.content ?? payload?.content @@ -84,7 +100,11 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom if (!content) throw new Error("模型未返回有效内容"); + console.log("[API] 模型返回内容 (前500字符):", content.slice(0, 500)); + const parsed = extractJson(content) as Record; + console.log("[API] 解析后的JSON:", parsed); + const dimensionScores: Record = {}; const rawScores = parsed.dimensionScores as Record | undefined; if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); @@ -95,6 +115,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom } const { totalScore, grade } = computeTotalAndGrade(dimensionScores); + console.log("[API] 计算后总分:", totalScore, "等级:", grade); return { totalScore, diff --git a/src/features/script-tokens/ScriptTokensPage.tsx b/src/features/script-tokens/ScriptTokensPage.tsx index 9e43d2a..2525e16 100644 --- a/src/features/script-tokens/ScriptTokensPage.tsx +++ b/src/features/script-tokens/ScriptTokensPage.tsx @@ -1,5 +1,5 @@ import { CopyOutlined, DownOutlined, DownloadOutlined, FileTextOutlined, ReloadOutlined, TrophyOutlined, UploadOutlined } from "@ant-design/icons"; -import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; import { evaluateScript } from "../../api/scriptEvalClient"; interface ScoreDimension { @@ -35,9 +35,9 @@ const scoreDimensions: ScoreDimension[] = [ { key: "character", label: "角色塑造", - maxScore: 15, - weight: 0.15, - description: "人物立体度、动机合理性、弧光设计", + maxScore: 18, + weight: 0.18, + description: "主角弧光、角色辨识度、动机、配角质量", }, { key: "plot", @@ -63,9 +63,9 @@ const scoreDimensions: ScoreDimension[] = [ { key: "content", label: "内容深度", - maxScore: 15, - weight: 0.15, - description: "主题表达、情感共鸣、思想内核", + maxScore: 12, + weight: 0.12, + description: "主题表达、情感共鸣、社会/人性洞察", }, ]; @@ -170,6 +170,10 @@ function ScriptTokensPage() { const [copied, setCopied] = useState(false); const fileInputRef = useRef(null); + useEffect(() => { + console.log("[剧本评分] 页面已加载,ScriptTokensPage mounted"); + }, []); + const hasContent = Boolean(script.trim()); const lineNumbers = useMemo(() => { const count = Math.min(160, Math.max(10, script.split(/\r\n|\r|\n/).length)); @@ -202,14 +206,26 @@ function ScriptTokensPage() { }; const handleEvaluate = async () => { + console.log("[剧本评测] 点击开始评测,hasContent:", hasContent, "script长度:", script.length); if (!hasContent) return; setLoading(true); setResult(null); setEvalError(null); try { + console.log("[剧本评测] 开始评测,剧本长度:", script.length, "字符"); const aiResult = await evaluateScript(script); + console.log("[剧本评测] 评测完成,结果:", { + 总分: aiResult.totalScore, + 等级: aiResult.grade, + 维度得分: aiResult.dimensionScores, + 摘要: aiResult.summary, + 亮点: aiResult.highlights, + 问题: aiResult.issues, + 建议: aiResult.suggestions, + }); setResult(aiResult); } catch (err) { + console.error("[剧本评测] 评测失败:", err); setEvalError(err instanceof Error ? err.message : "评测服务暂时不可用,请稍后重试"); } setDetailsExpanded(true); diff --git a/vite.config.ts b/vite.config.ts index 9138051..2728dcf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ host: "127.0.0.1", }, esbuild: { - drop: ["console", "debugger"], + drop: ["debugger"], }, build: { sourcemap: "hidden", From 9504f8ee872dc9b01333338868d3bcde399f5f41 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Tue, 2 Jun 2026 16:16:09 +0800 Subject: [PATCH 4/8] fix(ecommerce): replace base64 upload with binary blob in video service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runVideoPlan was passing blob URLs as "dataUrl" to uploadAssetWithProgress, which sent them to /api/oss/upload (base64 path). Blob URLs don't match DATA_URL_PATTERN regex, causing corrupt 44-byte files on OSS. Now uses uploadAssetBinary (FormData multipart) via /api/oss/upload-binary, fetching blob → uploading binary directly, same as EcommercePage path. Co-Authored-By: Claude Opus 4.7 --- .../ecommerce/ecommerceVideoService.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/features/ecommerce/ecommerceVideoService.ts b/src/features/ecommerce/ecommerceVideoService.ts index f1f7639..5f92e86 100644 --- a/src/features/ecommerce/ecommerceVideoService.ts +++ b/src/features/ecommerce/ecommerceVideoService.ts @@ -9,7 +9,6 @@ import { type AdVideoUserConfig, } from "../../api/adVideoPlanClient"; import { aiGenerationClient } from "../../api/aiGenerationClient"; -import { uploadAssetWithProgress } from "../../api/uploadWithProgress"; import { waitForTask } from "../../api/taskSubscription"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import type { @@ -34,12 +33,18 @@ export async function runVideoPlan( onStepStart("upload"); const imageUrls: string[] = []; - for (const dataUrl of imageDataUrls) { - const result = await uploadAssetWithProgress( - { dataUrl, scope: "ecommerce-product", mimeType: "image/png" }, - { signal }, - ); - imageUrls.push(result.url); + const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); + for (const srcUrl of imageDataUrls) { + try { + const resp = await fetch(srcUrl); + const rawBlob = await resp.blob(); + const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png"; + const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); + const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" }); + imageUrls.push(result.url); + } catch { + // skip images that fail to upload + } } onStepDone("upload"); From 94c1453c9bc4670d5351f687999f7f8f0c288bb0 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Tue, 2 Jun 2026 16:58:59 +0800 Subject: [PATCH 5/8] fix: upload-binary Content-Type fix, 429/timeout retry, 120s timeout - Remove Content-Type: application/json from uploadAssetBinary FormData request - Add retryOnTransient for 429 + timeout + signal timed out errors - Increase AI chat timeout from 60s to 120s per call - Apply retry logic to both chat() and visionChat() --- src/api/adVideoPlanClient.ts | 113 ++++++++++++++++++++++------------ src/api/aiGenerationClient.ts | 4 +- 2 files changed, 75 insertions(+), 42 deletions(-) diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts index fc29983..2bae189 100644 --- a/src/api/adVideoPlanClient.ts +++ b/src/api/adVideoPlanClient.ts @@ -108,36 +108,63 @@ interface ChatMessage { content: string; } +const MAX_RETRIES = 3; +const RETRY_BASE_MS = 2000; +const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call + +function isTransientError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + return /\b429\b/.test(msg) || msg.includes("signal timed out") || msg.includes("aborted") || msg.includes("timeout"); +} + +async function retryOnTransient(fn: () => Promise, signal?: AbortSignal): Promise { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await fn(); + } catch (err) { + if (signal?.aborted) throw err; + if (attempt === MAX_RETRIES) throw err; + if (!isTransientError(err)) throw err; + const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000; + await new Promise((r) => setTimeout(r, delay)); + } + } + throw new Error("unreachable"); +} + async function chat( systemPrompt: string, userContent: string, options?: { model?: string; signal?: AbortSignal }, ): Promise { - const messages: ChatMessage[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: userContent }, - ]; - const timeoutSignal = AbortSignal.timeout(60000); - const combinedSignal = options?.signal - ? AbortSignal.any([options.signal, timeoutSignal]) - : timeoutSignal; - const res = await fetch(buildApiUrl("ai/chat"), { - method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ - model: options?.model ?? TEXT_MODEL, - messages, - stream: false, - temperature: 0.4, - }), - signal: combinedSignal, - }); - if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`); - const payload = await res.json(); - const content: string = - payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; - if (!content) throw new Error("模型未返回有效内容"); - return content; + return retryOnTransient(async () => { + const messages: ChatMessage[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: userContent }, + ]; + const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); + const combinedSignal = options?.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ + model: options?.model ?? TEXT_MODEL, + messages, + stream: false, + temperature: 0.4, + }), + signal: combinedSignal, + }); + if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`); + const payload = await res.json(); + const content: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!content) throw new Error("模型未返回有效内容"); + return content; + }, options?.signal); } async function visionChat( @@ -156,28 +183,32 @@ async function visionChat( ]; for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) { - const timeoutSignal = AbortSignal.timeout(60000); + const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; try { - const res = await fetch(buildApiUrl("ai/chat"), { - method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }), - signal: combinedSignal, - }); - if (!res.ok) { - const errBody = await res.text().catch(() => ""); - if (model === VISION_MODEL && errBody.includes("image format")) continue; - throw new Error(`图片理解调用失败 (${res.status})`); - } - const payload = await res.json(); - const out: string = - payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; - if (!out) throw new Error("图片理解未返回有效内容"); + const out = await retryOnTransient(async () => { + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }), + signal: combinedSignal, + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK"); + throw new Error(`图片理解调用失败 (${res.status})`); + } + const payload = await res.json(); + const result: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!result) throw new Error("图片理解未返回有效内容"); + return result; + }, signal); return out; } catch (err) { + if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue; if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue; throw err; } diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index 032ef9f..1335847 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -408,9 +408,11 @@ export const aiGenerationClient = { form.append("file", blob, options?.name || "upload.png"); if (options?.scope) form.append("scope", options.scope); if (options?.mimeType) form.append("mimeType", options.mimeType); + // Exclude Content-Type so browser auto-sets multipart/form-data with boundary + const { "Content-Type": _ct, ...authHeaders } = buildAuthHeaders(); const res = await fetch(buildApiUrl("oss/upload-binary"), { method: "POST", - headers: { ...buildAuthHeaders() }, + headers: authHeaders, body: form, }); if (!res.ok) { From 93a538d51d1b7f72049ade6b0f27b55b0a7a47b9 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Tue, 2 Jun 2026 17:37:51 +0800 Subject: [PATCH 6/8] feat: UI animation enhancements across all major pages P1 - Critical UX feedback: - Add scale-in + slide-up-in entrance animations to profile popover and notification panel - Port SmoothedProgressBar to EcommercePage (4 generation tools: clone, detail, tryOn, productSet) - Add result-reveal stagger animation to ecommerce result grids - Add heart-pop spring animation to CommunityPage favorite toggle P2 - Visual polish: - Add scroll-entrance IntersectionObserver animations for HomePage feature sections and experience section - Add chat-message-in entrance animation to WorkbenchPage message rows - Fix prefers-reduced-motion accessibility in WelcomeSplash canvas (skip animation, instant entry) P3 - CSS consolidation: - Remove conflicting .page-motion definition from legacy-pages.css (keep translateY version from legacy-components.css) - Consolidate skeleton-shimmer: remove opacity-pulse keyframe from primitives.css, unify with gradient sweep - Wire up --ease-spring token for heart-pop animation - Add :active press states (scale 0.97) to topbar buttons, brand lockup Co-Authored-By: Claude Opus 4.7 --- src/features/community/CommunityPage.tsx | 3 +- src/features/ecommerce/EcommercePage.tsx | 9 ++- .../ecommerce/EcommerceProgressBar.tsx | 30 ++++++++ src/features/home/HomePage.tsx | 18 +++-- src/features/home/WelcomeSplash.tsx | 20 +++++- src/features/workbench/WorkbenchPage.tsx | 2 +- src/hooks/useScrollEntrance.ts | 31 ++++++++ src/styles/components/legacy-components.css | 3 + src/styles/components/motion.css | 72 +++++++++++++++++++ src/styles/components/primitives.css | 14 ++-- src/styles/pages/ecommerce.css | 42 +++++++++++ src/styles/pages/legacy-pages.css | 15 +--- src/styles/shell/app-shell.css | 14 ++++ 13 files changed, 242 insertions(+), 31 deletions(-) create mode 100644 src/features/ecommerce/EcommerceProgressBar.tsx create mode 100644 src/hooks/useScrollEntrance.ts diff --git a/src/features/community/CommunityPage.tsx b/src/features/community/CommunityPage.tsx index 2d7d219..0356e98 100644 --- a/src/features/community/CommunityPage.tsx +++ b/src/features/community/CommunityPage.tsx @@ -477,8 +477,9 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject