// assistant.jsx — global AI helper, first pass function AssistantFloatingChat({ go, currentTripId }) { const isTripDetail = !!currentTripId; const [open, setOpen] = React.useState(false); const [pos, setPos] = React.useState(() => { try { return JSON.parse(localStorage.getItem('tabi_assistant_pos') || 'null') || { right: 18, bottom: 112 }; } catch { return { right: 18, bottom: 112 }; } }); const [dragging, setDragging] = React.useState(false); const [context, setContext] = React.useState({ trips: [], default_trip_id: null }); const [tripId, setTripId] = React.useState(() => localStorage.getItem('tabi_assistant_trip_id') || ''); const [conversationId, setConversationId] = React.useState(() => localStorage.getItem('tabi_assistant_conversation_id') || ''); const [conversations, setConversations] = React.useState([]); const [showHistory, setShowHistory] = React.useState(false); const [messages, setMessages] = React.useState([]); const [input, setInput] = React.useState(''); const [busy, setBusy] = React.useState(false); const [toolCalls, setToolCalls] = React.useState([]); const toolCallsRef = React.useRef([]); const [streamingText, setStreamingText] = React.useState(''); const streamingTextRef = React.useRef(''); const [expandedToolCalls, setExpandedToolCalls] = React.useState(() => new Set()); const [draft, setDraft] = React.useState(null); const [draftPayload, setDraftPayload] = React.useState(null); const [draftExpanded, setDraftExpanded] = React.useState(false); const [savedExpenseId, setSavedExpenseId] = React.useState(null); const [listening, setListening] = React.useState(false); const boxRef = React.useRef(null); const draftRef = React.useRef(null); const dragRef = React.useRef(null); const posRef = React.useRef(pos); const fileRef = React.useRef(null); const recognitionRef = React.useRef(null); const loadContext = React.useCallback(() => { const query = currentTripId || tripId; fetch(`/api/assistant/context${query ? `?trip_id=${encodeURIComponent(query)}` : ''}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` }, }) .then(r => r.ok ? r.json() : null) .then(d => { if (!d) return; setContext(d); // tripId may be stale (e.g. trip was deleted) — fall back if it's no longer valid const knownIds = new Set((d.trips || []).map(t => t.id)); const currentTrip = tripId && knownIds.has(tripId) ? tripId : ''; const nextTrip = currentTrip || currentTripId || d.default_trip_id || ''; if (nextTrip !== tripId) { setTripId(nextTrip); if (nextTrip) localStorage.setItem('tabi_assistant_trip_id', nextTrip); else localStorage.removeItem('tabi_assistant_trip_id'); } }) .catch(() => {}); }, [currentTripId, tripId]); const normalizeMessage = React.useCallback((m) => { const meta = m.metadata || m.meta || {}; return { id: m.id, role: m.role, text: m.text || '', metadata: meta, action: m.action || meta.action, draft: m.draft || meta.draft, stats: m.stats || meta.stats, invite: m.invite || meta.invite, artifact: m.artifact || meta.artifact, result: m.result || meta.result, imageUrl: m.imageUrl, created_at: m.created_at, }; }, []); const loadConversation = React.useCallback((id) => { if (!id) return Promise.resolve(null); return fetch(`/api/assistant/conversations/${id}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` }, }) .then(r => r.ok ? r.json() : null) .then(data => { if (!data) return null; setConversationId(data.id); localStorage.setItem('tabi_assistant_conversation_id', data.id); setMessages((data.messages || []).map(normalizeMessage)); if (data.default_trip_id) { setTripId(data.default_trip_id); localStorage.setItem('tabi_assistant_trip_id', data.default_trip_id); } setDraft(null); setDraftPayload(null); setDraftExpanded(false); setSavedExpenseId(null); return data; }); }, [normalizeMessage]); const createConversation = React.useCallback((defaultTripId = tripId || currentTripId || null) => { return fetch('/api/assistant/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify({ default_trip_id: defaultTripId || null }), }) .then(r => r.ok ? r.json() : Promise.reject(new Error('建立對話失敗'))) .then(data => { setConversations(prev => [data, ...prev.filter(c => c.id !== data.id)]); setShowHistory(false); return loadConversation(data.id).then(() => data.id); }); }, [currentTripId, loadConversation, tripId]); const deleteConversation = React.useCallback((id) => { return fetch(`/api/assistant/conversations/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${window.tabiToken()}` }, }) .then(r => r.ok ? r.json() : Promise.reject(new Error('刪除失敗'))) .then(async () => { const remaining = conversations.filter(c => c.id !== id); setConversations(remaining); if (id === conversationId) { if (remaining.length > 0) { await loadConversation(remaining[0].id); } else { await createConversation(tripId || currentTripId || null); } } }); }, [conversations, conversationId, loadConversation, createConversation, tripId, currentTripId]); const loadConversations = React.useCallback(() => { return fetch('/api/assistant/conversations', { headers: { Authorization: `Bearer ${window.tabiToken()}` }, }) .then(r => r.ok ? r.json() : { conversations: [] }) .then(async data => { const list = data.conversations || []; setConversations(list); const stored = localStorage.getItem('tabi_assistant_conversation_id') || conversationId; const selected = list.find(c => c.id === stored) || list[0]; if (selected) { await loadConversation(selected.id); return selected.id; } return createConversation(currentTripId || tripId || null); }) .catch(() => null); }, [conversationId, createConversation, currentTripId, loadConversation, tripId]); React.useEffect(() => { if (!open) return; loadContext(); loadConversations(); }, [open]); React.useEffect(() => { posRef.current = pos; }, [pos]); React.useEffect(() => { if (!boxRef.current) return; requestAnimationFrame(() => { if (draftRef.current) { draftRef.current.scrollIntoView({ block: 'start' }); } else if (boxRef.current) { boxRef.current.scrollTop = boxRef.current.scrollHeight; } }); }, [messages.length, !!draft, open]); const clampAssistantPos = (next) => { const buttonSize = 72; const minBottom = isTripDetail ? 172 : 112; const maxRight = Math.max(10, window.innerWidth - buttonSize - 10); const maxBottom = Math.max(minBottom, window.innerHeight - buttonSize - 10); const clamped = { right: Math.max(10, Math.min(maxRight, next.right)), bottom: Math.max(minBottom, Math.min(maxBottom, next.bottom)), }; const nearExpenseFab = isTripDetail && clamped.right < 96 && clamped.bottom < 172; return nearExpenseFab ? { ...clamped, bottom: 172 } : clamped; }; React.useEffect(() => { const sync = () => { const next = clampAssistantPos(posRef.current); posRef.current = next; setPos(next); localStorage.setItem('tabi_assistant_pos', JSON.stringify(next)); }; sync(); window.addEventListener('resize', sync); return () => window.removeEventListener('resize', sync); }, [isTripDetail]); const onPointerDown = (e) => { if (open) return; e.preventDefault(); setDragging(true); dragRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, startRight: posRef.current.right, startBottom: posRef.current.bottom, moved: false, }; e.currentTarget.setPointerCapture?.(e.pointerId); }; const onPointerMove = (e) => { if (!dragging || !dragRef.current || dragRef.current.pointerId !== e.pointerId) return; e.preventDefault(); const dx = e.clientX - dragRef.current.startX; const dy = e.clientY - dragRef.current.startY; if (Math.hypot(dx, dy) > 7) dragRef.current.moved = true; const next = clampAssistantPos({ right: dragRef.current.startRight - dx, bottom: dragRef.current.startBottom - dy, }); posRef.current = next; setPos(next); }; const finishDrag = (e) => { if (!dragRef.current || (e?.pointerId != null && dragRef.current.pointerId !== e.pointerId)) return; const moved = dragRef.current?.moved; const finalPos = posRef.current; setDragging(false); if (e?.currentTarget?.releasePointerCapture && e.pointerId != null) { try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {} } localStorage.setItem('tabi_assistant_pos', JSON.stringify(finalPos)); if (!moved) setOpen(true); dragRef.current = null; }; const onPointerUp = (e) => finishDrag(e); const onPointerCancel = (e) => { if (!dragRef.current || dragRef.current.pointerId !== e.pointerId) return; setDragging(false); dragRef.current = null; }; function selectedTrip() { return context.trips.find(t => t.id === tripId) || context.trips.find(t => t.id === context.default_trip_id) || context.trips[0] || null; } const quickActions = [ { icon: '探', color: TABI_COLORS.red, label: '查附近', text: '用我目前位置查附近推薦餐廳' }, { icon: '計', color: TABI_COLORS.blue, label: '今日支出', text: '幫我看這趟旅程今日支出統計' }, { icon: '購', color: TABI_COLORS.green, label: '購物清單', text: '幫我看購物清單' }, { icon: '備', color: TABI_COLORS.ink2, label: '行前準備', text: '幫我看行前準備清單' }, ]; const assistantExamples = [ '今天晚餐 1200 日圓,個人支出', '新增眼藥水到購物清單', '把護照影本勾完成', ]; const greetingWord = (() => { const h = new Date().getHours(); if (h < 5) return '夜深了'; if (h < 11) return '早安'; if (h < 14) return '午安'; if (h < 18) return '下午好'; return '晚安'; })(); function useQuickAction(text) { if (busy) return; sendMessage(text); } function runResultRowAction(action) { if (!action || !go) return; if (action.type === 'shopping_detail' && action.id) { setOpen(false); go('shopping_detail', action.id); } if (action.type === 'shopping_trip' && action.id) { setOpen(false); go('shopping_trip', action.id); } if (action.type === 'shopping' || action.type === 'shopping_list') { setOpen(false); go('shopping'); } if (action.type === 'checklist') { setOpen(false); go('checklist'); } } function normalizeAssistantText(text) { return String(text || '') .replace(/共通支出|公共支出|共同之出|共用支出/g, '共同支出') .replace(/個人之出|個人之處|私人支出/g, '個人支出') .replace(/ㄧ蘭|衣蘭|一欄/g, '一蘭') .replace(/日園|日元/g, '日圓') .replace(/台弊|台幤/g, '台幣') .replace(/均份/g, '均分') .replace(/自定金額|自訂金鵝/g, '自訂金額'); } function shouldUseLocation(text) { // Only request the user's GPS for "near me" style queries. Generic // category words (餐廳/景點/咖啡...) alone must NOT trigger this — // a query like "大阪有什麼餐廳" names its own location and should go // through web search, not Google Maps "nearby" using the user's GPS. return /附近|周邊|周遭|身邊|這邊|這裡/.test(String(text || '')); } function requestCurrentLocation() { if (!navigator.geolocation) return Promise.resolve({ location: null, reason: 'unsupported' }); return new Promise(resolve => { navigator.geolocation.getCurrentPosition( pos => resolve({ location: { lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy, }, reason: null, }), err => resolve({ location: null, reason: err?.code === 1 ? 'denied' : err?.code === 3 ? 'timeout' : 'unavailable' }), { enableHighAccuracy: false, timeout: 6500, maximumAge: 10 * 60 * 1000 }, ); }); } function locationHelpMessage(reason) { if (reason === 'unsupported') return '這個瀏覽器不支援定位。你可以改說「熱海車站附近晚餐」或「大阪難波附近餐廳」。'; if (reason === 'denied') return '我沒有取得定位權限,所以不能用 Google Maps 查附近。請到瀏覽器網站設定把「位置」改成允許,重新整理後再試。也可以直接輸入地點,例如「熱海車站附近晚餐」。'; if (reason === 'timeout') return '定位等太久沒有回來。請確認手機定位已開啟,或改輸入地點,例如「熱海車站附近晚餐」。'; return '我沒有取得目前位置,所以不能查真正的附近。請允許定位,或改輸入明確地點。'; } function pushToolCallStart(payload) { setToolCalls(prev => { const next = [ ...prev, { id: `${payload.tool || 'tool'}-${Date.now()}-${prev.length}`, tool: payload.tool, label: payload.tool_label || payload.tool || '工具', status: 'running', input: payload.input || null, output: null, }, ]; toolCallsRef.current = next; return next; }); } function applyToolCallDone(payload) { setToolCalls(prev => { const idx = [...prev].reverse().findIndex(t => t.tool === payload.tool && t.status === 'running'); if (idx === -1) return prev; const realIdx = prev.length - 1 - idx; const next = [...prev]; next[realIdx] = { ...next[realIdx], status: 'done', output: payload.output || null }; toolCallsRef.current = next; return next; }); } function appendStreamDelta(delta) { if (!delta) return; streamingTextRef.current += delta; setStreamingText(streamingTextRef.current); } function applyAssistantResponse(data) { if (data.conversation) { setConversationId(data.conversation.id); localStorage.setItem('tabi_assistant_conversation_id', data.conversation.id); setConversations(prev => [data.conversation, ...prev.filter(c => c.id !== data.conversation.id)]); } const finishedToolCalls = toolCallsRef.current; toolCallsRef.current = []; setToolCalls([]); streamingTextRef.current = ''; setStreamingText(''); const assistantMsgBase = data.assistant_message ? normalizeMessage(data.assistant_message) : null; const assistantMsg = assistantMsgBase && finishedToolCalls.length > 0 ? { ...assistantMsgBase, toolCalls: finishedToolCalls } : assistantMsgBase; const withToolCalls = (msg) => finishedToolCalls.length > 0 ? { ...msg, toolCalls: finishedToolCalls } : msg; if (data.action === 'expense_draft' && data.draft) { setDraft(data.draft); setDraftPayload(data.draft.payload || null); if (data.draft.trip?.id) { setTripId(data.draft.trip.id); localStorage.setItem('tabi_assistant_trip_id', data.draft.trip.id); } setSavedExpenseId(null); setDraftExpanded(true); setMessages(prev => [...prev, assistantMsg || withToolCalls({ role: 'assistant', text: `我整理成一筆支出草稿:${data.draft.summary}`, draft: data.draft })]); } else if (data.action === 'trip_stats') { setMessages(prev => [...prev, assistantMsg || withToolCalls({ role: 'assistant', text: data.message || '已整理旅程統計。', stats: data.stats })]); } else if (data.action === 'trip_invite') { setMessages(prev => [...prev, assistantMsg || withToolCalls({ role: 'assistant', text: data.message || '已準備邀請連結。', invite: data.invite })]); } else if (data.action === 'artifact') { setMessages(prev => [...prev, assistantMsg || withToolCalls({ role: 'assistant', text: data.message || '已產生報告。', artifact: data.artifact })]); } else if (data.result) { setMessages(prev => [...prev, assistantMsg || withToolCalls({ role: 'assistant', text: data.message || '已完成。', result: data.result })]); } else { setMessages(prev => [...prev, assistantMsg || withToolCalls({ role: 'assistant', text: data.message || '我收到你的需求了。' })]); } } async function readAssistantStream(response) { if (!response.body || !response.headers.get('content-type')?.includes('text/event-stream')) return null; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let finalData = null; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const chunks = buffer.split('\n\n'); buffer = chunks.pop() || ''; for (const chunk of chunks) { const lines = chunk.split('\n'); const event = (lines.find(line => line.startsWith('event:')) || 'event: message').replace(/^event:\s*/, '').trim(); const dataLine = lines.find(line => line.startsWith('data:')); const payload = dataLine ? JSON.parse(dataLine.replace(/^data:\s*/, '')) : {}; if (event === 'status') { if (payload.stage === 'delta') { appendStreamDelta(payload.delta || ''); } else { if (payload.stage === 'tool') pushToolCallStart(payload); if (payload.stage === 'tool_done') applyToolCallDone(payload); } } if (event === 'final') finalData = payload; if (event === 'error') throw new Error(payload.detail || 'AI 助理暫時無法處理'); } } return finalData; } function inlineAssistantText(text, sources = []) { const parts = []; const pattern = /(\*\*[^*]+\*\*|\[[1-9][0-9]*\])/g; let last = 0; String(text || '').replace(pattern, (match, _token, offset) => { if (offset > last) parts.push(String(text).slice(last, offset)); if (match.startsWith('**')) { parts.push({match.slice(2, -2)}); } else { const number = parseInt(match.slice(1, -1), 10); const source = sources[number - 1]; if (source?.url) { parts.push( {number} ); } else { parts.push(match); } } last = offset + match.length; return match; }); if (last < String(text || '').length) parts.push(String(text || '').slice(last)); return parts; } function renderAssistantMarkdown(text, sources = []) { const normalized = String(text || '').replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); if (!normalized) return null; const blocks = normalized.split(/\n\s*\n/); return (
{blocks.map((block, blockIdx) => { const lines = block.split('\n').map(line => line.trim()).filter(Boolean); if (!lines.length) return null; const heading = lines.length === 1 ? lines[0].match(/^#{1,4}\s*(.+)$/) : null; if (heading) { return (
{inlineAssistantText(heading[1], sources)}
); } const listLines = lines.filter(line => /^[-*]\s+/.test(line) || /^[0-9]+[.)]\s+/.test(line)); if (listLines.length === lines.length) { return (
{lines.map((line, idx) => (
{/^[0-9]+/.test(line) ? line.match(/^[0-9]+/)?.[0] : '•'} {inlineAssistantText(line.replace(/^[-*]\s+/, '').replace(/^[0-9]+[.)]\s+/, ''), sources)}
))}
); } return (
{lines.map((line, idx) => ( {idx > 0 &&
} {inlineAssistantText(line.replace(/^#{1,4}\s*/, ''), sources)}
))}
); })}
); } function renderAssistantFlexCards(cards = []) { if (!Array.isArray(cards) || cards.length === 0) return null; return (
{cards.slice(0, 6).map((card, idx) => { const action = (card.actions || []).find(a => a.type === 'link' && a.url); return (
{card.image_url ? ( { e.currentTarget.style.display = 'none'; }} /> ) : (
SOURCE
{card.source_number || idx + 1}
)}
{(card.badges || []).slice(0, 2).map((badge, badgeIdx) => ( {badge.label} ))} {card.source_label || card.subtitle}
{card.title || '搜尋結果'}
{card.body || card.subtitle || ''}
{action && ( {action.label || '開啟'} )}
); })}
); } function toggleToolCall(id) { setExpandedToolCalls(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function renderToolCallCard(call) { const expanded = expandedToolCalls.has(call.id); const isDone = call.status === 'done'; const hasBody = (call.input && Object.keys(call.input).length > 0) || call.output; return (
{expanded && (
{call.input && Object.keys(call.input).length > 0 && (
輸入
{JSON.stringify(call.input, null, 2)}
)} {call.output && (
結果
{call.output.message && (
{call.output.message}
)} {(call.output.sources || []).map((s, idx) => (
{s.url ? ( {idx + 1}. {s.title || s.url} ) : (
{idx + 1}. {s.title}
)} {s.content && (
{s.content}
)}
))} {(call.output.rows || []).map((row, idx) => (
{row.label} {row.value}
))}
)}
)}
); } function applyDraftInstruction(text) { if (!draftPayload) return false; const patch = {}; let touched = false; const value = normalizeAssistantText(text); if (/共同|大家|分帳/.test(value)) { patch.visibility = 'shared'; patch.split_method = /自訂/.test(value) ? 'custom' : (draftPayload.split_method === 'custom' ? 'custom' : 'equal'); patch.is_proxy = false; patch.proxy_for = null; touched = true; } if (/個人|自己的|私人/.test(value)) { patch.visibility = 'personal'; patch.split_method = 'solo'; patch.splits = null; touched = true; } if (/均分/.test(value)) { patch.visibility = 'shared'; patch.split_method = 'equal'; patch.splits = null; touched = true; } if (/自訂/.test(value)) { patch.visibility = 'shared'; patch.split_method = 'custom'; patch.splits = members.reduce((acc, m) => { acc[m.id] = (draftPayload.splits || {})[m.id] || 0; return acc; }, {}); touched = true; } const amountMatch = value.match(/(?:金額|改|換|變成|是)?\s*([0-9][0-9,]*)\s*(日圓|円|yen|jpy|台幣|twd|ntd)?/i); if (amountMatch) { patch.amount = parseInt(amountMatch[1].replace(/,/g, ''), 10) || draftPayload.amount; const word = (amountMatch[2] || '').toLowerCase(); if (['台幣', 'twd', 'ntd'].includes(word)) patch.currency = 'TWD'; if (['日圓', '円', 'yen', 'jpy'].includes(word)) patch.currency = 'JPY'; touched = true; } for (const c of ['餐飲', '交通', '購物', '住宿', '門票', '其他']) { if (value.includes(c)) { patch.category = c; touched = true; break; } } const titleMatch = value.match(/(?:名稱|品項|標題)(?:改成|改|是|:|:)\s*([^,,。]+)/); if (titleMatch) { patch.title = titleMatch[1].trim().slice(0, 40); touched = true; } if (!touched) return false; setDraftPayload(prev => prev ? { ...prev, ...patch } : prev); setDraftExpanded(false); setMessages(prev => [...prev, { role: 'assistant', text: '我已更新草稿,先幫你收成小卡片。' }]); return true; } async function sendMessage(overrideText) { const text = normalizeAssistantText((overrideText ?? input).trim()); if (!text || busy) return; setInput(''); setMessages(prev => [...prev, { role: 'user', text }]); if (draftPayload && applyDraftInstruction(text)) return; setDraft(null); setDraftPayload(null); setDraftExpanded(false); setSavedExpenseId(null); setToolCalls([]); toolCallsRef.current = []; streamingTextRef.current = ''; setStreamingText(''); setExpandedToolCalls(new Set()); setBusy(true); try { const cid = conversationId || await createConversation(tripId || currentTripId || null); let locationPayload = null; if (shouldUseLocation(text)) { const locationResult = await requestCurrentLocation(); locationPayload = locationResult.location; if (!locationPayload) { setMessages(prev => [...prev, { role: 'assistant', text: locationHelpMessage(locationResult.reason), result: { kind: 'location_required', title: '需要開啟定位', rows: [ { label: '目前狀態', value: locationResult.reason === 'denied' ? '定位被封鎖' : '未取得定位' }, { label: '替代方式', value: '輸入明確地點搜尋' }, ], }, }]); return; } } const requestPayload = { text, trip_id: tripId || null, location: locationPayload }; const streamResponse = await fetch(`/api/assistant/conversations/${cid}/messages/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() }, body: JSON.stringify(requestPayload), }); let data = await readAssistantStream(streamResponse); if (!streamResponse.ok && !data) { const errorData = await streamResponse.json().catch(() => ({})); throw new Error(errorData.detail || 'AI 助理暫時無法處理'); } if (!data) { const r = await fetch(`/api/assistant/conversations/${cid}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() }, body: JSON.stringify(requestPayload), }); data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.detail || 'AI 助理暫時無法處理'); } applyAssistantResponse(data); } catch (e) { const erroredToolCalls = toolCallsRef.current.map(c => c.status === 'running' ? { ...c, status: 'error' } : c); toolCallsRef.current = []; setToolCalls([]); streamingTextRef.current = ''; setStreamingText(''); setMessages(prev => [...prev, { role: 'assistant', text: e.message || '解析失敗,請換個說法。', ...(erroredToolCalls.length > 0 ? { toolCalls: erroredToolCalls } : {}), }]); } finally { setBusy(false); } } async function confirmDraft() { if (!draft?.trip?.id || !draftPayload || busy) return; setBusy(true); try { const url = savedExpenseId ? `/api/trips/${draft.trip.id}/expenses/${savedExpenseId}` : `/api/trips/${draft.trip.id}/expenses`; const r = await fetch(url, { method: savedExpenseId ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify(draftPayload), }); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.detail || (savedExpenseId ? '更新失敗' : '新增失敗')); const nextId = data.id || savedExpenseId; setBusy(false); setSavedExpenseId(nextId); setMessages(prev => [...prev, { role: 'assistant', text: savedExpenseId ? '已更新這筆支出。' : `已新增到「${draft.trip.name}」。` }]); setDraftExpanded(false); if (go) go('trips'); } catch (e) { setMessages(prev => [...prev, { role: 'assistant', text: e.message || '新增失敗,請再確認一次。' }]); } finally { setBusy(false); } } const trip = selectedTrip(); const members = draft?.trip?.members || trip?.members || []; const splitTotal = draftPayload?.split_method === 'custom' ? Object.values(draftPayload.splits || {}).reduce((sum, v) => sum + (parseInt(v, 10) || 0), 0) : 0; const warningParts = [ ...(draftPayload && Number(draftPayload.amount || 0) <= 0 ? ['請填入有效金額。'] : []), ...(draftPayload?.visibility === 'shared' && draftPayload?.split_method === 'custom' && splitTotal !== Number(draftPayload.amount || 0) ? [`自訂分帳加總 ${splitTotal.toLocaleString()},需等於總額 ${Number(draftPayload.amount || 0).toLocaleString()}。`] : []), ]; const warning = Array.from(new Set(warningParts)).join(' '); const fieldStyle = { width: '100%', minHeight: 39, borderRadius: 12, border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg, padding: '0 10px', color: TABI_COLORS.ink, fontSize: 16, fontWeight: 700, boxSizing: 'border-box', }; const labelStyle = { display: 'block', fontSize: 10.5, fontWeight: 900, color: TABI_COLORS.ink3, marginBottom: 5 }; const patchPayload = (patch) => setDraftPayload(prev => prev ? { ...prev, ...patch } : prev); const setVisibility = (visibility) => { setDraftPayload(prev => { if (!prev) return prev; return { ...prev, visibility, split_method: visibility === 'personal' ? 'solo' : (prev.split_method === 'custom' ? 'custom' : 'equal'), splits: visibility === 'personal' ? null : prev.splits, is_proxy: visibility === 'personal' ? prev.is_proxy : false, proxy_for: visibility === 'personal' ? prev.proxy_for : null, }; }); }; const setSplitMethod = (split_method) => { setDraftPayload(prev => { if (!prev) return prev; const next = { ...prev, split_method }; if (split_method === 'custom') { const current = prev.splits || {}; next.splits = members.reduce((acc, m) => { acc[m.id] = current[m.id] || 0; return acc; }, {}); } else { next.splits = null; } return next; }); }; const setSplitAmount = (memberId, value) => { const amount = Math.max(0, parseInt(String(value || '').replace(/,/g, ''), 10) || 0); setDraftPayload(prev => prev ? { ...prev, splits: { ...(prev.splits || {}), [memberId]: amount } } : prev); }; const startVoiceInput = () => { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { setMessages(prev => [...prev, { role: 'assistant', text: '這個瀏覽器目前不支援語音輸入。' }]); return; } if (listening && recognitionRef.current) { recognitionRef.current.stop(); return; } const rec = new SpeechRecognition(); recognitionRef.current = rec; rec.lang = 'zh-TW'; rec.interimResults = false; rec.continuous = false; rec.onstart = () => setListening(true); rec.onend = () => setListening(false); rec.onerror = () => setListening(false); rec.onresult = (event) => { const transcript = Array.from(event.results || []).map(r => r[0]?.transcript || '').join('').trim(); if (transcript) setInput(prev => `${prev}${prev ? ' ' : ''}${transcript}`); }; rec.start(); }; const handleImagePick = async (file) => { if (!file) return; const imageUrl = URL.createObjectURL(file); setMessages(prev => [ ...prev, { role: 'user', text: `上傳圖片:${file.name}`, imageUrl }, ]); setBusy(true); try { const cid = conversationId || await createConversation(tripId || currentTripId || null); const form = new FormData(); form.append('image', file); form.append('text', input.trim() || '分析這張圖片,如果是商品請整理資訊'); if (tripId) form.append('trip_id', tripId); if (cid) form.append('conversation_id', cid); const r = await fetch('/api/assistant/image', { method: 'POST', headers: { Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() }, body: form, }); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.detail || '圖片分析失敗'); setMessages(prev => [...prev, { role: 'assistant', text: data.message || '我分析了這張圖片。', result: data.result }]); } catch (e) { setMessages(prev => [...prev, { role: 'assistant', text: e.message || '圖片分析失敗。' }]); } finally { setBusy(false); } }; const moneyText = (amount, currency) => { if (currency === 'JPY') return `¥${Number(amount || 0).toLocaleString()}`; if (currency === 'TWD') return `NT$${Number(amount || 0).toLocaleString()}`; return `${currency || ''} ${Number(amount || 0).toLocaleString()}`.trim(); }; const copyAssistantText = async (text) => { await navigator.clipboard?.writeText(text || ''); }; const shareAssistantInvite = (invite) => { if (!invite) return; if (navigator.share) { navigator.share({ title: `加入 ${invite.trip_name} 的旅行記帳`, text: invite.share_text, url: invite.url, }); } else { copyAssistantText(invite.share_text || invite.url); } }; const openArtifact = (artifact) => { if (!artifact?.url) return; window.open(artifact.url, '_blank', 'noopener,noreferrer'); }; const moneyListText = (rows, fallbackAmount, fallbackCurrency) => { if (Array.isArray(rows) && rows.length) { return rows.map(row => moneyText(row.amount, row.currency)).join(' / '); } return moneyText(fallbackAmount, fallbackCurrency); }; const dateValue = draftPayload?.expense_date ? String(draftPayload.expense_date).slice(0, 10) : ''; return ( <> {!open && ( )} {open && ReactDOM.createPortal(
旅程助理
自然語言操作你的旅行工具
{trip?.is_active ? '進行中' : '可切換'}
{!draftExpanded && messages.length === 0 && !draft && ( <>
{trip?.name ? `${greetingWord}!「${trip.name}」想先做點什麼?` : `${greetingWord}!想先處理哪趟旅程?`}
我可以用自然語言幫你操作旅程:查地點、整理購物清單、更新行前準備、產生支出統計,也可以把你輸入的支出整理成可確認的草稿。
{assistantExamples.map(example => ( ))}
{quickActions.map(a => ( ))}
)} {!draftExpanded && messages.map((m, i) => (
{m.role === 'assistant' && m.toolCalls?.length > 0 && (
{m.toolCalls.map(call => renderToolCallCard(call))}
)} {m.imageUrl && }
{m.role === 'assistant' ? renderAssistantMarkdown(m.text, m.result?.sources || []) : m.text}
{m.draft && (
{m.draft.payload?.visibility === 'shared' ? '割' : '私'}
{m.draft.payload?.title || '支出草稿'}
{moneyText(m.draft.payload?.amount, m.draft.payload?.currency)} · {m.draft.payload?.visibility === 'shared' ? '共同支出' : '個人支出'}
)} {m.stats && (
{[ ['總支出', moneyText(m.stats.total, m.stats.currency)], ['我的合計', moneyText(m.stats.my_total, m.stats.currency)], ['個人', moneyText(m.stats.personal_total, m.stats.currency)], ['共同', moneyText(m.stats.shared_total, m.stats.currency)], ].map(([label, value]) => (
{label}
{value}
))}
)} {m.invite && (
邀請連結
{m.invite.url}
)} {m.artifact && (
ARTIFACT
{m.artifact.title || '旅程支出報告'}
{m.artifact.summary || '已整理成手機版互動報告'}
{m.artifact.preview && (
總支出
{moneyListText(m.artifact.preview.total_by_currency, m.artifact.preview.total, m.artifact.preview.currency)}
筆數
{m.artifact.preview.expense_count || 0} 筆
)}
)} {m.result && (
{m.result.image_url && }
{m.result.title || '操作結果'}
{m.result.query && (
{m.result.query}
)} {m.result.cards?.length ? renderAssistantFlexCards(m.result.cards) : (
{(m.result.rows || []).slice(0, 8).map((row, idx) => { const clickable = !!row.action; const RowTag = clickable ? 'button' : 'div'; return ( runResultRowAction(row.action) : undefined} style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.05fr) minmax(0, 0.95fr)', gap: 8, alignItems: 'baseline', borderBottom: idx === Math.min((m.result.rows || []).length, 8) - 1 ? 'none' : `1px solid ${TABI_COLORS.line}`, paddingBottom: 7, minWidth: 0, width: '100%', borderTop: 'none', borderLeft: 'none', borderRight: 'none', background: clickable ? 'rgba(255,250,242,0.42)' : 'transparent', cursor: clickable ? 'pointer' : 'default', textAlign: 'left', borderRadius: clickable ? 11 : 0, padding: clickable ? '8px 9px' : '0 0 7px', }}>
{row.label}
{Array.isArray(row.children) && row.children.length > 0 && (
{row.children.slice(0, 3).map((child, childIdx) => (
{child.label} · {child.value}
))}
)}
{row.value}{clickable ? ' ›' : ''}
);})}
)} {!m.result.cards?.length && m.result.sources && m.result.sources.length > 0 && (
來源
{m.result.sources.slice(0, 4).map((source, idx) => (
{source.title || '未命名來源'}
{(source.url || '').replace(/^https?:\/\/(www\.)?/, '')}
))}
)}
)}
))} {!draftExpanded && (toolCalls.length > 0 || streamingText) && (
{toolCalls.length > 0 && (
{toolCalls.map(call => renderToolCallCard(call))}
)} {streamingText && (
{renderAssistantMarkdown(streamingText)}
)}
)} {!draftExpanded && busy && toolCalls.length === 0 && !streamingText && (
{[0, 1, 2].map(i => ( ))}
)} {draft && draftPayload && (
{!draftExpanded ? ( <>
{draftPayload.visibility === 'shared' ? '割' : '私'}
{savedExpenseId ? '已新增支出' : '支出草稿'}
{draftPayload.title || '未命名支出'}
{draftPayload.currency || 'JPY'} {Number(draftPayload.amount || 0).toLocaleString()} · {draftPayload.category || '其他'} · {draftPayload.visibility === 'shared' ? '共同支出' : '個人支出'}
{draftPayload.visibility === 'shared' && (
{draftPayload.split_method === 'custom' ? '自訂金額' : '均分'} {draftPayload.paid_by && members.find(m => m.id === draftPayload.paid_by) ? ` · ${members.find(m => m.id === draftPayload.paid_by).display_name} 付款` : ''}
)}
{warning &&
{warning}
}
) : ( <>
{savedExpenseId ? '編輯已新增支出' : '支出草稿'}
類型
{[ ['personal', '個人支出'], ['shared', '共同支出'], ].map(([v, label]) => { const on = draftPayload.visibility === v; return ( ); })}
{draftPayload.visibility === 'shared' && (
)} {draftPayload.visibility === 'personal' && (
{draftPayload.is_proxy && ( )}
)} {draftPayload.visibility === 'shared' && (
共同支出分帳方式
{[ ['equal', '均分'], ['custom', '自訂金額'], ].map(([v, label]) => { const on = draftPayload.split_method === v; return ( ); })}
{draftPayload.split_method === 'custom' && (
{members.map(m => ( ))}
加總 {splitTotal.toLocaleString()} / 總額 {Number(draftPayload.amount || 0).toLocaleString()}
)}
)} {warning &&
{warning}
}
)}
)}
{!draftExpanded && (
{ handleImagePick(e.target.files?.[0]); e.target.value = ''; }} />