Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |