oh-my-knowledge 0.0.1 → 0.19.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 (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +600 -322
  3. package/README.zh.md +724 -0
  4. package/dist/src/analysis/coverage-analyzer.d.ts +57 -0
  5. package/dist/src/analysis/coverage-analyzer.d.ts.map +1 -0
  6. package/dist/src/analysis/coverage-analyzer.js +262 -0
  7. package/dist/src/analysis/coverage-analyzer.js.map +1 -0
  8. package/dist/src/analysis/gap-analyzer.d.ts +114 -0
  9. package/dist/src/analysis/gap-analyzer.d.ts.map +1 -0
  10. package/dist/src/analysis/gap-analyzer.js +439 -0
  11. package/dist/src/analysis/gap-analyzer.js.map +1 -0
  12. package/dist/src/analysis/hedging-classifier.d.ts +34 -0
  13. package/dist/src/analysis/hedging-classifier.d.ts.map +1 -0
  14. package/dist/src/analysis/hedging-classifier.js +144 -0
  15. package/dist/src/analysis/hedging-classifier.js.map +1 -0
  16. package/dist/src/analysis/report-diagnostics.d.ts +9 -0
  17. package/dist/src/analysis/report-diagnostics.d.ts.map +1 -0
  18. package/dist/src/analysis/report-diagnostics.js +631 -0
  19. package/dist/src/analysis/report-diagnostics.js.map +1 -0
  20. package/dist/src/authoring/evolver.d.ts +64 -0
  21. package/dist/src/authoring/evolver.d.ts.map +1 -0
  22. package/dist/src/authoring/evolver.js +275 -0
  23. package/dist/src/authoring/evolver.js.map +1 -0
  24. package/dist/src/authoring/generator.d.ts +13 -0
  25. package/dist/src/authoring/generator.d.ts.map +1 -0
  26. package/dist/src/authoring/generator.js +60 -0
  27. package/dist/src/authoring/generator.js.map +1 -0
  28. package/dist/src/cli.d.ts +3 -0
  29. package/dist/src/cli.d.ts.map +1 -0
  30. package/dist/src/cli.js +1012 -0
  31. package/dist/src/cli.js.map +1 -0
  32. package/dist/src/eval-core/cache.d.ts +11 -0
  33. package/dist/src/eval-core/cache.d.ts.map +1 -0
  34. package/dist/src/eval-core/cache.js +53 -0
  35. package/dist/src/eval-core/cache.js.map +1 -0
  36. package/dist/src/eval-core/ci-gates.d.ts +17 -0
  37. package/dist/src/eval-core/ci-gates.d.ts.map +1 -0
  38. package/dist/src/eval-core/ci-gates.js +42 -0
  39. package/dist/src/eval-core/ci-gates.js.map +1 -0
  40. package/dist/src/eval-core/dependency-checker.d.ts +37 -0
  41. package/dist/src/eval-core/dependency-checker.d.ts.map +1 -0
  42. package/dist/src/eval-core/dependency-checker.js +280 -0
  43. package/dist/src/eval-core/dependency-checker.js.map +1 -0
  44. package/dist/src/eval-core/evaluation-execution.d.ts +26 -0
  45. package/dist/src/eval-core/evaluation-execution.d.ts.map +1 -0
  46. package/dist/src/eval-core/evaluation-execution.js +196 -0
  47. package/dist/src/eval-core/evaluation-execution.js.map +1 -0
  48. package/dist/src/eval-core/evaluation-job.d.ts +50 -0
  49. package/dist/src/eval-core/evaluation-job.d.ts.map +1 -0
  50. package/dist/src/eval-core/evaluation-job.js +114 -0
  51. package/dist/src/eval-core/evaluation-job.js.map +1 -0
  52. package/dist/src/eval-core/evaluation-reporting.d.ts +28 -0
  53. package/dist/src/eval-core/evaluation-reporting.d.ts.map +1 -0
  54. package/dist/src/eval-core/evaluation-reporting.js +123 -0
  55. package/dist/src/eval-core/evaluation-reporting.js.map +1 -0
  56. package/dist/src/eval-core/execution-strategy.d.ts +11 -0
  57. package/dist/src/eval-core/execution-strategy.d.ts.map +1 -0
  58. package/dist/src/eval-core/execution-strategy.js +121 -0
  59. package/dist/src/eval-core/execution-strategy.js.map +1 -0
  60. package/dist/src/eval-core/fact-checker.d.ts +25 -0
  61. package/dist/src/eval-core/fact-checker.d.ts.map +1 -0
  62. package/dist/src/eval-core/fact-checker.js +66 -0
  63. package/dist/src/eval-core/fact-checker.js.map +1 -0
  64. package/dist/src/eval-core/schema.d.ts +30 -0
  65. package/dist/src/eval-core/schema.d.ts.map +1 -0
  66. package/dist/src/eval-core/schema.js +195 -0
  67. package/dist/src/eval-core/schema.js.map +1 -0
  68. package/dist/src/eval-core/statistics.d.ts +86 -0
  69. package/dist/src/eval-core/statistics.d.ts.map +1 -0
  70. package/dist/src/eval-core/statistics.js +210 -0
  71. package/dist/src/eval-core/statistics.js.map +1 -0
  72. package/dist/src/eval-core/task-planner.d.ts +4 -0
  73. package/dist/src/eval-core/task-planner.d.ts.map +1 -0
  74. package/dist/src/eval-core/task-planner.js +33 -0
  75. package/dist/src/eval-core/task-planner.js.map +1 -0
  76. package/dist/src/eval-workflows/each-evaluation-workflow.d.ts +137 -0
  77. package/dist/src/eval-workflows/each-evaluation-workflow.d.ts.map +1 -0
  78. package/dist/src/eval-workflows/each-evaluation-workflow.js +174 -0
  79. package/dist/src/eval-workflows/each-evaluation-workflow.js.map +1 -0
  80. package/dist/src/eval-workflows/evaluation-pipeline.d.ts +42 -0
  81. package/dist/src/eval-workflows/evaluation-pipeline.d.ts.map +1 -0
  82. package/dist/src/eval-workflows/evaluation-pipeline.js +218 -0
  83. package/dist/src/eval-workflows/evaluation-pipeline.js.map +1 -0
  84. package/dist/src/eval-workflows/evaluation-preparation.d.ts +64 -0
  85. package/dist/src/eval-workflows/evaluation-preparation.d.ts.map +1 -0
  86. package/dist/src/eval-workflows/evaluation-preparation.js +90 -0
  87. package/dist/src/eval-workflows/evaluation-preparation.js.map +1 -0
  88. package/dist/src/eval-workflows/run-evaluation.d.ts +111 -0
  89. package/dist/src/eval-workflows/run-evaluation.d.ts.map +1 -0
  90. package/dist/src/eval-workflows/run-evaluation.js +272 -0
  91. package/dist/src/eval-workflows/run-evaluation.js.map +1 -0
  92. package/dist/src/executors/anthropic-api.d.ts +3 -0
  93. package/dist/src/executors/anthropic-api.d.ts.map +1 -0
  94. package/dist/src/executors/anthropic-api.js +43 -0
  95. package/dist/src/executors/anthropic-api.js.map +1 -0
  96. package/dist/src/executors/claude-cli.d.ts +3 -0
  97. package/dist/src/executors/claude-cli.d.ts.map +1 -0
  98. package/dist/src/executors/claude-cli.js +103 -0
  99. package/dist/src/executors/claude-cli.js.map +1 -0
  100. package/dist/src/executors/claude-sdk-trace.d.ts +10 -0
  101. package/dist/src/executors/claude-sdk-trace.d.ts.map +1 -0
  102. package/dist/src/executors/claude-sdk-trace.js +100 -0
  103. package/dist/src/executors/claude-sdk-trace.js.map +1 -0
  104. package/dist/src/executors/claude-sdk.d.ts +3 -0
  105. package/dist/src/executors/claude-sdk.d.ts.map +1 -0
  106. package/dist/src/executors/claude-sdk.js +160 -0
  107. package/dist/src/executors/claude-sdk.js.map +1 -0
  108. package/dist/src/executors/gemini.d.ts +3 -0
  109. package/dist/src/executors/gemini.d.ts.map +1 -0
  110. package/dist/src/executors/gemini.js +78 -0
  111. package/dist/src/executors/gemini.js.map +1 -0
  112. package/dist/src/executors/index.d.ts +7 -0
  113. package/dist/src/executors/index.d.ts.map +1 -0
  114. package/dist/src/executors/index.js +22 -0
  115. package/dist/src/executors/index.js.map +1 -0
  116. package/dist/src/executors/openai-api.d.ts +3 -0
  117. package/dist/src/executors/openai-api.d.ts.map +1 -0
  118. package/dist/src/executors/openai-api.js +40 -0
  119. package/dist/src/executors/openai-api.js.map +1 -0
  120. package/dist/src/executors/openai-cli.d.ts +3 -0
  121. package/dist/src/executors/openai-cli.d.ts.map +1 -0
  122. package/dist/src/executors/openai-cli.js +60 -0
  123. package/dist/src/executors/openai-cli.js.map +1 -0
  124. package/dist/src/executors/script.d.ts +3 -0
  125. package/dist/src/executors/script.d.ts.map +1 -0
  126. package/dist/src/executors/script.js +63 -0
  127. package/dist/src/executors/script.js.map +1 -0
  128. package/dist/src/executors/shared.d.ts +117 -0
  129. package/dist/src/executors/shared.d.ts.map +1 -0
  130. package/dist/src/executors/shared.js +49 -0
  131. package/dist/src/executors/shared.js.map +1 -0
  132. package/dist/src/grading/assertions.d.ts +18 -0
  133. package/dist/src/grading/assertions.d.ts.map +1 -0
  134. package/dist/src/grading/assertions.js +239 -0
  135. package/dist/src/grading/assertions.js.map +1 -0
  136. package/dist/src/grading/index.d.ts +26 -0
  137. package/dist/src/grading/index.d.ts.map +1 -0
  138. package/dist/src/grading/index.js +98 -0
  139. package/dist/src/grading/index.js.map +1 -0
  140. package/dist/src/grading/judge.d.ts +13 -0
  141. package/dist/src/grading/judge.d.ts.map +1 -0
  142. package/dist/src/grading/judge.js +98 -0
  143. package/dist/src/grading/judge.js.map +1 -0
  144. package/dist/src/grading/layered-scores.d.ts +13 -0
  145. package/dist/src/grading/layered-scores.d.ts.map +1 -0
  146. package/dist/src/grading/layered-scores.js +62 -0
  147. package/dist/src/grading/layered-scores.js.map +1 -0
  148. package/dist/src/inputs/eval-config.d.ts +13 -0
  149. package/dist/src/inputs/eval-config.d.ts.map +1 -0
  150. package/dist/src/inputs/eval-config.js +136 -0
  151. package/dist/src/inputs/eval-config.js.map +1 -0
  152. package/dist/src/inputs/load-samples.d.ts +16 -0
  153. package/dist/src/inputs/load-samples.d.ts.map +1 -0
  154. package/dist/src/inputs/load-samples.js +51 -0
  155. package/dist/src/inputs/load-samples.js.map +1 -0
  156. package/dist/src/inputs/mcp-resolver.d.ts +50 -0
  157. package/dist/src/inputs/mcp-resolver.d.ts.map +1 -0
  158. package/dist/src/inputs/mcp-resolver.js +307 -0
  159. package/dist/src/inputs/mcp-resolver.js.map +1 -0
  160. package/dist/src/inputs/skill-loader.d.ts +18 -0
  161. package/dist/src/inputs/skill-loader.d.ts.map +1 -0
  162. package/dist/src/inputs/skill-loader.js +222 -0
  163. package/dist/src/inputs/skill-loader.js.map +1 -0
  164. package/dist/src/inputs/url-fetcher.d.ts +19 -0
  165. package/dist/src/inputs/url-fetcher.d.ts.map +1 -0
  166. package/dist/src/inputs/url-fetcher.js +241 -0
  167. package/dist/src/inputs/url-fetcher.js.map +1 -0
  168. package/dist/src/observability/skill-health-analyzer.d.ts +83 -0
  169. package/dist/src/observability/skill-health-analyzer.d.ts.map +1 -0
  170. package/dist/src/observability/skill-health-analyzer.js +238 -0
  171. package/dist/src/observability/skill-health-analyzer.js.map +1 -0
  172. package/dist/src/observability/trace-adapter.d.ts +75 -0
  173. package/dist/src/observability/trace-adapter.d.ts.map +1 -0
  174. package/dist/src/observability/trace-adapter.js +367 -0
  175. package/dist/src/observability/trace-adapter.js.map +1 -0
  176. package/dist/src/renderer/html-renderer.d.ts +9 -0
  177. package/dist/src/renderer/html-renderer.d.ts.map +1 -0
  178. package/dist/src/renderer/html-renderer.js +360 -0
  179. package/dist/src/renderer/html-renderer.js.map +1 -0
  180. package/dist/src/renderer/layout.d.ts +13 -0
  181. package/dist/src/renderer/layout.d.ts.map +1 -0
  182. package/dist/src/renderer/layout.js +582 -0
  183. package/dist/src/renderer/layout.js.map +1 -0
  184. package/dist/src/renderer/skill-health-renderer.d.ts +20 -0
  185. package/dist/src/renderer/skill-health-renderer.d.ts.map +1 -0
  186. package/dist/src/renderer/skill-health-renderer.js +253 -0
  187. package/dist/src/renderer/skill-health-renderer.js.map +1 -0
  188. package/dist/src/renderer/summary.d.ts +21 -0
  189. package/dist/src/renderer/summary.d.ts.map +1 -0
  190. package/dist/src/renderer/summary.js +1072 -0
  191. package/dist/src/renderer/summary.js.map +1 -0
  192. package/dist/src/renderer/table.d.ts +3 -0
  193. package/dist/src/renderer/table.d.ts.map +1 -0
  194. package/dist/src/renderer/table.js +134 -0
  195. package/dist/src/renderer/table.js.map +1 -0
  196. package/dist/src/renderer/trends.d.ts +6 -0
  197. package/dist/src/renderer/trends.d.ts.map +1 -0
  198. package/dist/src/renderer/trends.js +127 -0
  199. package/dist/src/renderer/trends.js.map +1 -0
  200. package/dist/src/server/job-store.d.ts +4 -0
  201. package/dist/src/server/job-store.d.ts.map +1 -0
  202. package/dist/src/server/job-store.js +68 -0
  203. package/dist/src/server/job-store.js.map +1 -0
  204. package/dist/src/server/report-server.d.ts +17 -0
  205. package/dist/src/server/report-server.d.ts.map +1 -0
  206. package/dist/src/server/report-server.js +591 -0
  207. package/dist/src/server/report-server.js.map +1 -0
  208. package/dist/src/server/report-store.d.ts +44 -0
  209. package/dist/src/server/report-store.d.ts.map +1 -0
  210. package/dist/src/server/report-store.js +190 -0
  211. package/dist/src/server/report-store.js.map +1 -0
  212. package/dist/src/types.d.ts +559 -0
  213. package/dist/src/types.d.ts.map +1 -0
  214. package/dist/src/types.js +2 -0
  215. package/dist/src/types.js.map +1 -0
  216. package/package.json +53 -5
@@ -0,0 +1,591 @@
1
+ import { createServer } from 'node:http';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { renderRunList, renderRunDetail, renderEachRunDetail, renderTrendsPage } from '../renderer/html-renderer.js';
6
+ import { renderSkillHealthReport } from '../renderer/skill-health-renderer.js';
7
+ import { DEFAULT_LANG, t, layout } from '../renderer/layout.js';
8
+ import { createFileJobStore, DEFAULT_JOBS_DIR } from './job-store.js';
9
+ import { createFileStore, queryJob, queryJobList, queryRun, queryRunList, queryTrend } from './report-store.js';
10
+ const DEFAULT_PORT = 7799;
11
+ const DEFAULT_REPORTS_DIR = join(homedir(), '.oh-my-knowledge', 'reports');
12
+ const DEFAULT_ANALYSES_DIR = join(homedir(), '.oh-my-knowledge', 'analyses');
13
+ function listAnalyses(dir) {
14
+ if (!existsSync(dir))
15
+ return [];
16
+ const items = [];
17
+ for (const file of readdirSync(dir)) {
18
+ if (!file.endsWith('.json'))
19
+ continue;
20
+ try {
21
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
22
+ if (!data.meta || !data.overall)
23
+ continue;
24
+ items.push({
25
+ id: file.replace(/\.json$/, ''),
26
+ generatedAt: data.meta.generatedAt,
27
+ sessionCount: data.meta.sessionCount,
28
+ segmentCount: data.meta.segmentCount,
29
+ skillCount: Object.keys(data.bySkill || {}).length,
30
+ healthBand: data.overall.healthBand,
31
+ });
32
+ }
33
+ catch { /* skip corrupt */ }
34
+ }
35
+ // 最新在前
36
+ items.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
37
+ return items;
38
+ }
39
+ function loadAnalysis(dir, id) {
40
+ const path = join(dir, `${id}.json`);
41
+ if (!existsSync(path))
42
+ return null;
43
+ try {
44
+ return JSON.parse(readFileSync(path, 'utf-8'));
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * 扫 analyses/ 所有 JSON,按 skillName 过滤,按时间排序成 trend points。
52
+ */
53
+ function querySkillTrend(dir, skillName) {
54
+ const items = listAnalyses(dir);
55
+ const points = [];
56
+ for (const it of items) {
57
+ const report = loadAnalysis(dir, it.id);
58
+ if (!report)
59
+ continue;
60
+ const h = report.bySkill[skillName];
61
+ if (!h)
62
+ continue;
63
+ // 旧格式 (加 usage 字段前的 analysis) 用 safe access,缺字段降级为 0/undefined
64
+ const u = h.usage;
65
+ const billable = (u?.inputTokens ?? 0) + (u?.outputTokens ?? 0);
66
+ const cached = (u?.cacheReadTokens ?? 0) + (u?.cacheCreationTokens ?? 0);
67
+ points.push({
68
+ analysisId: it.id,
69
+ generatedAt: report.meta.generatedAt,
70
+ gapRate: h.gap?.gapRate ?? 0,
71
+ weightedGapRate: h.gap?.weightedGapRate ?? 0,
72
+ failureRate: h.toolFailureRate ?? 0,
73
+ coverageRate: h.coverage?.fileCoverageRate ?? null,
74
+ billableTokens: billable,
75
+ cachedTokens: cached,
76
+ totalTokens: u?.totalTokens ?? 0,
77
+ avgTokensPerSegment: u?.avgTokensPerSegment ?? 0,
78
+ durationMs: u?.durationMs ?? 0,
79
+ segmentCount: h.segmentCount ?? 0,
80
+ stability: h.stability ?? 'stable',
81
+ });
82
+ }
83
+ // 最旧在前,便于折线图从左到右展示时间序列
84
+ points.sort((a, b) => a.generatedAt.localeCompare(b.generatedAt));
85
+ return { skillName, points };
86
+ }
87
+ /**
88
+ * 比较两份 skill health report. `from` 通常是较早的,`to` 是较晚的;
89
+ * 对于每个 skill, 显示前后值和 delta. 缺失一侧时 presence 标记。
90
+ */
91
+ function querySkillDiff(dir, fromId, toId) {
92
+ const from = loadAnalysis(dir, fromId);
93
+ const to = loadAnalysis(dir, toId);
94
+ if (!from || !to)
95
+ return null;
96
+ const allSkills = new Set([...Object.keys(from.bySkill), ...Object.keys(to.bySkill)]);
97
+ const rows = [];
98
+ for (const skill of allSkills) {
99
+ const f = from.bySkill[skill];
100
+ const t = to.bySkill[skill];
101
+ if (f && t) {
102
+ rows.push({
103
+ skillName: skill,
104
+ presence: 'both',
105
+ fromGap: f.gap.weightedGapRate,
106
+ toGap: t.gap.weightedGapRate,
107
+ deltaGap: t.gap.weightedGapRate - f.gap.weightedGapRate,
108
+ fromFailure: f.toolFailureRate,
109
+ toFailure: t.toolFailureRate,
110
+ deltaFailure: t.toolFailureRate - f.toolFailureRate,
111
+ fromCoverage: f.coverage?.fileCoverageRate ?? null,
112
+ toCoverage: t.coverage?.fileCoverageRate ?? null,
113
+ deltaCoverage: (f.coverage?.fileCoverageRate != null && t.coverage?.fileCoverageRate != null)
114
+ ? t.coverage.fileCoverageRate - f.coverage.fileCoverageRate
115
+ : null,
116
+ fromSegments: f.segmentCount,
117
+ toSegments: t.segmentCount,
118
+ deltaSegments: t.segmentCount - f.segmentCount,
119
+ });
120
+ }
121
+ else if (f) {
122
+ rows.push({ skillName: skill, presence: 'only-from', fromGap: f.gap.weightedGapRate, fromFailure: f.toolFailureRate, fromCoverage: f.coverage?.fileCoverageRate ?? null, fromSegments: f.segmentCount });
123
+ }
124
+ else if (t) {
125
+ rows.push({ skillName: skill, presence: 'only-to', toGap: t.gap.weightedGapRate, toFailure: t.toolFailureRate, toCoverage: t.coverage?.fileCoverageRate ?? null, toSegments: t.segmentCount });
126
+ }
127
+ }
128
+ // 按 deltaGap 绝对值倒序 (变化大的在前,缺失的放最后)
129
+ rows.sort((a, b) => {
130
+ const aDelta = a.presence === 'both' ? Math.abs(a.deltaGap) : -1;
131
+ const bDelta = b.presence === 'both' ? Math.abs(b.deltaGap) : -1;
132
+ return bDelta - aDelta;
133
+ });
134
+ return { fromId, toId, fromAt: from.meta.generatedAt, toAt: to.meta.generatedAt, rows };
135
+ }
136
+ function fmtDelta(d, isPercent = true) {
137
+ if (d == null)
138
+ return '—';
139
+ const pct = isPercent ? d * 100 : d;
140
+ const sign = pct > 0 ? '+' : '';
141
+ const color = Math.abs(pct) < 1 ? '#888' : pct > 0 ? '#dc2626' : '#16a34a';
142
+ return `<span style="color:${color}">${sign}${pct.toFixed(isPercent ? 1 : 0)}${isPercent ? '%' : ''}</span>`;
143
+ }
144
+ function fmtPct(v) {
145
+ if (v == null)
146
+ return '—';
147
+ return `${Math.round(v * 100)}%`;
148
+ }
149
+ function renderSkillDiffPage(diff, lang = DEFAULT_LANG) {
150
+ const { fromId, toId, fromAt, toAt, rows } = diff;
151
+ const langQ = lang === DEFAULT_LANG ? '' : `?lang=${lang}`;
152
+ const rowHtml = rows.map((r) => {
153
+ const tag = r.presence === 'only-from' ? `<span style="color:var(--green);font-size:10px;padding:1px 6px;background:var(--green-bg);border-radius:3px" data-i18n="diffTagRemoved">${t('diffTagRemoved', lang)}</span>`
154
+ : r.presence === 'only-to' ? `<span style="color:var(--accent);font-size:10px;padding:1px 6px;background:var(--info-bg);border-radius:3px" data-i18n="diffTagNew">${t('diffTagNew', lang)}</span>`
155
+ : '';
156
+ return `<tr>
157
+ <td style="padding:8px 10px;font-family:ui-monospace,monospace">${r.skillName} ${tag}</td>
158
+ <td style="padding:8px 10px;text-align:right">${r.fromSegments ?? '—'} → ${r.toSegments ?? '—'} ${r.presence === 'both' ? `(${fmtDelta(r.deltaSegments, false)})` : ''}</td>
159
+ <td style="padding:8px 10px;text-align:right">${fmtPct(r.fromGap)} → ${fmtPct(r.toGap)} ${r.presence === 'both' ? fmtDelta(r.deltaGap) : ''}</td>
160
+ <td style="padding:8px 10px;text-align:right">${fmtPct(r.fromFailure)} → ${fmtPct(r.toFailure)} ${r.presence === 'both' ? fmtDelta(r.deltaFailure) : ''}</td>
161
+ <td style="padding:8px 10px;text-align:right">${fmtPct(r.fromCoverage)} → ${fmtPct(r.toCoverage)} ${r.presence === 'both' && r.deltaCoverage != null ? fmtDelta(r.deltaCoverage) : ''}</td>
162
+ </tr>`;
163
+ }).join('');
164
+ const body = `
165
+ <main style="max-width:1000px;margin:0 auto;padding:24px">
166
+ <nav style="margin-bottom:8px">
167
+ <a href="/analyses${langQ}" data-i18n="backToAnalyses" style="color:var(--accent);text-decoration:none;margin-right:12px">${t('backToAnalyses', lang)}</a>
168
+ <a href="/analyses/${encodeURIComponent(fromId)}${langQ}" data-i18n="diffNavFrom" style="color:var(--accent);text-decoration:none;margin-right:12px">${t('diffNavFrom', lang)}</a>
169
+ <a href="/analyses/${encodeURIComponent(toId)}${langQ}" data-i18n="diffNavTo" style="color:var(--accent);text-decoration:none">${t('diffNavTo', lang)}</a>
170
+ </nav>
171
+ <h1 data-i18n="skillDiffHeading" style="font-size:20px;margin:8px 0">${t('skillDiffHeading', lang)}</h1>
172
+ <div style="color:var(--text-muted);font-size:13px;margin-bottom:20px">
173
+ <span data-i18n="diffNavFrom">${t('diffNavFrom', lang)}</span> <code>${fromId}</code> (${fromAt.slice(0, 19).replace('T', ' ')}) → <span data-i18n="diffNavTo">${t('diffNavTo', lang)}</span> <code>${toId}</code> (${toAt.slice(0, 19).replace('T', ' ')})<br/>
174
+ <span data-i18n="diffSortHint">${t('diffSortHint', lang)}</span>
175
+ </div>
176
+ <table style="border-collapse:collapse;width:100%;font-size:13px">
177
+ <thead><tr>
178
+ <th style="text-align:left;padding:10px;border-bottom:2px solid var(--border);font-weight:600" data-i18n="diffColSkill">${t('diffColSkill', lang)}</th>
179
+ <th style="text-align:right;padding:10px;border-bottom:2px solid var(--border);font-weight:600" data-i18n="diffColSegments">${t('diffColSegments', lang)}</th>
180
+ <th style="text-align:right;padding:10px;border-bottom:2px solid var(--border);font-weight:600" data-i18n="diffColWeightedGap">${t('diffColWeightedGap', lang)}</th>
181
+ <th style="text-align:right;padding:10px;border-bottom:2px solid var(--border);font-weight:600" data-i18n="diffColFailureRate">${t('diffColFailureRate', lang)}</th>
182
+ <th style="text-align:right;padding:10px;border-bottom:2px solid var(--border);font-weight:600" data-i18n="diffColCoverage">${t('diffColCoverage', lang)}</th>
183
+ </tr></thead>
184
+ <tbody>${rowHtml}</tbody>
185
+ </table>
186
+ </main>`;
187
+ return layout(t('skillDiffHeading', lang), body, lang);
188
+ }
189
+ function renderSkillTrendPage(trend, lang = DEFAULT_LANG) {
190
+ const { skillName, points } = trend;
191
+ const langQ = lang === DEFAULT_LANG ? '' : `?lang=${lang}`;
192
+ if (points.length === 0) {
193
+ const emptyBody = `
194
+ <main style="max-width:900px;margin:0 auto;padding:24px">
195
+ <nav style="margin-bottom:12px"><a href="/analyses${langQ}" data-i18n="backToAnalyses" style="color:var(--accent);text-decoration:none">${t('backToAnalyses', lang)}</a></nav>
196
+ <h1 style="font-size:20px;margin:8px 0 4px"><span data-i18n="skillTrendHeading">${t('skillTrendHeading', lang)}</span> · ${skillName}</h1>
197
+ <p style="color:var(--text-muted)" data-i18n="noTrendData">${t('noTrendData', lang)}</p>
198
+ </main>`;
199
+ return layout(`${t('skillTrendHeading', lang)} · ${skillName}`, emptyBody, lang);
200
+ }
201
+ // SVG 折线图: gapRate 主线 + failureRate 辅线, X 轴时间序
202
+ const W = 760, H = 200, PAD = 40;
203
+ const toX = (i) => points.length === 1 ? W / 2 : PAD + (i / (points.length - 1)) * (W - 2 * PAD);
204
+ const toY = (v) => H - PAD - v * (H - 2 * PAD);
205
+ const pathOf = (key) => {
206
+ const usable = points.map((p, i) => ({ x: toX(i), y: p[key] ?? null }));
207
+ let d = '';
208
+ for (const pt of usable) {
209
+ if (pt.y == null)
210
+ continue;
211
+ d += d ? ` L ${pt.x} ${toY(pt.y)}` : `M ${pt.x} ${toY(pt.y)}`;
212
+ }
213
+ return d;
214
+ };
215
+ const dots = (key, color) => points.map((p, i) => p[key] == null ? '' : `<circle cx="${toX(i)}" cy="${toY(p[key])}" r="3" fill="${color}"/>`).join('');
216
+ const yTicks = [0, 0.5, 1.0].map((v) => `<g><line x1="${PAD}" y1="${toY(v)}" x2="${W - PAD}" y2="${toY(v)}" stroke="var(--border)"/><text x="${PAD - 6}" y="${toY(v) + 4}" text-anchor="end" font-size="11" fill="var(--text-muted)">${Math.round(v * 100)}%</text></g>`).join('');
217
+ const svg = `<svg viewBox="0 0 ${W} ${H}" style="width:100%;max-width:${W}px;height:${H}px;background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius)">
218
+ ${yTicks}
219
+ <path d="${pathOf('gapRate')}" stroke="#f87171" stroke-width="2" fill="none"/>
220
+ <path d="${pathOf('weightedGapRate')}" stroke="#fbbf24" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
221
+ <path d="${pathOf('failureRate')}" stroke="#a78bfa" stroke-width="2" fill="none"/>
222
+ <path d="${pathOf('coverageRate')}" stroke="#4ade80" stroke-width="2" fill="none"/>
223
+ ${dots('gapRate', '#f87171')}${dots('weightedGapRate', '#fbbf24')}${dots('failureRate', '#a78bfa')}${dots('coverageRate', '#4ade80')}
224
+ </svg>`;
225
+ const legend = `<div style="margin:12px 0;font-size:12px;color:var(--text-secondary)">
226
+ <span style="color:#f87171">● <span data-i18n="trendLegendGap">${t('trendLegendGap', lang)}</span></span> ·
227
+ <span style="color:#fbbf24">◆ <span data-i18n="trendLegendWeighted">${t('trendLegendWeighted', lang)}</span></span> ·
228
+ <span style="color:#a78bfa">● <span data-i18n="trendLegendFailure">${t('trendLegendFailure', lang)}</span></span> ·
229
+ <span style="color:#4ade80">● <span data-i18n="trendLegendCoverage">${t('trendLegendCoverage', lang)}</span></span>
230
+ </div>`;
231
+ const rows = points.map((p) => `<tr>
232
+ <td style="padding:6px 10px;font-family:ui-monospace,monospace;font-size:12px"><a href="/analyses/${encodeURIComponent(p.analysisId)}${langQ}" style="color:var(--accent);text-decoration:none">${p.generatedAt.slice(0, 19).replace('T', ' ')}</a></td>
233
+ <td style="padding:6px 10px;text-align:right">${p.segmentCount}</td>
234
+ <td style="padding:6px 10px;text-align:right;color:#f87171">${Math.round(p.gapRate * 100)}%</td>
235
+ <td style="padding:6px 10px;text-align:right;color:#fbbf24">${Math.round(p.weightedGapRate * 100)}%</td>
236
+ <td style="padding:6px 10px;text-align:right;color:#a78bfa">${Math.round(p.failureRate * 100)}%</td>
237
+ <td style="padding:6px 10px;text-align:right;color:#4ade80">${p.coverageRate == null ? '—' : Math.round(p.coverageRate * 100) + '%'}</td>
238
+ <td style="padding:6px 10px;text-align:right;font-family:ui-monospace,monospace;font-size:12px" title="input+output only; cache 分开计">${(p.billableTokens / 1000).toFixed(1)}k</td>
239
+ <td style="padding:6px 10px;text-align:right;font-family:ui-monospace,monospace;font-size:12px">${(p.durationMs / 1000).toFixed(1)}s</td>
240
+ </tr>`).join('');
241
+ const subtitle = `${points.length} <span data-i18n="trendNPoints">${t('trendNPoints', lang)}</span> · <span data-i18n="trendEarliest">${t('trendEarliest', lang)}</span> ${points[0].generatedAt.slice(0, 10)} · <span data-i18n="trendLatest">${t('trendLatest', lang)}</span> ${points[points.length - 1].generatedAt.slice(0, 10)}`;
242
+ const body = `
243
+ <main style="max-width:900px;margin:0 auto;padding:24px">
244
+ <nav style="margin-bottom:8px"><a href="/analyses${langQ}" data-i18n="backToAnalyses" style="color:var(--accent);text-decoration:none">${t('backToAnalyses', lang)}</a></nav>
245
+ <h1 style="font-size:20px;margin:8px 0 4px"><span data-i18n="skillTrendHeading">${t('skillTrendHeading', lang)}</span> · ${skillName}</h1>
246
+ <div style="color:var(--text-muted);font-size:13px;margin-bottom:16px">${subtitle}</div>
247
+ ${svg}
248
+ ${legend}
249
+ <table style="border-collapse:collapse;width:100%;font-size:13px;margin-top:12px">
250
+ <thead><tr>
251
+ <th style="text-align:left;padding:8px 10px;border-bottom:2px solid var(--border);color:var(--text-secondary);font-weight:600" data-i18n="trendColTimestamp">${t('trendColTimestamp', lang)}</th>
252
+ <th style="text-align:right;padding:8px 10px;border-bottom:2px solid var(--border);color:var(--text-secondary);font-weight:600" data-i18n="trendColSegs">${t('trendColSegs', lang)}</th>
253
+ <th style="text-align:right;padding:8px 10px;border-bottom:2px solid var(--border);color:var(--text-secondary);font-weight:600" data-i18n="trendColGap">${t('trendColGap', lang)}</th>
254
+ <th style="text-align:right;padding:8px 10px;border-bottom:2px solid var(--border);color:var(--text-secondary);font-weight:600" data-i18n="trendColWeighted">${t('trendColWeighted', lang)}</th>
255
+ <th style="text-align:right;padding:8px 10px;border-bottom:2px solid var(--border);color:var(--text-secondary);font-weight:600" data-i18n="trendColFailure">${t('trendColFailure', lang)}</th>
256
+ <th style="text-align:right;padding:8px 10px;border-bottom:2px solid var(--border);color:var(--text-secondary);font-weight:600" data-i18n="trendColCoverage">${t('trendColCoverage', lang)}</th>
257
+ <th style="text-align:right;padding:8px 10px;border-bottom:2px solid var(--border);color:var(--text-secondary);font-weight:600" data-i18n="trendColTokens">${t('trendColTokens', lang)}</th>
258
+ <th style="text-align:right;padding:8px 10px;border-bottom:2px solid var(--border);color:var(--text-secondary);font-weight:600" data-i18n="trendColDuration">${t('trendColDuration', lang)}</th>
259
+ </tr></thead>
260
+ <tbody>${rows}</tbody>
261
+ </table>
262
+ </main>`;
263
+ return layout(`${t('skillTrendHeading', lang)} · ${skillName}`, body, lang);
264
+ }
265
+ function renderAnalysisList(items, lang = DEFAULT_LANG) {
266
+ const langQ = lang === DEFAULT_LANG ? '' : `?lang=${lang}`;
267
+ const body = items.length === 0
268
+ ? `
269
+ <main style="max-width:900px;margin:0 auto;padding:24px">
270
+ <nav style="margin-bottom:12px"><a href="/${langQ}" data-i18n="backToEvalReports" style="color:var(--accent);text-decoration:none">${t('backToEvalReports', lang)}</a></nav>
271
+ <h1 data-i18n="skillHealthTitle" style="font-size:20px;margin:8px 0 16px">${t('skillHealthTitle', lang)}</h1>
272
+ <div style="color:var(--text-muted);padding:16px" data-i18n="noAnalyses">${t('noAnalyses', lang)}</div>
273
+ </main>`
274
+ : (() => {
275
+ const rows = items.map((it) => {
276
+ const badgeColor = it.healthBand === 'red' ? 'var(--red)' : it.healthBand === 'yellow' ? 'var(--yellow)' : 'var(--green)';
277
+ const enc = encodeURIComponent(it.id);
278
+ return `<li style="padding:10px 14px;border-bottom:1px solid var(--border);list-style:none;display:flex;align-items:center;gap:12px">
279
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${badgeColor}"></span>
280
+ <label style="font-size:11px;color:var(--text-muted);display:flex;align-items:center;gap:3px"><input type="radio" name="from" value="${enc}" onchange="updateCompare()"> <span data-i18n="analysesFromLabel">${t('analysesFromLabel', lang)}</span></label>
281
+ <label style="font-size:11px;color:var(--text-muted);display:flex;align-items:center;gap:3px"><input type="radio" name="to" value="${enc}" onchange="updateCompare()"> <span data-i18n="analysesToLabel">${t('analysesToLabel', lang)}</span></label>
282
+ <a href="/analyses/${enc}${langQ}" style="color:var(--accent);text-decoration:none;flex:1;font-family:ui-monospace,monospace">${it.id}</a>
283
+ <span style="color:var(--text-muted);font-size:12px">${it.sessionCount} <span data-i18n="analysesSessions">${t('analysesSessions', lang)}</span> · ${it.segmentCount} <span data-i18n="analysesSegs">${t('analysesSegs', lang)}</span> · ${it.skillCount} <span data-i18n="analysesSkills">${t('analysesSkills', lang)}</span></span>
284
+ </li>`;
285
+ }).join('');
286
+ return `
287
+ <main style="max-width:900px;margin:0 auto;padding:24px">
288
+ <nav style="margin-bottom:12px"><a href="/${langQ}" data-i18n="backToEvalReports" style="color:var(--accent);text-decoration:none">${t('backToEvalReports', lang)}</a></nav>
289
+ <h1 data-i18n="skillHealthTitle" style="font-size:20px;margin:8px 0 16px">${t('skillHealthTitle', lang)}</h1>
290
+ <div style="margin-bottom:12px;padding:8px 12px;background:var(--bg-surface);border-radius:var(--radius);font-size:12px;color:var(--text-secondary)">
291
+ <span data-i18n="analysesCompareHint">${t('analysesCompareHint', lang)}</span>
292
+ <a id="compare-btn" style="margin-left:8px;padding:4px 10px;background:var(--accent);color:white;text-decoration:none;border-radius:3px;opacity:0.4;pointer-events:none" data-i18n="analysesCompareBtn">${t('analysesCompareBtn', lang)}</a>
293
+ </div>
294
+ <ul style="padding:0;margin:0;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden">${rows}</ul>
295
+ </main>
296
+ <script>
297
+ function updateCompare() {
298
+ var f = document.querySelector('input[name=from]:checked');
299
+ var t2 = document.querySelector('input[name=to]:checked');
300
+ var btn = document.getElementById('compare-btn');
301
+ if (f && t2 && f.value !== t2.value) {
302
+ btn.href = '/analyses-diff?from=' + f.value + '&to=' + t2.value + '&lang=' + (document.documentElement.dataset.lang || '${DEFAULT_LANG}');
303
+ btn.style.opacity = '1';
304
+ btn.style.pointerEvents = 'auto';
305
+ } else {
306
+ btn.removeAttribute('href');
307
+ btn.style.opacity = '0.4';
308
+ btn.style.pointerEvents = 'none';
309
+ }
310
+ }
311
+ </script>`;
312
+ })();
313
+ return layout(t('skillHealthTitle', lang), body, lang);
314
+ }
315
+ function getErrorMessage(err) {
316
+ return err instanceof Error ? err.message : String(err);
317
+ }
318
+ export function createReportServer({ port, reportsDir = DEFAULT_REPORTS_DIR, analysesDir = DEFAULT_ANALYSES_DIR, jobsDir = DEFAULT_JOBS_DIR, store, jobStore } = {}) {
319
+ let server = null;
320
+ let serverUrl = null;
321
+ // Use provided store or default to file store
322
+ const reportStore = store || createFileStore(reportsDir);
323
+ const resolvedJobStore = jobStore || createFileJobStore(jobsDir);
324
+ async function handleRequest(req, res) {
325
+ try {
326
+ const parsed = new URL(req.url || '/', 'http://127.0.0.1');
327
+ const path = parsed.pathname;
328
+ const langParam = parsed.searchParams.get('lang');
329
+ const lang = langParam === 'en' ? 'en' : langParam === 'zh' ? 'zh' : DEFAULT_LANG;
330
+ if (path === '/health') {
331
+ res.writeHead(200, { 'Content-Type': 'application/json' });
332
+ res.end(JSON.stringify({ ok: true, service: 'omk-bench' }));
333
+ return;
334
+ }
335
+ if (path === '/api/shutdown' && req.method === 'POST') {
336
+ res.writeHead(200, { 'Content-Type': 'application/json' });
337
+ res.end(JSON.stringify({ ok: true }));
338
+ // Graceful shutdown after response sent
339
+ setTimeout(() => { if (server)
340
+ server.close(); }, 100);
341
+ return;
342
+ }
343
+ if (path === '/api/runs') {
344
+ const runs = await queryRunList(reportStore);
345
+ res.writeHead(200, { 'Content-Type': 'application/json' });
346
+ res.end(JSON.stringify(runs));
347
+ return;
348
+ }
349
+ if (path === '/api/analyses') {
350
+ res.writeHead(200, { 'Content-Type': 'application/json' });
351
+ res.end(JSON.stringify(listAnalyses(analysesDir)));
352
+ return;
353
+ }
354
+ if (path === '/analyses') {
355
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
356
+ res.end(renderAnalysisList(listAnalyses(analysesDir), lang));
357
+ return;
358
+ }
359
+ const analysisDetailMatch = path.match(/^\/analyses\/(.+)$/);
360
+ if (analysisDetailMatch) {
361
+ const id = decodeURIComponent(analysisDetailMatch[1]);
362
+ const report = loadAnalysis(analysesDir, id);
363
+ if (!report) {
364
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
365
+ res.end('analysis not found');
366
+ return;
367
+ }
368
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
369
+ res.end(renderSkillHealthReport(report, lang));
370
+ return;
371
+ }
372
+ const analysisApiMatch = path.match(/^\/api\/analyses\/(.+)$/);
373
+ if (analysisApiMatch) {
374
+ const id = decodeURIComponent(analysisApiMatch[1]);
375
+ const report = loadAnalysis(analysesDir, id);
376
+ if (!report) {
377
+ res.writeHead(404, { 'Content-Type': 'application/json' });
378
+ res.end(JSON.stringify({ error: 'analysis not found' }));
379
+ return;
380
+ }
381
+ res.writeHead(200, { 'Content-Type': 'application/json' });
382
+ res.end(JSON.stringify(report));
383
+ return;
384
+ }
385
+ const skillTrendApiMatch = path.match(/^\/api\/skill-trend\/(.+)$/);
386
+ if (skillTrendApiMatch) {
387
+ const skillName = decodeURIComponent(skillTrendApiMatch[1]);
388
+ res.writeHead(200, { 'Content-Type': 'application/json' });
389
+ res.end(JSON.stringify(querySkillTrend(analysesDir, skillName)));
390
+ return;
391
+ }
392
+ const skillTrendPageMatch = path.match(/^\/skill-trend\/(.+)$/);
393
+ if (skillTrendPageMatch) {
394
+ const skillName = decodeURIComponent(skillTrendPageMatch[1]);
395
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
396
+ res.end(renderSkillTrendPage(querySkillTrend(analysesDir, skillName), lang));
397
+ return;
398
+ }
399
+ if (path === '/analyses-diff') {
400
+ const fromId = parsed.searchParams.get('from');
401
+ const toId = parsed.searchParams.get('to');
402
+ if (!fromId || !toId) {
403
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
404
+ res.end('missing from/to query params');
405
+ return;
406
+ }
407
+ const diff = querySkillDiff(analysesDir, fromId, toId);
408
+ if (!diff) {
409
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
410
+ res.end('analysis not found');
411
+ return;
412
+ }
413
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
414
+ res.end(renderSkillDiffPage(diff, lang));
415
+ return;
416
+ }
417
+ if (path === '/api/analyses-diff') {
418
+ const fromId = parsed.searchParams.get('from');
419
+ const toId = parsed.searchParams.get('to');
420
+ if (!fromId || !toId) {
421
+ res.writeHead(400, { 'Content-Type': 'application/json' });
422
+ res.end(JSON.stringify({ error: 'missing from/to query params' }));
423
+ return;
424
+ }
425
+ const diff = querySkillDiff(analysesDir, fromId, toId);
426
+ if (!diff) {
427
+ res.writeHead(404, { 'Content-Type': 'application/json' });
428
+ res.end(JSON.stringify({ error: 'analysis not found' }));
429
+ return;
430
+ }
431
+ res.writeHead(200, { 'Content-Type': 'application/json' });
432
+ res.end(JSON.stringify(diff));
433
+ return;
434
+ }
435
+ if (path === '/api/jobs') {
436
+ const limitParam = parsed.searchParams.get('limit');
437
+ const jobs = await queryJobList(resolvedJobStore, {
438
+ status: parsed.searchParams.get('status') || undefined,
439
+ reportId: parsed.searchParams.get('reportId') || undefined,
440
+ project: parsed.searchParams.get('project') || undefined,
441
+ owner: parsed.searchParams.get('owner') || undefined,
442
+ tag: parsed.searchParams.get('tag') || undefined,
443
+ limit: limitParam ? Number(limitParam) : undefined,
444
+ });
445
+ res.writeHead(200, { 'Content-Type': 'application/json' });
446
+ res.end(JSON.stringify(jobs));
447
+ return;
448
+ }
449
+ const jobApiMatch = path.match(/^\/api\/job\/(.+)$/);
450
+ if (jobApiMatch) {
451
+ const job = await queryJob(resolvedJobStore, decodeURIComponent(jobApiMatch[1]));
452
+ if (!job) {
453
+ res.writeHead(404, { 'Content-Type': 'application/json' });
454
+ res.end(JSON.stringify({ error: 'job not found' }));
455
+ return;
456
+ }
457
+ res.writeHead(200, { 'Content-Type': 'application/json' });
458
+ res.end(JSON.stringify(job));
459
+ return;
460
+ }
461
+ const runApiMatch = path.match(/^\/api\/run\/(.+)$/);
462
+ if (runApiMatch) {
463
+ const id = decodeURIComponent(runApiMatch[1]);
464
+ if (req.method === 'DELETE') {
465
+ const removed = await reportStore.remove(id);
466
+ if (!removed) {
467
+ res.writeHead(404, { 'Content-Type': 'application/json' });
468
+ res.end(JSON.stringify({ error: 'run not found' }));
469
+ }
470
+ else {
471
+ res.writeHead(200, { 'Content-Type': 'application/json' });
472
+ res.end(JSON.stringify({ ok: true }));
473
+ }
474
+ return;
475
+ }
476
+ const run = await queryRun(reportStore, id);
477
+ if (!run) {
478
+ res.writeHead(404, { 'Content-Type': 'application/json' });
479
+ res.end(JSON.stringify({ error: 'run not found' }));
480
+ return;
481
+ }
482
+ res.writeHead(200, { 'Content-Type': 'application/json' });
483
+ res.end(JSON.stringify(run));
484
+ return;
485
+ }
486
+ // Trends API
487
+ const trendsApiMatch = path.match(/^\/api\/trends\/(.+)$/);
488
+ if (trendsApiMatch) {
489
+ const variantName = decodeURIComponent(trendsApiMatch[1]);
490
+ const trend = await queryTrend(reportStore, variantName);
491
+ res.writeHead(200, { 'Content-Type': 'application/json' });
492
+ res.end(JSON.stringify({ variant: trend.variant, points: trend.points }));
493
+ return;
494
+ }
495
+ // Trends page
496
+ const trendsPageMatch = path.match(/^\/trends\/(.+)$/);
497
+ if (trendsPageMatch) {
498
+ const variantName = decodeURIComponent(trendsPageMatch[1]);
499
+ const trend = await queryTrend(reportStore, variantName);
500
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
501
+ res.end(renderTrendsPage(variantName, trend.runs));
502
+ return;
503
+ }
504
+ const runPageMatch = path.match(/^\/run\/(.+)$/);
505
+ if (runPageMatch) {
506
+ const run = await queryRun(reportStore, decodeURIComponent(runPageMatch[1]));
507
+ res.writeHead(run ? 200 : 404, { 'Content-Type': 'text/html; charset=utf-8' });
508
+ res.end(run?.each ? renderEachRunDetail(run) : renderRunDetail(run));
509
+ return;
510
+ }
511
+ if (path === '/') {
512
+ const runs = await reportStore.list();
513
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
514
+ res.end(renderRunList(runs));
515
+ return;
516
+ }
517
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
518
+ res.end('Not Found');
519
+ }
520
+ catch (err) {
521
+ res.writeHead(500, { 'Content-Type': 'application/json' });
522
+ res.end(JSON.stringify({ error: getErrorMessage(err) }));
523
+ }
524
+ }
525
+ async function start() {
526
+ if (server)
527
+ return serverUrl;
528
+ if (!existsSync(reportsDir))
529
+ mkdirSync(reportsDir, { recursive: true });
530
+ if (!existsSync(analysesDir))
531
+ mkdirSync(analysesDir, { recursive: true });
532
+ if (!existsSync(jobsDir))
533
+ mkdirSync(jobsDir, { recursive: true });
534
+ const p = port || Number(process.env.OMK_BENCH_PORT || DEFAULT_PORT);
535
+ const host = '127.0.0.1';
536
+ const boot = (listenPort) => new Promise((resolve, reject) => {
537
+ const srv = createServer(handleRequest);
538
+ srv.once('error', reject);
539
+ srv.listen(listenPort, host, () => resolve(srv));
540
+ });
541
+ try {
542
+ server = await boot(p);
543
+ }
544
+ catch {
545
+ // Port occupied — check if it's an existing omk service
546
+ const url = `http://${host}:${p}`;
547
+ let isOmk = false;
548
+ try {
549
+ const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(2000) });
550
+ const data = await res.json();
551
+ isOmk = data.service === 'omk-bench';
552
+ }
553
+ catch { /* not reachable or not omk */ }
554
+ if (isOmk) {
555
+ // Shut down old omk service, then take over the port
556
+ try {
557
+ await fetch(`${url}/api/shutdown`, { method: 'POST', signal: AbortSignal.timeout(2000) });
558
+ }
559
+ catch { /* ignore */ }
560
+ await new Promise((r) => setTimeout(r, 500));
561
+ try {
562
+ server = await boot(p);
563
+ }
564
+ catch {
565
+ throw new Error(`端口 ${p} 仍被占用,请手动关闭后重试:lsof -ti:${p} | xargs kill`);
566
+ }
567
+ }
568
+ else {
569
+ throw new Error(`端口 ${p} 已被其他程序占用。\n` +
570
+ ` 查看占用进程:lsof -i:${p}\n` +
571
+ ` 释放端口:lsof -ti:${p} | xargs kill\n` +
572
+ ` 或指定其他端口:omk bench report --port 8080`);
573
+ }
574
+ }
575
+ const addr = server.address();
576
+ serverUrl = `http://${host}:${addr.port}`;
577
+ return serverUrl;
578
+ }
579
+ async function stop() {
580
+ if (!server)
581
+ return;
582
+ await new Promise((resolve) => server.close(() => resolve()));
583
+ server = null;
584
+ serverUrl = null;
585
+ }
586
+ function getUrl() {
587
+ return serverUrl;
588
+ }
589
+ return { start, stop, getUrl };
590
+ }
591
+ //# sourceMappingURL=report-server.js.map