cc-insight 0.1.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.
@@ -0,0 +1,326 @@
1
+ // public/js/insights.js
2
+ import { setRange } from './app.js'
3
+
4
+ const TOPIC_COLORS = {
5
+ '调试修复': 'var(--green)',
6
+ '新功能开发': 'var(--cyan)',
7
+ '架构设计': 'var(--amber)',
8
+ '代码重构': 'var(--purple)',
9
+ '学习探索': 'var(--red)',
10
+ '配置运维': '#06b6d4',
11
+ '数据分析': '#f97316',
12
+ '其他': 'var(--muted)',
13
+ }
14
+
15
+ // 热力图用 rgba,支持按强度调节透明度
16
+ const TOPIC_HEX = {
17
+ '调试修复': '74,222,128',
18
+ '新功能开发': '34,211,238',
19
+ '架构设计': '245,158,11',
20
+ '代码重构': '167,139,250',
21
+ '学习探索': '248,113,113',
22
+ '配置运维': '6,182,212',
23
+ '数据分析': '249,115,22',
24
+ '其他': '107,114,128',
25
+ }
26
+
27
+ function topicColor(topic) {
28
+ return TOPIC_COLORS[topic] ?? 'var(--muted)'
29
+ }
30
+
31
+ function topicCellColor(topic, count, maxCount) {
32
+ if (!count) return 'var(--bg3)'
33
+ const rgb = TOPIC_HEX[topic] ?? '107,114,128'
34
+ const p = count / maxCount
35
+ const alpha = p < 0.25 ? 0.25 : p < 0.5 ? 0.5 : p < 0.75 ? 0.75 : 1.0
36
+ return `rgba(${rgb},${alpha})`
37
+ }
38
+
39
+ const CARD_HEIGHT = 300
40
+
41
+ function rangeFilter(current) {
42
+ const ranges = [
43
+ { value: '7d', label: '7 天' },
44
+ { value: '30d', label: '30 天' },
45
+ { value: '90d', label: '90 天' },
46
+ { value: 'all', label: '全部' },
47
+ ]
48
+ return `
49
+ <div class="range-filter">
50
+ <span>时间范围:</span>
51
+ ${ranges.map(r => `
52
+ <button class="range-btn ${r.value === current ? 'active' : ''}"
53
+ data-range="${r.value}">${r.label}</button>
54
+ `).join('')}
55
+ </div>`
56
+ }
57
+
58
+ function projectName(p) {
59
+ if (!p || p === '未知') return '未知'
60
+ const parts = p.split('/')
61
+ const home = '/Users/' + (parts[2] ?? '')
62
+ if (p === home) return '~'
63
+ if (p.startsWith(home + '/')) return '~' + p.slice(home.length)
64
+ return parts.filter(Boolean).pop() ?? p
65
+ }
66
+
67
+ function summaryCards(data) {
68
+ const { roundsByTopic, durationByTopic, densityByTopic, projectDist, outlierSessions } = data
69
+ const topDensity = densityByTopic[0]
70
+ const projFiltered = (projectDist ?? []).filter(r => projectName(r.project) !== '~')
71
+ const projTotal = projFiltered.reduce((s, r) => s + (r.count ?? 0), 0)
72
+ const topProject = projFiltered[0]
73
+ const topOutlier = outlierSessions[0]
74
+
75
+ const inefficient = roundsByTopic[0]
76
+ const durationMap = Object.fromEntries((durationByTopic ?? []).map(r => [r.topic, r.pct]))
77
+ const ineffPct = inefficient ? (durationMap[inefficient.topic] ?? null) : null
78
+ const ineffSub = inefficient
79
+ ? `平均 ${inefficient.avgRounds} 轮${ineffPct !== null ? ` · 时长占 ${ineffPct}%` : ''}`
80
+ : '暂无数据'
81
+
82
+ const outlierSub = topOutlier
83
+ ? (topOutlier.firstUserMsg
84
+ ? topOutlier.firstUserMsg.slice(0, 28) + (topOutlier.firstUserMsg.length > 28 ? '…' : '')
85
+ : topOutlier.topic ?? '—')
86
+ : '暂无数据'
87
+
88
+ function card(label, value, sub, color) {
89
+ return `
90
+ <div class="card">
91
+ <div class="card-label">${label}</div>
92
+ <div class="card-value" style="color:${color};font-size:20px;word-break:break-all;">
93
+ ${value}
94
+ </div>
95
+ <div class="card-sub" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${sub}</div>
96
+ </div>`
97
+ }
98
+
99
+ return `
100
+ <div class="grid-4" style="margin-bottom:14px;">
101
+ ${card('耗时话题',
102
+ inefficient ? inefficient.topic : '—',
103
+ ineffSub,
104
+ 'var(--red)')}
105
+ ${card('Session 轮次最多',
106
+ topOutlier ? `${topOutlier.messageCount} 轮` : '—',
107
+ outlierSub,
108
+ 'var(--amber)')}
109
+ ${card('工具密度高',
110
+ topDensity ? topDensity.topic : '—',
111
+ topDensity ? `${topDensity.avgTurns} 轮 · ${topDensity.avgTools} 次调用` : '暂无数据',
112
+ 'var(--cyan)')}
113
+ ${card('最活跃项目',
114
+ topProject ? (topProject.project.split('/').filter(Boolean).pop() ?? projectName(topProject.project)) : '—',
115
+ topProject ? `占 ${projTotal > 0 ? Math.round(topProject.count / projTotal * 100) : 0}%` : '暂无数据',
116
+ 'var(--green)')}
117
+ </div>`
118
+ }
119
+
120
+ // ── 容器内滚动渲染器(替代翻页)──
121
+ function renderScrollable(el, rows, renderItem, emptyMsg = '暂无数据') {
122
+ if (!rows || rows.length === 0) {
123
+ el.innerHTML = `<div style="color:var(--muted);font-size:14px;padding:12px 0;">${emptyMsg}</div>`
124
+ return
125
+ }
126
+ el.style.overflowY = 'auto'
127
+ el.innerHTML = rows.map(renderItem).join('')
128
+ }
129
+
130
+ export async function renderInsightsPage(container, range) {
131
+ const scroller = container.querySelector('.insights-scroll')
132
+ const savedScroll = scroller?.scrollTop ?? 0
133
+
134
+ const data = await fetch(`/api/efficiency?range=${range}`).then(r => r.json())
135
+
136
+ container.style.display = 'flex'
137
+ container.style.flexDirection = 'column'
138
+
139
+ const cardStyle = `height:${CARD_HEIGHT}px;display:flex;flex-direction:column;`
140
+ const contentStyle = `flex:1;min-height:0;overflow-y:auto;`
141
+
142
+ container.innerHTML = `
143
+ ${rangeFilter(range)}
144
+ <div class="insights-scroll" style="flex:1;min-height:0;overflow-y:auto;padding-bottom:20px;">
145
+ ${summaryCards(data)}
146
+ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:10px;">
147
+ <div class="card" style="${cardStyle}">
148
+ <div class="section-header"><span class="section-title">耗时话题 — 轮数 & 时长占比</span></div>
149
+ <div id="ins-rounds" style="${contentStyle}"></div>
150
+ </div>
151
+ <div class="card" style="${cardStyle}">
152
+ <div class="section-header"><span class="section-title">自动化程度 — 工具调用密度</span></div>
153
+ <div id="ins-density" style="${contentStyle}"></div>
154
+ </div>
155
+ <div class="card" style="${cardStyle}">
156
+ <div class="section-header"><span class="section-title">项目分布 — 活跃目录</span></div>
157
+ <div id="ins-projects" style="${contentStyle}"></div>
158
+ </div>
159
+ </div>
160
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
161
+ <div class="card" style="${cardStyle}">
162
+ <div class="section-header"><span class="section-title">时间规律 — 时段 × 话题</span></div>
163
+ <div id="ins-heatmap" style="flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;"></div>
164
+ </div>
165
+ <div class="card" style="${cardStyle}">
166
+ <div class="section-header"><span class="section-title">Session — 高轮次对话</span></div>
167
+ <div id="ins-outliers" style="${contentStyle}"></div>
168
+ </div>
169
+ </div>
170
+ </div>`
171
+
172
+ container.querySelectorAll('.range-btn').forEach(btn => {
173
+ btn.addEventListener('click', () => setRange(btn.dataset.range))
174
+ })
175
+
176
+ renderRounds(document.getElementById('ins-rounds'), data.roundsByTopic, data.durationByTopic)
177
+ renderDensity(document.getElementById('ins-density'), data.densityByTopic)
178
+ renderHeatmap(document.getElementById('ins-heatmap'), data.heatmap)
179
+ renderOutliers(document.getElementById('ins-outliers'), data.outlierSessions)
180
+ renderProjects(document.getElementById('ins-projects'), data.projectDist)
181
+
182
+ if (savedScroll > 0) {
183
+ const s = container.querySelector('.insights-scroll')
184
+ if (s) s.scrollTop = savedScroll
185
+ }
186
+ }
187
+
188
+ // ── 条形图 item ──
189
+ function barItem(topic, label, pct) {
190
+ const color = topicColor(topic)
191
+ return `
192
+ <div style="margin-bottom:10px;">
193
+ <div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px;">
194
+ <span style="color:var(--text);">${topic}</span>
195
+ <span style="color:${color};">${label}</span>
196
+ </div>
197
+ <div style="background:var(--bg3);border-radius:3px;height:6px;">
198
+ <div style="width:${pct}%;background:${color};height:6px;border-radius:3px;
199
+ transition:width 0.3s;min-width:4px;"></div>
200
+ </div>
201
+ </div>`
202
+ }
203
+
204
+ function renderRounds(el, rows, durationRows) {
205
+ const max = Math.max(...(rows ?? []).map(r => r.avgRounds), 1)
206
+ const pctMap = Object.fromEntries((durationRows ?? []).map(r => [r.topic, r.pct]))
207
+ renderScrollable(el, rows, r => {
208
+ const pct = pctMap[r.topic] ?? null
209
+ const label = pct !== null ? `${r.avgRounds} 轮 · ${pct}%` : `${r.avgRounds} 轮`
210
+ return barItem(r.topic, label, r.avgRounds / max * 100)
211
+ })
212
+ }
213
+
214
+ function renderDensity(el, rows) {
215
+ const max = Math.max(...(rows ?? []).map(r => r.density), 1)
216
+ renderScrollable(el, rows,
217
+ r => barItem(r.topic, `${r.avgTurns} 轮 · ${r.avgTools} 次调用`, r.density / max * 100))
218
+ }
219
+
220
+ // ── 时间规律热力图(话题 Y 轴 × 小时 X 轴)──
221
+ function renderHeatmap(el, rows) {
222
+ if (!rows || rows.length === 0) {
223
+ el.innerHTML = `<div style="color:var(--muted);font-size:14px;padding:12px 0;">暂无数据</div>`
224
+ return
225
+ }
226
+
227
+ const topics = [...new Set(rows.map(r => r.topic))].slice(0, 8)
228
+ const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
229
+ const lookup = {}
230
+ for (const r of rows) {
231
+ if (!lookup[r.topic]) lookup[r.topic] = {}
232
+ lookup[r.topic][r.hour] = r.count
233
+ }
234
+ const maxCount = Math.max(...rows.map(r => r.count), 1)
235
+
236
+ const LABEL_W = '48px'
237
+ const GAP = '3px'
238
+
239
+ // X 轴小时标签行(宽度与格子对齐)
240
+ const hourHeader = `
241
+ <div style="display:flex;align-items:center;gap:${GAP};margin-bottom:4px;">
242
+ <span style="width:${LABEL_W};flex-shrink:0;"></span>
243
+ <div style="display:flex;flex:1;gap:${GAP};">
244
+ ${hours.map(h => `
245
+ <div style="flex:1;text-align:center;font-size:9px;color:var(--muted);">${h}</div>
246
+ `).join('')}
247
+ </div>
248
+ </div>`
249
+
250
+ // 每个话题一行;格子 aspect-ratio:1 保证正方形,行高由格子宽度决定
251
+ const topicRows = topics.map(t => {
252
+ const cells = hours.map(h => {
253
+ const cnt = lookup[t]?.[h] ?? 0
254
+ return `<div title="${h}:00 · ${t} · ${cnt} sessions"
255
+ style="flex:1;aspect-ratio:1;border-radius:2px;
256
+ background:${topicCellColor(t, cnt, maxCount)};"></div>`
257
+ }).join('')
258
+ return `
259
+ <div style="display:flex;align-items:center;gap:${GAP};margin-bottom:${GAP};">
260
+ <span style="width:${LABEL_W};flex-shrink:0;font-size:10px;color:${topicColor(t)};
261
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;
262
+ padding-right:6px;">${t}</span>
263
+ <div style="display:flex;flex:1;gap:${GAP};">${cells}</div>
264
+ </div>`
265
+ }).join('')
266
+
267
+ el.innerHTML = `
268
+ <div style="padding-top:4px;">
269
+ ${hourHeader}
270
+ ${topicRows}
271
+ </div>`
272
+ }
273
+
274
+ // ── Session 明细 ──
275
+ function renderOutliers(el, rows) {
276
+ function fmtDate(ms) {
277
+ if (!ms) return ''
278
+ return new Date(ms).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
279
+ }
280
+
281
+ const filtered = (rows ?? []).filter(r => r.firstUserMsg && r.firstUserMsg.trim()).slice(0, 10)
282
+ // SQL 返回 50 条保证过滤后有足够数量,slice(0,10) 取前 10
283
+ renderScrollable(el, filtered, r => {
284
+ const msg = r.firstUserMsg
285
+ const preview = msg.length > 70 ? msg.slice(0, 70) + '…' : msg
286
+ return `
287
+ <div style="background:var(--bg3);border-radius:4px;padding:7px 10px;
288
+ margin-bottom:5px;border-left:3px solid ${topicColor(r.topic)};">
289
+ <div style="display:flex;justify-content:space-between;align-items:center;">
290
+ <span style="font-size:12px;color:${topicColor(r.topic)};">${r.topic ?? '未分类'}</span>
291
+ <span style="font-size:11px;color:var(--muted);">${r.messageCount} 轮 · ${fmtDate(r.startTime)}</span>
292
+ </div>
293
+ <div title="${msg.replace(/"/g, '&quot;')}"
294
+ style="font-size:11px;color:var(--muted);margin-top:3px;cursor:default;
295
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
296
+ ${preview}
297
+ </div>
298
+ </div>`
299
+ }, '暂无高轮次 Session')
300
+ }
301
+
302
+ // ── 项目分布 ──
303
+ function renderProjects(el, rows) {
304
+ const COLORS = [
305
+ 'var(--green)', 'var(--cyan)', 'var(--amber)', 'var(--purple)',
306
+ 'var(--red)', '#f97316', '#06b6d4', 'var(--muted)',
307
+ ]
308
+ const filtered = (rows ?? []).filter(r => projectName(r.project) !== '~')
309
+ const total = filtered.reduce((s, r) => s + (r.count ?? 0), 0)
310
+ renderScrollable(el, filtered, r => {
311
+ const color = COLORS[filtered.indexOf(r)] ?? 'var(--muted)'
312
+ const pct = total > 0 ? Math.round(r.count / total * 100) : 0
313
+ return `
314
+ <div style="margin-bottom:10px;">
315
+ <div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px;">
316
+ <span style="color:var(--text);overflow:hidden;text-overflow:ellipsis;
317
+ white-space:nowrap;max-width:60%;">${projectName(r.project)}</span>
318
+ <span style="color:${color};white-space:nowrap;">${r.count} 次 · ${pct}%</span>
319
+ </div>
320
+ <div style="background:var(--bg3);border-radius:3px;height:6px;">
321
+ <div style="width:${pct}%;background:${color};height:6px;border-radius:3px;
322
+ transition:width 0.3s;min-width:4px;"></div>
323
+ </div>
324
+ </div>`
325
+ })
326
+ }
@@ -0,0 +1,143 @@
1
+ // public/js/mcp.js
2
+
3
+ const SOURCE_LABEL = {
4
+ config: { text: 'Config', color: 'var(--cyan)' },
5
+ history: { text: 'History', color: 'var(--amber)' },
6
+ 'claude.ai': { text: 'Claude.ai', color: 'var(--purple)' },
7
+ }
8
+
9
+ const STATUS_LABEL = {
10
+ configured: { text: '已配置', color: 'var(--green)' },
11
+ used: { text: '历史使用', color: 'var(--amber)' },
12
+ hosted: { text: '托管', color: 'var(--cyan)' },
13
+ }
14
+
15
+ function badge(text, color) {
16
+ return `<span style="font-size:12px;padding:2px 7px;border-radius:3px;
17
+ background:color-mix(in srgb,${color} 15%,transparent);
18
+ border:1px solid color-mix(in srgb,${color} 40%,transparent);
19
+ color:${color};white-space:nowrap;">${text}</span>`
20
+ }
21
+
22
+ function serverCard(s) {
23
+ const src = SOURCE_LABEL[s.source] ?? { text: s.source, color: 'var(--muted)' }
24
+ const sta = STATUS_LABEL[s.status] ?? { text: s.status, color: 'var(--muted)' }
25
+
26
+ // 工具列表:最多展示 6 个,超出折叠
27
+ const toolChips = (s.tools ?? []).map(t =>
28
+ `<span style="font-size:12px;color:var(--muted);background:var(--bg3);
29
+ padding:1px 6px;border-radius:3px;">${t}</span>`
30
+ )
31
+ const showTools = toolChips.slice(0, 6).join(' ')
32
+ const moreCount = toolChips.length - 6
33
+ const toolsRow = toolChips.length > 0
34
+ ? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;">
35
+ ${showTools}
36
+ ${moreCount > 0 ? `<span style="font-size:12px;color:var(--muted);">+${moreCount} 更多</span>` : ''}
37
+ </div>`
38
+ : ''
39
+
40
+ // 命令 / URL
41
+ const cmdRow = s.command
42
+ ? `<div style="margin-top:6px;font-size:13px;color:var(--muted);
43
+ font-family:var(--font);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
44
+ title="${s.command}">${s.command}</div>`
45
+ : s.url
46
+ ? `<div style="margin-top:6px;font-size:13px;color:var(--muted);
47
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
48
+ title="${s.url}">${s.url}</div>`
49
+ : ''
50
+
51
+ return `
52
+ <div style="background:var(--bg2);border:1px solid var(--border);
53
+ border-radius:var(--radius);padding:14px 16px;margin-bottom:8px;">
54
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
55
+ <span style="font-size:15px;font-weight:bold;color:var(--text);flex:1;min-width:0;
56
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${s.name}</span>
57
+ ${badge(src.text, src.color)}
58
+ ${badge(sta.text, sta.color)}
59
+ ${s.tools?.length > 0
60
+ ? `<span style="font-size:13px;color:var(--muted);">${s.tools.length} 个工具</span>`
61
+ : ''}
62
+ </div>
63
+ ${cmdRow}
64
+ ${toolsRow}
65
+ </div>`
66
+ }
67
+
68
+ function summaryCards(servers) {
69
+ const total = servers.length
70
+ const configured = servers.filter(s => s.source === 'config').length
71
+ const history = servers.filter(s => s.source === 'history').length
72
+ const hosted = servers.filter(s => s.source === 'claude.ai').length
73
+
74
+ const item = (label, val, color) => `
75
+ <div class="card">
76
+ <div class="card-label">${label}</div>
77
+ <div class="card-value" style="color:${color};">${val}</div>
78
+ </div>`
79
+
80
+ return `
81
+ <div class="grid-4" style="margin-bottom:14px;">
82
+ ${item('Total Servers', total, 'var(--text)')}
83
+ ${item('Configured', configured, 'var(--green)')}
84
+ ${item('History', history, 'var(--amber)')}
85
+ ${item('Claude.ai', hosted, 'var(--purple)')}
86
+ </div>`
87
+ }
88
+
89
+ export async function renderMcp(container) {
90
+ container.style.display = 'flex'
91
+ container.style.flexDirection = 'column'
92
+ container.innerHTML = `<div style="color:var(--muted);font-size:14px;padding:20px;">加载中…</div>`
93
+
94
+ let servers
95
+ try {
96
+ servers = await fetch('/api/mcp-servers').then(r => r.json())
97
+ } catch {
98
+ container.innerHTML = `<div style="color:var(--red);font-size:14px;padding:20px;">加载失败</div>`
99
+ return
100
+ }
101
+
102
+ if (!servers || servers.length === 0) {
103
+ container.innerHTML = `
104
+ <div class="card" style="margin-top:20px;">
105
+ <div class="section-title" style="margin-bottom:8px;">MCP Servers</div>
106
+ <div class="muted" style="font-size:14px;">未检测到任何 MCP Server</div>
107
+ </div>`
108
+ return
109
+ }
110
+
111
+ // 按来源分组:config > claude.ai > history
112
+ const order = ['config', 'claude.ai', 'history']
113
+ const grouped = {}
114
+ for (const src of order) grouped[src] = []
115
+ for (const s of servers) {
116
+ const key = order.includes(s.source) ? s.source : 'history'
117
+ grouped[key].push(s)
118
+ }
119
+
120
+ const groupTitle = (label, color, count) => count === 0 ? '' : `
121
+ <div style="font-size:11px;letter-spacing:1px;text-transform:uppercase;
122
+ color:${color};margin:16px 0 8px;">${label} · ${count}</div>`
123
+
124
+ const listHtml = [
125
+ grouped['config'].length > 0 ? groupTitle('Config 配置', 'var(--green)', grouped['config'].length) + grouped['config'].map(serverCard).join('') : '',
126
+ grouped['claude.ai'].length > 0 ? groupTitle('Claude.ai 托管', 'var(--purple)', grouped['claude.ai'].length) + grouped['claude.ai'].map(serverCard).join('') : '',
127
+ grouped['history'].length > 0 ? groupTitle('历史使用', 'var(--amber)', grouped['history'].length) + grouped['history'].map(serverCard).join('') : '',
128
+ ].join('')
129
+
130
+ container.innerHTML = `
131
+ <div style="flex:1;min-height:0;overflow-y:auto;margin:-20px;padding:20px;">
132
+ <div style="max-width:860px;">
133
+ <div style="margin-bottom:16px;">
134
+ <div style="font-size:15px;font-weight:bold;color:var(--text);">MCP Servers</div>
135
+ <div style="font-size:13px;color:var(--muted);margin-top:2px;">
136
+ 来源:配置文件 / 历史调用记录 / Claude.ai 托管
137
+ </div>
138
+ </div>
139
+ ${summaryCards(servers)}
140
+ <div>${listHtml}</div>
141
+ </div>
142
+ </div>`
143
+ }