oh-my-knowledge 0.35.0 → 0.37.0

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.
Files changed (90) hide show
  1. package/README.md +5 -3
  2. package/README.zh.md +5 -3
  3. package/dist/assets/agent-skills/omk/SKILL.md +4 -2
  4. package/dist/assets/agent-skills/omk/references/commands.md +121 -2
  5. package/dist/authoring/evolver.js +1 -1
  6. package/dist/cli/commands/doctor.js +21 -5
  7. package/dist/cli/commands/eval/index.js +7 -6
  8. package/dist/cli/commands/evolve.d.ts +11 -0
  9. package/dist/cli/commands/evolve.js +105 -5
  10. package/dist/cli/commands/list.d.ts +25 -0
  11. package/dist/cli/commands/list.js +118 -0
  12. package/dist/cli/commands/promote.d.ts +24 -0
  13. package/dist/cli/commands/promote.js +158 -0
  14. package/dist/cli/commands/rollback.d.ts +20 -0
  15. package/dist/cli/commands/rollback.js +95 -0
  16. package/dist/cli/commands/sample.d.ts +1 -0
  17. package/dist/cli/commands/sample.js +10 -4
  18. package/dist/cli/lib/cell-format.d.ts +17 -0
  19. package/dist/cli/lib/cell-format.js +19 -0
  20. package/dist/cli/lib/cmd-flags.d.ts +1 -0
  21. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  22. package/dist/cli/lib/i18n-dict/common.js +8 -0
  23. package/dist/cli/lib/i18n-dict/list.d.ts +3 -0
  24. package/dist/cli/lib/i18n-dict/list.js +36 -0
  25. package/dist/cli/lib/i18n-dict/promote.d.ts +3 -0
  26. package/dist/cli/lib/i18n-dict/promote.js +54 -0
  27. package/dist/cli/lib/i18n-dict/rollback.d.ts +3 -0
  28. package/dist/cli/lib/i18n-dict/rollback.js +22 -0
  29. package/dist/cli/lib/i18n-dict.d.ts +4 -1
  30. package/dist/cli/lib/i18n-dict.js +6 -0
  31. package/dist/cli/lib/progress.d.ts +3 -0
  32. package/dist/cli/lib/progress.js +22 -0
  33. package/dist/cli/lib/run-tally.js +1 -1
  34. package/dist/cli/lib/shared.js +1 -1
  35. package/dist/cli/lib/source-probe.d.ts +15 -0
  36. package/dist/cli/lib/source-probe.js +129 -0
  37. package/dist/doctor/endpoint-rule.d.ts +60 -0
  38. package/dist/doctor/endpoint-rule.js +381 -0
  39. package/dist/doctor/health/load-custom-dimensions.d.ts +6 -0
  40. package/dist/doctor/health/load-custom-dimensions.js +43 -3
  41. package/dist/doctor/index.js +9 -2
  42. package/dist/eval-core/evaluation-reporting.d.ts +1 -0
  43. package/dist/eval-core/evaluation-reporting.js +6 -4
  44. package/dist/eval-workflows/batch-evaluation-workflow.js +3 -2
  45. package/dist/eval-workflows/run-evaluation.js +2 -2
  46. package/dist/executors/codex-cli.js +5 -1
  47. package/dist/inputs/skill-loader.js +6 -3
  48. package/dist/managed/index.d.ts +2 -0
  49. package/dist/managed/index.js +2 -0
  50. package/dist/managed/list-view.d.ts +58 -0
  51. package/dist/managed/list-view.js +70 -0
  52. package/dist/managed/promote-gate.d.ts +40 -0
  53. package/dist/managed/promote-gate.js +37 -0
  54. package/dist/managed/store.d.ts +25 -2
  55. package/dist/managed/store.js +137 -11
  56. package/dist/observability/experience.d.ts +2 -0
  57. package/dist/observability/experience.js +31 -2
  58. package/dist/observability/inbox.js +31 -5
  59. package/dist/observability/review-state.js +22 -11
  60. package/dist/observability/soft-standards/llm-extractor.js +3 -3
  61. package/dist/observability/soft-standards/skill-standards-store.d.ts +1 -0
  62. package/dist/observability/soft-standards/skill-standards-store.js +77 -14
  63. package/dist/observability/soft-standards/types.d.ts +2 -2
  64. package/dist/renderer/doctor-detail-renderer.d.ts +9 -0
  65. package/dist/renderer/doctor-detail-renderer.js +114 -0
  66. package/dist/renderer/html-renderer.d.ts +3 -2
  67. package/dist/renderer/html-renderer.js +102 -102
  68. package/dist/renderer/icons.d.ts +29 -0
  69. package/dist/renderer/icons.js +66 -0
  70. package/dist/renderer/layout.js +81 -68
  71. package/dist/renderer/observation-inbox/styles.d.ts +1 -1
  72. package/dist/renderer/observation-inbox/styles.js +53 -53
  73. package/dist/renderer/report-shell.d.ts +77 -0
  74. package/dist/renderer/report-shell.js +223 -0
  75. package/dist/renderer/skill-detail-renderer.js +147 -352
  76. package/dist/renderer/skill-health-renderer.js +50 -73
  77. package/dist/renderer/skill-list-renderer.js +358 -306
  78. package/dist/renderer/summary.js +272 -162
  79. package/dist/renderer/test-view.d.ts +4 -3
  80. package/dist/renderer/test-view.js +386 -135
  81. package/dist/server/report-server.js +142 -28
  82. package/dist/server/report-store.d.ts +1 -1
  83. package/dist/server/report-store.js +16 -15
  84. package/dist/server/skill-index.js +5 -4
  85. package/dist/types/doctor.d.ts +24 -2
  86. package/dist/types/doctor.js +1 -1
  87. package/dist/types/managed.d.ts +17 -3
  88. package/dist/types/observability.d.ts +6 -6
  89. package/dist/types/report.d.ts +9 -4
  90. package/package.json +1 -1
@@ -1,345 +1,397 @@
1
1
  /**
2
- * Skill 列表页 — 卡片式,关键信号(健康等级 / 参考分 / 待优化数 / 趋势)显性化。
3
- *
4
- * ┌─ skill-name ───────────────────────────────── ⚠ N 待优化 · ↗ 趋势 ─┐
5
- * │ ┌─────┐ 健康摘要一句话 │
6
- * │ │ 🟡 │ 🩺 7/8 通过 · 🧪 5 用例 80% 4.2/5 · 👁 30 段 95% 稳定 │
7
- * │ │ 良好 │ │
8
- * │ │ 85 │ │
9
- * │ └─────┘ │
10
- * └────────────────────────────────────────────────────────────────────┘
2
+ * Skill 列表页 — 仪表盘布局,精确参照内部 SkillHealth dashboard。
11
3
  */
12
4
  import { layout, e, DEFAULT_LANG } from './layout.js';
13
5
  import { assessHealth } from './skill-detail-renderer.js';
