mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat(audio): add audio generation capabilities with configurable settings and UI enhancements
This commit is contained in:
@@ -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 文本接口在文档站容器中可访问。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user