Farm2Market / src /App.tsx
pixel3user
Update Farm2Market demo frontend
e46c4bd
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;