// data-loader.jsx — 把後端 admin API 拉成 mock-shape,pages 元件大致不需改。 // // 為什麼用 mock-shape adapter 而非每 page 各自 useEffect: // - mock-shape 子元件(BrandsTable, ConvoListItem, KPICard...)依賴的欄位很多 // (tier / dailyCost array / status / anomaly...),後端目前回不全 // - 在這層補 fallback 值,pages 一行不動 → MVP 上線最快 // - 後續逐欄補真:例如 plan_tier 從 MySQL.brand 拿到了,就在這裡換掉 fallback // // 暴露: // window.AdminDataLoader.load(range) → 把 normalized data 寫到 window.MOCK + window.LIVE // window.LIVE = 各 endpoint 原始 JSON(給需要 delta % 的 KPI cards 用) // window.MOCK = mock-shape normalized data // window.AdminDataLoader.normalizeMessages(msgs) → LangChain → mock viewer shape (function () { const PROVIDERS = [ { match: /claude/i, provider: 'anthropic' }, { match: /gpt|openai/i, provider: 'openai' }, { match: /gemini/i, provider: 'google' }, ]; function providerOf(model) { if (!model) return 'unknown'; for (const p of PROVIDERS) if (p.match.test(model)) return p.provider; return 'other'; } // 後端 thread_id 是長 UUID-ish;admin viewer 想顯示成 mock-style 的 'thread_xxx', // 我們直接拿 thread_id 字串。 function statusFromConvo(c) { if (c.total_tool_calls > 20) return 'looped'; if ((c.message_count || 0) > 50) return 'long'; // 'errored' / 'abandoned' 暫無偵測訊號 — 預設 completed return 'completed'; } function normalizeBrands(topBrands, brandSeriesMap, days, globalCacheHit) { const DAYS = Math.max(days.length, 1); return topBrands.map((b) => { const bid = String(b.brand_id); const dailyCost = (brandSeriesMap[bid] || Array(DAYS).fill(0)).map((v) => +(+v).toFixed(4)); const totalDaily = dailyCost.reduce((a, n) => a + n, 0) || 1; const totalConvos = b.total_convos || 0; // 沒有真實 per-day 對話數 → 按費用比例分攤 const dailyConvos = dailyCost.map((c) => Math.max(0, Math.round((c / totalDaily) * totalConvos))); const todayCost = dailyCost[dailyCost.length - 1] || 0; const recent7 = dailyCost.slice(-8, -1); const avg7 = recent7.length ? recent7.reduce((a, n) => a + n, 0) / recent7.length : 0; const anomalyRatio = avg7 > 0 ? +(todayCost / avg7).toFixed(2) : 0; const isAnomalous = avg7 > 0.5 && anomalyRatio > 3; const lastTs = b.last_activity_at ? new Date(b.last_activity_at).getTime() : null; const lastActivityHrs = lastTs ? Math.max(0, Math.floor((Date.now() - lastTs) / 3600000)) : 9999; const status = lastActivityHrs > 168 ? 'dormant' : isAnomalous ? 'anomalous' : 'active'; return { id: bid, name: b.brand_name || bid, tier: 'growth', // 未接 MySQL plan_tier,先給中性值 contactEmail: '—', plan_ceiling_usd: 1500, activeUsers: 0, dailyCost, dailyConvos, totalCost: +(+(b.total_cost_usd || 0)).toFixed(4), totalConvos, tokens: b.total_tokens || 0, avgCostPerConvo: +(+(b.avg_cost_per_convo || 0)).toFixed(5), // per-brand cache_hit_rate 後端已聚合(cache_read / (input+cache_read));fallback 到全域避免 0 cacheHit: typeof b.cache_hit_rate === 'number' ? b.cache_hit_rate : globalCacheHit, todayCost: +todayCost.toFixed(4), anomalyRatio, isAnomalous, lastActivityHrs, status, }; }); } function normalizeConversations(rows) { return (rows || []).map((c) => { const inputT = c.total_input_tokens || 0; const cacheRead = c.total_cache_read_tokens || 0; const denom = inputT + cacheRead; const brandIdStr = c.brand_id != null ? String(c.brand_id) : ''; const adminIdStr = c.user_admin_id != null ? String(c.user_admin_id) : ''; // brand/admin 顯示 name,沒拿到 name 才 fallback 到 ID 字串 const brandDisplay = c.brand_name || brandIdStr || '—'; const adminDisplay = c.user_admin_name || (adminIdStr ? `admin#${adminIdStr}` : '—'); return { thread_id: c.thread_id, brand_id: brandIdStr, // raw idx(給 hover tooltip) brand_name: c.brand_name || '', // 真實 name brand_display: brandDisplay, // 預設顯示用:name → fallback id user_admin_id: adminIdStr, // raw idx(給 hover tooltip) user_admin: adminDisplay, // 預設顯示用:name → fallback admin#id user_admin_name: c.user_admin_name || '', user_admin_email: '', channel: c.channel || 'dashboard', model: c.last_model || '', title: c.title || '新對話', created_at: c.created_at ? new Date(c.created_at) : new Date(), // 軟刪標記:使用者刪了但 admin 仍可閱讀,UI 加 icon + 灰化 deleted_at: c.deleted_at ? new Date(c.deleted_at) : null, msgCount: c.message_count || 0, toolCount: c.total_tool_calls || 0, tokens: c.total_tokens || 0, tokensInput: inputT, tokensOutput: c.total_output_tokens || 0, tokensCacheRead: cacheRead, tokensCacheCreate: c.total_cache_creation_tokens || 0, cacheHit: denom > 0 ? cacheRead / denom : 0, cost: +(+(c.total_cost_usd || 0)).toFixed(5), status: statusFromConvo(c), }; }); } // LangChain BaseMessage → mock viewer shape({role, content, tool_calls, tool_result, tokens, ...})。 // 後端 _serialize_message 已給 plain-dict {role, content, tool_calls, tool_call_id}。 function normalizeMessages(messages) { const normalized = (messages || []).map((m) => { const role = m.role || 'unknown'; let content = ''; const tool_calls = []; let tool_result = null; if (typeof m.content === 'string') { content = m.content; } else if (Array.isArray(m.content)) { // content blocks:只取 text 與 tool_result,**不**從 tool_use 拿 args // — Anthropic content_block 序列化常把 input 丟成空 {};真正可信的 args 是 m.tool_calls[].args const textChunks = []; for (const block of m.content) { if (block.type === 'text' && block.text) textChunks.push(block.text); if (block.type === 'tool_result') { const txt = typeof block.content === 'string' ? block.content : JSON.stringify(block.content); tool_result = { name: block.tool_use_id || 'tool', preview: txt.slice(0, 4000) }; } } content = textChunks.join('\n\n'); } else if (m.content != null) { content = String(m.content); } // tool_calls:一律以 m.tool_calls (LangChain 標準) 為唯一 source // — content blocks 的 tool_use.input 在後端序列化階段已遺失,那條路是不可靠的 fallback if (Array.isArray(m.tool_calls) && m.tool_calls.length) { for (const tc of m.tool_calls) { const realArgs = (tc.args && typeof tc.args === 'object' && Object.keys(tc.args).length) ? tc.args : (tc.arguments && typeof tc.arguments === 'object' ? tc.arguments : {}); tool_calls.push({ id: tc.id, name: tc.name, args: realArgs, duration_ms: 0, }); } } // ToolMessage(role=tool)— content 是工具回傳值。 // 把它從 content(會在 message bubble 直接 render 一大坨)移到 tool_result(ToolResultBlock 摺疊)。 if (role === 'tool') { if (tool_result == null) { const txt = typeof m.content === 'string' ? m.content : Array.isArray(m.content) ? m.content.map((b) => b.text || JSON.stringify(b)).join('\n') : String(m.content); tool_result = { name: m.tool_call_id || 'tool', preview: txt.slice(0, 4000) }; } content = ''; // 不重複:tool_result 已包含同一塊內容 } // 紀錄原始 tool_call_id 以便後續 merge(tool_result.name 是 fallback string,可能不是 id) const tool_call_id = m.tool_call_id || (tool_result && tool_result.name) || null; return { role, content, tool_calls, tool_result, tool_call_id, tokens: null, // checkpoint 沒帶 per-msg token 細項;總量由 sidebar 從 conversation cost 顯示 cost: null, model: null, latency_ms: null, timestamp: '', }; }); return mergeToolResultsIntoCalls(normalized); } // 把 role=tool 訊息(工具回傳值)依 tool_call_id 配對到上方 assistant message 的 tool_calls, // 變成 ToolCallBlock 內的 `result` 欄位。然後從訊息流移除獨立 tool 訊息——讓 viewer 看起來 // 一個 assistant turn = 文字 + (call+回傳) 並列,不會再出現「下一個 turn 是工具結果」的視覺斷裂。 function mergeToolResultsIntoCalls(messages) { const resultByCallId = {}; for (const m of messages) { if (m.role === 'tool' && m.tool_result && m.tool_call_id) { resultByCallId[m.tool_call_id] = m.tool_result; } } return messages .filter((m) => m.role !== 'tool') .map((m) => { if (!m.tool_calls || !m.tool_calls.length) return m; return { ...m, tool_calls: m.tool_calls.map((tc) => ({ ...tc, result: resultByCallId[tc.id] || null, })), }; }); } // 把 sparse series({date_iso: value})對齊到 fullDates array:缺的位置補 0 function alignToDates(fullDates, byDate) { return fullDates.map((d) => +(byDate[d] || 0)); } // 規則: // today → 今天 1 天 // 7d / 30d → 滾動 N 天到今天 // month → 本月 1 號到今天 // last → 上月整月(1 號到上月最後一天) // backend cost-trend / token-mix 也跟著這個 range 算,所以 fullDates 會跟回傳資料對得上。 function buildFullDates(range) { const TODAY = new Date(); const todayMidUtc = Date.UTC(TODAY.getUTCFullYear(), TODAY.getUTCMonth(), TODAY.getUTCDate()); const dayMs = 86400000; const fmt = (utcMs) => new Date(utcMs).toISOString().slice(0, 10); const fullDates = []; if (range === 'today') { fullDates.push(fmt(todayMidUtc)); } else if (range === 'month') { const first = Date.UTC(TODAY.getUTCFullYear(), TODAY.getUTCMonth(), 1); for (let t = first; t <= todayMidUtc; t += dayMs) fullDates.push(fmt(t)); } else if (range === 'last') { const lastMonthEnd = Date.UTC(TODAY.getUTCFullYear(), TODAY.getUTCMonth(), 0); // 0 day = 上月最後一天 const lastMonthFirst = Date.UTC(new Date(lastMonthEnd).getUTCFullYear(), new Date(lastMonthEnd).getUTCMonth(), 1); for (let t = lastMonthFirst; t <= lastMonthEnd; t += dayMs) fullDates.push(fmt(t)); } else { const N = range === '7d' ? 7 : 30; for (let i = N - 1; i >= 0; i--) fullDates.push(fmt(todayMidUtc - i * dayMs)); } return { fullDates, DAYS: fullDates.length, TODAY }; } function buildBrandSeriesMap(costTrend, fullDates) { const costTrendDays = (costTrend && costTrend.days) || []; const brandSeriesMap = {}; for (const s of (costTrend && costTrend.series) || []) { if (!s.key || s.key === 'other') continue; const byDate = {}; (s.values || []).forEach((v, i) => { if (costTrendDays[i]) byDate[costTrendDays[i]] = v; }); brandSeriesMap[String(s.key)] = alignToDates(fullDates, byDate); } return brandSeriesMap; } function buildDailyAgg(fullDates, brandSeriesMap) { return fullDates.map((date, i) => { const perBrand = {}; let total = 0; for (const [bid, values] of Object.entries(brandSeriesMap)) { const v = values[i] || 0; perBrand[bid] = v; total += v; } return { date, total: +total.toFixed(4), perBrand }; }); } function buildTokenMix(tokenMix, fullDates) { const byDate = {}; for (const d of (tokenMix && tokenMix.days) || []) { if (d.date) byDate[d.date] = d; } return fullDates.map((date) => { const d = byDate[date] || {}; const fresh = d.fresh || 0; const cacheRead = d.cache_read || 0; const denom = cacheRead + fresh; return { date, fresh, cacheRead, cacheCreate: d.cache_creation || 0, output: d.output || 0, cacheHit: denom > 0 ? +(cacheRead / denom).toFixed(3) : 0, }; }); } function buildModels(byModel) { return ((byModel && byModel.models) || []).map((m) => [ m.model, providerOf(m.model), 0.001, 0.005, // prices placeholder ]); } function buildPerModel(byModel) { return ((byModel && byModel.models) || []).map((m) => ({ model: m.model, provider: providerOf(m.model), requests: m.requests, tokens: m.tokens, cost: m.cost_usd, avgPer: m.requests > 0 ? +(m.cost_usd / m.requests).toFixed(5) : 0, })); } // window.MOCK / LIVE 永遠保有有效空殼,pages 在 loading 時也不會 crash。 function ensureGlobals() { const { fullDates, DAYS, TODAY } = buildFullDates('30d'); if (!window.MOCK) { window.MOCK = { TODAY, DAYS, BRANDS: [], DAILY_AGG: fullDates.map((d) => ({ date: d, total: 0, perBrand: {} })), TOKEN_MIX: fullDates.map((d) => ({ date: d, fresh: 0, cacheRead: 0, cacheCreate: 0, output: 0, cacheHit: 0 })), MODELS: [['claude-haiku-4-5', 'anthropic', 0.0008, 0.004]], PER_MODEL: [], CONVERSATIONS: [], STAFF: [], SAMPLE_THREAD: { messages: [] }, }; } if (!window.LIVE) { window.LIVE = { kpis: null, costTrend: null, topBrands: null, brandsList: null, conversations: null, byModel: null, cacheRate: null, tokenMix: null }; } } // 並行打 endpoints;任一失敗只警告不中止整批。 async function callEndpoints(spec) { const keys = Object.keys(spec); const settled = await Promise.allSettled(keys.map((k) => spec[k]())); const out = {}; keys.forEach((k, i) => { if (settled[i].status === 'fulfilled') { out[k] = settled[i].value; } else { console.warn('[AdminDataLoader]', k, 'failed:', settled[i].reason); out[k] = null; } }); return out; } // ── Per-route loaders ───────────────────────────────────── // 共用 helper:拉 cost-trend + brands list + cache rate + (optional byModel/tokenMix),重建 BRANDS / DAILY_AGG。 // overview / brands / cost 都需要 BRANDS(topBar / 散布圖 / cache 排行),所以這三頁共用 builder。 async function loadOverview(range) { const A = window.AdminAPI; const r = await callEndpoints({ kpis: () => A.overview.kpis(range), costTrend: () => A.overview.costTrend(range, 5), topBrands: () => A.overview.topBrands(range, 30), cacheRate: () => A.cost.cacheRate(range), }); const { fullDates, DAYS, TODAY } = buildFullDates(range); const brandSeriesMap = buildBrandSeriesMap(r.costTrend, fullDates); const globalCacheHit = (r.cacheRate && r.cacheRate.rates && r.cacheRate.rates.length) ? r.cacheRate.rates[r.cacheRate.rates.length - 1] : 0; window.MOCK.TODAY = TODAY; window.MOCK.DAYS = DAYS; const topBrandsRows = (r.topBrands && r.topBrands.brands) || []; window.MOCK.BRANDS = normalizeBrands(topBrandsRows, brandSeriesMap, fullDates, globalCacheHit); window.MOCK.DAILY_AGG = buildDailyAgg(fullDates, brandSeriesMap); Object.assign(window.LIVE, r); } async function loadBrands(range) { const A = window.AdminAPI; const r = await callEndpoints({ costTrend: () => A.overview.costTrend(range, 5), brandsList: () => A.brands.list(range, 100), cacheRate: () => A.cost.cacheRate(range), }); const { fullDates, DAYS, TODAY } = buildFullDates(range); const brandSeriesMap = buildBrandSeriesMap(r.costTrend, fullDates); const globalCacheHit = (r.cacheRate && r.cacheRate.rates && r.cacheRate.rates.length) ? r.cacheRate.rates[r.cacheRate.rates.length - 1] : 0; const rows = (r.brandsList && r.brandsList.brands) || []; window.MOCK.TODAY = TODAY; window.MOCK.DAYS = DAYS; window.MOCK.BRANDS = normalizeBrands(rows, brandSeriesMap, fullDates, globalCacheHit); window.MOCK.DAILY_AGG = buildDailyAgg(fullDates, brandSeriesMap); Object.assign(window.LIVE, r); } // conversations.list 目前不接 range 參數(後端是看全期),因此 loader 不收 range async function loadConversations() { const A = window.AdminAPI; const r = await callEndpoints({ conversations: () => A.conversations.list({ limit: 200 }), }); window.MOCK.CONVERSATIONS = normalizeConversations((r.conversations && r.conversations.conversations) || []); Object.assign(window.LIVE, r); } async function loadCost(range) { const A = window.AdminAPI; const r = await callEndpoints({ costTrend: () => A.overview.costTrend(range, 5), brandsList: () => A.brands.list(range, 100), byModel: () => A.cost.byModel(range), cacheRate: () => A.cost.cacheRate(range), tokenMix: () => A.cost.tokenMix(range), }); const { fullDates, DAYS, TODAY } = buildFullDates(range); const brandSeriesMap = buildBrandSeriesMap(r.costTrend, fullDates); const globalCacheHit = (r.cacheRate && r.cacheRate.rates && r.cacheRate.rates.length) ? r.cacheRate.rates[r.cacheRate.rates.length - 1] : 0; const rows = (r.brandsList && r.brandsList.brands) || []; const MODELS = buildModels(r.byModel); window.MOCK.TODAY = TODAY; window.MOCK.DAYS = DAYS; window.MOCK.BRANDS = normalizeBrands(rows, brandSeriesMap, fullDates, globalCacheHit); window.MOCK.DAILY_AGG = buildDailyAgg(fullDates, brandSeriesMap); window.MOCK.TOKEN_MIX = buildTokenMix(r.tokenMix, fullDates); window.MOCK.MODELS = MODELS.length ? MODELS : [['claude-haiku-4-5', 'anthropic', 0.0008, 0.004]]; window.MOCK.PER_MODEL = buildPerModel(r.byModel); Object.assign(window.LIVE, r); } async function loadForRoute(route, range = '30d') { ensureGlobals(); if (route === 'conversations') return loadConversations(); if (route === 'brands') return loadBrands(range); if (route === 'cost') return loadCost(range); return loadOverview(range); // overview / 預設 } ensureGlobals(); window.AdminDataLoader = { loadForRoute, loadOverview, loadBrands, loadConversations, loadCost, normalizeMessages }; })();