// Tabi — screen components
// Persimmon palette, Noto Sans JP / TC
// ── User LLM/ASR config (localStorage only, never stored server-side) ──
window.tabiLLMHeaders = function() {
try {
const cfg = JSON.parse(localStorage.getItem('TABI_LLM_CONFIG') || '{}');
// Support new format (llm_mode / asr_mode) and old format (mode)
const llmMode = cfg.llm_mode || (cfg.mode === 'custom' ? 'custom' : 'default');
const asrMode = cfg.asr_mode || (cfg.asr_base_url ? 'custom' : 'default');
const h = {};
if (llmMode === 'custom' && cfg.llm_base_url) {
h['X-LLM-Base-Url'] = cfg.llm_base_url;
h['X-LLM-Api-Key'] = cfg.llm_api_key || '';
h['X-LLM-Model'] = cfg.llm_model || '';
}
if (asrMode === 'custom' && cfg.asr_base_url) {
h['X-ASR-Base-Url'] = cfg.asr_base_url;
h['X-ASR-Api-Key'] = cfg.asr_api_key || '';
h['X-ASR-Model'] = cfg.asr_model || '';
}
return h;
} catch { return {}; }
};
const TABI_COLORS = {
ink: '#1A1612',
ink2: '#5A4F45',
ink3: '#8B7E70',
bg: '#FAF6F0',
bg2: '#F1EAE0',
card: '#FFFFFF',
line: 'rgba(26,22,18,0.08)',
line2: 'rgba(26,22,18,0.14)',
red: '#C8553D', // 朱
redDeep: '#A0412F',
green: '#5B7553',
blue: '#3B5A7A',
warn: '#D9A441',
};
const tabiFont = `"Noto Sans JP","Noto Sans TC","Hiragino Sans",-apple-system,system-ui,sans-serif`;
const tabiSerif = `"Noto Serif JP","Shippori Mincho","Hiragino Mincho ProN",serif`;
// ─────────── shared bits ───────────
function TabiTopBar({ title, subtitle, onBack, accent, right }) {
return (
{onBack && (
)}
{subtitle && (
{subtitle}
)}
{title}
{right}
);
}
function TabiTabBar({ active, onChange }) {
const tabs = [
{ id: 'home', label: 'ホーム', zh: '首頁', icon: HomeIcon },
{ id: 'menu', label: 'メニュー', zh: '菜單', icon: MenuIcon },
{ id: 'voice', label: '会話', zh: '對話', icon: VoiceIcon },
{ id: 'trips', label: '家計簿', zh: '記帳', icon: WalletIcon },
];
return (
{tabs.map(t => {
const on = active === t.id;
const Icon = t.icon;
return (
);
})}
);
}
// ─────────── line icons ───────────
const stroke = (active) => ({
fill: 'none', stroke: 'currentColor',
strokeWidth: active ? 2 : 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
});
function HomeIcon({ size = 24, active }) {
return ();
}
function MenuIcon({ size = 24, active }) {
return ();
}
function VoiceIcon({ size = 24, active }) {
return ();
}
function ReceiptIcon({ size = 24, active }) {
return ();
}
function TicketIcon({ size = 24, active }) {
return ();
}
function LogIcon({ size = 24, active }) {
return ();
}
function WalletIcon({ size = 24, active }) {
return ();
}
function CameraIcon({ size = 24, active }) {
return ();
}
function MicIcon({ size = 24, active }) {
return ();
}
function PlayIcon({ size = 16 }) {
return ();
}
function StopIcon({ size = 16 }) {
return ();
}
function CheckIcon({ size = 14 }) {
return ();
}
// ─────────── TRAVEL PHRASES ─────────────────────────────────
let _ttsAudio = null;
let _ttsRunId = 0;
let _ttsEndHandler = null;
function _stopSpeech() {
_ttsRunId += 1;
if (_ttsAudio) {
try { _ttsAudio.pause(); _ttsAudio.removeAttribute('src'); _ttsAudio.load(); } catch {}
_ttsAudio = null;
}
window.speechSynthesis?.cancel();
if (_ttsEndHandler) {
const done = _ttsEndHandler;
_ttsEndHandler = null;
done();
}
}
function _ttsLanguageKey(lang) {
const value = String(lang || '').toLowerCase();
if (value.startsWith('ja') || value.includes('_ja') || value.includes('-ja')) return 'ja';
if (value.startsWith('zh') || value.includes('_zh') || value.includes('-zh')) return 'zh';
if (value.startsWith('en') || value.includes('_en') || value.includes('-en')) return 'en';
return null;
}
function _ttsConfig() {
try { return JSON.parse(localStorage.getItem('TABI_LLM_CONFIG') || '{}'); }
catch { return {}; }
}
function _ttsVoiceForLang(lang) {
const key = _ttsLanguageKey(lang);
if (!key) return '';
return (_ttsConfig()[`tts_voice_${key}`] || '').trim();
}
function _saveTtsConfigPatch(patch) {
const cfg = { ..._ttsConfig(), ...patch };
localStorage.setItem('TABI_LLM_CONFIG', JSON.stringify(cfg));
window.dispatchEvent(new CustomEvent('tabiTtsSettingsChanged', { detail: cfg }));
return cfg;
}
function _ttsModelLangName(model) {
const value = String(model || '').toLowerCase().trim();
if (value.endsWith('-ja') || value.endsWith('_ja')) return '日文';
if (value.endsWith('-zh') || value.endsWith('_zh')) return '中文';
if (value.endsWith('-en') || value.endsWith('_en')) return '英文';
return '';
}
function _ttsVoiceDisplayName(model, labels = {}) {
const fallback = String(model || '').trim();
const label = labels?.[model] || fallback;
const lang = _ttsModelLangName(model);
return lang ? `${label}(${lang})` : label;
}
let _ttsModelsCache = null;
let _ttsModelsPromise = null;
async function _loadTtsModelOptions() {
if (_ttsModelsCache) return _ttsModelsCache;
if (!_ttsModelsPromise) {
_ttsModelsPromise = fetch('/api/tts/models', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(async res => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || '取得聲音失敗');
_ttsModelsCache = {
models: Array.isArray(data.models) ? data.models : [],
labels: data.labels && typeof data.labels === 'object' ? data.labels : {},
};
return _ttsModelsCache;
})
.finally(() => { _ttsModelsPromise = null; });
}
return _ttsModelsPromise;
}
function _useTtsOptions() {
const [state, setState] = React.useState(_ttsModelsCache || { models: [], labels: {} });
React.useEffect(() => {
let alive = true;
_loadTtsModelOptions().then(data => { if (alive) setState(data); }).catch(() => {});
return () => { alive = false; };
}, []);
return state;
}
function _numInRange(value, fallback, min, max) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function _ttsPlaybackSettings() {
const cfg = _ttsConfig();
return {
rate: _numInRange(cfg.tts_rate, 1, 0.5, 1.5),
volume: _numInRange(cfg.tts_volume, 1, 0, 2.5),
};
}
function _browserSpeech(text, lang, options = {}) {
const finish = () => { _ttsEndHandler = null; options.onEnd?.(); };
if (!text || !window.speechSynthesis) { finish(); return; }
const playback = _ttsPlaybackSettings();
const utt = new SpeechSynthesisUtterance(text);
utt.lang = lang || 'ja-JP';
utt.rate = _numInRange((options.rate || 0.9) * playback.rate, 0.9, 0.1, 10);
utt.volume = Math.min(1, playback.volume);
utt.onstart = options.onStart;
utt.onend = finish;
utt.onerror = () => { _ttsEndHandler = null; (options.onError || options.onEnd)?.(); };
window.speechSynthesis.speak(utt);
}
function _splitSpeechText(text, lang) {
const value = String(text || '').replace(/\s+/g, ' ').trim();
if (!value) return [];
const key = _ttsLanguageKey(lang) || 'ja';
const limit = key === 'ja' ? 45 : key === 'zh' ? 55 : key === 'en' ? 120 : 70;
if (value.length <= limit) return [value];
const pattern = key === 'en'
? /[^.!?;]+[.!?;]?/g
: /[^。!?!?;;、,,.]+[。!?!?;;、,,.]?/g;
const pieces = value.match(pattern) || [value];
const chunks = [];
let current = '';
const pushCurrent = () => {
const chunk = current.trim();
if (!chunk) return;
if (chunks.length && chunk.length <= 6) {
chunks[chunks.length - 1] += chunk;
} else {
chunks.push(chunk);
}
current = '';
};
for (const raw of pieces) {
let piece = raw.trim();
if (!piece) continue;
while (piece.length > limit) {
const head = piece.slice(0, limit);
if (current) pushCurrent();
chunks.push(head);
piece = piece.slice(limit);
}
if (!piece) continue;
if (!current) {
current = piece;
} else if ((current + piece).length <= limit) {
current += piece;
} else {
pushCurrent();
current = piece;
}
}
pushCurrent();
return chunks.filter(Boolean);
}
async function _createTtsSession(text, voice, lang) {
const res = await fetch('/api/tts/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` },
body: JSON.stringify({ text, voice }),
});
if (!res.ok) throw new Error(`TTS session ${res.status}`);
return res.json();
}
async function _createTtsAudio(text, voice, lang) {
const data = await _createTtsSession(text, voice, lang);
return new Audio(data.stream_url);
}
async function _createTtsBlobAudio(text, voice, lang) {
const data = await _createTtsSession(text, voice, lang);
const res = await fetch(data.stream_url);
if (!res.ok) throw new Error(`TTS blob ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.addEventListener('ended', () => URL.revokeObjectURL(url), { once: true });
audio.addEventListener('error', () => URL.revokeObjectURL(url), { once: true });
return audio;
}
function _playAudioElement(audio, runId, onAudioStart) {
return new Promise((resolve, reject) => {
if (_ttsRunId !== runId) { resolve(false); return; }
const playback = _ttsPlaybackSettings();
audio.volume = Math.min(1, playback.volume);
audio.playbackRate = playback.rate;
audio.preservesPitch = true;
audio.mozPreservesPitch = true;
audio.webkitPreservesPitch = true;
if (playback.volume > 1) {
try {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx && !audio._tabiGainNode) {
const ctx = window._tabiAudioContext || new AudioCtx();
window._tabiAudioContext = ctx;
const source = ctx.createMediaElementSource(audio);
const gain = ctx.createGain();
gain.gain.value = playback.volume;
source.connect(gain).connect(ctx.destination);
audio._tabiGainNode = gain;
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
} else if (audio._tabiGainNode) {
audio._tabiGainNode.gain.value = playback.volume;
}
} catch {}
}
_ttsAudio = audio;
audio.onended = () => resolve(true);
audio.onerror = () => {
const err = audio.error;
const detail = err ? `audio playback failed code=${err.code} message=${err.message || ''}` : 'audio playback failed';
reject(new Error(detail));
};
audio.play()
.then(() => { if (_ttsRunId === runId) onAudioStart?.(); })
.catch(err => reject(new Error(err?.message || err?.name || 'audio.play failed')));
});
}
async function _playFishBlobSequence(chunks, voice, runId, options = {}) {
for (let i = 0; i < chunks.length; i += 1) {
if (_ttsRunId !== runId) return false;
const audio = await _createTtsBlobAudio(chunks[i], voice, options.lang);
await _playAudioElement(audio, runId, i === 0 ? options.onStart : null);
if (_ttsRunId === runId) _ttsAudio = null;
}
if (_ttsRunId === runId) {
_ttsEndHandler = null;
options.onEnd?.();
}
return true;
}
async function _playSpeech(text, lang, options = {}) {
if (!text) return false;
const runId = _ttsRunId + 1;
_stopSpeech();
_ttsRunId = runId;
const voice = _ttsVoiceForLang(lang);
const fallback = (reason = 'unknown') => {
if (_ttsRunId !== runId) return false;
_ttsEndHandler = options.onEnd || null;
_browserSpeech(text, lang, options);
return true;
};
if (!voice) return fallback('no voice selected');
if (!window.tabiToken?.()) return fallback('missing token');
try {
options.onGenerating?.();
_ttsEndHandler = options.onEnd || null;
const chunks = _splitSpeechText(text, lang);
if (!chunks.length) return false;
let nextAudioPromise = _createTtsAudio(chunks[0], voice, lang);
for (let i = 0; i < chunks.length; i += 1) {
if (_ttsRunId !== runId) return false;
const audio = await nextAudioPromise;
if (i + 1 < chunks.length) nextAudioPromise = _createTtsAudio(chunks[i + 1], voice, lang);
await _playAudioElement(audio, runId, i === 0 ? options.onStart : null);
if (_ttsRunId === runId) _ttsAudio = null;
}
if (_ttsRunId === runId) {
_ttsEndHandler = null;
options.onEnd?.();
}
return true;
} catch (e) {
try {
const chunks = _splitSpeechText(text, lang);
if (chunks.length && voice && window.tabiToken?.()) {
return await _playFishBlobSequence(chunks, voice, runId, { ...options, lang });
}
} catch (blobError) {
return fallback(`${e?.message || 'tts failed'}; blob retry failed: ${blobError?.message || blobError}`);
}
return fallback(e?.message || 'tts failed');
}
}
function _speakJP(text, options = {}) {
return _playSpeech(text, 'ja-JP', { rate: 0.85, ...options });
}
// ─────────── HOME ───────────
// ─────────── LLM SETTINGS SHEET ───────────
// Defined outside LLMSettingsSheet so React never remounts them mid-keystroke
const _settingsInp = {
width: '100%', padding: '10px 12px', borderRadius: 10,
border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg,
fontSize: 16, color: TABI_COLORS.ink, outline: 'none',
fontFamily: 'monospace', boxSizing: 'border-box',
};
function EyeIcon({ open }) {
return open ? (
) : (
);
}
function ServiceSection({ type, label, tag, mode, setMode, url, setUrl, apiKey, setApiKey, model, setModel, urlPlaceholder, modelPlaceholder }) {
const [showKey, setShowKey] = React.useState(false);
const [testState, setTestState] = React.useState('idle');
const [testMsg, setTestMsg] = React.useState('');
const runTest = async () => {
if (!url.trim()) { setTestState('error'); setTestMsg('請先填入 Base URL'); return; }
if (type === 'llm' && !model.trim()) { setTestState('error'); setTestMsg('請先填入模型名稱'); return; }
setTestState('testing'); setTestMsg('');
try {
const res = await fetch('/api/scan/test-endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.tabiToken()}`,
},
body: JSON.stringify({ type, base_url: url.trim(), api_key: apiKey, model: model.trim() }),
signal: AbortSignal.timeout(20000),
});
const data = await res.json();
if (data.ok) {
setTestState('ok'); setTestMsg(data.detail || '連線成功');
} else {
setTestState('error'); setTestMsg(data.detail || '測試失敗');
}
} catch (e) {
setTestState('error');
setTestMsg(e.name === 'TimeoutError' ? '等待逾時(20s)' : '無法連線');
}
};
const testColor = testState === 'ok' ? TABI_COLORS.green : testState === 'error' ? TABI_COLORS.red : TABI_COLORS.ink3;
return (
{[['default', '預設'], ['custom', '自訂']].map(([v, lbl]) => (
))}
{mode === 'default' && (
使用網站端點
)}
{mode === 'custom' && (
API Key
{ setApiKey(e.target.value); setTestState('idle'); }}
placeholder="sk-..."
style={{ ..._settingsInp, paddingRight: 42 }}
/>
{testState !== 'idle' && testState !== 'testing' && (
{testState === 'ok' ? '✓ ' : '✗ '}{testMsg}
)}
)}
);
}
function TTSInlineSettings({ lang = 'ja-JP', dark = false }) {
const key = _ttsLanguageKey(lang);
const { models, labels } = _useTtsOptions();
const [cfg, setCfg] = React.useState(_ttsConfig());
React.useEffect(() => {
const sync = () => setCfg(_ttsConfig());
window.addEventListener('tabiTtsSettingsChanged', sync);
return () => window.removeEventListener('tabiTtsSettingsChanged', sync);
}, []);
const voiceKey = key ? `tts_voice_${key}` : null;
const voice = voiceKey ? (cfg[voiceKey] || '') : '';
const volume = _numInRange(cfg.tts_volume, 1, 0, 2.5);
const rate = _numInRange(cfg.tts_rate, 1, 0.5, 1.5);
const recommendedOptions = key ? models.filter(name => String(name).toLowerCase().trim().endsWith(key)) : [];
const otherOptions = key ? models.filter(name => !recommendedOptions.includes(name)) : [];
const ink = dark ? 'rgba(255,255,255,0.88)' : TABI_COLORS.ink;
const muted = dark ? 'rgba(255,255,255,0.5)' : TABI_COLORS.ink3;
const panelBg = dark ? 'rgba(255,255,255,0.08)' : TABI_COLORS.bg2;
const fieldBg = dark ? 'rgba(0,0,0,0.18)' : TABI_COLORS.card;
const border = dark ? 'rgba(255,255,255,0.12)' : TABI_COLORS.line;
const patch = next => setCfg(_saveTtsConfigPatch(next));
const Slider = ({ label, value, min, max, step, suffix, format, onChange }) => (
);
return (
語音設定
{voice ? _ttsVoiceDisplayName(voice, labels) : '瀏覽器語音'} · {Math.round(volume * 100)}% · {rate.toFixed(2)}x
{key ? (
) : (
此語言目前使用瀏覽器內建語音。
)}
Math.round(v * 100)} onChange={v => patch({ tts_volume: v })} />
Number(v).toFixed(2)} onChange={v => patch({ tts_rate: v })} />
);
}
function TTSVoiceSection({ models, labels = {}, status, voiceJa, setVoiceJa, voiceZh, setVoiceZh, voiceEn, setVoiceEn, volume, setVolume, rate, setRate, onReload }) {
const SettingSlider = ({ label, value, setValue, min, max, step, suffix, format }) => (
{label}
{format(value)}{suffix}
setValue(Number(e.target.value))}
style={{ width: '100%', accentColor: TABI_COLORS.red }}
/>
);
const voicesFor = (lang) => models.filter(name => {
const n = String(name).toLowerCase().trim();
return n.endsWith(lang);
});
const Field = ({ label, value, setValue, lang }) => {
const recommendedOptions = voicesFor(lang);
const otherOptions = models.filter(name => !recommendedOptions.includes(name));
return (
{label}
);
};
return (
語音播放(TTS)
Fish Speech 聲音選擇
Math.round(v * 100)} />
Number(v).toFixed(2)} />
{status.text}
);
}
function LLMSettingsSheet({ onClose }) {
const load = () => {
try { return JSON.parse(localStorage.getItem('TABI_LLM_CONFIG') || '{}'); }
catch { return {}; }
};
const saved = load();
const initLlmMode = saved.llm_mode || (saved.mode === 'custom' ? 'custom' : 'default');
const initAsrMode = saved.asr_mode || (saved.asr_base_url ? 'custom' : 'default');
const [llmMode, setLlmMode] = React.useState(initLlmMode);
const [llmUrl, setLlmUrl] = React.useState(saved.llm_base_url || '');
const [llmKey, setLlmKey] = React.useState(saved.llm_api_key || '');
const [llmModel, setLlmModel] = React.useState(saved.llm_model || '');
const [asrMode, setAsrMode] = React.useState(initAsrMode);
const [asrUrl, setAsrUrl] = React.useState(saved.asr_base_url || '');
const [asrKey, setAsrKey] = React.useState(saved.asr_api_key || '');
const [asrModel, setAsrModel] = React.useState(saved.asr_model || '');
const [ttsModels, setTtsModels] = React.useState([]);
const [ttsLabels, setTtsLabels] = React.useState({});
const [ttsStatus, setTtsStatus] = React.useState({ ok: false, text: '尚未載入聲音;未選擇時會使用瀏覽器內建語音。' });
const [ttsVoiceJa, setTtsVoiceJa] = React.useState(saved.tts_voice_ja || '');
const [ttsVoiceZh, setTtsVoiceZh] = React.useState(saved.tts_voice_zh || '');
const [ttsVoiceEn, setTtsVoiceEn] = React.useState(saved.tts_voice_en || '');
const [ttsVolume, setTtsVolume] = React.useState(_numInRange(saved.tts_volume, 1, 0, 2.5));
const [ttsRate, setTtsRate] = React.useState(_numInRange(saved.tts_rate, 1, 0.5, 1.5));
const [savedOk, setSavedOk] = React.useState(false);
const loadTtsModels = async () => {
try {
setTtsStatus({ ok: false, text: '正在取得聲音列表…' });
const res = await fetch('/api/tts/models', { headers: { Authorization: `Bearer ${window.tabiToken()}` } });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || '取得失敗');
const models = Array.isArray(data.models) ? data.models : [];
setTtsModels(models);
setTtsLabels(data.labels && typeof data.labels === 'object' ? data.labels : {});
setTtsStatus({ ok: true, text: models.length ? `已載入 ${models.length} 個聲音` : '端點可用,但目前沒有聲音。' });
} catch (e) {
setTtsModels([]);
setTtsLabels({});
setTtsStatus({ ok: false, text: `TTS 未啟用或無法連線,會使用瀏覽器內建語音。${e.message ? ' ' + e.message : ''}` });
}
};
React.useEffect(() => { loadTtsModels(); }, []);
const save = () => {
const cfg = {
llm_mode: llmMode,
llm_base_url: llmUrl.trim(),
llm_api_key: llmKey,
llm_model: llmModel.trim(),
asr_mode: asrMode,
asr_base_url: asrUrl.trim(),
asr_api_key: asrKey,
asr_model: asrModel.trim(),
tts_voice_ja: ttsVoiceJa,
tts_voice_zh: ttsVoiceZh,
tts_voice_en: ttsVoiceEn,
tts_volume: ttsVolume,
tts_rate: ttsRate,
};
localStorage.setItem('TABI_LLM_CONFIG', JSON.stringify(cfg));
window.dispatchEvent(new CustomEvent('tabiTtsSettingsChanged', { detail: cfg }));
setSavedOk(true);
setTimeout(() => { setSavedOk(false); onClose(); }, 800);
};
return ReactDOM.createPortal(
{/* Header */}
{/* LLM section */}
{/* ASR section */}
🔒 API Key 只存於你的手機,不會傳送至本網站資料庫。
,
document.body
);
}
function HomeScreen({ go, userName }) {
const [camSheet, setCamSheet] = React.useState(false);
const [scanReceiptOpen, setScanReceiptOpen] = React.useState(false);
const [showLLMSettings, setShowLLMSettings] = React.useState(false);
const [homeTrips, setHomeTrips] = React.useState([]);
const [allPhrases, setAllPhrases] = React.useState([]);
const [sampledPhrases, setSampledPhrases] = React.useState([]);
const [speaking, setSpeaking] = React.useState(null);
const [ttsPending, setTtsPending] = React.useState(null);
function loadHomeTrips() {
fetch('/api/trips', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.ok ? r.json() : [])
.then(d => setHomeTrips(Array.isArray(d) ? d : []))
.catch(() => {});
}
React.useEffect(() => {
loadHomeTrips();
fetch('/api/phrases', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.ok ? r.json() : [])
.then(data => {
if (!Array.isArray(data) || !data.length) return;
setAllPhrases(data);
const shuffled = [...data].sort(() => Math.random() - 0.5);
setSampledPhrases(shuffled.slice(0, 5));
})
.catch(() => {});
}, []);
function handleSpeak(idx, text) {
if (speaking === idx || ttsPending === idx) {
_stopSpeech();
setSpeaking(null);
setTtsPending(null);
return;
}
_speakJP(text, {
onGenerating: () => { setTtsPending(idx); setSpeaking(null); },
onStart: () => { setTtsPending(null); setSpeaking(idx); },
onEnd: () => { setTtsPending(null); setSpeaking(s => s === idx ? null : s); },
onError: () => { setTtsPending(null); setSpeaking(s => s === idx ? null : s); },
});
}
const features = [
{ id: 'sign', jp: '写真翻訳', zh: '拍照翻譯', desc: '路標・看板・標示翻譯', tone: TABI_COLORS.warn, Icon: CameraIcon },
{ id: 'menuHistory', jp: 'メニュー翻訳', zh: '菜單翻譯', desc: '拍照辨識・自動點餐句', tone: TABI_COLORS.red, Icon: MenuIcon },
{ id: 'voice', jp: '会話通訳', zh: '即時對話', desc: '說話即翻、雙向對話', tone: TABI_COLORS.blue, Icon: MicIcon },
{ id: 'receipt', jp: 'レシート整理', zh: '掃描發票', desc: '拍發票・自動辨識・匯入記帳', tone: TABI_COLORS.green, Icon: ReceiptIcon },
{ id: 'trips', jp: '旅の家計簿', zh: '共同記帳', desc: '多人拆帳・自動結算', tone: TABI_COLORS.warn, Icon: ReceiptIcon },
{ id: 'coupons', jp: 'クーポン', zh: '優惠券', desc: '折扣優惠・分類搜尋', tone: TABI_COLORS.red, Icon: TicketIcon },
];
return (
{/* Camera mode choice sheet */}
{camSheet && (
setCamSheet(false)} style={{
position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)',
zIndex: 100, display: 'flex', alignItems: 'flex-end',
}}>
e.stopPropagation()} style={{
width: '100%', background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0',
padding: '12px 20px 36px',
}}>
選擇拍照模式
{[
{ id: 'camera', title: '菜單翻譯', desc: '掃描菜單・選料理・生成點餐句', Icon: MenuIcon, tone: TABI_COLORS.red },
{ id: 'sign', title: '拍照翻譯', desc: '路標・看板・標示等任意文字翻譯', Icon: CameraIcon, tone: TABI_COLORS.warn },
].map(opt => (
))}
)}
{/* hero */}
旅 · TABI · 日本旅行
{(() => {
let isCustom = false;
try { isCustom = JSON.parse(localStorage.getItem('TABI_LLM_CONFIG') || '{}').mode === 'custom'; } catch {}
return (
);
})()}
こんにちは、
{userName || '旅人'}さん。
{/* big primary CTA */}
{/* feature grid */}
{features.map(f => (
))}
{/* common phrases */}
{sampledPhrases.length === 0 ? (
載入中…
) : sampledPhrases.map((p, i) => (
{p.category || p.cat}
{p.japanese || p.jp}
{p.chinese || p.zh}
))}
{scanReceiptOpen && (
setScanReceiptOpen(false)}
onSaved={() => setScanReceiptOpen(false)}
onTripCreated={() => loadHomeTrips()}
/>
)}
{showLLMSettings && (
setShowLLMSettings(false)} />
)}
);
}
function couponCategories(value) {
return String(value || '').split(',').map(v => v.trim()).filter(Boolean);
}
function couponCategoryText(value) {
const cats = couponCategories(value);
return cats.length ? cats.join('、') : '';
}
function formatCouponDate(value) {
if (!value) return '無期限';
const parts = String(value).split('-');
if (parts.length !== 3) return `期限 ${value}`;
return `期限 ${Number(parts[0])}/${Number(parts[1])}/${Number(parts[2])}`;
}
function CouponListCard({ coupon, onOpen }) {
return (
);
}
function CouponsScreen({ go }) {
const [coupons, setCoupons] = React.useState([]);
const [query, setQuery] = React.useState('');
const [category, setCategory] = React.useState('全部');
const [page, setPage] = React.useState(1);
const pageTopRef = React.useRef(null);
const pageSize = 10;
React.useEffect(() => {
fetch('/api/coupons')
.then(r => r.ok ? r.json() : [])
.then(data => setCoupons(Array.isArray(data) ? data : []))
.catch(() => setCoupons([]));
}, []);
React.useEffect(() => { setPage(1); }, [query, category]);
function openCoupon(coupon) {
const href = coupon.target_type === 'image' ? (coupon.target_url || coupon.image_url) : coupon.target_url;
if (href) window.open(href, '_blank', 'noopener,noreferrer');
}
function goCouponPage(nextPage) {
setPage(nextPage);
requestAnimationFrame(() => {
pageTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
const cats = React.useMemo(() => {
const seen = new Set();
coupons.forEach(c => couponCategories(c.category).forEach(cat => seen.add(cat)));
return ['全部', ...Array.from(seen)];
}, [coupons]);
const filtered = coupons.filter(c => {
const q = query.trim().toLowerCase();
const matchesCat = category === '全部' || couponCategories(c.category).includes(category);
if (!matchesCat) return false;
if (!q) return true;
return [c.title, c.description, c.category].some(v => String(v || '').toLowerCase().includes(q));
});
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(page, totalPages);
const pageItems = filtered.slice((safePage - 1) * pageSize, safePage * pageSize);
return (
go('home')} />
⌕
setQuery(e.target.value)} placeholder="搜尋店名、分類或優惠內容" style={{
flex: 1, border: 'none', outline: 'none', background: 'transparent', color: TABI_COLORS.ink,
fontSize: 14, fontFamily: tabiFont,
}} />
{query && (
)}
{cats.map(cat => {
const on = category === cat;
return (
);
})}
{filtered.length} 張優惠券
第 {safePage} / {totalPages} 頁
{pageItems.length ? pageItems.map(coupon => (
)) : (
找不到符合條件的優惠券
)}
{totalPages > 1 && (
)}
);
}
function SectionLabel({ jp, zh, right }) {
return (
{zh}
{jp}
{right &&
{right}
}
);
}
// ─────────── CAMERA ───────────
function CameraScreen({ go, mode = 'menu', onMenuScanned, onSignScanned }) {
const [files, setFiles] = React.useState([]);
const [previews, setPreviews] = React.useState([]);
const [uploading, setUploading] = React.useState(false);
const [uploadProgress, setUploadProgress] = React.useState('');
const [error, setError] = React.useState(null);
const [alertModal, setAlertModal] = React.useState(null); // { emoji, title, desc }
const cameraRef = React.useRef();
const galleryRef = React.useRef();
const labels = {
menu: { jp: 'メニューを撮影', zh: '拍攝菜單' },
receipt: { jp: 'レシートを撮影', zh: '拍攝發票' },
sign: { jp: '写真を翻訳', zh: '拍照翻譯' },
}[mode];
const addFiles = (fileList) => {
if (!fileList?.length) return;
setError(null);
setAlertModal(null);
const arr = Array.from(fileList);
const urls = arr.map(f => URL.createObjectURL(f));
setFiles(prev => [...prev, ...arr]);
setPreviews(prev => [...prev, ...urls]);
};
const removeFile = (i) => {
URL.revokeObjectURL(previews[i]);
setFiles(prev => prev.filter((_, idx) => idx !== i));
setPreviews(prev => prev.filter((_, idx) => idx !== i));
};
const upload = async () => {
if (!files.length || uploading) return;
setUploading(true);
setError(null);
try {
if (mode === 'sign') {
const results = [];
for (let i = 0; i < files.length; i++) {
if (files.length > 1) setUploadProgress(`翻譯第 ${i + 1} 張,共 ${files.length} 張…`);
const fd = new FormData();
fd.append('image', files[i]);
const r = await fetch('/api/scan/sign', {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() },
body: fd,
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
if (err.detail === 'NO_TEXT_FOUND') {
if (files.length === 1) {
setAlertModal({ emoji: '🔍', title: '找不到文字', desc: '圖片中未找到可識別的文字,請拍攝含有路標、看板或文字的圖片' });
return;
}
continue; // skip this photo but keep going
}
throw new Error(err.detail || `錯誤 ${r.status}`);
}
const data = await r.json();
results.push({ ...data, image_url: previews[i] || null });
}
if (results.length === 0) {
setAlertModal({ emoji: '🔍', title: '找不到文字', desc: '所有圖片中均未找到可識別的文字,請拍攝含有路標、看板或文字的圖片' });
return;
}
const allSegments = results.flatMap(r => r.segments);
if (onSignScanned) onSignScanned({
photos: results,
image_url: results[0].image_url,
segments: allSegments,
summary: results.length === 1 ? results[0].summary : `共 ${results.length} 張照片`,
note: results[0].note,
});
go('signResult');
} else {
const fd = new FormData();
for (const f of files) fd.append('images', f);
fd.append('target_lang', 'ZH');
const r = await fetch('/api/scan/menu', {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() },
body: fd,
});
if (r.status !== 202) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail || `錯誤 ${r.status}`);
}
const start = await r.json();
const jobId = start.job_id;
if (!jobId) throw new Error('伺服器未回傳分析編號');
const pollMs = 1500;
const maxTries = 200;
let data = null;
for (let t = 0; t < maxTries; t++) {
if (t > 0) await new Promise(res => setTimeout(res, pollMs));
const pr = await fetch(`/api/scan/menu/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${window.tabiToken()}` },
});
if (!pr.ok) {
const xe = await pr.json().catch(() => ({}));
throw new Error(xe.detail || `輪詢錯誤 ${pr.status}`);
}
const st = await pr.json();
if (st.status === 'completed') {
data = st;
break;
}
if (st.status === 'failed') {
const d = st.detail || '';
if (d === 'NOT_A_MENU') {
setAlertModal({ emoji: '🍽️', title: '這不是菜單', desc: '請拍攝餐廳菜單,系統才能辨識料理項目並協助點餐' });
return;
}
throw new Error(d || 'AI 分析失敗');
}
if (!['pending', 'running'].includes(st.status)) {
throw new Error(st.detail || `未知狀態:${st.status}`);
}
}
if (!data) throw new Error('AI 分析逾時,請稍後再試或減少圖片張數');
if (onMenuScanned) onMenuScanned(data.menu_id);
go('menuResult');
}
} catch (e) {
setError(e.message);
} finally {
setUploading(false);
}
};
const hasFiles = files.length > 0;
return (
{ addFiles(e.target.files); e.target.value = ''; }} />
{ addFiles(e.target.files); e.target.value = ''; }} />
{/* header */}
{files.length > 0 && (
{files.length} 張
)}
{/* preview area */}
{!hasFiles && (
📷
點「拍照」直接拍攝
點「相簿」可一次選多張
菜單多頁可分開拍後一起送出
)}
{hasFiles && (
{previews.map((url, i) => (
{files.length > 1 && (
第 {i + 1} 頁
)}
))}
)}
{uploading && (
{mode === 'sign'
? (uploadProgress || 'AI 翻譯中…')
: `AI 分析菜單中${files.length > 1 ? `(共 ${files.length} 頁)` : ''}…`}
)}
{error && !uploading && (
{error}
)}
{/* Alert modal — centered overlay for recognizable error types */}
{alertModal && (
{alertModal.emoji}
{alertModal.title}
{alertModal.desc}
)}
{/* bottom bar */}
{hasFiles && (
)}
);
}
// ─────────── MENU RESULT ───────────
function MenuResultScreen({ go, menuId, onOrderReady, targetLang = 'JA', setTargetLang, backTo = 'camera' }) {
const [menu, setMenu] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [cart, setCart] = React.useState(() => {
try { return JSON.parse(localStorage.getItem(`tabi_cart_${menuId}`) || 'null')?.cart || {}; } catch (_) { return {}; }
});
const [localName, setLocalName] = React.useState('');
const [editingName, setEditingName] = React.useState(false);
const [pendingName, setPendingName] = React.useState('');
const [imageUrl, setImageUrl] = React.useState(null);
const [uploadingImg, setUploadingImg] = React.useState(false);
const imgInputRef = React.useRef();
const [localTripId, setLocalTripId] = React.useState(undefined);
const [trips, setTrips] = React.useState(null);
const [tripSheet, setTripSheet] = React.useState(false);
const [editMode, setEditMode] = React.useState(false);
const [editingItem, setEditingItem] = React.useState(null);
const [savingItem, setSavingItem] = React.useState(false);
const [members, setMembers] = React.useState([]);
const [memberCart, setMemberCart] = React.useState(() => {
try { return JSON.parse(localStorage.getItem(`tabi_cart_${menuId}`) || 'null')?.memberCart || {}; } catch (_) { return {}; }
});
React.useEffect(() => {
if (!menuId) { setError('菜單 ID 遺失'); setLoading(false); return; }
fetch(`/api/menus/${menuId}`, {
headers: { Authorization: `Bearer ${window.tabiToken()}` },
})
.then(r => { if (!r.ok) throw new Error(`錯誤 ${r.status}`); return r.json(); })
.then(data => {
setMenu(data);
setLocalName(data.name);
setImageUrl(data.image_url || null);
setLocalTripId(data.trip_id || null);
setLoading(false);
})
.catch(e => { setError(e.message); setLoading(false); });
}, [menuId]);
React.useEffect(() => {
if (localTripId === undefined) return; // not loaded yet — don't clear restored cart
if (!localTripId) { setMembers([]); setMemberCart({}); return; }
fetch(`/api/trips/${localTripId}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.json())
.then(d => setMembers(Array.isArray(d.members) ? d.members : []))
.catch(() => setMembers([]));
}, [localTripId]);
// Persist cart to localStorage on every change
React.useEffect(() => {
if (!menuId) return;
localStorage.setItem(`tabi_cart_${menuId}`, JSON.stringify({ cart, memberCart }));
}, [cart, memberCart, menuId]);
const saveName = async () => {
const name = pendingName.trim();
if (!name || !menuId) { setEditingName(false); return; }
setLocalName(name);
setEditingName(false);
await fetch(`/api/menus/${menuId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
}).catch(() => {});
};
const uploadImage = async (file) => {
if (!file || !menuId) return;
setUploadingImg(true);
try {
const fd = new FormData();
fd.append('image', file);
const r = await fetch(`/api/menus/${menuId}/image`, {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}` },
body: fd,
});
if (!r.ok) throw new Error();
const data = await r.json();
setImageUrl(data.image_url + '?t=' + Date.now());
} catch (_) {}
setUploadingImg(false);
};
const loadTrips = () => {
if (trips !== null) return;
fetch('/api/trips', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.json()).then(d => setTrips(Array.isArray(d) ? d : [])).catch(() => setTrips([]));
};
const tapMemberItem = (itemId, memberId) => {
setMemberCart(prev => {
const item = { ...(prev[itemId] || {}) };
item[memberId] = (item[memberId] || 0) + 1;
return { ...prev, [itemId]: item };
});
};
const removeMemberFromItem = (itemId, memberId) => {
setMemberCart(prev => {
const item = { ...(prev[itemId] || {}) };
const newQty = (item[memberId] || 0) - 1;
if (newQty <= 0) delete item[memberId];
else item[memberId] = newQty;
const next = { ...prev };
if (Object.keys(item).length === 0) delete next[itemId];
else next[itemId] = item;
return next;
});
};
const linkTrip = async (tripId) => {
setLocalTripId(tripId);
await fetch(`/api/menus/${menuId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ trip_id: tripId }),
}).catch(() => {});
};
const saveItem = async (data) => {
if (!data.original_name.trim()) return;
setSavingItem(true);
try {
if (data.id) {
const r = await fetch(`/api/menus/${menuId}/items/${data.id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
original_name: data.original_name.trim(),
translated_name: data.translated_name.trim() || null,
price: data.price !== '' ? parseInt(data.price) : null,
category: data.category || null,
}),
});
if (r.ok) {
const updated = await r.json();
setMenu(m => ({ ...m, items: m.items.map(it => it.id === data.id ? { ...it, ...updated } : it) }));
}
} else {
const r = await fetch(`/api/menus/${menuId}/items`, {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
original_name: data.original_name.trim(),
translated_name: data.translated_name.trim() || null,
price: data.price !== '' ? parseInt(data.price) : null,
category: data.category || '主食',
}),
});
if (r.ok) {
const newIt = await r.json();
setMenu(m => ({ ...m, items: [...m.items, newIt] }));
}
}
} catch (_) {}
setSavingItem(false);
setEditingItem(null);
};
const deleteItem = async (itemId) => {
await fetch(`/api/menus/${menuId}/items/${itemId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${window.tabiToken()}` },
}).catch(() => {});
setMenu(m => ({ ...m, items: m.items.filter(it => it.id !== itemId) }));
setEditingItem(null);
};
const setQty = (id, q) => {
setCart(prev => {
const next = { ...prev };
if (q <= 0) delete next[id]; else next[id] = q;
return next;
});
};
const usingMembers = members.length > 0;
const memberQtySum = (v) => Object.values(v).reduce((s, n) => s + n, 0);
const totalQty = usingMembers
? Object.values(memberCart).reduce((a, v) => a + memberQtySum(v), 0)
: Object.values(cart).reduce((a, b) => a + b, 0);
const totalPrice = usingMembers
? Object.entries(memberCart).reduce((a, [id, v]) => {
const it = menu?.items.find(i => i.id === id);
return a + (it?.price || 0) * memberQtySum(v);
}, 0)
: Object.entries(cart).reduce((a, [id, q]) => {
const it = menu?.items.find(i => i.id === id);
return a + (it?.price || 0) * q;
}, 0);
const confirmOrder = () => {
if (totalQty === 0 || !menu) return;
const cartItems = usingMembers
? Object.entries(memberCart)
.filter(([_, v]) => memberQtySum(v) > 0)
.map(([id, v]) => {
const it = menu.items.find(i => i.id === id);
return { menu_item_id: it.id, name: it.translated_name || it.original_name, jp_name: it.original_name, quantity: memberQtySum(v), price: it.price, member_ids: Object.keys(v), member_qtys: v };
})
: Object.entries(cart).map(([id, qty]) => {
const it = menu.items.find(i => i.id === id);
return { menu_item_id: it.id, name: it.translated_name || it.original_name, jp_name: it.original_name, quantity: qty, price: it.price };
});
if (onOrderReady) onOrderReady(cartItems, localTripId, members);
go('orderConfirm');
};
if (loading) return (
);
if (error) return (
go(backTo)} />
⚠️
{error}
);
return (
go(backTo)}
right={!editMode && (
)}
/>
{/* ── Trip picker sheet ── */}
{tripSheet && ReactDOM.createPortal(
setTripSheet(false)} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
zIndex: 9200, display: 'flex', alignItems: 'flex-end',
animation: 'tabiFade 0.2s ease',
}}>
e.stopPropagation()} style={{
width: '100%', maxWidth: 480, margin: '0 auto',
background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0',
padding: '12px 0 28px', maxHeight: '70vh', overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
}}>
{trips === null ? (
載入中…
) : trips.length === 0 ? (
還沒有旅程,請先在旅行頁建立旅程
) : trips.map(t => (
))}
,
document.body
)}
{/* ── Item edit/add sheet ── */}
{editingItem !== null && ReactDOM.createPortal(
setEditingItem(null)} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
zIndex: 9200, display: 'flex', alignItems: 'flex-end',
animation: 'tabiFade 0.2s ease',
}}>
e.stopPropagation()} style={{
width: '100%', maxWidth: 480, margin: '0 auto',
background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0',
padding: '12px 0 calc(env(safe-area-inset-bottom, 0px) + 24px)',
maxHeight: '85vh', overflowY: 'auto', WebkitOverflowScrolling: 'touch',
}}>
{editingItem.id ? '編輯品項' : '新增品項'}
{[
{ label: '原文名稱', key: 'original_name', placeholder: '原文(日文、英文等)' },
{ label: '中文譯名', key: 'translated_name', placeholder: '中文名稱(可留空)' },
{ label: '價格(円)', key: 'price', placeholder: '0', inputMode: 'numeric' },
].map(({ label, key, placeholder, inputMode }) => (
{label}
setEditingItem(prev => ({ ...prev, [key]: e.target.value }))}
inputMode={inputMode}
placeholder={placeholder}
style={{
width: '100%', padding: '10px 12px', borderRadius: 10,
border: `1px solid ${TABI_COLORS.line2}`,
background: TABI_COLORS.card, color: TABI_COLORS.ink,
fontSize: 14, fontFamily: 'inherit', outline: 'none',
boxSizing: 'border-box',
}}
/>
))}
分類
{['主食', '飲品'].map(cat => (
))}
{editingItem.id && (
)}
,
document.body
)}
{/* ── Edit toolbar — only shown in edit mode, pinned below title bar ── */}
{editMode && (
編輯模式
)}
{/* ── Restaurant header card ── */}
{ const f = e.target.files?.[0]; if (f) uploadImage(f); e.target.value = ''; }}
/>
{/* image area */}
imgInputRef.current?.click()}
style={{
position: 'relative', height: 148, cursor: 'pointer', overflow: 'hidden',
background: imageUrl ? '#000' : `linear-gradient(135deg, ${TABI_COLORS.bg2} 0%, ${TABI_COLORS.line2} 100%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{imageUrl ? (

) : (
{localName || '未命名店家'}
點此拍攝或上傳店家照片
)}
{uploadingImg && (
)}
{/* name row */}
{editingName ? (
setPendingName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false); }}
style={{
flex: 1, minWidth: 0, fontFamily: tabiSerif, fontSize: 16, fontWeight: 600,
border: `1.5px solid ${TABI_COLORS.red}`, borderRadius: 10,
padding: '6px 10px', background: TABI_COLORS.bg, color: TABI_COLORS.ink,
outline: 'none',
}}
/>
) : (
{localName || '未命名店家'}
)}
{menu.source_lang} 菜單 · {menu.items.length} 道料理
{/* trip selector row */}
{localTripId
? (trips?.find(t => t.id === localTripId)?.name || '旅程已連結')
: '未連結旅程'}
{/* ── Menu items ── */}
{menu.items.length === 0 && (
未能解析出菜單項目,請重新拍攝
)}
{menu.items.map(it => {
const q = usingMembers ? memberQtySum(memberCart[it.id] || {}) : (cart[it.id] || 0);
return (
{editMode ? (
) : it.category && (
{it.category}
)}
{it.translated_name || it.original_name}
原文 · {it.original_name}
{it.translated_desc && (
{it.translated_desc}
)}
{it.price != null ? (
<>
¥{it.price.toLocaleString()}
≈ NT${Math.round(it.price * 0.21)}
>
) : (
價格未標示
)}
{!usingMembers && (
q === 0 ? (
) : (
{q}
)
)}
{usingMembers && !editMode && (
{members.map(m => {
const mQty = memberCart[it.id]?.[m.id] || 0;
const sel = mQty > 0;
return (
{sel && (
)}
);
})}
)}
);
})}
{totalQty > 0 && (
{totalQty}
共 {totalQty} 項
{totalPrice > 0 && (
¥{totalPrice.toLocaleString()}
)}
確認點餐 ›
)}
);
}
const qtyBtn = {
width: 28, height: 28, borderRadius: 14, border: `1px solid ${TABI_COLORS.line2}`,
background: 'transparent', color: TABI_COLORS.ink, fontSize: 16, cursor: 'pointer',
display: 'grid', placeItems: 'center', lineHeight: 1,
};
// ─────────── ORDER CONFIRM ───────────
const ORDER_LANGS = [
{ code: 'JA', label: '日文', flag: '🇯🇵', tts: 'ja-JP' },
{ code: 'EN', label: '英文', flag: '🇺🇸', tts: 'en-US' },
{ code: 'KO', label: '韓文', flag: '🇰🇷', tts: 'ko-KR' },
{ code: 'TH', label: '泰文', flag: '🇹🇭', tts: 'th-TH' },
{ code: 'VI', label: '越南文', flag: '🇻🇳', tts: 'vi-VN' },
{ code: 'FR', label: '法文', flag: '🇫🇷', tts: 'fr-FR' },
{ code: 'ES', label: '西班牙文', flag: '🇪🇸', tts: 'es-ES' },
{ code: 'IT', label: '義大利文', flag: '🇮🇹', tts: 'it-IT' },
{ code: 'DE', label: '德文', flag: '🇩🇪', tts: 'de-DE' },
];
function OrderConfirmScreen({ go, orderItems = [], targetLang = 'JA', menuId, menuTripId, tripMembers = [] }) {
const [sentence, setSentence] = React.useState(null);
const [notes, setNotes] = React.useState('');
const [lang, setLang] = React.useState(targetLang);
const [generating, setGenerating] = React.useState(false);
const [playing, setPlaying] = React.useState(false);
const [ttsPending, setTtsPending] = React.useState(false);
const [bigCard, setBigCard] = React.useState(false);
const [flipped, setFlipped] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [expSheet, setExpSheet] = React.useState(false);
const [expTitle, setExpTitle] = React.useState('餐費');
const [expAmount, setExpAmount] = React.useState('');
const [expTripId, setExpTripId] = React.useState(null);
const [expTrips, setExpTrips] = React.useState(null);
const [expMembers, setExpMembers] = React.useState([]);
const [expPaidBy, setExpPaidBy] = React.useState(null);
const [expSaving, setExpSaving] = React.useState(false);
const [expPayMode, setExpPayMode] = React.useState('together'); // together | equal | separate
const [expSettled, setExpSettled] = React.useState({}); // { memberId: bool }
const [expShowCreateTrip, setExpShowCreateTrip] = React.useState(false);
const langMeta = ORDER_LANGS.find(l => l.code === lang) || ORDER_LANGS[0];
function switchLang(code) {
if (code === lang) return;
setLang(code);
setSentence(null); // clear old sentence when language changes
_stopSpeech();
setPlaying(false);
setTtsPending(false);
}
const generateSentence = async () => {
if (generating) return;
setGenerating(true);
try {
const r = await fetch('/api/orders/sentence', {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
items: orderItems.map(i => ({ name: i.name, jp_name: i.jp_name, quantity: i.quantity })),
target_lang: lang,
notes: notes,
}),
});
if (!r.ok) throw new Error(`錯誤 ${r.status}`);
const data = await r.json();
setSentence(data.sentence);
} catch (e) {
alert('生成失敗:' + e.message);
} finally {
setGenerating(false);
}
};
const stopSpeak = () => {
_stopSpeech();
setPlaying(false);
setTtsPending(false);
};
const speak = () => {
if (!sentence) return;
_playSpeech(sentence, langMeta.tts, {
onGenerating: () => { setTtsPending(true); setPlaying(false); },
onStart: () => { setTtsPending(false); setPlaying(true); },
onEnd: () => { setTtsPending(false); setPlaying(false); },
onError: () => { setTtsPending(false); setPlaying(false); },
});
};
const total = orderItems.reduce((a, it) => a + (it.price || 0) * it.quantity, 0);
const save = async () => {
setSaving(true);
try {
await fetch('/api/orders', {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
menu_id: menuId || null,
generated_sentence: sentence,
notes: notes || null,
items: orderItems.map(i => ({
menu_item_id: i.menu_item_id || null,
item_name: i.jp_name || i.name,
quantity: i.quantity,
price_snapshot: i.price || null,
})),
}),
});
} catch (_) {}
go('home');
};
const calcMemberSplits = (members, totalAmt, forceEqual = false) => {
if (!members?.length) return {};
const shares = {};
const amt = parseInt(totalAmt) || 0;
if (forceEqual) {
const n = members.length;
const pp = Math.floor(amt / n);
const rem = amt - pp * n;
members.forEach((m, i) => { shares[m.id] = pp + (i === 0 ? rem : 0); });
} else {
const hasQtys = orderItems.some(i => i.member_qtys && Object.keys(i.member_qtys).length > 0);
const hasIds = orderItems.some(i => i.member_ids?.length > 0);
if (hasQtys) {
orderItems.forEach(item => {
if (!item.member_qtys || !item.price) return;
Object.entries(item.member_qtys).forEach(([mid, qty]) => {
shares[mid] = (shares[mid] || 0) + item.price * qty;
});
});
} else if (hasIds) {
orderItems.forEach(item => {
if (!item.member_ids?.length || !item.price) return;
const pp = Math.floor(item.price / item.member_ids.length);
const rem = item.price - pp * item.member_ids.length;
item.member_ids.forEach((mid, idx) => {
shares[mid] = (shares[mid] || 0) + pp + (idx === 0 ? rem : 0);
});
});
} else {
const n = members.length;
const pp = Math.floor(amt / n);
const rem = amt - pp * n;
members.forEach((m, i) => { shares[m.id] = pp + (i === 0 ? rem : 0); });
}
}
return shares;
};
const openExpSheet = () => {
setExpAmount(total > 0 ? String(total) : '');
setExpTripId(menuTripId || null);
setExpPaidBy(null);
setExpPayMode('together');
setExpShowCreateTrip(false);
setExpSettled({});
const initMembers = menuTripId && tripMembers.length > 0 ? tripMembers : [];
setExpMembers(initMembers);
if (!expTrips) {
fetch('/api/trips', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.json()).then(d => setExpTrips(Array.isArray(d) ? d : [])).catch(() => setExpTrips([]));
}
if (menuTripId && initMembers.length === 0) {
fetch(`/api/trips/${menuTripId}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.json()).then(d => d.members && setExpMembers(d.members)).catch(() => {});
}
setExpSheet(true);
};
const loadExpMembers = (tripId) => {
setExpPaidBy(null);
setExpMembers([]);
if (!tripId) return;
fetch(`/api/trips/${tripId}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.json()).then(d => d.members && setExpMembers(d.members)).catch(() => {});
};
const selectExpTrip = (tripId) => {
setExpTripId(tripId);
loadExpMembers(tripId);
if (menuId && tripId && tripId !== menuTripId) {
fetch(`/api/menus/${menuId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ trip_id: tripId }),
}).catch(() => {});
}
};
const submitExpense = async () => {
if (!expTripId || !expAmount) return;
if ((expPayMode === 'together' || expPayMode === 'equal') && !expPaidBy) return;
setExpSaving(true);
const token = window.tabiToken();
const splits = calcMemberSplits(expMembers, expAmount, expPayMode === 'equal');
try {
if (expPayMode === 'separate') {
await Promise.all(
Object.entries(splits).filter(([_, a]) => a > 0).map(([mid, amt]) => {
const m = expMembers.find(m => m.id === mid);
return fetch(`/api/trips/${expTripId}/expenses`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: expTitle.trim() || '餐費',
amount: amt, currency: 'JPY',
paid_by: mid, split_method: 'solo', visibility: 'shared',
}),
});
})
);
} else {
const orderResp = await fetch('/api/orders', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
menu_id: menuId || null,
generated_sentence: sentence,
notes: notes || null,
items: orderItems.map(i => ({
menu_item_id: i.menu_item_id || null,
item_name: i.jp_name || i.name,
quantity: i.quantity,
price_snapshot: i.price || null,
})),
}),
});
if (!orderResp.ok) throw new Error('儲存訂單失敗');
const { id: orderId } = await orderResp.json();
const expResp = await fetch(`/api/orders/${orderId}/to-expense`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
trip_id: expTripId,
title: expTitle.trim() || '餐費',
amount: parseInt(expAmount) || 0,
paid_by: expPaidBy,
split_method: Object.keys(splits).length > 0 ? 'custom' : 'equal',
splits: Object.keys(splits).length > 0 ? splits : null,
settled_members: Object.entries(expSettled).filter(([,v]) => v).map(([k]) => k),
}),
});
if (!expResp.ok) throw new Error('記帳失敗');
}
setExpSheet(false);
go('home');
} catch (e) {
alert(e.message);
} finally {
setExpSaving(false);
}
};
return (
go('menuResult')} />
{/* ── 點餐明細 ── */}
{orderItems.map((it, i) => {
const memberEntries = it.member_qtys
? Object.entries(it.member_qtys).filter(([, q]) => q > 0)
: [];
const hasMemberQtys = memberEntries.length > 0;
return (
×{it.quantity}
{it.price != null && (
¥{(it.price * it.quantity).toLocaleString()}
)}
{hasMemberQtys && (
{memberEntries.map(([mid, qty]) => {
const m = tripMembers.find(m => m.id === mid);
if (!m) return null;
const perPrice = it.price != null ? it.price * qty : null;
return (
{m.initial || m.display_name?.[0]}
{m.display_name}
×{qty}
{perPrice != null && (
¥{perPrice.toLocaleString()}
)}
);
})}
)}
);
})}
{total > 0 && (
合計
¥{total.toLocaleString()}
≈ NT${Math.round(total * 0.21)}
)}
{/* ── 個人小計 ── */}
{(() => {
const hasQtys = orderItems.some(i => i.member_qtys && Object.keys(i.member_qtys).length > 0);
if (!hasQtys || tripMembers.length === 0) return null;
const perPerson = {};
orderItems.forEach(item => {
if (!item.member_qtys || item.price == null) return;
Object.entries(item.member_qtys).forEach(([mid, qty]) => {
perPerson[mid] = (perPerson[mid] || 0) + item.price * qty;
});
});
const entries = Object.entries(perPerson).filter(([, v]) => v > 0);
if (entries.length === 0) return null;
return (
{entries.map(([mid, amt], i) => {
const m = tripMembers.find(m => m.id === mid);
if (!m) return null;
return (
{m.initial || m.display_name?.[0]}
{m.display_name}
¥{amt.toLocaleString()}
≈ NT${Math.round(amt * 0.21)}
);
})}
);
})()}
{/* ── 備註 ── */}
{/* ── 語言選擇 ── */}
{ORDER_LANGS.map(l => {
const on = lang === l.code;
return (
);
})}
{/* ── 生成按鈕 ── */}
{/* ── 點餐句卡片 ── */}
{sentence && (() => {
const senLen = sentence.length;
const bigFontSize = senLen > 50 ? 26 : senLen > 30 ? 36 : senLen > 18 ? 46 : 58;
return (
<>
{bigCard && ReactDOM.createPortal(
{/* top: original sentence summary */}
{orderItems.map(i => `${i.name}×${i.quantity}`).join('、')}
setBigCard(false)} style={{
width: 36, height: 36, borderRadius: 18, flexShrink: 0,
background: 'rgba(255,255,255,0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
}}>
{/* big text */}
setFlipped(f => !f)}>
{sentence}
{/* bottom controls */}
點 ✕ 關閉
,
document.body
)}
給店員看 · 給店員聽
{langMeta.flag} {langMeta.label}
{ setFlipped(true); setBigCard(true); }} style={{
fontFamily: tabiSerif, fontSize: 20, lineHeight: 1.6, fontWeight: 500,
cursor: 'pointer', position: 'relative',
}}>
{sentence}
>
);
})()}
{/* ── 底部操作 ── */}
{/* ── Expense recording sheet ── */}
{expShowCreateTrip && (
setExpShowCreateTrip(false)}
onCreated={(tripId) => {
setExpShowCreateTrip(false);
fetch('/api/trips', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.json())
.then(d => { setExpTrips(Array.isArray(d) ? d : []); selectExpTrip(tripId); })
.catch(() => {});
}}
/>
)}
{expSheet && !expShowCreateTrip && ReactDOM.createPortal((() => {
const splits = calcMemberSplits(expMembers, expAmount);
const canConfirm = !expSaving && expTripId && expAmount &&
(expPayMode === 'separate' ? expMembers.length > 0 : !!expPaidBy);
const splitPreview = calcMemberSplits(expMembers, expAmount, expPayMode === 'equal');
return (
setExpSheet(false)} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
zIndex: 9200, display: 'flex', alignItems: 'flex-end',
}}>
e.stopPropagation()} style={{
width: '100%', maxWidth: 480, margin: '0 auto',
background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0',
maxHeight: '85vh', overflowY: 'auto', WebkitOverflowScrolling: 'touch',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 24px)',
}}>
{/* Title + Amount row */}
名稱
setExpTitle(e.target.value)}
placeholder="餐費" style={{
width: '100%', padding: '11px 13px', borderRadius: 11,
border: `1px solid ${TABI_COLORS.line2}`, background: TABI_COLORS.card,
color: TABI_COLORS.ink, fontSize: 14, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box',
}}/>
金額(円)
setExpAmount(e.target.value)}
inputMode="numeric" placeholder="0" style={{
width: '100%', padding: '11px 13px', borderRadius: 11,
border: `1px solid ${TABI_COLORS.line2}`, background: TABI_COLORS.card,
color: TABI_COLORS.ink, fontSize: 14, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box',
}}/>
{/* Trip selector */}
旅程
{!expTripId && (
請先選擇旅程才能記帳
)}
{expTrips === null ? (
載入中…
) : (
{expTrips.map(t => (
))}
)}
{/* Pay mode selector */}
{expMembers.length > 0 && (
付款方式
{[['together', '依點餐分'], ['equal', '平均分'], ['separate', '各自付款']].map(([mode, label]) => (
))}
{expPayMode === 'separate' && (
依照每人點的品項各自新增記帳,不計算欠款
)}
{expPayMode === 'equal' && (
不管各自點什麼,總金額平均分攤給所有人
)}
)}
{/* Payer (together / equal mode) */}
{expMembers.length > 0 && (expPayMode === 'together' || expPayMode === 'equal') && (
誰先付帳?
{expMembers.map(m => (
))}
)}
{/* Per-member split preview */}
{expMembers.length > 0 && Object.keys(splitPreview).length > 0 && (
{expPayMode === 'separate' ? '各自記帳金額' : '各自負擔'}
{expMembers.map(m => {
const amt = splitPreview[m.id] || 0;
const isPayer = m.id === expPaidBy;
const isSettled = isPayer || !!expSettled[m.id];
const showToggle = expPayMode !== 'separate' && !isPayer;
return (
{m.initial || m.display_name?.[0]}
{m.display_name}
¥{amt.toLocaleString()}
{isPayer ? (
付款人
) : showToggle ? (
) : null}
);
})}
)}
{/* Confirm */}
);
})(), document.body)}
);
}
function PlayingBars() {
return (
{[0, 1, 2, 3].map(i => (
))}
);
}
// ─────────── VOICE CHAT ───────────
const VOICE_LANGS = [
{ code: 'ZH', zh: '中文', name: '繁體中文', tts: 'zh-TW', asr: 'zh', flag: '🇹🇼' },
{ code: 'JA', zh: '日文', name: '日本語', tts: 'ja-JP', asr: 'ja', flag: '🇯🇵' },
{ code: 'EN', zh: '英文', name: 'English', tts: 'en-US', asr: 'en', flag: '🇬🇧' },
{ code: 'KO', zh: '韓文', name: '한국어', tts: 'ko-KR', asr: 'ko', flag: '🇰🇷' },
{ code: 'TH', zh: '泰文', name: 'ภาษาไทย', tts: 'th-TH', asr: 'th', flag: '🇹🇭' },
{ code: 'VI', zh: '越南文', name: 'Tiếng Việt', tts: 'vi-VN', asr: 'vi', flag: '🇻🇳' },
{ code: 'FR', zh: '法文', name: 'Français', tts: 'fr-FR', asr: 'fr', flag: '🇫🇷' },
{ code: 'ES', zh: '西班牙文', name: 'Español', tts: 'es-ES', asr: 'es', flag: '🇪🇸' },
{ code: 'IT', zh: '義大利文', name: 'Italiano', tts: 'it-IT', asr: 'it', flag: '🇮🇹' },
{ code: 'DE', zh: '德文', name: 'Deutsch', tts: 'de-DE', asr: 'de', flag: '🇩🇪' },
];
function VoiceLangSheet({ open, current, onSelect, onClose, title }) {
if (!open) return null;
return (
e.stopPropagation()} style={{
width: '100%', background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0',
padding: '12px 0 32px', maxHeight: '72%', overflowY: 'auto',
}}>
{title}
{VOICE_LANGS.map(l => (
))}
);
}
function VoiceChatScreen({ go }) {
const [msgs, setMsgs] = React.useState([]);
const [myLang, setMyLang] = React.useState('ZH');
const [theirLang, setTheirLang] = React.useState('JA');
const [langSheet, setLangSheet] = React.useState(null); // 'me' | 'them'
const [recording, setRecording] = React.useState(null);
const [transcribing, setTranscribing] = React.useState(null);
const [translating, setTranslating] = React.useState(null);
const [myDraft, setMyDraft] = React.useState('');
const [themDraft, setThemDraft] = React.useState('');
const [error, setError] = React.useState(null);
const [saved, setSaved] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [pendingNav, setPendingNav] = React.useState(null); // fn to call after confirm
const mediaRecorderRef = React.useRef(null);
const chunksRef = React.useRef([]);
const chatRef = React.useRef(null);
const msgsRef = React.useRef(msgs);
const savedRef = React.useRef(saved);
React.useEffect(() => { msgsRef.current = msgs; }, [msgs]);
React.useEffect(() => { savedRef.current = saved; }, [saved]);
React.useEffect(() => {
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
}, [msgs, recording, transcribing, translating]);
// Register navigation guard — intercepts tab changes while msgs exist
React.useEffect(() => {
window._tabiVoiceGuard = (navigateFn) => {
if (msgsRef.current.length > 0 && !savedRef.current) {
setPendingNav(() => navigateFn);
} else {
navigateFn();
}
};
return () => { window._tabiVoiceGuard = null; };
}, []);
const guardedGo = (dest) => {
if (msgs.length > 0 && !saved) {
setPendingNav(() => () => go(dest));
} else {
go(dest);
}
};
const saveConversation = async () => {
if (saving || msgs.length === 0) return;
setSaving(true);
setError(null);
try {
// Generate title with LLM
let title = msgs[0].orig.slice(0, 24);
try {
const tr = await fetch('/api/voice/summarize-title', {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json', ...window.tabiLLMHeaders() },
body: JSON.stringify({
messages: msgs.map(m => ({ direction: m.from === 'me' ? 'outbound' : 'inbound', original_text: m.orig })),
source_lang: myLang,
target_lang: theirLang,
}),
});
if (tr.ok) { const d = await tr.json(); if (d.title) title = d.title; }
} catch (_) { /* keep fallback title */ }
const r1 = await fetch('/api/voice/conversations', {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ title, source_lang: myLang, target_lang: theirLang }),
});
if (!r1.ok) throw new Error('建立對話失敗');
const { id } = await r1.json();
await Promise.all(msgs.map(m =>
fetch(`/api/voice/conversations/${id}/messages`, {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
direction: m.from === 'me' ? 'outbound' : 'inbound',
original_text: m.orig,
translated_text: m.tr,
}),
})
));
setSaved(true);
} catch (e) {
setError('儲存失敗:' + e.message);
}
setSaving(false);
};
const callTranslate = async (text, from) => {
if (!text.trim()) return;
setTranslating(from);
setError(null);
try {
const history = msgs.map(m => ({
from_lang: m.from === 'me' ? myLang : theirLang,
original: m.orig,
translation: m.tr,
}));
const r = await fetch('/api/voice/translate', {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json', ...window.tabiLLMHeaders() },
body: JSON.stringify({
text: text.trim(),
from_lang: from === 'me' ? myLang : theirLang,
to_lang: from === 'me' ? theirLang : myLang,
history,
}),
});
if (!r.ok) throw new Error(`翻譯失敗 ${r.status}`);
const data = await r.json();
setMsgs(prev => [...prev, { from, orig: text.trim(), tr: data.translation }]);
setSaved(false);
} catch (e) {
setError(e.message);
}
setTranslating(null);
};
const blobToWav = async (blob) => {
const ctx = new AudioContext();
const ab = await blob.arrayBuffer();
const audioBuf = await ctx.decodeAudioData(ab);
await ctx.close();
const samples = audioBuf.getChannelData(0);
const sr = audioBuf.sampleRate;
const n = samples.length;
const buf = new ArrayBuffer(44 + n * 2);
const v = new DataView(buf);
const ws = (off, s) => { for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); };
ws(0,'RIFF'); v.setUint32(4, 36 + n*2, true); ws(8,'WAVE');
ws(12,'fmt '); v.setUint32(16,16,true); v.setUint16(20,1,true); v.setUint16(22,1,true);
v.setUint32(24,sr,true); v.setUint32(28,sr*2,true); v.setUint16(32,2,true); v.setUint16(34,16,true);
ws(36,'data'); v.setUint32(40, n*2, true);
for (let i = 0; i < n; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
v.setInt16(44 + i*2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return new Blob([buf], { type: 'audio/wav' });
};
const transcribeAndTranslate = async (blob, mimeType, side) => {
setTranscribing(side);
setError(null);
let text;
try {
// Convert to WAV — many ASR APIs can't parse WebM duration without a full EBML parser
let sendBlob = blob;
try { sendBlob = await blobToWav(blob); } catch (_) { /* fallback to original */ }
const fd = new FormData();
const langCode = (side === 'me' ? myLang : theirLang);
const langInfo = VOICE_LANGS.find(l => l.code === langCode) || VOICE_LANGS[0];
fd.append('audio', sendBlob, 'audio.wav');
fd.append('lang', langInfo.asr);
const r = await fetch('/api/voice/transcribe', {
method: 'POST',
headers: { Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() },
body: fd,
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail || `語音辨識失敗 ${r.status}`);
}
const data = await r.json();
text = data.text?.trim();
} catch (e) {
setError(e.message);
setTranscribing(null);
return;
}
setTranscribing(null);
if (!text) { setError('未偵測到內容,請再試一次'); return; }
await callTranslate(text, side);
};
const startRecording = async (side) => {
// Stop if already recording this side
if (recording === side) { mediaRecorderRef.current?.stop(); return; }
setError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus', '']
.find(t => !t || MediaRecorder.isTypeSupported(t));
const mr = new MediaRecorder(stream, mimeType ? { mimeType } : {});
chunksRef.current = [];
mr.ondataavailable = e => { if (e.data.size > 0) chunksRef.current.push(e.data); };
mr.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
setRecording(null);
const blob = new Blob(chunksRef.current, { type: mr.mimeType || 'audio/webm' });
if (blob.size < 500) { setError('錄音太短,請再試一次'); return; }
await transcribeAndTranslate(blob, mr.mimeType, side);
};
mr.start(100);
mediaRecorderRef.current = mr;
setRecording(side);
} catch (e) {
if (e.name === 'NotAllowedError') setError('請允許麥克風權限後再試');
else setError('無法啟動麥克風:' + e.message);
}
};
const sendText = (side) => {
const text = side === 'me' ? myDraft : themDraft;
if (!text.trim() || translating || transcribing || recording) return;
side === 'me' ? setMyDraft('') : setThemDraft('');
callTranslate(text, side);
};
const myLangInfo = VOICE_LANGS.find(l => l.code === myLang) || VOICE_LANGS[0];
const theirLangInfo = VOICE_LANGS.find(l => l.code === theirLang) || VOICE_LANGS[1];
const rows = [
{ side: 'me', draft: myDraft, setDraft: setMyDraft, placeholder: `輸入${myLangInfo.zh}…`, tone: TABI_COLORS.red },
{ side: 'them', draft: themDraft, setDraft: setThemDraft, placeholder: `輸入${theirLangInfo.zh}…`, tone: TABI_COLORS.blue },
];
return (
guardedGo('home')}
right={
{msgs.length > 0 && !saved && (
)}
{saved && (
✓ 已儲存
)}
}
/>
{/* lang bar — clickable to change language */}
⇄
{/* leave-confirmation modal */}
{pendingNav && (
離開對話?
對話尚未儲存,離開後將無法復原。
)}
{/* lang picker sheets */}
setLangSheet(null)}
title="我說的語言"
/>
setLangSheet(null)}
title="對方說的語言"
/>
{/* chat area */}
{msgs.length === 0 && (
🗣️
我方或對方點 🎤 錄音,或直接打字
翻譯結果會大字顯示給對方看
)}
{msgs.map((m, i) =>
)}
{/* input panel — two clearly labelled sections */}
{rows.map((row, idx) => {
const isRecording = recording === row.side;
const busySomething = !!translating || !!transcribing || (!!recording && !isRecording);
const canSend = !!row.draft.trim() && !translating && !transcribing && !recording;
const langInfo = row.side === 'me' ? myLangInfo : theirLangInfo;
return (
0 ? `1px solid ${TABI_COLORS.line}` : 'none',
}}>
{/* section label */}
{row.side === 'me' ? '我方' : '對方'}
{langInfo.flag} {langInfo.zh}
{isRecording && (
聆聽中… (再點停止)
)}
{/* input row */}
row.setDraft(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendText(row.side)}
placeholder={row.placeholder}
disabled={isRecording || !!translating}
style={{
flex: 1, minWidth: 0, padding: '10px 12px', borderRadius: 12,
border: `1.5px solid ${isRecording ? row.tone : TABI_COLORS.line2}`,
background: TABI_COLORS.card, fontSize: 16, color: TABI_COLORS.ink,
outline: 'none', fontFamily: 'inherit', touchAction: 'manipulation',
transition: 'border-color 0.15s',
}}
/>
);
})}
{(transcribing || translating) && (
{transcribing ? '辨識中…' : '翻譯中…'}
)}
{error && (
{error}
)}
);
}
function Bubble({ m, myLang, theirLang }) {
const [bigCard, setBigCard] = React.useState(false);
const [flipped, setFlipped] = React.useState(true);
const [speaking, setSpeaking] = React.useState(false);
const [ttsPending, setTtsPending] = React.useState(false);
const me = m.from === 'me';
const srcCode = me ? myLang : theirLang;
const tgtCode = me ? theirLang : myLang;
const srcInfo = VOICE_LANGS.find(l => l.code === srcCode) || VOICE_LANGS[0];
const tgtInfo = VOICE_LANGS.find(l => l.code === tgtCode) || VOICE_LANGS[1];
const stopSpeak = () => {
_stopSpeech();
setSpeaking(false);
setTtsPending(false);
};
const speak = (text, tts) => {
_playSpeech(text, tts, {
onGenerating: () => { setTtsPending(true); setSpeaking(false); },
onStart: () => { setTtsPending(false); setSpeaking(true); },
onEnd: () => { setTtsPending(false); setSpeaking(false); },
onError: () => { setTtsPending(false); setSpeaking(false); },
});
};
const trLen = (m.tr || '').length;
const bigFontSize = trLen > 50 ? 26 : trLen > 30 ? 36 : trLen > 18 ? 46 : 58;
return (
{bigCard && ReactDOM.createPortal(
{/* ── holder's controls (top, normal orientation) ── */}
{/* original text — for holder to confirm */}
{m.orig}
{/* close X */}
setBigCard(false)} style={{
width: 36, height: 36, borderRadius: 18, flexShrink: 0,
background: 'rgba(255,255,255,0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
}}>
{/* ── big translation — rotatable ── */}
{/* ── holder's bottom controls (normal orientation) ── */}
點 ✕ 關閉
,
document.body
)}
{me ? '我' : '對方'} · {srcInfo.flag} {srcInfo.zh}
{/* original text — muted */}
{m.orig}
{/* translation — clickable to expand */}
setBigCard(true)} style={{
padding: '10px 16px 8px',
fontFamily: tabiSerif, fontSize: 20, fontWeight: 600, lineHeight: 1.55,
color: me ? '#fff' : TABI_COLORS.ink,
cursor: 'pointer', position: 'relative',
}}>
{m.tr}
{/* expand hint icon */}
{/* footer: play button + expand button + lang label */}
{tgtInfo.zh} · {tgtCode}
);
}
// ─────────── VOICE HISTORY ───────────
function VoiceHistoryScreen({ go, onOpen }) {
const [list, setList] = React.useState(null);
const [confirmDel, setConfirmDel] = React.useState(null);
const [deleting, setDeleting] = React.useState(false);
const load = () => {
fetch('/api/voice/conversations', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.json()).then(d => setList(Array.isArray(d) ? d : [])).catch(() => setList([]));
};
React.useEffect(() => { load(); }, []);
const remove = async (id) => {
setDeleting(true);
await fetch(`/api/voice/conversations/${id}`, {
method: 'DELETE', headers: { Authorization: `Bearer ${window.tabiToken()}` },
}).catch(() => {});
setDeleting(false);
setConfirmDel(null);
load();
};
const LANG_FLAG = { ZH:'🇹🇼', JA:'🇯🇵', EN:'🇬🇧', KO:'🇰🇷', TH:'🇹🇭', VI:'🇻🇳', FR:'🇫🇷', ES:'🇪🇸', IT:'🇮🇹', DE:'🇩🇪' };
return (
go('voice')} />
{list === null ? (
) : list.length === 0 ? (
💬
還沒有儲存的對話
) : list.map(c => (
onOpen(c.id)} style={{
flex: 1, minWidth: 0, cursor: 'pointer',
}}>
{LANG_FLAG[c.source_lang] || '🌐'}
{c.source_lang} → {c.target_lang}
{LANG_FLAG[c.target_lang] || '🌐'}
{c.title || '未命名對話'}
{_fmtDate(c.updated_at)}
))}
{confirmDel && (
setConfirmDel(null)} style={{
position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)',
zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}}>
e.stopPropagation()} style={{
background: TABI_COLORS.bg, borderRadius: 18, padding: '22px', width: '100%', maxWidth: 280,
}}>
刪除這段對話?
刪除後將無法復原。
)}
);
}
function VoiceDetailScreen({ go, convId }) {
const [conv, setConv] = React.useState(null);
React.useEffect(() => {
if (!convId) return;
fetch(`/api/voice/conversations/${convId}`, {
headers: { Authorization: `Bearer ${window.tabiToken()}` },
}).then(r => r.json()).then(setConv).catch(() => {});
}, [convId]);
if (!conv) return (
);
return (
go('voiceHistory')}
/>
{conv.messages.map(m => (
))}
);
}
const tinyBtn = {
fontSize: 10.5, padding: '4px 8px', borderRadius: 999,
border: `1px solid ${TABI_COLORS.line}`, background: 'transparent',
color: TABI_COLORS.ink3, cursor: 'pointer', display: 'inline-flex',
alignItems: 'center', gap: 4,
};
function SpeakBtn({ label, subtitle, tone, active, disabled, onClick }) {
return (
);
}
// ─────────── RECEIPT RESULT ───────────
function ReceiptResultScreen({ go }) {
const items = [
{ jp: 'おにぎり 鮭', zh: '飯糰 · 鮭魚', price: 180, qty: 2 },
{ jp: 'からあげ棒', zh: '炸雞棒', price: 168, qty: 1 },
{ jp: 'ペプシ 500ml', zh: '百事可樂 500ml', price: 158, qty: 1 },
{ jp: 'ホットコーヒー L', zh: '熱咖啡 L', price: 200, qty: 1 },
];
const subtotal = items.reduce((a, b) => a + b.price * b.qty, 0);
const tax = Math.round(subtotal * 0.1);
const total = subtotal + tax;
return (
go('home')}
right={}
/>
{/* receipt summary */}
Lawson 新宿東口店
2026/05/06 · 14:02 · 東京都
{/* totals */}
合計 TOTAL
¥{total.toLocaleString()}
≈ 新台幣
NT${Math.round(total * 0.21)}
{/* breakdown */}
{items.map((it, i) => (
×{it.qty}
¥{(it.price * it.qty).toLocaleString()}
))}
{[
['小計 / 不含稅', `¥${subtotal.toLocaleString()}`],
['消費税 10%', `¥${tax.toLocaleString()}`],
].map(([k, v], i) => (
{k}{v}
))}
{/* trip totals card */}
累積 12 張發票
¥48,520 ≈ NT$10,189
{/* category bar */}
{[
['餐飲 38%', TABI_COLORS.red],
['便利店 24%', TABI_COLORS.green],
['交通 20%', TABI_COLORS.blue],
['購物 18%', TABI_COLORS.warn],
].map(([t, c]) => (
{t}
))}
);
}
// ─────────── SIGN RESULT (拍照翻譯) ───────────
function SignResultScreen({ go, signData }) {
const [playing, setPlaying] = React.useState(false);
const [ttsPending, setTtsPending] = React.useState(false);
const [speakIdx, setSpeakIdx] = React.useState(null); // global segment index
const [pendingIdx, setPendingIdx] = React.useState(null);
// Normalise: support multi-photo {photos:[...]}, single-photo {segments,summary,note}, legacy {original,translation}
const photos = React.useMemo(() => {
if (signData?.photos?.length) return signData.photos;
if (signData?.segments) return [{ segments: signData.segments, summary: signData.summary, note: signData.note, image_url: signData.image_url }];
if (signData?.original) {
const origs = signData.original.split(/\s*\/\s*/).map(s => s.trim()).filter(Boolean);
const trans = (signData.translation || '').split(/\s*\/\s*/).map(s => s.trim());
return [{ segments: origs.map((orig, i) => ({ orig, tran: trans[i] || '' })), image_url: null }];
}
return [];
}, [signData]);
const allSegments = React.useMemo(() => photos.flatMap(p => p.segments || []), [photos]);
const multiPhoto = photos.length > 1;
// Compute global index offset for each photo
const photoOffset = (photoIdx) => photos.slice(0, photoIdx).reduce((s, p) => s + (p.segments || []).length, 0);
function stopSpeech() {
_stopSpeech();
setPlaying(false);
setTtsPending(false);
setSpeakIdx(null);
setPendingIdx(null);
}
function speakAll() {
const text = allSegments.map(s => s.orig).join('。');
if (!text) return;
setSpeakIdx(null);
_playSpeech(text, 'ja-JP', {
rate: 0.9,
onGenerating: () => { setTtsPending(true); setPlaying(false); },
onStart: () => { setTtsPending(false); setPlaying(true); },
onEnd: () => { setTtsPending(false); setPlaying(false); },
onError: () => { setTtsPending(false); setPlaying(false); },
});
}
function speakOne(globalIdx, text) {
if (speakIdx === globalIdx || pendingIdx === globalIdx) { stopSpeech(); return; }
if (!text) return;
setPlaying(false);
_playSpeech(text, 'ja-JP', {
rate: 0.9,
onGenerating: () => { setPendingIdx(globalIdx); setSpeakIdx(null); },
onStart: () => { setPendingIdx(null); setSpeakIdx(globalIdx); },
onEnd: () => { setPendingIdx(null); setSpeakIdx(null); },
onError: () => { setPendingIdx(null); setSpeakIdx(null); },
});
}
const origFontSize = (text) => {
if (!text) return 20;
if (text.length <= 10) return 22;
if (text.length <= 20) return 18;
if (text.length <= 35) return 16;
return 14;
};
if (!signData) return (
go('home')} />
📷
拍攝路標、看板、標示等任意文字
AI 即時翻譯成中文
);
// Overall overview for the top card
const overviewText = multiPhoto
? `共 ${photos.length} 張照片,合計 ${allSegments.length} 段文字`
: (photos[0]?.summary || photos[0]?.note || `辨識到 ${allSegments.length} 段文字`);
// Render one translation card (used per-photo in multi, or once in single)
function SegmentCard({ segs, globalOffset }) {
return (
{segs.length} 段文字
{!multiPhoto && (
)}
{segs.map((seg, i) => {
const gi = globalOffset + i;
return (
0 ? `1px solid ${TABI_COLORS.line}` : 'none',
display: 'flex', alignItems: 'flex-start', gap: 10,
}}>
{seg.orig}
{seg.tran && (
35 ? 13 : 14, color: TABI_COLORS.ink2, lineHeight: 1.6 }}>
{seg.tran}
)}
);
})}
);
}
return (
go('home')}
right={
}
/>
{/* ① Overview card */}
📋
{multiPhoto && (
)}
{/* ② Photo sections */}
{photos.map((photo, pi) => (
{/* Per-photo header when multiple */}
{multiPhoto && (
第 {pi + 1} 張
{photo.summary && (
<>
{photo.summary}
>
)}
)}
{/* Photo thumbnail — full image, no crop */}
{photo.image_url && (
)}
{/* Note for this photo */}
{photo.note && (
💡{photo.note}
)}
{/* Translation card for this photo */}
))}
{/* ③ Action buttons */}
);
}
// ─────────── SIGN CHAT SCREEN ────────────────────────────────
function SignChatScreen({ go, signData }) {
const [messages, setMessages] = React.useState([]);
const [input, setInput] = React.useState('');
const [loading, setLoading] = React.useState(false);
const bottomRef = React.useRef();
React.useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, loading]);
async function send() {
const text = input.trim();
if (!text || loading) return;
setInput('');
const next = [...messages, { role: 'user', content: text }];
setMessages(next);
setLoading(true);
try {
const r = await fetch('/api/scan/sign-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() },
body: JSON.stringify({
segments: signData?.segments || [],
summary: signData?.summary || '',
note: signData?.note || '',
history: messages,
message: text,
}),
});
if (!r.ok) throw new Error('請求失敗');
const data = await r.json();
setMessages(prev => [...prev, { role: 'assistant', content: data.reply }]);
} catch {
setMessages(prev => [...prev, { role: 'assistant', content: '抱歉,發生錯誤,請再試一次。' }]);
} finally {
setLoading(false);
}
}
const summary = signData?.summary || signData?.note || '';
return (
go('signResult')} />
{/* Context chip */}
{summary ? (
) : null}
{/* Messages */}
{messages.length === 0 && !loading && (
💬
可以問我關於這張照片的任何問題
例:這裡是什麼地方?這個標示什麼意思?
)}
{messages.map((m, i) => (
))}
{loading && (
)}
{/* Input bar */}
setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
placeholder="問關於這張照片的問題…"
style={{
flex: 1, padding: '11px 14px', borderRadius: 14,
border: `1.5px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg,
fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit',
}}
/>
);
}
// ─────────── PHRASES SCREEN ─────────────────────────────────
const PHRASE_CATS = ['全部', '禮貌', '溝通', '問路', '交通', '住宿', '餐廳', '購物', '便利商店', '藥妝', '緊急'];
function PhrasesScreen({ go }) {
const [phrases, setPhrases] = React.useState([]);
const [activeCat, setActiveCat] = React.useState('全部');
const [speaking, setSpeaking] = React.useState(null);
const [ttsPending, setTtsPending] = React.useState(null);
const [search, setSearch] = React.useState('');
React.useEffect(() => {
fetch('/api/phrases', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.ok ? r.json() : [])
.then(d => setPhrases(Array.isArray(d) ? d : []))
.catch(() => {});
}, []);
function handleSpeak(id, text) {
if (speaking === id || ttsPending === id) {
_stopSpeech();
setSpeaking(null);
setTtsPending(null);
return;
}
_speakJP(text, {
onGenerating: () => { setTtsPending(id); setSpeaking(null); },
onStart: () => { setTtsPending(null); setSpeaking(id); },
onEnd: () => { setTtsPending(null); setSpeaking(s => s === id ? null : s); },
onError: () => { setTtsPending(null); setSpeaking(s => s === id ? null : s); },
});
}
const q = search.trim().toLowerCase();
const visible = phrases.filter(p => {
const catOk = activeCat === '全部' || p.category === activeCat;
const searchOk = !q || p.japanese.toLowerCase().includes(q) || p.chinese.includes(q);
return catOk && searchOk;
});
// Derive used categories from actual data (keep canonical order)
const usedCats = PHRASE_CATS.filter(c => c === '全部' || phrases.some(p => p.category === c));
return (
go('home')} />
{/* Search bar */}
setSearch(e.target.value)}
placeholder="搜尋句子…"
style={{
flex: 1, border: 'none', outline: 'none', padding: '11px 0',
background: 'transparent', fontSize: 14, color: TABI_COLORS.ink,
fontFamily: tabiFont,
}}
/>
{search && (
)}
{/* Category tabs */}
{usedCats.map(cat => {
const on = activeCat === cat;
return (
);
})}
{/* Phrase list */}
{phrases.length === 0 ? (
載入中…
) : visible.length === 0 ? (
沒有符合的句子
) : (
{visible.map((p, i) => (
{activeCat === '全部' && (
{p.category}
)}
))}
)}
);
}
Object.assign(window, {
TABI_COLORS, tabiFont, tabiSerif,
TabiTopBar, TabiTabBar,
HomeIcon, MenuIcon, VoiceIcon, ReceiptIcon, LogIcon, WalletIcon, CameraIcon, MicIcon, PlayIcon, StopIcon, CheckIcon,
HomeScreen, CameraScreen, MenuResultScreen, OrderConfirmScreen,
VoiceChatScreen, VoiceHistoryScreen, VoiceDetailScreen,
ReceiptResultScreen, SignResultScreen, SignChatScreen, PhrasesScreen,
});