lumencode 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumencode",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "LumenCode — AI 编码助手使用报告工具,从 JSONL 日志和 Git 仓库提取 AI 贡献度、效率与使用指标,支持 Web 可视化和命令行两种模式",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { COLORS, SCENARIO_COLORS, TEXT, ID, STORAGE } from './config.js';
2
- import { esc, fmt, fmtShort, destroyChart, destroyAllCharts, getChart, setChart, todayISO, fmtDate } from './utils.js';
2
+ import { esc, fmt, fmtShort, destroyChart, destroyAllCharts, getChart, setChart, todayISO, fmtDate, TOOL_DISPLAY_NAMES, groupMcpByServer, aggregateToolsWithDualCounts } from './utils.js';
3
3
  import { createLatestRequestGuard, fetchTools, fetchReport, fetchConfig, saveConfig, fetchDetails, fetchSessions, fetchStepStats, fetchHooksStatus, updateHooks } from './api.js';
4
4
  import { renderWorkTypePie, renderModelBars, renderProjectBars, renderTimelineArea, renderCacheStack } from './charts.js';
5
5
  import { renderGitInsights, renderLineBlameEvidence } from './git-insights.js';
@@ -135,6 +135,12 @@ function appState() {
135
135
  { l: 'IDLE DAYS', v: '-', s: 'no activity' },
136
136
  ],
137
137
  toolRankData: [],
138
+ toolRankTotal: 0,
139
+ toolRankTab: 'all',
140
+ toolRankTotalCalls: 0,
141
+ toolRankAllTotal: 0,
142
+ toolRankSkillTotal: 0,
143
+ toolRankMcpTotal: 0,
138
144
  projectData: [],
139
145
 
140
146
  /* tool summary for rail */
@@ -310,6 +316,66 @@ function appState() {
310
316
  if (this.view === 'report') this.loadReportContent();
311
317
  },
312
318
 
319
+ setToolRankTab(tab) {
320
+ this.toolRankTab = tab;
321
+ this._computeToolRank();
322
+ const container = document.getElementById('toolCallsContainer');
323
+ if (container) container.scrollTop = 0;
324
+ },
325
+
326
+ _computeToolRank() {
327
+ const usageStats = this._lastUsageStats || {};
328
+ const tab = this.toolRankTab;
329
+ const getCalls = (val) => typeof val === 'number' ? val : (val.calls || 0);
330
+ const getUses = (val) => typeof val === 'number' ? val : (val.uses || 0);
331
+ const sumCalls = (obj) => Object.values(obj || {}).reduce((s, v) => s + getCalls(v), 0);
332
+
333
+ this.toolRankAllTotal = sumCalls(usageStats.tools);
334
+ this.toolRankSkillTotal = sumCalls(usageStats.skills);
335
+ this.toolRankMcpTotal = sumCalls(usageStats.mcpTools);
336
+
337
+ if (tab === 'all') {
338
+ const dual = aggregateToolsWithDualCounts(usageStats.tools || {});
339
+ const entries = Object.entries(dual).sort((a, b) => b[1].uses - a[1].uses);
340
+ const maxUses = Math.max(...entries.map(([, v]) => v.uses), 1);
341
+ this.toolRankData = entries.map(([name, d]) => ({
342
+ name,
343
+ calls: d.calls,
344
+ uses: d.uses,
345
+ value: d.calls,
346
+ pct: Math.round((d.uses / maxUses) * 100),
347
+ displayName: TOOL_DISPLAY_NAMES[name] || '',
348
+ }));
349
+ this.toolRankTotalCalls = this.toolRankAllTotal;
350
+ } else if (tab === 'skill') {
351
+ const skills = usageStats.skills || {};
352
+ const entries = Object.entries(skills).sort((a, b) => getUses(b[1]) - getUses(a[1]));
353
+ const maxUses = Math.max(...entries.map(([, v]) => getUses(v)), 1);
354
+ this.toolRankData = entries.map(([name, val]) => {
355
+ const calls = getCalls(val);
356
+ const uses = getUses(val);
357
+ return {
358
+ name,
359
+ calls,
360
+ uses,
361
+ value: calls,
362
+ pct: Math.round((uses / maxUses) * 100),
363
+ displayName: '',
364
+ };
365
+ });
366
+ this.toolRankTotalCalls = this.toolRankSkillTotal;
367
+ } else if (tab === 'mcp') {
368
+ this.toolRankData = groupMcpByServer(usageStats.mcpTools || {});
369
+ this.toolRankTotalCalls = this.toolRankMcpTotal;
370
+ }
371
+
372
+ this.toolRankTotal = this.toolRankData.length;
373
+ let rank = 0;
374
+ for (const item of this.toolRankData) {
375
+ if (!item.isGroup) item.rank = ++rank;
376
+ }
377
+ },
378
+
313
379
  /* ── period / date ── */
