feat(audio): add audio generation capabilities with configurable settings and UI enhancements

This commit is contained in:
HouYunFei
2026-06-02 17:50:36 +08:00
parent 38ec654748
commit 8f19a18c35
14 changed files with 528 additions and 18 deletions
+1
View File
@@ -4,6 +4,7 @@
+ [新增] 新增文档站点页面。
+ [优化] 优化画布连线交互。
+ [优化] 优化模型选择用户偏好。
## v0.2.0 - 2026-06-01
@@ -6,6 +6,7 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。
- 画布音频节点底部生成面板改为音频提示词、音频模型下拉和 OpenAI Speech 参数设置,支持 `voice`、`response_format`、`speed`、`instructions` 并通过 `/audio/speech` 生成音频节点;需要验证本地直连和云端渠道的生成、重试、下载和刷新恢复。
- 画布左上角菜单和右上角状态栏新增“文档”入口,会使用 `NEXT_PUBLIC_DOC_URL` 配置的地址并在新标签打开文档站;需要验证登录和未登录状态下顶部入口都可见。
- 文档站搜索改为中英文混合 tokenizer,中文正文、标题和短语会按中文词、单字、二元和三元片段建立索引;需要验证 `/api/search` 和搜索弹窗能命中文档中的中文关键词。
- 文档站改为 Next.js standalone server 输出,新增 `docs/Dockerfile`、`docs/docker-compose.yml` 和 `docs/docker-compose.local.yml` 独立运行入口;需要验证文档页、搜索接口和 LLM 文本接口在文档站容器中可访问。
+4
View File
@@ -26,6 +26,10 @@ func AIChatCompletions(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/chat/completions")
}
func AIAudioSpeech(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/audio/speech")
}
func AIVideos(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/videos")
}
+1
View File
@@ -32,6 +32,7 @@ func New() *gin.Engine {
v1.POST("/images/generations", gin.WrapF(handler.AIImagesGenerations))
v1.POST("/images/edits", gin.WrapF(handler.AIImagesEdits))
v1.POST("/chat/completions", gin.WrapF(handler.AIChatCompletions))
v1.POST("/audio/speech", gin.WrapF(handler.AIAudioSpeech))
v1.POST("/videos", gin.WrapF(handler.AIVideos))
v1.POST("/media/references", gin.WrapF(handler.UploadReferenceMedia))
v1.GET("/videos/:id", func(c *gin.Context) {
@@ -7,6 +7,7 @@ import { BookOpen, Home, ImageIcon, Images, List, Menu, MessageSquare, Music2, P
import { saveAs } from "file-saver";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { DOCS_URL } from "@/constant/env";
import { defaultConfig, type AiConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
@@ -1633,7 +1634,7 @@ function InfiniteCanvasPage() {
);
const effectivePrompt = generationContext.prompt.trim();
const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode;
if (!effectivePrompt && mode === "text") {
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
setRunningNodeId(null);
return;
}
@@ -1819,6 +1820,28 @@ function InfiniteCanvasPage() {
return;
}
if (mode === "audio") {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
const isEmptyAudioNode = sourceNode?.type === CanvasNodeType.Audio && !sourceNode.metadata?.content;
const audioId = isEmptyAudioNode ? nodeId : nanoid();
const parent = sourceNode?.position || { x: 0, y: 0 };
const audioNode: CanvasNodeData = {
id: audioId,
type: CanvasNodeType.Audio,
title: effectivePrompt.slice(0, 32) || "Generated Audio",
position: isEmptyAudioNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y + ((sourceNode?.height || spec.height) - spec.height) / 2 },
width: isEmptyAudioNode ? sourceNode.width : spec.width,
height: isEmptyAudioNode ? sourceNode.height : spec.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig) },
};
pendingChildIds = [audioId];
setNodes((prev) => (isEmptyAudioNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...audioNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), audioNode]));
if (!isEmptyAudioNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: audioId }]);
const audio = await storeGeneratedAudio(await requestAudioGeneration(generationConfig, effectivePrompt), generationConfig.audioFormat);
setNodes((prev) => prev.map((node) => (node.id === audioId ? { ...node, metadata: { ...node.metadata, ...audioMetadata(audio), prompt: effectivePrompt, ...buildAudioGenerationMetadata(generationConfig) } } : node)));
return;
}
let streamed = "";
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const textCount = isConfigNode ? getGenerationCount(generationConfig.count) : 1;
@@ -1895,7 +1918,7 @@ function InfiniteCanvasPage() {
size: savedImageMetadata.size || effectiveConfig.size,
count: "1",
}
: { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : "image"), count: "1" };
: { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image"), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
return;
@@ -1938,6 +1961,11 @@ function InfiniteCanvasPage() {
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark } } : item)));
return;
}
if (node.type === CanvasNodeType.Audio) {
const audio = await storeGeneratedAudio(await requestAudioGeneration(generationConfig, prompt), generationConfig.audioFormat);
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, ...audioMetadata(audio), prompt, ...buildAudioGenerationMetadata(generationConfig) } } : item)));
return;
}
const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryImages).then((items) => items[0]) : await requestGeneration(generationConfig, prompt).then((items) => items[0]);
const uploadedImage = await uploadImage(image.dataUrl);
@@ -2571,7 +2599,12 @@ function imageExtension(dataUrl: string) {
}
function audioExtension(mimeType?: string) {
return mimeType?.includes("wav") ? "wav" : "mp3";
if (mimeType?.includes("wav")) return "wav";
if (mimeType?.includes("opus")) return "opus";
if (mimeType?.includes("aac")) return "aac";
if (mimeType?.includes("flac")) return "flac";
if (mimeType?.includes("pcm")) return "pcm";
return "mp3";
}
function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
@@ -2597,6 +2630,16 @@ function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: A
};
}
function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata {
return {
model: config.model,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,
audioInstructions: config.audioInstructions,
};
}
function referenceUrl(image: ReferenceImage) {
return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined);
}
@@ -2697,16 +2740,20 @@ function getInputSummary(inputs: NodeGenerationInput[]) {
}
function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : config.textModel;
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
return {
...config,
model: node?.metadata?.model || defaultModel || config.model || defaultConfig.model,
model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model),
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice,
audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat,
audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions,
count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
};
}
@@ -0,0 +1,116 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { AudioSettingsPanel } from "@/components/audio-settings-panel";
import { audioFormatLabel, audioSpeedLabel, audioVoiceLabel } from "@/lib/audio-generation";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
export type CanvasAudioSettingKey = "audioVoice" | "audioFormat" | "audioSpeed" | "audioInstructions";
type CanvasAudioSettingsPopoverProps = {
config: AiConfig;
onConfigChange: (key: CanvasAudioSettingKey, value: string) => void;
buttonClassName?: string;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
};
export function CanvasAudioSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasAudioSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
useEffect(() => {
if (!open) return;
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
setOpen(false);
};
syncPosition();
window.addEventListener("resize", syncPosition);
window.addEventListener("scroll", syncPosition, true);
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
window.removeEventListener("resize", syncPosition);
window.removeEventListener("scroll", syncPosition, true);
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [open]);
const panel = open && buttonRect ? <AudioSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
<span className="truncate">
{audioVoiceLabel(config.audioVoice)} · {audioFormatLabel(config.audioFormat)} · {audioSpeedLabel(config.audioSpeed)}
</span>
</Button>
</span>
{panel}
</>
);
}
function AudioSettingsPortal({
buttonRect,
panelRef,
placement,
theme,
config,
onConfigChange,
}: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasAudioSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
onConfigChange: (key: CanvasAudioSettingKey, value: string) => void;
}) {
const width = 356;
const gap = 8;
const margin = 12;
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: 1200,
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
background: theme.toolbar.panel,
borderRadius: 18,
boxShadow: "0 18px 54px rgba(28, 25, 23, 0.16)",
padding: 18,
overflowY: "auto",
color: theme.node.text,
} as const;
return createPortal(
<div
ref={panelRef}
className="canvas-image-settings-popover"
style={style}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<AudioSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} className="space-y-4" />
</div>,
document.body,
);
}
@@ -13,6 +13,7 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import type { NodeGenerationInput } from "./canvas-node-generation";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
@@ -45,6 +46,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
const imageInputs = inputs.filter((input) => input.type === "image");
const videoInputs = inputs.filter((input) => input.type === "video");
const audioInputs = inputs.filter((input) => input.type === "audio");
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
const canGenerate = mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput;
const moveInput = (input: NodeGenerationInput, offset: number) => {
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
@@ -108,6 +111,15 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
</span>
),
},
{
value: "audio",
label: (
<span className="inline-flex items-center gap-1">
<Music2 className="size-3.5" />
</span>
),
},
]}
/>
</div>
@@ -124,19 +136,21 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
</button>
</div>
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "text" ? "grid-cols-1" : "grid-cols-[minmax(0,1fr)_148px]"}`} onMouseDown={(event) => event.stopPropagation()}>
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "video" ? (
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
) : mode === "image" ? (
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
) : mode === "audio" ? (
<CanvasAudioSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
) : null}
</div>
<Button
type="primary"
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount && !inputSummary.videoCount && !inputSummary.audioCount)}
disabled={isRunning || !canGenerate}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onGenerate(node.id)}
>
@@ -395,16 +409,20 @@ function InputChip({ label, value, style }: { label: string; value: string; styl
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : globalConfig.textModel;
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
model: node.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : globalConfig.model || defaultConfig.model),
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
count: String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
};
}
@@ -415,3 +433,10 @@ function videoConfigPatch(key: keyof AiConfig, value: string) {
if (key === "videoWatermark") return { watermark: value };
return { [key]: value };
}
function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
if (key === "audioVoice") return { audioVoice: value };
if (key === "audioFormat") return { audioFormat: value };
if (key === "audioSpeed") return { audioSpeed: value };
return { audioInstructions: value };
}
@@ -11,6 +11,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
@@ -72,7 +73,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
}}
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
placeholder={mode === "video" ? "描述要生成的视频内容" : mode === "image" ? (hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容") : hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
/>
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
@@ -95,6 +96,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} />
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
</>
) : mode === "audio" ? (
<>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} />
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
</>
) : (
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} />
)}
@@ -120,27 +126,45 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
}
function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
return type === CanvasNodeType.Text ? "text" : type === CanvasNodeType.Video ? "video" : "image";
return type === CanvasNodeType.Text ? "text" : type === CanvasNodeType.Video ? "video" : type === CanvasNodeType.Audio ? "audio" : "image";
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : globalConfig.textModel;
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
model: node.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : globalConfig.model || defaultConfig.model),
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
count: String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
};
}
function promptPlaceholder(mode: CanvasNodeGenerationMode, hasImageContent: boolean, hasTextContent: boolean) {
if (mode === "video") return "描述要生成的视频内容";
if (mode === "audio") return "描述要生成的音频内容";
if (mode === "image") return hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容";
return hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容";
}
function videoConfigPatch(key: keyof AiConfig, value: string) {
if (key === "videoSeconds") return { seconds: value };
if (key === "videoGenerateAudio") return { generateAudio: value };
if (key === "videoWatermark") return { watermark: value };
return { [key]: value };
}
function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
if (key === "audioVoice") return { audioVoice: value };
if (key === "audioFormat") return { audioFormat: value };
if (key === "audioSpeed") return { audioSpeed: value };
return { audioInstructions: value };
}
+5 -1
View File
@@ -18,7 +18,7 @@ export enum CanvasNodeType {
}
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
export type CanvasGenerationMode = "text" | "image" | "video";
export type CanvasGenerationMode = "text" | "image" | "video" | "audio";
export type CanvasImageGenerationType = "generation" | "edit";
export type CanvasNodeMetadata = {
@@ -37,6 +37,10 @@ export type CanvasNodeMetadata = {
vquality?: string;
generateAudio?: string;
watermark?: string;
audioVoice?: string;
audioFormat?: string;
audioSpeed?: string;
audioInstructions?: string;
references?: string[];
naturalWidth?: number;
naturalHeight?: number;
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { type ReactNode } from "react";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { audioFormatOptions, audioSpeedLabel, audioVoiceOptions, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
import { type CanvasTheme } from "@/lib/canvas-theme";
import type { AiConfig } from "@/stores/use-config-store";
const speedOptions = ["0.75", "1", "1.25", "1.5"];
type AudioSettingKey = "audioVoice" | "audioFormat" | "audioSpeed" | "audioInstructions";
type AudioSettingsPanelProps = {
config: AiConfig;
onConfigChange: (key: AudioSettingKey, value: string) => void;
theme: CanvasTheme;
showTitle?: boolean;
className?: string;
};
export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5" }: AudioSettingsPanelProps) {
const voice = normalizeAudioVoiceValue(config.audioVoice);
const format = normalizeAudioFormatValue(config.audioFormat);
const speed = normalizeAudioSpeedValue(config.audioSpeed);
return (
<ImageSettingsTheme theme={theme}>
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
{showTitle ? <div className="text-lg font-semibold"></div> : null}
<SettingGroup title="声音" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5">
{audioVoiceOptions.map((item) => (
<OptionPill key={item.value} selected={voice === item.value} theme={theme} onClick={() => onConfigChange("audioVoice", item.value)}>
{item.label}
</OptionPill>
))}
</div>
</SettingGroup>
<SettingGroup title="格式" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5">
{audioFormatOptions.map((item) => (
<OptionPill key={item.value} selected={format === item.value} theme={theme} onClick={() => onConfigChange("audioFormat", item.value)}>
{item.label}
</OptionPill>
))}
</div>
</SettingGroup>
<SettingGroup title="语速" color={theme.node.muted}>
<div className="grid grid-cols-4 gap-2.5">
{speedOptions.map((value) => (
<OptionPill key={value} selected={speed === value} theme={theme} onClick={() => onConfigChange("audioSpeed", value)}>
{audioSpeedLabel(value)}
</OptionPill>
))}
</div>
<input
type="number"
min={0.25}
max={4}
step={0.05}
className="h-9 w-full rounded-full border bg-transparent px-3 text-center text-sm outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text, WebkitTextFillColor: theme.node.text }}
value={config.audioSpeed || "1"}
onChange={(event) => onConfigChange("audioSpeed", event.target.value)}
onBlur={(event) => onConfigChange("audioSpeed", normalizeAudioSpeedValue(event.target.value))}
onMouseDown={(event) => event.stopPropagation()}
/>
</SettingGroup>
<SettingGroup title="声音指令" color={theme.node.muted}>
<textarea
value={config.audioInstructions || ""}
placeholder="例如:自然、温暖、适合旁白。"
className="thin-scrollbar h-20 w-full resize-none rounded-xl border bg-transparent px-3 py-2 text-sm leading-5 outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
onChange={(event) => onConfigChange("audioInstructions", event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
/>
</SettingGroup>
</div>
</ImageSettingsTheme>
);
}
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
return (
<button type="button" className="h-9 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80" style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
{children}
</button>
);
}
function SettingGroup({ title, color, children }: { title: string; color: string; children: ReactNode }) {
return (
<div className="space-y-2.5">
<div className="text-xs font-medium" style={{ color }}>
{title}
</div>
{children}
</div>
);
}
+22 -1
View File
@@ -5,6 +5,7 @@ import { useState } from "react";
import { ModelPicker } from "@/components/model-picker";
import { fetchImageModels } from "@/services/api/image";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import { filterModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
type ModelGroup = {
@@ -180,7 +181,7 @@ export function AppConfigModal() {
</Form.Item>
))}
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-4">
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
<Input
type="number"
@@ -191,7 +192,27 @@ export function AppConfigModal() {
onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))}
/>
</Form.Item>
<Form.Item label="默认音频声音" className="mb-4">
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
</Form.Item>
<Form.Item label="默认音频格式" className="mb-4">
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
</Form.Item>
<Form.Item label="默认音频语速" className="mb-4">
<Input
type="number"
min={0.25}
max={4}
step={0.05}
value={config.audioSpeed}
onChange={(event) => updateConfig("audioSpeed", event.target.value)}
onBlur={(event) => updateConfig("audioSpeed", normalizeAudioSpeedValue(event.target.value))}
/>
</Form.Item>
</div>
<Form.Item label="默认音频指令" className="mb-4">
<Input.TextArea rows={2} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
</Form.Item>
{effectiveMode === "local" ? (
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
+61
View File
@@ -0,0 +1,61 @@
export const audioVoiceOptions = [
{ value: "alloy", label: "Alloy" },
{ value: "ash", label: "Ash" },
{ value: "ballad", label: "Ballad" },
{ value: "coral", label: "Coral" },
{ value: "echo", label: "Echo" },
{ value: "fable", label: "Fable" },
{ value: "nova", label: "Nova" },
{ value: "onyx", label: "Onyx" },
{ value: "sage", label: "Sage" },
{ value: "shimmer", label: "Shimmer" },
{ value: "verse", label: "Verse" },
{ value: "marin", label: "Marin" },
{ value: "cedar", label: "Cedar" },
];
export const audioFormatOptions = [
{ value: "mp3", label: "MP3" },
{ value: "wav", label: "WAV" },
{ value: "opus", label: "Opus" },
{ value: "aac", label: "AAC" },
{ value: "flac", label: "FLAC" },
{ value: "pcm", label: "PCM" },
];
export function normalizeAudioVoiceValue(value: string) {
return audioVoiceOptions.some((item) => item.value === value) ? value : "alloy";
}
export function normalizeAudioFormatValue(value: string) {
return audioFormatOptions.some((item) => item.value === value) ? value : "mp3";
}
export function normalizeAudioSpeedValue(value: string) {
const speed = Number(value);
if (!Number.isFinite(speed)) return "1";
return String(Math.max(0.25, Math.min(4, Number(speed.toFixed(2)))));
}
export function audioVoiceLabel(value: string) {
const voice = normalizeAudioVoiceValue(value);
return audioVoiceOptions.find((item) => item.value === voice)?.label || voice;
}
export function audioFormatLabel(value: string) {
const format = normalizeAudioFormatValue(value);
return audioFormatOptions.find((item) => item.value === format)?.label || format;
}
export function audioSpeedLabel(value: string) {
return `${normalizeAudioSpeedValue(value)}x`;
}
export function audioMimeType(format: string) {
if (format === "wav") return "audio/wav";
if (format === "opus") return "audio/opus";
if (format === "aac") return "audio/aac";
if (format === "flac") return "audio/flac";
if (format === "pcm") return "audio/pcm";
return "audio/mpeg";
}
+91
View File
@@ -0,0 +1,91 @@
import axios from "axios";
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig) {
const token = useUserStore.getState().token;
return config.channelMode === "remote"
? {
...(token ? { Authorization: `Bearer ${token}` } : {}),
"Content-Type": "application/json",
}
: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
};
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
}
export async function requestAudioGeneration(config: AiConfig, prompt: string): Promise<Blob> {
const model = (config.model || config.audioModel).trim();
assertAudioConfig(config, model);
const format = normalizeAudioFormatValue(config.audioFormat);
const instructions = config.audioInstructions.trim();
try {
const response = await axios.post<Blob>(
aiApiUrl(config, "/audio/speech"),
{
model,
input: prompt,
voice: normalizeAudioVoiceValue(config.audioVoice),
response_format: format,
speed: Number(normalizeAudioSpeedValue(config.audioSpeed)),
...(instructions ? { instructions } : {}),
},
{ headers: aiHeaders(config), responseType: "blob" },
);
await assertAudioBlob(response.data);
refreshRemoteUser(config);
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
} catch (error) {
throw new Error(readAxiosError(error, "音频生成失败"));
}
}
export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise<UploadedFile> {
const audio = blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
return uploadMediaFile(audio, "audio");
}
function assertAudioConfig(config: AiConfig, model: string) {
if (!model) throw new Error("请先配置音频模型");
if (config.channelMode === "local" && !config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (config.channelMode === "local" && !config.apiKey.trim()) throw new Error("请先配置 API Key");
}
async function assertAudioBlob(blob: Blob) {
if (!blob.type.includes("json")) return;
let payload: { code?: number; msg?: string; error?: { message?: string } };
try {
payload = JSON.parse(await blob.text()) as { code?: number; msg?: string; error?: { message?: string } };
} catch {
return;
}
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "音频生成失败");
if (payload.error?.message) throw new Error(payload.error.message);
}
function readAxiosError(error: unknown, fallback: string) {
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || statusMessage(error.response?.status, fallback);
}
return error instanceof Error ? error.message : fallback;
}
function statusMessage(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
if (status === 429) return "请求被限流或额度不足,请稍后重试";
return status ? `${fallback}${status}` : fallback;
}
+15 -3
View File
@@ -16,6 +16,10 @@ export type AiConfig = {
videoModel: string;
textModel: string;
audioModel: string;
audioVoice: string;
audioFormat: string;
audioSpeed: string;
audioInstructions: string;
videoSeconds: string;
vquality: string;
videoGenerateAudio: string;
@@ -43,7 +47,11 @@ export const defaultConfig: AiConfig = {
imageModel: "gpt-image-2",
videoModel: "grok-imagine-video",
textModel: "gpt-5.5",
audioModel: "",
audioModel: "gpt-4o-mini-tts",
audioVoice: "alloy",
audioFormat: "mp3",
audioSpeed: "1",
audioInstructions: "",
videoSeconds: "6",
vquality: "720",
videoGenerateAudio: "true",
@@ -124,7 +132,7 @@ function isImageModelName(model: string) {
function isAudioModelName(model: string) {
const value = model.toLowerCase();
return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("whisper") || value.includes("transcribe") || value.includes("voice") || value.includes("music") || value.includes("sound");
return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("voice") || value.includes("music") || value.includes("sound");
}
function isTextModelName(model: string) {
@@ -199,7 +207,11 @@ export const useConfigStore = create<ConfigStore>()(
imageModel: config.imageModel || config.model,
videoModel: config.videoModel || "grok-imagine-video",
textModel: config.textModel || config.model,
audioModel: config.audioModel || "",
audioModel: config.audioModel || defaultConfig.audioModel,
audioVoice: config.audioVoice || defaultConfig.audioVoice,
audioFormat: config.audioFormat || defaultConfig.audioFormat,
audioSpeed: config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: config.audioInstructions || "",
videoSeconds: config.videoSeconds || "6",
vquality: config.vquality || "720",
videoGenerateAudio: config.videoGenerateAudio || "true",