PageSourceSearch

https://haberbosch.ai/js/views/explorer.js

js haberbosch.ai collected 2026-10-05 13:33:12 UTC 23,836 bytes, 448 lines download raw bytes

1// ══════════════════════════════════════
2// TAB 5: Bot Library (Bot库) — API-driven
3// ══════════════════════════════════════
4const PAGE_SIZE = 50;
5
6const API_SORT_OPTS = [
7  {val:'chat_imp', label:'Chat/Imp'},
8  {val:'feed_ctr', label:'Feed CTR'},
9  {val:'shows', label:'曝光量'},
10  {val:'chats', label:'聊天数'},
11  {val:'chat_users', label:'聊天用户'},
12  {val:'ctr', label:'总CTR'},
13  {val:'open_rate', label:'开聊率'},
14  {val:'depth', label:'聊天深度'},
15  {val:'retention1', label:'次日留存'},
16  {val:'retention7', label:'7日留存'},
17  {val:'return_rate', label:'回访率'},
18];
19
20function renderApiExplorer() {
21  if (!apiBotsResult) return;
22  const c = document.getElementById('view-bots');
23  const { total, page, page_size, total_pages, bots } = apiBotsResult;
24  const { date_from, date_to } = apiDateRange();
25  const isCustom = apiBotsState.dateRange === 'custom';
26
27  let h = '';
28  h += `<div class="page-title">Bot库</div>`;
29  h += `<div class="page-subtitle">实时数据 — ${total.toLocaleString()} bots &nbsp;·&nbsp; ${date_from} → ${date_to}</div>`;
30
31  // Controls bar
32  h += `<div class="filter-bar" style="flex-wrap:wrap;gap:8px;">`;
33  // Date range picker
34  h += `<div style="display:flex;gap:4px;align-items:center;">`;
35  ['7d','30d','custom'].forEach(r => {
36    h += `<button class="page-btn${apiBotsState.dateRange===r?' active':''}" data-range="${r}" style="min-width:44px">${r}</button>`;
37  });
38  if (isCustom) {
39    h += `<input type="date" id="apiDateFrom" value="${apiBotsState.date_from||date_from}" style="background:#2a2a28;border:1px solid rgba(255,255,255,0.12);border-radius:6px;color:rgba(255,255,255,0.7);padding:4px 8px;font-size:12px;">`;
40    h += `<span style="color:rgba(255,255,255,0.3)">→</span>`;
41    h += `<input type="date" id="apiDateTo" value="${apiBotsState.date_to||date_to}" style="background:#2a2a28;border:1px solid rgba(255,255,255,0.12);border-radius:6px;color:rgba(255,255,255,0.7);padding:4px 8px;font-size:12px;">`;
42    h += `<button class="page-btn" id="apiDateApply" style="background:rgba(56,189,248,0.15);border-color:rgba(56,189,248,0.3)">Apply</button>`;
43  }
44  h += `</div>`;
45  // Tag filter
46  h += `<select class="filter-select" id="apiTagFilter" style="min-width:160px"><option value="">标签 (All)</option>`;
47  apiTags.forEach(t => { h += `<option value="${t.tag}" ${apiBotsState.tag===t.tag?'selected':''}>${t.tag} (${t.bot_count.toLocaleString()})</option>`; });
48  h += `</select>`;
49  // Sort
50  h += `<select class="filter-select" id="apiSortBy">`;
51  API_SORT_OPTS.forEach(o => { h += `<option value="${o.val}" ${apiBotsState.sort_by===o.val?'selected':''}>${o.label}</option>`; });
52  h += `</select>`;
53  h += `<select class="filter-select" id="apiSortOrder" style="width:80px">
54    <option value="desc" ${apiBotsState.sort_order==='desc'?'selected':''}>↓</option>
55    <option value="asc" ${apiBotsState.sort_order==='asc'?'selected':''}>↑</option>
56  </select>`;
57  // Search
58  h += `<input type="text" class="search-input" placeholder="搜索标题..." id="apiSearch" value="${apiBotsState.search}" style="flex:1;min-width:160px">`;
59  h += `</div>`;
60
61  // Helper: compute ratio from raw numbers (always matches displayed values)
62  const ratio = (a, b, dp=4) => (b && b > 0) ? (a / b) : null;
63  const fmtN = (n) => n != null && n > 0 ? n.toLocaleString() : '—';
64  const fmtR = (v, dp=2) => v != null ? (v * 100).toFixed(dp) + '%' : '—';
65  const fmtD = (v, dp=1) => v != null ? v.toFixed(dp) : '—';
66
67  // Table — Jay's requested metrics, all verifiable from raw numbers
68  const sortCols = [
69    {key:'_rank',label:'#',sort:null},
70    {key:'title',label:'Bot',sort:null},
71    {key:'feed_shows',label:'Feed曝光',sort:'shows'},
72    {key:'feed_clicks',label:'Feed点击',sort:'clicks'},
73    {key:'chats',label:'总Chat',sort:'chats'},
74    {key:'feed_ctr',label:'FeedCTR',sort:'feed_ctr'},
75    {key:'chat_per_imp',label:'Chat/Imp',sort:'chat_imp'},
76    {key:'feed_open_rate',label:'Feed开聊率',sort:'open_rate'},
77    {key:'depth',label:'人均深度',sort:'depth'},
78    {key:'chat_users',label:'聊天UV',sort:'chat_users'},
79    {key:'tag',label:'标签',sort:null},
80  ];
81
82  h += `<div class="table-wrap"><table><thead><tr>`;
83  for (const col of sortCols) {
84    const isActive = col.sort && apiBotsState.sort_by === col.sort;
85    const arrow = isActive ? (apiBotsState.sort_order==='asc'?'▲':'▼') : '▲';
86    h += `<th class="${col.sort?'sortable':''} ${isActive?'sort-active':''}" data-sortkey="${col.sort||''}">${col.label}${col.sort?`<span class="sort-arrow">${arrow}</span>`:''}</th>`;
87  }
88  h += `</tr></thead><tbody>`;
89
90  const startRank = (page - 1) * page_size;
91  bots.forEach((b, idx) => {
92    const rank = startRank + idx + 1;
93    const thumb = b.thumbnail_url ? `<img src="${b.thumbnail_url}" style="width:36px;height:36px;border-radius:6px;object-fit:cover;flex-shrink:0;background:#2a2a28;" loading="lazy" onerror="this.style.display='none'">` : '';
94    const title = (b.title || b.prompt_id).replace(/"/g, '&quot;');
95    // Raw numbers from API
96    const feedShows = b.home_shows_sum || b.feed_shows || 0;
97    const feedClicks = b.home_clicks_sum || b.feed_clicks || 0;
98    const totalChats = b.chats_sum || 0;
99    const chatUV = b.chat_users || 0;
100    const feedClickUV = b.home_click_users || b.feed_click_uv || 0;
101    const feedChatUV = b.home_chat_users || b.feed_chat_uv || 0;
102    // Ratios computed from raw numbers — always verifiable
103    const feedCTR = ratio(feedClicks, feedShows);
104    // chat/imp 列 = 后端 chat_imp (home_chats / home_shows, feed CPI). 跟后端
105    // chat_imp 排序对齐 — 之前用 ratio(chats_sum, home_shows_sum) 是混搭值
106    // (全渠道 chat / feed shows) 跟排序不一致, 看起来"排序乱".
107    const feedChats = b.home_chats_sum || b.feed_chats || 0;
108    const chatPerImp = b.chat_imp != null ? b.chat_imp : ratio(feedChats, feedShows);
109    const feedOpenRate = ratio(feedChatUV, feedClickUV);
110    const depth = ratio(totalChats, chatUV);
111    h += `<tr class="clickable-row" data-botid="${b.prompt_id}">
112      <td class="mono" style="color:rgba(255,255,255,0.2)">${rank}</td>
113      <td class="title-cell"><div style="display:flex;align-items:center;gap:8px;">${thumb}<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${title}">${b.title||b.prompt_id}</span></div></td>
114      <td class="mono">${fmtN(feedShows)}</td>
115      <td class="mono">${fmtN(feedClicks)}</td>
116      <td class="mono">${fmtN(totalChats)}</td>
117      <td class="mono">${fmtR(feedCTR)}</td>
118      <td class="mono" style="color:#3a86ff;font-weight:500">${fmtD(chatPerImp,2)}</td>
119      <td class="mono">${fmtR(feedOpenRate)}</td>
120      <td class="mono">${fmtD(depth,1)}</td>
121      <td class="mono">${fmtN(chatUV)}</td>
122      <td style="font-size:11px;color:rgba(255,255,255,0.35)">${b.v8_primary_tag||b.tag||''}</td>
123    </tr>`;
124  });
125  h += `</tbody></table></div>`;
126
127  // Pagination
128  h += `<div class="pagination">`;
129  if (total_pages > 1) {
130    h += `<button class="page-btn" data-apipage="${Math.max(1,page-1)}" ${page===1?'disabled':''}>上一页</button>`;
131    const startP = Math.max(1, Math.min(page-3, total_pages-6));
132    const endP = Math.min(total_pages, startP+6);
133    for (let i = startP; i <= endP; i++) {
134      h += `<button class="page-btn${i===page?' active':''}" data-apipage="${i}">${i}</button>`;
135    }
136    h += `<button class="page-btn" data-apipage="${Math.min(total_pages,page+1)}" ${page>=total_pages?'disabled':''}>下一页</button>`;
137  }
138  h += `<span class="page-info">第 ${page} / ${total_pages||1} 页 &nbsp;·&nbsp; ${total.toLocaleString()} bots</span>`;
139  h += `</div>`;
140
141  c.innerHTML = h;
142
143  // Event listeners
144  c.querySelectorAll('[data-range]').forEach(btn => {
145    btn.addEventListener('click', () => {
146      apiBotsState.dateRange = btn.dataset.range;
147      apiBotsState.page = 1;
148      if (btn.dataset.range !== 'custom') { loadTagsFromAPI().then(() => loadBotsFromAPI()); }
149      else { renderApiExplorer(); }
150    });
151  });
152  const applyBtn = document.getElementById('apiDateApply');
153  if (applyBtn) {
154    applyBtn.addEventListener('click', () => {
155      apiBotsState.date_from = document.getElementById('apiDateFrom').value;
156      apiBotsState.date_to = document.getElementById('apiDateTo').value;
157      apiBotsState.page = 1;
158      loadTagsFromAPI().then(() => loadBotsFromAPI());
159    });
160  }
161  document.getElementById('apiTagFilter').addEventListener('change', e => {
162    apiBotsState.tag = e.target.value; apiBotsState.page = 1; loadBotsFromAPI();
163  });
164  document.getElementById('apiSortBy').addEventListener('change', e => {
165    apiBotsState.sort_by = e.target.value; apiBotsState.page = 1; loadBotsFromAPI();
166  });
167  document.getElementById('apiSortOrder').addEventListener('change', e => {
168    apiBotsState.sort_order = e.target.value; apiBotsState.page = 1; loadBotsFromAPI();
169  });
170  let searchTimer;
171  document.getElementById('apiSearch').addEventListener('input', e => {
172    clearTimeout(searchTimer);
173    searchTimer = setTimeout(() => {
174      apiBotsState.search = e.target.value; apiBotsState.page = 1; loadBotsFromAPI();
175    }, 400);
176  });
177  c.querySelectorAll('th.sortable').forEach(th => {
178    th.addEventListener('click', () => {
179      const k = th.dataset.sortkey;
180      if (!k) return;
181      if (apiBotsState.sort_by === k) apiBotsState.sort_order = apiBotsState.sort_order === 'desc' ? 'asc' : 'desc';
182      else { apiBotsState.sort_by = k; apiBotsState.sort_order = 'desc'; }
183      apiBotsState.page = 1; loadBotsFromAPI();
184    });
185  });
186  c.querySelectorAll('[data-apipage]').forEach(btn => {
187    btn.addEventListener('click', () => { apiBotsState.page = parseInt(btn.dataset.apipage); loadBotsFromAPI(); });
188  });
189  c.querySelectorAll('.clickable-row').forEach(row => {
190    row.addEventListener('click', () => {
191      const botID = row.dataset.botid;
192      const bot = bots.find(b => b.prompt_id === botID);
193      if (bot) { renderApiDetail(bot); switchViewRaw('botdetail'); }
194    });
195  });
196}
197
198// Keep old renderExplorer as fallback for gene-annotated data
199function renderExplorer() {
200  // Mark as loaded to prevent activateLibraryTab() from firing a duplicate call
201  window._botsLibraryLoaded = true;
202  loadTagsFromAPI().then(() => loadBotsFromAPI());
203}
204
205// ══════════════════════════════════════
206// API Bot Detail
207// ══════════════════════════════════════
208function _renderMetricCards(b) {
209  // Use pre-computed API values when available, fall back to raw calculation
210  const fs = b.feed_shows || b.home_shows_sum || 0;
211  const fc = b.feed_clicks || b.home_clicks_sum || 0;
212  const tc = b.chats_sum || 0;
213  const cuv = b.chat_users || 0;
214
215  const feedCTR = b.feed_ctr != null ? (b.feed_ctr * 100).toFixed(2) + '%'
216    : fs > 0 ? (fc / fs * 100).toFixed(2) + '%' : '—';
217  // chat_per_imp = total chats / feed shows (matches backend metrics.py)
218  const chatPerImp = b.chat_per_imp != null ? b.chat_per_imp
219    : fs > 0 ? tc / fs : null;
220  const chatPerImpStr = chatPerImp !== null ? chatPerImp.toFixed(2) : '—';
221  const feedOpenRate = b.feed_open_rate != null ? (b.feed_open_rate * 100).toFixed(2) + '%'
222    : '—';
223  const depth = b.depth != null ? Number(b.depth).toFixed(1)
224    : cuv > 0 ? (tc / cuv).toFixed(1) : '—';
225  const ret1 = b.retention1 != null ? pct(b.retention1) : '—';
226  const ret7 = b.retention7 != null ? pct(b.retention7) : '—';
227
228  return `
229    <div class="metric-card"><div class="metric-label">Feed曝光</div><div class="metric-value">${fs.toLocaleString()}</div></div>
230    <div class="metric-card"><div class="metric-label">Feed点击</div><div class="metric-value">${fc.toLocaleString()}</div></div>
231    <div class="metric-card"><div class="metric-label">总Chat</div><div class="metric-value">${tc.toLocaleString()}</div></div>
232    <div class="metric-card"><div class="metric-label">FeedCTR</div><div class="metric-value">${feedCTR}</div></div>
233    <div class="metric-card"><div class="metric-label">Chat/Imp</div><div class="metric-value" style="color:#3a86ff">${chatPerImpStr}</div></div>
234    <div class="metric-card"><div class="metric-label">Feed开聊率</div><div class="metric-value">${feedOpenRate}</div></div>
235    <div class="metric-card"><div class="metric-label">人均深度</div><div class="metric-value">${depth}</div></div>
236    <div class="metric-card"><div class="metric-label">聊天UV</div><div class="metric-value">${cuv.toLocaleString()}</div></div>
237    <div class="metric-card"><div class="metric-label">次日留存</div><div class="metric-value">${ret1}</div></div>
238    <div class="metric-card"><div class="metric-label">7日留存</div><div class="metric-value">${ret7}</div></div>`;
239}
240
241function switchViewRaw(name) {
242  document.querySelectorAll('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.view===name));
243  document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id==='view-'+name));
244}
245
246async function renderApiDetail(bot) {
247  const c = document.getElementById('view-botdetail');
248  const { date_from, date_to } = apiDateRange();
249
250  // Show initial layout immediately
251  const thumb = bot.thumbnail_url ? `<img src="${bot.thumbnail_url}" style="width:72px;height:72px;border-radius:12px;object-fit:cover;flex-shrink:0;background:#2a2a28;" onerror="this.style.display='none'">` : '';
252  let h = `<div class="back-area"><button class="btn" id="backToApiExplorer">← 返回列表</button></div>`;
253  h += `<div class="detail-header" style="display:flex;gap:20px;align-items:flex-start;">${thumb}
254    <div><div class="detail-title">${bot.title||bot.prompt_id}</div>
255    <div style="color:rgba(255,255,255,0.3);font-size:11px;margin-bottom:8px">${bot.v8_primary_tag||bot.tag||''} · ${bot.language||''}</div>
256    <div class="detail-desc" id="apiDetailDesc">Loading content...</div></div></div>`;
257
258  // Render metric cards with placeholder — will be updated when fresh API data arrives
259  h += `<div class="metric-grid" id="apiDetailMetrics">`;
260  h += _renderMetricCards(bot);
261  h += `</div>`;
262
263  h += `<div class="section"><div class="section-header"><div class="section-title">日趋势 Daily Trend</div></div>
264    <div id="apiDailyChart" class="card" style="min-height:60px">Loading...</div></div>`;
265
266  c.innerHTML = h;
267
268  document.getElementById('backToApiExplorer').addEventListener('click', () => {
269    switchViewRaw('bots');
270  });
271
272  // Load content and daily data in parallel
273  Promise.all([
274    fetch(`${API_BASE}/api/bots/${bot.prompt_id}/content`).then(r =>
274 r.ok ? r.json() : null).catch(()=>null),
275    fetch(`${API_BASE}/api/bots/${bot.prompt_id}?date_from=${date_from}&date_to=${date_to}`).then(r => r.ok ? r.json() : null).catch(()=>null),
276  ]).then(([content, detail]) => {
277    const descEl = document.getElementById('apiDetailDesc');
278    if (descEl && content) {
279      descEl.textContent = content.description || '(no description)';
280    } else if (descEl) {
281      descEl.textContent = '(content unavailable)';
282    }
283
284    // Refresh metric cards with precise BITMAP data from detail API
285    const metricsEl = document.getElementById('apiDetailMetrics');
286    if (metricsEl && detail && detail.bot) {
287      metricsEl.innerHTML = _renderMetricCards(detail.bot);
288    }
289
290    const chartEl = document.getElementById('apiDailyChart');
291    if (chartEl && detail && detail.daily && detail.daily.length) {
292      const daily = detail.daily;
293      const maxShows = Math.max(...daily.map(d=>d.shows_sum||0), 1);
294      let ch = `<div style="display:flex;align-items:flex-end;gap:6px;height:80px;padding:8px 0">`;
295      daily.forEach(d => {
296        const h_pct = Math.round((d.shows_sum||0)/maxShows*100);
297        const tip = `${d.event_date}: ${(d.shows_sum||0).toLocaleString()} shows`;
298        ch += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:3px">
299          <div style="width:100%;background:#38bdf8;border-radius:2px 2px 0 0;height:${h_pct}%;min-height:2px;opacity:0.7" title="${tip}"></div>
300          <div style="font-size:9px;color:rgba(255,255,255,0.25);writing-mode:vertical-rl;transform:rotate(180deg)">${d.event_date.slice(5)}</div>
301        </div>`;
302      });
303      ch += `</div>`;
304      ch += `<div style="font-size:10px;color:rgba(255,255,255,0.3);margin-top:2px">柱高=Feed曝光</div>`;
305      ch += `<div style="display:flex;gap:16px;margin-top:4px;flex-wrap:wrap">`;
306      daily.forEach(d => {
307        const ci = d.chat_imp != null ? fmt(d.chat_imp) : '—';
308        ch += `<span style="font-size:11px;color:rgba(255,255,255,0.4)">${d.event_date.slice(5)} CI:<span style="color:rgba(255,255,255,0.6)">${ci}</span></span>`;
309      });
310      ch += `</div>`;
311      chartEl.innerHTML = ch;
312    } else if (chartEl) {
313      chartEl.textContent = 'No daily data available.';
314    }
315  });
316}
317
318// ══════════════════════════════════════
319// Bot Detail (legacy gene-annotated)
320// ══════════════════════════════════════
321function renderDetail(bot) {
322  const c = document.getElementById('view-botdetail');
323  let h = '';
324
325  h += `<div class="back-area"><button class="btn" id="backToExplorer">\u2190 返回列表</button></div>`;
326
327  h += `<div class="detail-header" style="display:flex;gap:20px;align-items:flex-start;">
328    ${bot.avatar?`<img src="${bot.avatar}" style="width:72px;height:72px;border-radius:12px;object-fit:cover;flex-shrink:0;background:#2a2a28;" onerror="this.style.display='none'">`:''}
329    <div>
330      <div class="detail-title">${bot.title}</div>
331      <div style="margin-bottom:8px">${formatBadge(getFormat(bot))}</div>
332      <div class="detail-desc">${bot.description||'No description available.'}</div>
333      ${bot.link?`<a class="detail-link" href="${bot.link}" target="_blank">Open on Emochi \u2197</a>`:''}
334    </div>
335  </div>`;
336
337  // Metrics
338  h += `<div class="metric-grid">
339    <div class="metric-card"><div class="metric-label">Chat / Imp</div><div class="metric-value" style="color:#3a86ff">${fmt(bot.chat_imp)}</div></div>
340    <div class="metric-card"><div class="metric-label">Depth</div><div class="metric-value">${fmt(bot.depth,1)}</div></div>
341    <div class="metric-card"><div class="metric-label">Time (min)</div><div class="metric-value">${fmt(bot.time_min,1)}</div></div>
342    <div class="metric-card"><div class="metric-label">CTR</div><div class="metric-value">${pct(bot.ctr)}</div></div>
343    <div class="metric-card"><div class="metric-label">开聊率</div><div class="metric-value">${pct(bot.open_rate)}</div></div>
344    <div class="metric-card"><div class="metric-label">曝光量</div><div class="metric-value">
344${bot.exposure?Math.round(bot.exposure).toLocaleString():'\u2014'}</div></div>
345    <div class="metric-card"><div class="metric-label">聊天用户</div><div class="metric-value">${bot.chat_users?Math.round(bot.chat_users).toLocaleString():'\u2014'}</div></div>
346    <div class="metric-card"><div class="metric-label">次日留存</div><div class="metric-value">${pct(bot.d1_retention)}</div></div>
347    <div class="metric-card"><div class="metric-label">7日留存</div><div class="metric-value">${pct(bot.d7_retention)}</div></div>
348  </div>`;
349
350  // Format Badge (large)
351  const fmtKey = getFormat(bot);
352  const fmtInfo = FORMAT_LABELS[fmtKey];
353  if (fmtInfo) {
354    h += `<div class="section">
355      <div class="section-header"><div class="section-title">内容形态</div></div>
356      <div class="card" style="display:flex;align-items:center;gap:16px;">
357        <span class="format-badge" style="color:${fmtInfo.color};border-color:${fmtInfo.color}30;background:${fmtInfo.color}10;height:32px;font-size:14px;padding:0 16px;">${fmtInfo.name}</span>
358        <span style="font-size:13px;color:rgba(255,255,255,0.4);">${fmtInfo.desc}</span>
359      </div>
360    </div>`;
361  }
362
363  // Gene Profile
364  h += `<div class="section">
365    <div class="section-header"><div class="section-title">基因概览 Gene Profile</div></div>
366    <div class="card"><div class="gene-profile">`;
367
368  const geneItems = [
369    {dim:'起始事件',prefix:'I',val:bot.inciting_incident,sec:bot.inciting_incident_secondary},
370    {dim:'世界设定',prefix:'W',val:bot.setting},
371    {dim:'Setting IP',prefix:null,val:bot.setting_ip},
372    {dim:'封闭程度',prefix:null,val:bot.setting_confinement},
373    {dim:'角色规模',prefix:null,val:bot.cast_size},
374    {dim:'角色关系',prefix:'R',val:bot.cast_relationship},
375    {dim:'角色区分',prefix:null,val:bot.cast_differentiation},
376    {dim:'用户身份',prefix:'U',val:bot.user_identity},
377    {dim:'用户权力',prefix:null,val:bot.user_power},
378    {dim:'情感承诺',prefix:'E',val:bot.emotional_promise,sec:bot.emotional_promise_secondary},
379    {dim:'语言',prefix:null,val:bot.language},
380  ];
381
382  for (const g of geneItems) {
383    const primary = g.prefix&&g.val ? pillFull(g.val,g.prefix) : (g.val||'\u2014');
384    const secondary = g.sec&&g.prefix ? ' + '+pillFull(g.sec,g.prefix) : (g.sec?' + '+g.sec:'');
385    h += `<div class="gene-row">
386      <div class="gene-dim-label" ${g.prefix?`style="color:${DIM_COLORS[g.prefix]}"`:''} >${g.dim}</div>
387      <div class="gene-dim-value">${primary}${secondary}</div>
388    </div>`;
389  }
390  h += `</div></div></div>`;
391
392  // Cast Archetypes
393  if (bot.cast_archetypes&&bot.cast_archetypes.length) {
394    h += `<div class="section">
395      <div class="section-header">
396        <div class="section-accent" style="background:${DIM_COLORS.C}"></div>
397        <div class="section-title">角色原型 Cast</div>
398      </div>
399      <div class="cast-grid">`;
400    for (const ca of bot.cast_archetypes) {
401      h += `<div class="cast-item">
402        <span class="cast-name">${ca.name||'?'}</span>
403        ${pillFull(ca.archetype,'C')}
404      </div>`;
405    }
406    h += `</div></div>`;
407  }
408
409  // Tension Mechanisms
410  if (bot.tension_mechanisms&&bot.tension_mechanisms.length) {
411    h += `<div class="section">
412      <div class="section-header">
413        <div class="section-accent" style="background:${DIM_COLORS.T}"></div>
414        <div class="section-title">张力机制 Tensions</div>
415      </div>
416      <div style="display:flex;flex-wrap:wrap;gap:6px;">`;
417    for (const t of bot.tension_mechanisms) {
418      h += pillFull(t,'T');
419    }
420    h += `</div></div>`;
421  }
422
423  // Welcome Message
424  h += `<div class="section">
425    <div class="section-header"><div class="section-title">Welcome Message</div></div>
426    <div class="card"><pre class="prompt-text">${(bot.welcomeMessage||'N/A').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</pre></div>
427  </div>`;
428
429  // System Prompt (Collapsible)
430  h += `<div class="section">
431    <div class="section-header"><div class="section-title">System Prompt</div></div>
432    <button class="collapsible-toggle" id="sysPromptToggle">点击展开 System Prompt</button>
433    <div class="collapsible-content" id="sysPromptContent">
434      <div class="card" style="margin-top:8px;"><pre class="prompt-text">${(bot.initPrompt||'N/A').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</pre></div>
435    </div>
436  </div>`;
437
438  c.innerHTML = h;
439  document.getElementById('backToExplorer').addEventListener('click',()=>switchView('bots'));
440
441  // Collapsible toggle
442  document.getElementById('sysPromptToggle').addEventListener('click', function() {
443    const content = document.getElementById('sysPromptContent');
444    const isOpen = content.classList.contains('open');
445    content.classList.toggle('open');
446    this.textContent = isOpen ? '点击展开 System Prompt' : '点击收起 System Prompt';
447  });
448}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.