// Page 3: 對話列表 + 檢視器 (light theme, all Chinese) function PageConversations({ filters, density, selectedThreadId, setSelectedThreadId }) { const { CONVERSATIONS } = window.MOCK; const [preset, setPreset] = React.useState('all'); const [search, setSearch] = React.useState(''); const presets = [ { id: 'all', label: '全部', filter: () => true }, { id: 'looped', label: '迴圈警示', filter: c => c.status === 'looped', color: '#B45309' }, { id: 'long', label: '過長 (>20 訊息)', filter: c => c.status === 'long' || c.msgCount > 20, color: '#6D28D9' }, { id: 'errored', label: '錯誤結束', filter: c => c.status === 'errored', color: '#DC2626' }, { id: 'abandoned', label: '中途中斷', filter: c => c.status === 'abandoned', color: '#9098A3' }, { id: 'expensive', label: '高費用 (>$0.50)', filter: c => c.cost > 0.5, color: '#DB2777' }, ]; const activePreset = presets.find(p => p.id === preset); const filtered = CONVERSATIONS.filter(activePreset.filter).filter(c => { if (!search) return true; return c.title.toLowerCase().includes(search.toLowerCase()) || c.brand_id.includes(search.toLowerCase()) || c.user_admin.toLowerCase().includes(search.toLowerCase()); }); const selected = CONVERSATIONS.find(c => c.thread_id === selectedThreadId) || filtered[0]; return (
對話檢視器
共 {CONVERSATIONS.length} 筆 · 已篩選 {filtered.length} 筆
setSearch(e.target.value)} placeholder="搜尋標題、品牌、使用者..." style={{ width: '100%', padding: '6px 10px', fontSize: 12, background: '#FFFFFF', border: '1px solid #E5E5E0', borderRadius: 4, color: '#17171A', outline: 'none', }} />
{presets.map(p => ( ))}
{filtered.slice(0, 80).map(c => ( setSelectedThreadId(c.thread_id)} /> ))}
{selected && }
); } // 軟刪 icon — 使用者刪了,admin 仍能讀。垃圾桶 SVG inline,避免拉 icon library。 function DeletedIcon({ size = 11, color = '#9098A3' }) { return ( ); } function ConvoListItem({ convo, active, onClick }) { const statusColor = { completed: '#15803D', errored: '#DC2626', looped: '#B45309', long: '#6D28D9', abandoned: '#9098A3', }[convo.status]; const statusLabel = { completed: '完成', errored: '錯誤', looped: '迴圈', long: '過長', abandoned: '中斷' }[convo.status]; const isDeleted = !!convo.deleted_at; const ago = (() => { const mins = Math.floor((Date.now() - convo.created_at.getTime()) / 60000); if (mins < 60) return mins + ' 分前'; const hrs = Math.floor(mins / 60); if (hrs < 24) return hrs + ' 小時前'; return Math.floor(hrs / 24) + ' 天前'; })(); const deletedTooltip = isDeleted ? `使用者於 ${convo.deleted_at.toISOString().slice(0, 19).replace('T', ' ')} UTC 刪除(admin 仍可閱讀)` : ''; return ( ); } function ConvoViewer({ convo }) { // 真實 thread:useEffect 拉 /api/conversations/:thread_id(從 LangGraph checkpointer 撈) const [messages, setMessages] = React.useState(null); // null = loading, [] = 空, [...] = ok const [error, setError] = React.useState(null); const [showRaw, setShowRaw] = React.useState(null); const [selectedTurn, setSelectedTurn] = React.useState(null); React.useEffect(() => { let alive = true; setMessages(null); setError(null); setSelectedTurn(null); if (!convo || !convo.thread_id) return; window.AdminAPI.conversations.get(convo.thread_id) .then((data) => { if (!alive) return; const normalized = window.AdminDataLoader.normalizeMessages((data && data.messages) || []); setMessages(normalized); }) .catch((e) => { if (!alive) return; console.error('load thread failed', e); setError(e.message || '載入失敗'); setMessages([]); }); return () => { alive = false; }; }, [convo && convo.thread_id]); const full = { ...convo, messages: messages || [], // cacheHit 從 conversations.list 帶下來(real:cache_read / (input + cache_read)) // avgLatency 後端尚未 instrument per-message latency avgLatency: null, errors: error ? [{ code: 'LOAD_ERROR', message: error }] : null, }; return (
{convo.deleted_at && ( )} {convo.title} {convo.deleted_at && ( 已刪 )} {convo.status !== 'completed' && ( {{ errored: '錯誤', looped: '迴圈', long: '過長', abandoned: '中斷' }[convo.status]} )}
{convo.thread_id} · {convo.brand_display || convo.brand_id} · {convo.user_admin} · {convo.created_at.toISOString().slice(0, 19).replace('T', ' ')} UTC
{messages == null ? (
載入對話中...
) : error ? (
對話載入失敗:{error}
) : messages.length === 0 ? (
此 thread 沒有訊息(可能尚未存進 checkpointer)
) : messages.map((m, i) => ( setShowRaw(showRaw === i ? null : i)} selected={selectedTurn === i} onSelect={() => setSelectedTurn(selectedTurn === i ? null : i)} /> ))}
); } function Message({ msg, idx, convo, showRaw, setShowRaw, selected, onSelect }) { const roleCfg = { system: { bg: '#F0F0EE', border: '#D4D4CF', label: '系統提示', color: '#5C6470' }, user: { bg: '#DBEAFE', border: '#93C5FD', label: '使用者', color: '#1D4ED8' }, assistant: { bg: '#FFFFFF', border: '#E5E5E0', label: 'AI 助理', color: '#17171A' }, tool: { bg: '#EDE9FE', border: '#C4B5FD', label: '工具結果', color: '#6D28D9' }, }[msg.role]; return (
{roleCfg.label} turn {idx} · {msg.timestamp} {msg.model && <>·{msg.model}} {msg.tokens && <>·{msg.tokens.input}↓ {msg.tokens.output}↑} {msg.cost && <>· 0.05 ? '#B45309' : '#5C6470' }}>{fmtUSD(msg.cost, 4)}} {msg.latency_ms && <>·{msg.latency_ms}ms}
{msg.content &&
{msg.content}
} {msg.tool_calls && msg.tool_calls.map((tc, ti) => ( ))} {/* role=tool 已在 normalize 階段被 merge 進上一則 assistant 的 tool_calls[].result, 這裡不再有 standalone tool message 要渲染;保留 fallback 防舊資料 */} {showRaw && (
{JSON.stringify(msg, null, 2)}
)}
); } function ToolResultBlock({ result, hasContent }) { const [open, setOpen] = React.useState(false); const preview = result.preview || ''; const charCount = preview.length; return (
{open && (
{preview}
)}
); } function ToolCallBlock({ tc }) { const [open, setOpen] = React.useState(false); // tc.result 由 normalize 階段 merge 自 ToolMessage(依 tool_call_id 配對) const argsKeys = tc.args && typeof tc.args === 'object' ? Object.keys(tc.args) : []; const argsJSON = argsKeys.length ? JSON.stringify(tc.args, null, 2) : '(無參數)'; const outputText = tc.result && tc.result.preview ? tc.result.preview : null; const outputLen = outputText ? outputText.length : 0; return (
{open && (
{outputText != null && ( )}
)}
); } function ToolPanel({ label, json, accentColor, preformatted }) { return (
{label}
{json}
); } function ConvoSidebar({ convo, selectedTurn }) { // 真實值來自 conversations.list 聚合(agent_token_usage SUM)。 // checkpointer 沒帶 per-message token / cost / latency,所以 sidebar 用整體聚合。 const totalIn = +(convo.tokensInput || 0); const totalOut = +(convo.tokensOutput || 0); const cacheRead = +(convo.tokensCacheRead || 0); const cacheCreate = +(convo.tokensCacheCreate || 0); const cacheHit = +(convo.cacheHit || 0); const tools = {}; convo.messages.forEach(m => { (m.tool_calls || []).forEach(tc => { tools[tc.name] = (tools[tc.name] || 0) + 1; }); }); const turn = selectedTurn != null ? convo.messages[selectedTurn] : null; return (
對話總計 使用工具 {Object.entries(tools).map(([name, count]) => ( ))} {Object.keys(tools).length === 0 &&
未使用工具
} {convo.errors && convo.errors.length > 0 && ( <> 錯誤 {convo.errors.map((e, i) => (
{e.code}
{e.message}
))} )} {turn && turn.tokens && ( <> 已選 turn {selectedTurn} {turn.tokens.cache_read && } {turn.cost && } {turn.latency_ms && } )}
); } function SectionHeader({ children, style }) { return (
{children}
); } function Row({ label, value, mono, small, highlight }) { return (
{label} {value}
); } window.PageConversations = PageConversations;