Farm2Market / src /VoiceFeatureDemo.tsx
pixel3user
Update Farm2Market demo frontend
806144f
import { useEffect, useMemo, useState } from 'react';
import type { DemoLocale } from './types';
type VoiceScene = {
id: 'listen' | 'route' | 'cards';
durationMs: number;
};
type VoiceFeatureDemoProps = {
locale: DemoLocale;
isBusy: boolean;
};
const COPY = {
en: {
title: 'Voice Demo',
busy: 'Running',
hidden: 'Hidden',
play: 'Play',
pause: 'Pause',
replay: 'Replay',
hide: 'Hide',
show: 'Show',
videoName: 'voice_demo.webm',
step: 'Step',
lanes: {
listen: 'Listen',
route: 'Route',
cards: 'Cards',
},
routeWords: {
nodes: ['Parse', 'Plan', 'Call'],
chips: ['Stock', 'Price', 'Store', 'Safe'],
},
captions: {
listen: 'Capture voice and intent',
route: 'Plan tool sequence',
cards: 'Return voice cards',
},
stepCopy: {
listen: {
title: 'Live Voice Capture',
body:
'Microphone audio is sampled in real time, normalized for volume variance, and segmented into phrase-sized chunks for stable transcript quality.',
detail:
'Noise suppression, language hinting, and intent candidate extraction run before any tool is called.',
},
route: {
title: 'Tool Route Planning',
body:
'The planner maps your spoken goal into executable actions, selects the best tool chain, and prepares argument payloads with guardrails.',
detail:
'Validation checks include category filters, price constraints, availability context, and fallback routing when confidence is low.',
},
cards: {
title: 'Structured Voice Response',
body:
'Results are transformed into concise spoken output plus visual cards so users can confirm key data without opening raw payloads.',
detail:
'Action chips are attached for immediate follow-up tasks such as editing stock, opening product details, or triggering the next workflow.',
},
},
cardVisual: {
summaryTitle: 'Assistant Summary',
summaryBody: 'I updated stock to 48, synced pickup details, and prepared two follow-up actions.',
actions: ['Open Product', 'Edit Again', 'Share Update'],
statsTitle: 'Result Snapshot',
stats: [
{ label: 'Confidence', value: '96%' },
{ label: 'Tool Calls', value: '3' },
{ label: 'Latency', value: '1.2s' },
],
statsNote: 'Structured payload attached to voice response card.',
},
},
'zh-TW': {
title: '語音導覽',
busy: '執行中',
hidden: '已隱藏',
play: '播放',
pause: '暫停',
replay: '重播',
hide: '隱藏',
show: '顯示',
videoName: '語音導覽影片.webm',
step: '步驟',
lanes: {
listen: '收音',
route: '路由',
cards: '結果',
},
routeWords: {
nodes: ['解析', '規劃', '呼叫'],
chips: ['庫存', '價格', '店家', '安全'],
},
captions: {
listen: '接收語音與意圖',
route: '規劃工具流程',
cards: '回傳語音卡片',
},
stepCopy: {
listen: {
title: '即時語音收音',
body: '系統會即時擷取麥克風音訊,先做音量與分段標準化,再輸出更穩定的語音文字片段。',
detail: '在呼叫工具前,會先完成降噪、語系判斷與初步意圖分類。',
},
route: {
title: '工具路由規劃',
body: '規劃器會把語音目標轉成可執行步驟,選擇最佳工具鏈並組裝對應參數。',
detail: '同時檢查分類、價格、庫存與容錯路由,確保低信心情況也能安全回應。',
},
cards: {
title: '語音結果封裝',
body: '最終回覆會整合口語摘要與視覺卡片,讓使用者快速理解結果而不必直接讀 JSON。',
detail: '卡片會附帶後續操作按鈕,例如更新庫存、查看商品頁、或直接進入下一步。',
},
},
cardVisual: {
summaryTitle: '語音摘要',
summaryBody: '已更新庫存為 48,補上取貨資訊,並整理兩個後續操作。',
actions: ['查看商品', '再次編輯', '分享更新'],
statsTitle: '結果快照',
stats: [
{ label: '信心值', value: '96%' },
{ label: '工具數', value: '3' },
{ label: '延遲', value: '1.2s' },
],
statsNote: '已將結構化載荷附加到語音卡片。',
},
},
} as const;
const SCENES: VoiceScene[] = [
{ id: 'listen', durationMs: 1700 },
{ id: 'route', durationMs: 1750 },
{ id: 'cards', durationMs: 1700 },
];
export default function VoiceFeatureDemo({ locale, isBusy }: VoiceFeatureDemoProps) {
const copy = COPY[locale];
const scenes = useMemo(() => SCENES, []);
const [sceneIndex, setSceneIndex] = useState(0);
const [elapsedMs, setElapsedMs] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
const [isCollapsed, setIsCollapsed] = useState(false);
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
const apply = () => setReducedMotion(media.matches);
apply();
if (media.addEventListener) {
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}
media.addListener(apply);
return () => media.removeListener(apply);
}, []);
useEffect(() => {
if (reducedMotion) setIsPlaying(false);
}, [reducedMotion]);
useEffect(() => {
setSceneIndex(0);
setElapsedMs(0);
setIsCollapsed(false);
if (!reducedMotion) setIsPlaying(true);
}, [locale, reducedMotion]);
useEffect(() => {
if (!isBusy) return;
setIsCollapsed(true);
setIsPlaying(false);
}, [isBusy]);
useEffect(() => {
if (!isPlaying || isCollapsed || isBusy || reducedMotion) return;
const timer = window.setInterval(() => {
setElapsedMs((prev) => {
const scene = scenes[sceneIndex];
if (!scene) return 0;
const next = prev + 120;
if (next < scene.durationMs) return next;
setSceneIndex((current) => (current + 1) % scenes.length);
return 0;
});
}, 120);
return () => window.clearInterval(timer);
}, [isPlaying, isCollapsed, isBusy, reducedMotion, scenes, sceneIndex]);
const activeScene = scenes[sceneIndex] ?? scenes[0];
const totalDuration = scenes.reduce((sum, scene) => sum + scene.durationMs, 0);
const previousDuration = scenes.slice(0, sceneIndex).reduce((sum, scene) => sum + scene.durationMs, 0);
const progress = totalDuration > 0 ? Math.round(((previousDuration + elapsedMs) / totalDuration) * 100) : 0;
const sceneCaption = copy.captions[activeScene.id];
const replay = () => {
setSceneIndex(0);
setElapsedMs(0);
if (!reducedMotion && !isBusy) setIsPlaying(true);
};
if (isCollapsed) {
return (
<section className="voice-walk-collapsed" aria-live="polite">
<div className="voice-walk-mini-markers" aria-hidden="true">
<span />
<span />
<span />
</div>
<p>{isBusy ? copy.busy : copy.hidden}</p>
{!isBusy ? (
<button
type="button"
className="ghost-btn"
onClick={() => {
setIsCollapsed(false);
replay();
}}
>
{copy.show}
</button>
) : null}
</section>
);
}
return (
<section className="voice-walk-video" aria-label={copy.title}>
<header className="voice-walk-head">
<div>
<h3>{copy.title}</h3>
<small>
{copy.step} {sceneIndex + 1}/{scenes.length}
</small>
</div>
<div className="voice-walk-actions">
<button
type="button"
className="voice-walk-icon-btn"
onClick={() => setIsPlaying((prev) => !prev)}
disabled={isBusy || reducedMotion}
aria-label={isPlaying ? copy.pause : copy.play}
>
{isPlaying ? '||' : '>'}
</button>
<button type="button" className="voice-walk-icon-btn" onClick={replay} disabled={isBusy} aria-label={copy.replay}>
R
</button>
<button type="button" className="voice-walk-icon-btn" onClick={() => setIsCollapsed(true)} aria-label={copy.hide}>
X
</button>
</div>
</header>
<div className="voice-walk-frame" data-scene={activeScene.id}>
<div className="voice-walk-windowbar">
<span />
<span />
<span />
<small>{copy.videoName}</small>
</div>
<div className="voice-walk-canvas">
<section className="voice-walk-lane listen">
<span className="voice-walk-lane-index">1</span>
<small className="voice-walk-lane-label">{copy.lanes.listen}</small>
<div className="voice-walk-wave">
{Array.from({ length: 10 }).map((_, index) => (
<span key={`wave-${index}`} style={{ animationDelay: `${index * 0.08}s` }} />
))}
</div>
<div className="voice-walk-mic">
<span />
</div>
<div className="voice-walk-step-copy">
<strong>{copy.stepCopy.listen.title}</strong>
<p>{copy.stepCopy.listen.body}</p>
<small>{copy.stepCopy.listen.detail}</small>
</div>
</section>
<section className="voice-walk-lane route">
<span className="voice-walk-lane-index">2</span>
<small className="voice-walk-lane-label">{copy.lanes.route}</small>
<div className="voice-walk-route-nodes">
{copy.routeWords.nodes.map((word) => (
<span key={word}>{word}</span>
))}
</div>
<div className="voice-walk-route-flow">
<span />
</div>
<div className="voice-walk-route-chips">
{copy.routeWords.chips.map((word) => (
<span key={word}>{word}</span>
))}
</div>
<div className="voice-walk-step-copy">
<strong>{copy.stepCopy.route.title}</strong>
<p>{copy.stepCopy.route.body}</p>
<small>{copy.stepCopy.route.detail}</small>
</div>
</section>
<section className="voice-walk-lane cards">
<span className="voice-walk-lane-index">3</span>
<small className="voice-walk-lane-label">{copy.lanes.cards}</small>
<article className="voice-walk-card">
<header className="voice-walk-card-head">
<strong>{copy.cardVisual.summaryTitle}</strong>
</header>
<p className="voice-walk-card-body">{copy.cardVisual.summaryBody}</p>
<div className="voice-walk-chip-row">
{copy.cardVisual.actions.map((action) => (
<span key={action}>{action}</span>
))}
</div>
</article>
<article className="voice-walk-card">
<header className="voice-walk-card-head">
<strong>{copy.cardVisual.statsTitle}</strong>
</header>
<div className="voice-walk-grid">
{copy.cardVisual.stats.map((item) => (
<span key={item.label}>
<small>{item.label}</small>
<strong>{item.value}</strong>
</span>
))}
</div>
<p className="voice-walk-card-note">{copy.cardVisual.statsNote}</p>
</article>
<div className="voice-walk-step-copy">
<strong>{copy.stepCopy.cards.title}</strong>
<p>{copy.stepCopy.cards.body}</p>
<small>{copy.stepCopy.cards.detail}</small>
</div>
</section>
</div>
</div>
<footer className="voice-walk-meta">
<div className="voice-walk-progress-track" aria-hidden="true">
<span style={{ width: `${Math.min(100, Math.max(0, progress))}%` }} />
</div>
<p className="voice-walk-caption">{sceneCaption}</p>
<div className="voice-walk-scene-dots">
{scenes.map((scene, index) => (
<button
key={scene.id}
type="button"
className={`voice-walk-scene-dot ${index === sceneIndex ? 'active' : index < sceneIndex ? 'complete' : ''}`}
onClick={() => {
setSceneIndex(index);
setElapsedMs(0);
}}
aria-label={`${copy.step} ${index + 1}`}
>
<span />
</button>
))}
</div>
</footer>
</section>
);
}