import { useEffect, useMemo, useRef, useState } from 'react'; import type { DemoLocale, DemoMode, ProgressStep, ProgressStepStatus, TraceItem } from './types'; import MarketplaceFeatureDemo from './MarketplaceFeatureDemo'; import { callVoiceTool } from './liveApi'; import type { LiveVoiceToolUi } from './liveApi'; type MarketplaceMode = 'hybrid' | 'products' | 'stores' | 'stats'; type ProductRecord = { id: string; name: string; category: string; price: number; unit: string; stock: number; tags: string[]; location: string; seller: string; trend: number; }; type StoreRecord = { id: string; name: string; type: 'retail' | 'restaurant' | 'wholesale' | 'co-op'; location: string; buyingFocus: string[]; responseRate: number; activeListings: number; rating: number; }; type RankedProduct = ProductRecord & { score: number }; type RankedStore = StoreRecord & { score: number }; type MarketplaceStats = { sellers: number; buyers: number; products: number; stores: number; avgResponseRate: number; weeklyDemandIndex: number; }; type MarketplaceStepTemplate = { id: string; label: string; runningDetail: string; doneDetail: string; kind: TraceItem['kind']; delayMs: number; }; type MarketplaceDemoPanelProps = { 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; }; const PRODUCT_DATA: ProductRecord[] = [ { id: 'prd-101', name: 'Free-range Eggs', category: 'Eggs', price: 118, unit: 'box', stock: 48, tags: ['egg', 'protein', 'fresh'], location: 'Hsinchu', seller: 'Beipu Greenfield Farm', trend: 84, }, { id: 'prd-102', name: 'Organic Spinach Bundle', category: 'Vegetables', price: 72, unit: 'bundle', stock: 63, tags: ['leafy', 'organic', 'salad'], location: 'Taoyuan', seller: 'Morning Leaf Cooperative', trend: 69, }, { id: 'prd-103', name: 'Golden Sweet Corn', category: 'Produce', price: 95, unit: 'pack', stock: 64, tags: ['corn', 'sweet', 'seasonal'], location: 'Miaoli', seller: 'Golden Ridge Farm', trend: 77, }, { id: 'prd-104', name: 'Ariake Seaweed Tofu', category: 'Tofu', price: 145, unit: 'set', stock: 18, tags: ['tofu', 'protein', 'vegan'], location: 'Taichung', seller: 'Blue Harbor Foods', trend: 58, }, { id: 'prd-105', name: 'Premium Cherry Tomatoes', category: 'Produce', price: 160, unit: 'box', stock: 32, tags: ['tomato', 'snack', 'fruit'], location: 'Tainan', seller: 'Red Orchard Collective', trend: 73, }, { id: 'prd-106', name: 'Jasmine Rice 2kg', category: 'Grains', price: 210, unit: 'bag', stock: 25, tags: ['rice', 'staple', 'bulk'], location: 'Yunlin', seller: 'Riverbank Fields', trend: 66, }, { id: 'prd-107', name: 'Fresh Strawberries', category: 'Fruit', price: 245, unit: 'box', stock: 16, tags: ['fruit', 'dessert', 'seasonal'], location: 'Nantou', seller: 'Suncrest Berry Farm', trend: 88, }, { id: 'prd-108', name: 'Young Ginger Pack', category: 'Produce', price: 86, unit: 'pack', stock: 40, tags: ['ginger', 'spice', 'seasoning'], location: 'Pingtung', seller: 'South Harvest Garden', trend: 55, }, ]; const STORE_DATA: StoreRecord[] = [ { id: 'str-201', name: 'Fresh Table Market', type: 'retail', location: 'Taipei', buyingFocus: ['Eggs', 'Fruit', 'Vegetables'], responseRate: 0.92, activeListings: 18, rating: 4.7, }, { id: 'str-202', name: 'Harvest Bento Kitchen', type: 'restaurant', location: 'Hsinchu', buyingFocus: ['Eggs', 'Tofu', 'Produce'], responseRate: 0.87, activeListings: 11, rating: 4.5, }, { id: 'str-203', name: 'Island Bulk Foods', type: 'wholesale', location: 'Taichung', buyingFocus: ['Grains', 'Eggs', 'Produce'], responseRate: 0.84, activeListings: 22, rating: 4.4, }, { id: 'str-204', name: 'Green Basket Co-op', type: 'co-op', location: 'Tainan', buyingFocus: ['Vegetables', 'Fruit', 'Grains'], responseRate: 0.89, activeListings: 14, rating: 4.6, }, { id: 'str-205', name: 'Seaside Family Mart', type: 'retail', location: 'Keelung', buyingFocus: ['Fruit', 'Produce'], responseRate: 0.81, activeListings: 9, rating: 4.1, }, { id: 'str-206', name: 'Lotus Garden Restaurant', type: 'restaurant', location: 'Taoyuan', buyingFocus: ['Tofu', 'Vegetables', 'Rice'], responseRate: 0.9, activeListings: 13, rating: 4.8, }, ]; const now = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', }); const sleep = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); function makeId(prefix: string): string { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; } function normalize(text: string): string { return text.trim().toLowerCase(); } function tokenize(query: string): string[] { return normalize(query) .split(/[\s,]+/) .map((token) => token.trim()) .filter(Boolean); } function buildTrace( kind: TraceItem['kind'], title: string, detail: string, status: TraceItem['status'], payload?: Record ): TraceItem { return { id: makeId('trace'), kind, title, detail, status, payload, timestamp: now(), }; } function categoryLabel(locale: DemoLocale, value: string): string { if (locale !== 'zh-TW') return value; const labels: Record = { all: '全部', Eggs: '蛋品', Vegetables: '蔬菜', Produce: '農產', Tofu: '豆製品', Grains: '穀物', Fruit: '水果', }; return labels[value] ?? value; } function storeTypeLabel(locale: DemoLocale, value: string): string { if (locale !== 'zh-TW') return value; const labels: Record = { all: '全部', retail: '零售', restaurant: '餐飲', wholesale: '批發', 'co-op': '合作社', }; return labels[value] ?? value; } function focusLabel(locale: DemoLocale, value: string): string { if (locale !== 'zh-TW') return value; const labels: Record = { Eggs: '蛋品', Fruit: '水果', Vegetables: '蔬菜', Tofu: '豆製品', Produce: '農產', Grains: '穀物', Rice: '米', }; return labels[value] ?? value; } function computeMarketplaceStats(products: ProductRecord[], stores: StoreRecord[]): MarketplaceStats { const responseRateAverage = stores.length > 0 ? stores.reduce((sum, store) => sum + store.responseRate, 0) / stores.length : 0; const weeklyDemandIndex = products.length > 0 ? Math.round(products.reduce((sum, product) => sum + product.trend, 0) / products.length) : 0; return { sellers: 42, buyers: 67, products: products.length, stores: stores.length, avgResponseRate: responseRateAverage, weeklyDemandIndex, }; } function toRankedProductsFromUi(ui: LiveVoiceToolUi | undefined, limit: number): RankedProduct[] { const rows = Array.isArray(ui?.items) ? ui.items : []; return rows.slice(0, limit).map((item, index) => { const row = typeof item === 'object' && item ? (item as Record) : {}; return { id: typeof row.id === 'string' ? row.id : `live-product-${index}`, name: typeof row.name === 'string' ? row.name : `Product ${index + 1}`, category: typeof row.category === 'string' ? row.category : 'General', price: typeof row.price === 'number' ? row.price : 0, unit: typeof row.unit === 'string' ? row.unit : 'unit', stock: typeof row.stock === 'number' ? row.stock : 0, tags: [], location: typeof row.location === 'string' ? row.location : '-', seller: typeof row.seller === 'string' ? row.seller : '-', trend: Math.max(10, 100 - index * 8), score: Math.max(18, 100 - index * 12), }; }); } function toRankedStoresFromUi(ui: LiveVoiceToolUi | undefined, limit: number): RankedStore[] { const rows = Array.isArray(ui?.items) ? ui.items : []; return rows.slice(0, limit).map((item, index) => { const row = typeof item === 'object' && item ? (item as Record) : {}; const sourceType = typeof row.type === 'string' && (row.type === 'retail' || row.type === 'restaurant' || row.type === 'wholesale' || row.type === 'co-op') ? row.type : 'retail'; return { id: typeof row.id === 'string' ? row.id : `live-store-${index}`, name: typeof row.name === 'string' ? row.name : `Store ${index + 1}`, type: sourceType, location: typeof row.location === 'string' ? row.location : '-', buyingFocus: [], responseRate: 0.8, activeListings: typeof row.activeListings === 'number' ? row.activeListings : 0, rating: typeof row.rating === 'number' ? row.rating : 4, score: Math.max(20, 100 - index * 12), }; }); } function toMarketplaceStatsFromUi(ui: LiveVoiceToolUi | undefined): MarketplaceStats | null { const rows = Array.isArray(ui?.items) ? ui.items : []; if (!rows.length) return null; const map: Record = {}; for (const row of rows) { if (typeof row !== 'object' || !row) continue; const item = row as Record; const label = typeof item.label === 'string' ? item.label.toLowerCase() : ''; const value = typeof item.value === 'number' ? item.value : typeof item.value === 'string' ? Number(item.value) : NaN; if (!label || Number.isNaN(value)) continue; map[label] = value; } return { sellers: map.sellers ?? map['賣家'] ?? 0, buyers: map.buyers ?? map['買家'] ?? 0, products: map.products ?? map['產品'] ?? map['商品'] ?? 0, stores: map.stores ?? map['店家'] ?? 0, avgResponseRate: 0, weeklyDemandIndex: 0, }; } function scoreProduct( record: ProductRecord, queryTokens: string[], category: string, minPrice: number, maxPrice: number ): number { if (category !== 'all' && record.category !== category) return -1; if (record.price < minPrice || record.price > maxPrice) return -1; let score = 20 + Math.round(record.trend / 2); if (record.stock > 0) score += 12; if (queryTokens.length === 0) return score; for (const token of queryTokens) { const inName = normalize(record.name).includes(token); const inCategory = normalize(record.category).includes(token); const inTags = record.tags.some((tag) => normalize(tag).includes(token)); const inSeller = normalize(record.seller).includes(token); const inLocation = normalize(record.location).includes(token); if (inName) score += 28; if (inCategory) score += 16; if (inTags) score += 12; if (inSeller) score += 9; if (inLocation) score += 8; } return score; } function scoreStore(record: StoreRecord, queryTokens: string[], type: string, location: string): number { if (type !== 'all' && record.type !== type) return -1; if (location.trim() && !normalize(record.location).includes(normalize(location))) return -1; let score = 26 + Math.round(record.responseRate * 30) + Math.round(record.rating * 5); if (queryTokens.length === 0) return score; for (const token of queryTokens) { const inName = normalize(record.name).includes(token); const inType = normalize(record.type).includes(token); const inFocus = record.buyingFocus.some((item) => normalize(item).includes(token)); const inLocation = normalize(record.location).includes(token); if (inName) score += 26; if (inType) score += 14; if (inFocus) score += 12; if (inLocation) score += 10; } return score; } function buildSteps(locale: DemoLocale, mode: MarketplaceMode): MarketplaceStepTemplate[] { const isZh = locale === 'zh-TW'; const steps: MarketplaceStepTemplate[] = [ { id: 'market-step-1', label: isZh ? '解析搜尋意圖' : 'Parse marketplace intent', runningDetail: isZh ? '整理查詢、價格與位置條件。' : 'Normalizing query, price and location filters.', doneDetail: isZh ? '搜尋意圖已解析。' : 'Search intent resolved.', kind: 'planner', delayMs: 420, }, ]; if (mode === 'hybrid' || mode === 'products') { steps.push({ id: 'market-step-2', label: isZh ? '呼叫商品搜尋' : 'Call product search', runningDetail: isZh ? '執行 product_search_public。' : 'Executing product_search_public route.', doneDetail: isZh ? '商品資料已回傳。' : 'Product results received.', kind: 'tool', delayMs: 520, }); } if (mode === 'hybrid' || mode === 'stores') { steps.push({ id: 'market-step-3', label: isZh ? '呼叫店家搜尋' : 'Call store search', runningDetail: isZh ? '執行 store_search_public。' : 'Executing store_search_public route.', doneDetail: isZh ? '店家資料已回傳。' : 'Store results received.', kind: 'tool', delayMs: 500, }); } if (mode === 'hybrid' || mode === 'stats') { steps.push({ id: 'market-step-4', label: isZh ? '取得市集統計' : 'Fetch marketplace stats', runningDetail: isZh ? '執行 marketplace_get_stats。' : 'Executing marketplace_get_stats route.', doneDetail: isZh ? '市集統計已更新。' : 'Marketplace stats updated.', kind: 'tool', delayMs: 420, }); } steps.push({ id: 'market-step-5', label: isZh ? '排名與渲染結果' : 'Rank and render results', runningDetail: isZh ? '進行相關性排序與展示組裝。' : 'Running relevance ranking and building output cards.', doneDetail: isZh ? '市集結果已就緒。' : 'Marketplace output is ready.', kind: 'renderer', delayMs: 360, }); return steps; } const COPY = { en: { title: 'Marketplace Discovery', subtitle: 'Simulated public marketplace search and ranking workflow', statusRunning: 'Searching…', statusReady: 'Ready', modeLabel: 'Search Mode', modeHybrid: 'Hybrid (products + stores + stats)', modeProducts: 'Products only', modeStores: 'Stores only', modeStats: 'Stats only', queryLabel: 'Marketplace Query', queryPlaceholder: 'Search by keyword, category, location, or buyer intent.', filtersLabel: 'Filters', categoryLabel: 'Category', storeTypeLabel: 'Store Type', locationLabel: 'Location', minPriceLabel: 'Min Price', maxPriceLabel: 'Max Price', limitLabel: 'Result Limit', run: 'Run Search', stop: 'Stop', reset: 'Reset Output', recentLabel: 'Recent Queries', outputTitle: 'Marketplace Results', outputSubtitle: 'Ranked product/store cards with summary stats', emptyOutput: 'Run a search to preview marketplace discovery output.', noMatches: 'No records matched current filters. Try a broader query.', stopped: 'Marketplace search was stopped by user.', summary: 'Summary', statsTitle: 'Marketplace Stats', productsTitle: 'Ranked Products', storesTitle: 'Ranked Stores', metaTitle: 'Run Metadata', rawPayload: 'Structured payload', product: { price: 'Price', stock: 'Stock', location: 'Location', seller: 'Seller', trend: 'Trend', open: 'Open product', shortlist: 'Shortlist', }, store: { type: 'Type', location: 'Location', focus: 'Buying focus', response: 'Response', listings: 'Listings', score: 'Score', open: 'Open store', contact: 'Draft outreach', }, stat: { sellers: 'Sellers', buyers: 'Buyers', products: 'Products', stores: 'Stores', demand: 'Demand Index', response: 'Avg Response', }, labels: { query: 'Query', mode: 'Mode', startedAt: 'Started At', duration: 'Duration', toolCalls: 'Tool Calls', }, }, 'zh-TW': { title: '市集探索中心', subtitle: '前端模擬公開市集搜尋與排序流程', statusRunning: '搜尋中…', statusReady: '就緒', modeLabel: '搜尋模式', modeHybrid: '混合(商品 + 店家 + 統計)', modeProducts: '僅商品', modeStores: '僅店家', modeStats: '僅統計', queryLabel: '市集查詢', queryPlaceholder: '輸入關鍵字、品類、地區或買家需求。', filtersLabel: '篩選條件', categoryLabel: '商品分類', storeTypeLabel: '店家類型', locationLabel: '地區', minPriceLabel: '最低價格', maxPriceLabel: '最高價格', limitLabel: '顯示筆數', run: '開始搜尋', stop: '停止', reset: '重置輸出', recentLabel: '最近查詢', outputTitle: '市集結果', outputSubtitle: '含排序商品/店家卡片與統計摘要', emptyOutput: '請先執行搜尋以查看市集探索輸出。', noMatches: '目前條件找不到資料,可放寬條件再試。', stopped: '市集搜尋已由使用者停止。', summary: '摘要', statsTitle: '市集統計', productsTitle: '商品排名', storesTitle: '店家排名', metaTitle: '執行資訊', rawPayload: '結構化輸出', product: { price: '價格', stock: '庫存', location: '地區', seller: '賣家', trend: '趨勢', open: '開啟商品', shortlist: '加入候選', }, store: { type: '類型', location: '地區', focus: '採購重點', response: '回覆率', listings: '需求數', score: '分數', open: '開啟店家', contact: '草擬聯絡', }, stat: { sellers: '賣家', buyers: '買家', products: '商品', stores: '店家', demand: '需求指數', response: '平均回覆', }, labels: { query: '查詢', mode: '模式', startedAt: '開始時間', duration: '耗時', toolCalls: '工具呼叫', }, }, } as const; export default function MarketplaceDemoPanel({ mode: demoMode, locale, onWorkingChange, onWorkflowInit, onStepStatus, onTrace, onClearPanels, queuedPrompt, onConsumeQueuedPrompt, }: MarketplaceDemoPanelProps) { const copy = COPY[locale]; const modeLabels: Record = { hybrid: copy.modeHybrid, products: copy.modeProducts, stores: copy.modeStores, stats: copy.modeStats, }; const [mode, setMode] = useState('hybrid'); const [query, setQuery] = useState(''); const [category, setCategory] = useState('all'); const [storeType, setStoreType] = useState('all'); const [location, setLocation] = useState(''); const [minPrice, setMinPrice] = useState(0); const [maxPrice, setMaxPrice] = useState(260); const [limit, setLimit] = useState(6); const [isRunning, setIsRunning] = useState(false); const [products, setProducts] = useState([]); const [stores, setStores] = useState([]); const [stats, setStats] = useState(null); const [summary, setSummary] = useState(null); const [error, setError] = useState(null); const [recentQueries, setRecentQueries] = useState([]); const [meta, setMeta] = useState<{ query: string; mode: MarketplaceMode; startedAt: string; durationMs: number; toolCalls: string[]; } | null>(null); const runTokenRef = useRef(0); const mountedRef = useRef(true); const currentStepRef = useRef(null); const categoryOptions = useMemo(() => { const unique = Array.from(new Set(PRODUCT_DATA.map((record) => record.category))); return ['all', ...unique]; }, []); const stepRunner = async ( runToken: number, step: MarketplaceStepTemplate, payload?: Record ): Promise => { if (!mountedRef.current || runTokenRef.current !== runToken) return false; currentStepRef.current = step.id; onStepStatus(step.id, 'in_progress', step.runningDetail); onTrace(buildTrace(step.kind, step.label, step.runningDetail, 'running')); if (step.delayMs > 0) { await sleep(step.delayMs); } if (!mountedRef.current || runTokenRef.current !== runToken) return false; onStepStatus(step.id, 'completed', step.doneDetail); onTrace(buildTrace(step.kind, step.label, step.doneDetail, 'ok', payload)); return true; }; const stopSearch = () => { if (!isRunning) return; runTokenRef.current += 1; if (currentStepRef.current) { onStepStatus(currentStepRef.current, 'error', copy.stopped); } onTrace(buildTrace('tool', locale === 'zh-TW' ? '搜尋中止' : 'Search stopped', copy.stopped, 'error')); setError(copy.stopped); setIsRunning(false); onWorkingChange(false); }; const resetOutput = () => { setProducts([]); setStores([]); setStats(null); setSummary(null); setError(null); setMeta(null); onClearPanels(); }; const runSearch = async (forcedQuery?: string) => { const activeQuery = (forcedQuery ?? query).trim(); const steps = buildSteps(locale, mode); const runToken = runTokenRef.current + 1; runTokenRef.current = runToken; const startMs = Date.now(); const tokens = tokenize(activeQuery); const toolCalls: string[] = []; onClearPanels(); setIsRunning(true); onWorkingChange(true); setProducts([]); setStores([]); setStats(null); setSummary(null); setError(null); setMeta(null); if (activeQuery) { setRecentQueries((prev) => [activeQuery, ...prev.filter((item) => item !== activeQuery)].slice(0, 8)); } onWorkflowInit( steps.map((step) => ({ id: step.id, label: step.label, detail: step.runningDetail, status: 'pending', })) ); try { const parsed = await stepRunner(runToken, steps[0], { query: activeQuery || '*', tokens, filters: { category, storeType, location, minPrice, maxPrice, limit }, }); if (!parsed) return; if (demoMode === 'live') { let liveProducts: RankedProduct[] = []; let liveStores: RankedStore[] = []; let liveStats: MarketplaceStats | null = null; const productStep = steps.find((step) => step.id === 'market-step-2'); if (productStep) { toolCalls.push('product_search_public'); const productReady = await stepRunner(runToken, productStep, { args: { nameContains: activeQuery || undefined, categoryEquals: category !== 'all' ? category : undefined, minPrice, maxPrice, limit, }, }); if (!productReady) return; const tool = await callVoiceTool({ name: 'product_search_public', locale, args: { nameContains: activeQuery || undefined, categoryEquals: category !== 'all' ? category : undefined, minPrice, maxPrice, limit, }, }); liveProducts = toRankedProductsFromUi(tool.ui, limit); setProducts(liveProducts); onTrace( buildTrace( 'tool', locale === 'zh-TW' ? '商品結果' : 'Product results', locale === 'zh-TW' ? `回傳 ${liveProducts.length} 筆商品` : `${liveProducts.length} products returned`, 'ok', { count: liveProducts.length, uiKind: tool.ui?.kind || null } ) ); } const storeStep = steps.find((step) => step.id === 'market-step-3'); if (storeStep) { toolCalls.push('store_search_public'); const storeReady = await stepRunner(runToken, storeStep, { args: { nameContains: activeQuery || undefined, typeEquals: storeType !== 'all' ? storeType : undefined, locationContains: location || undefined, limit, }, }); if (!storeReady) return; const tool = await callVoiceTool({ name: 'store_search_public', locale, args: { nameContains: activeQuery || undefined, typeEquals: storeType !== 'all' ? storeType : undefined, locationContains: location || undefined, limit, }, }); liveStores = toRankedStoresFromUi(tool.ui, limit); setStores(liveStores); onTrace( buildTrace( 'tool', locale === 'zh-TW' ? '店家結果' : 'Store results', locale === 'zh-TW' ? `回傳 ${liveStores.length} 筆店家` : `${liveStores.length} stores returned`, 'ok', { count: liveStores.length, uiKind: tool.ui?.kind || null } ) ); } const statsStep = steps.find((step) => step.id === 'market-step-4'); if (statsStep) { toolCalls.push('marketplace_get_stats'); const statsReady = await stepRunner(runToken, statsStep); if (!statsReady) return; const tool = await callVoiceTool({ name: 'marketplace_get_stats', locale, }); liveStats = toMarketplaceStatsFromUi(tool.ui) || computeMarketplaceStats(liveProducts, liveStores); setStats(liveStats); onTrace( buildTrace( 'tool', locale === 'zh-TW' ? '市集統計' : 'Marketplace stats', locale === 'zh-TW' ? '已取得即時統計。' : 'Fetched live marketplace stats.', 'ok', { uiKind: tool.ui?.kind || null } ) ); } const renderStep = steps.find((step) => step.id === 'market-step-5'); if (renderStep) { const rendered = await stepRunner(runToken, renderStep, { products: liveProducts.length, stores: liveStores.length, stats: Boolean(liveStats), mode: 'live', }); if (!rendered) return; } const summaryText = locale === 'zh-TW' ? `已完成即時市集搜尋:商品 ${liveProducts.length} 筆,店家 ${liveStores.length} 筆。` : `Live marketplace search completed: ${liveProducts.length} products and ${liveStores.length} stores.`; setSummary(summaryText); setMeta({ query: activeQuery || '*', mode, startedAt: new Date(startMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), durationMs: Date.now() - startMs, toolCalls, }); return; } const productStep = steps.find((step) => step.id === 'market-step-2'); if (productStep) { toolCalls.push('product_search_public'); const productReady = await stepRunner(runToken, productStep, { args: { nameContains: activeQuery || undefined, categoryEquals: category !== 'all' ? category : undefined, minPrice, maxPrice, limit, }, }); if (!productReady) return; const rankedProducts = PRODUCT_DATA.map((record) => ({ ...record, score: scoreProduct(record, tokens, category, minPrice, maxPrice), })) .filter((record) => record.score > 0) .sort((a, b) => b.score - a.score) .slice(0, limit); setProducts(rankedProducts); onTrace( buildTrace( 'tool', locale === 'zh-TW' ? '商品結果' : 'Product results', locale === 'zh-TW' ? `回傳 ${rankedProducts.length} 筆商品` : `${rankedProducts.length} products returned`, 'ok', { count: rankedProducts.length } ) ); } const storeStep = steps.find((step) => step.id === 'market-step-3'); if (storeStep) { toolCalls.push('store_search_public'); const storeReady = await stepRunner(runToken, storeStep, { args: { nameContains: activeQuery || undefined, typeEquals: storeType !== 'all' ? storeType : undefined, locationContains: location || undefined, limit, }, }); if (!storeReady) return; const rankedStores = STORE_DATA.map((record) => ({ ...record, score: scoreStore(record, tokens, storeType, location), })) .filter((record) => record.score > 0) .sort((a, b) => b.score - a.score) .slice(0, limit); setStores(rankedStores); onTrace( buildTrace( 'tool', locale === 'zh-TW' ? '店家結果' : 'Store results', locale === 'zh-TW' ? `回傳 ${rankedStores.length} 筆店家` : `${rankedStores.length} stores returned`, 'ok', { count: rankedStores.length } ) ); } const statsStep = steps.find((step) => step.id === 'market-step-4'); if (statsStep) { toolCalls.push('marketplace_get_stats'); const statsReady = await stepRunner(runToken, statsStep); if (!statsReady) return; setStats(computeMarketplaceStats(PRODUCT_DATA, STORE_DATA)); } const renderStep = steps.find((step) => step.id === 'market-step-5'); if (renderStep) { const rendered = await stepRunner(runToken, renderStep, { products: mode === 'stores' || mode === 'stats' ? undefined : 'ranked', stores: mode === 'products' || mode === 'stats' ? undefined : 'ranked', stats: mode === 'products' || mode === 'stores' ? undefined : 'included', }); if (!rendered) return; } const productCount = mode === 'stores' || mode === 'stats' ? 0 : PRODUCT_DATA.filter((record) => scoreProduct(record, tokens, category, minPrice, maxPrice) > 0).slice(0, limit).length; const storeCount = mode === 'products' || mode === 'stats' ? 0 : STORE_DATA.filter((record) => scoreStore(record, tokens, storeType, location) > 0).slice(0, limit).length; const summaryText = locale === 'zh-TW' ? `已完成市集搜尋:商品 ${productCount} 筆,店家 ${storeCount} 筆。` : `Marketplace search completed: ${productCount} products and ${storeCount} stores ranked.`; setSummary(summaryText); setMeta({ query: activeQuery || '*', mode, startedAt: new Date(startMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), durationMs: Date.now() - startMs, toolCalls, }); } catch (runtimeError) { const message = runtimeError instanceof Error ? runtimeError.message : copy.stopped; setError(message); if (currentStepRef.current) { onStepStatus(currentStepRef.current, 'error', message); } onTrace(buildTrace('tool', locale === 'zh-TW' ? '搜尋錯誤' : 'Search error', message, 'error')); } finally { if (runTokenRef.current === runToken) { setIsRunning(false); onWorkingChange(false); } } }; useEffect(() => { if (!queuedPrompt) return; const consume = async () => { setQuery(queuedPrompt); onConsumeQueuedPrompt(); await runSearch(queuedPrompt); }; void consume(); }, [queuedPrompt]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; runTokenRef.current += 1; onWorkingChange(false); }; }, []); const hasResults = products.length > 0 || stores.length > 0 || Boolean(stats); return (

{copy.title}

{copy.subtitle}

{isRunning ? copy.statusRunning : copy.statusReady}