314
380
  setPeriod(p) {
315
381
  this.period = p;
@@ -433,6 +499,7 @@ function appState() {
433
499
  /* ── render data ── */
434
500
  renderData(data) {
435
501
  const { usageStats, gitStats, start, end, prevStats, trendData, costBreakdown } = data;
502
+ this._lastUsageStats = usageStats;
436
503
  this.hasData = usageStats.requestCount > 0;
437
504
  if (!this.hasData) {
438
505
  this.kpiData = [
@@ -526,9 +593,7 @@ function appState() {
526
593
  this.projectData = projEntries.map(([name, d]) => ({ name: name.length > 20 ? '...' + name.slice(-17) : name, value: d.requests }));
527
594
 
528
595
  /* Tool rank */
529
- const toolEntries = Object.entries(usageStats.tools || {}).sort((a, b) => b[1] - a[1]).slice(0, 10);
530
- const maxTool = Math.max(...toolEntries.map(([, v]) => v), 1);
531
- this.toolRankData = toolEntries.map(([name, value]) => ({ name, value, pct: Math.round((value / maxTool) * 100) }));
596
+ this._computeToolRank();
532
597
 
533
598
  /* Tool rail tokens — only refresh sidebar when viewing all tools */
534
599
  if (this.activeTool === 'all') {
@@ -881,19 +946,167 @@ function showToast(msg) {
881
946
  }
882
947
 
883
948
  /* ── Settings Modal ── */
949
+ const SCENARIO_LABELS = { coding: '编码', testing: '测试', debugging: '调试', documentation: '文档', review: '审查', planning: '规划', refactoring: '重构' };
950
+
951
+ function renderKeywordsEditor(keywords) {
952
+ const container = document.getElementById('cfgKeywordsEditor');
953
+ if (!container) return;
954
+ container.innerHTML = '';
955
+ for (const [key, label] of Object.entries(SCENARIO_LABELS)) {
956
+ const words = keywords[key] || [];
957
+ const row = document.createElement('div');
958
+ row.className = 'kw-row';
959
+ row.dataset.key = key;
960
+
961
+ const lbl = document.createElement('div');
962
+ lbl.className = 'kw-label';
963
+ lbl.textContent = label;
964
+
965
+ const tags = document.createElement('div');
966
+ tags.className = 'kw-tags';
967
+ for (const w of words) tags.appendChild(makeKwTag(w));
968
+
969
+ const addWrap = document.createElement('div');
970
+ addWrap.className = 'kw-add-row';
971
+ const addBtn = document.createElement('button');
972
+ addBtn.className = 'kw-add-btn';
973
+ addBtn.textContent = '+';
974
+ addBtn.title = '添加关键词';
975
+ addBtn.onclick = () => {
976
+ addWrap.innerHTML = '';
977
+ const inp = document.createElement('input');
978
+ inp.className = 'kw-add-input';
979
+ inp.placeholder = '关键词';
980
+ const ok = document.createElement('button');
981
+ ok.className = 'kw-add-btn';
982
+ ok.textContent = '确定';
983
+ ok.onclick = () => {
984
+ const v = inp.value.trim();
985
+ if (v && !tags.querySelector('[data-word="' + CSS.escape(v) + '"]')) tags.insertBefore(makeKwTag(v), addWrap);
986
+ resetAddBtn();
987
+ };
988
+ inp.onkeydown = (e) => { if (e.key === 'Enter') ok.click(); if (e.key === 'Escape') resetAddBtn(); };
989
+ addWrap.appendChild(inp);
990
+ addWrap.appendChild(ok);
991
+ inp.focus();
992
+ };
993
+ function resetAddBtn() { addWrap.innerHTML = ''; addWrap.appendChild(addBtn); }
994
+ resetAddBtn();
995
+
996
+ row.appendChild(lbl);
997
+ row.appendChild(tags);
998
+ row.appendChild(addWrap);
999
+ container.appendChild(row);
1000
+ }
1001
+ }
1002
+
1003
+ function makeKwTag(word) {
1004
+ const tag = document.createElement('span');
1005
+ tag.className = 'kw-tag';
1006
+ tag.dataset.word = word;
1007
+ tag.textContent = word;
1008
+ const x = document.createElement('span');
1009
+ x.className = 'kw-tag-remove';
1010
+ x.textContent = '×';
1011
+ x.onclick = () => tag.remove();
1012
+ tag.appendChild(x);
1013
+ return tag;
1014
+ }
1015
+
1016
+ function collectKeywordsFromEditor() {
1017
+ const result = {};
1018
+ const container = document.getElementById('cfgKeywordsEditor');
1019
+ if (!container) return result;
1020
+ for (const row of container.querySelectorAll('.kw-row')) {
1021
+ const key = row.dataset.key;
1022
+ const words = Array.from(row.querySelectorAll('.kw-tag')).map(t => t.dataset.word);
1023
+ if (words.length > 0) result[key] = words;
1024
+ }
1025
+ // 清洗校验:先 trim 再去重、过滤空串、截断超长词、过滤控制字符
1026
+ for (const [key, words] of Object.entries(result)) {
1027
+ result[key] = [...new Set(words.map(w => w.trim()))]
1028
+ .filter(w => w.length > 0 && w.length <= 50)
1029
+ .filter(w => !/[\x00-\x1f\x7f]/.test(w));
1030
+ }
1031
+ return result;
1032
+ }
1033
+
1034
+ window.closeSettings = () => {
1035
+ const modal = document.getElementById('settingsModal');
1036
+ if (modal) modal.style.display = 'none';
1037
+ };
1038
+
1039
+ /* ── Advanced Section Toggle ── */
1040
+ window.toggleKeywordsSection = () => {
1041
+ const section = document.getElementById('cfgKeywordsSection');
1042
+ const btn = document.getElementById('cfgKeywordsToggle');
1043
+ if (!section || !btn) return;
1044
+ const isHidden = section.style.display === 'none';
1045
+ section.style.display = isHidden ? 'block' : 'none';
1046
+ btn.classList.toggle('expanded', isHidden);
1047
+ };
1048
+
1049
+ /* ── Path Tag Editor ── */
1050
+ const FOLDER_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
1051
+ const CLOSE_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
1052
+
1053
+ function renderPathTags(containerId, paths) {
1054
+ const container = document.getElementById(containerId);
1055
+ if (!container) return;
1056
+ container.innerHTML = '';
1057
+ for (let i = 0; i < paths.length; i++) {
1058
+ const tag = document.createElement('div');
1059
+ tag.className = 'path-tag';
1060
+ tag.innerHTML = `
1061
+ <span class="path-tag-icon">${FOLDER_ICON}</span>
1062
+ <span class="path-tag-text" title="${esc(paths[i])}">${esc(paths[i])}</span>
1063
+ <button class="path-tag-remove" onclick="removePathTag('${containerId}', ${i})" title="删除">${CLOSE_ICON}</button>
1064
+ `;
1065
+ container.appendChild(tag);
1066
+ }
1067
+ }
1068
+
1069
+ function addPathTag(containerId, inputId) {
1070
+ const input = document.getElementById(inputId);
1071
+ const container = document.getElementById(containerId);
1072
+ if (!input || !container) return;
1073
+ const raw = input.value.trim();
1074
+ if (!raw) return;
1075
+ // 支持粘贴多行或多逗号分隔的内容,一次性解析添加
1076
+ const paths = raw.split(/[,,\n\r]+/).map(s => s.trim()).filter(Boolean);
1077
+ const existing = getPathTags(containerId);
1078
+ for (const p of paths) {
1079
+ if (!existing.includes(p)) existing.push(p);
1080
+ }
1081
+ renderPathTags(containerId, existing);
1082
+ input.value = '';
1083
+ input.focus();
1084
+ }
1085
+
1086
+ function removePathTag(containerId, index) {
1087
+ const paths = getPathTags(containerId);
1088
+ paths.splice(index, 1);
1089
+ renderPathTags(containerId, paths);
1090
+ }
1091
+
1092
+ function getPathTags(containerId) {
1093
+ const container = document.getElementById(containerId);
1094
+ if (!container) return [];
1095
+ return Array.from(container.querySelectorAll('.path-tag-text')).map(el => el.textContent);
1096
+ }
1097
+
884
1098
  window.openSettings = async () => {
885
1099
  const modal = document.getElementById('settingsModal');
1100
+ const hint = document.getElementById('cfgSaveHint');
1101
+ if (hint) { hint.textContent = ''; hint.className = ''; }
886
1102
  if (modal) modal.style.display = 'flex';
887
1103
  try {
888
1104
  const cfg = await fetchConfig();
889
1105
  const dirEl = document.getElementById('cfgClaudeDir');
890
- const reposEl = document.getElementById('cfgRepos');
891
- const excludeEl = document.getElementById('cfgExclude');
892
- const kwEl = document.getElementById('cfgKeywords');
893
1106
  if (dirEl) dirEl.value = cfg.claudeDir || '';
894
- if (reposEl) reposEl.value = (cfg.repos || []).join('\n');
895
- if (excludeEl) excludeEl.value = (cfg.excludeProjects || []).join('\n');
896
- if (kwEl) kwEl.value = cfg.scenarioKeywords ? JSON.stringify(cfg.scenarioKeywords, null, 2) : '{}';
1107
+ renderPathTags('cfgReposTags', cfg.repos || []);
1108
+ renderPathTags('cfgExcludeTags', cfg.excludeProjects || []);
1109
+ renderKeywordsEditor(cfg.scenarioKeywords || {});
897
1110
  } catch (err) {
898
1111
  showToast('加载配置失败: ' + err.message);
899
1112
  }
@@ -914,19 +1127,21 @@ document.getElementById('welcomeStartBtn')?.addEventListener('click', async () =
914
1127
  });
915
1128
 
916
1129
  window.saveSettings = async () => {
917
- let scenarioKeywords;
918
- try { scenarioKeywords = JSON.parse(document.getElementById('cfgKeywords').value); } catch { showToast('场景关键词 JSON 格式错误'); return; }
1130
+ const hint = document.getElementById('cfgSaveHint');
1131
+ const scenarioKeywords = collectKeywordsFromEditor();
919
1132
  const payload = {
920
1133
  claudeDir: document.getElementById('cfgClaudeDir').value.trim(),
921
- repos: document.getElementById('cfgRepos').value.split('\n').map(s => s.trim()).filter(Boolean),
922
- excludeProjects: document.getElementById('cfgExclude').value.split('\n').map(s => s.trim()).filter(Boolean),
1134
+ repos: getPathTags('cfgReposTags'),
1135
+ excludeProjects: getPathTags('cfgExcludeTags'),
923
1136
  scenarioKeywords,
924
1137
  };
925
1138
  try {
926
1139
  await saveConfig(payload);
927
- document.getElementById('settingsModal').style.display = 'none';
928
- window.location.reload();
929
- } catch (err) { showToast('保存失败: ' + err.message); }
1140
+ if (hint) { hint.textContent = '配置已保存'; hint.className = 'cfg-save-ok'; }
1141
+ setTimeout(() => window.location.reload(), 1200);
1142
+ } catch (err) {
1143
+ if (hint) { hint.textContent = '保存失败: ' + err.message; hint.className = 'cfg-save-err'; }
1144
+ }
930
1145
  };
931
1146
 
932
1147
  /* ── Drill-down global handler ── */
package/public/config.js CHANGED
@@ -30,6 +30,7 @@ export const SCENARIO_COLORS = {
30
30
  '阅读/研究': '#9878d0',
31
31
  '规划/设计': '#d49060',
32
32
  '代码审查': '#60b8b8',
33
+ '重构': '#7c6fae',
33
34
  '其他': '#989898',
34
35
  };
35
36
 
package/public/export.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { TEXT } from './config.js';
2
2
  import { esc, fmt, fmtShort, getChart } from './utils.js';
3
3
 
4
+ // 工具调用值兼容:{name: number} 或 {name: {calls, uses}}
5
+ const toolCalls = (v) => typeof v === 'number' ? v : (v.calls || 0);
6
+ const toolUses = (v) => typeof v === 'number' ? v : (v.uses || 0);
7
+
4
8
  // ── CSV 导出 ──
5
9
  export function exportCSV(data, period) {
6
10
  if (!data) return;
@@ -73,11 +77,11 @@ export function exportCSV(data, period) {
73
77
  lines.push('');
74
78
  }
75
79
 
76
- const toolEntries = Object.entries(usageStats.tools).sort((a, b) => b[1] - a[1]);
80
+ const toolEntries = Object.entries(usageStats.tools).sort((a, b) => toolCalls(b[1]) - toolCalls(a[1]));
77
81
  if (toolEntries.length > 0) {
78
82
  lines.push('# 工具使用');
79
- lines.push('工具,调用次数');
80
- for (const [name, count] of toolEntries) lines.push(`${name},${count}`);
83
+ lines.push('工具,调用次数,使用次数');
84
+ for (const [name, val] of toolEntries) lines.push(`${name},${toolCalls(val)},${toolUses(val)}`);
81
85
  lines.push('');
82
86
  }
83
87
 
@@ -121,7 +125,7 @@ export function printReport(data, period) {
121
125
 
122
126
  const projRows = Object.entries(usageStats.projects).sort((a, b) => b[1].requests - a[1].requests).map(([n, d]) => [n, d.requests, d.sessions instanceof Set ? d.sessions.size : (d.sessions || 0)]);
123
127
  const modelRows = Object.entries(usageStats.models).sort((a, b) => b[1].count - a[1].count).map(([n, d]) => [n, d.count, fmtShort(d.inputTokens), fmtShort(d.outputTokens)]);
124
- const toolRows = Object.entries(usageStats.tools).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([n, c]) => [n, c]);
128
+ const toolRows = Object.entries(usageStats.tools).sort((a, b) => toolCalls(b[1]) - toolCalls(a[1])).slice(0, 10).map(([n, v]) => [n, toolCalls(v), toolUses(v)]);
125
129
  const scenarioRows = Object.entries(usageStats.scenarios).sort((a, b) => b[1] - a[1]).map(([n, c]) => [n, c]);
126
130
 
127
131
  const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Claude Code 使用${periodName}</title>
@@ -156,7 +160,7 @@ th{font-weight:600;background:#f8f9fa}
156
160
  </div>
157
161
  ${printTable('项目分布', ['项目', '请求数', '会话数'], projRows)}
158
162
  ${printTable('模型分布', ['模型', '请求数', '输入', '输出'], modelRows)}
159
- ${printTable('工具使用排行', ['工具', '调用次数'], toolRows)}
163
+ ${printTable('工具使用排行', ['工具', '调用次数', '使用次数'], toolRows)}
160
164
  ${printTable('场景分布', ['场景', '请求数'], scenarioRows)}
161
165
  ${gitStats && gitStats.commits > 0 ? printTable('Git 代码产出', ['指标', '数值'], (() => {
162
166
  const rows = [['提交次数', gitStats.commits], ['新增行数', '+' + fmt(gitStats.linesAdded)], ['删除行数', '-' + fmt(gitStats.linesDeleted)], ['变更文件', gitStats.filesChanged]];
@@ -229,7 +233,7 @@ export function exportHTML(data, period) {
229
233
 
230
234
  const projRows = Object.entries(usageStats.projects).sort((a, b) => b[1].requests - a[1].requests).map(([n, d]) => [n, d.requests, d.sessions instanceof Set ? d.sessions.size : (d.sessions || 0)]);
231
235
  const modelRows = Object.entries(usageStats.models).sort((a, b) => b[1].count - a[1].count).map(([n, d]) => [n, d.count, fmtShort(d.inputTokens), fmtShort(d.outputTokens), d.cost ? '$' + d.cost.toFixed(2) : '-']);
232
- const toolRows = Object.entries(usageStats.tools).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([n, c]) => [n, c]);
236
+ const toolRows = Object.entries(usageStats.tools).sort((a, b) => toolCalls(b[1]) - toolCalls(a[1])).slice(0, 10).map(([n, v]) => [n, toolCalls(v), toolUses(v)]);
233
237
  const scenarioRows = Object.entries(usageStats.scenarios).sort((a, b) => b[1] - a[1]).map(([n, c]) => [n, c]);
234
238
 
235
239
  const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>AI 编码助手使用${periodName}</title>
@@ -264,7 +268,7 @@ th{font-weight:600;background:#f8f9fa}
264
268
  </div>
265
269
  ${printTable('项目分布', ['项目', '请求数', '会话数'], projRows)}
266
270
  ${printTable('模型分布', ['模型', '请求数', '输入', '输出', '费用'], modelRows)}
267
- ${printTable('工具使用排行', ['工具', '调用次数'], toolRows)}
271
+ ${printTable('工具使用排行', ['工具', '调用次数', '使用次数'], toolRows)}
268
272
  ${printTable('场景分布', ['场景', '请求数'], scenarioRows)}
269
273
  ${data.costBreakdown?.models?.length ? printTable('模型费用', ['模型', '费用', '计费方式', '请求数'], data.costBreakdown.models.map(m => [m.name, '$' + (m.cost || 0).toFixed(2), m.mode === 'actual' ? '实际' : m.mode === 'estimated' ? '估算' : '未知', m.requests])) : ''}
270
274
  ${gitStats && gitStats.commits > 0 ? printTable('Git 代码产出', ['指标', '数值'], [['提交次数', gitStats.commits], ['新增行数', '+' + fmt(gitStats.linesAdded)], ['删除行数', '-' + fmt(gitStats.linesDeleted)], ['变更文件', gitStats.filesChanged]]) : ''}
package/public/index.html CHANGED
@@ -1,4 +1,4 @@
1
- <!DOCTYPE html>
1
+ <!DOCTYPE html>
2
2
  <html lang="zh-CN">
3
3
  <head>
4
4
  <meta charset="UTF-8">
@@ -509,15 +509,31 @@
509
509
  <h2 class="section-head-title">工具调用排行</h2>
510
510
  <span class="label section-head-en">/ TOOL CALLS</span>
511
511
  <span class="section-head-line"></span>
512
+ <div style="display:flex;align-items:center;gap:8px;margin-left:auto;">
513
+ <div style="display:flex;gap:0;border:1px solid var(--border);border-radius:4px;overflow:hidden;">
514
+ <button class="period-btn" :class="toolRankTab === 'all' ? 'active' : ''" @click="setToolRankTab('all')" style="border:none;font-size:10px;padding:2px 8px;" x-text="'全部 ' + toolRankAllTotal"></button>
515
+ <button class="period-btn" :class="toolRankTab === 'skill' ? 'active' : ''" @click="setToolRankTab('skill')" style="border:none;font-size:10px;padding:2px 8px;" x-text="'Skill ' + toolRankSkillTotal"></button>
516
+ <button class="period-btn" :class="toolRankTab === 'mcp' ? 'active' : ''" @click="setToolRankTab('mcp')" style="border:none;font-size:10px;padding:2px 8px;" x-text="'MCP ' + toolRankMcpTotal"></button>
517
+ </div>
518
+ </div>
512
519
  </div>
513
520
  <div class="card" style="padding:24px;">
514
- <div id="toolCallsContainer" style="display:flex;flex-direction:column;gap:12px;">
515
- <template x-for="(t, i) in toolRankData" :key="t.name">
516
- <div class="tool-rank">
517
- <span class="font-mono" style="font-size:10px;opacity:0.4" x-text="String(i+1).padStart(2,'0')"></span>
518
- <span class="font-mono" style="font-size:12px" x-text="t.name"></span>
519
- <div class="bar-track"><div class="bar-fill" :style="'width:' + t.pct + '%;background:' + (i === 0 ? colors.rust : 'var(--foreground)')"></div></div>
520
- <span class="font-mono text-right" style="font-size:11px" x-text="t.value"></span>
521
+ <div id="toolCallsContainer" style="display:flex;flex-direction:column;gap:12px;max-height:420px;overflow-y:auto;padding-right:4px;">
522
+ <template x-for="(t, i) in toolRankData" :key="toolRankTab + '-' + t.name + '-' + i">
523
+ <div style="display:contents">
524
+ <!-- MCP 分组行 -->
525
+ <div x-show="t.isGroup" class="tool-rank-group">
526
+ <span class="font-mono" x-text="t.name"></span>
527
+ </div>
528
+ <!-- 普通排行行 -->
529
+ <div x-show="!t.isGroup" class="tool-rank" :class="toolRankTab === 'all' ? 'has-note' : ''">
530
+ <span class="font-mono" style="font-size:10px;opacity:0.4" x-text="String(t.rank || (i+1)).padStart(2,'0')"></span>
531
+ <span class="font-mono" style="font-size:12px" x-text="t.name" :title="t.name"></span>
532
+ <div class="bar-track"><div class="bar-fill" :style="'width:' + t.pct + '%;background:' + (t.rank === 1 ? colors.rust : 'var(--foreground)')"></div></div>
533
+ <span class="font-mono text-right" style="font-size:11px" x-text="t.calls === t.uses ? t.calls : t.calls + '/' + t.uses" :title="t.calls === t.uses ? '使用次数:' + t.calls : '调用次数 / 使用次数:' + t.calls + ' / ' + t.uses"></span>
534
+ <!-- 通俗备注(仅全部工具 Tab)-->
535
+ <span x-show="toolRankTab === 'all' && t.displayName" style="font-size:11px;opacity:0.45;white-space:nowrap;" x-text="t.displayName"></span>
536
+ </div>
521
537
  </div>
522
538
  </template>
523
539
  </div>
@@ -546,6 +562,7 @@
546
562
  <div style="display:flex;border:1px solid var(--border);border-radius:6px;overflow:hidden;">
547
563
  <button class="period-btn" :class="reportLevel === 'detailed' ? 'active' : ''" @click="setReportLevel('detailed')" style="border-left:none">详细 Detail</button>
548
564
  <button class="period-btn" :class="reportLevel === 'brief' ? 'active' : ''" @click="setReportLevel('brief')">简略 Brief</button>
565
+ <button class="period-btn" :class="reportLevel === 'boss' ? 'active' : ''" @click="setReportLevel('boss')">汇报 Boss</button>
549
566
  </div>
550
567
  <button class="btn btn-outline" @click="copyReport()" style="display:inline-flex;align-items:center;gap:6px;">
551
568
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
@@ -713,21 +730,64 @@
713
730
 
714
731
  <!-- ── Settings Modal ── -->
715
732
  <div id="settingsModal" class="modal-overlay" style="display:none;">
716
- <div class="modal-backdrop" onclick="document.getElementById('settingsModal').style.display='none'"></div>
717
- <div class="modal-panel">
733
+ <div class="modal-backdrop" onclick="closeSettings()"></div>
734
+ <div class="modal-panel" style="max-width:580px;">
718
735
  <div class="modal-header">
719
736
  <h3 style="font-size:15px;font-weight:500;">配置</h3>
720
- <button class="rail-btn-icon" onclick="document.getElementById('settingsModal').style.display='none'" style="color:var(--foreground);opacity:0.65;">
737
+ <button class="rail-btn-icon" onclick="closeSettings()" style="color:var(--foreground);opacity:0.65;">
721
738
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
722
739
  </button>
723
740
  </div>
724
741
  <div class="modal-body">
725
- <div class="form-group"><label class="form-label">Claude 日志目录</label><input type="text" id="cfgClaudeDir" class="form-input"></div>
726
- <div class="form-group"><label class="form-label">本地项目路径(每行一个)</label><textarea id="cfgRepos" class="form-textarea" rows="3"></textarea></div>
727
- <div class="form-group"><label class="form-label">排除项目(每行一个)</label><textarea id="cfgExclude" class="form-textarea" rows="3"></textarea></div>
728
- <div class="form-group"><label class="form-label">场景关键词</label><textarea id="cfgKeywords" class="form-textarea" rows="5"></textarea><p class="form-hint">JSON 格式,如 { "coding": ["实现", "开发"] }</p></div>
742
+ <!-- 数据源 -->
743
+ <div class="cfg-section-title">数据源</div>
744
+ <div class="form-group">
745
+ <label class="form-label">Claude 日志目录</label>
746
+ <input type="text" id="cfgClaudeDir" class="form-input" placeholder="例如: C:/Users/xxx/.claude">
747
+ <p class="form-hint">Claude Code 会话日志的存放路径,通常是 ~/.claude</p>
748
+ </div>
749
+ <!-- 代码仓库 -->
750
+ <div class="cfg-section-title" style="margin-top:28px;">代码仓库</div>
751
+ <div class="form-group">
752
+ <label class="form-label">项目路径</label>
753
+ <div id="cfgReposTags" class="path-tags"></div>
754
+ <div class="path-add-box">
755
+ <span class="path-add-icon">
756
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
757
+ </span>
758
+ <input type="text" id="cfgReposInput" class="path-add-input" placeholder="粘贴或输入绝对路径..." onkeydown="if(event.key==='Enter')addPathTag('cfgReposTags','cfgReposInput')">
759
+ <button class="path-add-btn" onclick="addPathTag('cfgReposTags','cfgReposInput')">添加</button>
760
+ </div>
761
+ <p class="form-hint">按回车或点击添加,路径可单独删除</p>
762
+ </div>
763
+ <div class="form-group">
764
+ <label class="form-label">排除项目 <span style="opacity:0.4;font-weight:400;">(可选)</span></label>
765
+ <div id="cfgExcludeTags" class="path-tags"></div>
766
+ <div class="path-add-box">
767
+ <span class="path-add-icon">
768
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
769
+ </span>
770
+ <input type="text" id="cfgExcludeInput" class="path-add-input" placeholder="输入要排除的项目名..." onkeydown="if(event.key==='Enter')addPathTag('cfgExcludeTags','cfgExcludeInput')">
771
+ <button class="path-add-btn" onclick="addPathTag('cfgExcludeTags','cfgExcludeInput')">添加</button>
772
+ </div>
773
+ <p class="form-hint">按回车或点击添加,项目名称可单独删除</p>
774
+ </div>
775
+ <!-- 场景分类(默认折叠) -->
776
+ <div style="margin-top:28px;">
777
+ <button id="cfgKeywordsToggle" class="cfg-advanced-toggle" onclick="toggleKeywordsSection()">
778
+ <span>场景分类</span>
779
+ <svg id="cfgKeywordsArrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
780
+ </button>
781
+ <div id="cfgKeywordsSection" class="cfg-advanced-body" style="display:none;">
782
+ <p class="form-hint" style="margin-bottom:12px;">根据工具调用中的关键词自动判断工作场景,点击可编辑</p>
783
+ <div id="cfgKeywordsEditor"></div>
784
+ </div>
785
+ </div>
786
+ </div>
787
+ <div class="modal-footer">
788
+ <span id="cfgSaveHint" style="font-size:12px;margin-right:auto;"></span>
789
+ <button class="btn btn-primary" onclick="saveSettings()">保存配置</button>
729
790
  </div>
730
- <div class="modal-footer"><button class="btn btn-primary" onclick="saveSettings()">保存</button></div>
731
791
  </div>
732
792
  </div>
733
793
 
@@ -795,3 +855,4 @@
795
855
  <script type="module" src="/app.js"></script>
796
856
  </body>
797
857
  </html>
858
+