Farm2Market / src /VoiceDemoPanel.tsx
pixel3user
Update Farm2Market demo frontend
e46c4bd
import { useEffect, useMemo, useRef, useState } from 'react';
import type { DemoLocale, DemoMode, ProgressStep, ProgressStepStatus, TraceItem } from './types';
import VoiceFeatureDemo from './VoiceFeatureDemo';
import { sendVoiceAssistantMessage } from './liveApi';
import type { LiveVoiceToolUi } from './liveApi';
type VoiceStatus = 'idle' | 'connecting' | 'ready' | 'listening' | 'processing' | 'error';
type VoiceRole = 'assistant' | 'user' | 'system';
type VoiceToolCard =
| {
kind: 'product_updated';
title: string;
description: string;
product: {
name: string;
price: string;
stock: string;
category: string;
};
actions: string[];
}
| {
kind: 'toolkit';
title: string;
description: string;
items: string[];
actions: string[];
}
| {
kind: 'stats';
title: string;
description: string;
stats: Array<{ label: string; value: string }>;
actions: string[];
};
type VoiceEntry = {
id: string;
role: VoiceRole;
text?: string;
card?: VoiceToolCard;
timestamp: string;
};
type WorkflowStepTemplate = {
id: string;
label: string;
runningDetail: string;
doneDetail: string;
kind: TraceItem['kind'];
delayMs: number;
};
type VoiceDemoPanelProps = {
mode: DemoMode;
locale: DemoLocale;
onWorkingChange: (working: boolean) => void;
onWorkflowInit: (steps: ProgressStep[]) => void;
onStepStatus: (stepId: string, status: ProgressStepStatus, detail?: string) => void;
onTrace: (item: TraceItem) => void;
onClearPanels: () => void;
queuedPrompt: string | null;
onConsumeQueuedPrompt: () => void;
};
function uiActionLabels(ui: LiveVoiceToolUi | undefined): string[] {
if (!ui || !Array.isArray(ui.actions)) return [];
return ui.actions
.map((action) => (action && typeof action.label === 'string' ? action.label : ''))
.filter(Boolean)
.slice(0, 4);
}
function mapLiveUiToCard(ui: LiveVoiceToolUi | undefined, locale: DemoLocale): VoiceToolCard | undefined {
if (!ui) return undefined;
const kind = typeof ui.kind === 'string' ? ui.kind : '';
const fallbackTitle = locale === 'zh-TW' ? '工具結果' : 'Tool Result';
const title = typeof ui.title === 'string' ? ui.title : fallbackTitle;
const description = typeof ui.description === 'string' ? ui.description : '';
const actions = uiActionLabels(ui);
if (kind === 'product_created' || kind === 'product_updated') {
const product = typeof ui.product === 'object' && ui.product ? (ui.product as Record<string, unknown>) : {};
return {
kind: 'product_updated',
title,
description,
product: {
name: typeof product.name === 'string' ? product.name : locale === 'zh-TW' ? '未命名產品' : 'Unnamed product',
price:
typeof product.price === 'number'
? `NT$ ${product.price}${typeof product.unit === 'string' && product.unit ? ` / ${product.unit}` : ''}`
: '-',
stock: typeof product.stock === 'number' ? String(product.stock) : '-',
category: typeof product.category === 'string' ? product.category : '-',
},
actions: actions.length ? actions : locale === 'zh-TW' ? ['查看產品'] : ['Open product'],
};
}
if (kind === 'stats' || kind === 'marketplace_stats') {
const items = Array.isArray(ui.items) ? ui.items : [];
const stats = items
.map((item) => {
if (typeof item !== 'object' || !item) return null;
const row = item as Record<string, unknown>;
if (typeof row.label !== 'string') return null;
return {
label: row.label,
value:
typeof row.value === 'number'
? String(row.value)
: typeof row.value === 'string'
? row.value
: '-',
};
})
.filter((row): row is { label: string; value: string } => Boolean(row))
.slice(0, 6);
return {
kind: 'stats',
title,
description,
stats,
actions: actions.length ? actions : locale === 'zh-TW' ? ['查看詳情'] : ['View details'],
};
}
const items = Array.isArray(ui.items) ? ui.items : [];
const textItems = items
.map((item) => {
if (typeof item !== 'object' || !item) return '';
const row = item as Record<string, unknown>;
if (typeof row.title === 'string') return row.title;
if (typeof row.name === 'string') return row.name;
if (typeof row.label === 'string') return row.label;
return '';
})
.filter(Boolean)
.slice(0, 5);
if (!textItems.length && !description) return undefined;
return {
kind: 'toolkit',
title,
description: description || (locale === 'zh-TW' ? '已收到後端工具輸出。' : 'Backend tool output received.'),
items: textItems.length ? textItems : [locale === 'zh-TW' ? '已完成即時工具執行' : 'Live tool execution completed'],
actions: actions.length ? actions : locale === 'zh-TW' ? ['下一步'] : ['Next step'],
};
}
const COPY = {
en: {
title: 'Voice Assistant',
launcher: 'AI Voice Assistant',
status: {
idle: 'Idle',
connecting: 'Connecting',
ready: 'Ready',
listening: 'Listening',
processing: 'Thinking',
error: 'Retry needed',
},
listeningTitle: 'Listening',
listeningDesc: 'Capturing your voice command…',
thinkingTitle: 'Thinking',
thinkingDesc: 'Planning tools and preparing response…',
readyMessage: 'Voice assistant is ready. Tap the mic and start speaking.',
reconnect: 'Reconnect',
startTutorial: 'Start Voice Walkthrough',
open: 'Open',
close: 'Close',
micStart: 'Start',
micStop: 'Stop',
tutorialRequest: 'Start a voice walkthrough for this page with key actions.',
clearSession: 'Clear Session',
roleLabels: {
assistant: 'assistant',
user: 'user',
system: 'system',
},
},
'zh-TW': {
title: '語音助理',
launcher: 'AI 語音助理',
status: {
idle: '待命',
connecting: '連線中',
ready: '已就緒',
listening: '聆聽中',
processing: '思考中',
error: '需要重試',
},
listeningTitle: '聆聽中',
listeningDesc: '正在接收你的語音指令…',
thinkingTitle: '思考中',
thinkingDesc: '正在規劃工具並準備回覆…',
readyMessage: '語音助理已就緒,按下麥克風開始說話。',
reconnect: '重新連線',
startTutorial: '開始語音導覽',
open: '開啟',
close: '關閉',
micStart: '開始',
micStop: '停止',
tutorialRequest: '請開始這個頁面的語音導覽,並說明主要操作。',
clearSession: '清除對話',
roleLabels: {
assistant: '助理',
user: '使用者',
system: '系統',
},
},
} as const;
const VOICE_SAMPLE_INPUTS = {
en: [
'Find products below 200 and show the best options.',
'Update egg stock to 48 boxes and add pickup details.',
'Show my dashboard stats for this week.',
],
'zh-TW': ['幫我找 200 以下的雞蛋', '把雞蛋庫存改成 48 箱並補上取貨資訊', '顯示本週營運統計'],
} as const;
const now = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
function makeId(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
}
function buildTrace(kind: TraceItem['kind'], title: string, detail: string, status: TraceItem['status'], payload?: Record<string, unknown>): TraceItem {
return {
id: makeId('trace'),
kind,
title,
detail,
status,
payload,
timestamp: now(),
};
}
function buildTurnSteps(locale: DemoLocale): WorkflowStepTemplate[] {
if (locale === 'zh-TW') {
return [
{
id: 'voice-step-1',
label: '語音內容標準化',
runningDetail: '整理語句、判斷語系與指令意圖。',
doneDetail: '語音轉寫與意圖解析完成。',
kind: 'validator',
delayMs: 540,
},
{
id: 'voice-step-2',
label: '工具路由規劃',
runningDetail: '選擇最適合的產品/市集工具。',
doneDetail: '已決定工具與參數。',
kind: 'planner',
delayMs: 700,
},
{
id: 'voice-step-3',
label: '執行工具',
runningDetail: '執行動作並收集結構化結果。',
doneDetail: '工具執行完成並回傳資料。',
kind: 'tool',
delayMs: 900,
},
{
id: 'voice-step-4',
label: '回覆整理',
runningDetail: '彙整語音摘要與卡片結果。',
doneDetail: '最終語音回覆已完成。',
kind: 'renderer',
delayMs: 420,
},
];
}
return [
{
id: 'voice-step-1',
label: 'Normalize transcript',
runningDetail: 'Cleaning transcript and intent hints.',
doneDetail: 'Transcript and intent parsing complete.',
kind: 'validator',
delayMs: 540,
},
{
id: 'voice-step-2',
label: 'Plan tool route',
runningDetail: 'Selecting the best action workflow.',
doneDetail: 'Tool and arguments prepared.',
kind: 'planner',
delayMs: 700,
},
{
id: 'voice-step-3',
label: 'Execute tool',
runningDetail: 'Running action and collecting structured output.',
doneDetail: 'Tool execution returned successfully.',
kind: 'tool',
delayMs: 900,
},
{
id: 'voice-step-4',
label: 'Render voice response',
runningDetail: 'Composing concise spoken summary and cards.',
doneDetail: 'Final voice response prepared.',
kind: 'renderer',
delayMs: 420,
},
];
}
function buildTutorialSteps(locale: DemoLocale): WorkflowStepTemplate[] {
if (locale === 'zh-TW') {
return [
{
id: 'voice-tutorial-1',
label: '分析頁面脈絡',
runningDetail: '讀取目前頁面重點與導覽目標。',
doneDetail: '頁面脈絡分析完成。',
kind: 'planner',
delayMs: 620,
},
{
id: 'voice-tutorial-2',
label: '規劃導覽節奏',
runningDetail: '安排導覽順序與下一步提示。',
doneDetail: '導覽腳本已生成。',
kind: 'renderer',
delayMs: 780,
},
];
}
return [
{
id: 'voice-tutorial-1',
label: 'Analyze page context',
runningDetail: 'Reading current page scope and key tasks.',
doneDetail: 'Page context resolved.',
kind: 'planner',
delayMs: 620,
},
{
id: 'voice-tutorial-2',
label: 'Build walkthrough script',
runningDetail: 'Preparing guided route and next actions.',
doneDetail: 'Walkthrough output is ready.',
kind: 'renderer',
delayMs: 780,
},
];
}
function buildToolCard(locale: DemoLocale): VoiceToolCard {
if (locale === 'zh-TW') {
return {
kind: 'product_updated',
title: '產品更新完成',
description: '已套用語音指令並更新產品資料。',
product: {
name: '放牧雞蛋',
price: 'NT$ 118 / 盒',
stock: '48',
category: '蛋品',
},
actions: ['查看產品', '繼續修改', '設定取貨地址'],
};
}
return {
kind: 'product_updated',
title: 'Product Updated',
description: 'Your spoken command has been applied to the product record.',
product: {
name: 'Free-range Eggs',
price: 'NT$ 118 / box',
stock: '48',
category: 'Eggs',
},
actions: ['Open product', 'Edit again', 'Set pickup address'],
};
}
function buildTutorialCard(locale: DemoLocale): VoiceToolCard {
if (locale === 'zh-TW') {
return {
kind: 'toolkit',
title: '頁面語音導覽',
description: '這是本頁建議的操作路徑。',
items: ['先查看目前庫存與價格', '接著更新產品圖片或文案', '最後確認店家與取貨設定'],
actions: ['開始第一步', '跳到產品管理', '查看分析'],
};
}
return {
kind: 'toolkit',
title: 'Page Voice Walkthrough',
description: 'Recommended sequence for this screen.',
items: ['Review current stock and pricing', 'Update product image or copy', 'Confirm store and pickup settings'],
actions: ['Start step 1', 'Go to products', 'Open analytics'],
};
}
function buildStatsCard(locale: DemoLocale): VoiceToolCard {
if (locale === 'zh-TW') {
return {
kind: 'stats',
title: '語音摘要統計',
description: '依目前資料整理的重點指標。',
stats: [
{ label: '產品數', value: '42' },
{ label: '店家數', value: '18' },
{ label: '本週互動', value: '136' },
],
actions: ['查看詳情', '切換市集模式'],
};
}
return {
kind: 'stats',
title: 'Voice Summary Stats',
description: 'Key indicators assembled from current data.',
stats: [
{ label: 'Products', value: '42' },
{ label: 'Stores', value: '18' },
{ label: 'Weekly interactions', value: '136' },
],
actions: ['View details', 'Switch market mode'],
};
}
export default function VoiceDemoPanel({
mode,
locale,
onWorkingChange,
onWorkflowInit,
onStepStatus,
onTrace,
onClearPanels,
queuedPrompt,
onConsumeQueuedPrompt,
}: VoiceDemoPanelProps) {
const copy = COPY[locale];
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<VoiceStatus>('idle');
const [isRecording, setIsRecording] = useState(false);
const [micLevel, setMicLevel] = useState(0);
const [entries, setEntries] = useState<VoiceEntry[]>([]);
const threadRef = useRef<HTMLDivElement | null>(null);
const micIntervalRef = useRef<number | null>(null);
const autoStopRef = useRef<number | null>(null);
const runRef = useRef(0);
const mountedRef = useRef(true);
const activity = useMemo(() => {
if (status === 'listening') return { title: copy.listeningTitle, desc: copy.listeningDesc, style: 'listening' as const };
if (status === 'processing' || status === 'connecting') return { title: copy.thinkingTitle, desc: copy.thinkingDesc, style: 'thinking' as const };
return null;
}, [copy.listeningDesc, copy.listeningTitle, copy.thinkingDesc, copy.thinkingTitle, status]);
const appendEntry = (entry: Omit<VoiceEntry, 'id' | 'timestamp'>) => {
setEntries((prev) => [...prev, { ...entry, id: makeId('voice-entry'), timestamp: now() }]);
};
const stopMicSimulation = () => {
if (micIntervalRef.current !== null) {
window.clearInterval(micIntervalRef.current);
micIntervalRef.current = null;
}
if (autoStopRef.current !== null) {
window.clearTimeout(autoStopRef.current);
autoStopRef.current = null;
}
setMicLevel(0);
setIsRecording(false);
};
const runWorkflow = async (steps: WorkflowStepTemplate[], outcome: { text: string; card?: VoiceToolCard }) => {
runRef.current += 1;
const runId = runRef.current;
onWorkingChange(true);
setStatus('processing');
const initialized: ProgressStep[] = steps.map((step) => ({
id: step.id,
label: step.label,
detail: step.runningDetail,
status: 'pending',
}));
onWorkflowInit(initialized);
for (const step of steps) {
if (!mountedRef.current || runRef.current !== runId) return;
onStepStatus(step.id, 'in_progress', step.runningDetail);
onTrace(buildTrace(step.kind, step.label, step.runningDetail, 'running'));
await sleep(step.delayMs);
if (!mountedRef.current || runRef.current !== runId) return;
onStepStatus(step.id, 'completed', step.doneDetail);
onTrace(
buildTrace(step.kind, step.label, step.doneDetail, 'ok', step.kind === 'tool'
? { route: 'voice_tool', latencyMs: Math.floor(640 + Math.random() * 280) }
: undefined)
);
}
if (!mountedRef.current || runRef.current !== runId) return;
appendEntry({ role: 'assistant', text: outcome.text });
if (outcome.card) appendEntry({ role: 'assistant', card: outcome.card });
setStatus('ready');
onWorkingChange(false);
};
const openAssistant = async () => {
if (isOpen) return;
setIsOpen(true);
setStatus('connecting');
onWorkingChange(true);
const stepId = 'voice-connect';
onWorkflowInit([
{
id: stepId,
label: locale === 'zh-TW' ? '建立語音工作階段' : 'Establish voice session',
detail: locale === 'zh-TW' ? '初始化語音介面與音訊狀態。' : 'Initializing voice runtime and audio state.',
status: 'pending',
},
]);
onStepStatus(
stepId,
'in_progress',
locale === 'zh-TW' ? '正在連線語音流程…' : 'Connecting voice workflow…'
);
onTrace(
buildTrace(
'planner',
locale === 'zh-TW' ? '語音初始化' : 'Voice bootstrap',
locale === 'zh-TW' ? '建立前端語音示範工作階段。' : 'Creating frontend voice demo session.',
'running'
)
);
await sleep(540);
if (!mountedRef.current) return;
onStepStatus(
stepId,
'completed',
locale === 'zh-TW' ? '語音工作階段已就緒。' : 'Voice session is ready.'
);
onTrace(
buildTrace(
'planner',
locale === 'zh-TW' ? '語音初始化' : 'Voice bootstrap',
locale === 'zh-TW' ? '語音介面可開始收音。' : 'Voice interface is now ready for capture.',
'ok'
)
);
setStatus('ready');
onWorkingChange(false);
appendEntry({ role: 'system', text: copy.readyMessage });
};
const closeAssistant = () => {
stopMicSimulation();
runRef.current += 1;
setStatus('idle');
setIsOpen(false);
onWorkingChange(false);
};
const runLiveVoiceTurn = async (transcript: string) => {
runRef.current += 1;
const runId = runRef.current;
onWorkingChange(true);
setStatus('processing');
const steps = buildTurnSteps(locale);
onWorkflowInit(
steps.map((step) => ({
id: step.id,
label: step.label,
detail: step.runningDetail,
status: 'pending',
}))
);
try {
onStepStatus(steps[0].id, 'in_progress', steps[0].runningDetail);
onTrace(buildTrace(steps[0].kind, steps[0].label, steps[0].runningDetail, 'running'));
await sleep(160);
if (!mountedRef.current || runRef.current !== runId) return;
onStepStatus(steps[0].id, 'completed', steps[0].doneDetail);
onTrace(buildTrace(steps[0].kind, steps[0].label, steps[0].doneDetail, 'ok'));
onStepStatus(steps[1].id, 'in_progress', steps[1].runningDetail);
onTrace(buildTrace(steps[1].kind, steps[1].label, steps[1].runningDetail, 'running'));
const response = await sendVoiceAssistantMessage({
message: transcript,
locale,
pageRoute: '/hf-demo/voice',
pageContext: locale === 'zh-TW' ? 'Hugging Face Voice Agent Demo' : 'Hugging Face Voice Agent Demo',
});
if (!mountedRef.current || runRef.current !== runId) return;
onStepStatus(steps[1].id, 'completed', steps[1].doneDetail);
onTrace(
buildTrace(steps[1].kind, steps[1].label, steps[1].doneDetail, 'ok', {
messages: response.messages?.length ?? 0,
uiKind: response.ui?.kind || null,
})
);
onStepStatus(steps[2].id, 'in_progress', steps[2].runningDetail);
onTrace(buildTrace(steps[2].kind, steps[2].label, steps[2].runningDetail, 'running'));
const assistantTexts = (response.messages || [])
.filter((message) => message.role === 'assistant' && message.text)
.map((message) => message.text);
if (assistantTexts.length) {
for (const text of assistantTexts) {
appendEntry({ role: 'assistant', text });
}
} else {
appendEntry({
role: 'assistant',
text: locale === 'zh-TW' ? '已完成後端語音工具流程。' : 'Live voice tool flow completed.',
});
}
const liveCard = mapLiveUiToCard(response.ui, locale);
if (liveCard) {
appendEntry({ role: 'assistant', card: liveCard });
}
onStepStatus(steps[2].id, 'completed', steps[2].doneDetail);
onTrace(
buildTrace(steps[2].kind, steps[2].label, steps[2].doneDetail, 'ok', {
card: liveCard?.kind || null,
})
);
onStepStatus(steps[3].id, 'in_progress', steps[3].runningDetail);
onTrace(buildTrace(steps[3].kind, steps[3].label, steps[3].runningDetail, 'running'));
await sleep(120);
if (!mountedRef.current || runRef.current !== runId) return;
onStepStatus(steps[3].id, 'completed', steps[3].doneDetail);
onTrace(buildTrace(steps[3].kind, steps[3].label, steps[3].doneDetail, 'ok'));
setStatus('ready');
onWorkingChange(false);
} catch (error) {
const message = error instanceof Error ? error.message : locale === 'zh-TW' ? '語音流程發生錯誤。' : 'Voice flow failed.';
onStepStatus(steps[1].id, 'error', message);
onTrace(buildTrace('tool', locale === 'zh-TW' ? '語音錯誤' : 'Voice error', message, 'error'));
setStatus('error');
onWorkingChange(false);
}
};
const simulateVoiceTurn = async (transcript?: string) => {
const sample = transcript || VOICE_SAMPLE_INPUTS[locale][Math.floor(Math.random() * VOICE_SAMPLE_INPUTS[locale].length)];
appendEntry({ role: 'user', text: sample });
if (mode === 'live') {
await runLiveVoiceTurn(sample);
return;
}
const steps = buildTurnSteps(locale);
const summary =
locale === 'zh-TW'
? `我已處理「${sample}」,並完成對應工具操作。要不要繼續更新圖片或取貨設定?`
: `I processed "${sample}" and finished the tool workflow. Want to update images or pickup details next?`;
const card = sample.includes('統計') || sample.toLowerCase().includes('stats') ? buildStatsCard(locale) : buildToolCard(locale);
await runWorkflow(steps, { text: summary, card });
};
const stopRecording = async (forcedTranscript?: string) => {
if (!isRecording) return;
stopMicSimulation();
await simulateVoiceTurn(forcedTranscript);
};
const startRecording = () => {
if (isRecording || status === 'processing' || status === 'connecting') return;
setStatus('listening');
setIsRecording(true);
micIntervalRef.current = window.setInterval(() => {
setMicLevel(0.18 + Math.random() * 0.82);
}, 120);
autoStopRef.current = window.setTimeout(() => {
void stopRecording();
}, 2800);
};
const toggleMic = () => {
if (isRecording) {
void stopRecording();
return;
}
startRecording();
};
const handleTutorial = async () => {
if (!isOpen) {
await openAssistant();
}
appendEntry({ role: 'user', text: copy.tutorialRequest });
if (mode === 'live') {
await runLiveVoiceTurn(copy.tutorialRequest);
return;
}
await runWorkflow(buildTutorialSteps(locale), {
text:
locale === 'zh-TW'
? '已完成語音導覽摘要。我先帶你看重點,再引導下一步操作。'
: 'Voice walkthrough summary is ready. I will guide the key sections step by step.',
card: buildTutorialCard(locale),
});
};
useEffect(() => {
if (!queuedPrompt) return;
const applyQueuedPrompt = async () => {
onConsumeQueuedPrompt();
if (!isOpen) {
await openAssistant();
}
await simulateVoiceTurn(queuedPrompt);
};
void applyQueuedPrompt();
}, [queuedPrompt]);
useEffect(() => {
if (!threadRef.current) return;
threadRef.current.scrollTo({ top: threadRef.current.scrollHeight, behavior: 'smooth' });
}, [activity, entries, isRecording, micLevel]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
stopMicSimulation();
onWorkingChange(false);
};
}, []);
const clearSession = () => {
setEntries([]);
onClearPanels();
};
const renderCard = (card: VoiceToolCard) => {
if (card.kind === 'product_updated') {
return (
<section className="voice-tool-card product">
<header>
<h4>{card.title}</h4>
<p>{card.description}</p>
</header>
<div className="voice-product-grid">
<div>
<span>{locale === 'zh-TW' ? '品項' : 'Product'}</span>
<strong>{card.product.name}</strong>
</div>
<div>
<span>{locale === 'zh-TW' ? '價格' : 'Price'}</span>
<strong>{card.product.price}</strong>
</div>
<div>
<span>{locale === 'zh-TW' ? '庫存' : 'Stock'}</span>
<strong>{card.product.stock}</strong>
</div>
<div>
<span>{locale === 'zh-TW' ? '分類' : 'Category'}</span>
<strong>{card.product.category}</strong>
</div>
</div>
<div className="voice-action-row">
{card.actions.map((action) => (
<button key={action} type="button">
{action}
</button>
))}
</div>
</section>
);
}
if (card.kind === 'stats') {
return (
<section className="voice-tool-card stats">
<header>
<h4>{card.title}</h4>
<p>{card.description}</p>
</header>
<div className="voice-stats-grid">
{card.stats.map((stat) => (
<article key={stat.label}>
<span>{stat.label}</span>
<strong>{stat.value}</strong>
</article>
))}
</div>
<div className="voice-action-row">
{card.actions.map((action) => (
<button key={action} type="button">
{action}
</button>
))}
</div>
</section>
);
}
return (
<section className="voice-tool-card toolkit">
<header>
<h4>{card.title}</h4>
<p>{card.description}</p>
</header>
<ul>
{card.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<div className="voice-action-row">
{card.actions.map((action) => (
<button key={action} type="button">
{action}
</button>
))}
</div>
</section>
);
};
const isDemoBusy = status === 'connecting' || status === 'listening' || status === 'processing';
return (
<div className="voice-demo-root">
<VoiceFeatureDemo locale={locale} isBusy={isDemoBusy} />
{isOpen ? (
<section className="voice-dialog">
<header className="voice-dialog-header">
<div>
<h3>{copy.title}</h3>
<div className="voice-status-chip">{copy.status[status]}</div>
</div>
<button type="button" className="voice-close-btn" onClick={closeAssistant}>
{copy.close}
</button>
</header>
<div className="voice-thread-shell">
<div className="voice-thread" ref={threadRef}>
{entries.length === 0 ? (
<p className="voice-empty-hint">{locale === 'zh-TW' ? '等待語音輸入…' : 'Waiting for voice input…'}</p>
) : null}
{entries.map((entry) => (
<article key={entry.id} className={`voice-entry ${entry.role}`}>
<header>
<span>{copy.roleLabels[entry.role]}</span>
<time>{entry.timestamp}</time>
</header>
{entry.text ? <p>{entry.text}</p> : null}
{entry.card ? renderCard(entry.card) : null}
</article>
))}
</div>
{activity ? (
<div className={`voice-activity-overlay ${activity.style}`}>
<div className="voice-activity-orb">
<span />
<span />
<span />
</div>
<h4>{activity.title}</h4>
<p>{activity.desc}</p>
</div>
) : null}
</div>
<footer className="voice-footer">
<div className="voice-footer-actions">
<button type="button" className="ghost-btn" onClick={() => void handleTutorial()} disabled={status === 'connecting' || status === 'processing'}>
{copy.startTutorial}
</button>
<button type="button" className="ghost-btn" onClick={clearSession}>
{copy.clearSession}
</button>
</div>
<div className="voice-mic-shell">
<div
className="voice-mic-glow"
style={{
opacity: 0.2 + micLevel * 0.6,
transform: `scale(${1 + micLevel * 0.75})`,
}}
/>
<button
type="button"
className={`voice-mic-btn ${isRecording ? 'recording' : ''}`}
onClick={toggleMic}
disabled={status === 'connecting' || status === 'processing'}
>
{isRecording ? copy.micStop : copy.micStart}
</button>
</div>
<button type="button" className="ghost-btn" onClick={() => void openAssistant()} disabled={status === 'connecting'}>
{copy.reconnect}
</button>
</footer>
</section>
) : (
<div className="voice-launcher-wrap">
<button type="button" className="voice-launcher-btn" onClick={() => void openAssistant()}>
<span className="voice-launcher-ring" />
<span className="voice-launcher-text">
<strong>{copy.launcher}</strong>
<small>{copy.open}</small>
</span>
</button>
</div>
)}
</div>
);
}