Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useState } from 'react'; | |
| import type { DemoLocale } from './types'; | |
| type ChatDemoScene = { | |
| id: 'prompt' | 'workflow' | 'output'; | |
| title: string; | |
| caption: string; | |
| prompt: string; | |
| tool: string; | |
| result: string; | |
| durationMs: number; | |
| }; | |
| type ChatFeatureDemoProps = { | |
| locale: DemoLocale; | |
| isBusy: boolean; | |
| }; | |
| const COPY = { | |
| en: { | |
| title: 'Feature Walkthrough', | |
| subtitle: 'Video-style preview of how the Chat Agent works', | |
| stateBusy: 'Live run active', | |
| statePlaying: 'Playing demo', | |
| statePaused: 'Demo paused', | |
| play: 'Play', | |
| pause: 'Pause', | |
| replay: 'Replay', | |
| hide: 'Hide', | |
| show: 'Show Demo', | |
| hiddenBusy: 'Walkthrough hidden while the agent is running.', | |
| hiddenIdle: 'Walkthrough hidden.', | |
| reducedMotion: 'Autoplay is disabled because reduced motion is enabled.', | |
| videoName: 'chat_agent_walkthrough.mp4', | |
| lanePrompt: 'Prompt', | |
| laneWorkflow: 'Workflow', | |
| laneOutput: 'Output', | |
| quickPromptLabel: 'quick prompt', | |
| workflowProgress: 'progress panel update', | |
| workflowTrace: 'trace event recorded', | |
| resultCardTitle: 'Result Card', | |
| payloadCardTitle: 'Structured Payload', | |
| payloadCardText: 'JSON preview + action chips', | |
| }, | |
| 'zh-TW': { | |
| title: '功能導覽', | |
| subtitle: '以影片風格快速示範 Chat Agent 使用方式', | |
| stateBusy: '執行中', | |
| statePlaying: '導覽播放中', | |
| statePaused: '導覽已暫停', | |
| play: '播放', | |
| pause: '暫停', | |
| replay: '重播', | |
| hide: '隱藏', | |
| show: '顯示導覽', | |
| hiddenBusy: '模型執行中,已暫時隱藏導覽。', | |
| hiddenIdle: '導覽已隱藏。', | |
| reducedMotion: '你啟用了減少動畫,已停用自動播放。', | |
| videoName: '聊天導覽影片.mp4', | |
| lanePrompt: '輸入', | |
| laneWorkflow: '流程', | |
| laneOutput: '輸出', | |
| quickPromptLabel: '快速提示', | |
| workflowProgress: '進度面板更新', | |
| workflowTrace: '追蹤事件記錄', | |
| resultCardTitle: '結果卡片', | |
| payloadCardTitle: '結構化載荷', | |
| payloadCardText: 'JSON 預覽 + 動作標籤', | |
| }, | |
| } as const; | |
| const SCENES = { | |
| en: [ | |
| { | |
| id: 'prompt', | |
| title: 'Step 1: Prompt and context', | |
| caption: 'User chooses a quick prompt or types intent in natural language.', | |
| prompt: 'Find eggs under NT$200 and rank by freshness.', | |
| tool: 'Prompt parser prepares query tokens and constraints', | |
| result: 'Input normalized and queued', | |
| durationMs: 1700, | |
| }, | |
| { | |
| id: 'workflow', | |
| title: 'Step 2: Tool orchestration', | |
| caption: 'Planner selects tools and the progress panel updates in real time.', | |
| prompt: 'Routing: product_search_public + score ranking', | |
| tool: 'Planner validates filters and runs tool chain', | |
| result: 'Structured candidate set is ready', | |
| durationMs: 1750, | |
| }, | |
| { | |
| id: 'output', | |
| title: 'Step 3: Structured answer', | |
| caption: 'Assistant responds with cards, metrics, actions, and JSON payload.', | |
| prompt: 'Rendering response bundle for UI cards', | |
| tool: 'Renderer composes summary and recommendation cards', | |
| result: 'Chat output card with payload attached', | |
| durationMs: 1750, | |
| }, | |
| ], | |
| 'zh-TW': [ | |
| { | |
| id: 'prompt', | |
| title: '步驟 1:輸入需求', | |
| caption: '使用者可點擊快速提示,或直接輸入自然語句。', | |
| prompt: '找出 200 元以下雞蛋,並依新鮮度排序。', | |
| tool: '系統先解析條件與語意', | |
| result: '已建立查詢上下文', | |
| durationMs: 1700, | |
| }, | |
| { | |
| id: 'workflow', | |
| title: '步驟 2:工具編排', | |
| caption: '規劃器決定工具路徑,進度面板同步顯示執行狀態。', | |
| prompt: '路由:product_search_public + 排名計算', | |
| tool: '規劃器驗證條件並串接工具', | |
| result: '已取得結構化候選結果', | |
| durationMs: 1750, | |
| }, | |
| { | |
| id: 'output', | |
| title: '步驟 3:輸出答案', | |
| caption: '最後輸出摘要、卡片與 JSON 結構化資料。', | |
| prompt: '生成可視化回覆內容', | |
| tool: 'Renderer 整理摘要與推薦卡片', | |
| result: '可直接展示的結果已完成', | |
| durationMs: 1750, | |
| }, | |
| ], | |
| } as const satisfies Record<DemoLocale, ChatDemoScene[]>; | |
| function ChatFeatureDemo({ locale, isBusy }: ChatFeatureDemoProps) { | |
| const copy = COPY[locale]; | |
| const scenes = useMemo(() => SCENES[locale], [locale]); | |
| 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 replay = () => { | |
| setSceneIndex(0); | |
| setElapsedMs(0); | |
| if (!reducedMotion && !isBusy) { | |
| setIsPlaying(true); | |
| } | |
| }; | |
| if (isCollapsed) { | |
| return ( | |
| <section className="chat-demo-collapsed" aria-live="polite"> | |
| <p>{isBusy ? copy.hiddenBusy : copy.hiddenIdle}</p> | |
| {!isBusy ? ( | |
| <button | |
| type="button" | |
| className="ghost-btn" | |
| onClick={() => { | |
| setIsCollapsed(false); | |
| replay(); | |
| }} | |
| > | |
| {copy.show} | |
| </button> | |
| ) : null} | |
| </section> | |
| ); | |
| } | |
| return ( | |
| <section className="chat-demo-video" aria-label={copy.title}> | |
| <header className="chat-demo-head"> | |
| <div> | |
| <h3>{copy.title}</h3> | |
| <p>{copy.subtitle}</p> | |
| {reducedMotion ? <small>{copy.reducedMotion}</small> : null} | |
| </div> | |
| <div className="chat-demo-head-actions"> | |
| <span className={`chat-demo-state ${isBusy ? 'busy' : isPlaying ? 'playing' : 'paused'}`}> | |
| {isBusy ? copy.stateBusy : isPlaying ? copy.statePlaying : copy.statePaused} | |
| </span> | |
| <button | |
| type="button" | |
| className="ghost-btn" | |
| onClick={() => setIsPlaying((prev) => !prev)} | |
| disabled={isBusy || reducedMotion} | |
| > | |
| {isPlaying ? copy.pause : copy.play} | |
| </button> | |
| <button type="button" className="ghost-btn" onClick={replay} disabled={isBusy}> | |
| {copy.replay} | |
| </button> | |
| <button type="button" className="ghost-btn" onClick={() => setIsCollapsed(true)}> | |
| {copy.hide} | |
| </button> | |
| </div> | |
| </header> | |
| <div className="chat-demo-frame" data-scene={activeScene.id}> | |
| <div className="chat-demo-windowbar"> | |
| <span /> | |
| <span /> | |
| <span /> | |
| <small>{copy.videoName}</small> | |
| </div> | |
| <div className="chat-demo-canvas"> | |
| <section className="chat-demo-lane prompts"> | |
| <h4>{copy.lanePrompt}</h4> | |
| <div className="chat-demo-prompt-chip">{copy.quickPromptLabel}</div> | |
| <div className="chat-demo-prompt-bubble">{activeScene.prompt}</div> | |
| </section> | |
| <section className="chat-demo-lane workflow"> | |
| <h4>{copy.laneWorkflow}</h4> | |
| <div className="chat-demo-step active">{activeScene.tool}</div> | |
| <div className="chat-demo-step">{copy.workflowProgress}</div> | |
| <div className="chat-demo-step">{copy.workflowTrace}</div> | |
| </section> | |
| <section className="chat-demo-lane output"> | |
| <h4>{copy.laneOutput}</h4> | |
| <article className="chat-demo-output-card"> | |
| <strong>{copy.resultCardTitle}</strong> | |
| <p>{activeScene.result}</p> | |
| </article> | |
| <article className="chat-demo-output-card"> | |
| <strong>{copy.payloadCardTitle}</strong> | |
| <p>{copy.payloadCardText}</p> | |
| </article> | |
| </section> | |
| <span className="chat-demo-cursor" aria-hidden="true" /> | |
| </div> | |
| </div> | |
| <footer className="chat-demo-meta"> | |
| <div className="chat-demo-caption"> | |
| <strong>{activeScene.title}</strong> | |
| <p>{activeScene.caption}</p> | |
| </div> | |
| <div className="chat-demo-progress-track" aria-hidden="true"> | |
| <span style={{ width: `${Math.min(100, Math.max(0, progress))}%` }} /> | |
| </div> | |
| <div className="chat-demo-scene-row"> | |
| {scenes.map((scene, index) => ( | |
| <button | |
| key={scene.id} | |
| type="button" | |
| className={`chat-demo-scene-pill ${index === sceneIndex ? 'active' : index < sceneIndex ? 'complete' : ''}`} | |
| onClick={() => { | |
| setSceneIndex(index); | |
| setElapsedMs(0); | |
| }} | |
| > | |
| {scene.title} | |
| </button> | |
| ))} | |
| </div> | |
| </footer> | |
| </section> | |
| ); | |
| } | |
| export default ChatFeatureDemo; | |