diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index 4bd4d31..accb697 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -134,6 +134,12 @@ export interface ChatInput { temperature?: number; } +export interface ChatUsage { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; +} + export interface AiTaskStatus { taskId: string; projectId?: string; @@ -159,7 +165,7 @@ function normalizeTaskStatus(status: AiTaskStatus["status"]): WebGenerationPrevi function taskTitle(task: AiTaskStatus): string { const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : ""; if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt; - return task.type === "video" ? "视频生成任务" : "图像生成任务"; + return task.type === "video" ? "\u89c6\u9891\u751f\u6210\u4efb\u52a1" : "\u56fe\u50cf\u751f\u6210\u4efb\u52a1"; } function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask { @@ -500,6 +506,7 @@ export const aiGenerationClient = { input: ChatInput, onChunk: (text: string) => void, signal?: AbortSignal, + onUsage?: (usage: ChatUsage) => void, ): Promise { const res = await fetch(buildApiUrl("ai/chat"), { method: "POST", @@ -512,7 +519,7 @@ export const aiGenerationClient = { } const reader = res.body?.getReader(); - if (!reader) throw new Error("无法读取响应流"); + if (!reader) throw new Error("\u65e0\u6cd5\u8bfb\u53d6\u54cd\u5e94\u6d41"); const decoder = new TextDecoder(); let buffer = ""; @@ -529,8 +536,24 @@ export const aiGenerationClient = { const payload = line.slice(6).trim(); if (!payload) continue; try { - const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string }; + const chunk = JSON.parse(payload) as { + delta?: string; + done?: boolean; + error?: string; + usage?: ChatUsage & { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + }; if (chunk.error) throw new Error(chunk.error); + if (chunk.usage) { + onUsage?.({ + promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens, + completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens, + totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens, + }); + } if (chunk.delta) onChunk(chunk.delta); if (chunk.done) return; } catch (e) { diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts index 9a8f836..d593541 100644 --- a/src/api/scriptEvalClient.ts +++ b/src/api/scriptEvalClient.ts @@ -4,6 +4,8 @@ export interface ScriptEvalResult { totalScore: number; grade: string; dimensionScores: Record; + subScores?: Record>; + evidence?: Record; summary: string; issues: string[]; highlights: string[]; @@ -12,6 +14,33 @@ export interface ScriptEvalResult { const MODEL = "qwen3.7-max"; +const EVAL_OUTPUT_CONTRACT = ` +强制输出 JSON,主维度键名必须严格为: +hook(20), plot(20), character(15), logic(15), visual(15), content(15)。 +不要把 dialogue 作为主维度返回;台词对白作为 character/plot/content 的证据和子项分析。 + +同时返回 subScores 和 evidence: +- subScores:每个主维度 3-5 个细分参数,分值按该维度满分拆分。 +- evidence:每个主维度 1-3 条具体证据,必须指向场景、台词、设定、冲突或段落。 + +返回结构: +{ + "dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "logic": 数字, "visual": 数字, "content": 数字 }, + "subScores": { + "hook": { "openingImpact": 数字, "suspenseChain": 数字, "sceneHook": 数字 }, + "plot": { "structure": 数字, "rhythm": 数字, "conflict": 数字, "reversal": 数字 }, + "character": { "motivation": 数字, "arc": 数字, "voice": 数字, "relationship": 数字 }, + "logic": { "causality": 数字, "worldRules": 数字, "foreshadowing": 数字, "continuity": 数字 }, + "visual": { "sceneDetail": 数字, "shotPotential": 数字, "aigcFeasibility": 数字 }, + "content": { "theme": 数字, "emotion": 数字, "marketFit": 数字, "originality": 数字 } + }, + "evidence": { "hook": ["..."], "plot": ["..."], "character": ["..."], "logic": ["..."], "visual": ["..."], "content": ["..."] }, + "summary": "200-300字综合评价", + "issues": ["具体扣分点,带维度和证据", ...], + "highlights": ["具体亮点,带维度和证据", ...], + "suggestions": ["按优先级排列的改稿建议", ...] +}`; + const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 【剧本类型识别】 @@ -46,10 +75,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有 const DIMENSION_WEIGHTS: Record = { hook: { maxScore: 20 }, plot: { maxScore: 20 }, - character: { maxScore: 18 }, - dialogue: { maxScore: 15 }, + character: { maxScore: 15 }, + logic: { maxScore: 15 }, visual: { maxScore: 15 }, - content: { maxScore: 12 }, + content: { maxScore: 15 }, }; function computeTotalAndGrade(scores: Record): { totalScore: number; grade: string } { @@ -68,6 +97,48 @@ function extractJson(text: string): unknown { return JSON.parse(raw); } +function normalizeScoreValue(value: unknown, maxScore: number): number { + const score = Number(value); + if (!Number.isFinite(score)) return 0; + return Math.max(0, Math.min(maxScore, Math.round(score * 10) / 10)); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function normalizeNestedScores(value: unknown): Record> { + if (!isRecord(value)) return {}; + + const normalized: Record> = {}; + for (const [dimensionKey, dimension] of Object.entries(DIMENSION_WEIGHTS)) { + const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined); + if (!isRecord(source)) continue; + + const entries = Object.entries(source) + .map(([key, score]) => [key, normalizeScoreValue(score, dimension.maxScore)] as const) + .filter(([, score]) => score > 0); + if (entries.length > 0) normalized[dimensionKey] = Object.fromEntries(entries); + } + + return normalized; +} + +function normalizeEvidence(value: unknown): Record { + if (!isRecord(value)) return {}; + + const normalized: Record = {}; + for (const dimensionKey of Object.keys(DIMENSION_WEIGHTS)) { + const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined); + if (!Array.isArray(source)) continue; + + const items = source.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3); + if (items.length > 0) normalized[dimensionKey] = items; + } + + return normalized; +} + export async function evaluateScript(script: string, signal?: AbortSignal): Promise { const res = await fetch(buildApiUrl("ai/chat"), { method: "POST", @@ -76,6 +147,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom model: MODEL, messages: [ { role: "system", content: EVAL_SYSTEM_PROMPT }, + { role: "system", content: EVAL_OUTPUT_CONTRACT }, { role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` }, ], stream: false, @@ -101,8 +173,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); for (const key of Object.keys(DIMENSION_WEIGHTS)) { - const val = Number(rawScores[key] ?? 0); - dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val)); + const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key]; + dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore); } const { totalScore, grade } = computeTotalAndGrade(dimensionScores); @@ -111,6 +183,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom totalScore, grade, dimensionScores, + subScores: normalizeNestedScores(parsed.subScores), + evidence: normalizeEvidence(parsed.evidence), summary: String(parsed.summary || ""), issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [], highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [], diff --git a/src/api/taskSubscription.ts b/src/api/taskSubscription.ts index 7c1fa33..ae702ec 100644 --- a/src/api/taskSubscription.ts +++ b/src/api/taskSubscription.ts @@ -1,4 +1,9 @@ import { aiGenerationClient } from "./aiGenerationClient"; +import { + buildLocalTimeoutMessage, + getTaskTimeoutPolicy, + isTaskLocallyTimedOut, +} from "../utils/taskLifecycle"; export interface TaskProgressEvent { taskId: string; @@ -12,16 +17,28 @@ export interface WaitForTaskOptions { onProgress?: (event: TaskProgressEvent) => void; abortRef?: { current: boolean }; timeoutMs?: number; + noProgressTimeoutMs?: number; + startedAt?: number; + kind?: "image" | "video" | "text"; + model?: string | null; + operation?: string | null; } const POLL_INTERVAL = 3000; -const DEFAULT_TIMEOUT = 30 * 60 * 1000; export function waitForTask( taskId: string, options: WaitForTaskOptions = {}, ): Promise { - const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options; + const { onProgress, abortRef } = options; + const timeoutPolicy = getTaskTimeoutPolicy({ + kind: options.kind, + model: options.model, + operation: options.operation, + }); + const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs; + const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs; + const startedAt = options.startedAt ?? Date.now(); return new Promise((resolve, reject) => { let settled = false; @@ -29,6 +46,8 @@ export function waitForTask( let timeoutId: ReturnType | null = null; let sseConnected = false; let fallbackTimerId: ReturnType | null = null; + let lastProgress = 0; + let lastProgressAt = startedAt; const settle = (fn: () => void) => { if (settled) return; @@ -40,7 +59,7 @@ export function waitForTask( }; timeoutId = setTimeout( - () => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))), + () => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))), timeoutMs, ); @@ -50,19 +69,22 @@ export function waitForTask( settle(() => resolve(null)); return; } + const progress = Number(event.progress || 0); + if (progress > lastProgress || event.status === "completed") { + lastProgress = Math.max(lastProgress, progress); + lastProgressAt = Date.now(); + } onProgress?.(event); if (event.status === "completed") { settle(() => resolve(event.resultUrl || null)); } else if (event.status === "failed" || event.status === "cancelled") { - settle(() => reject(new Error(event.error || "任务失败"))); + settle(() => reject(new Error(event.error || "任务失败,请稍后重试"))); } }; - // Try SSE first cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate); sseConnected = true; - // Fallback: if SSE doesn't deliver any event within 5s, switch to polling fallbackTimerId = setTimeout(() => { if (settled || !sseConnected) return; if (cleanup) cleanup(); @@ -72,9 +94,22 @@ export function waitForTask( function startPolling() { const poll = async () => { while (!settled) { - if (abortRef?.current) { settle(() => resolve(null)); return; } + if (abortRef?.current) { + settle(() => resolve(null)); + return; + } await new Promise((r) => setTimeout(r, POLL_INTERVAL)); if (settled || abortRef?.current) return; + const timeoutReason = isTaskLocallyTimedOut({ + startedAt, + lastProgressAt, + progress: lastProgress, + policy: { ...timeoutPolicy, noProgressTimeoutMs }, + }); + if (timeoutReason) { + settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))); + return; + } try { const task = await aiGenerationClient.getTaskStatus(taskId); handleUpdate({ @@ -89,7 +124,7 @@ export function waitForTask( } } }; - poll(); + void poll(); } }); } diff --git a/src/api/webGenerationGateway.ts b/src/api/webGenerationGateway.ts index 2944a15..16ae3e7 100644 --- a/src/api/webGenerationGateway.ts +++ b/src/api/webGenerationGateway.ts @@ -103,7 +103,7 @@ export const webGenerationGateway = { prompt, createdAt, source: "server", - errorMessage: err instanceof Error ? err.message : "请求失败", + errorMessage: err instanceof Error ? err.message : "请求失败,请稍后重试", }; } }, diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index ce0a772..4bb75c1 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -13,6 +13,7 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient"; +import { toast } from "./toast/toastStore"; import type { ServerConnectionHealth } from "../api/serverConnection"; import { ossAssets } from "../data/ossAssets"; import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions"; @@ -74,7 +75,7 @@ function AppShell({ const [navJustActivated, setNavJustActivated] = useState(null); const isAuthView = activeView === "login"; const isImmersiveView = activeView === "agent" || activeView === "avatarConsole"; - const showFloatingNav = (!isAuthView || !!session) && !isImmersiveView && activeView !== "home"; + const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home"; const toolSurfaceViews = [ "workbench", "canvas", @@ -370,7 +371,7 @@ function AppShell({ className="member-button" type="button" aria-label={`积分余额 ${displayedBalanceLabel}`} - onClick={() => setRechargeOpen(true)} + onClick={() => toast.info("充值功能即将开放,敬请期待")} > {displayedBalanceLabel} diff --git a/src/data/ossAssets.ts b/src/data/ossAssets.ts index 221e4db..14136d8 100644 --- a/src/data/ossAssets.ts +++ b/src/data/ossAssets.ts @@ -21,7 +21,7 @@ export const ossAssets = { }, home: { backgroundVideo: muban("hero-bg.mp4"), - heroSlides: [muban("hero-1.png"), muban("hero-2.png"), muban("hero-3.png")], + heroSlides: [oss("static/banners/light2_轮播1.jpg"), oss("static/banners/light2_轮播2.jpg"), oss("static/banners/light2_轮播3.jpg")], features: { ecommerce: muban("feature-ecommerce.jpg"), script: muban("feature-script.jpg"), diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 0806c67..8eaf46a 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -182,6 +182,7 @@ import { } from "./canvasWorkflowDeserialize"; import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents"; import type { CanvasNodeToolbarAction } from "./canvasComponents"; +import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels"; import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing"; const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ @@ -336,6 +337,7 @@ function CanvasPage({ const [imageFocusNodeId, setImageFocusNodeId] = useState(null); const [imageFocusDraft, setImageFocusDraft] = useState(null); const [imageFocusDrag, setImageFocusDrag] = useState(null); + const [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null); const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState(null); const [stylePickerCases, setStylePickerCases] = useState([]); const [stylePickerLoading, setStylePickerLoading] = useState(false); @@ -3871,12 +3873,12 @@ function CanvasPage({ onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu} />
e.stopPropagation()}> - - + - - + +
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
, disabled: !imageNode.imageUrl }, @@ -4697,16 +4699,42 @@ function CanvasPage({ )} + +
@@ -5856,6 +5884,27 @@ function CanvasPage({ + {canvasToolModal && ( +
setCanvasToolModal(null)}> +
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={canvasToolModal.tool === "multiGrid" ? "多宫格" : canvasToolModal.tool === "upscale" ? "超分" : "局部重绘"}> +
+

{canvasToolModal.tool === "multiGrid" ? "多宫格生成" : canvasToolModal.tool === "upscale" ? "图片超分" : "局部重绘"}

+ +
+
+ {canvasToolModal.tool === "multiGrid" && ( + { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} /> + )} + {canvasToolModal.tool === "upscale" && ( + { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} /> + )} + {canvasToolModal.tool === "inpaint" && ( + { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} /> + )} +
+
+
+ )} ); } diff --git a/src/features/canvas/canvasToolPanels.tsx b/src/features/canvas/canvasToolPanels.tsx new file mode 100644 index 0000000..02afbf7 --- /dev/null +++ b/src/features/canvas/canvasToolPanels.tsx @@ -0,0 +1,221 @@ +import { useCallback, useRef, useState } from "react"; +import { aiGenerationClient } from "../../api/aiGenerationClient"; +import { waitForTask } from "../../api/taskSubscription"; +import { toast } from "../../components/toast/toastStore"; +import type { CanvasImageNode } from "./canvasTypes"; + +interface CanvasToolPanelProps { + imageUrl: string; + imageNode: CanvasImageNode; + onComplete: (resultUrl: string) => void; +} + +export function CanvasMultiGridPanel({ imageUrl, onComplete }: CanvasToolPanelProps) { + const [gridMode, setGridMode] = useState<"grid-4" | "grid-9">("grid-4"); + const [prompt, setPrompt] = useState(""); + const [loading, setLoading] = useState(false); + const cancelRef = useRef(false); + + const handleGenerate = useCallback(async () => { + if (!imageUrl) return; + setLoading(true); + cancelRef.current = false; + try { + const { taskId } = await aiGenerationClient.createImageTask({ + model: "gpt-image-2", + prompt: prompt || "基于参考图生成多宫格变体", + referenceUrls: [imageUrl], + gridMode, + }); + const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef }); + if (resultUrl) { + onComplete(resultUrl); + toast.success("多宫格生成完成"); + } + } catch (err: unknown) { + if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "多宫格生成失败"); + } finally { + setLoading(false); + } + }, [imageUrl, prompt, gridMode, onComplete]); + + return ( +
+
+
+ +
+ {([["grid-4", "2×2"], ["grid-9", "3×3"]] as const).map(([value, label]) => ( + + ))} +
+ +