// 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) => (
))}
{(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]) => (
))}
)}
{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 && (
)}
)}
))}
{!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 = ''; }} />
)}
{showHistory && (
e.target === e.currentTarget && setShowHistory(false)}>
)}
,
document.body
)}
>
);
}