1 line
135 KiB
JSON
1 line
135 KiB
JSON
{"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"}}}} |