Files

1 line
135 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "chentx1243/mimo-tts-studio", "files": {"CLAUDE.md": {"3d292fb06a7cec55a3ff97b89d37f645a0bcca57": {"t": 1785595139.9601972, "n": 3752, "h": "3198aa8a0066ccad", "c": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nMiMo Audio Workstation (铸光音频工作站) — a visual node-based audio workstation for voice cloning and voice design using the Xiaomi MiMo TTS API. Users wire together reference audio, voice style instructions, text prompts, and TTS nodes on a React Flow canvas to generate cloned or designed audio. The UI is entirely in Chinese.\n\n## Commands\n\n| Command | Purpose |\n|---|---|\n| `npm run dev` | Start full dev environment (backend on :3001 + Vite frontend on :5173, `/api` proxied) |\n| `npm run dev:client` | Vite frontend only |\n| `npm run dev:server` | Express backend only (hot-reload via tsx) |\n| `npm run build` | Production build: `tsc -b` + `vite build` + esbuild bundle server to `build/server/index.cjs` |\n| `npm run electron` | Build + launch Electron desktop app |\n| `npm run dist:win` | Build + package Windows NSIS installer (output in `release/`) |\n\nNo test runner, linter, or formatter is configured.\n\n## Environment\n\nCopy `.env.example` to `.env` and set `MIMO_API_KEY`. Required variables:\n- `MIMO_API_KEY` — MiMo API key for TTS and LLM calls\n- `PORT` — server port (default 3001)\n\n## Architecture\n\nThis is a monolithic full-stack TypeScript app with only **3 source files**:\n\n- **`src/App.tsx`** (~1900 lines) — Entire React frontend: 6 custom node types, workspace management, audio recording/playback, stash panel, auto-save\n- **`server/index.ts`** (~1200 lines) — Express 5 API server: TTS proxying, workspace CRUD, AI text optimization, smart workspace generation\n- **`electron/main.cjs`** — Electron main process: starts server in-process, loads it in a BrowserWindow\n\n### Node Types (React Flow)\n\n| Node | Purpose |\n|---|---|\n| `referenceAudio` | Upload or record a voice sample |\n| `voiceStyle` | Director text for emotion/expression (has AI optimize) |\n| `prompt` | Text content to synthesize |\n| `voiceClone` | Main TTS node — takes ref audio + style + text, calls MiMo voice clone API |\n| `voiceDesign` | TTS with designed voice (no ref audio needed, has AI polish) |\n| `artifact` | Output node with playback/download/stash |\n\n### Backend API Endpoints\n\n- `POST /api/tts/voiceclone` — voice cloning (multipart form with audio file)\n- `POST /api/tts/voicedesign` — voice design synthesis (JSON body)\n- `POST /api/voice-style/optimize` — AI optimization of voice style text\n- `POST /api/voice-design/optimize` — AI polishing of voice design descriptions\n- `POST /api/workspaces/smart` — AI-powered smart workspace generation\n- CRUD `/api/workspaces` — workspace persistence\n\nAll TTS requests proxy to `https://api.xiaomimimo.com/v1/chat/completions` using models: `mimo-v2.5-tts-voiceclone`, `mimo-v2.5-tts-voicedesign`, `mimo-v2-flash`, `mimo-v2.5-pro`.\n\n### Key Patterns\n\n- **Data URL transport**: Audio stored/transmitted as base64 data URLs throughout (workspace JSON, API responses)\n- **Callback hydration**: React Flow nodes hydrated with callbacks via `useMemo` before passing to graph (functions not serialized to persisted state)\n- **Atomic file persistence**: Workspace data uses write-to-`.tmp`-then-rename to prevent corruption; stored at `data/workspaces.json`\n- **Auto-save with 5-second debounce**: Changes to nodes/edges/stash trigger debounced save to backend\n\n## Tech Stack\n\nReact 19 + TypeScript + Vite + @xyflow/react (React Flow) + Express 5 + Electron + electron-builder. ESM modules (`\"type\": \"module\"`).\n\n## Build Outputs\n\n- `dist/` — Vite frontend build (gitignored)\n- `build/server/index.cjs` — esbuild-bundled server for Electron (gitignored)\n- `release/` — Electron-builder installer output (gitignored)\n- `data/workspaces.json` — runtime workspace store (gitignored)\n"}}, "README.md": {"3d292fb06a7cec55a3ff97b89d37f645a0bcca57": {"t": 1785595189.283081, "n": 2538, "h": "8bbe3f15bc95258a", "c": "# 音频工作站\n\n基于节点连线的可视化音频工作站,使用小米 MiMo TTS API 实现语音克隆与语音设计。\n\n用户可在 React Flow 画布上将参考音频、语音风格指令、文本提示词和 TTS 节点进行连线,生成克隆语音或设计语音。\n\n## 功能特性\n\n- **可视化节点编辑器** — 拖拽连线,所见即所得的音频处理流程\n- **语音克隆 (Voice Clone)** — 上传参考音频 + 文本,克隆目标音色\n- **语音设计 (Voice Design)** — 无需参考音频,通过文字描述生成全新音色\n- **智能画板生成** — AI 一键生成完整的节点工作流\n- **AI 文本优化** — 自动优化语音风格描述和提示词\n- **音频暂存区** — 保存、管理和回放生成的音频片段\n- **多工作区** — 支持创建和管理多个独立工作区\n- **桌面应用** — 可打包为 Windows Electron 桌面应用\n\n## 节点类型\n\n| 节点 | 说明 |\n|---|---|\n| 参考音频 (Reference Audio) | 上传或录制语音样本 |\n| 语音风格 (Voice Style) | 情感/表达方式的导演指令,支持 AI 优化 |\n| 提示词 (Prompt) | 待合成的文本内容 |\n| 语音克隆 (Voice Clone) | 主 TTS 节点,接入参考音频 + 风格 + 文本,调用 MiMo 克隆 API |\n| 语音设计 (Voice Design) | 通过文字描述生成语音,无需参考音频 |\n| 输出 (Artifact) | 播放/下载/暂存生成的音频 |\n\n## 技术栈\n\n- **前端**: React 19 + TypeScript + Vite + @xyflow/react (React Flow) + Lucide Icons\n- **后端**: Express 5 + TypeScript\n- **桌面**: Electron + electron-builder\n- **AI**: 小米 MiMo TTS API (`mimo-v2.5-tts-voiceclone` / `mimo-v2.5-tts-voicedesign`)\n\n## 快速开始\n\n### 环境要求\n\n- Node.js >= 22\n- npm\n\n### 安装\n\n```bash\ngit clone <repo-url>\ncd mimo\nnpm install\n```\n\n### 配置\n\n复制 `.env.example` 为 `.env`,填入你的 MiMo API Key:\n\n```bash\ncp .env.example .env\n```\n\n```env\nMIMO_API_KEY=your_mimo_api_key_here\nPORT=3001\n```\n\n### 启动开发环境\n\n```bash\nnpm run dev\n```\n\n启动后访问:\n- 前端: http://localhost:5173\n- 后端: http://localhost:3001\n\n### 单独启动\n\n```bash\nnpm run dev:client # 仅前端 (Vite)\nnpm run dev:server # 仅后端 (Express)\n```\n\n## 构建与打包\n\n### Web 构建\n\n```bash\nnpm run build\n```\n\n输出:\n- `dist/` — 前端静态资源\n- `build/server/index.cjs` — 服务端 bundle\n\n### Electron 桌面应用\n\n```bash\nnpm run electron # 构建并启动桌面应用\nnpm run dist:win # 构建并打包 Windows 安装程序 (输出到 release/)\n```\n\n## 项目结构\n\n```\nmimo/\n├── src/\n│ ├── App.tsx # 前端主应用(节点编辑器、工作区管理、音频处理)\n│ ├── main.tsx # React 入口\n│ └── styles.css # 全局样式\n├── server/\n│ └── index.ts # Express API 服务(TTS 代理、工作区 CRUD、AI 优化)\n├── electron/\n│ └── main.cjs # Electron 主进程\n├── data/\n│ └── workspaces.json # 工作区数据持久化(运行时生成)\n├── .env.example # 环境变量模板\n└── package.json\n```\n\n## API 接口\n\n| 方法 | 路径 | 说明 |\n|---|---|---|\n| POST | `/api/tts/voiceclone` | 语音克隆(multipart,含音频文件) |\n| POST | `/api/tts/voicedesign` | 语音设计(JSON) |\n| POST | `/api/voice-style/optimize` | AI 优化语音风格文本 |\n| POST | `/api/voice-design/optimize` | AI 优化语音设计描述 |\n| POST | `/api/workspaces/smart` | AI 智能生成工作区 |\n| GET | `/api/workspaces` | 获取所有工作区 |\n| PUT | `/api/workspaces/:id` | 更新工作区 |\n| DELETE | `/api/workspaces/:id` | 删除工作区 |\n\n## License\n\n该项目基于小米Mimo模型提供核心能力,并非Mimo品牌或小米产品;项目完全由ClaudeCode+mimo-v2.5-pro编写,无人工编写成分。\n"}}, "src/App.tsx": {"3d292fb06a7cec55a3ff97b89d37f645a0bcca57": {"t": 1785846874.449936, "n": 119914, "h": "c365275a97d8e9f0", "c": "import \"@xyflow/react/dist/style.css\";\nimport {\n addEdge,\n Background,\n BackgroundVariant,\n BaseEdge,\n Controls,\n EdgeLabelRenderer,\n Handle,\n MiniMap,\n Position,\n ReactFlow,\n ReactFlowProvider,\n type Connection,\n type Edge,\n type EdgeProps,\n type Node,\n type NodeProps,\n type NodeTypes,\n type ReactFlowInstance,\n useEdgesState,\n useNodesState\n} from \"@xyflow/react\";\nimport {\n AlertTriangle,\n Archive,\n AudioLines,\n BookOpen,\n BookPlus,\n ChevronDown,\n ChevronLeft,\n ChevronRight,\n Download,\n FileAudio,\n Key,\n Loader2,\n Mic2,\n Pause,\n PanelTop,\n Play,\n Plus,\n RefreshCw,\n Save,\n Sparkles,\n Square,\n Trash2,\n Wand2,\n X\n} from \"lucide-react\";\nimport { ChangeEvent, MouseEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { getBezierPath } from \"@xyflow/react\";\nimport JSZip from \"jszip\";\n\ntype StatusResponse = {\n ok: boolean;\n model: string;\n apiKeyConfigured: boolean;\n maxAudioBytes: number;\n allowedMimeTypes: string[];\n};\n\ntype ApiSettingsResponse = {\n apiKey: string;\n apiEndpoint: string;\n configured: boolean;\n};\n\ntype WorkspaceSummary = {\n id: string;\n name: string;\n type: \"board\" | \"audiobook\";\n createdAt: string;\n updatedAt: string;\n nodeCount?: number;\n edgeCount?: number;\n stashCount?: number;\n characterCount?: number;\n segmentCount?: number;\n phase?: string;\n};\n\ntype BoardWorkspacePayload = {\n id: string;\n type: \"board\";\n name: string;\n createdAt: string;\n updatedAt: string;\n nodes: StudioNode[];\n edges: StudioEdge[];\n stashItems: StashItem[];\n};\n\ntype AudiobookCharacter = {\n id: string;\n name: string;\n roleType: \"narrator\" | \"protagonist\" | \"supporting\" | \"custom\";\n aliases: string[];\n voiceSource: \"analysis\" | \"manualDesign\" | \"manualClone\";\n voiceMode: \"designed\" | \"cloned\";\n isSystem?: boolean;\n isVoiceLocked?: boolean;\n gender: string;\n age: string;\n voiceTraits: string;\n personality: string;\n voiceDescription: string;\n voiceSampleText?: string;\n voiceDataUrl: string | null;\n voiceStatus: \"pending\" | \"generating\" | \"ready\" | \"error\";\n voiceError?: string;\n referenceAudioDataUrl?: string;\n referenceAudioFileName?: string;\n referenceAudioMimeType?: string;\n};\n\ntype AudiobookSegment = {\n id: string;\n text: string;\n characterId: string | null;\n characterName: string;\n emotion: string;\n isAutoAnnotated: boolean;\n};\n\ntype AudiobookProduct = {\n id: string;\n segmentId: string;\n characterId: string | null;\n characterName: string;\n text: string;\n instruction: string;\n audioDataUrl: string | null;\n status: \"pending\" | \"generating\" | \"ready\" | \"error\";\n error?: string;\n elapsedMs?: number;\n createdAt: string;\n synthesisMethod: \"voiceClone\" | \"voiceDesign\";\n};\n\ntype AudiobookChapter = {\n id: string;\n title: string;\n novelText: string;\n characterHints: string;\n segments: AudiobookSegment[];\n products: AudiobookProduct[];\n phase: \"character-creation\" | \"annotation\" | \"generation\";\n createdAt: string;\n updatedAt: string;\n};\n\ntype AudiobookWorkspacePayload = {\n id: string;\n type: \"audiobook\";\n name: string;\n createdAt: string;\n updatedAt: string;\n activeChapterId: string;\n novelText: string;\n characterHints: string;\n characters: AudiobookCharacter[];\n segments: AudiobookSegment[];\n products: AudiobookProduct[];\n phase: \"character-creation\" | \"annotation\" | \"generation\";\n chapters: AudiobookChapter[];\n};\n\ntype WorkspacePayload = BoardWorkspacePayload | AudiobookWorkspacePayload;\n\ntype WorkspacesResponse = {\n activeWorkspaceId: string | null;\n workspaces: WorkspaceSummary[];\n};\n\ntype StudioNodeType = \"referenceAudio\" | \"voiceStyle\" | \"prompt\" | \"voiceClone\" | \"voiceDesign\" | \"artifact\" | \"comment\";\ntype StudioNode = Node<NodeData, StudioNodeType>;\ntype StudioEdge = Edge<{ onDeleteEdge?: (edgeId: string) => void }>;\n\ntype AudioAsset = {\n fileName: string;\n mimeType: string;\n size: number;\n dataUrl: string;\n};\n\ntype ArtifactData = {\n fileName: string;\n audioDataUrl: string;\n elapsedMs: number;\n createdAt: string;\n sourceNodeName: string;\n};\n\ntype StashItem = ArtifactData & {\n id: string;\n};\n\ntype NodeData = {\n title: string;\n text?: string;\n instruction?: string;\n audio?: AudioAsset;\n artifact?: ArtifactData;\n isRunning?: boolean;\n error?: string;\n onPatch?: (nodeId: string, patch: Partial<NodeData>) => void;\n onDelete?: (nodeId: string) => void;\n onRunClone?: (nodeId: string) => void;\n onRunVoiceDesign?: (nodeId: string) => void;\n onOptimizeStyle?: (nodeId: string) => void;\n onOptimizeVoiceDesign?: (nodeId: string) => void;\n onStashArtifact?: (artifact: ArtifactData) => void;\n isArtifactStashed?: (artifact: ArtifactData) => boolean;\n};\n\ntype DebugResponse = {\n audioDataUrl: string;\n fileName: string;\n elapsedMs: number;\n};\n\ntype StyleOptimizeResponse = {\n optimizedText: string;\n elapsedMs: number;\n error?: string;\n};\n\nconst nodeCatalog: Record<\n StudioNodeType,\n {\n label: string;\n description: string;\n defaultData: () => NodeData;\n }\n> = {\n referenceAudio: {\n label: \"参考音频\",\n description: \"上传声音样本,输出给克隆节点\",\n defaultData: () => ({ title: \"参考音频\", text: \"声音样本\" })\n },\n voiceStyle: {\n label: \"语音风格\",\n description: \"导演文本,控制声音情绪和表达\",\n defaultData: () => ({ title: \"语音风格\", text: \"自然、清晰、略带播客讲述感,语速中等,语气友好但不过分夸张。\" })\n },\n prompt: {\n label: \"提示词\",\n description: \"要生成成音频的文本内容\",\n defaultData: () => ({ title: \"提示词\", text: \"今天我们完成了铸光音频工作站的第一条生成链路,现在用这段声音检查相似度、节奏和情绪表现。\" })\n },\n voiceClone: {\n label: \"音频克隆\",\n description: \"读取输入并生成克隆音频\",\n defaultData: () => ({\n title: \"音频克隆\",\n instruction: \"自然、清晰、略带播客讲述感,语速中等,语气友好但不过分夸张。\",\n text: \"今天我们完成了铸光音频工作站的第一条生成链路,现在用这段声音检查相似度、节奏和情绪表现。\"\n })\n },\n voiceDesign: {\n label: \"音色创造\",\n description: \"用文字设计音色并直接合成音频\",\n defaultData: () => ({\n title: \"音色创造\",\n instruction: \"年轻女性声音,温暖、清澈,带一点自然的纪录片旁白质感,情绪克制但有亲近感。\",\n text: \"这是一段使用文字设计音色直接生成的语音,用来验证音色、情绪和表达质感。\"\n })\n },\n artifact: {\n label: \"产物\",\n description: \"保存生成结果和下载入口\",\n defaultData: () => ({ title: \"音频产物\" })\n },\n comment: {\n label: \"文本注释\",\n description: \"画布上的备注说明\",\n defaultData: () => ({ title: \"注释\", text: \"\" })\n }\n};\n\nconst autoSaveDelayMs = 30000;\nconst DEFAULT_API_KEY = \"sk-cbpjuoes34akq38omkqz9s08s7h9dxwe7cjshz5824kskliz\";\nconst DEFAULT_API_ENDPOINT = \"https://api.xiaomimimo.com/v1/chat/completions\";\nconst API_KEY_STORAGE_KEY = \"mimo-api-key\";\nconst API_ENDPOINT_STORAGE_KEY = \"mimo-api-endpoint\";\n\nconst allowedAudioTypes = new Set([\"audio/mpeg\", \"audio/mp3\", \"audio/mp4\", \"audio/m4a\", \"audio/wav\", \"audio/x-wav\", \"audio/wave\", \"video/mp4\"]);\nconst maxAudioBytes = Math.floor(7.5 * 1024 * 1024);\n\nexport default function App() {\n return (\n <ReactFlowProvider>\n <StudioApp />\n </ReactFlowProvider>\n );\n}\n\nfunction StudioApp() {\n const [status, setStatus] = useState<StatusResponse | null>(null);\n const [statusError, setStatusError] = useState<string | null>(null);\n const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);\n const [activeWorkspace, setActiveWorkspace] = useState<WorkspacePayload | null>(null);\n const [nodes, setNodes, onNodesChange] = useNodesState<StudioNode>([]);\n const [edges, setEdges, onEdgesChange] = useEdgesState<StudioEdge>([]);\n const [menu, setMenu] = useState<{ x: number; y: number; flowX: number; flowY: number } | null>(null);\n const [isSaving, setIsSaving] = useState(false);\n const [isStashOpen, setIsStashOpen] = useState(false);\n const [boardDialog, setBoardDialog] = useState<\"choice\" | \"smart\" | \"audiobook\" | null>(null);\n const flowRef = useRef<ReactFlowInstance<StudioNode, StudioEdge> | null>(null);\n const saveTimerRef = useRef<number | null>(null);\n const [apiKey, setApiKey] = useState<string>(() => localStorage.getItem(API_KEY_STORAGE_KEY) || DEFAULT_API_KEY);\n const [apiEndpoint, setApiEndpoint] = useState<string>(() => localStorage.getItem(API_ENDPOINT_STORAGE_KEY) || DEFAULT_API_ENDPOINT);\n const [showApiKeyModal, setShowApiKeyModal] = useState(false);\n const [apiKeyInput, setApiKeyInput] = useState(apiKey);\n const [apiEndpointInput, setApiEndpointInput] = useState(apiEndpoint);\n const [topbarCollapsed, setTopbarCollapsed] = useState(false);\n const [showDefaultKeyWarning, setShowDefaultKeyWarning] = useState(true);\n const [sidebarCollapsed, setSidebarCollapsed] = useState(false);\n const topbarHoverTimerRef = useRef<number | null>(null);\n\n const autoSaveKey = useMemo(() => {\n if (!activeWorkspace) return \"\";\n if (activeWorkspace.type === \"audiobook\") {\n return JSON.stringify({\n id: activeWorkspace.id,\n name: activeWorkspace.name,\n novelText: activeWorkspace.novelText,\n characterHints: activeWorkspace.characterHints,\n characters: activeWorkspace.characters,\n segments: activeWorkspace.segments,\n products: activeWorkspace.products,\n phase: activeWorkspace.phase\n });\n }\n return JSON.stringify({\n id: activeWorkspace.id,\n name: activeWorkspace.name,\n nodes,\n edges,\n stashItems: activeWorkspace.stashItems ?? []\n });\n }, [activeWorkspace, nodes, edges]);\n\n useEffect(() => {\n void loadStatus();\n void loadApiSettings();\n void loadWorkspaceList();\n\n const collapseTimer = window.setTimeout(() => {\n setTopbarCollapsed(true);\n }, 5000);\n\n return () => window.clearTimeout(collapseTimer);\n }, []);\n\n useEffect(() => {\n function handleMouseMove(e: globalThis.MouseEvent) {\n if (e.clientY < 20) {\n setTopbarCollapsed(false);\n if (topbarHoverTimerRef.current) {\n window.clearTimeout(topbarHoverTimerRef.current);\n }\n }\n }\n\n document.addEventListener(\"mousemove\", handleMouseMove);\n return () => document.removeEventListener(\"mousemove\", handleMouseMove);\n }, []);\n\n useEffect(() => {\n if (!activeWorkspace) {\n return;\n }\n\n if (saveTimerRef.current) {\n window.clearTimeout(saveTimerRef.current);\n }\n\n saveTimerRef.current = window.setTimeout(() => {\n void saveWorkspace();\n }, autoSaveDelayMs);\n\n return () => {\n if (saveTimerRef.current) {\n window.clearTimeout(saveTimerRef.current);\n }\n };\n // Track board and audiobook state changes without depending on the save function identity.\n }, [autoSaveKey]);\n\n const nodeCallbacks = useMemo(\n () => ({\n onPatch: patchNode,\n onDelete: deleteNode,\n onRunClone: runVoiceClone,\n onRunVoiceDesign: runVoiceDesign,\n onOptimizeStyle: optimizeVoiceStyle,\n onOptimizeVoiceDesign: optimizeVoiceDesign,\n onStashArtifact: stashArtifact,\n isArtifactStashed\n }),\n [nodes, edges, apiKey, activeWorkspace?.type === \"board\" ? activeWorkspace.stashItems : undefined]\n );\n\n const hydratedNodes = useMemo(() => {\n return nodes.map((node) => ({\n ...node,\n data: {\n ...node.data,\n ...nodeCallbacks\n }\n }));\n }, [nodes, nodeCallbacks]);\n\n const hydratedEdges = useMemo(() => {\n return edges.map((edge) => ({\n ...edge,\n type: \"deletable\",\n data: {\n ...edge.data,\n onDeleteEdge: deleteEdge\n }\n }));\n }, [edges]);\n\n const nodeTypes = useMemo<NodeTypes>(\n () => ({\n referenceAudio: ReferenceAudioNode,\n voiceStyle: VoiceStyleNode,\n prompt: PromptNode,\n voiceClone: VoiceCloneNode,\n voiceDesign: VoiceDesignNode,\n artifact: ArtifactNode,\n comment: CommentNode\n }),\n []\n );\n\n const edgeTypes = useMemo(\n () => ({\n deletable: DeletableEdge\n }),\n []\n );\n\n async function loadStatus() {\n try {\n setStatusError(null);\n const response = await fetch(\"/api/status\");\n if (!response.ok) {\n throw new Error(`状态检查失败:HTTP ${response.status}`);\n }\n setStatus((await response.json()) as StatusResponse);\n } catch (error) {\n setStatusError(error instanceof Error ? error.message : \"状态检查失败\");\n }\n }\n\n async function loadApiSettings() {\n try {\n const response = await fetch(\"/api/settings\");\n if (!response.ok) {\n throw new Error(`API 配置加载失败:HTTP ${response.status}`);\n }\n\n const settings = (await response.json()) as ApiSettingsResponse;\n const localKey = localStorage.getItem(API_KEY_STORAGE_KEY) || \"\";\n const localEndpoint = localStorage.getItem(API_ENDPOINT_STORAGE_KEY) || \"\";\n const nextKey = settings.apiKey || localKey || DEFAULT_API_KEY;\n const nextEndpoint = settings.apiEndpoint || localEndpoint || DEFAULT_API_ENDPOINT;\n\n setApiKey(nextKey);\n setApiEndpoint(nextEndpoint);\n setApiKeyInput(nextKey);\n setApiEndpointInput(nextEndpoint);\n\n if (!settings.configured && localKey) {\n void persistApiSettings(localKey, localEndpoint || nextEndpoint);\n return;\n }\n\n if (!settings.configured && !localKey) {\n setShowApiKeyModal(true);\n }\n } catch (error) {\n console.warn(\"[settings] failed to load API settings\", error);\n if (!localStorage.getItem(API_KEY_STORAGE_KEY)) {\n setShowApiKeyModal(true);\n }\n }\n }\n\n function saveApiKey() {\n const trimmedKey = apiKeyInput.trim();\n const trimmedEndpoint = apiEndpointInput.trim();\n if (!trimmedKey) return;\n setApiKey(trimmedKey);\n localStorage.setItem(API_KEY_STORAGE_KEY, trimmedKey);\n if (trimmedEndpoint) {\n setApiEndpoint(trimmedEndpoint);\n localStorage.setItem(API_ENDPOINT_STORAGE_KEY, trimmedEndpoint);\n } else {\n setApiEndpoint(DEFAULT_API_ENDPOINT);\n localStorage.removeItem(API_ENDPOINT_STORAGE_KEY);\n }\n void persistApiSettings(trimmedKey, trimmedEndpoint || DEFAULT_API_ENDPOINT);\n closeApiKeyModal();\n void loadStatus();\n }\n\n async function persistApiSettings(apiKey: string, apiEndpoint: string) {\n try {\n await fetch(\"/api/settings\", {\n method: \"PUT\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ apiKey, apiEndpoint })\n });\n } catch (error) {\n console.warn(\"[settings] failed to persist API settings\", error);\n }\n }\n\n function openApiKeyModal() {\n setApiKeyInput(apiKey);\n setApiEndpointInput(apiEndpoint);\n setShowApiKeyModal(true);\n }\n\n function closeApiKeyModal() {\n setShowApiKeyModal(false);\n topbarHoverTimerRef.current = window.setTimeout(() => setTopbarCollapsed(true), 3000);\n }\n\n async function loadWorkspaceList(preferredId?: string) {\n const response = await fetch(\"/api/workspaces\");\n if (!response.ok) {\n throw new Error(`加载画板列表失败:HTTP ${response.status}`);\n }\n const payload = (await response.json()) as WorkspacesResponse;\n const workspaceItems = Array.isArray(payload.workspaces) ? payload.workspaces : [];\n setWorkspaces(workspaceItems);\n const targetId = preferredId ?? payload.activeWorkspaceId ?? workspaceItems[0]?.id;\n if (targetId) {\n await loadWorkspace(targetId);\n }\n }\n\n async function loadWorkspace(id: string) {\n const response = await fetch(`/api/workspaces/${id}`);\n if (!response.ok) {\n throw new Error(`加载画板失败:HTTP ${response.status}`);\n }\n const workspace = (await response.json()) as WorkspacePayload;\n setActiveWorkspace(workspace);\n if (workspace.type === \"board\") {\n setNodes(workspace.nodes ?? []);\n setEdges(workspace.edges ?? []);\n } else {\n setNodes([]);\n setEdges([]);\n }\n }\n\n async function createWorkspace() {\n const name = `新工作台 ${new Date().toLocaleString(\"zh-CN\", { hour12: false })}`;\n const response = await fetch(\"/api/workspaces\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ name, nodes: [], edges: [], stashItems: [] })\n });\n const workspace = (await response.json()) as WorkspacePayload;\n await loadWorkspaceList(workspace.id);\n }\n\n async function createSmartWorkspace(formData: FormData) {\n const response = await fetch(\"/api/workspaces/smart\", {\n method: \"POST\",\n headers: { \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint },\n body: formData\n });\n const workspace = (await response.json()) as WorkspacePayload & { error?: string };\n if (!response.ok) {\n throw new Error(workspace.error || `智能画板生成失败:HTTP ${response.status}`);\n }\n await loadWorkspaceList(workspace.id);\n }\n\n async function createAudiobookWorkspace(data: { novelText: string; characterHints: string; name?: string }) {\n const response = await fetch(\"/api/audiobook\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint },\n body: JSON.stringify(data)\n });\n const workspace = (await response.json()) as AudiobookWorkspacePayload & { error?: string };\n if (!response.ok) {\n throw new Error(workspace.error || \"创建有声书失败\");\n }\n await loadWorkspaceList(workspace.id);\n }\n\n function patchAudiobook(patch: Partial<AudiobookWorkspacePayload>) {\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\") return workspace;\n return { ...workspace, ...patch } as AudiobookWorkspacePayload;\n });\n }\n\n async function addAudiobookChapter(data: { title: string; novelText: string; characterHints: string }) {\n if (!activeWorkspace || activeWorkspace.type !== \"audiobook\") throw new Error(\"工作区状态异常\");\n const workspaceId = activeWorkspace.id;\n const response = await fetch(`/api/audiobook/${workspaceId}/chapters`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint },\n body: JSON.stringify(data)\n });\n const result = (await response.json()) as { workspace?: AudiobookWorkspacePayload; error?: string };\n if (!response.ok || !result.workspace) {\n throw new Error(result.error || \"新增章节失败\");\n }\n setActiveWorkspace(result.workspace);\n }\n\n async function activateAudiobookChapter(chapterId: string) {\n if (!activeWorkspace || activeWorkspace.type !== \"audiobook\") return;\n const workspaceId = activeWorkspace.id;\n const response = await fetch(`/api/audiobook/${workspaceId}/chapters/${chapterId}/activate`, {\n method: \"POST\"\n });\n const result = (await response.json()) as { workspace?: AudiobookWorkspacePayload; error?: string };\n if (!response.ok || !result.workspace) {\n throw new Error(result.error || \"切换章节失败\");\n }\n setActiveWorkspace(result.workspace);\n }\n\n async function createAudiobookCharacter(formData: FormData) {\n if (!activeWorkspace || activeWorkspace.type !== \"audiobook\") throw new Error(\"工作区状态异常\");\n const workspaceId = activeWorkspace.id;\n const response = await fetch(`/api/audiobook/${workspaceId}/characters`, {\n method: \"POST\",\n body: formData\n });\n const result = (await response.json()) as { characters?: AudiobookCharacter[]; chapter?: AudiobookChapter; error?: string };\n if (!response.ok) {\n throw new Error(result.error || \"创建音色角色失败\");\n }\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n return { ...workspace, characters: result.characters ?? workspace.characters };\n });\n }\n\n async function analyzeAudiobookCharacters() {\n if (!activeWorkspace || activeWorkspace.type !== \"audiobook\") throw new Error(\"工作区状态异常\");\n const workspaceId = activeWorkspace.id;\n const response = await fetch(`/api/audiobook/${workspaceId}/characters/analyze`, {\n method: \"POST\",\n headers: { \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint }\n });\n const result = (await response.json()) as { characters?: AudiobookCharacter[]; chapter?: AudiobookChapter; error?: string };\n if (!response.ok) {\n throw new Error(result.error || \"角色分析失败\");\n }\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n if (!result.chapter) {\n return { ...workspace, characters: result.characters ?? [] };\n }\n return {\n ...workspace,\n characters: result.characters ?? workspace.characters,\n chapters: workspace.chapters.map((chapter) => (chapter.id === result.chapter!.id ? result.chapter! : chapter)),\n activeChapterId: result.chapter.id,\n novelText: result.chapter.novelText,\n characterHints: result.chapter.characterHints,\n segments: result.chapter.segments,\n products: result.chapter.products,\n phase: result.chapter.phase\n };\n });\n }\n\n async function generateCharacterVoice(charId: string) {\n if (!activeWorkspace || activeWorkspace.type !== \"audiobook\") return;\n const workspaceId = activeWorkspace.id;\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n return {\n ...workspace,\n characters: workspace.characters.map((c) =>\n c.id === charId ? { ...c, voiceStatus: \"generating\" as AudiobookCharacter[\"voiceStatus\"], voiceError: undefined } : c\n )\n };\n });\n const response = await fetch(`/api/audiobook/${workspaceId}/characters/${charId}/voice`, {\n method: \"POST\",\n headers: { \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint }\n });\n const result = (await response.json()) as { character?: AudiobookCharacter; error?: string };\n if (!response.ok) {\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n return {\n ...workspace,\n characters: workspace.characters.map((c) =>\n c.id === charId ? { ...c, voiceStatus: \"error\" as AudiobookCharacter[\"voiceStatus\"], voiceError: result.error || \"生成失败\" } : c\n )\n };\n });\n return;\n }\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n return {\n ...workspace,\n characters: workspace.characters.map((c) => (c.id === charId ? result.character! : c))\n };\n });\n }\n\n async function deleteCharacterVoice(charId: string) {\n if (!activeWorkspace || activeWorkspace.type !== \"audiobook\") return;\n const workspaceId = activeWorkspace.id;\n const response = await fetch(`/api/audiobook/${workspaceId}/characters/${charId}/voice`, {\n method: \"DELETE\"\n });\n const result = (await response.json()) as { character?: AudiobookCharacter; error?: string };\n if (!response.ok) {\n throw new Error(result.error || \"删除音色失败\");\n }\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n return {\n ...workspace,\n characters: workspace.characters.map((c) => (c.id === charId ? result.character! : c))\n };\n });\n }\n\n async function autoAnnotateAudiobook() {\n if (!activeWorkspace || activeWorkspace.type !== \"audiobook\") throw new Error(\"工作区状态异常\");\n const workspaceId = activeWorkspace.id;\n const response = await fetch(`/api/audiobook/${workspaceId}/annotate`, {\n method: \"POST\",\n headers: { \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint }\n });\n const result = (await response.json()) as { segments?: AudiobookSegment[]; chapter?: AudiobookChapter; error?: string };\n if (!response.ok) {\n throw new Error(result.error || \"自动标注失败\");\n }\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n if (result.chapter) {\n return {\n ...workspace,\n chapters: workspace.chapters.map((chapter) => (chapter.id === result.chapter!.id ? result.chapter! : chapter)),\n activeChapterId: result.chapter.id,\n segments: result.chapter.segments,\n products: result.chapter.products,\n phase: result.chapter.phase\n };\n }\n return { ...workspace, segments: result.segments ?? [], phase: \"annotation\" };\n });\n }\n\n async function updateAudiobookSegment(segId: string, patch: { characterId: string | null; characterName: string; emotion: string }) {\n if (!activeWorkspace || activeWorkspace.type !== \"audiobook\") return;\n const response = await fetch(`/api/audiobook/${activeWorkspace.id}/segments/${segId}`, {\n method: \"PUT\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(patch)\n });\n const result = (await response.json()) as { segment?: AudiobookSegment; error?: string };\n if (!response.ok) {\n throw new Error(result.error || \"标注失败\");\n }\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== activeWorkspace.id) return workspace;\n return {\n ...workspace,\n segments: workspace.segments.map((s) => (s.id === segId ? result.segment! : s)),\n chapters: workspace.chapters.map((chapter) =>\n chapter.id === workspace.activeChapterId\n ? { ...chapter, segments: chapter.segments.map((s) => (s.id === segId ? result.segment! : s)) }\n : chapter\n )\n };\n });\n }\n\n async function generateAudiobookAudio() {\n if (!activeWorkspace || activeWorkspace.type !== \"audiobook\") throw new Error(\"工作区状态异常\");\n const workspaceId = activeWorkspace.id;\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n return {\n ...workspace,\n phase: \"generation\",\n chapters: workspace.chapters.map((chapter) =>\n chapter.id === workspace.activeChapterId ? { ...chapter, phase: \"generation\" } : chapter\n )\n };\n });\n const response = await fetch(`/api/audiobook/${workspaceId}/generate`, {\n method: \"POST\",\n headers: { \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint }\n });\n const result = (await response.json()) as { products?: AudiobookProduct[]; error?: string };\n if (!response.ok) {\n throw new Error(result.error || \"生成失败\");\n }\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n return {\n ...workspace,\n products: result.products ?? [],\n chapters: workspace.chapters.map((chapter) =>\n chapter.id === workspace.activeChapterId ? { ...chapter, products: result.products ?? [] } : chapter\n )\n };\n });\n await pollAudiobookGeneration(workspaceId);\n }\n\n async function retryAudiobookProduct(productId: string) {\n if (!activeWorkspace || activeWorkspace.type !== \"audiobook\") return;\n const workspaceId = activeWorkspace.id;\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n return {\n ...workspace,\n products: workspace.products.map((product) =>\n product.id === productId\n ? { ...product, status: \"generating\" as AudiobookProduct[\"status\"], audioDataUrl: null, error: undefined, elapsedMs: undefined }\n : product\n ),\n chapters: workspace.chapters.map((chapter) =>\n chapter.id === workspace.activeChapterId\n ? {\n ...chapter,\n products: chapter.products.map((product) =>\n product.id === productId\n ? { ...product, status: \"generating\" as AudiobookProduct[\"status\"], audioDataUrl: null, error: undefined, elapsedMs: undefined }\n : product\n )\n }\n : chapter\n )\n };\n });\n\n const response = await fetch(`/api/audiobook/${workspaceId}/products/${productId}/retry`, {\n method: \"POST\",\n headers: { \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint }\n });\n const result = (await response.json()) as { product?: AudiobookProduct; error?: string };\n if (result.product) {\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"audiobook\" || workspace.id !== workspaceId) return workspace;\n return {\n ...workspace,\n products: workspace.products.map((product) => (product.id === productId ? result.product! : product)),\n chapters: workspace.chapters.map((chapter) =>\n chapter.id === workspace.activeChapterId\n ? { ...chapter, products: chapter.products.map((product) => (product.id === productId ? result.product! : product)) }\n : chapter\n )\n };\n });\n }\n if (!response.ok && !result.product) {\n throw new Error(result.error || \"重试生成失败\");\n }\n }\n\n async function pollAudiobookGeneration(workspaceId: string) {\n for (let attempt = 0; attempt < 240; attempt++) {\n await wait(1500);\n const response = await fetch(`/api/workspaces/${workspaceId}`);\n if (!response.ok) {\n throw new Error(`刷新有声书生成进度失败:HTTP ${response.status}`);\n }\n\n const workspace = (await response.json()) as WorkspacePayload;\n if (workspace.type !== \"audiobook\") {\n throw new Error(\"工作区类型异常\");\n }\n\n setActiveWorkspace((current) => {\n if (!current || current.type !== \"audiobook\" || current.id !== workspaceId) return current;\n return workspace;\n });\n\n if (!workspace.products.some((product) => product.status === \"pending\" || product.status === \"generating\")) {\n return;\n }\n }\n\n throw new Error(\"有声书生成仍在进行中,请稍后刷新查看结果。\");\n }\n\n function wait(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms));\n }\n\n async function deleteWorkspace() {\n if (!activeWorkspace || !window.confirm(`删除「${activeWorkspace.name}」?`)) {\n return;\n }\n\n await fetch(`/api/workspaces/${activeWorkspace.id}`, { method: \"DELETE\" });\n setActiveWorkspace(null);\n setNodes([]);\n setEdges([]);\n await loadWorkspaceList();\n }\n\n async function saveWorkspace() {\n if (!activeWorkspace) {\n return;\n }\n\n setIsSaving(true);\n try {\n let body: Record<string, unknown>;\n if (activeWorkspace.type === \"audiobook\") {\n body = {\n name: activeWorkspace.name,\n activeChapterId: activeWorkspace.activeChapterId,\n novelText: activeWorkspace.novelText,\n characterHints: activeWorkspace.characterHints,\n characters: activeWorkspace.characters,\n segments: activeWorkspace.segments,\n products: activeWorkspace.products,\n chapters: activeWorkspace.chapters,\n phase: activeWorkspace.phase,\n baseUpdatedAt: activeWorkspace.updatedAt\n };\n } else {\n const cleanNodes = nodes.map(stripNodeCallbacks);\n body = {\n name: activeWorkspace.name,\n nodes: cleanNodes,\n edges,\n stashItems: activeWorkspace.stashItems ?? []\n };\n }\n\n const response = await fetch(`/api/workspaces/${activeWorkspace.id}`, {\n method: \"PUT\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(body)\n });\n const saved = (await response.json()) as WorkspacePayload & { error?: string };\n if (!response.ok) {\n throw new Error(saved.error || `保存失败:HTTP ${response.status}`);\n }\n setActiveWorkspace((current) => {\n if (!current || current.id !== saved.id) return current;\n if (current.type === \"audiobook\" && saved.type === \"audiobook\") {\n return saved;\n }\n return saved;\n });\n setWorkspaces((items) =>\n items.map((item) =>\n item.id === saved.id\n ? {\n ...item,\n name: saved.name,\n updatedAt: saved.updatedAt\n }\n : item\n )\n );\n } finally {\n setIsSaving(false);\n }\n }\n\n function patchWorkspaceName(name: string) {\n setActiveWorkspace((workspace) => (workspace ? { ...workspace, name } : workspace));\n }\n\n function stashArtifact(artifact: ArtifactData) {\n if (!activeWorkspace || isArtifactStashed(artifact)) {\n return;\n }\n\n const item: StashItem = {\n id: createId(\"stash\"),\n ...artifact\n };\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"board\") return workspace;\n return { ...workspace, stashItems: [item, ...workspace.stashItems] };\n });\n setWorkspaces((items) => items.map((w) => (w.id === activeWorkspace.id ? { ...w, stashCount: (w.stashCount ?? 0) + 1 } : w)));\n }\n\n function isArtifactStashed(artifact: ArtifactData) {\n if (!activeWorkspace || activeWorkspace.type !== \"board\") return false;\n return activeWorkspace.stashItems.some((item) => item.fileName === artifact.fileName && item.audioDataUrl === artifact.audioDataUrl);\n }\n\n function deleteStashItem(itemId: string) {\n if (!activeWorkspace || activeWorkspace.type !== \"board\") {\n return;\n }\n\n setActiveWorkspace((workspace) => {\n if (!workspace || workspace.type !== \"board\") return workspace;\n return { ...workspace, stashItems: workspace.stashItems.filter((item) => item.id !== itemId) };\n });\n setWorkspaces((items) =>\n items.map((workspace) =>\n workspace.id === activeWorkspace.id ? { ...workspace, stashCount: Math.max(0, (workspace.stashCount ?? 0) - 1) } : workspace\n )\n );\n }\n\n function patchNode(nodeId: string, patch: Partial<NodeData>) {\n setNodes((items) => items.map((node) => (node.id === nodeId ? { ...node, data: { ...node.data, ...patch } } : node)));\n }\n\n function deleteNode(nodeId: string) {\n setNodes((items) => items.filter((node) => node.id !== nodeId));\n setEdges((items) => items.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));\n }\n\n const onConnect = useCallback(\n (connection: Connection) => {\n setEdges((items) =>\n addEdge(\n {\n ...connection,\n type: \"deletable\",\n animated: true,\n style: { stroke: \"#c5a45d\", strokeWidth: 2 }\n },\n items\n )\n );\n },\n [setEdges]\n );\n\n function openContextMenu(event: MouseEvent) {\n event.preventDefault();\n const point = flowRef.current?.screenToFlowPosition({ x: event.clientX, y: event.clientY }) ?? { x: 80, y: 80 };\n setMenu({ x: event.clientX, y: event.clientY, flowX: point.x, flowY: point.y });\n }\n\n function addNode(type: StudioNodeType) {\n if (!menu) {\n return;\n }\n\n const node: StudioNode = {\n id: createId(type),\n type,\n position: { x: menu.flowX, y: menu.flowY },\n data: nodeCatalog[type].defaultData()\n };\n setNodes((items) => [...items, node]);\n setMenu(null);\n }\n\n function deleteEdge(edgeId: string) {\n setEdges((items) => items.filter((edge) => edge.id !== edgeId));\n }\n\n async function runVoiceClone(nodeId: string) {\n const cloneNode = nodes.find((node) => node.id === nodeId);\n if (!cloneNode || cloneNode.type !== \"voiceClone\") {\n return;\n }\n\n if (!apiKey) {\n patchNode(nodeId, { error: \"API Key 未配置,请点击顶部 API Key 区域配置。\" });\n return;\n }\n\n const resolved = resolveCloneInputs(cloneNode, nodes, edges);\n if (!resolved.audio) {\n patchNode(nodeId, { error: \"缺少参考音频,请连接参考音频节点或在节点中上传。\" });\n return;\n }\n\n const textItems = resolveCloneTextInputs(cloneNode, nodes, edges);\n const cloneTexts = textItems.length > 0 ? textItems : [{ title: cloneNode.data.title, text: resolved.text }];\n\n if (cloneTexts.every((item) => !item.text.trim())) {\n patchNode(nodeId, { error: \"缺少音频文本,请连接提示词节点到「文本」输入或在节点中填写。\" });\n return;\n }\n\n patchNode(nodeId, { isRunning: true, error: undefined });\n\n try {\n for (const [index, item] of cloneTexts.filter((entry) => entry.text.trim()).entries()) {\n const formData = new FormData();\n formData.append(\"voice\", dataUrlToFile(resolved.audio.dataUrl, resolved.audio.fileName, resolved.audio.mimeType));\n formData.append(\"text\", item.text.trim());\n formData.append(\"instruction\", resolved.instruction.trim());\n formData.append(\"format\", \"wav\");\n\n const response = await fetch(\"/api/tts/voiceclone\", {\n method: \"POST\",\n headers: { \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint },\n body: formData\n });\n const payload = (await response.json()) as DebugResponse & { error?: string; details?: unknown };\n\n if (!response.ok) {\n patchNode(nodeId, { isRunning: false, error: payload.error || `第 ${index + 1} 条音频克隆失败。` });\n return;\n }\n\n const artifactNode = createArtifactNode(cloneNode, payload, item.title);\n const artifactEdge: StudioEdge = {\n id: createId(\"edge\"),\n source: cloneNode.id,\n sourceHandle: \"output\",\n target: artifactNode.id,\n targetHandle: \"artifact\",\n type: \"deletable\",\n animated: true,\n style: { stroke: \"#c5a45d\", strokeWidth: 2 }\n };\n\n setNodes((items) => items.concat(artifactNode));\n setEdges((items) => items.concat(artifactEdge));\n }\n\n patchNode(nodeId, { isRunning: false, error: undefined });\n } catch (error) {\n patchNode(nodeId, { isRunning: false, error: error instanceof Error ? error.message : \"请求失败。\" });\n }\n }\n\n async function runVoiceDesign(nodeId: string) {\n const designNode = nodes.find((node) => node.id === nodeId);\n if (!designNode || designNode.type !== \"voiceDesign\") {\n return;\n }\n\n if (!apiKey) {\n patchNode(nodeId, { error: \"API Key 未配置,请点击顶部 API Key 区域配置。\" });\n return;\n }\n\n const voiceDescription = String(designNode.data.instruction || \"\").trim();\n const promptInputs = resolveVoiceDesignInputs(designNode, nodes, edges);\n const textItems = promptInputs.length > 0 ? promptInputs : [{ title: designNode.data.title, text: String(designNode.data.text || \"\").trim() }];\n\n if (!voiceDescription) {\n patchNode(nodeId, { error: \"请先填写音色描述。\" });\n return;\n }\n\n if (textItems.every((item) => !item.text.trim())) {\n patchNode(nodeId, { error: \"请连接提示词节点,或在节点内填写音频文本。\" });\n return;\n }\n\n patchNode(nodeId, { isRunning: true, error: undefined });\n\n try {\n for (const [index, item] of textItems.filter((entry) => entry.text.trim()).entries()) {\n const response = await fetch(\"/api/tts/voicedesign\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint },\n body: JSON.stringify({\n voiceDescription,\n text: item.text.trim(),\n format: \"wav\"\n })\n });\n const payload = (await response.json()) as DebugResponse & { error?: string; details?: unknown };\n\n if (!response.ok) {\n patchNode(nodeId, { isRunning: false, error: payload.error || `第 ${index + 1} 条音色创造失败。` });\n return;\n }\n\n const artifactNode = createArtifactNode(designNode, payload, item.title);\n const artifactEdge: StudioEdge = {\n id: createId(\"edge\"),\n source: designNode.id,\n sourceHandle: \"output\",\n target: artifactNode.id,\n targetHandle: \"artifact\",\n type: \"deletable\",\n animated: true,\n style: { stroke: \"#c5a45d\", strokeWidth: 2 }\n };\n\n setNodes((items) => items.concat(artifactNode));\n setEdges((items) => items.concat(artifactEdge));\n }\n\n patchNode(nodeId, { isRunning: false, error: undefined });\n } catch (error) {\n patchNode(nodeId, { isRunning: false, error: error instanceof Error ? error.message : \"请求失败。\" });\n }\n }\n\n async function optimizeVoiceStyle(nodeId: string) {\n const styleNode = nodes.find((node) => node.id === nodeId);\n if (!styleNode || styleNode.type !== \"voiceStyle\") {\n return;\n }\n\n if (!apiKey) {\n patchNode(nodeId, { error: \"API Key 未配置,请点击顶部 API Key 区域配置。\" });\n return;\n }\n\n const style = String(styleNode.data.text || \"\").trim();\n if (!style) {\n patchNode(nodeId, { error: \"请先填写需要优化的语音风格。\" });\n return;\n }\n\n patchNode(nodeId, { isRunning: true, error: undefined });\n\n try {\n const response = await fetch(\"/api/voice-style/optimize\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint },\n body: JSON.stringify({ style })\n });\n const payload = (await response.json()) as StyleOptimizeResponse;\n\n if (!response.ok) {\n patchNode(nodeId, { isRunning: false, error: payload.error || \"AI 优化失败。\" });\n return;\n }\n\n patchNode(nodeId, {\n text: payload.optimizedText,\n isRunning: false,\n error: undefined\n });\n } catch (error) {\n patchNode(nodeId, {\n isRunning: false,\n error: error instanceof Error ? error.message : \"AI 优化请求失败。\"\n });\n }\n }\n\n async function optimizeVoiceDesign(nodeId: string) {\n const designNode = nodes.find((node) => node.id === nodeId);\n if (!designNode || designNode.type !== \"voiceDesign\") {\n return;\n }\n\n if (!apiKey) {\n patchNode(nodeId, { error: \"API Key 未配置,请点击顶部 API Key 区域配置。\" });\n return;\n }\n\n const voiceDescription = String(designNode.data.instruction || \"\").trim();\n if (!voiceDescription) {\n patchNode(nodeId, { error: \"请先填写需要润色的音色描述。\" });\n return;\n }\n\n patchNode(nodeId, { isRunning: true, error: undefined });\n\n try {\n const response = await fetch(\"/api/voice-design/optimize\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", \"X-API-Key\": apiKey, \"X-API-Endpoint\": apiEndpoint },\n body: JSON.stringify({ voiceDescription })\n });\n const payload = (await response.json()) as StyleOptimizeResponse;\n\n if (!response.ok) {\n patchNode(nodeId, { isRunning: false, error: payload.error || \"AI 润色音色描述失败。\" });\n return;\n }\n\n patchNode(nodeId, {\n instruction: payload.optimizedText,\n isRunning: false,\n error: undefined\n });\n } catch (error) {\n patchNode(nodeId, {\n isRunning: false,\n error: error instanceof Error ? error.message : \"AI 润色音色描述请求失败。\"\n });\n }\n }\n\n async function downloadStashZip() {\n if (!activeWorkspace || activeWorkspace.type !== \"board\" || activeWorkspace.stashItems.length === 0) {\n return;\n }\n const items = activeWorkspace.stashItems;\n\n const zip = new JSZip();\n const usedNames = new Map<string, number>();\n for (const item of items) {\n const safeName = getUniqueFileName(getArtifactDownloadFileName(item.sourceNodeName || item.fileName, item.fileName), usedNames);\n zip.file(safeName, dataUrlToUint8Array(item.audioDataUrl));\n }\n\n const blob = await zip.generateAsync({ type: \"blob\" });\n const url = URL.createObjectURL(blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = `${sanitizeFileName(activeWorkspace.name)}-${formatDateForFile(new Date())}.zip`;\n link.click();\n URL.revokeObjectURL(url);\n }\n\n return (\n <main className=\"studio-shell\" onClick={() => setMenu(null)}>\n <header\n className={`studio-topbar ${topbarCollapsed ? \"collapsed\" : \"\"}`}\n onMouseEnter={() => {\n if (topbarHoverTimerRef.current) {\n window.clearTimeout(topbarHoverTimerRef.current);\n }\n }}\n onMouseLeave={() => {\n if (!showApiKeyModal) {\n topbarHoverTimerRef.current = window.setTimeout(() => setTopbarCollapsed(true), 2000);\n }\n }}\n >\n <div className=\"brand-block\">\n <span className=\"brand-kicker\">ZHUGUANG AUDIO WORKSTATION</span>\n <h1>铸光音频工作站</h1>\n </div>\n <div className=\"topbar-actions\">\n <StatusPill apiKey={apiKey} onOpenModal={openApiKeyModal} />\n <button type=\"button\" onClick={() => void saveWorkspace()}>\n <Save size={16} />\n {isSaving ? \"保存中\" : \"保存\"}\n </button>\n </div>\n </header>\n\n {apiKey === DEFAULT_API_KEY && showDefaultKeyWarning ? (\n <section className=\"api-warning\">\n <AlertTriangle size={18} />\n <span>当前使用的是默认 API Key,不保证长期可用。请点击右上角 API Key 区域配置自己的密钥。</span>\n <button className=\"api-warning-close\" type=\"button\" onClick={() => setShowDefaultKeyWarning(false)}>\n <X size={16} />\n </button>\n </section>\n ) : null}\n\n {showApiKeyModal && (\n <div className=\"api-key-modal\" onClick={closeApiKeyModal}>\n <div className=\"api-key-modal-content\" onClick={(e) => e.stopPropagation()}>\n <div className=\"api-key-modal-header\">\n <h3>\n <Key size={18} />\n 配置 API Key\n </h3>\n <button className=\"api-key-modal-close\" type=\"button\" onClick={closeApiKeyModal}>\n <X size={18} />\n </button>\n </div>\n <div className=\"api-key-modal-body\">\n <p className=\"api-key-modal-hint\">\n 请输入您的 MiMo API Key。可前往{\" \"}\n <a href=\"https://platform.xiaomimimo.com/\" target=\"_blank\" rel=\"noopener noreferrer\">\n platform.xiaomimimo.com\n </a>{\" \"}\n 获取。\n </p>\n <input\n type=\"text\"\n className=\"api-key-input\"\n value={apiKeyInput}\n onChange={(e) => setApiKeyInput(e.target.value)}\n placeholder=\"sk-...\"\n spellCheck={false}\n />\n {apiKey === DEFAULT_API_KEY && (\n <p className=\"api-key-modal-warn\">\n <AlertTriangle size={14} />\n 当前使用的默认 Key 不可长期使用,不保证可用性,建议尽快配置自己的密钥。\n </p>\n )}\n <div className=\"api-endpoint-section\">\n <p className=\"api-key-modal-hint\">\n API 地址(可选,留空使用默认地址):\n </p>\n <input\n type=\"text\"\n className=\"api-key-input\"\n value={apiEndpointInput}\n onChange={(e) => setApiEndpointInput(e.target.value)}\n placeholder=\"https://api.xiaomimimo.com/v1/chat/completions\"\n spellCheck={false}\n />\n <p className=\"api-endpoint-hint\">\n token 套餐计划可使用:https://token-plan-cn.xiaomimimo.com/v1/chat/completions\n </p>\n <p className=\"api-endpoint-hint\">\n 非套餐请保持默认\n </p>\n </div>\n </div>\n <div className=\"api-key-modal-footer\">\n <button type=\"button\" className=\"api-key-btn-cancel\" onClick={closeApiKeyModal}>\n 取消\n </button>\n <button type=\"button\" className=\"api-key-btn-save\" onClick={saveApiKey}>\n 保存\n </button>\n </div>\n </div>\n </div>\n )}\n\n <section className=\"studio-layout\">\n <aside className={`board-sidebar ${sidebarCollapsed ? \"collapsed\" : \"\"}`}>\n <div className=\"sidebar-title\">\n <PanelTop size={17} />\n <span>画板库</span>\n <button\n className=\"sidebar-toggle\"\n type=\"button\"\n onClick={() => setSidebarCollapsed((value) => !value)}\n title={sidebarCollapsed ? \"展开画板库\" : \"折叠画板库\"}\n >\n {sidebarCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}\n </button>\n </div>\n {!sidebarCollapsed && (\n <>\n <button className=\"new-board\" type=\"button\" onClick={() => setBoardDialog(\"choice\")}>\n <Plus size={16} />\n 新建画板\n </button>\n <div className=\"board-list\">\n {workspaces.map((workspace) => (\n <div className=\"board-list-entry\" key={workspace.id}>\n <button\n className={workspace.id === activeWorkspace?.id ? \"board-item active\" : \"board-item\"}\n type=\"button\"\n onClick={() => void loadWorkspace(workspace.id)}\n >\n <strong>\n {workspace.type === \"audiobook\" ? <BookOpen size={14} style={{ marginRight: 6, verticalAlign: \"middle\" }} /> : null}\n {workspace.name}\n </strong>\n <span>\n {workspace.type === \"audiobook\"\n ? `${workspace.characterCount ?? 0} 角色 / ${workspace.segmentCount ?? 0} 段落`\n : `${workspace.nodeCount ?? 0} 节点 / ${workspace.edgeCount ?? 0} 连线`}\n </span>\n </button>\n {workspace.id === activeWorkspace?.id && activeWorkspace.type === \"board\" && activeWorkspace.stashItems.length > 0 ? (\n <StashPanel\n isOpen={isStashOpen}\n items={activeWorkspace.stashItems}\n onBatchDownload={() => void downloadStashZip()}\n onDelete={deleteStashItem}\n onToggle={() => setIsStashOpen((value) => !value)}\n />\n ) : null}\n </div>\n ))}\n </div>\n <button className=\"danger subtle\" type=\"button\" onClick={() => void deleteWorkspace()} disabled={!activeWorkspace}>\n <Trash2 size={16} />\n 删除当前画板\n </button>\n </>\n )}\n </aside>\n\n {activeWorkspace?.type === \"audiobook\" ? (\n <AudiobookConsole\n workspace={activeWorkspace}\n apiKey={apiKey}\n apiEndpoint={apiEndpoint}\n onPatch={patchAudiobook}\n onAddChapter={addAudiobookChapter}\n onActivateChapter={(chapterId) => void activateAudiobookChapter(chapterId)}\n onCreateCharacter={createAudiobookCharacter}\n onAnalyze={analyzeAudiobookCharacters}\n onGenerateVoice={(charId) => void generateCharacterVoice(charId)}\n onDeleteVoice={(charId) => void deleteCharacterVoice(charId)}\n onAutoAnnotate={autoAnnotateAudiobook}\n onUpdateSegment={(segId, patch) => void updateAudiobookSegment(segId, patch)}\n onSaveWorkspace={saveWorkspace}\n onGenerate={generateAudiobookAudio}\n onRetryProduct={retryAudiobookProduct}\n />\n ) : (\n <section className=\"canvas-panel\">\n <div className=\"canvas-titlebar\">\n <input\n value={activeWorkspace?.name ?? \"\"}\n onChange={(event) => patchWorkspaceName(event.target.value)}\n onBlur={() => void saveWorkspace()}\n placeholder=\"未命名工作台\"\n />\n <span>右键画布添加节点,拖动端口建立连接</span>\n </div>\n <div className=\"flow-wrap\" onContextMenu={openContextMenu}>\n <ReactFlow<StudioNode, StudioEdge>\n nodes={hydratedNodes}\n edges={hydratedEdges}\n nodeTypes={nodeTypes}\n edgeTypes={edgeTypes}\n onInit={(instance) => {\n flowRef.current = instance;\n }}\n onNodesChange={onNodesChange}\n onEdgesChange={onEdgesChange}\n onConnect={onConnect}\n fitView\n proOptions={{ hideAttribution: true }}\n >\n <Background color=\"#3f3a2d\" gap={34} size={1.2} variant={BackgroundVariant.Dots} />\n <Controls />\n <MiniMap pannable zoomable nodeColor=\"#c5a45d\" maskColor=\"rgba(8, 8, 7, 0.72)\" />\n </ReactFlow>\n {menu ? <ContextMenu menu={menu} onAdd={addNode} /> : null}\n </div>\n </section>\n )}\n </section>\n {boardDialog ? (\n <BoardCreateDialog\n mode={boardDialog}\n onClose={() => setBoardDialog(null)}\n onCreateBlank={() => {\n setBoardDialog(null);\n void createWorkspace();\n }}\n onCreateSmart={createSmartWorkspace}\n onCreateAudiobook={async (data) => {\n await createAudiobookWorkspace(data);\n }}\n onSwitchMode={setBoardDialog}\n />\n ) : null}\n </main>\n );\n}\n\nfunction BoardCreateDialog({\n mode,\n onClose,\n onCreateBlank,\n onCreateSmart,\n onCreateAudiobook,\n onSwitchMode\n}: {\n mode: \"choice\" | \"smart\" | \"audiobook\";\n onClose: () => void;\n onCreateBlank: () => void;\n onCreateSmart: (formData: FormData) => Promise<void>;\n onCreateAudiobook: (data: { novelText: string; characterHints: string }) => Promise<void>;\n onSwitchMode: (mode: \"choice\" | \"smart\" | \"audiobook\") => void;\n}) {\n const [voiceFile, setVoiceFile] = useState<File | null>(null);\n const [voicePreviewUrl, setVoicePreviewUrl] = useState<string | null>(null);\n const [sceneDescription, setSceneDescription] = useState(\"\");\n const [script, setScript] = useState(\"\");\n const [error, setError] = useState<string | null>(null);\n const [isGenerating, setIsGenerating] = useState(false);\n const [progress, setProgress] = useState(0);\n const [progressText, setProgressText] = useState(\"\");\n const [isCreatingAudiobook, setIsCreatingAudiobook] = useState(false);\n const [isRecording, setIsRecording] = useState(false);\n const [recordingSeconds, setRecordingSeconds] = useState(0);\n // 有声书表单状态\n const [novelText, setNovelText] = useState(\"\");\n const [characterHints, setCharacterHints] = useState(\"\");\n const mediaRecorderRef = useRef<MediaRecorder | null>(null);\n const recordingChunksRef = useRef<BlobPart[]>([]);\n const recordingStreamRef = useRef<MediaStream | null>(null);\n const recordingTimerRef = useRef<number | null>(null);\n const progressTimerRef = useRef<number | null>(null);\n\n useEffect(() => {\n return () => {\n stopRecordingTimer();\n stopRecordingStream();\n if (progressTimerRef.current) {\n window.clearTimeout(progressTimerRef.current);\n }\n };\n }, []);\n\n const progressStages = [\n { percent: 15, text: \"正在读取...\" },\n { percent: 35, text: \"正在分析情感...\" },\n { percent: 55, text: \"正在规划画板...\" },\n { percent: 75, text: \"画板创建中...\" },\n { percent: 90, text: \"正在收尾...\" },\n { percent: 95, text: \"马上就好了...\" }\n ];\n\n const audiobookProgressStages = [\n { percent: 12, text: \"正在连接文段切分模型...\" },\n { percent: 28, text: \"正在阅读小说原文...\" },\n { percent: 45, text: \"正在识别旁白和角色对话...\" },\n { percent: 62, text: \"正在拆分单一说话人片段...\" },\n { percent: 82, text: \"正在整理有声书结构...\" },\n { percent: 95, text: \"即将创建画板...\" }\n ];\n\n function startProgressSimulation(stages = progressStages, initialText = \"正在连接 AI 服务...\") {\n setProgress(0);\n setProgressText(initialText);\n let stageIndex = 0;\n\n function advance() {\n if (stageIndex < stages.length) {\n const stage = stages[stageIndex];\n setProgress(stage.percent);\n setProgressText(stage.text);\n stageIndex++;\n const delay = 3000 + Math.random() * 2000;\n progressTimerRef.current = window.setTimeout(advance, delay);\n }\n }\n\n progressTimerRef.current = window.setTimeout(advance, 500);\n }\n\n function stopProgressSimulation(finalProgress: number) {\n if (progressTimerRef.current) {\n window.clearTimeout(progressTimerRef.current);\n progressTimerRef.current = null;\n }\n setProgress(finalProgress);\n }\n\n async function setSmartVoiceFile(file: File) {\n if (!allowedAudioTypes.has(file.type) && !/\\.(mp3|m4a|mp4|wav)$/i.test(file.name)) {\n setError(\"仅支持 mp3、m4a/mp4 或 wav 参考音频。\");\n return;\n }\n\n if (file.size > maxAudioBytes) {\n setError(`参考音频不能超过 ${formatBytes(maxAudioBytes)}。`);\n return;\n }\n\n setVoiceFile(file);\n setVoicePreviewUrl(await blobToDataUrl(file));\n setError(null);\n }\n\n function onFileChange(event: ChangeEvent<HTMLInputElement>) {\n const file = event.target.files?.[0];\n if (file) {\n void setSmartVoiceFile(file);\n }\n }\n\n async function startRecording() {\n if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === \"undefined\") {\n setError(\"当前浏览器不支持录音,请改用上传音频文件。\");\n return;\n }\n\n try {\n const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n const mimeType = getSupportedRecordingMimeType();\n const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);\n\n recordingChunksRef.current = [];\n recordingStreamRef.current = stream;\n mediaRecorderRef.current = recorder;\n setRecordingSeconds(0);\n setIsRecording(true);\n setError(null);\n\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) {\n recordingChunksRef.current.push(event.data);\n }\n };\n recorder.onstop = () => {\n void commitRecording(recorder.mimeType || mimeType || \"audio/webm\");\n };\n recorder.start();\n recordingTimerRef.current = window.setInterval(() => {\n setRecordingSeconds((value) => value + 1);\n }, 1000);\n } catch (recordingError) {\n stopRecordingStream();\n setIsRecording(false);\n setError(recordingError instanceof Error ? recordingError.message : \"录音启动失败。\");\n }\n }\n\n function stopRecording() {\n const recorder = mediaRecorderRef.current;\n if (recorder && recorder.state !== \"inactive\") {\n recorder.stop();\n }\n setIsRecording(false);\n stopRecordingTimer();\n }\n\n async function commitRecording(mimeType: string) {\n const chunks = recordingChunksRef.current;\n recordingChunksRef.current = [];\n stopRecordingStream();\n\n if (chunks.length === 0) {\n setError(\"没有录到有效音频。\");\n return;\n }\n\n try {\n const recordedBlob = new Blob(chunks, { type: mimeType });\n const wavBlob = await convertRecordedBlobToWav(recordedBlob);\n const fileName = `smart-reference-${formatDateForFile(new Date())}.wav`;\n const file = new File([wavBlob], fileName, { type: \"audio/wav\" });\n await setSmartVoiceFile(file);\n } catch (recordingError) {\n setError(recordingError instanceof Error ? recordingError.message : \"录音处理失败。\");\n }\n }\n\n function stopRecordingTimer() {\n if (recordingTimerRef.current) {\n window.clearInterval(recordingTimerRef.current);\n recordingTimerRef.current = null;\n }\n }\n\n function stopRecordingStream() {\n recordingStreamRef.current?.getTracks().forEach((track) => track.stop());\n recordingStreamRef.current = null;\n mediaRecorderRef.current = null;\n }\n\n async function submitSmartWorkspace() {\n const paragraphs = splitScriptInput(script);\n if (!sceneDescription.trim()) {\n setError(\"请填写场景描述。\");\n return;\n }\n if (paragraphs.length === 0) {\n setError(\"请填写台词文稿,并使用 ---- 分隔独立段落。\");\n return;\n }\n\n setIsGenerating(true);\n setError(null);\n startProgressSimulation();\n try {\n const formData = new FormData();\n if (voiceFile) {\n formData.append(\"voice\", voiceFile);\n }\n formData.append(\"sceneDescription\", sceneDescription.trim());\n formData.append(\"script\", script.trim());\n await onCreateSmart(formData);\n stopProgressSimulation(100);\n setProgressText(\"我正在全速处理\");\n await new Promise((resolve) => setTimeout(resolve, 500));\n onClose();\n } catch (submitError) {\n stopProgressSimulation(0);\n setError(submitError instanceof Error ? submitError.message : \"智能画板生成失败。\");\n } finally {\n setIsGenerating(false);\n }\n }\n\n async function submitAudiobookWorkspace() {\n if (!novelText.trim()) {\n setError(\"请输入小说原文。\");\n return;\n }\n\n setIsCreatingAudiobook(true);\n setError(null);\n startProgressSimulation(audiobookProgressStages, \"正在连接文段切分模型...\");\n try {\n await onCreateAudiobook({ novelText: novelText.trim(), characterHints: characterHints.trim() });\n stopProgressSimulation(100);\n setProgressText(\"有声书创建完成\");\n await new Promise((resolve) => setTimeout(resolve, 500));\n onClose();\n } catch (submitError) {\n stopProgressSimulation(0);\n setError(submitError instanceof Error ? submitError.message : \"创建有声书失败。\");\n } finally {\n setIsCreatingAudiobook(false);\n }\n }\n\n return (\n <div className=\"modal-backdrop\" onClick={() => {\n if (!isGenerating && !isCreatingAudiobook) onClose();\n }}>\n <section className=\"board-modal\" onClick={(event) => event.stopPropagation()}>\n <header className=\"modal-header\">\n <div>\n <strong>{mode === \"choice\" ? \"新建画板\" : mode === \"smart\" ? \"智能画板\" : \"智能有声书\"}</strong>\n <span>{mode === \"choice\" ? \"选择创建方式\" : mode === \"smart\" ? \"根据场景、文稿和可选参考音频生成工作流\" : \"输入小说原文和人物信息,AI自动创建角色音色并生成有声书\"}</span>\n </div>\n <button className=\"icon-button\" type=\"button\" onClick={onClose} title=\"关闭\" disabled={isGenerating || isCreatingAudiobook}>\n ×\n </button>\n </header>\n\n {mode === \"choice\" ? (\n <div className=\"create-choice-grid\">\n <button type=\"button\" onClick={onCreateBlank}>\n <PanelTop size={18} />\n <span>空白画板</span>\n <small>创建一个新的空白画板</small>\n </button>\n <button type=\"button\" onClick={() => onSwitchMode(\"smart\")}>\n <Sparkles size={18} />\n <span>智能画板</span>\n <small>有参考音频则生成克隆链路,无参考音频则生成音色创造链路</small>\n </button>\n <button type=\"button\" onClick={() => onSwitchMode(\"audiobook\")}>\n <BookOpen size={18} />\n <span>智能有声书</span>\n <small>输入小说原文和人物信息,AI自动创建角色音色并生成有声书</small>\n </button>\n </div>\n ) : mode === \"smart\" ? (\n <div className=\"smart-board-form\">\n <label className=\"file-picker nodrag\">\n <input accept=\"audio/*,video/mp4,.mp3,.m4a,.mp4,.wav\" type=\"file\" onChange={onFileChange} />\n <span>{voiceFile ? voiceFile.name : \"上传参考音频(可选)\"}</span>\n <small>{voiceFile ? `${voiceFile.type || \"audio\"} · ${formatBytes(voiceFile.size)}` : \"不上传时,将使用音色创造节点生成每段音频\"}</small>\n </label>\n <div className=\"recording-panel nodrag\">\n <button className={isRecording ? \"record-button recording\" : \"record-button\"} type=\"button\" onClick={() => void startRecording()} disabled={isRecording || isGenerating}>\n <Mic2 size={15} />\n 开始录制\n </button>\n <button className=\"record-stop-button\" type=\"button\" onClick={stopRecording} disabled={!isRecording || isGenerating}>\n <Square size={13} />\n 停止\n </button>\n <span>{isRecording ? `录制中 ${formatTime(recordingSeconds)}` : \"当场录制参考音频\"}</span>\n </div>\n {voicePreviewUrl ? <StudioAudioPlayer src={voicePreviewUrl} /> : null}\n <label className=\"node-field\">\n <span>场景描述</span>\n <textarea value={sceneDescription} onChange={(event) => setSceneDescription(event.target.value)} rows={4} placeholder=\"用简单的关键词去描述这段文本的语境语气并补充必要的信息,帮助模型理解需求\" />\n </label>\n <label className=\"node-field\">\n <span>完整台词文稿</span>\n <textarea\n value={script}\n onChange={(event) => setScript(event.target.value)}\n rows={8}\n placeholder=\"这里输入需要模型朗读的内容,段落使用 ---- 分割,每段建议小于100字\"\n />\n </label>\n {error ? <p className=\"node-error\">{error}</p> : null}\n {isGenerating && (\n <div className=\"smart-progress\">\n <div className=\"smart-progress-bar\">\n <div className=\"smart-progress-fill\" style={{ width: `${progress}%` }} />\n </div>\n <p className=\"smart-progress-text\">{progressText}</p>\n </div>\n )}\n <div className=\"modal-actions\">\n <button type=\"button\" onClick={() => onSwitchMode(\"choice\")} disabled={isGenerating}>\n 返回\n </button>\n <button className=\"run-button\" type=\"button\" onClick={() => void submitSmartWorkspace()} disabled={isGenerating || isRecording}>\n {isGenerating ? <Loader2 className=\"spin\" size={16} /> : <Sparkles size={16} />}\n {isGenerating ? \"生成中\" : \"生成智能画板\"}\n </button>\n </div>\n </div>\n ) : (\n <div className=\"smart-board-form\">\n <label className=\"node-field\">\n <span>小说原文</span>\n <textarea\n value={novelText}\n onChange={(event) => setNovelText(event.target.value)}\n rows={10}\n placeholder=\"粘贴小说原文,AI 会按旁白和角色对话切分片段\"\n disabled={isCreatingAudiobook}\n />\n </label>\n <label className=\"node-field\">\n <span>关键人物背景信息</span>\n <textarea\n value={characterHints}\n onChange={(event) => setCharacterHints(event.target.value)}\n rows={4}\n placeholder={\"每行一个角色,格式:角色名,性别,年龄,声音特点\\n例如:林黛玉,女,16岁,声音清脆柔弱,略带哀愁\"}\n disabled={isCreatingAudiobook}\n />\n </label>\n {error ? <p className=\"node-error\">{error}</p> : null}\n {isCreatingAudiobook && (\n <div className=\"smart-progress\">\n <div className=\"smart-progress-bar\">\n <div className=\"smart-progress-fill\" style={{ width: `${progress}%` }} />\n </div>\n <p className=\"smart-progress-text\">{progressText}</p>\n </div>\n )}\n <div className=\"modal-actions\">\n <button type=\"button\" onClick={() => onSwitchMode(\"choice\")} disabled={isCreatingAudiobook}>\n 返回\n </button>\n <button\n className=\"run-button\"\n type=\"button\"\n onClick={() => void submitAudiobookWorkspace()}\n disabled={isCreatingAudiobook}\n >\n {isCreatingAudiobook ? <Loader2 className=\"spin\" size={16} /> : <BookOpen size={16} />}\n {isCreatingAudiobook ? \"切分中...\" : \"创建有声书\"}\n </button>\n </div>\n </div>\n )}\n </section>\n </div>\n );\n}\n\n// ====== 有声书控制台组件 ======\n\nfunction AudiobookConsole({\n workspace,\n apiKey,\n apiEndpoint,\n onPatch,\n onAddChapter,\n onActivateChapter,\n onCreateCharacter,\n onAnalyze,\n onGenerateVoice,\n onDeleteVoice,\n onAutoAnnotate,\n onUpdateSegment,\n onSaveWorkspace,\n onGenerate,\n onRetryProduct\n}: {\n workspace: AudiobookWorkspacePayload;\n apiKey: string;\n apiEndpoint: string;\n onPatch: (patch: Partial<AudiobookWorkspacePayload>) => void;\n onAddChapter: (data: { title: string; novelText: string; characterHints: string }) => Promise<void>;\n onActivateChapter: (chapterId: string) => void;\n onCreateCharacter: (formData: FormData) => Promise<void>;\n onAnalyze: () => Promise<void>;\n onGenerateVoice: (charId: string) => void;\n onDeleteVoice: (charId: string) => void;\n onAutoAnnotate: () => Promise<void>;\n onUpdateSegment: (segId: string, patch: { characterId: string | null; characterName: string; emotion: string }) => void;\n onSaveWorkspace: () => Promise<void>;\n onGenerate: () => Promise<void>;\n onRetryProduct: (productId: string) => Promise<void>;\n}) {\n const [isAnalyzing, setIsAnalyzing] = useState(false);\n const [analyzeProgress, setAnalyzeProgress] = useState(0);\n const [analyzeText, setAnalyzeText] = useState(\"\");\n const [editingSegId, setEditingSegId] = useState<string | null>(null);\n const [editCharId, setEditCharId] = useState<string>(\"\");\n const [editEmotion, setEditEmotion] = useState(\"\");\n const [isAnnotating, setIsAnnotating] = useState(false);\n const [annotateProgress, setAnnotateProgress] = useState(0);\n const [annotateText, setAnnotateText] = useState(\"\");\n const [isGenerating, setIsGenerating] = useState(false);\n const [chapterTitle, setChapterTitle] = useState(\"\");\n const [chapterText, setChapterText] = useState(\"\");\n const [chapterHints, setChapterHints] = useState(\"\");\n const [isAddingChapter, setIsAddingChapter] = useState(false);\n const [characterName, setCharacterName] = useState(\"\");\n const [characterRoleType, setCharacterRoleType] = useState<AudiobookCharacter[\"roleType\"]>(\"supporting\");\n const [characterVoiceMode, setCharacterVoiceMode] = useState<AudiobookCharacter[\"voiceMode\"]>(\"designed\");\n const [characterDescription, setCharacterDescription] = useState(\"\");\n const [characterTraits, setCharacterTraits] = useState(\"\");\n const [characterAliases, setCharacterAliases] = useState(\"\");\n const [characterVoiceFile, setCharacterVoiceFile] = useState<File | null>(null);\n const [isCreatingCharacter, setIsCreatingCharacter] = useState(false);\n const productSectionRef = useRef<HTMLDivElement | null>(null);\n const progressTimerRef = useRef<number | null>(null);\n const runningActionRef = useRef<\"analyze\" | \"annotate\" | \"generate\" | null>(null);\n\n const allVoicesReady = workspace.characters.length > 0 && workspace.characters.every((c) => c.voiceStatus === \"ready\");\n const hasAnnotations = workspace.segments.some((s) => s.characterId || s.characterName);\n const hasUsedAutoAnnotation = workspace.segments.some((s) => s.isAutoAnnotated);\n const showProductSection = isGenerating || workspace.phase === \"generation\" || workspace.products.length > 0;\n const allProductsReady = workspace.products.length > 0 && workspace.products.every((product) => product.status === \"ready\" && product.audioDataUrl);\n const activeChapter = workspace.chapters.find((chapter) => chapter.id === workspace.activeChapterId) ?? workspace.chapters[0];\n const narrator = workspace.characters.find((character) => character.roleType === \"narrator\");\n\n useEffect(() => {\n if (!isGenerating) {\n return;\n }\n window.requestAnimationFrame(() => {\n productSectionRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n });\n }, [isGenerating]);\n\n function startProgress(\n setProgress: (v: number) => void,\n setText: (v: string) => void,\n stages: { percent: number; text: string }[]\n ) {\n let stageIndex = 0;\n let currentPercent = 5;\n setProgress(5);\n setText(stages[0]?.text || \"处理中...\");\n function advance() {\n if (stageIndex < stages.length) {\n currentPercent = stages[stageIndex].percent;\n setProgress(currentPercent);\n setText(stages[stageIndex].text);\n stageIndex++;\n progressTimerRef.current = window.setTimeout(advance, 2000 + Math.random() * 2000);\n } else {\n // 模拟阶段结束,继续缓慢脉冲动画直到 stopProgress 被调用\n const base = currentPercent;\n const pulse = Math.min(base + 2 + Math.random() * 3, 95);\n setProgress(pulse);\n progressTimerRef.current = window.setTimeout(() => {\n setProgress(base);\n progressTimerRef.current = window.setTimeout(advance, 3000 + Math.random() * 2000);\n }, 1500);\n }\n }\n progressTimerRef.current = window.setTimeout(advance, 800);\n }\n\n function stopProgress(finalProgress: number, setProgress: (v: number) => void) {\n if (progressTimerRef.current) {\n window.clearTimeout(progressTimerRef.current);\n progressTimerRef.current = null;\n }\n setProgress(finalProgress);\n }\n\n async function handleAnalyze() {\n if (runningActionRef.current) {\n return;\n }\n runningActionRef.current = \"analyze\";\n setIsAnalyzing(true);\n setAnalyzeProgress(0);\n startProgress(setAnalyzeProgress, setAnalyzeText, [\n { percent: 15, text: \"正在连接 AI 服务...\" },\n { percent: 35, text: \"正在阅读小说原文...\" },\n { percent: 55, text: \"识别出场人物...\" },\n { percent: 75, text: \"生成角色描述...\" },\n { percent: 90, text: \"生成音色描述...\" },\n { percent: 95, text: \"即将完成...\" }\n ]);\n try {\n await onAnalyze();\n stopProgress(100, setAnalyzeProgress);\n setAnalyzeText(\"分析完成!\");\n } catch {\n stopProgress(0, setAnalyzeProgress);\n setAnalyzeText(\"分析失败,请重试\");\n } finally {\n setTimeout(() => {\n runningActionRef.current = null;\n setIsAnalyzing(false);\n }, 500);\n }\n }\n\n async function handleAutoAnnotate() {\n if (runningActionRef.current || hasUsedAutoAnnotation) {\n return;\n }\n runningActionRef.current = \"annotate\";\n setIsAnnotating(true);\n setAnnotateProgress(0);\n startProgress(setAnnotateProgress, setAnnotateText, [\n { percent: 15, text: \"正在连接 AI 服务...\" },\n { percent: 35, text: \"分析段落内容...\" },\n { percent: 55, text: \"识别对话角色...\" },\n { percent: 75, text: \"生成朗读情绪...\" },\n { percent: 90, text: \"整理标注结果...\" },\n { percent: 95, text: \"即将完成...\" }\n ]);\n try {\n await onAutoAnnotate();\n stopProgress(100, setAnnotateProgress);\n setAnnotateText(\"标注完成!\");\n } catch {\n stopProgress(0, setAnnotateProgress);\n setAnnotateText(\"标注失败,请重试\");\n } finally {\n setTimeout(() => {\n runningActionRef.current = null;\n setIsAnnotating(false);\n }, 500);\n }\n }\n\n async function handleGenerate() {\n if (runningActionRef.current) {\n return;\n }\n runningActionRef.current = \"generate\";\n setIsGenerating(true);\n try {\n await onSaveWorkspace();\n await onGenerate();\n } catch (error) {\n console.error(\"[audiobook:generate] failed\", error);\n } finally {\n setTimeout(() => {\n runningActionRef.current = null;\n setIsGenerating(false);\n }, 500);\n }\n }\n\n async function handleRetryProduct(productId: string) {\n await onRetryProduct(productId);\n }\n\n async function handleAddChapter() {\n if (!chapterText.trim() || isAddingChapter) {\n return;\n }\n setIsAddingChapter(true);\n try {\n await onAddChapter({\n title: chapterTitle.trim() || `章节 ${workspace.chapters.length + 1}`,\n novelText: chapterText,\n characterHints: chapterHints\n });\n setChapterTitle(\"\");\n setChapterText(\"\");\n setChapterHints(\"\");\n } finally {\n setIsAddingChapter(false);\n }\n }\n\n async function handleCreateCharacter() {\n if (!characterName.trim() || isCreatingCharacter) {\n return;\n }\n setIsCreatingCharacter(true);\n try {\n const formData = new FormData();\n formData.append(\"name\", characterName.trim());\n formData.append(\"roleType\", characterRoleType);\n formData.append(\"aliases\", characterAliases);\n formData.append(\"voiceTraits\", characterTraits);\n formData.append(\"voiceDescription\", characterDescription);\n formData.append(\"personality\", characterTraits || characterDescription);\n if (characterVoiceMode === \"cloned\" && characterVoiceFile) {\n formData.append(\"voice\", characterVoiceFile);\n }\n await onCreateCharacter(formData);\n setCharacterName(\"\");\n setCharacterRoleType(\"supporting\");\n setCharacterVoiceMode(\"designed\");\n setCharacterDescription(\"\");\n setCharacterTraits(\"\");\n setCharacterAliases(\"\");\n setCharacterVoiceFile(null);\n } finally {\n setIsCreatingCharacter(false);\n }\n }\n\n async function downloadAudiobookProductsZip() {\n if (!allProductsReady) {\n return;\n }\n\n const zip = new JSZip();\n const usedNames = new Map<string, number>();\n workspace.products.forEach((product, index) => {\n if (!product.audioDataUrl) {\n return;\n }\n const indexLabel = String(index + 1).padStart(2, \"0\");\n const characterName = sanitizeFileName(product.characterName || \"旁白\").replace(/\\.[a-z0-9]{1,8}$/i, \"\");\n const fileName = getUniqueFileName(`segment-${indexLabel}-${characterName}.wav`, usedNames);\n zip.file(fileName, dataUrlToUint8Array(product.audioDataUrl));\n });\n\n const blob = await zip.generateAsync({ type: \"blob\" });\n const url = URL.createObjectURL(blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = `${sanitizeFileName(workspace.name)}-${formatDateForFile(new Date())}.zip`;\n link.click();\n URL.revokeObjectURL(url);\n }\n\n function startEditSegment(segId: string) {\n const seg = workspace.segments.find((s) => s.id === segId);\n if (seg) {\n setEditingSegId(segId);\n setEditCharId(seg.characterId || narrator?.id || \"\");\n setEditEmotion(seg.emotion);\n }\n }\n\n function saveEditSegment() {\n if (!editingSegId) return;\n const char = workspace.characters.find((c) => c.id === editCharId);\n onUpdateSegment(editingSegId, {\n characterId: editCharId || narrator?.id || null,\n characterName: char?.name || \"旁白\",\n emotion: editEmotion\n });\n setEditingSegId(null);\n }\n\n return (\n <section className=\"audiobook-panel\">\n <div className=\"audiobook-titlebar\">\n <BookOpen size={16} />\n <input\n value={workspace.name}\n onChange={(event) => onPatch({ name: event.target.value })}\n placeholder=\"未命名有声书\"\n />\n <span className=\"audiobook-phase-badge\">\n {workspace.phase === \"character-creation\" ? \"角色创建\" : workspace.phase === \"annotation\" ? \"文本标注\" : \"语音生成\"}\n </span>\n </div>\n\n <div className=\"audiobook-body\">\n <div className=\"audiobook-section\">\n <div className=\"section-header\">\n <h3>章节项目</h3>\n <span className=\"section-hint inline\">当前:{activeChapter?.title || \"未命名章节\"}</span>\n </div>\n <div className=\"chapter-tabs\">\n {workspace.chapters.map((chapter, index) => (\n <button\n key={chapter.id}\n type=\"button\"\n className={chapter.id === workspace.activeChapterId ? \"chapter-tab active\" : \"chapter-tab\"}\n onClick={() => onActivateChapter(chapter.id)}\n >\n <BookOpen size={13} />\n {chapter.title || `章节 ${index + 1}`}\n </button>\n ))}\n </div>\n <div className=\"chapter-create-panel\">\n <input\n value={chapterTitle}\n onChange={(event) => setChapterTitle(event.target.value)}\n placeholder={`章节 ${workspace.chapters.length + 1}`}\n />\n <textarea\n value={chapterText}\n onChange={(event) => setChapterText(event.target.value)}\n placeholder=\"粘贴下一章或新的小说片段,系统会加入当前书籍项目并复用音色库\"\n rows={4}\n />\n <textarea\n value={chapterHints}\n onChange={(event) => setChapterHints(event.target.value)}\n placeholder=\"可选:本章新增人物说明,每行一个角色\"\n rows={2}\n />\n <button className=\"run-button\" type=\"button\" onClick={() => void handleAddChapter()} disabled={isAddingChapter || !chapterText.trim()}>\n {isAddingChapter ? <Loader2 className=\"spin\" size={14} /> : <BookPlus size={14} />}\n {isAddingChapter ? \"新增中...\" : \"添加章节\"}\n </button>\n </div>\n </div>\n\n <div className=\"audiobook-section\">\n <div className=\"section-header\">\n <h3>书籍音色库</h3>\n {narrator ? <span className=\"section-hint inline\">旁白:{narrator.voiceStatus === \"ready\" ? \"已准备\" : \"待生成\"}</span> : null}\n </div>\n <div className=\"manual-character-panel\">\n <input value={characterName} onChange={(event) => setCharacterName(event.target.value)} placeholder=\"音色角色名称,如:主角 / 特邀主播A\" />\n <select value={characterRoleType} onChange={(event) => setCharacterRoleType(event.target.value as AudiobookCharacter[\"roleType\"])}>\n <option value=\"protagonist\">主角</option>\n <option value=\"supporting\">配角</option>\n <option value=\"narrator\">旁白</option>\n <option value=\"custom\">自定义</option>\n </select>\n <select value={characterVoiceMode} onChange={(event) => setCharacterVoiceMode(event.target.value as AudiobookCharacter[\"voiceMode\"])}>\n <option value=\"designed\">自然语言音色设计</option>\n <option value=\"cloned\">上传参考音频克隆</option>\n </select>\n <input value={characterAliases} onChange={(event) => setCharacterAliases(event.target.value)} placeholder=\"别名/称呼,可用逗号分隔\" />\n <textarea value={characterTraits} onChange={(event) => setCharacterTraits(event.target.value)} placeholder=\"性格、身份、声音特点\" rows={2} />\n {characterVoiceMode === \"designed\" ? (\n <textarea value={characterDescription} onChange={(event) => setCharacterDescription(event.target.value)} placeholder=\"音色描述:男女老少、声音质感、气质等\" rows={2} />\n ) : (\n <label className=\"upload-row\">\n <FileAudio size={14} />\n <span>{characterVoiceFile ? characterVoiceFile.name : \"上传参考音频\"}</span>\n <input\n type=\"file\"\n accept=\"audio/mpeg,audio/mp3,audio/mp4,audio/m4a,audio/wav,audio/wave,video/mp4\"\n onChange={(event) => setCharacterVoiceFile(event.target.files?.[0] ?? null)}\n />\n </label>\n )}\n <button className=\"run-button\" type=\"button\" onClick={() => void handleCreateCharacter()} disabled={isCreatingCharacter || !characterName.trim()}>\n {isCreatingCharacter ? <Loader2 className=\"spin\" size={14} /> : <Plus size={14} />}\n {isCreatingCharacter ? \"创建中...\" : \"创建音色角色\"}\n </button>\n </div>\n </div>\n\n {/* 角色创造区域 */}\n <div className=\"audiobook-section\">\n <div className=\"section-header\">\n <h3>角色创造</h3>\n {workspace.phase === \"character-creation\" && (\n <button className=\"run-button\" type=\"button\" onClick={() => void handleAnalyze()} disabled={isAnalyzing}>\n {isAnalyzing ? <Loader2 className=\"spin\" size={14} /> : <Wand2 size={14} />}\n {isAnalyzing ? \"分析中...\" : \"开始分析\"}\n </button>\n )}\n </div>\n\n {isAnalyzing && (\n <div className=\"smart-progress\">\n <div className=\"smart-progress-bar\">\n <div className=\"smart-progress-fill\" style={{ width: `${analyzeProgress}%` }} />\n </div>\n <p className=\"smart-progress-text\">{analyzeText}</p>\n </div>\n )}\n\n {workspace.characters.length === 0 && !isAnalyzing && (\n <p className=\"section-hint\">点击\"开始分析\",AI将从小说原文中识别角色并生成音色描述。</p>\n )}\n\n <div className=\"character-grid\">\n {workspace.characters.map((char) => (\n <div key={char.id} className=\"character-card\">\n <div className=\"character-info\">\n <strong>{char.name}</strong>\n {char.gender && <span className=\"char-tag\">{char.gender}</span>}\n {char.age && <span className=\"char-tag\">{char.age}</span>}\n <p className=\"char-personality\">{char.personality}</p>\n <p className=\"char-voice-desc\">{char.voiceDescription}</p>\n {char.voiceSampleText && <p className=\"char-voice-sample\">试听:{char.voiceSampleText}</p>}\n </div>\n <div className=\"character-voice\">\n {char.voiceStatus === \"ready\" && char.voiceDataUrl ? (\n <>\n <StudioAudioPlayer src={char.voiceDataUrl} />\n <button className=\"icon-button\" type=\"button\" onClick={() => void onDeleteVoice(char.id)} title=\"重新生成\">\n <Trash2 size={14} />\n </button>\n </>\n ) : char.voiceStatus === \"generating\" ? (\n <span className=\"voice-status generating\"><Loader2 className=\"spin\" size={14} /> 生成中...</span>\n ) : char.voiceStatus === \"error\" ? (\n <span className=\"voice-status error\">{char.voiceError || \"生成失败\"}</span>\n ) : (\n <button className=\"run-button\" type=\"button\" onClick={() => onGenerateVoice(char.id)}>\n <AudioLines size={14} />\n 生成音色\n </button>\n )}\n </div>\n </div>\n ))}\n </div>\n\n {workspace.phase === \"character-creation\" && allVoicesReady && (\n <button\n className=\"run-button phase-advance\"\n type=\"button\"\n onClick={() => onPatch({ phase: \"annotation\" })}\n >\n 确认并进入标注\n </button>\n )}\n </div>\n\n {/* 标注区域 */}\n {workspace.phase !== \"character-creation\" && (\n <div className=\"audiobook-section\">\n <div className=\"section-header\">\n <h3>文本标注</h3>\n <div className=\"annotation-controls\">\n <button\n className=\"run-button\"\n type=\"button\"\n onClick={() => void handleAutoAnnotate()}\n disabled={isAnnotating || hasUsedAutoAnnotation}\n title={hasUsedAutoAnnotation ? \"AI auto annotation can only be used once. Please edit manually.\" : undefined}\n >\n {isAnnotating ? <Loader2 className=\"spin\" size={14} /> : <Wand2 size={14} />}\n {isAnnotating ? \"标注中...\" : \"AI 自动标注\"}\n </button>\n </div>\n </div>\n\n {isAnnotating && (\n <div className=\"smart-progress\">\n <div className=\"smart-progress-bar\">\n <div className=\"smart-progress-fill\" style={{ width: `${annotateProgress}%` }} />\n </div>\n <p className=\"smart-progress-text\">{annotateText}</p>\n </div>\n )}\n\n <div className=\"segment-list\">\n {workspace.segments.map((seg, index) => (\n <div key={seg.id} className=\"segment-block\" onDoubleClick={() => startEditSegment(seg.id)}>\n <div className=\"segment-header\">\n <span className=\"seg-index\">#{index + 1}</span>\n {seg.characterName && (\n <span className=\"annotation-badge\">\n {seg.characterName}\n {seg.emotion && ` · ${seg.emotion}`}\n </span>\n )}\n </div>\n <p className=\"segment-text\">{seg.text}</p>\n {editingSegId === seg.id && (\n <div className=\"segment-editor\">\n <select value={editCharId} onChange={(e) => setEditCharId(e.target.value)}>\n {workspace.characters.map((c) => (\n <option key={c.id} value={c.id}>{c.name}</option>\n ))}\n </select>\n <input\n type=\"text\"\n value={editEmotion}\n onChange={(e) => setEditEmotion(e.target.value)}\n placeholder=\"朗读情绪(如:温柔地、焦急地)\"\n />\n <button type=\"button\" onClick={saveEditSegment}>确定</button>\n <button type=\"button\" onClick={() => setEditingSegId(null)}>取消</button>\n </div>\n )}\n </div>\n ))}\n </div>\n\n {workspace.phase === \"annotation\" && hasAnnotations && !isGenerating && (\n <>\n <button className=\"run-button phase-advance\" type=\"button\" onClick={() => void handleGenerate()} disabled={isGenerating}>\n {isGenerating ? <Loader2 className=\"spin\" size={14} /> : <Sparkles size={14} />}\n {isGenerating ? \"生成中...\" : \"一键生成\"}\n </button>\n </>\n )}\n </div>\n )}\n\n {/* 产物列表区域 */}\n {showProductSection && (\n <div className=\"audiobook-section\" ref={productSectionRef}>\n <div className=\"section-header\">\n <h3>产物列表</h3>\n <div className=\"product-toolbar\">\n {allProductsReady && (\n <button className=\"run-button\" type=\"button\" onClick={() => void downloadAudiobookProductsZip()}>\n <Download size={14} />\n 批量下载 ZIP\n </button>\n )}\n <button\n className=\"run-button\"\n type=\"button\"\n onClick={() => void handleGenerate()}\n disabled={isGenerating}\n >\n {isGenerating ? <Loader2 className=\"spin\" size={14} /> : <Play size={14} />}\n {isGenerating ? \"生成中...\" : \"重新生成\"}\n </button>\n </div>\n </div>\n <div className=\"product-list\">\n {workspace.products.map((prod, index) => (\n <div key={prod.id} className=\"product-item\">\n <div className=\"product-info\">\n <span className=\"product-index\">#{index + 1}</span>\n <span className=\"product-char\">{prod.characterName}</span>\n <span className=\"product-text\">{prod.text.slice(0, 50)}{prod.text.length > 50 ? \"...\" : \"\"}</span>\n </div>\n {prod.status === \"ready\" && prod.audioDataUrl ? (\n <div className=\"product-actions\">\n <StudioAudioPlayer src={prod.audioDataUrl} />\n <a\n className=\"icon-button product-download-button\"\n href={prod.audioDataUrl}\n download={`segment-${index + 1}.wav`}\n title=\"下载\"\n >\n <Download size={14} />\n </a>\n <button className=\"icon-button\" type=\"button\" onClick={() => void handleRetryProduct(prod.id)} title=\"重新生成\">\n <RefreshCw size={14} />\n </button>\n </div>\n ) : prod.status === \"generating\" ? (\n <span className=\"voice-status generating\"><Loader2 className=\"spin\" size={14} /> 合成中...</span>\n ) : prod.status === \"error\" ? (\n <div className=\"product-actions\">\n <span className=\"voice-status error\">{prod.error || \"失败\"}</span>\n <button className=\"icon-button\" type=\"button\" onClick={() => void handleRetryProduct(prod.id)} title=\"重试生成\">\n <RefreshCw size={14} />\n </button>\n </div>\n ) : (\n <div className=\"product-actions\">\n <span className=\"voice-status\">等待中</span>\n <button className=\"icon-button\" type=\"button\" onClick={() => void handleRetryProduct(prod.id)} title=\"生成当前片段\">\n <RefreshCw size={14} />\n </button>\n </div>\n )}\n </div>\n ))}\n </div>\n </div>\n )}\n </div>\n </section>\n );\n}\n\nfunction ReferenceAudioNode({ id, data }: NodeProps<StudioNode>) {\n const [previewUrl, setPreviewUrl] = useState<string | null>(data.audio?.dataUrl ?? null);\n const [isRecording, setIsRecording] = useState(false);\n const [recordingSeconds, setRecordingSeconds] = useState(0);\n const mediaRecorderRef = useRef<MediaRecorder | null>(null);\n const recordingChunksRef = useRef<BlobPart[]>([]);\n const recordingStreamRef = useRef<MediaStream | null>(null);\n const recordingTimerRef = useRef<number | null>(null);\n\n useEffect(() => {\n return () => {\n stopRecordingTimer();\n stopRecordingStream();\n };\n }, []);\n\n function onFileChange(event: ChangeEvent<HTMLInputElement>) {\n const file = event.target.files?.[0];\n if (!file) {\n return;\n }\n\n if (!allowedAudioTypes.has(file.type) && !/\\.(mp3|m4a|mp4|wav)$/i.test(file.name)) {\n data.onPatch?.(id, { error: \"仅支持 mp3、m4a/mp4 或 wav 参考音频。\" });\n return;\n }\n\n if (file.size > maxAudioBytes) {\n data.onPatch?.(id, { error: `参考音频不能超过 ${formatBytes(maxAudioBytes)}。` });\n return;\n }\n\n const reader = new FileReader();\n reader.onload = () => {\n const dataUrl = String(reader.result);\n setPreviewUrl(dataUrl);\n data.onPatch?.(id, {\n audio: {\n fileName: file.name,\n mimeType: file.type || guessMimeFromName(file.name),\n size: file.size,\n dataUrl\n },\n error: undefined\n });\n };\n reader.readAsDataURL(file);\n }\n\n async function startRecording() {\n if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === \"undefined\") {\n data.onPatch?.(id, { error: \"当前浏览器不支持录音,请改用上传音频文件。\" });\n return;\n }\n\n try {\n const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n const mimeType = getSupportedRecordingMimeType();\n const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);\n\n recordingChunksRef.current = [];\n recordingStreamRef.current = stream;\n mediaRecorderRef.current = recorder;\n setRecordingSeconds(0);\n setIsRecording(true);\n data.onPatch?.(id, { error: undefined });\n\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) {\n recordingChunksRef.current.push(event.data);\n }\n };\n recorder.onstop = () => {\n void commitRecording(recorder.mimeType || mimeType || \"audio/webm\");\n };\n recorder.start();\n recordingTimerRef.current = window.setInterval(() => {\n setRecordingSeconds((value) => value + 1);\n }, 1000);\n } catch (error) {\n stopRecordingStream();\n setIsRecording(false);\n data.onPatch?.(id, { error: error instanceof Error ? error.message : \"录音启动失败。\" });\n }\n }\n\n function stopRecording() {\n const recorder = mediaRecorderRef.current;\n if (recorder && recorder.state !== \"inactive\") {\n recorder.stop();\n }\n setIsRecording(false);\n stopRecordingTimer();\n }\n\n async function commitRecording(mimeType: string) {\n const chunks = recordingChunksRef.current;\n recordingChunksRef.current = [];\n stopRecordingStream();\n\n if (chunks.length === 0) {\n data.onPatch?.(id, { error: \"没有录到有效音频。\" });\n return;\n }\n\n try {\n const recordedBlob = new Blob(chunks, { type: mimeType });\n const wavBlob = await convertRecordedBlobToWav(recordedBlob);\n\n if (wavBlob.size > maxAudioBytes) {\n data.onPatch?.(id, { error: `录音文件不能超过 ${formatBytes(maxAudioBytes)}。请缩短录制时长。` });\n return;\n }\n\n const fileName = `recorded-reference-${formatDateForFile(new Date())}.wav`;\n const dataUrl = await blobToDataUrl(wavBlob);\n setPreviewUrl(dataUrl);\n data.onPatch?.(id, {\n audio: {\n fileName,\n mimeType: \"audio/wav\",\n size: wavBlob.size,\n dataUrl\n },\n error: undefined\n });\n } catch (error) {\n data.onPatch?.(id, { error: error instanceof Error ? error.message : \"录音处理失败。\" });\n }\n }\n\n function stopRecordingTimer() {\n if (recordingTimerRef.current) {\n window.clearInterval(recordingTimerRef.current);\n recordingTimerRef.current = null;\n }\n }\n\n function stopRecordingStream() {\n recordingStreamRef.current?.getTracks().forEach((track) => track.stop());\n recordingStreamRef.current = null;\n mediaRecorderRef.current = null;\n }\n\n return (\n <StudioNodeFrame id={id} data={data} icon={<FileAudio size={17} />} tone=\"audio\">\n <Handle type=\"source\" position={Position.Right} id=\"audio\" className=\"node-handle\" />\n <label className=\"file-picker nodrag\">\n <input accept=\"audio/*,video/mp4,.mp3,.m4a,.mp4,.wav\" type=\"file\" onChange={onFileChange} />\n <span>{data.audio ? data.audio.fileName : \"上传参考音频\"}</span>\n <small>{data.audio ? `${data.audio.mimeType} · ${formatBytes(data.audio.size)}` : \"支持改后缀的 M4A/MP4\"}</small>\n </label>\n <div className=\"recording-panel nodrag\">\n <button className={isRecording ? \"record-button recording\" : \"record-button\"} type=\"button\" onClick={() => void startRecording()} disabled={isRecording}>\n <Mic2 size={15} />\n 开始录制\n </button>\n <button className=\"record-stop-button\" type=\"button\" onClick={stopRecording} disabled={!isRecording}>\n <Square size={13} />\n 停止\n </button>\n <span>{isRecording ? `录制中 ${formatTime(recordingSeconds)}` : \"当场录制参考音频\"}</span>\n </div>\n {previewUrl ? <StudioAudioPlayer src={previewUrl} /> : null}\n {data.error ? <p className=\"node-error\">{data.error}</p> : null}\n </StudioNodeFrame>\n );\n}\n\nfunction CommentNode({ id, data }: NodeProps<StudioNode>) {\n return (\n <div className=\"comment-node\">\n <textarea\n className=\"nodrag\"\n value={data.text ?? \"\"}\n onChange={(event) => data.onPatch?.(id, { text: event.target.value })}\n rows={3}\n placeholder=\"添加注释...\"\n />\n </div>\n );\n}\n\nfunction PromptNode({ id, data }: NodeProps<StudioNode>) {\n return (\n <StudioNodeFrame id={id} data={data} icon={<Sparkles size={17} />} tone=\"prompt\">\n <Handle type=\"source\" position={Position.Right} id=\"text\" className=\"node-handle\" />\n <textarea\n className=\"nodrag\"\n value={data.text ?? \"\"}\n onChange={(event) => data.onPatch?.(id, { text: event.target.value })}\n rows={6}\n placeholder=\"写入最终要生成成音频的文本,并连接到克隆节点的「文本」输入。\"\n />\n </StudioNodeFrame>\n );\n}\n\nfunction VoiceStyleNode({ id, data }: NodeProps<StudioNode>) {\n return (\n <StudioNodeFrame id={id} data={data} icon={<Sparkles size={17} />} tone=\"style\">\n <Handle type=\"source\" position={Position.Right} id=\"style\" className=\"node-handle\" />\n <textarea\n className=\"nodrag\"\n value={data.text ?? \"\"}\n onChange={(event) => data.onPatch?.(id, { text: event.target.value })}\n rows={6}\n placeholder=\"写入语气、情绪、语速、角色和导演指令,并连接到克隆节点的「风格」输入。\"\n />\n {data.error ? <p className=\"node-error\">{data.error}</p> : null}\n </StudioNodeFrame>\n );\n}\n\nfunction VoiceCloneNode({ id, data }: NodeProps<StudioNode>) {\n return (\n <StudioNodeFrame id={id} data={data} icon={<Mic2 size={17} />} tone=\"clone\">\n <Handle type=\"target\" position={Position.Left} id=\"voice\" className=\"node-handle handle-voice\" />\n <Handle type=\"target\" position={Position.Left} id=\"instruction\" className=\"node-handle handle-instruction\" />\n <Handle type=\"target\" position={Position.Left} id=\"text\" className=\"node-handle handle-text\" />\n <Handle type=\"source\" position={Position.Right} id=\"output\" className=\"node-handle\" />\n <div className=\"input-map\">\n <span>参考</span>\n <span>风格</span>\n <span>文本</span>\n </div>\n <label className=\"node-field nodrag\">\n <span>语音风格(导演文本)</span>\n <textarea value={data.instruction ?? \"\"} onChange={(event) => data.onPatch?.(id, { instruction: event.target.value })} rows={4} />\n </label>\n <label className=\"node-field nodrag\">\n <span>音频文本</span>\n <textarea value={data.text ?? \"\"} onChange={(event) => data.onPatch?.(id, { text: event.target.value })} rows={5} />\n </label>\n {data.error ? <p className=\"node-error\">{data.error}</p> : null}\n <button className=\"run-button nodrag\" type=\"button\" onClick={() => data.onRunClone?.(id)} disabled={data.isRunning}>\n {data.isRunning ? <Loader2 className=\"spin\" size={16} /> : <AudioLines size={16} />}\n {data.isRunning ? \"生成中\" : \"运行克隆\"}\n </button>\n </StudioNodeFrame>\n );\n}\n\nfunction VoiceDesignNode({ id, data }: NodeProps<StudioNode>) {\n return (\n <StudioNodeFrame id={id} data={data} icon={<Sparkles size={17} />} tone=\"design\">\n <Handle type=\"target\" position={Position.Left} id=\"text\" className=\"node-handle handle-text\" />\n <Handle type=\"source\" position={Position.Right} id=\"output\" className=\"node-handle\" />\n <div className=\"input-map input-map-design\">\n <span>文本</span>\n </div>\n <label className=\"node-field nodrag\">\n <span>音色描述</span>\n <textarea value={data.instruction ?? \"\"} onChange={(event) => data.onPatch?.(id, { instruction: event.target.value })} rows={5} />\n </label>\n <label className=\"node-field nodrag\">\n <span>音频文本</span>\n <textarea value={data.text ?? \"\"} onChange={(event) => data.onPatch?.(id, { text: event.target.value })} rows={6} />\n </label>\n {data.error ? <p className=\"node-error\">{data.error}</p> : null}\n <button className=\"run-button nodrag\" type=\"button\" onClick={() => data.onRunVoiceDesign?.(id)} disabled={data.isRunning}>\n {data.isRunning ? <Loader2 className=\"spin\" size={16} /> : <Sparkles size={16} />}\n {data.isRunning ? \"生成中\" : \"批量运行音色创造\"}\n </button>\n </StudioNodeFrame>\n );\n}\n\nfunction ArtifactNode({ id, data }: NodeProps<StudioNode>) {\n const artifact = data.artifact;\n const isStashed = artifact ? data.isArtifactStashed?.(artifact) : false;\n const artifactForStash = artifact ? { ...artifact, sourceNodeName: data.title } : null;\n\n return (\n <StudioNodeFrame id={id} data={data} icon={<Archive size={17} />} tone=\"artifact\">\n <Handle type=\"target\" position={Position.Left} id=\"artifact\" className=\"node-handle\" />\n {artifact ? <Handle type=\"source\" position={Position.Right} id=\"audio\" className=\"node-handle handle-artifact-audio\" /> : null}\n {artifact ? (\n <>\n <StudioAudioPlayer src={artifact.audioDataUrl} />\n <div className=\"artifact-meta\">\n <span>{artifact.fileName}</span>\n <span>{artifact.elapsedMs} ms · {new Date(artifact.createdAt).toLocaleString(\"zh-CN\", { hour12: false })}</span>\n </div>\n <div className=\"artifact-actions\">\n <button\n className=\"download-link nodrag\"\n type=\"button\"\n onClick={() => artifactForStash && data.onStashArtifact?.(artifactForStash)}\n disabled={isStashed}\n >\n <Archive size={15} />\n {isStashed ? \"已暂存\" : \"暂存\"}\n </button>\n <a className=\"download-link nodrag\" href={artifact.audioDataUrl} download={getArtifactDownloadFileName(data.title, artifact.fileName)}>\n <Download size={15} />\n 下载\n </a>\n </div>\n </>\n ) : (\n <p className=\"node-muted\">等待音频克隆节点写入产物。</p>\n )}\n </StudioNodeFrame>\n );\n}\n\nfunction StudioNodeFrame({\n id,\n data,\n icon,\n tone,\n children\n}: {\n id: string;\n data: NodeData;\n icon: ReactNode;\n tone: string;\n children: ReactNode;\n}) {\n return (\n <section className={`studio-node node-${tone}`}>\n <header className=\"node-header\">\n <div className=\"node-title-wrap\">\n {icon}\n <input\n className=\"node-title-input nodrag\"\n title=\"节点命名\"\n value={data.title}\n onChange={(event) => data.onPatch?.(id, { title: event.target.value })}\n />\n </div>\n <div className=\"node-header-actions\">\n {tone === \"style\" || tone === \"design\" ? (\n <button\n className=\"icon-button optimize-icon nodrag\"\n type=\"button\"\n onClick={() => (tone === \"style\" ? data.onOptimizeStyle?.(id) : data.onOptimizeVoiceDesign?.(id))}\n disabled={data.isRunning}\n title={data.isRunning ? \"AI优化中\" : tone === \"style\" ? \"AI优化语音风格\" : \"AI润色音色描述\"}\n >\n {data.isRunning ? <Loader2 className=\"spin\" size={14} /> : <Sparkles size={14} />}\n </button>\n ) : null}\n <button className=\"icon-button nodrag\" type=\"button\" onClick={() => data.onDelete?.(id)} title=\"删除节点\">\n <Trash2 size={14} />\n </button>\n </div>\n </header>\n {children}\n </section>\n );\n}\n\nfunction ContextMenu({ menu, onAdd }: { menu: { x: number; y: number }; onAdd: (type: StudioNodeType) => void }) {\n return (\n <div className=\"context-menu\" style={{ left: menu.x, top: menu.y }} onClick={(event) => event.stopPropagation()}>\n <strong>添加工作节点</strong>\n {(Object.keys(nodeCatalog) as StudioNodeType[]).filter((type) => type !== \"artifact\").map((type) => (\n <button key={type} type=\"button\" onClick={() => onAdd(type)}>\n <span>{nodeCatalog[type].label}</span>\n <small>{nodeCatalog[type].description}</small>\n </button>\n ))}\n </div>\n );\n}\n\nfunction StatusPill({ apiKey, onOpenModal }: { apiKey: string; onOpenModal: () => void }) {\n const masked = apiKey.length > 8 ? `${apiKey.slice(0, 3)}***${apiKey.slice(-4)}` : \"***\";\n const isDefault = apiKey === DEFAULT_API_KEY;\n return (\n <button className={`status-pill ${isDefault ? \"warn\" : \"good\"}`} type=\"button\" onClick={onOpenModal}>\n <Key size={14} />\n <span>API Key: {masked}</span>\n </button>\n );\n}\n\nfunction StashPanel({\n isOpen,\n items,\n onBatchDownload,\n onDelete,\n onToggle\n}: {\n isOpen: boolean;\n items: StashItem[];\n onBatchDownload: () => void;\n onDelete: (itemId: string) => void;\n onToggle: () => void;\n}) {\n return (\n <section className=\"stash-panel\">\n <button className=\"stash-header\" type=\"button\" onClick={onToggle} aria-expanded={isOpen}>\n <span>\n <ChevronDown className={isOpen ? \"stash-chevron open\" : \"stash-chevron\"} size={14} />\n 暂存 {items.length}\n </span>\n </button>\n {isOpen ? (\n <div className=\"stash-body\">\n <div className=\"stash-toolbar\">\n <span>暂存 {items.length}</span>\n <button className=\"stash-download-all\" type=\"button\" onClick={onBatchDownload}>\n <Download size={13} />\n 批量下载 ZIP\n </button>\n </div>\n {items.map((item) => (\n <article className=\"stash-item\" key={item.id}>\n <strong title={item.sourceNodeName || item.fileName}>{item.sourceNodeName || item.fileName}</strong>\n <StashMiniPlayer src={item.audioDataUrl} />\n <a\n className=\"stash-round-action\"\n href={item.audioDataUrl}\n download={getArtifactDownloadFileName(item.sourceNodeName || item.fileName, item.fileName)}\n title=\"下载暂存音频\"\n >\n <Download size={13} />\n </a>\n <button className=\"stash-round-action nodrag\" type=\"button\" onClick={() => onDelete(item.id)} title=\"删除暂存\">\n <Trash2 size={13} />\n </button>\n </article>\n ))}\n </div>\n ) : null}\n </section>\n );\n}\n\nfunction StashMiniPlayer({ src }: { src: string }) {\n const audioRef = useRef<HTMLAudioElement | null>(null);\n const [isPlaying, setIsPlaying] = useState(false);\n\n function togglePlay() {\n const audio = audioRef.current;\n if (!audio) {\n return;\n }\n\n if (audio.paused) {\n void audio.play();\n } else {\n audio.pause();\n }\n }\n\n return (\n <>\n <audio\n ref={audioRef}\n src={src}\n preload=\"metadata\"\n onPlay={() => setIsPlaying(true)}\n onPause={() => setIsPlaying(false)}\n onEnded={() => setIsPlaying(false)}\n />\n <button className=\"stash-round-action nodrag\" type=\"button\" onClick={togglePlay} title={isPlaying ? \"暂停\" : \"播放\"}>\n {isPlaying ? <Pause size={13} /> : <Play size={13} />}\n </button>\n </>\n );\n}\n\nfunction DeletableEdge({\n id,\n sourceX,\n sourceY,\n targetX,\n targetY,\n sourcePosition,\n targetPosition,\n style,\n markerEnd,\n data\n}: EdgeProps<StudioEdge>) {\n const [isHovered, setIsHovered] = useState(false);\n const [edgePath, labelX, labelY] = getBezierPath({\n sourceX,\n sourceY,\n sourcePosition,\n targetX,\n targetY,\n targetPosition\n });\n\n return (\n <>\n <g onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>\n <path className=\"edge-hover-path\" d={edgePath} />\n <BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />\n </g>\n <EdgeLabelRenderer>\n <button\n className={isHovered ? \"edge-delete visible\" : \"edge-delete\"}\n onClick={(event) => {\n event.stopPropagation();\n data?.onDeleteEdge?.(id);\n }}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n style={{\n transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`\n }}\n title=\"断开链接\"\n type=\"button\"\n >\n ×\n </button>\n </EdgeLabelRenderer>\n </>\n );\n}\n\nfunction StudioAudioPlayer({ src }: { src: string }) {\n const audioRef = useRef<HTMLAudioElement | null>(null);\n const [isPlaying, setIsPlaying] = useState(false);\n const [currentTime, setCurrentTime] = useState(0);\n const [duration, setDuration] = useState(0);\n\n const progress = duration > 0 ? (currentTime / duration) * 100 : 0;\n\n function togglePlay() {\n const audio = audioRef.current;\n if (!audio) {\n return;\n }\n\n if (audio.paused) {\n void audio.play();\n } else {\n audio.pause();\n }\n }\n\n function seek(value: string) {\n const nextTime = Number(value);\n const audio = audioRef.current;\n if (!audio || Number.isNaN(nextTime)) {\n return;\n }\n\n audio.currentTime = nextTime;\n setCurrentTime(nextTime);\n }\n\n return (\n <div className=\"studio-player nodrag\">\n <audio\n ref={audioRef}\n src={src}\n preload=\"metadata\"\n onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}\n onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}\n onPlay={() => setIsPlaying(true)}\n onPause={() => setIsPlaying(false)}\n onEnded={() => setIsPlaying(false)}\n />\n <button className=\"player-button\" type=\"button\" onClick={togglePlay} title={isPlaying ? \"暂停\" : \"播放\"}>\n {isPlaying ? <Pause size={15} /> : <Play size={15} />}\n </button>\n <span className=\"player-time\">\n {formatTime(currentTime)} / {formatTime(duration)}\n </span>\n <input\n aria-label=\"播放进度\"\n className=\"player-range player-progress\"\n max={duration || 0}\n min={0}\n onChange={(event) => seek(event.target.value)}\n style={{ \"--progress\": `${progress}%` } as React.CSSProperties}\n type=\"range\"\n value={currentTime}\n />\n </div>\n );\n}\n\nfunction resolveCloneInputs(cloneNode: StudioNode, nodes: StudioNode[], edges: StudioEdge[]) {\n const incoming = edges.filter((edge) => edge.target === cloneNode.id);\n const getSource = (targetHandle: string) => {\n const edge = incoming.find((item) => item.targetHandle === targetHandle);\n return edge ? nodes.find((node) => node.id === edge.source) : undefined;\n };\n\n const voiceNode = getSource(\"voice\");\n const instructionNode = getSource(\"instruction\");\n const textNode = getSource(\"text\");\n const artifact = voiceNode?.data.artifact;\n const artifactAudio: AudioAsset | undefined = artifact\n ? {\n fileName: artifact.fileName,\n mimeType: guessMimeFromName(artifact.fileName),\n size: dataUrlToUint8Array(artifact.audioDataUrl).byteLength,\n dataUrl: artifact.audioDataUrl\n }\n : undefined;\n\n return {\n audio: voiceNode?.data.audio ?? artifactAudio ?? cloneNode.data.audio,\n instruction: instructionNode?.data.text ?? cloneNode.data.instruction ?? \"\",\n text: textNode?.data.text ?? cloneNode.data.text ?? \"\"\n };\n}\n\nfunction resolveCloneTextInputs(cloneNode: StudioNode, nodes: StudioNode[], edges: StudioEdge[]) {\n return edges\n .filter((edge) => edge.target === cloneNode.id && edge.targetHandle === \"text\")\n .map((edge) => nodes.find((node) => node.id === edge.source && node.type === \"prompt\"))\n .filter((node): node is StudioNode => Boolean(node))\n .map((node) => ({\n title: node.data.title,\n text: String(node.data.text || \"\")\n }));\n}\n\nfunction resolveVoiceDesignInputs(designNode: StudioNode, nodes: StudioNode[], edges: StudioEdge[]) {\n return edges\n .filter((edge) => edge.target === designNode.id && edge.targetHandle === \"text\")\n .map((edge) => nodes.find((node) => node.id === edge.source && node.type === \"prompt\"))\n .filter((node): node is StudioNode => Boolean(node))\n .map((node) => ({\n title: node.data.title,\n text: String(node.data.text || \"\")\n }));\n}\n\nfunction createArtifactNode(sourceNode: StudioNode, result: DebugResponse, title?: string): StudioNode {\n const artifactTitle = title?.trim() ? `${title.trim()} 产物` : `产物 ${new Date().toLocaleTimeString(\"zh-CN\", { hour12: false })}`;\n\n return {\n id: createId(\"artifact\"),\n type: \"artifact\",\n position: {\n x: sourceNode.position.x + 420,\n y: sourceNode.position.y + 36\n },\n data: {\n title: artifactTitle,\n artifact: {\n fileName: result.fileName,\n audioDataUrl: result.audioDataUrl,\n elapsedMs: result.elapsedMs,\n createdAt: new Date().toISOString(),\n sourceNodeName: artifactTitle\n }\n }\n };\n}\n\nfunction stripNodeCallbacks(node: StudioNode): StudioNode {\n const { onPatch, onDelete, onRunClone, onRunVoiceDesign, onOptimizeStyle, onOptimizeVoiceDesign, onStashArtifact, isArtifactStashed, ...data } = node.data;\n return { ...node, data };\n}\n\nfunction dataUrlToFile(dataUrl: string, fileName: string, mimeType: string): File {\n const [meta, base64] = dataUrl.split(\",\");\n const resolvedMime = mimeType || meta.match(/data:(.*);base64/)?.[1] || \"audio/mpeg\";\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let index = 0; index < binary.length; index += 1) {\n bytes[index] = binary.charCodeAt(index);\n }\n return new File([bytes], fileName, { type: resolvedMime });\n}\n\nfunction dataUrlToUint8Array(dataUrl: string): Uint8Array {\n const base64 = dataUrl.split(\",\")[1] ?? \"\";\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let index = 0; index < binary.length; index += 1) {\n bytes[index] = binary.charCodeAt(index);\n }\n return bytes;\n}\n\nfunction getSupportedRecordingMimeType(): string {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\", \"audio/mpeg\"];\n return candidates.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)) ?? \"\";\n}\n\nasync function convertRecordedBlobToWav(blob: Blob): Promise<Blob> {\n const AudioContextConstructor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n if (!AudioContextConstructor) {\n throw new Error(\"当前浏览器无法处理录音,请改用上传音频文件。\");\n }\n\n const audioContext = new AudioContextConstructor();\n try {\n const audioBuffer = await audioContext.decodeAudioData(await blob.arrayBuffer());\n return encodeAudioBufferToWav(audioBuffer);\n } finally {\n await audioContext.close();\n }\n}\n\nfunction encodeAudioBufferToWav(audioBuffer: AudioBuffer): Blob {\n const channelCount = audioBuffer.numberOfChannels;\n const sampleRate = audioBuffer.sampleRate;\n const frameCount = audioBuffer.length;\n const bytesPerSample = 2;\n const blockAlign = channelCount * bytesPerSample;\n const byteRate = sampleRate * blockAlign;\n const dataSize = frameCount * blockAlign;\n const buffer = new ArrayBuffer(44 + dataSize);\n const view = new DataView(buffer);\n const channels = Array.from({ length: channelCount }, (_, index) => audioBuffer.getChannelData(index));\n\n writeAscii(view, 0, \"RIFF\");\n view.setUint32(4, 36 + dataSize, true);\n writeAscii(view, 8, \"WAVE\");\n writeAscii(view, 12, \"fmt \");\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true);\n view.setUint16(22, channelCount, true);\n view.setUint32(24, sampleRate, true);\n view.setUint32(28, byteRate, true);\n view.setUint16(32, blockAlign, true);\n view.setUint16(34, 16, true);\n writeAscii(view, 36, \"data\");\n view.setUint32(40, dataSize, true);\n\n let offset = 44;\n for (let frame = 0; frame < frameCount; frame += 1) {\n for (let channel = 0; channel < channelCount; channel += 1) {\n const sample = Math.max(-1, Math.min(1, channels[channel][frame]));\n view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);\n offset += bytesPerSample;\n }\n }\n\n return new Blob([buffer], { type: \"audio/wav\" });\n}\n\nfunction writeAscii(view: DataView, offset: number, value: string) {\n for (let index = 0; index < value.length; index += 1) {\n view.setUint8(offset + index, value.charCodeAt(index));\n }\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"读取录音失败。\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction splitScriptInput(script: string): string[] {\n return script\n .split(/\\n?\\s*----\\s*\\n?/g)\n .map((item) => item.trim())\n .filter(Boolean);\n}\n\nfunction guessMimeFromName(fileName: string): string {\n if (/\\.wav$/i.test(fileName)) {\n return \"audio/wav\";\n }\n if (/\\.(m4a|mp4)$/i.test(fileName)) {\n return \"audio/m4a\";\n }\n return \"audio/mp3\";\n}\n\nfunction sanitizeFileName(value: string): string {\n return value.replace(/[<>:\"/\\\\|?*\\u0000-\\u001f]/g, \"_\").trim() || \"audio.wav\";\n}\n\nfunction getArtifactDownloadFileName(title: string, originalFileName: string): string {\n const safeTitle = sanitizeFileName(title).replace(/\\.[a-z0-9]{1,8}$/i, \"\") || \"audio\";\n return `${safeTitle}${getFileExtension(originalFileName)}`;\n}\n\nfunction getFileExtension(fileName: string): string {\n const match = sanitizeFileName(fileName).match(/(\\.[a-z0-9]{1,8})$/i);\n return match?.[1] ?? \".wav\";\n}\n\nfunction getUniqueFileName(fileName: string, usedNames: Map<string, number>): string {\n const count = usedNames.get(fileName) ?? 0;\n usedNames.set(fileName, count + 1);\n if (count === 0) {\n return fileName;\n }\n\n const dotIndex = fileName.lastIndexOf(\".\");\n if (dotIndex === -1) {\n return `${fileName}-${count + 1}`;\n }\n\n return `${fileName.slice(0, dotIndex)}-${count + 1}${fileName.slice(dotIndex)}`;\n}\n\nfunction formatDateForFile(date: Date): string {\n return date.toISOString().replace(/[:.]/g, \"-\");\n}\n\nfunction createId(prefix: string): string {\n return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction formatBytes(bytes: number): string {\n if (bytes < 1024 * 1024) {\n return `${(bytes / 1024).toFixed(1)} KB`;\n }\n return `${(bytes / 1024 / 1024).toFixed(1)} MB`;\n}\n\nfunction formatTime(value: number): string {\n if (!Number.isFinite(value) || value <= 0) {\n return \"0:00\";\n }\n\n const minutes = Math.floor(value / 60);\n const seconds = Math.floor(value % 60);\n return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n}\n"}}}}