{"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 \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;\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) => 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 \n \n \n );\n}\n\nfunction StudioApp() {\n const [status, setStatus] = useState(null);\n const [statusError, setStatusError] = useState(null);\n const [workspaces, setWorkspaces] = useState([]);\n const [activeWorkspace, setActiveWorkspace] = useState(null);\n const [nodes, setNodes, onNodesChange] = useNodesState([]);\n const [edges, setEdges, onEdgesChange] = useEdgesState([]);\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 | null>(null);\n const saveTimerRef = useRef(null);\n const [apiKey, setApiKey] = useState(() => localStorage.getItem(API_KEY_STORAGE_KEY) || DEFAULT_API_KEY);\n const [apiEndpoint, setApiEndpoint] = useState(() => 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(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(\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) {\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;\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) {\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();\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
setMenu(null)}>\n {\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
\n ZHUGUANG AUDIO WORKSTATION\n

铸光音频工作站

\n
\n
\n \n \n
\n \n\n {apiKey === DEFAULT_API_KEY && showDefaultKeyWarning ? (\n
\n \n 当前使用的是默认 API Key,不保证长期可用。请点击右上角 API Key 区域配置自己的密钥。\n \n
\n ) : null}\n\n {showApiKeyModal && (\n
\n
e.stopPropagation()}>\n
\n

\n \n 配置 API Key\n

\n \n
\n
\n

\n 请输入您的 MiMo API Key。可前往{\" \"}\n \n platform.xiaomimimo.com\n {\" \"}\n 获取。\n

\n setApiKeyInput(e.target.value)}\n placeholder=\"sk-...\"\n spellCheck={false}\n />\n {apiKey === DEFAULT_API_KEY && (\n

\n \n 当前使用的默认 Key 不可长期使用,不保证可用性,建议尽快配置自己的密钥。\n

\n )}\n
\n

\n API 地址(可选,留空使用默认地址):\n

\n setApiEndpointInput(e.target.value)}\n placeholder=\"https://api.xiaomimimo.com/v1/chat/completions\"\n spellCheck={false}\n />\n

\n token 套餐计划可使用:https://token-plan-cn.xiaomimimo.com/v1/chat/completions\n

\n

\n 非套餐请保持默认\n

\n
\n
\n
\n \n \n
\n
\n
\n )}\n\n
\n \n\n {activeWorkspace?.type === \"audiobook\" ? (\n 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
\n
\n patchWorkspaceName(event.target.value)}\n onBlur={() => void saveWorkspace()}\n placeholder=\"未命名工作台\"\n />\n 右键画布添加节点,拖动端口建立连接\n
\n
\n \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 \n \n \n \n {menu ? : null}\n
\n
\n )}\n
\n {boardDialog ? (\n 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
\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;\n onCreateAudiobook: (data: { novelText: string; characterHints: string }) => Promise;\n onSwitchMode: (mode: \"choice\" | \"smart\" | \"audiobook\") => void;\n}) {\n const [voiceFile, setVoiceFile] = useState(null);\n const [voicePreviewUrl, setVoicePreviewUrl] = useState(null);\n const [sceneDescription, setSceneDescription] = useState(\"\");\n const [script, setScript] = useState(\"\");\n const [error, setError] = useState(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(null);\n const recordingChunksRef = useRef([]);\n const recordingStreamRef = useRef(null);\n const recordingTimerRef = useRef(null);\n const progressTimerRef = useRef(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) {\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
{\n if (!isGenerating && !isCreatingAudiobook) onClose();\n }}>\n
event.stopPropagation()}>\n
\n
\n {mode === \"choice\" ? \"新建画板\" : mode === \"smart\" ? \"智能画板\" : \"智能有声书\"}\n {mode === \"choice\" ? \"选择创建方式\" : mode === \"smart\" ? \"根据场景、文稿和可选参考音频生成工作流\" : \"输入小说原文和人物信息,AI自动创建角色音色并生成有声书\"}\n
\n \n
\n\n {mode === \"choice\" ? (\n
\n \n \n \n
\n ) : mode === \"smart\" ? (\n
\n \n
\n \n \n {isRecording ? `录制中 ${formatTime(recordingSeconds)}` : \"当场录制参考音频\"}\n
\n {voicePreviewUrl ? : null}\n