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