Spaces:
Sleeping
Sleeping
| import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; | |
| import { buildWelcomeText, getQuickPrompts, runMockAgent } from './mockAgent'; | |
| import type { DemoLocale, DemoMessage, DemoMode, DemoTabId, ProgressStep, TraceItem } from './types'; | |
| import VoiceDemoPanel from './VoiceDemoPanel'; | |
| import MarketingStudioPanel from './MarketingStudioPanel'; | |
| import InvoiceDemoPanel from './InvoiceDemoPanel'; | |
| import MarketplaceDemoPanel from './MarketplaceDemoPanel'; | |
| import ChatFeatureDemo from './ChatFeatureDemo'; | |
| import { runLiveAgent } from './liveAgent'; | |
| const TAB_DEFINITIONS: Record<DemoLocale, Array<{ id: DemoTabId; label: string; subtitle: string }>> = { | |
| en: [ | |
| { id: 'chat', label: 'Chat Agent', subtitle: 'Tool-first orchestration' }, | |
| { id: 'voice', label: 'Voice Agent', subtitle: 'Speech-style actions' }, | |
| { id: 'marketing', label: 'Marketing Studio', subtitle: 'Copy + visual workflow' }, | |
| { id: 'invoice', label: 'Invoice AI', subtitle: 'OCR + extraction demo' }, | |
| { id: 'marketplace', label: 'Marketplace', subtitle: 'Search + ranking + stats' }, | |
| ], | |
| 'zh-TW': [ | |
| { id: 'chat', label: '聊天代理', subtitle: '工具優先協作流程' }, | |
| { id: 'voice', label: '語音代理', subtitle: '語音式操作流程' }, | |
| { id: 'marketing', label: '行銷工作室', subtitle: '文案 + 視覺素材流程' }, | |
| { id: 'invoice', label: '發票 AI', subtitle: 'OCR + 欄位擷取示範' }, | |
| { id: 'marketplace', label: '市集探索', subtitle: '搜尋 + 排名 + 統計' }, | |
| ], | |
| }; | |
| const APP_COPY = { | |
| en: { | |
| eyebrow: 'Farm2Market Demo', | |
| title: 'Agent Workspace', | |
| statusRunning: 'Running', | |
| statusIdle: 'Idle', | |
| hideControls: 'Hide Controls', | |
| showControls: 'Show Controls', | |
| tabAria: 'Agent tabs', | |
| controlsTitle: 'Controls', | |
| controlsSubtitle: 'Switch scenario and prompt packs.', | |
| demoMode: 'Demo Mode', | |
| modeMock: 'Mock (stable)', | |
| modeLive: 'Live (backend)', | |
| language: 'Language', | |
| english: 'English', | |
| traditionalChinese: 'Traditional Chinese', | |
| tryPrompts: 'Try Prompts', | |
| progressTitle: 'Model Progress', | |
| progressRunning: 'In progress', | |
| progressDone: 'Completed', | |
| structuredOutput: 'Structured Output', | |
| workingMessage: 'Working through the request…', | |
| composerPlaceholder: 'Describe what to demo, e.g. search eggs below 200', | |
| clearPanels: 'Clear Panels', | |
| runningAction: 'Running…', | |
| runDemo: 'Run Demo', | |
| traceTitle: 'Agent Trace', | |
| traceLive: 'Live events', | |
| traceLast: 'Last run summary', | |
| tracePayload: 'payload', | |
| traceWaiting: 'Waiting for trace events…', | |
| unknownError: 'Unknown runtime error', | |
| roleLabels: { | |
| user: 'user', | |
| assistant: 'assistant', | |
| system: 'system', | |
| }, | |
| kindLabels: { | |
| planner: 'planner', | |
| tool: 'tool', | |
| validator: 'validator', | |
| renderer: 'renderer', | |
| }, | |
| }, | |
| 'zh-TW': { | |
| eyebrow: 'Farm2Market 示範', | |
| title: '代理工作台', | |
| statusRunning: '執行中', | |
| statusIdle: '待命', | |
| hideControls: '隱藏控制', | |
| showControls: '顯示控制', | |
| tabAria: '代理分頁', | |
| controlsTitle: '控制面板', | |
| controlsSubtitle: '切換情境與提示組合。', | |
| demoMode: '示範模式', | |
| modeMock: '模擬(穩定)', | |
| modeLive: '即時(後端)', | |
| language: '語言', | |
| english: '英文', | |
| traditionalChinese: '繁體中文', | |
| tryPrompts: '快速提示', | |
| progressTitle: '模型處理進度', | |
| progressRunning: '執行中', | |
| progressDone: '完成', | |
| structuredOutput: '結構化輸出', | |
| workingMessage: '正在處理中,請稍候…', | |
| composerPlaceholder: '描述你要示範的功能,例如:幫我搜尋 200 以下雞蛋', | |
| clearPanels: '清除面板', | |
| runningAction: '執行中…', | |
| runDemo: '執行示範', | |
| traceTitle: '代理追蹤', | |
| traceLive: '即時事件', | |
| traceLast: '最近一次摘要', | |
| tracePayload: '載荷', | |
| traceWaiting: '等待追蹤事件…', | |
| unknownError: '未知執行錯誤', | |
| roleLabels: { | |
| user: '使用者', | |
| assistant: '助理', | |
| system: '系統', | |
| }, | |
| kindLabels: { | |
| planner: '規劃', | |
| tool: '工具', | |
| validator: '驗證', | |
| renderer: '輸出', | |
| }, | |
| }, | |
| } as const; | |
| function timestampLabel(): string { | |
| return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| } | |
| function createMessage( | |
| role: DemoMessage['role'], | |
| text: string, | |
| extra?: Pick<DemoMessage, 'cards' | 'jsonPayload'> | |
| ): DemoMessage { | |
| return { | |
| id: `${role}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, | |
| role, | |
| text, | |
| timestamp: timestampLabel(), | |
| cards: extra?.cards, | |
| jsonPayload: extra?.jsonPayload, | |
| }; | |
| } | |
| function statusIcon(status: ProgressStep['status']): string { | |
| if (status === 'completed') return '✓'; | |
| if (status === 'error') return '!'; | |
| if (status === 'in_progress') return '●'; | |
| return '○'; | |
| } | |
| function App() { | |
| const [activeTab, setActiveTab] = useState<DemoTabId>('chat'); | |
| const [mode, setMode] = useState<DemoMode>('mock'); | |
| const [locale, setLocale] = useState<DemoLocale>('en'); | |
| const [input, setInput] = useState(''); | |
| const [isWorking, setIsWorking] = useState(false); | |
| const [progressSteps, setProgressSteps] = useState<ProgressStep[]>([]); | |
| const [traceItems, setTraceItems] = useState<TraceItem[]>([]); | |
| const [progressVisible, setProgressVisible] = useState(false); | |
| const [mobileControlsOpen, setMobileControlsOpen] = useState(false); | |
| const [queuedVoicePrompt, setQueuedVoicePrompt] = useState<string | null>(null); | |
| const [queuedMarketingPrompt, setQueuedMarketingPrompt] = useState<string | null>(null); | |
| const [queuedInvoicePrompt, setQueuedInvoicePrompt] = useState<string | null>(null); | |
| const [queuedMarketplacePrompt, setQueuedMarketplacePrompt] = useState<string | null>(null); | |
| const [messages, setMessages] = useState<DemoMessage[]>([ | |
| createMessage('system', buildWelcomeText('chat', 'en')), | |
| ]); | |
| const firstRenderRef = useRef(true); | |
| const threadRef = useRef<HTMLDivElement | null>(null); | |
| const copy = APP_COPY[locale]; | |
| const tabs = TAB_DEFINITIONS[locale]; | |
| const quickPrompts = useMemo(() => getQuickPrompts(activeTab, locale), [activeTab, locale]); | |
| const showTracePanel = isWorking || traceItems.length > 0; | |
| useEffect(() => { | |
| if (firstRenderRef.current) { | |
| firstRenderRef.current = false; | |
| return; | |
| } | |
| setMessages((prev) => [...prev, createMessage('system', buildWelcomeText(activeTab, locale))]); | |
| setProgressSteps([]); | |
| setTraceItems([]); | |
| setInput(''); | |
| setQueuedVoicePrompt(null); | |
| setQueuedMarketingPrompt(null); | |
| setQueuedInvoicePrompt(null); | |
| setQueuedMarketplacePrompt(null); | |
| setIsWorking(false); | |
| }, [activeTab, locale]); | |
| useEffect(() => { | |
| if (isWorking) { | |
| setProgressVisible(true); | |
| return; | |
| } | |
| if (!progressSteps.length) { | |
| setProgressVisible(false); | |
| return; | |
| } | |
| const timer = window.setTimeout(() => { | |
| setProgressVisible(false); | |
| }, 2000); | |
| return () => window.clearTimeout(timer); | |
| }, [isWorking, progressSteps]); | |
| useEffect(() => { | |
| if (activeTab === 'voice' || activeTab === 'marketing' || activeTab === 'invoice' || activeTab === 'marketplace') return; | |
| if (!threadRef.current) return; | |
| threadRef.current.scrollTo({ top: threadRef.current.scrollHeight, behavior: 'smooth' }); | |
| }, [activeTab, messages, progressSteps, isWorking]); | |
| const updateStepStatus = (stepId: string, status: ProgressStep['status'], detail?: string) => { | |
| setProgressSteps((prev) => | |
| prev.map((step) => (step.id === stepId ? { ...step, status, detail: detail ?? step.detail } : step)) | |
| ); | |
| }; | |
| const pushTrace = (item: TraceItem) => { | |
| setTraceItems((prev) => [...prev, item].slice(-20)); | |
| }; | |
| const clearPanels = () => { | |
| setTraceItems([]); | |
| setProgressSteps([]); | |
| }; | |
| async function handleSubmit(event: FormEvent) { | |
| event.preventDefault(); | |
| if (isWorking) return; | |
| const trimmed = input.trim(); | |
| if (!trimmed) return; | |
| setMessages((prev) => [...prev, createMessage('user', trimmed)]); | |
| setInput(''); | |
| setTraceItems([]); | |
| setIsWorking(true); | |
| try { | |
| const result = | |
| mode === 'live' | |
| ? await runLiveAgent({ | |
| tab: activeTab, | |
| input: trimmed, | |
| locale, | |
| onWorkflowInit: (steps) => { | |
| setProgressSteps(steps); | |
| }, | |
| onStepStatus: (stepId, status, detail) => { | |
| updateStepStatus(stepId, status, detail); | |
| }, | |
| onTrace: (item) => { | |
| pushTrace(item); | |
| }, | |
| }) | |
| : await runMockAgent({ | |
| tab: activeTab, | |
| input: trimmed, | |
| locale, | |
| mode, | |
| onWorkflowInit: (steps) => { | |
| setProgressSteps(steps); | |
| }, | |
| onStepStatus: (stepId, status, detail) => { | |
| updateStepStatus(stepId, status, detail); | |
| }, | |
| onTrace: (item) => { | |
| pushTrace(item); | |
| }, | |
| }); | |
| setMessages((prev) => [ | |
| ...prev, | |
| createMessage('assistant', result.summary, { | |
| cards: result.cards, | |
| jsonPayload: result.payload, | |
| }), | |
| ]); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : copy.unknownError; | |
| setMessages((prev) => [ | |
| ...prev, | |
| createMessage( | |
| 'assistant', | |
| locale === 'zh-TW' | |
| ? `執行時發生錯誤:${message}` | |
| : `The demo run failed with an error: ${message}` | |
| ), | |
| ]); | |
| setProgressSteps((prev) => | |
| prev.map((step) => (step.status === 'in_progress' ? { ...step, status: 'error', detail: message } : step)) | |
| ); | |
| } finally { | |
| setIsWorking(false); | |
| } | |
| } | |
| return ( | |
| <div className={`demo-root ${showTracePanel ? 'trace-open' : 'trace-hidden'} ${mobileControlsOpen ? 'controls-open' : ''}`}> | |
| <div className="bg-orb bg-orb-a" /> | |
| <div className="bg-orb bg-orb-b" /> | |
| <header className="topbar"> | |
| <div> | |
| <p className="eyebrow">{copy.eyebrow}</p> | |
| <h1>{copy.title}</h1> | |
| </div> | |
| <div className="topbar-actions"> | |
| <span className={`status-pill ${isWorking ? 'busy' : 'idle'}`}>{isWorking ? copy.statusRunning : copy.statusIdle}</span> | |
| <button className="ghost-btn mobile-only" type="button" onClick={() => setMobileControlsOpen((prev) => !prev)}> | |
| {mobileControlsOpen ? copy.hideControls : copy.showControls} | |
| </button> | |
| </div> | |
| </header> | |
| <nav className="tab-row" aria-label={copy.tabAria}> | |
| {tabs.map((tab) => ( | |
| <button | |
| key={tab.id} | |
| type="button" | |
| className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`} | |
| onClick={() => setActiveTab(tab.id)} | |
| > | |
| <span>{tab.label}</span> | |
| <small>{tab.subtitle}</small> | |
| </button> | |
| ))} | |
| </nav> | |
| <div className="workspace"> | |
| <aside className="panel controls-panel"> | |
| <div className="panel-header"> | |
| <h2>{copy.controlsTitle}</h2> | |
| <p>{copy.controlsSubtitle}</p> | |
| </div> | |
| <label className="field"> | |
| <span>{copy.demoMode}</span> | |
| <select value={mode} onChange={(event) => setMode(event.target.value as DemoMode)}> | |
| <option value="mock">{copy.modeMock}</option> | |
| <option value="live">{copy.modeLive}</option> | |
| </select> | |
| </label> | |
| <label className="field"> | |
| <span>{copy.language}</span> | |
| <select value={locale} onChange={(event) => setLocale(event.target.value as DemoLocale)}> | |
| <option value="en">{copy.english}</option> | |
| <option value="zh-TW">{copy.traditionalChinese}</option> | |
| </select> | |
| </label> | |
| <section className="prompt-bank"> | |
| <h3>{copy.tryPrompts}</h3> | |
| <div className="chip-list"> | |
| {quickPrompts.map((prompt) => ( | |
| <button | |
| key={prompt} | |
| type="button" | |
| className="prompt-chip" | |
| onClick={() => { | |
| if (activeTab === 'voice') { | |
| setQueuedVoicePrompt(prompt); | |
| setMobileControlsOpen(false); | |
| return; | |
| } | |
| if (activeTab === 'marketing') { | |
| setQueuedMarketingPrompt(prompt); | |
| setMobileControlsOpen(false); | |
| return; | |
| } | |
| if (activeTab === 'invoice') { | |
| setQueuedInvoicePrompt(prompt); | |
| setMobileControlsOpen(false); | |
| return; | |
| } | |
| if (activeTab === 'marketplace') { | |
| setQueuedMarketplacePrompt(prompt); | |
| setMobileControlsOpen(false); | |
| return; | |
| } | |
| setInput(prompt); | |
| setMobileControlsOpen(false); | |
| }} | |
| > | |
| {prompt} | |
| </button> | |
| ))} | |
| </div> | |
| </section> | |
| </aside> | |
| <section className="panel conversation-panel"> | |
| {progressVisible && progressSteps.length > 0 ? ( | |
| <section className="progress-panel" aria-live="polite"> | |
| <header> | |
| <h3>{copy.progressTitle}</h3> | |
| <span>{isWorking ? copy.progressRunning : copy.progressDone}</span> | |
| </header> | |
| <ul> | |
| {progressSteps.map((step) => ( | |
| <li key={step.id} className={`step-${step.status}`}> | |
| <span className={`step-icon ${step.status === 'in_progress' ? 'spin' : ''}`}>{statusIcon(step.status)}</span> | |
| <div> | |
| <strong>{step.label}</strong> | |
| <small>{step.detail}</small> | |
| </div> | |
| </li> | |
| ))} | |
| </ul> | |
| </section> | |
| ) : null} | |
| {activeTab === 'voice' ? ( | |
| <VoiceDemoPanel | |
| mode={mode} | |
| locale={locale} | |
| onWorkingChange={setIsWorking} | |
| onWorkflowInit={setProgressSteps} | |
| onStepStatus={updateStepStatus} | |
| onTrace={pushTrace} | |
| onClearPanels={clearPanels} | |
| queuedPrompt={queuedVoicePrompt} | |
| onConsumeQueuedPrompt={() => setQueuedVoicePrompt(null)} | |
| /> | |
| ) : activeTab === 'marketing' ? ( | |
| <MarketingStudioPanel | |
| mode={mode} | |
| locale={locale} | |
| onWorkingChange={setIsWorking} | |
| onWorkflowInit={setProgressSteps} | |
| onStepStatus={updateStepStatus} | |
| onTrace={pushTrace} | |
| onClearPanels={clearPanels} | |
| queuedPrompt={queuedMarketingPrompt} | |
| onConsumeQueuedPrompt={() => setQueuedMarketingPrompt(null)} | |
| /> | |
| ) : activeTab === 'invoice' ? ( | |
| <InvoiceDemoPanel | |
| mode={mode} | |
| locale={locale} | |
| onWorkingChange={setIsWorking} | |
| onWorkflowInit={setProgressSteps} | |
| onStepStatus={updateStepStatus} | |
| onTrace={pushTrace} | |
| onClearPanels={clearPanels} | |
| queuedPrompt={queuedInvoicePrompt} | |
| onConsumeQueuedPrompt={() => setQueuedInvoicePrompt(null)} | |
| /> | |
| ) : activeTab === 'marketplace' ? ( | |
| <MarketplaceDemoPanel | |
| mode={mode} | |
| locale={locale} | |
| onWorkingChange={setIsWorking} | |
| onWorkflowInit={setProgressSteps} | |
| onStepStatus={updateStepStatus} | |
| onTrace={pushTrace} | |
| onClearPanels={clearPanels} | |
| queuedPrompt={queuedMarketplacePrompt} | |
| onConsumeQueuedPrompt={() => setQueuedMarketplacePrompt(null)} | |
| /> | |
| ) : ( | |
| <> | |
| <ChatFeatureDemo locale={locale} isBusy={isWorking} /> | |
| <div className="thread" ref={threadRef}> | |
| {messages.map((message) => ( | |
| <article key={message.id} className={`message ${message.role}`}> | |
| <header> | |
| <span>{copy.roleLabels[message.role]}</span> | |
| <time>{message.timestamp}</time> | |
| </header> | |
| <p>{message.text}</p> | |
| {message.cards && message.cards.length > 0 ? ( | |
| <div className="result-grid"> | |
| {message.cards.map((card) => ( | |
| <section className="result-card" key={`${message.id}-${card.title}`}> | |
| <h4>{card.title}</h4> | |
| {card.subtitle ? <p>{card.subtitle}</p> : null} | |
| {card.metrics && card.metrics.length > 0 ? ( | |
| <div className="metric-grid"> | |
| {card.metrics.map((metric) => ( | |
| <div key={`${card.title}-${metric.label}`}> | |
| <span>{metric.label}</span> | |
| <strong>{metric.value}</strong> | |
| </div> | |
| ))} | |
| </div> | |
| ) : null} | |
| {card.tags && card.tags.length > 0 ? ( | |
| <div className="tag-row"> | |
| {card.tags.map((tag) => ( | |
| <span key={`${card.title}-${tag}`}>#{tag}</span> | |
| ))} | |
| </div> | |
| ) : null} | |
| {card.actions && card.actions.length > 0 ? ( | |
| <div className="action-row"> | |
| {card.actions.map((action) => ( | |
| <button key={`${card.title}-${action}`} type="button" className="mini-btn"> | |
| {action} | |
| </button> | |
| ))} | |
| </div> | |
| ) : null} | |
| </section> | |
| ))} | |
| </div> | |
| ) : null} | |
| {message.jsonPayload ? ( | |
| <details className="payload-viewer"> | |
| <summary>{copy.structuredOutput}</summary> | |
| <pre>{JSON.stringify(message.jsonPayload, null, 2)}</pre> | |
| </details> | |
| ) : null} | |
| </article> | |
| ))} | |
| {isWorking ? ( | |
| <article className="message assistant loading"> | |
| <header> | |
| <span>{copy.roleLabels.assistant}</span> | |
| <time>{timestampLabel()}</time> | |
| </header> | |
| <p>{copy.workingMessage}</p> | |
| <div className="typing-dots" aria-hidden="true"> | |
| <span /> | |
| <span /> | |
| <span /> | |
| </div> | |
| </article> | |
| ) : null} | |
| </div> | |
| <form className="composer" onSubmit={handleSubmit}> | |
| <textarea | |
| value={input} | |
| onChange={(event) => setInput(event.target.value)} | |
| rows={3} | |
| placeholder={ | |
| copy.composerPlaceholder | |
| } | |
| /> | |
| <div className="composer-actions"> | |
| <button type="button" className="ghost-btn" onClick={clearPanels} disabled={isWorking}> | |
| {copy.clearPanels} | |
| </button> | |
| <button type="submit" className="primary-btn" disabled={isWorking || !input.trim()}> | |
| {isWorking ? copy.runningAction : copy.runDemo} | |
| </button> | |
| </div> | |
| </form> | |
| </> | |
| )} | |
| </section> | |
| {showTracePanel ? ( | |
| <aside className="panel trace-panel"> | |
| <div className="panel-header"> | |
| <h2>{copy.traceTitle}</h2> | |
| <p>{isWorking ? copy.traceLive : copy.traceLast}</p> | |
| </div> | |
| {traceItems.length ? ( | |
| <ul className="trace-list"> | |
| {traceItems.map((item) => ( | |
| <li key={item.id} className={`trace-${item.status}`}> | |
| <header> | |
| <span className="kind">{copy.kindLabels[item.kind]}</span> | |
| <strong>{item.title}</strong> | |
| <time>{item.timestamp}</time> | |
| </header> | |
| <p>{item.detail}</p> | |
| {item.payload ? ( | |
| <details> | |
| <summary>{copy.tracePayload}</summary> | |
| <pre>{JSON.stringify(item.payload, null, 2)}</pre> | |
| </details> | |
| ) : null} | |
| </li> | |
| ))} | |
| </ul> | |
| ) : ( | |
| <div className="trace-empty"> | |
| <p>{copy.traceWaiting}</p> | |
| </div> | |
| )} | |
| </aside> | |
| ) : null} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default App; | |