diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..44008b1 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Dev proxy target — the backend API server +VITE_DEV_PROXY=http://47.110.225.76:3600 + +# Key server URL for auth/profile endpoints +VITE_KEY_SERVER_URL= + +# Main API base URL (used when not served from omniai.net.cn) +VITE_API_BASE_URL= \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 318a603..7198f5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -357,7 +357,7 @@ function App() { canvasAutoOpenedRecentRef.current = false; setWorkspaceExpanded(false); if (options?.resetView) { - handleSetView("workbench"); + handleSetView("login"); } }, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]); @@ -492,7 +492,7 @@ function App() { if (nextSession) { setSession(nextSession); } else { - clearAuthenticatedState(); + clearAuthenticatedState({ resetView: true }); } } finally { checking = false; diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts index ae700ea..2bae189 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; @@ -107,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( @@ -149,30 +177,43 @@ 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(CHAT_TIMEOUT_MS); + const combinedSignal = signal + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal; + try { + 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; + } + } + throw new Error("图片理解调用失败,所有模型均不可用"); } const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index f580b5a..1335847 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -403,6 +403,24 @@ 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); + // 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: authHeaders, + 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/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/components/AnimatedPanel.tsx b/src/components/AnimatedPanel.tsx new file mode 100644 index 0000000..53e0df8 --- /dev/null +++ b/src/components/AnimatedPanel.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +interface AnimatedPanelProps { + open: boolean; + children: ReactNode; + className?: string; + /** Duration in ms for the exit animation before unmounting. */ + exitDuration?: number; +} + +export function AnimatedPanel({ open, children, className, exitDuration = 140 }: AnimatedPanelProps) { + const [mounted, setMounted] = useState(open); + const [visible, setVisible] = useState(open); + const timerRef = useRef(null); + + useEffect(() => { + if (open) { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + setMounted(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setVisible(true); + }); + }); + } else { + setVisible(false); + timerRef.current = window.setTimeout(() => { + setMounted(false); + timerRef.current = null; + }, exitDuration); + } + }, [open, exitDuration]); + + useEffect(() => { + return () => { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + }; + }, []); + + if (!mounted) return null; + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index b1bdc16..6178646 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -16,6 +16,7 @@ import { canManageCommunityCases, canReviewCommunity } from "../features/communi import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types"; import NotificationCenter from "./NotificationCenter"; import { RechargeModal } from "./RechargeModal/RechargeModal"; +import { AnimatedPanel } from "./AnimatedPanel"; interface AppShellProps { activeView: WebViewKey; @@ -61,6 +62,8 @@ function AppShell({ const [profileOpen, setProfileOpen] = useState(false); const [rechargeOpen, setRechargeOpen] = useState(false); const [openSubmenuKey, setOpenSubmenuKey] = useState(null); + const prevActiveViewRef = useRef(activeView); + const [navJustActivated, setNavJustActivated] = useState(null); const isAuthView = activeView === "login"; const isImmersiveView = activeView === "agent" || activeView === "avatarConsole"; const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home"; @@ -100,6 +103,15 @@ function AppShell({ [navItems], ); + useEffect(() => { + if (activeView !== prevActiveViewRef.current) { + setNavJustActivated(activeView); + prevActiveViewRef.current = activeView; + const timer = window.setTimeout(() => setNavJustActivated(null), 320); + return () => window.clearTimeout(timer); + } + }, [activeView]); + useEffect(() => { if (typeof document === "undefined") { return; @@ -223,8 +235,8 @@ function AppShell({ - {session && profileOpen ? ( -
+
{avatarUrl ? {displayName} : avatarLabel} @@ -410,8 +421,7 @@ function AppShell({ ) : null} -
- ) : null} +
diff --git a/src/components/NotificationCenter.tsx b/src/components/NotificationCenter.tsx index db9b9a0..586b9f2 100644 --- a/src/components/NotificationCenter.tsx +++ b/src/components/NotificationCenter.tsx @@ -10,6 +10,7 @@ import { } from "@ant-design/icons"; import { useEffect, useRef, useState } from "react"; import type { WebNotification, WebNotificationType, WebViewKey } from "../types"; +import { AnimatedPanel } from "./AnimatedPanel"; const NOTIFICATION_ICONS: Record = { task_completed: , @@ -115,8 +116,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl {unreadCount > 99 ? "99+" : unreadCount} )} - {open && ( -
+
通知中心
@@ -158,8 +158,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl )) )}
-
- )} +
); } diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx index b008505..514b017 100644 --- a/src/components/PageTransition.tsx +++ b/src/components/PageTransition.tsx @@ -7,9 +7,40 @@ interface PageTransitionProps { const EXIT_DURATION_MS = 180; +const NAV_ORDER: string[] = [ + "home", + "workbench", + "ecommerce", + "ecommerceTemplates", + "sizeTemplate", + "canvas", + "scriptTokens", + "tokenUsage", + "community", + "assets", + "more", + "imageWorkbench", + "resolutionUpscale", + "watermarkRemoval", + "subtitleRemoval", + "digitalHuman", + "avatarConsole", + "characterMix", + "agent", + "settings", + "login", + "profile", + "report", +]; + +function getNavIndex(key: string): number { + return NAV_ORDER.indexOf(key); +} + export default function PageTransition({ viewKey, children }: PageTransitionProps) { const [displayedChildren, setDisplayedChildren] = useState(children); const [phase, setPhase] = useState<"idle" | "exit">("idle"); + const [direction, setDirection] = useState<"forward" | "backward" | "neutral">("neutral"); const prevKeyRef = useRef(viewKey); const timerRef = useRef>(); @@ -18,6 +49,15 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp setDisplayedChildren(children); return; } + const prevIndex = getNavIndex(prevKeyRef.current); + const nextIndex = getNavIndex(viewKey); + if (prevIndex < nextIndex) { + setDirection("forward"); + } else if (prevIndex > nextIndex) { + setDirection("backward"); + } else { + setDirection("neutral"); + } prevKeyRef.current = viewKey; setPhase("exit"); timerRef.current = setTimeout(() => { @@ -27,8 +67,10 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp return () => clearTimeout(timerRef.current); }, [viewKey, children]); + const dirClass = direction === "forward" ? " is-forward" : direction === "backward" ? " is-backward" : ""; + return ( -
+
{displayedChildren}
); 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
+ {detailStatus === "generating" ? : null}