// 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 (
);
}
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) => (
))}
>
)}
{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;