14
- function relTime(ts, lang) {
6
+ import { icon } from './icons.js';
7
+ // ── icons (Lucide SVG inline) ── 维度/状态图标统一走共享 icons.ts;此处仅保留弹框用的 help 图标。
8
+ const IC = {
9
+ helpCircle: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
10
+ };
11
+ function fmtDate(ts) {
15
12
  if (!ts)
16
- return lang === 'zh' ? '未跑' : 'never';
13
+ return '';
17
14
  try {
18
- const past = new Date(ts).getTime();
19
- const diff = Math.max(0, Date.now() - past);
20
- const mins = Math.floor(diff / 60000);
21
- if (mins < 60)
22
- return lang === 'zh' ? `${mins} 分钟前` : `${mins}m ago`;
23
- const hours = Math.floor(mins / 60);
24
- if (hours < 24)
25
- return lang === 'zh' ? `${hours} 小时前` : `${hours}h ago`;
26
- return lang === 'zh' ? `${Math.floor(hours / 24)} 天前` : `${Math.floor(hours / 24)}d ago`;
15
+ const d = new Date(ts);
16
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
27
17
  }
28
18
  catch {
29
19
  return '';
30
20
  }
31
21
  }
32
- function summarizeCard(entry, insights) {
33
- // 待优化数 = detectInsights 数(跟详情页用同一份输入,避免列表/详情口径不一致)。
34
- const warnCount = insights.length;
35
- const highWarn = insights.filter((i) => i.severity === 'high').length;
36
- // 趋势:用 eval 综合分历史第一份 vs 最后一份比较;无 history 用 doctor pass-rate fallback。
37
- let trendDir = 'none';
38
- let trendDelta = '';
39
- if (entry.evalHistory.length >= 2) {
40
- const first = entry.evalHistory[0].compositeScore;
41
- const last = entry.evalHistory[entry.evalHistory.length - 1].compositeScore;
42
- if (first != null && last != null) {
43
- const d = last - first;
44
- trendDir = d > 0.1 ? 'up' : d < -0.1 ? 'down' : 'flat';
45
- trendDelta = (d > 0 ? '+' : '') + d.toFixed(2);
46
- }
22
+ function fmtShort(ts) {
23
+ if (!ts)
24
+ return '-';
25
+ try {
26
+ const d = new Date(ts);
27
+ return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
47
28
  }
48
- else if (entry.doctorHistory.length >= 2) {
49
- const totalOf = (h) => h.passCount + h.warnCount + h.failCount;
50
- const passPctOf = (h) => totalOf(h) > 0 ? (h.passCount / totalOf(h)) * 100 : 0;
51
- const first = passPctOf(entry.doctorHistory[0]);
52
- const last = passPctOf(entry.doctorHistory[entry.doctorHistory.length - 1]);
53
- const d = last - first;
54
- trendDir = d > 5 ? 'up' : d < -5 ? 'down' : 'flat';
55
- trendDelta = (d > 0 ? '+' : '') + d.toFixed(0) + '%';
29
+ catch {
30
+ return '-';
56
31
  }
57
- return { warnCount, highWarn, trendDir, trendDelta };
58
32
  }
59
- function renderHealthSummary(entry, insights, lang) {
60
- const parts = [];
61
- if (entry.doctor) {
62
- if (entry.doctor.failCount > 0)
63
- parts.push(lang === 'zh' ? `doctor ${entry.doctor.failCount} 项不通过` : `doctor ${entry.doctor.failCount} fail`);
64
- else if (entry.doctor.warnCount > 0)
65
- parts.push(lang === 'zh' ? `doctor ${entry.doctor.warnCount} 项告警` : `doctor ${entry.doctor.warnCount} warn`);
66
- else
67
- parts.push(lang === 'zh' ? 'doctor 全通过' : 'doctor all pass');
68
- }
69
- if (entry.eval) {
70
- const t = entry.eval.passCount + entry.eval.failCount;
71
- if (t > 0) {
72
- const pct = Math.round((entry.eval.passCount / t) * 100);
73
- parts.push(lang === 'zh' ? `eval 通过率 ${pct}%` : `eval ${pct}% pass`);
74
- }
75
- }
76
- if (entry.observe) {
77
- if (entry.observe.confidence === 'underpowered') {
78
- // Too few segments to claim stable/flaky/unstable — surface as indicative only.
79
- parts.push(lang === 'zh' ? 'observe 样本不足(仅供参考)' : 'observe low N (indicative)');
33
+ // 综合健康配色:≥85 绿 / ≥60 琥珀 / 其余红。与 report-shell.healthColor 同口径,
34
+ // 保证同一个分数在首页列表与报告页 Hero 颜色一致(90 两处都是绿,不再首页橙、详情红)。
35
+ function scoreColor(s) { return s >= 85 ? '#1f9d63' : s >= 60 ? '#d97706' : '#dc2626'; }
36
+ function aggregate(entries, iMap, lang) {
37
+ let dP = 0, dW = 0, dF = 0, eP = 0, eF = 0, eSamples = 0, eSum = 0, eCt = 0, withObs = 0, hSum = 0, hCt = 0;
38
+ let eAssertP = 0, eAssertT = 0;
39
+ const gc = { excellent: 0, good: 0, fair: 0, unhealthy: 0, unscored: 0 };
40
+ const dH = [], eH = [], oH = [];
41
+ for (const ent of entries) {
42
+ const h = assessHealth(ent, iMap.get(ent.skillName) ?? [], lang);
43
+ gc[h.grade]++;
44
+ if (h.score != null) {
45
+ hSum += h.score;
46
+ hCt++;
80
47
  }
81
- else {
82
- const labels = lang === 'zh'
83
- ? { green: '稳定', yellow: '波动', red: '不稳' }
84
- : { green: 'stable', yellow: 'flaky', red: 'unstable' };
85
- parts.push(`observe ${labels[entry.observe.healthBand]}`);
48
+ if (ent.doctor) {
49
+ dP += ent.doctor.passCount;
50
+ dW += ent.doctor.warnCount;
51
+ dF += ent.doctor.failCount;
86
52
  }
87
- }
88
- if (parts.length === 0) {
89
- // Diagnosis-only skill(例如只跑了 observe ingest 拿到 skill_md_not_found):没有三大
90
- // snapshot 但有待优化项。显示「检出 N 个待优化」比「尚未运行任何检查」准确,
91
- // 否则会跟 warn badge 的「N 待优化」对不上、用户看到矛盾态。
92
- if (insights.length > 0) {
93
- return lang === 'zh' ? `检出 ${insights.length} 个待优化项` : `${insights.length} insight(s) detected`;
53
+ if (ent.eval) {
54
+ eP += ent.eval.passCount;
55
+ eF += ent.eval.failCount;
56
+ eSamples += ent.eval.totalSamples;
57
+ if (ent.eval.compositeScore != null) {
58
+ eSum += ent.eval.compositeScore;
59
+ eCt++;
60
+ }
61
+ eAssertP += ent.eval.passCount;
62
+ eAssertT += ent.eval.passCount + ent.eval.failCount;
94
63
  }
95
- return lang === 'zh' ? '尚未运行任何检查' : 'No checks run yet';
96
- }
97
- return parts.join(lang === 'zh' ? ',' : ', ');
98
- }
99
- function renderMetrics(entry, lang) {
100
- const items = [];
101
- if (entry.doctor) {
102
- const total = entry.doctor.passCount + entry.doctor.warnCount + entry.doctor.failCount;
103
- items.push(`<span class="sl-metric">🩺 ${entry.doctor.passCount}/${total} ${lang === 'zh' ? '通过' : 'pass'}</span>`);
104
- }
105
- if (entry.eval) {
106
- const total = entry.eval.passCount + entry.eval.failCount;
107
- const pct = total > 0 ? Math.round((entry.eval.passCount / total) * 100) : 0;
108
- const score = entry.eval.compositeScore != null ? entry.eval.compositeScore.toFixed(2) : '—';
109
- items.push(`<span class="sl-metric">🧪 ${entry.eval.totalSamples} ${lang === 'zh' ? '用例' : 'samples'} · ${pct}% · ${score}/5</span>`);
110
- }
111
- if (entry.observe) {
112
- if (entry.observe.confidence === 'underpowered') {
113
- // 段数过少,不报「X% 稳定」硬指标 —— 只标段数 + 仅供参考,跟摘要口径一致,避免同一张卡自相矛盾。
114
- items.push(`<span class="sl-metric">👁 ${entry.observe.segmentCount} ${lang === 'zh' ? '段 · 样本不足' : 'segs · low N'}</span>`);
64
+ if (ent.observe)
65
+ withObs++;
66
+ for (const x of ent.doctorHistory) {
67
+ const t = x.passCount + x.warnCount + x.failCount;
68
+ if (t > 0)
69
+ dH.push(Math.round((x.passCount / t) * 100));
115
70
  }
116
- else {
117
- const stab = ((1 - entry.observe.gapRate) * 100).toFixed(0);
118
- items.push(`<span class="sl-metric">👁 ${entry.observe.segmentCount} ${lang === 'zh' ? '段' : 'segs'} · ${stab}% ${lang === 'zh' ? '稳定' : 'stable'}</span>`);
71
+ for (const x of ent.evalHistory) {
72
+ if (x.compositeScore != null)
73
+ eH.push(Math.round((x.compositeScore / 5) * 100));
119
74
  }
75
+ for (const x of ent.observeHistory)
76
+ oH.push(Math.round((1 - x.gapRate) * 100));
120
77
  }
121
- return items.join('');
78
+ return { totalSkills: entries.length, dP, dW, dF, eP, eF, eSamples, eAvg: eCt > 0 ? eSum / eCt : null, eAssertP, eAssertT, withObs, lastTs: entries.flatMap((x) => [x.doctor?.timestamp, x.eval?.timestamp, x.observe?.generatedAt]).filter((x) => Boolean(x)).sort().pop() ?? null, gc, greenCt: gc.good + gc.excellent, composite: hCt > 0 ? Math.round(hSum / hCt) : null, dHist: dH, eHist: eH, oHist: oH };
122
79
  }
123
- function renderTrendBadge(t, lang) {
124
- if (t.trendDir === 'none')
125
- return '';
126
- const arrow = t.trendDir === 'up' ? '↗' : t.trendDir === 'down' ? '↘' : '→';
127
- const cls = t.trendDir === 'up' ? 'up' : t.trendDir === 'down' ? 'down' : 'flat';
128
- const label = lang === 'zh'
129
- ? (t.trendDir === 'up' ? '上升' : t.trendDir === 'down' ? '下降' : '持平')
130
- : (t.trendDir === 'up' ? 'up' : t.trendDir === 'down' ? 'down' : 'flat');
131
- return `<span class="sl-trend sl-trend--${cls}" title="${lang === 'zh' ? '基于历史评测综合分变化' : 'Based on historical eval composite delta'}">${arrow} ${label}${t.trendDelta ? ` (${e(t.trendDelta)})` : ''}</span>`;
80
+ function ring(score, size, sw) {
81
+ const r = (size - sw) / 2, c = 2 * Math.PI * r, off = c * (1 - (score ?? 0) / 100), clr = score != null ? scoreColor(score) : '#9ca3af', cx = size / 2;
82
+ return `<div style="position:relative;width:${size}px;height:${size}px"><svg width="${size}" height="${size}"><circle cx="${cx}" cy="${cx}" r="${r}" fill="none" stroke="#edf0f7" stroke-width="${sw}"/>${score != null ? `<circle cx="${cx}" cy="${cx}" r="${r}" fill="none" stroke="${clr}" stroke-width="${sw}" stroke-dasharray="${c.toFixed(2)}" stroke-dashoffset="${off.toFixed(2)}" stroke-linecap="round" transform="rotate(-90 ${cx} ${cx})" style="transition:stroke-dashoffset .6s"/>` : ''}</svg><div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center"><span style="font-size:${Math.round(size * 0.36)}px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums;color:${clr}">${score ?? '—'}</span></div></div>`;
132
83
  }
133
- function renderCard(entry, insights, langQ, lang) {
134
- const h = assessHealth(entry, insights, lang);
135
- const summary = summarizeCard(entry, insights);
136
- const lastTs = [entry.doctor?.timestamp, entry.eval?.timestamp, entry.observe?.generatedAt]
137
- .filter((s) => Boolean(s)).sort().pop();
138
- const detailHref = `/skills/${encodeURIComponent(entry.skillName)}${langQ}`;
139
- const warnBadge = summary.warnCount > 0
140
- ? `<span class="sl-warn-badge sl-warn-badge--${summary.highWarn > 0 ? 'high' : 'med'}">${summary.highWarn > 0 ? '🔴' : '⚠'} ${summary.warnCount} ${lang === 'zh' ? '待优化' : 'todo'}</span>`
141
- : `<span class="sl-warn-badge sl-warn-badge--ok">✓ ${lang === 'zh' ? '无待优化' : 'no todo'}</span>`;
142
- return `<a class="sl-card sl-card--${h.color}" data-color="${h.color}" href="${e(detailHref)}">
143
- <div class="sl-card-h">
144
- <span class="sl-card-emoji">${h.emoji}</span>
145
- <span class="sl-card-name">${e(entry.skillName)}</span>
146
- <span class="sl-card-time">${relTime(lastTs, lang)}</span>
147
- </div>
148
- <div class="sl-card-grade">
149
- <span class="sl-card-label">${e(h.label)}</span>
150
- ${h.score != null
151
- ? `<span class="sl-card-score">${h.score}<span class="sl-card-score-unit">/100</span></span>`
152
- : `<span class="sl-card-score sl-card-score--empty">—</span>`}
153
- ${warnBadge}
154
- ${renderTrendBadge(summary, lang)}
155
- </div>
156
- <div class="sl-card-summary">${renderHealthSummary(entry, insights, lang)}</div>
157
- <div class="sl-card-metrics">${renderMetrics(entry, lang)}</div>
158
- </a>`;
159
- }
160
- function renderSummaryBar(idx, allEntries, insightsByEntry, lang) {
161
- const s = idx.summary;
162
- if (s.totalSkills === 0)
163
- return '';
164
- // grade 重新统计(用 assessHealth 而不是 entry.band,跟卡片显示一致)
165
- const gradeCounts = { excellent: 0, good: 0, fair: 0, unhealthy: 0, unscored: 0 };
166
- for (const ent of allEntries) {
167
- const ins = insightsByEntry.get(ent.skillName) ?? [];
168
- const h = assessHealth(ent, ins, lang);
169
- gradeCounts[h.grade]++;
170
- }
171
- const totalSamples = allEntries.reduce((sum, ent) => sum + (ent.eval?.totalSamples ?? 0), 0);
172
- const totalWarn = allEntries.reduce((sum, ent) => sum + (insightsByEntry.get(ent.skillName)?.length ?? 0), 0);
173
- const totalHighWarn = allEntries.reduce((sum, ent) => sum + (insightsByEntry.get(ent.skillName)?.filter((i) => i.severity === 'high').length ?? 0), 0);
174
- const lastTs = allEntries.flatMap((ent) => [ent.doctor?.timestamp, ent.eval?.timestamp, ent.observe?.generatedAt])
175
- .filter((x) => Boolean(x)).sort().pop();
176
- const overview = lang === 'zh'
177
- ? `${s.totalSkills} skill · ${lang === 'zh' ? '共' : 'across'} ${totalSamples} ${lang === 'zh' ? '用例' : 'samples'}${lastTs ? ` · ${lang === 'zh' ? '最近' : 'last'} ${relTime(lastTs, lang)}` : ''}${totalWarn > 0 ? ` · ⚠ ${totalWarn} ${lang === 'zh' ? '待优化' : 'todo'}${totalHighWarn > 0 ? `(${totalHighWarn} ${lang === 'zh' ? '高优' : 'high'})` : ''}` : ''}`
178
- : `${s.totalSkills} skills · ${totalSamples} samples${lastTs ? ` · last ${relTime(lastTs, lang)}` : ''}${totalWarn > 0 ? ` · ⚠ ${totalWarn} todo${totalHighWarn > 0 ? ` (${totalHighWarn} high)` : ''}` : ''}`;
179
- const greenCount = gradeCounts.good + gradeCounts.excellent;
180
- const healthyCount = greenCount + gradeCounts.fair;
181
- const healthRate = s.totalSkills > 0 ? Math.round((healthyCount / s.totalSkills) * 100) : 0;
182
- const healthRateText = lang === 'zh'
183
- ? `健康率 ${healthyCount}/${s.totalSkills} (${healthRate}%)`
184
- : `Health ${healthyCount}/${s.totalSkills} (${healthRate}%)`;
185
- const gradeRow = `
186
- <button class="sl-filter-pill sl-filter-pill--all sl-filter-pill--active" data-filter="all">${lang === 'zh' ? '全部' : 'All'} ${s.totalSkills}</button>
187
- <button class="sl-filter-pill sl-filter-pill--red" data-filter="red">🔴 ${gradeCounts.unhealthy}</button>
188
- <button class="sl-filter-pill sl-filter-pill--yellow" data-filter="yellow">🟡 ${gradeCounts.fair}</button>
189
- <button class="sl-filter-pill sl-filter-pill--green" data-filter="green">🟢 ${greenCount}</button>
190
- <button class="sl-filter-pill sl-filter-pill--gray" data-filter="gray">⚪ ${gradeCounts.unscored}</button>
191
- <span class="sl-health-rate">${healthRateText}</span>
192
- `;
193
- return `<div class="sl-summary-card">
194
- <div class="sl-summary-line">${overview}</div>
195
- <div class="sl-summary-pills">${gradeRow}</div>
84
+ function renderPanel(a, lang) {
85
+ const zh = lang === 'zh';
86
+ const dTotal = a.dP + a.dW + a.dF;
87
+ const dScore = dTotal > 0 ? Math.round(((a.dP + a.dW * 0.5) / dTotal) * 100) : null;
88
+ // 页头(品牌已在常驻顶栏,这里不再重复 brand badge)。
89
+ const phead = `<div class="phead">
90
+ <h1>${zh ? 'Skill 健康评测工作台' : 'Skill Health Dashboard'}</h1>
91
+ <span class="phead-sub">${a.totalSkills} ${zh ? '个 skill' : 'skills'}</span>
92
+ <span class="phead-when">${icon('clock', { size: 13 })} ${zh ? '更新于' : 'updated'} ${fmtDate(a.lastTs)}</span>
93
+ </div>`;
94
+ // 综合健康(细线总览首格):ring + 标签 + 说明入口。
95
+ const ovMain = `<div class="ov-cell ov-main">
96
+ ${ring(a.composite, 60, 6)}
97
+ <div class="ov-main-lbl"><b>${zh ? '综合健康' : 'Overall health'}<span class="ov-help" onclick="document.getElementById('ep-help-modal').style.display='flex'">${IC.helpCircle}</span></b><span>${zh ? '加权 体检 / 评测 / 观察' : 'Weighted doctor / eval / observe'}</span></div>
98
+ </div>`;
99
+ const doctorCell = `<div class="ov-cell">
100
+ <div class="ov-top">${icon('doctor', { size: 15, cls: 'ov-ico' })}<span>${zh ? '健康体检' : 'Doctor'}</span></div>
101
+ ${dScore != null
102
+ ? `<div class="ov-big" style="color:${scoreColor(dScore)}">${dScore}<small> / 100</small></div>
103
+ <div class="ov-meta"><span class="c-pass">${zh ? '通过' : 'Pass'} <i>${a.dP}</i></span><span class="c-warn">${zh ? '警告' : 'Warn'} <i>${a.dW}</i></span><span class="c-fail">${zh ? '失败' : 'Fail'} <i>${a.dF}</i></span></div>`
104
+ : `<div class="ov-big ov-empty-v">${zh ? '未运行' : 'Not run'}</div>`}
196
105
  </div>`;
106
+ const evalCell = `<div class="ov-cell">
107
+ <div class="ov-top">${icon('eval', { size: 15, cls: 'ov-ico' })}<span>${zh ? '用例评测' : 'Eval'}</span></div>
108
+ ${a.eAvg != null
109
+ ? `<div class="ov-big" style="color:${scoreColor(Math.round((a.eAvg / 5) * 100))}">${a.eAvg.toFixed(1)}<small> / 5</small></div>
110
+ <div class="ov-meta"><span>${zh ? '断言' : 'Assert'} <i>${a.eAssertP}/${a.eAssertT}</i></span><span>${zh ? '用例' : 'Samples'} <i>${a.eSamples}</i></span></div>`
111
+ : `<div class="ov-big ov-empty-v">${zh ? '未运行' : 'Not run'}</div>`}
112
+ </div>`;
113
+ const obsCell = `<div class="ov-cell">
114
+ <div class="ov-top">${icon('observe', { size: 15, cls: 'ov-ico' })}<span>${zh ? '生产观察' : 'Observe'}</span></div>
115
+ ${a.withObs > 0
116
+ ? `<div class="ov-big">${a.withObs}<small> skill</small></div><div class="ov-meta ov-meta--muted">${zh ? '个 skill 有线上数据' : 'with traces'}</div>`
117
+ : `<div class="ov-big ov-empty-v">${zh ? '暂无数据' : 'No data'}</div><div class="ov-meta ov-meta--muted">${zh ? '接入线上调用后自动采集' : 'Auto-collected once invoked'}</div>`}
118
+ </div>`;
119
+ return `${phead}<div class="ov">${ovMain}${doctorCell}${evalCell}${obsCell}</div>
120
+
121
+ <div id="ep-help-modal" class="ep-modal-overlay" onclick="if(event.target===this)this.style.display='none'">
122
+ <div class="ep-modal">
123
+ <div class="ep-modal-head"><strong>${zh ? '综合健康计算说明' : 'Health Score'}</strong><button onclick="document.getElementById('ep-help-modal').style.display='none'">✕</button></div>
124
+ <div class="ep-modal-body">
125
+ <p><b>${zh ? '综合健康' : 'Overall Health'}</b> = ${zh ? '已评分 skill 综合分的算术平均(未跑任何可信维度的 skill 不计入)' : 'Arithmetic mean of scored skills’ composites (skills with no trusted dimension are excluded)'}。</p>
126
+ <p>${zh ? '单 skill 综合分 = 已运行维度各自归一到 0-100 后取算术平均:健康体检 =(通过 + 0.5×警告)/ 规则数;用例评测 = 综合分 / 5;生产观察 = 覆盖率 × 色带系数(黄 0.85 / 红 0.6),样本量不足时不计入。' : 'Per-skill composite = arithmetic mean of the dimensions that ran, each normalized to 0-100: doctor = (pass + 0.5×warn) / rules; eval = composite / 5; observe = coverage × band factor (yellow 0.85 / red 0.6), skipped when underpowered.'}</p>
127
+ <p>${zh ? '下方三格分别为:体检按规则数合并的通过率、评测为各 skill 综合分的平均、观察为有线上数据的 skill 数。' : 'The three cells below: doctor pools rule counts, eval averages per-skill composites, observe counts skills with traces.'}</p>
128
+ <p>${zh ? '分数配色:≥85 绿 / 60-84 琥珀 / <60 红。' : 'Score colors: ≥85 green / 60-84 amber / <60 red.'}</p>
129
+ </div>
130
+ </div>
131
+ </div>`;
197
132
  }
198
- function renderNextSteps(idx, allEntries, insightsByEntry, lang) {
199
- const items = [];
200
- const unhealthy = allEntries.filter((ent) => assessHealth(ent, insightsByEntry.get(ent.skillName) ?? [], lang).grade === 'unhealthy').length;
201
- if (unhealthy > 0) {
202
- items.push(lang === 'zh'
203
- ? `🔴 ${unhealthy} 个 skill 不健康 — 点开"不健康"卡片看待优化项,优先 high`
204
- : `🔴 ${unhealthy} skill(s) unhealthy — open the cards to see issues (high first)`);
133
+ // renderSkillModal 已移除:首页点行直接进 skill 详情页(doctor/eval/observe 已在该页合一),弹框预览冗余。
134
+ function renderRow(entry, insights, langQ, lang) {
135
+ const h = assessHealth(entry, insights, lang);
136
+ // 点行直接进该 skill 最新 eval 报告(无 eval 则最新 doctor);跨维度/历史在报告页内切换。
137
+ const amp = lang === DEFAULT_LANG ? '' : `&lang=${lang}`;
138
+ const href = entry.eval
139
+ ? `/reports/${encodeURIComponent(entry.eval.reportId)}${langQ}`
140
+ : entry.doctor
141
+ ? `/doctors/${encodeURIComponent(entry.doctor.reportId)}?skill=${encodeURIComponent(entry.skillName)}${amp}`
142
+ : `/runs${langQ}`;
143
+ const dT = entry.doctor ? entry.doctor.passCount + entry.doctor.warnCount + entry.doctor.failCount : 0;
144
+ const dP = dT > 0 ? Math.round(((entry.doctor.passCount + entry.doctor.warnCount * 0.5) / dT) * 100) : null;
145
+ const eP = entry.eval?.compositeScore != null ? Math.round((entry.eval.compositeScore / 5) * 100) : null;
146
+ const oP = entry.observe ? Math.round((1 - entry.observe.gapRate) * 100) : null;
147
+ const mkBar = (p, t) => { const c = p != null ? scoreColor(p) : '#edf0f7'; return `<div class="t-bar" title="${e(t)} ${p ?? '—'}"><div class="t-bar-f" style="width:${p ?? 0}%;background:${c}"></div></div>`; };
148
+ const hasScore = h.score != null;
149
+ const clr = hasScore ? scoreColor(h.score) : '#9ca3af';
150
+ const lastTs = [entry.doctor?.timestamp, entry.eval?.timestamp, entry.observe?.generatedAt].filter((s) => Boolean(s)).sort().pop() ?? null;
151
+ const reportCount = entry.doctorHistory.length + entry.evalHistory.length + entry.observeHistory.length;
152
+ const zh = lang === 'zh';
153
+ let doctorStatus;
154
+ if (!entry.doctor) {
155
+ doctorStatus = `<span class="t-dash">—</span>`;
205
156
  }
206
- const noObserve = idx.summary.totalSkills - idx.summary.withObserve;
207
- if (noObserve > 0 && idx.summary.totalSkills >= 3) {
208
- items.push(lang === 'zh'
209
- ? `👀 ${noObserve} 个 skill 没跑过 observe — 跑 omk observe 接生产数据`
210
- : `👀 ${noObserve} skills missing observe — run omk observe`);
157
+ else if (entry.doctor.failCount === 0 && entry.doctor.warnCount === 0) {
158
+ doctorStatus = `<span class="t-chk t-chk--ok">✓ ${zh ? '全通过' : 'All pass'}</span>`;
211
159
  }
212
- if (idx.summary.gray > 0) {
213
- items.push(lang === 'zh'
214
- ? `⚪ ${idx.summary.gray} 个 skill 完全没报告 — 先跑 omk doctor / omk eval`
215
- : `⚪ ${idx.summary.gray} skills with no reports — run omk doctor / omk eval first`);
160
+ else {
161
+ const parts = [`<span class="t-c-pass">${entry.doctor.passCount} ${zh ? '过' : 'P'}</span>`];
162
+ if (entry.doctor.warnCount > 0)
163
+ parts.push(`<span class="t-c-warn">${entry.doctor.warnCount} ${zh ? '警' : 'W'}</span>`);
164
+ if (entry.doctor.failCount > 0)
165
+ parts.push(`<span class="t-c-fail">${entry.doctor.failCount} ${zh ? '败' : 'F'}</span>`);
166
+ doctorStatus = `<span class="t-chk">${parts.join('')}</span>`;
216
167
  }
217
- if (items.length === 0)
218
- return '';
219
- return `<div class="sl-next-steps">
220
- <div class="sl-next-steps-title">${lang === 'zh' ? '下一步建议' : 'Next steps'}</div>
221
- <ul>${items.map((x) => `<li>${e(x)}</li>`).join('')}</ul>
222
- </div>`;
168
+ const evalTotal = entry.eval ? entry.eval.passCount + entry.eval.failCount : 0;
169
+ const evalStatus = entry.eval
170
+ ? `<span class="t-eval-meta"><b>${entry.eval.compositeScore != null ? entry.eval.compositeScore.toFixed(1) : '-'}</b><span class="t-eval-unit">/5</span> <span class="t-eval-sep">·</span> ${evalTotal > 0 ? `${entry.eval.passCount}/${evalTotal}` : '-'} <span class="t-eval-dim">${entry.eval.totalSamples}${zh ? '例' : ''}</span></span>`
171
+ : `<span class="t-dash">—</span>`;
172
+ // 整行点击直接进 skill 详情页(去掉中间弹框,更流畅)。
173
+ // 跳转地址走 data-href + 底部脚本的事件委托,不内联 onclick:skill 名含引号时
174
+ // encodeURIComponent 不会编码单引号,裸拼进 onclick 字符串会变成 JS 注入面。
175
+ return `<tr class="t-row" data-color="${h.color}" data-name="${e(entry.skillName.toLowerCase())}" data-href="${e(href)}" style="cursor:pointer">
176
+ <td class="t-skill"><span class="t-name">${e(entry.skillName)}</span></td>
177
+ <td class="t-score">${hasScore ? `<div class="t-eval"><div class="t-eval-num" style="color:${clr}"><strong>${h.score}</strong></div><div class="t-eval-bars">${mkBar(dP, zh ? '体检' : 'Doctor')}${mkBar(eP, zh ? '评测' : 'Eval')}${mkBar(oP, zh ? '观察' : 'Observe')}</div></div>` : `<span class="t-na">${zh ? '未评测' : 'N/A'}</span>`}</td>
178
+ <td>${doctorStatus}</td>
179
+ <td>${evalStatus}</td>
180
+ <td class="t-reports">${reportCount || '-'}</td>
181
+ <td class="t-time">${fmtShort(lastTs)}</td>
182
+ <td class="t-act"><a href="${e(href)}" class="t-link">${zh ? '查看详情' : 'Details'} →</a></td>
183
+ </tr>`;
223
184
  }
224
- const SKILL_LIST_CSS = `
225
- /* 顶部汇总卡 */
226
- .sl-summary-card { background:var(--bg-surface);border-radius:8px;padding:14px 18px;box-shadow:var(--shadow-sm);margin:8px 0 18px;display:flex;flex-direction:column;gap:10px }
227
- .sl-summary-line { font-size:13.5px;color:var(--text-secondary);font-variant-numeric:tabular-nums;line-height:1.55 }
228
- .sl-summary-pills { display:flex;gap:8px;flex-wrap:wrap;align-items:center }
229
- .sl-filter-pill { display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border-radius:14px;font-size:12px;font-weight:600;border:1.5px solid transparent;cursor:pointer;transition:all .15s;background:var(--bg-soft);color:var(--text-secondary) }
230
- .sl-filter-pill:hover { opacity:.85 }
231
- .sl-filter-pill--active { border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) }
232
- .sl-filter-pill--red { background:rgba(156,74,63,.10);color:#9c4a3f }
233
- .sl-filter-pill--yellow { background:rgba(176,128,48,.10);color:#b08030 }
234
- .sl-filter-pill--green { background:rgba(94,130,82,.10);color:#5e8252 }
235
- .sl-filter-pill--gray { background:var(--bg-soft);color:var(--text-muted) }
236
- .sl-filter-pill--all { background:var(--bg-soft);color:var(--text-primary) }
237
- .sl-health-rate { font-size:13px;font-weight:600;color:var(--text-secondary);margin-left:8px;padding:4px 10px;background:var(--bg-soft);border-radius:6px }
238
- .sl-card--hidden { display:none !important }
185
+ const CSS = `
186
+ /* ═══ 总览(细线 4 格,低饱和) ═══ */
187
+ .phead{display:flex;align-items:baseline;gap:12px;margin-bottom:18px}
188
+ .phead h1{font-size:20px;font-weight:700;margin:0;color:var(--text-primary);letter-spacing:-.01em}
189
+ .phead-sub{color:var(--text-muted);font-size:13px}
190
+ .phead-when{margin-left:auto;color:var(--text-muted);font-size:12.5px;display:inline-flex;align-items:center;gap:6px;font-variant-numeric:tabular-nums}
191
+ .phead-when svg{color:var(--text-faint)}
192
+ .ov{display:grid;grid-template-columns:220px 1fr 1fr 1fr;gap:1px;background:var(--border);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:22px;box-shadow:0 8px 24px rgba(31,41,55,4%)}
193
+ .ov-cell{background:#fff;padding:18px 20px}
194
+ .ov-main{display:flex;align-items:center;gap:16px}
195
+ .ov-main-lbl b{display:flex;align-items:center;gap:5px;font-size:13.5px;color:var(--text-primary);font-weight:600;margin-bottom:3px}
196
+ .ov-main-lbl span{font-size:12px;color:var(--text-secondary)}
197
+ .ov-help{display:inline-flex;color:var(--text-faint);cursor:pointer}
198
+ .ov-help:hover{color:var(--accent)}
199
+ .ov-top{display:flex;align-items:center;gap:8px;color:var(--text-secondary);font-size:12.5px;font-weight:500;margin-bottom:12px}
200
+ .ov-ico{color:var(--text-muted)}
201
+ .ov-big{font-size:26px;font-weight:700;letter-spacing:-.02em;line-height:1;color:var(--text-primary);font-variant-numeric:tabular-nums}
202
+ .ov-big small{font-size:13px;color:var(--text-muted);font-weight:500}
203
+ .ov-empty-v{font-size:14px;color:var(--text-muted);font-weight:500}
204
+ .ov-meta{margin-top:10px;display:flex;flex-wrap:wrap;gap:12px;font-size:12px;color:var(--text-secondary)}
205
+ .ov-meta i{font-style:normal;font-weight:600;font-variant-numeric:tabular-nums}
206
+ .ov-meta--muted{color:var(--text-muted)}
207
+ .c-pass{color:#1f9d63!important}.c-warn{color:#d97706!important}.c-fail{color:#dc2626!important}.c-na{color:#9ca3af!important}
208
+ @media(max-width:860px){.ov{grid-template-columns:1fr 1fr}.ov-main{grid-column:1 / -1}}
209
+ @media(max-width:560px){.ov{grid-template-columns:1fr}.ov-main{grid-column:auto}}
239
210
 
240
- /* 卡片列表 — 网格布局 */
241
- .sl-cards { display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:12px }
211
+ /* ═══ Help Modal ═══ */
212
+ .ep-modal-overlay{display:none;position:fixed;inset:0;z-index:999;background:rgba(0,0,0,.45);align-items:center;justify-content:center}
213
+ .ep-modal{background:#fff;border-radius:12px;max-width:520px;width:90%;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,.18)}
214
+ .ep-modal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
215
+ .ep-modal-head strong{font-size:16px;color:#182033}
216
+ .ep-modal-head button{background:none;border:none;font-size:18px;color:#637083;cursor:pointer;padding:4px 8px;border-radius:4px}
217
+ .ep-modal-head button:hover{background:#f8f9fd}
218
+ .ep-modal-body{color:#637083;font-size:13px;line-height:1.7}
219
+ .ep-modal-body p{margin:0 0 8px}
220
+ .ep-modal-body b{color:#182033}
242
221
 
243
- .sl-card { display:flex;flex-direction:column;gap:10px;padding:16px 18px;background:var(--bg-surface);border-radius:10px;box-shadow:var(--shadow-sm);text-decoration:none;color:var(--text-primary);transition:transform .12s,box-shadow .12s,border-color .12s;border-left:4px solid var(--border) }
244
- .sl-card:hover { transform:translateY(-1px);box-shadow:var(--shadow-md);text-decoration:none }
245
- .sl-card:focus-visible { outline:2px solid var(--accent);outline-offset:2px }
246
- .sl-card--green { border-left-color:#5e8252 }
247
- .sl-card--yellow { border-left-color:#b08030 }
248
- .sl-card--red { border-left-color:#9c4a3f }
249
- .sl-card--gray { border-left-color:var(--border) }
222
+ /* ═══ Table Panel ═══ */
223
+ .t-panel{padding:12px 14px;background:#fff;border:1px solid #e4e8f1;border-radius:8px;box-shadow:0 8px 24px rgba(31,41,55,4%)}
224
+ .t-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:12px}
225
+ .t-head-l{display:inline-flex;align-items:center;gap:8px}
226
+ .t-head h2{margin:0;color:#182033;font-size:16px;font-weight:700;line-height:1.4}
227
+ .t-head-ct{font-size:12px;font-weight:500;color:#637083}
228
+ .t-filters{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:10px}
229
+ .t-search{width:220px;padding:5px 10px 5px 30px;border:1px solid #e4e8f1;border-radius:6px;font-size:13px;color:#182033;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='M21 21l-4.3-4.3'/%3E%3C/svg%3E") 8px center no-repeat;outline:none;font-family:inherit}
230
+ .t-search:focus{border-color:#4f46e5;box-shadow:0 0 0 2px rgba(79,70,229,10%)}
231
+ .t-seg{display:inline-flex;padding:2px;background:#f0f1f3;border-radius:6px;gap:0}
232
+ .t-seg-btn{padding:4px 12px;font-size:12px;font-weight:400;border:none;cursor:pointer;background:transparent;color:#637083;transition:all .2s;outline:none;font-family:inherit;border-radius:4px}
233
+ .t-seg-btn:hover{color:#182033}
234
+ .t-seg-btn--on{background:#fff;color:#182033;box-shadow:0 1px 3px rgba(0,0,0,.08)}
250
235
 
251
- /* 顶:健康等级色卡 */
252
- .sl-card-grade { display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:7px;background:var(--bg-soft) }
253
- .sl-card--green .sl-card-grade { background:rgba(94,130,82,.08) }
254
- .sl-card--yellow .sl-card-grade { background:rgba(176,128,48,.08) }
255
- .sl-card--red .sl-card-grade { background:rgba(156,74,63,.08) }
256
- .sl-card-emoji { font-size:18px;line-height:1 }
257
- .sl-card-label { font-size:12px;font-weight:600;color:var(--text-primary);letter-spacing:0.02em;margin-top:2px }
258
- .sl-card-score { font-size:20px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1;margin-top:2px;display:flex;align-items:baseline;gap:1px }
259
- .sl-card--green .sl-card-score { color:#5e8252 }
260
- .sl-card--yellow .sl-card-score { color:#b08030 }
261
- .sl-card--red .sl-card-score { color:#9c4a3f }
262
- .sl-card--gray .sl-card-score { color:var(--text-muted) }
263
- .sl-card-score-unit { font-size:10px;color:var(--text-muted);font-weight:500 }
264
- .sl-card-score--empty { font-size:16px;color:var(--text-muted);font-weight:500 }
236
+ /* ═══ Table ═══ */
237
+ .t-tbl{width:100%;border-collapse:collapse}
238
+ .t-tbl th{text-align:left;padding:10px 16px;font-size:11px;font-weight:600;color:#9ca3af;background:transparent;border-bottom:1px solid #e4e8f1;white-space:nowrap;letter-spacing:.03em;text-transform:uppercase}
239
+ .t-tbl td{text-align:left;padding:18px 16px;border-bottom:1px solid #f1f3f7;vertical-align:middle}
240
+ .t-tbl tr:last-child td{border-bottom:none}
241
+ .t-tbl tbody tr{transition:background .12s}
242
+ .t-tbl tbody tr:hover td{background:#fafbff}
243
+ .t-skill{min-width:0}
244
+ .t-name{color:#182033;text-decoration:none;font-size:14px;font-weight:600;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
245
+ .t-name:hover{color:#4f46e5}
246
+ .t-score{}
247
+ .t-eval{display:flex;align-items:center;gap:10px}
248
+ .t-eval-num{flex-shrink:0;width:30px;text-align:right}
249
+ .t-eval-num strong{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums}
250
+ .t-eval-bars{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}
251
+ .t-bar{height:3px;background:#eef1f6;border-radius:2px;overflow:hidden;cursor:pointer}
252
+ .t-bar-f{height:100%;border-radius:2px}
253
+ .t-na{display:inline-block;padding:2px 10px;font-size:12px;color:#9ca3af;background:#f6f8fb;border-radius:10px}
254
+ .t-chk{display:inline-flex;gap:8px;font-size:12px;font-variant-numeric:tabular-nums}
255
+ .t-chk--ok{color:#9ca3af;font-weight:500}
256
+ .t-c-pass{color:#1f9d63;font-weight:600}
257
+ .t-c-warn{color:#d97706;font-weight:600}
258
+ .t-c-fail{color:#dc2626;font-weight:600}
259
+ .t-dash{color:#cbd2dd}
260
+ .t-eval-meta{font-size:12.5px;color:#637083;font-variant-numeric:tabular-nums}
261
+ .t-eval-meta b{font-size:13px;font-weight:700;color:#182033}
262
+ .t-eval-unit{color:#9ca3af}
263
+ .t-eval-sep{color:#cbd2dd;margin:0 2px}
264
+ .t-eval-dim{color:#9ca3af}
265
+ .t-reports{font-size:13px;color:#637083;font-variant-numeric:tabular-nums}
266
+ .t-time{font-size:12px;color:#9ca3af;font-variant-numeric:tabular-nums;white-space:nowrap}
267
+ .t-act{}
268
+ .t-link{font-size:12px;color:#4f46e5;text-decoration:none;font-weight:500;transition:color .15s;opacity:0}
269
+ .t-tbl tbody tr:hover .t-link{opacity:1}
270
+ .t-link:hover{text-decoration:underline}
271
+ .t-row--hidden{display:none!important}
272
+ .t-filt-out{display:none!important}
265
273
 
266
- /* 标题行 */
267
- .sl-card-h { display:flex;align-items:center;gap:8px }
268
- .sl-card-emoji { font-size:20px;line-height:1;flex-shrink:0 }
269
- .sl-card-name { font-size:15px;font-weight:600;color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1 }
270
- .sl-card-time { font-size:11px;color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;flex-shrink:0 }
271
- .sl-card-summary { font-size:13px;color:var(--text-secondary);line-height:1.5 }
272
- .sl-card-metrics { display:flex;gap:8px;flex-wrap:wrap }
273
- .sl-metric { font-size:11.5px;color:var(--text-secondary);font-variant-numeric:tabular-nums;background:var(--bg-soft);padding:3px 9px;border-radius:5px;line-height:1.5 }
274
- .sl-warn-badge { display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:11px;font-size:12px;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.4 }
275
- .sl-warn-badge--high { background:rgba(156,74,63,.14);color:#9c4a3f }
276
- .sl-warn-badge--med { background:rgba(176,128,48,.14);color:#b08030 }
277
- .sl-warn-badge--ok { background:rgba(94,130,82,.14);color:#5e8252 }
278
- .sl-trend { font-size:11px;font-weight:600;font-variant-numeric:tabular-nums;padding:1px 7px;border-radius:8px;letter-spacing:0.01em }
279
- .sl-trend--up { background:rgba(94,130,82,.14);color:#5e8252 }
280
- .sl-trend--down { background:rgba(156,74,63,.14);color:#9c4a3f }
281
- .sl-trend--flat { background:var(--bg-soft);color:var(--text-muted) }
282
- .sl-card-arrow { color:var(--text-muted);font-size:18px;font-weight:300;line-height:1;margin-top:2px }
274
+ /* ═══ Pager ═══ */
275
+ .t-pager{display:flex;align-items:center;justify-content:flex-end;gap:6px;padding:12px 14px 4px;font-size:12px;color:#637083}
276
+ .t-pg-btn{padding:4px 10px;border:1px solid #e4e8f1;border-radius:4px;background:#fff;color:#637083;cursor:pointer;font-size:12px;font-family:inherit;outline:none}
277
+ .t-pg-btn:hover{border-color:#4f46e5;color:#4f46e5}
278
+ .t-pg-btn:disabled{opacity:.4;cursor:default;border-color:#e4e8f1;color:#9ca3af}
279
+ .t-pg-info{font-variant-numeric:tabular-nums}
283
280
 
284
- /* Next steps */
285
- .sl-next-steps { margin-top:24px;padding:14px 18px;background:var(--bg-soft);border-radius:6px }
286
- .sl-next-steps-title { font-size:13px;font-weight:600;color:var(--text-primary);margin-bottom:8px;letter-spacing:0.02em }
287
- .sl-next-steps ul { margin:0;padding-left:18px;color:var(--text-secondary);font-size:13.5px;line-height:1.8 }
288
- .sl-empty-state { text-align:center;padding:60px 20px;color:var(--text-muted) }
289
- .sl-empty-state code { background:var(--bg-soft);padding:2px 6px;border-radius:3px;font-family:"SF Mono",Menlo,monospace;font-size:13px }
290
- .sl-legacy-hint { margin-top:24px;text-align:center;font-size:11.5px;color:var(--text-muted) }
291
- .sl-legacy-hint a { color:var(--accent) }
281
+ /* ═══ Legacy ═══ */
292
282
 
293
- /* 响应式 */
294
- @media(max-width:640px){
295
- .sl-cards { grid-template-columns:1fr }
296
- .sl-card-name { font-size:14px }
297
- .sl-metric { font-size:11px;padding:2px 7px }
298
- }
283
+ /* ═══ Skill Detail Modal ═══ */
284
+ .sd-overlay{display:none;position:fixed;inset:0;z-index:999;background:rgba(0,0,0,.45);align-items:center;justify-content:center}
285
+ .sd-modal{background:#fff;border-radius:12px;width:90%;max-width:760px;max-height:85vh;overflow:hidden;position:relative;box-shadow:0 20px 60px rgba(0,0,0,.18);display:flex;flex-direction:column}
286
+ .sd-close{position:absolute;top:14px;right:14px;background:none;border:none;font-size:18px;color:#637083;cursor:pointer;padding:4px 8px;border-radius:4px;z-index:1}
287
+ .sd-close:hover{background:#f8f9fd;color:#182033}
288
+ .sd-body{padding:24px 24px 22px;overflow-y:auto}
289
+ .sd-header{padding-bottom:16px;margin-bottom:16px;border-bottom:1px solid #e4e8f1}
290
+ .sd-header h2{margin:0;font-size:18px;font-weight:700;color:#182033;letter-spacing:-.2px}
291
+ .sd-meta{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:8px;color:#637083;font-size:12px}
292
+ .sd-meta span{display:inline-flex;align-items:center;gap:4px}
293
+ .sd-overall{display:flex;align-items:center;justify-content:center;gap:24px;padding:20px 24px;margin-bottom:16px;background:linear-gradient(135deg,#f8faff 0%,#f4f0ff 100%);border:1px solid #e4e8f1;border-radius:12px}
294
+ .sd-overall-meta{display:flex;flex-direction:column;align-items:flex-start;gap:4px}
295
+ .sd-overall-label{color:#637083;font-size:12px;font-weight:500}
296
+ .sd-overall-grade{display:inline-flex;align-items:baseline;gap:8px;font-size:28px;font-weight:800;line-height:1}
297
+ .sd-overall-grade small{font-size:12px;font-weight:600;color:#637083}
298
+ .sd-overall-hint{color:#637083;font-size:11px}
299
+ .sd-dim{position:relative;padding:14px 16px;margin-bottom:12px;background:#fff;border:1px solid #e4e8f1;border-radius:10px}
300
+ .sd-dim:last-of-type{margin-bottom:0}
301
+ .dim-doctor{background:rgba(37,99,235,6%);border-color:rgba(37,99,235,18%)}
302
+ .dim-eval{background:rgba(124,58,237,6%);border-color:rgba(124,58,237,18%)}
303
+ .dim-observe{background:rgba(5,150,105,6%);border-color:rgba(5,150,105,18%)}
304
+ .sd-dim-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}
305
+ .sd-dim-title{display:flex;align-items:center;gap:8px;color:#182033;font-size:14px;font-weight:700}
306
+ .sd-dim-title svg{flex-shrink:0;box-sizing:content-box;width:16px;height:16px;padding:6px;border-radius:8px}
307
+ .dim-doctor .sd-dim-title svg{color:#2563eb;background:rgba(37,99,235,12%)}
308
+ .dim-eval .sd-dim-title svg{color:#7c3aed;background:rgba(124,58,237,12%)}
309
+ .dim-observe .sd-dim-title svg{color:#059669;background:rgba(5,150,105,12%)}
310
+ .sd-dim-title span{font-size:14px;font-weight:700;color:#182033}
311
+ .sd-dim-title small{font-size:12px;font-weight:500;color:#637083;margin-left:4px}
312
+ .sd-dim-score{font-size:24px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1}
313
+ .sd-dim-score small{font-size:12px;font-weight:500;color:#637083}
314
+ .sd-dim-na{font-size:12px;color:#637083}
315
+ .sd-dim-bar{height:6px;background:rgba(255,255,255,70%);border-radius:4px;overflow:hidden;margin-bottom:12px}
316
+ .sd-dim-bar-fill{height:100%;border-radius:4px;transition:width .4s}
317
+ .sd-dim-empty{padding:12px 14px;color:#637083;font-size:12px;line-height:1.5;background:rgba(100,116,139,6%);border-radius:6px}
318
+ .m-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
319
+ .m-grid:has(> :nth-child(3):last-child){grid-template-columns:repeat(3,1fr)}
320
+ .m-cell{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#fff;border:1px solid rgba(0,0,0,4%);border-radius:8px}
321
+ .m-cell-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:7px;flex-shrink:0}
322
+ .m-cell-meta{display:flex;flex-direction:column;gap:1px;min-width:0}
323
+ .m-cell-meta span{font-size:11px;color:#637083;font-weight:500;line-height:1.2}
324
+ .m-cell-meta b{font-size:16px;font-weight:800;color:#182033;font-variant-numeric:tabular-nums;line-height:1.1}
325
+ .m-cell-meta b small{font-size:11px;font-weight:600;color:#637083;margin-left:1px}
326
+ .m-pass .m-cell-icon{background:rgba(31,157,99,14%);color:#1f9d63}
327
+ .m-warn .m-cell-icon{background:rgba(217,119,6,16%);color:#d97706}
328
+ .m-fail .m-cell-icon{background:rgba(220,38,38,14%);color:#dc2626}
329
+ .m-total .m-cell-icon{background:rgba(37,99,235,14%);color:#2563eb}
330
+ .m-eval .m-cell-icon{background:rgba(124,58,237,14%);color:#7c3aed}
331
+ .m-avg .m-cell-icon{background:rgba(99,102,241,14%);color:#6366f1}
332
+ .sd-history{margin-top:10px;padding-top:8px;border-top:1px solid #e4e8f1}
333
+ .sd-history-title{font-size:11px;font-weight:600;color:#637083;margin-bottom:6px}
334
+ .sd-history-item{display:flex;align-items:center;gap:10px;padding:4px 8px;border-radius:4px;font-size:12px;background:rgba(79,70,229,4%);font-weight:500;color:#182033}
335
+ .sd-history-date{min-width:100px;color:#637083}
336
+ .sd-history-score{font-weight:700;font-variant-numeric:tabular-nums;min-width:30px}
337
+ .sd-history-link{margin-left:auto;color:#4f46e5;font-weight:500;text-decoration:none;white-space:nowrap}
338
+ .sd-history-link:hover{text-decoration:underline}
339
+ .sd-guide{display:flex;align-items:center;gap:6px;margin-top:16px;padding:8px 12px;background:rgba(79,70,229,5%);border:1px solid rgba(79,70,229,12%);border-radius:6px;font-size:12px;color:#637083}
340
+ .sd-guide a{color:#4f46e5;font-weight:600;text-decoration:none}
341
+ .sd-guide a:hover{text-decoration:underline}
299
342
  `;
300
343
  export function renderSkillList(idx, lang = DEFAULT_LANG) {
301
344
  const langQ = lang === DEFAULT_LANG ? '' : `?lang=${lang}`;
302
- // insightsBySkill 在 buildSkillIndex 里跟 SkillIndex 一起算好(共享缓存)。
303
- // list renderer 直接消费,不再每请求 N×detectInsights 重算。
304
- const insightsByEntry = idx.insightsBySkill;
305
- const body = idx.entries.length === 0
306
- ? `<div class="sl-empty-state">
307
- <p>${lang === 'zh' ? '暂无 skill 报告。先跑一次评测:' : 'No skill reports yet. Run an evaluation first:'}</p>
308
- <p><code>omk eval --treatment my-skill</code></p>
309
- </div>`
310
- : `<div class="sl-cards">${idx.entries.map((ent) => renderCard(ent, insightsByEntry.get(ent.skillName) ?? [], langQ, lang)).join('')}</div>`;
311
- const title = lang === 'zh' ? 'OMK Studio 工作台' : 'OMK Studio';
312
- const subtitle = lang === 'zh'
313
- ? '一站式查看每个 skill 的健康状况、待优化项和历史趋势'
314
- : 'One-stop view of every skill — health, issues, and historical trends';
315
- return layout(title, `
316
- <main>
317
- <h1>${title}</h1>
318
- <p class="subtitle">${subtitle}</p>
319
- ${renderSummaryBar(idx, idx.entries, insightsByEntry, lang)}
320
- ${body}
321
- ${renderNextSteps(idx, idx.entries, insightsByEntry, lang)}
322
- <div class="sl-legacy-hint">
323
- ${lang === 'zh' ? '找老的 run 列表?' : 'Looking for the old run list?'} <a href="/runs${langQ}">/runs</a>
345
+ const iMap = idx.insightsBySkill;
346
+ const a = aggregate(idx.entries, iMap, lang);
347
+ const zh = lang === 'zh';
348
+ if (idx.entries.length === 0) {
349
+ return layout('OMK Studio', `<main><div class="t-panel"><div style="display:flex;align-items:center;gap:16px;padding:24px 20px"><div style="display:flex;align-items:center;justify-content:center;width:48px;height:48px;color:#637083;background:#f8f9fd;border:1px solid #e4e8f1;border-radius:8px">${icon('eval', { size: 24 })}</div><div><strong style="color:#182033;font-size:14px">${zh ? '暂无 skill 报告' : 'No skill reports'}</strong><br><span style="color:#637083;font-size:12px">${zh ? '先跑一次评测:' : 'Run:'} <code style="padding:1px 6px;background:rgba(79,70,229,10%);color:#4f46e5;font-size:12px;border-radius:4px">omk eval --treatment my-skill</code></span></div></div></div></main><style>${CSS}</style>`, lang);
350
+ }
351
+ const rows = idx.entries.map((ent) => renderRow(ent, iMap.get(ent.skillName) ?? [], langQ, lang)).join('');
352
+ return layout(zh ? 'OMK 评测' : 'OMK Eval', `<main>
353
+ ${renderPanel(a, lang)}
354
+ <div class="t-panel">
355
+ <div class="t-head">
356
+ <div class="t-head-l"><h2>${zh ? '明细' : 'Details'}</h2><span class="t-head-ct">${a.totalSkills} skill</span></div>
357
+ <div class="t-filters">
358
+ <input type="text" class="t-search" id="t-search" placeholder="${zh ? '搜索 skill 名称...' : 'Search...'}" />
359
+ <div class="t-seg">
360
+ <button class="t-seg-btn t-seg-btn--on" data-filter="all">${zh ? '全部' : 'All'}</button>
361
+ <button class="t-seg-btn" data-filter="red">${zh ? '异常' : 'Bad'}</button>
362
+ <button class="t-seg-btn" data-filter="yellow">${zh ? '待改进' : 'Fair'}</button>
363
+ <button class="t-seg-btn" data-filter="green">${zh ? '正常' : 'OK'}</button>
364
+ <button class="t-seg-btn" data-filter="gray">${zh ? '未评估' : 'N/A'}</button>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ <div style="overflow-x:auto">
369
+ <table class="t-tbl">
370
+ <colgroup><col style="width:200px"><col style="width:180px"><col style="width:90px"><col style="width:130px"><col style="width:50px"><col style="width:100px"><col style="width:80px"></colgroup>
371
+ <thead><tr><th>Skill</th><th>${zh ? '综合分' : 'Score'}</th><th>Doctor</th><th>Eval</th><th>${zh ? '报告数' : 'Runs'}</th><th>${zh ? '更新时间' : 'Updated'}</th><th>${zh ? '操作' : ''}</th></tr></thead>
372
+ <tbody>${rows}</tbody>
373
+ </table>
374
+ </div>
375
+ <div class="t-pager" id="t-pager"></div>
324
376
  </div>
325
- </main>
326
- <style>${SKILL_LIST_CSS}</style>
327
- <script>
328
- (function(){
329
- var pills = document.querySelectorAll('.sl-filter-pill');
330
- pills.forEach(function(pill){
331
- pill.addEventListener('click', function(){
332
- pills.forEach(function(p){ p.classList.remove('sl-filter-pill--active'); });
333
- pill.classList.add('sl-filter-pill--active');
334
- var filter = pill.getAttribute('data-filter');
335
- var cards = document.querySelectorAll('.sl-card');
336
- cards.forEach(function(card){
337
- if (filter === 'all') { card.classList.remove('sl-card--hidden'); }
338
- else { card.classList.toggle('sl-card--hidden', card.getAttribute('data-color') !== filter); }
339
- });
340
- });
341
- });
342
- })();
343
- </script>
344
- `, lang);
377
+ </main><style>${CSS}</style>
378
+ <script>!function(){
379
+ var PS=10,pg=1,bs=document.querySelectorAll('.t-seg-btn'),s=document.getElementById('t-search'),pgr=document.getElementById('t-pager');
380
+ function vis(){return Array.from(document.querySelectorAll('.t-row')).filter(function(r){return !r.classList.contains('t-filt-out')})}
381
+ function filt(){
382
+ var a=document.querySelector('.t-seg-btn--on'),fl=a?a.getAttribute('data-filter'):'all',q=(s?s.value:'').toLowerCase();
383
+ document.querySelectorAll('.t-row').forEach(function(r){r.classList.toggle('t-filt-out',!(( fl==='all'||r.getAttribute('data-color')===fl)&&(!q||(r.getAttribute('data-name')||'').indexOf(q)!==-1)))});
384
+ pg=1;page()
385
+ }
386
+ function page(){
387
+ var rs=vis(),t=rs.length,pp=Math.max(1,Math.ceil(t/PS));if(pg>pp)pg=pp;
388
+ var st=(pg-1)*PS,en=st+PS;
389
+ rs.forEach(function(r,i){r.classList.toggle('t-row--hidden',i<st||i>=en)});
390
+ if(pgr)pgr.innerHTML=t<=PS?'':\`<span class="t-pg-info">\${st+1}-\${Math.min(en,t)} / \${t}</span><button class="t-pg-btn" onclick="window.__pg(-1)" \${pg<=1?'disabled':''}>‹ ${zh ? '上一页' : 'Prev'}</button><button class="t-pg-btn" onclick="window.__pg(1)" \${pg>=pp?'disabled':''}>${zh ? '下一页' : 'Next'} ›</button>\`
391
+ }
392
+ window.__pg=function(d){pg+=d;page()};
393
+ bs.forEach(function(b){b.addEventListener('click',function(){bs.forEach(function(x){x.classList.remove('t-seg-btn--on')});b.classList.add('t-seg-btn--on');filt()})});
394
+ document.addEventListener('click',function(ev){var el=ev.target instanceof Element?ev.target:null;if(!el||el.closest('a,button'))return;var r=el.closest('.t-row[data-href]');if(r)location.href=r.getAttribute('data-href')});
395
+ if(s)s.addEventListener('input',filt);page()
396
+ }()</script>`, lang);
345
397
  }