feat: add official game audio generation client

This commit is contained in:
2026-09-21 12:17:32 +08:00
parent d222d17500
commit 09e0ce5cf3
28 changed files with 1835 additions and 31 deletions

View File

@@ -64,6 +64,7 @@ import type {
} from '../../../shared/coding-conversation-contracts';
import { CodingAttachmentPreview } from './CodingAttachmentPreview';
import { CodingWelcomeHero } from './CodingWelcomeHero';
import { GameAudioFiles } from './GameAudioFiles';
const EMPTY_NODES: ConversationNode[] = [];
const IDLE_RUN: ConversationRunState = { status: 'idle' };
@@ -105,26 +106,29 @@ function capabilityBillingLabel(billing: CapabilityToolDetails['billing']): stri
}
function gameResourceProgressLabel(details: CapabilityToolDetails): string | null {
if (details.plugin_id !== 'makelore.game-resource' || details.operation !== 'generate'
if (!['makelore.game-resource', 'makelore.game-audio'].includes(details.plugin_id)
|| !['generate', 'preview'].includes(details.operation)
|| !details.data || typeof details.data !== 'object' || Array.isArray(details.data)) return null;
const data = details.data as Record<string, unknown>;
const label = details.plugin_id === 'makelore.game-audio' ? '游戏音频' : '游戏资源';
const phase = typeof data.phase === 'string' ? data.phase : null;
const deliveryStatus = typeof data.deliveryStatus === 'string' ? data.deliveryStatus : null;
const providerStatus = typeof data.providerStatus === 'string' ? data.providerStatus : null;
const outputCount = Number.isSafeInteger(data.outputCount) ? data.outputCount as number : 0;
if (phase === 'saved' || deliveryStatus === 'saved') {
return `游戏资源 · 已保存 ${outputCount} 个文件`;
return `${label} · 已保存 ${outputCount} 个文件`;
}
if (phase === 'saving' || deliveryStatus === 'saving') return '游戏资源 · 正在保存到项目';
if (deliveryStatus === 'delivery_failed') return '游戏资源 · 自动保存失败,等待重试';
if (phase === 'submitted') return '游戏资源 · 已提交';
if (phase === 'saving' || deliveryStatus === 'saving') return label + ' · 正在保存到项目';
if (deliveryStatus === 'delivery_failed') return label + ' · 自动保存失败,等待恢复';
if (deliveryStatus === 'needs_reconciliation') return label + ' · 等待原任务状态恢复,请勿重复生成';
if (phase === 'submitted') return label + ' · 已提交';
if (phase === 'generating' || ['reserved', 'accepted', 'running'].includes(providerStatus ?? '')) {
return '游戏资源 · 正在生成';
return label + ' · 正在生成';
}
if (providerStatus === 'pending_review') return '游戏资源 · 账单待审核';
if (providerStatus === 'submission_unknown') return '游戏资源 · 提交状态待确认';
if (providerStatus === 'cancelled') return '游戏资源 · 已取消';
if (providerStatus === 'failed') return '游戏资源 · 生成失败';
if (providerStatus === 'pending_review') return label + ' · 账单待审核';
if (providerStatus === 'submission_unknown') return label + ' · 提交状态待确认';
if (providerStatus === 'cancelled') return label + ' · 已取消';
if (providerStatus === 'failed') return label + ' · 生成失败';
return null;
}
@@ -652,6 +656,7 @@ const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolD
{details.success ? '成功' : details.error ?? '请求失败'} · HTTP {details.status}
</p>
<p className="mt-1 text-muted-foreground">{capabilityBillingLabel(details.billing)}</p>
{details.plugin_id === 'makelore.game-audio' && <GameAudioFiles data={details.data} />}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { hostApiFetch } from '@/lib/host-api';
function record(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
const AUDIO_PATH = /^assets\/generated\/game-audio\/[0-9a-f-]{36}\/(?:music|sound)-\d+\.(?:mp3|wav|ogg|flac|m4a)$/u;
function AudioFile({ executionId, index, relative }: { executionId: string; index: number; relative: string }) {
const [source, setSource] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const name = relative.split('/').at(-1) ?? relative;
const preview = async () => {
setLoading(true);
setError(false);
try {
const file = await hostApiFetch<{ path: string; dataUrl: string }>(
'/api/coding/game-audio/' + executionId + '/outputs/' + index,
);
if (file.path !== relative || !file.dataUrl.startsWith('data:audio/')) throw new Error('Audio preview unavailable');
if (!mounted.current) return;
setSource(file.dataUrl);
} catch { if (mounted.current) setError(true); }
finally { if (mounted.current) setLoading(false); }
};
return <li className="space-y-2">
<p className="break-all font-mono text-xs">{relative}</p>
{source ? <audio aria-label={'试听 ' + name} controls preload="metadata" src={source} className="h-10 w-full" />
: <Button type="button" variant="outline" className="min-h-10" disabled={loading} onClick={() => void preview()}>
{loading ? '正在读取…' : '试听 ' + name}
</Button>}
{error && <p role="alert"></p>}
</li>;
}
export function GameAudioFiles({ data }: { data: unknown }) {
if (!record(data) || !Array.isArray(data.files) || typeof data.executionId !== 'string'
|| !/^[0-9a-f-]{36}$/u.test(data.executionId)) return null;
const files = data.files.flatMap((file) => record(file) && typeof file.path === 'string'
&& AUDIO_PATH.test(file.path) && typeof file.index === 'number' && Number.isInteger(file.index)
&& file.index >= 0 && file.index < 10 ? [{ path: file.path, index: file.index }] : []);
if (!files.length) return null;
return <ul aria-label="已保存音频" className="mt-3 space-y-3">
{files.map((file) => <AudioFile key={data.executionId + file.path} executionId={data.executionId as string} index={file.index} relative={file.path} />)}
</ul>;
}