Spaces:
Sleeping
Sleeping
| import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; | |
| import type { DemoLocale, DemoMode, ProgressStep, ProgressStepStatus, TraceItem } from './types'; | |
| import MarketingFeatureDemo from './MarketingFeatureDemo'; | |
| import { parseDataUrl, streamMarketingGeneration } from './liveApi'; | |
| type MarketingStepTemplate = { | |
| id: string; | |
| label: string; | |
| runningDetail: string; | |
| doneDetail: string; | |
| kind: TraceItem['kind']; | |
| delayMs: number; | |
| }; | |
| type MarketingProduct = { | |
| id: string; | |
| name: string; | |
| category: string; | |
| price: number; | |
| stock: number; | |
| unit: string; | |
| }; | |
| type MarketingDeck = { | |
| headline: string; | |
| caption: string; | |
| cta: string; | |
| hashtags: string[]; | |
| designNotes: string[]; | |
| }; | |
| type GeneratedImage = { | |
| id: string; | |
| dataUrl: string; | |
| mimeType: string; | |
| base64: string; | |
| label: string; | |
| }; | |
| type MarketingStudioPanelProps = { | |
| 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 COPY = { | |
| en: { | |
| title: 'Marketing Studio', | |
| subtitle: 'Simulated Gemini marketing enhancement workflow (frontend-only demo)', | |
| productLabel: 'Product', | |
| briefLabel: 'Creative Brief', | |
| promptPlaceholder: | |
| 'Describe the campaign angle, tone, and CTA. Example: Weekend promotion for family breakfast packs.', | |
| toneLabel: 'Tone', | |
| channelLabel: 'Channel', | |
| imageLabel: 'Reference Image', | |
| uploadButton: 'Upload Image', | |
| removeImage: 'Remove', | |
| noImage: 'No image uploaded. A synthetic product visual will be generated.', | |
| generating: 'Generating assets…', | |
| ready: 'Ready', | |
| generate: 'Generate Campaign', | |
| stop: 'Stop', | |
| reset: 'Reset Output', | |
| copyDeck: 'Copy Deck', | |
| copied: 'Campaign copy copied to clipboard.', | |
| outputTitle: 'Generated Campaign', | |
| outputSubtitle: 'Streaming copy + image assets', | |
| metadataTitle: 'Run Metadata', | |
| metadataModel: 'Model', | |
| metadataStartedAt: 'Started at', | |
| metadataDuration: 'Duration', | |
| metadataEvents: 'Events', | |
| metadataEventsWithImages: '{count} image + text stream', | |
| metadataEventsTextOnly: 'text stream', | |
| noOutput: 'Run generation to see marketing copy and visuals.', | |
| streamError: 'Generation halted before completion.', | |
| stoppedByUser: 'Generation stopped by user.', | |
| applyMock: 'Mock: attached generated image to product showcase.', | |
| tutorialPrompt: 'Create a polished campaign for this product with a direct CTA.', | |
| }, | |
| 'zh-TW': { | |
| title: '行銷工作室', | |
| subtitle: '前端模擬 Gemini 行銷增強流程(不連後端)', | |
| productLabel: '產品', | |
| briefLabel: '創意需求', | |
| promptPlaceholder: '描述活動主軸、語氣與 CTA。例如:週末家庭早餐檔期,強調產地直送。', | |
| toneLabel: '語氣', | |
| channelLabel: '投放渠道', | |
| imageLabel: '參考圖片', | |
| uploadButton: '上傳圖片', | |
| removeImage: '移除', | |
| noImage: '尚未上傳圖片,系統會產生模擬商品視覺。', | |
| generating: '生成中…', | |
| ready: '就緒', | |
| generate: '生成活動素材', | |
| stop: '停止', | |
| reset: '重置輸出', | |
| copyDeck: '複製文案', | |
| copied: '活動文案已複製到剪貼簿。', | |
| outputTitle: '生成結果', | |
| outputSubtitle: '即時文案串流 + 圖像素材', | |
| metadataTitle: '執行資訊', | |
| metadataModel: '模型', | |
| metadataStartedAt: '開始時間', | |
| metadataDuration: '耗時', | |
| metadataEvents: '事件', | |
| metadataEventsWithImages: '{count} 張圖片 + 文字串流', | |
| metadataEventsTextOnly: '文字串流', | |
| noOutput: '執行生成後可在此查看文案與圖像。', | |
| streamError: '生成流程尚未完整結束。', | |
| stoppedByUser: '已由使用者停止生成。', | |
| applyMock: '模擬:已將生成圖片套用到產品展示。', | |
| tutorialPrompt: '請為這個產品建立一套完整活動文案並附上明確 CTA。', | |
| }, | |
| } as const; | |
| const CHANNEL_OPTIONS = { | |
| en: ['LINE Message', 'Instagram Post', 'Store Flyer', 'Marketplace Banner'], | |
| 'zh-TW': ['LINE 訊息', '社群貼文', '店內海報', '市集橫幅'], | |
| } as const; | |
| const TONE_OPTIONS = { | |
| en: ['Friendly', 'Premium', 'Urgent', 'Seasonal'], | |
| 'zh-TW': ['親切', '精品感', '限時感', '季節感'], | |
| } as const; | |
| const PRODUCTS = { | |
| en: [ | |
| { id: 'p1', name: 'Free-range Eggs', category: 'Eggs', price: 118, stock: 48, unit: 'box' }, | |
| { id: 'p2', name: 'Organic Greens Bundle', category: 'Vegetables', price: 220, stock: 18, unit: 'set' }, | |
| { id: 'p3', name: 'Golden Sweet Corn', category: 'Produce', price: 95, stock: 64, unit: 'pack' }, | |
| ], | |
| 'zh-TW': [ | |
| { id: 'p1', name: '放牧雞蛋', category: '蛋品', price: 118, stock: 48, unit: '盒' }, | |
| { id: 'p2', name: '有機蔬菜組', category: '蔬菜', price: 220, stock: 18, unit: '組' }, | |
| { id: 'p3', name: '黃金甜玉米', category: '農產', price: 95, stock: 64, unit: '包' }, | |
| ], | |
| } as const; | |
| const now = () => | |
| new Date().toLocaleTimeString([], { | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| second: '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 splitTextChunks(text: string): string[] { | |
| const words = text.split(/\s+/).filter(Boolean); | |
| const chunks: string[] = []; | |
| let current = ''; | |
| for (const word of words) { | |
| const next = current ? `${current} ${word}` : word; | |
| if (next.length > 28) { | |
| if (current) chunks.push(`${current} `); | |
| current = word; | |
| } else { | |
| current = next; | |
| } | |
| } | |
| if (current) chunks.push(`${current} `); | |
| return chunks; | |
| } | |
| function makeTrace( | |
| 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 buildSteps(locale: DemoLocale): MarketingStepTemplate[] { | |
| if (locale === 'zh-TW') { | |
| return [ | |
| { | |
| id: 'mkt-step-1', | |
| label: '組裝行銷需求', | |
| runningDetail: '整理產品資訊、語氣與渠道參數。', | |
| doneDetail: '行銷需求已完成組裝。', | |
| kind: 'planner', | |
| delayMs: 500, | |
| }, | |
| { | |
| id: 'mkt-step-2', | |
| label: '圖片前處理', | |
| runningDetail: '分析參考圖片與視覺方向。', | |
| doneDetail: '視覺參考已就緒。', | |
| kind: 'validator', | |
| delayMs: 640, | |
| }, | |
| { | |
| id: 'mkt-step-3', | |
| label: '串流生成文案', | |
| runningDetail: '逐段輸出標題、內文、CTA 與 Hashtag。', | |
| doneDetail: '文案串流輸出完成。', | |
| kind: 'tool', | |
| delayMs: 0, | |
| }, | |
| { | |
| id: 'mkt-step-4', | |
| label: '生成圖片素材', | |
| runningDetail: '建立活動主視覺與備用版型。', | |
| doneDetail: '圖片素材已完成輸出。', | |
| kind: 'tool', | |
| delayMs: 0, | |
| }, | |
| { | |
| id: 'mkt-step-5', | |
| label: '打包最終輸出', | |
| runningDetail: '整理可直接使用的活動結果。', | |
| doneDetail: '活動素材已可發布。', | |
| kind: 'renderer', | |
| delayMs: 420, | |
| }, | |
| ]; | |
| } | |
| return [ | |
| { | |
| id: 'mkt-step-1', | |
| label: 'Assemble campaign brief', | |
| runningDetail: 'Collecting product context, tone, and channel.', | |
| doneDetail: 'Campaign brief assembled.', | |
| kind: 'planner', | |
| delayMs: 500, | |
| }, | |
| { | |
| id: 'mkt-step-2', | |
| label: 'Pre-process visual input', | |
| runningDetail: 'Analyzing reference image and visual direction.', | |
| doneDetail: 'Visual context prepared.', | |
| kind: 'validator', | |
| delayMs: 640, | |
| }, | |
| { | |
| id: 'mkt-step-3', | |
| label: 'Stream marketing copy', | |
| runningDetail: 'Streaming headline, caption, CTA, and hashtags.', | |
| doneDetail: 'Copy stream completed.', | |
| kind: 'tool', | |
| delayMs: 0, | |
| }, | |
| { | |
| id: 'mkt-step-4', | |
| label: 'Generate visuals', | |
| runningDetail: 'Creating hero and alternate campaign images.', | |
| doneDetail: 'Image assets generated.', | |
| kind: 'tool', | |
| delayMs: 0, | |
| }, | |
| { | |
| id: 'mkt-step-5', | |
| label: 'Package final output', | |
| runningDetail: 'Preparing publish-ready campaign deck.', | |
| doneDetail: 'Campaign output is ready.', | |
| kind: 'renderer', | |
| delayMs: 420, | |
| }, | |
| ]; | |
| } | |
| function createDeck(locale: DemoLocale, product: MarketingProduct, prompt: string, tone: string, channel: string): MarketingDeck { | |
| if (locale === 'zh-TW') { | |
| const cue = prompt.trim() ? `,聚焦「${prompt.trim().slice(0, 28)}」` : ''; | |
| return { | |
| headline: `${product.name} 新鮮上架,今天就下單${cue}`, | |
| caption: `來自在地農場的 ${product.name},口感穩定、品質透明。${tone}語氣搭配 ${channel} 推廣,強調價格 NT$${product.price}/${product.unit} 與現貨 ${product.stock} ${product.unit}。`, | |
| cta: '立即私訊預購,本週優先出貨。', | |
| hashtags: ['#產地直送', '#Farm2Market', '#今日新鮮', '#限時供應'], | |
| designNotes: ['白底乾淨陳列,強調產品本體', '加入暖色光感提升食慾', '版面留出 CTA 區塊可放價格與庫存'], | |
| }; | |
| } | |
| const cue = prompt.trim() ? `, focused on "${prompt.trim().slice(0, 32)}"` : ''; | |
| return { | |
| headline: `${product.name} is now in stock${cue}`, | |
| caption: `Freshly prepared ${product.category.toLowerCase()} with consistent quality and direct farm sourcing. Use a ${tone.toLowerCase()} tone for ${channel} and highlight NT$${product.price}/${product.unit} with ${product.stock} units available.`, | |
| cta: 'Reserve your batch now for this week\'s priority delivery.', | |
| hashtags: ['#Farm2Market', '#FarmFresh', '#DirectFromFarm', '#WeeklyDrop'], | |
| designNotes: ['Use clean white background and product-focused framing', 'Add gentle warm highlights for freshness', 'Reserve a clear CTA block for price and stock'], | |
| }; | |
| } | |
| function deckToMarkdown(locale: DemoLocale, deck: MarketingDeck): string { | |
| if (locale === 'zh-TW') { | |
| return [ | |
| `# ${deck.headline}`, | |
| '', | |
| `**文案**: ${deck.caption}`, | |
| '', | |
| `**CTA**: ${deck.cta}`, | |
| '', | |
| `**Hashtag**: ${deck.hashtags.join(' ')}`, | |
| '', | |
| '**設計重點**:', | |
| ...deck.designNotes.map((note) => `- ${note}`), | |
| ].join('\n'); | |
| } | |
| return [ | |
| `# ${deck.headline}`, | |
| '', | |
| `**Caption**: ${deck.caption}`, | |
| '', | |
| `**CTA**: ${deck.cta}`, | |
| '', | |
| `**Hashtags**: ${deck.hashtags.join(' ')}`, | |
| '', | |
| '**Design Notes**:', | |
| ...deck.designNotes.map((note) => `- ${note}`), | |
| ].join('\n'); | |
| } | |
| function parseDeckFromLiveText(locale: DemoLocale, text: string): MarketingDeck { | |
| const fallback = locale === 'zh-TW' | |
| ? { | |
| headline: '已生成行銷內容', | |
| caption: text.trim().slice(0, 220) || '已從後端串流取得文案結果。', | |
| cta: '立即查看並套用', | |
| hashtags: ['#Farm2Market'], | |
| designNotes: ['已使用後端即時生成結果。'], | |
| } | |
| : { | |
| headline: 'Marketing content generated', | |
| caption: text.trim().slice(0, 220) || 'Live backend copy has been streamed.', | |
| cta: 'Review and apply now', | |
| hashtags: ['#Farm2Market'], | |
| designNotes: ['Generated from live backend stream.'], | |
| }; | |
| const normalized = text | |
| .split('\n') | |
| .map((line) => line.trim()) | |
| .filter(Boolean); | |
| if (!normalized.length) return fallback; | |
| const hashTags = Array.from( | |
| new Set( | |
| (text.match(/#[\p{L}\p{N}_-]+/gu) || []) | |
| .map((tag) => tag.trim()) | |
| .filter(Boolean) | |
| ) | |
| ); | |
| const headline = normalized[0]?.replace(/^#+\s*/, '') || fallback.headline; | |
| const caption = normalized.slice(1, 4).join(' ') || fallback.caption; | |
| const ctaLine = normalized.find((line) => | |
| locale === 'zh-TW' ? /(立即|現在|下單|預購|CTA|行動)/.test(line) : /(cta|order|buy now|reserve|shop)/i.test(line) | |
| ); | |
| const noteLines = normalized | |
| .filter((line) => line.startsWith('-') || line.startsWith('•')) | |
| .map((line) => line.replace(/^[-•]\s*/, '')) | |
| .slice(0, 3); | |
| return { | |
| headline, | |
| caption, | |
| cta: ctaLine || fallback.cta, | |
| hashtags: hashTags.length ? hashTags.slice(0, 6) : fallback.hashtags, | |
| designNotes: noteLines.length ? noteLines : fallback.designNotes, | |
| }; | |
| } | |
| function makePalette(seed: number): [string, string, string] { | |
| const palettes: Array<[string, string, string]> = [ | |
| ['#1f8f4e', '#7dcf9b', '#edf8e6'], | |
| ['#0f6d8b', '#88d7e6', '#e8f7fb'], | |
| ['#d97706', '#f6b56d', '#fff4e6'], | |
| ['#6b7c2f', '#c8da89', '#f5f9e6'], | |
| ]; | |
| return palettes[seed % palettes.length]; | |
| } | |
| function generateMockImageDataUrl(product: MarketingProduct, deck: MarketingDeck, index: number, locale: DemoLocale): string { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = 960; | |
| canvas.height = 640; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) return ''; | |
| const isZh = locale === 'zh-TW'; | |
| const [primary, secondary, light] = makePalette(index + product.name.length); | |
| const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height); | |
| gradient.addColorStop(0, light); | |
| gradient.addColorStop(0.58, secondary); | |
| gradient.addColorStop(1, primary); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.globalAlpha = 0.2; | |
| for (let i = 0; i < 6; i += 1) { | |
| ctx.beginPath(); | |
| ctx.fillStyle = i % 2 ? '#ffffff' : primary; | |
| const radius = 60 + i * 22; | |
| ctx.arc(120 + i * 130, 100 + (i % 3) * 170, radius, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.fillStyle = 'rgba(255,255,255,0.88)'; | |
| ctx.fillRect(70, 70, canvas.width - 140, canvas.height - 140); | |
| ctx.fillStyle = '#163024'; | |
| ctx.font = 'bold 46px "Sora", sans-serif'; | |
| ctx.fillText(deck.headline.slice(0, 28), 112, 180); | |
| ctx.fillStyle = '#345747'; | |
| ctx.font = '28px "IBM Plex Sans", sans-serif'; | |
| ctx.fillText(product.name, 112, 238); | |
| ctx.fillStyle = '#1c6e43'; | |
| ctx.font = 'bold 36px "Sora", sans-serif'; | |
| ctx.fillText(`NT$ ${product.price}/${product.unit}`, 112, 308); | |
| ctx.fillStyle = '#445b51'; | |
| ctx.font = '24px "IBM Plex Sans", sans-serif'; | |
| ctx.fillText(deck.cta.slice(0, 44), 112, 372); | |
| ctx.fillStyle = '#0f6d8b'; | |
| ctx.font = 'bold 20px "IBM Plex Sans", sans-serif'; | |
| ctx.fillText(deck.hashtags.slice(0, 3).join(' '), 112, 434); | |
| ctx.fillStyle = 'rgba(31,143,78,0.14)'; | |
| ctx.fillRect(620, 120, 240, 360); | |
| ctx.fillStyle = '#1f8f4e'; | |
| ctx.font = 'bold 22px "Sora", sans-serif'; | |
| ctx.fillText(isZh ? '農鏈市集' : 'FARM2MARKET', 646, 170); | |
| ctx.font = '18px "IBM Plex Sans", sans-serif'; | |
| ctx.fillText(isZh ? (index % 2 === 0 ? '主視覺版' : '社群版') : index % 2 === 0 ? 'Hero Variant' : 'Social Variant', 646, 206); | |
| ctx.fillText(isZh ? `庫存:${product.stock}` : `Stock: ${product.stock}`, 646, 246); | |
| return canvas.toDataURL('image/png'); | |
| } | |
| export default function MarketingStudioPanel({ | |
| mode, | |
| locale, | |
| onWorkingChange, | |
| onWorkflowInit, | |
| onStepStatus, | |
| onTrace, | |
| onClearPanels, | |
| queuedPrompt, | |
| onConsumeQueuedPrompt, | |
| }: MarketingStudioPanelProps) { | |
| const copy = COPY[locale]; | |
| const products = PRODUCTS[locale]; | |
| const channels = CHANNEL_OPTIONS[locale]; | |
| const tones = TONE_OPTIONS[locale]; | |
| const [selectedProductId, setSelectedProductId] = useState<string>(products[0].id); | |
| const [prompt, setPrompt] = useState(''); | |
| const [tone, setTone] = useState<string>(tones[0]); | |
| const [channel, setChannel] = useState<string>(channels[0]); | |
| const [uploadedImage, setUploadedImage] = useState<{ name: string; previewUrl: string } | null>(null); | |
| const [isGenerating, setIsGenerating] = useState(false); | |
| const [streamText, setStreamText] = useState(''); | |
| const [deck, setDeck] = useState<MarketingDeck | null>(null); | |
| const [images, setImages] = useState<GeneratedImage[]>([]); | |
| const [error, setError] = useState<string | null>(null); | |
| const [meta, setMeta] = useState<{ model: string; durationMs: number; startedAt: string } | null>(null); | |
| const [note, setNote] = useState<string | null>(null); | |
| const fileInputRef = useRef<HTMLInputElement | null>(null); | |
| const abortRef = useRef<AbortController | null>(null); | |
| const runTokenRef = useRef(0); | |
| const mountedRef = useRef(true); | |
| const currentStepRef = useRef<string | null>(null); | |
| const selectedProduct = useMemo( | |
| () => products.find((product) => product.id === selectedProductId) ?? products[0], | |
| [products, selectedProductId] | |
| ); | |
| const canGenerate = Boolean(selectedProduct) && !isGenerating; | |
| const runStep = async ( | |
| runToken: number, | |
| template: MarketingStepTemplate, | |
| payload?: Record<string, unknown> | |
| ): Promise<boolean> => { | |
| if (!mountedRef.current || runTokenRef.current !== runToken) return false; | |
| currentStepRef.current = template.id; | |
| onStepStatus(template.id, 'in_progress', template.runningDetail); | |
| onTrace(makeTrace(template.kind, template.label, template.runningDetail, 'running')); | |
| if (template.delayMs > 0) { | |
| await sleep(template.delayMs); | |
| } | |
| if (!mountedRef.current || runTokenRef.current !== runToken) return false; | |
| onStepStatus(template.id, 'completed', template.doneDetail); | |
| onTrace(makeTrace(template.kind, template.label, template.doneDetail, 'ok', payload)); | |
| return true; | |
| }; | |
| const stopGeneration = () => { | |
| if (!isGenerating) return; | |
| runTokenRef.current += 1; | |
| abortRef.current?.abort(); | |
| abortRef.current = null; | |
| if (currentStepRef.current) { | |
| onStepStatus(currentStepRef.current, 'error', copy.stoppedByUser); | |
| } | |
| onTrace(makeTrace('tool', locale === 'zh-TW' ? '生成中止' : 'Generation stopped', copy.stoppedByUser, 'error')); | |
| setError(copy.stoppedByUser); | |
| setIsGenerating(false); | |
| onWorkingChange(false); | |
| }; | |
| const resetOutput = () => { | |
| setStreamText(''); | |
| setDeck(null); | |
| setImages([]); | |
| setError(null); | |
| setMeta(null); | |
| setNote(null); | |
| onClearPanels(); | |
| }; | |
| const runGeneration = async (initialPrompt?: string) => { | |
| if (!selectedProduct) return; | |
| const activePrompt = (initialPrompt ?? prompt).trim(); | |
| const steps = buildSteps(locale); | |
| const runToken = runTokenRef.current + 1; | |
| runTokenRef.current = runToken; | |
| const startedAtMs = Date.now(); | |
| onClearPanels(); | |
| setIsGenerating(true); | |
| onWorkingChange(true); | |
| setError(null); | |
| setStreamText(''); | |
| setImages([]); | |
| setDeck(null); | |
| setMeta(null); | |
| setNote(null); | |
| onWorkflowInit( | |
| steps.map((step) => ({ | |
| id: step.id, | |
| label: step.label, | |
| detail: step.runningDetail, | |
| status: 'pending', | |
| })) | |
| ); | |
| try { | |
| const prepared = await runStep(runToken, steps[0], { | |
| product: selectedProduct.name, | |
| tone, | |
| channel, | |
| }); | |
| if (!prepared) return; | |
| const imageReady = await runStep(runToken, steps[1], { | |
| hasReferenceImage: Boolean(uploadedImage), | |
| }); | |
| if (!imageReady) return; | |
| if (mode === 'live') { | |
| const textStep = steps[2]; | |
| const imageStep = steps[3]; | |
| const controller = new AbortController(); | |
| abortRef.current = controller; | |
| currentStepRef.current = textStep.id; | |
| onStepStatus(textStep.id, 'in_progress', textStep.runningDetail); | |
| onTrace(makeTrace(textStep.kind, textStep.label, textStep.runningDetail, 'running')); | |
| let imageStepStarted = false; | |
| let streamedImages = 0; | |
| let streamedText = ''; | |
| await streamMarketingGeneration({ | |
| prompt: activePrompt, | |
| locale, | |
| product: { | |
| name: selectedProduct.name, | |
| category: selectedProduct.category, | |
| description: selectedProduct.name, | |
| }, | |
| image: uploadedImage ? parseDataUrl(uploadedImage.previewUrl) : undefined, | |
| signal: controller.signal, | |
| onEvent: (event) => { | |
| if (!mountedRef.current || runTokenRef.current !== runToken) return; | |
| if (event.type === 'text') { | |
| streamedText += event.text; | |
| setStreamText((prev) => prev + event.text); | |
| return; | |
| } | |
| if (event.type === 'image') { | |
| streamedImages += 1; | |
| if (!imageStepStarted) { | |
| imageStepStarted = true; | |
| currentStepRef.current = imageStep.id; | |
| onStepStatus(imageStep.id, 'in_progress', imageStep.runningDetail); | |
| onTrace(makeTrace(imageStep.kind, imageStep.label, imageStep.runningDetail, 'running')); | |
| } | |
| const dataUrl = `data:${event.mimeType || 'image/png'};base64,${event.base64}`; | |
| setImages((prev) => [ | |
| ...prev, | |
| { | |
| id: makeId('img'), | |
| dataUrl, | |
| base64: event.base64, | |
| mimeType: event.mimeType || 'image/png', | |
| label: | |
| locale === 'zh-TW' | |
| ? streamedImages === 1 | |
| ? '主視覺' | |
| : `社群版型 ${streamedImages - 1}` | |
| : streamedImages === 1 | |
| ? 'Hero visual' | |
| : `Social variation ${streamedImages - 1}`, | |
| }, | |
| ]); | |
| onTrace( | |
| makeTrace( | |
| 'tool', | |
| locale === 'zh-TW' ? '圖片片段' : 'Image chunk', | |
| locale === 'zh-TW' ? `已接收第 ${streamedImages} 張圖片` : `Received image ${streamedImages}`, | |
| 'ok' | |
| ) | |
| ); | |
| return; | |
| } | |
| if (event.type === 'error') { | |
| throw new Error(event.message || copy.streamError); | |
| } | |
| }, | |
| }); | |
| if (!mountedRef.current || runTokenRef.current !== runToken) return; | |
| abortRef.current = null; | |
| onStepStatus(textStep.id, 'completed', textStep.doneDetail); | |
| onTrace(makeTrace(textStep.kind, textStep.label, textStep.doneDetail, 'ok', { chars: streamedText.length })); | |
| if (imageStepStarted) { | |
| onStepStatus(imageStep.id, 'completed', imageStep.doneDetail); | |
| onTrace(makeTrace(imageStep.kind, imageStep.label, imageStep.doneDetail, 'ok', { images: streamedImages })); | |
| } else { | |
| onStepStatus(imageStep.id, 'completed', locale === 'zh-TW' ? '本次未回傳圖片。' : 'No image chunks in this run.'); | |
| onTrace( | |
| makeTrace( | |
| imageStep.kind, | |
| imageStep.label, | |
| locale === 'zh-TW' ? '本次只回傳文字。' : 'Text-only response returned.', | |
| 'ok' | |
| ) | |
| ); | |
| } | |
| const finalDeck = parseDeckFromLiveText(locale, streamedText); | |
| setDeck(finalDeck); | |
| const packaged = await runStep(runToken, steps[4], { | |
| output: { | |
| text: true, | |
| images: streamedImages, | |
| mode: 'live', | |
| }, | |
| }); | |
| if (!packaged) return; | |
| setMeta({ | |
| model: 'gemini-2.5-flash-image (live)', | |
| startedAt: new Date(startedAtMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), | |
| durationMs: Date.now() - startedAtMs, | |
| }); | |
| return; | |
| } | |
| const deckPayload = createDeck(locale, selectedProduct, activePrompt, tone, channel); | |
| const markdown = deckToMarkdown(locale, deckPayload); | |
| const chunks = splitTextChunks(markdown); | |
| const textStep = steps[2]; | |
| currentStepRef.current = textStep.id; | |
| onStepStatus(textStep.id, 'in_progress', textStep.runningDetail); | |
| onTrace(makeTrace(textStep.kind, textStep.label, textStep.runningDetail, 'running')); | |
| for (let i = 0; i < chunks.length; i += 1) { | |
| if (!mountedRef.current || runTokenRef.current !== runToken) return; | |
| setStreamText((prev) => prev + chunks[i]); | |
| if (i % 5 === 0) { | |
| onTrace( | |
| makeTrace('tool', locale === 'zh-TW' ? '文案片段' : 'Copy chunk', locale === 'zh-TW' ? `已輸出第 ${i + 1} 段` : `Chunk ${i + 1} streamed`, 'ok') | |
| ); | |
| } | |
| await sleep(90); | |
| } | |
| if (!mountedRef.current || runTokenRef.current !== runToken) return; | |
| setDeck(deckPayload); | |
| onStepStatus(textStep.id, 'completed', textStep.doneDetail); | |
| onTrace(makeTrace(textStep.kind, textStep.label, textStep.doneDetail, 'ok', { chars: markdown.length })); | |
| const imageStep = steps[3]; | |
| currentStepRef.current = imageStep.id; | |
| onStepStatus(imageStep.id, 'in_progress', imageStep.runningDetail); | |
| onTrace(makeTrace(imageStep.kind, imageStep.label, imageStep.runningDetail, 'running')); | |
| await sleep(480); | |
| if (!mountedRef.current || runTokenRef.current !== runToken) return; | |
| const imageOne = generateMockImageDataUrl(selectedProduct, deckPayload, 0, locale); | |
| const imageTwo = generateMockImageDataUrl(selectedProduct, deckPayload, 1, locale); | |
| const generated: GeneratedImage[] = [imageOne, imageTwo].map((dataUrl, index) => ({ | |
| id: makeId('img'), | |
| dataUrl, | |
| mimeType: 'image/png', | |
| base64: dataUrl.split(',')[1] || '', | |
| label: locale === 'zh-TW' ? (index === 0 ? '主視覺' : '社群版型') : index === 0 ? 'Hero visual' : 'Social variation', | |
| })); | |
| setImages(generated); | |
| onTrace( | |
| makeTrace('tool', locale === 'zh-TW' ? '圖片事件' : 'Image event', locale === 'zh-TW' ? '已串流 2 張圖片事件。' : '2 image events streamed.', 'ok', { | |
| count: 2, | |
| mimeType: 'image/png', | |
| }) | |
| ); | |
| await sleep(300); | |
| if (!mountedRef.current || runTokenRef.current !== runToken) return; | |
| onStepStatus(imageStep.id, 'completed', imageStep.doneDetail); | |
| onTrace(makeTrace(imageStep.kind, imageStep.label, imageStep.doneDetail, 'ok')); | |
| const packaged = await runStep(runToken, steps[4], { | |
| output: { | |
| text: true, | |
| images: generated.length, | |
| }, | |
| }); | |
| if (!packaged) return; | |
| setMeta({ | |
| model: 'gemini-2.5-flash-image (simulated)', | |
| startedAt: new Date(startedAtMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), | |
| durationMs: Date.now() - startedAtMs, | |
| }); | |
| } catch (runtimeError) { | |
| const message = | |
| runtimeError instanceof Error | |
| ? runtimeError.name === 'AbortError' | |
| ? copy.stoppedByUser | |
| : runtimeError.message | |
| : copy.streamError; | |
| setError(message); | |
| if (currentStepRef.current) { | |
| onStepStatus(currentStepRef.current, 'error', message); | |
| } | |
| onTrace(makeTrace('tool', locale === 'zh-TW' ? '生成錯誤' : 'Generation error', message, 'error')); | |
| } finally { | |
| if (runTokenRef.current === runToken) { | |
| abortRef.current = null; | |
| setIsGenerating(false); | |
| onWorkingChange(false); | |
| } | |
| } | |
| }; | |
| const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => { | |
| const file = event.target.files?.[0]; | |
| if (!file) return; | |
| const readErrorMessage = locale === 'zh-TW' ? '無法讀取檔案' : 'Unable to read file'; | |
| const dataUrl = await new Promise<string>((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| if (typeof reader.result !== 'string') { | |
| reject(new Error(readErrorMessage)); | |
| return; | |
| } | |
| resolve(reader.result); | |
| }; | |
| reader.onerror = () => reject(new Error(readErrorMessage)); | |
| reader.readAsDataURL(file); | |
| }); | |
| setUploadedImage({ name: file.name, previewUrl: dataUrl }); | |
| }; | |
| const handleDownloadImage = (image: GeneratedImage, index: number) => { | |
| const link = document.createElement('a'); | |
| link.href = image.dataUrl; | |
| link.download = `marketing-${index + 1}.png`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }; | |
| const handleCopyDeck = async () => { | |
| if (!deck) return; | |
| const text = deckToMarkdown(locale, deck); | |
| await navigator.clipboard.writeText(text); | |
| setNote(copy.copied); | |
| }; | |
| const handleMockApply = () => { | |
| setNote(copy.applyMock); | |
| }; | |
| useEffect(() => { | |
| if (!queuedPrompt) return; | |
| const consume = async () => { | |
| setPrompt(queuedPrompt); | |
| onConsumeQueuedPrompt(); | |
| await runGeneration(queuedPrompt); | |
| }; | |
| void consume(); | |
| }, [queuedPrompt]); | |
| useEffect(() => { | |
| mountedRef.current = true; | |
| return () => { | |
| mountedRef.current = false; | |
| runTokenRef.current += 1; | |
| abortRef.current?.abort(); | |
| abortRef.current = null; | |
| onWorkingChange(false); | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| setSelectedProductId((prev) => { | |
| if (products.some((product) => product.id === prev)) return prev; | |
| return products[0].id; | |
| }); | |
| setTone(TONE_OPTIONS[locale][0]); | |
| setChannel(CHANNEL_OPTIONS[locale][0]); | |
| }, [locale]); | |
| return ( | |
| <div className="marketing-studio-root"> | |
| <MarketingFeatureDemo locale={locale} isBusy={isGenerating} /> | |
| <header className="marketing-studio-header"> | |
| <div> | |
| <h3>{copy.title}</h3> | |
| <p>{copy.subtitle}</p> | |
| </div> | |
| <div className={`status-pill ${isGenerating ? 'busy' : 'idle'}`}>{isGenerating ? copy.generating : copy.ready}</div> | |
| </header> | |
| <div className="marketing-studio-layout"> | |
| <section className="marketing-controls"> | |
| <div className="marketing-field-block"> | |
| <label>{copy.productLabel}</label> | |
| <div className="marketing-product-grid"> | |
| {products.map((product) => { | |
| const selected = product.id === selectedProductId; | |
| return ( | |
| <button | |
| key={product.id} | |
| type="button" | |
| className={`marketing-product-card ${selected ? 'selected' : ''}`} | |
| onClick={() => setSelectedProductId(product.id)} | |
| > | |
| <strong>{product.name}</strong> | |
| <small>{product.category}</small> | |
| <span> | |
| NT$ {product.price}/{product.unit} | |
| </span> | |
| <em>{locale === 'zh-TW' ? `庫存 ${product.stock}` : `Stock ${product.stock}`}</em> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| <div className="marketing-config-row"> | |
| <label> | |
| <span>{copy.toneLabel}</span> | |
| <select value={tone} onChange={(event) => setTone(event.target.value)} disabled={isGenerating}> | |
| {tones.map((option) => ( | |
| <option key={option} value={option}> | |
| {option} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| <label> | |
| <span>{copy.channelLabel}</span> | |
| <select value={channel} onChange={(event) => setChannel(event.target.value)} disabled={isGenerating}> | |
| {channels.map((option) => ( | |
| <option key={option} value={option}> | |
| {option} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| </div> | |
| <div className="marketing-field-block"> | |
| <label>{copy.briefLabel}</label> | |
| <textarea | |
| rows={6} | |
| value={prompt} | |
| onChange={(event) => setPrompt(event.target.value)} | |
| placeholder={copy.promptPlaceholder} | |
| disabled={isGenerating} | |
| /> | |
| </div> | |
| <div className="marketing-field-block"> | |
| <label>{copy.imageLabel}</label> | |
| <input ref={fileInputRef} type="file" accept="image/*" className="hidden-input" onChange={handleUpload} /> | |
| <div className="marketing-upload-row"> | |
| <button type="button" className="ghost-btn" onClick={() => fileInputRef.current?.click()} disabled={isGenerating}> | |
| {copy.uploadButton} | |
| </button> | |
| {uploadedImage ? ( | |
| <button | |
| type="button" | |
| className="ghost-btn" | |
| onClick={() => { | |
| setUploadedImage(null); | |
| if (fileInputRef.current) fileInputRef.current.value = ''; | |
| }} | |
| disabled={isGenerating} | |
| > | |
| {copy.removeImage} | |
| </button> | |
| ) : null} | |
| </div> | |
| {uploadedImage ? ( | |
| <div className="marketing-upload-preview"> | |
| <img src={uploadedImage.previewUrl} alt={uploadedImage.name} /> | |
| <small>{uploadedImage.name}</small> | |
| </div> | |
| ) : ( | |
| <p className="marketing-help-text">{copy.noImage}</p> | |
| )} | |
| </div> | |
| <div className="marketing-action-row"> | |
| <button type="button" className="primary-btn" disabled={!canGenerate} onClick={() => void runGeneration()}> | |
| {copy.generate} | |
| </button> | |
| {isGenerating ? ( | |
| <button type="button" className="ghost-btn" onClick={stopGeneration}> | |
| {copy.stop} | |
| </button> | |
| ) : null} | |
| <button type="button" className="ghost-btn" onClick={resetOutput} disabled={isGenerating}> | |
| {copy.reset} | |
| </button> | |
| </div> | |
| </section> | |
| <section className="marketing-output"> | |
| <header> | |
| <h4>{copy.outputTitle}</h4> | |
| <p>{copy.outputSubtitle}</p> | |
| </header> | |
| {note ? <div className="marketing-note">{note}</div> : null} | |
| {error ? <div className="marketing-error">{error}</div> : null} | |
| {!streamText && images.length === 0 && !isGenerating ? ( | |
| <div className="marketing-empty">{copy.noOutput}</div> | |
| ) : null} | |
| {streamText ? ( | |
| <article className="marketing-stream-card"> | |
| <div className="marketing-stream-actions"> | |
| <strong>{locale === 'zh-TW' ? '串流文案' : 'Streaming Copy'}</strong> | |
| <button type="button" className="ghost-btn" onClick={() => void handleCopyDeck()} disabled={!deck}> | |
| {copy.copyDeck} | |
| </button> | |
| </div> | |
| <pre>{streamText}</pre> | |
| </article> | |
| ) : null} | |
| {deck ? ( | |
| <article className="marketing-deck-grid"> | |
| <section> | |
| <h5>{locale === 'zh-TW' ? '標題' : 'Headline'}</h5> | |
| <p>{deck.headline}</p> | |
| </section> | |
| <section> | |
| <h5>{locale === 'zh-TW' ? 'CTA' : 'CTA'}</h5> | |
| <p>{deck.cta}</p> | |
| </section> | |
| <section> | |
| <h5>{locale === 'zh-TW' ? 'Hashtag' : 'Hashtags'}</h5> | |
| <p>{deck.hashtags.join(' ')}</p> | |
| </section> | |
| <section> | |
| <h5>{locale === 'zh-TW' ? '設計重點' : 'Design Notes'}</h5> | |
| <ul> | |
| {deck.designNotes.map((noteItem) => ( | |
| <li key={noteItem}>{noteItem}</li> | |
| ))} | |
| </ul> | |
| </section> | |
| </article> | |
| ) : null} | |
| {images.length > 0 ? ( | |
| <div className="marketing-image-grid"> | |
| {images.map((image, index) => ( | |
| <article key={image.id}> | |
| <img src={image.dataUrl} alt={image.label} /> | |
| <div className="marketing-image-meta"> | |
| <strong>{image.label}</strong> | |
| <div> | |
| <button type="button" className="ghost-btn" onClick={() => handleDownloadImage(image, index)}> | |
| {locale === 'zh-TW' ? '下載' : 'Download'} | |
| </button> | |
| <button type="button" className="ghost-btn" onClick={handleMockApply}> | |
| {locale === 'zh-TW' ? '套用' : 'Apply'} | |
| </button> | |
| </div> | |
| </div> | |
| </article> | |
| ))} | |
| </div> | |
| ) : null} | |
| {meta ? ( | |
| <article className="marketing-meta-card"> | |
| <h5>{copy.metadataTitle}</h5> | |
| <dl> | |
| <div> | |
| <dt>{copy.metadataModel}</dt> | |
| <dd>{meta.model}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.metadataStartedAt}</dt> | |
| <dd>{meta.startedAt}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.metadataDuration}</dt> | |
| <dd>{meta.durationMs} ms</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.metadataEvents}</dt> | |
| <dd> | |
| {images.length > 0 | |
| ? copy.metadataEventsWithImages.replace('{count}', String(images.length)) | |
| : copy.metadataEventsTextOnly} | |
| </dd> | |
| </div> | |
| </dl> | |
| </article> | |
| ) : null} | |
| </section> | |
| </div> | |
| </div> | |
| ); | |
| } | |