agentlytics 0.2.11 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cache.js +195 -2
- package/editors/gsd.js +366 -0
- package/package.json +1 -1
- package/server.js +102 -0
- package/ui/src/App.jsx +4 -1
- package/ui/src/components/ChatSidebar.jsx +31 -2
- package/ui/src/components/TokenTimeline.jsx +258 -0
- package/ui/src/lib/api.js +43 -0
- package/ui/src/pages/GSD.jsx +726 -0
package/server.js
CHANGED
|
@@ -727,6 +727,108 @@ app.get('/api/all-projects', (req, res) => {
|
|
|
727
727
|
}
|
|
728
728
|
});
|
|
729
729
|
|
|
730
|
+
// ============================================================
|
|
731
|
+
// GSD endpoints
|
|
732
|
+
// ============================================================
|
|
733
|
+
|
|
734
|
+
app.get('/api/gsd/projects', (req, res) => {
|
|
735
|
+
try {
|
|
736
|
+
res.json(cache.getCachedGSDProjects());
|
|
737
|
+
} catch (err) {
|
|
738
|
+
res.status(500).json({ error: err.message });
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
app.get('/api/gsd/phases', (req, res) => {
|
|
743
|
+
try {
|
|
744
|
+
const { folder } = req.query;
|
|
745
|
+
if (!folder) return res.status(400).json({ error: 'folder query param required' });
|
|
746
|
+
res.json(cache.getCachedGSDPhases(folder));
|
|
747
|
+
} catch (err) {
|
|
748
|
+
res.status(500).json({ error: err.message });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
app.get('/api/gsd/plan', (req, res) => {
|
|
753
|
+
try {
|
|
754
|
+
const { folder, phase } = req.query;
|
|
755
|
+
if (!folder || !phase) return res.status(400).json({ error: 'folder and phase query params required' });
|
|
756
|
+
const gsd = require('./editors/gsd');
|
|
757
|
+
const detail = gsd.getGSDPlanDetail(folder, phase);
|
|
758
|
+
if (!detail) return res.status(404).json({ error: 'Plan not found' });
|
|
759
|
+
res.json(detail);
|
|
760
|
+
} catch (err) {
|
|
761
|
+
res.status(500).json({ error: err.message });
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
app.get('/api/gsd/overview', (req, res) => {
|
|
766
|
+
try {
|
|
767
|
+
res.json(cache.getCachedGSDOverview());
|
|
768
|
+
} catch (err) {
|
|
769
|
+
res.status(500).json({ error: err.message });
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
app.get('/api/gsd/config', (req, res) => {
|
|
774
|
+
try {
|
|
775
|
+
const { folder } = req.query;
|
|
776
|
+
if (!folder) return res.status(400).json({ error: 'folder query param required' });
|
|
777
|
+
const configPath = require('path').join(folder, '.planning', 'config.json');
|
|
778
|
+
if (!fs.existsSync(configPath)) return res.json(null);
|
|
779
|
+
res.json(JSON.parse(fs.readFileSync(configPath, 'utf-8')));
|
|
780
|
+
} catch (err) {
|
|
781
|
+
res.status(500).json({ error: err.message });
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
app.get('/api/gsd/phase-tokens', (req, res) => {
|
|
786
|
+
try {
|
|
787
|
+
const { folder } = req.query;
|
|
788
|
+
if (!folder) return res.status(400).json({ error: 'folder query param required' });
|
|
789
|
+
res.json(cache.getGSDPhaseTokens(folder));
|
|
790
|
+
} catch (err) {
|
|
791
|
+
res.status(500).json({ error: err.message });
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// Generic .planning file reader
|
|
796
|
+
// type: 'state' (project-level STATE.md) | 'research' | 'verification' | 'summary' (phase-level, requires phase param)
|
|
797
|
+
app.get('/api/gsd/file', (req, res) => {
|
|
798
|
+
try {
|
|
799
|
+
const { folder, phase: phaseDir, type } = req.query;
|
|
800
|
+
if (!folder || !type) return res.status(400).json({ error: 'folder and type required' });
|
|
801
|
+
|
|
802
|
+
const planningDir = path.join(folder, '.planning');
|
|
803
|
+
let content = null;
|
|
804
|
+
|
|
805
|
+
if (type === 'state') {
|
|
806
|
+
const filePath = path.join(planningDir, 'STATE.md');
|
|
807
|
+
if (fs.existsSync(filePath)) content = fs.readFileSync(filePath, 'utf-8');
|
|
808
|
+
} else if (phaseDir) {
|
|
809
|
+
const phaseFullDir = path.join(planningDir, 'phases', phaseDir);
|
|
810
|
+
const pattern = type === 'research' ? /RESEARCH\.md$/i
|
|
811
|
+
: type === 'verification' ? /VERIFICATION\.md$/i
|
|
812
|
+
: type === 'summary' ? /SUMMARY\.md$/i
|
|
813
|
+
: null;
|
|
814
|
+
if (pattern && fs.existsSync(phaseFullDir)) {
|
|
815
|
+
const files = fs.readdirSync(phaseFullDir).filter(f => pattern.test(f)).sort();
|
|
816
|
+
if (files.length > 0) {
|
|
817
|
+
const sections = files.map(f => {
|
|
818
|
+
const c = fs.readFileSync(path.join(phaseFullDir, f), 'utf-8');
|
|
819
|
+
return files.length > 1 ? `## ${f}\n\n${c}` : c;
|
|
820
|
+
});
|
|
821
|
+
content = sections.join('\n\n---\n\n');
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
res.json({ content });
|
|
827
|
+
} catch (err) {
|
|
828
|
+
res.status(500).json({ error: err.message });
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
730
832
|
// SPA fallback
|
|
731
833
|
app.get('*', (req, res) => {
|
|
732
834
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
package/ui/src/App.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
2
|
import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
|
|
3
|
-
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown } from 'lucide-react'
|
|
3
|
+
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown, Target } from 'lucide-react'
|
|
4
4
|
import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
|
|
5
5
|
import { useTheme } from './lib/theme'
|
|
6
6
|
import { useLive } from './hooks/useLive'
|
|
@@ -21,6 +21,7 @@ import Subscriptions from './pages/Subscriptions'
|
|
|
21
21
|
import MCPs from './pages/MCPs'
|
|
22
22
|
import RelayDashboard from './pages/RelayDashboard'
|
|
23
23
|
import RelayUserDetail from './pages/RelayUserDetail'
|
|
24
|
+
import GSD from './pages/GSD'
|
|
24
25
|
|
|
25
26
|
function NavDropdown({ icon: Icon, label, items }) {
|
|
26
27
|
const [open, setOpen] = useState(false)
|
|
@@ -154,6 +155,7 @@ export default function App() {
|
|
|
154
155
|
{ to: '/compare', icon: GitCompare, label: 'Compare' },
|
|
155
156
|
]},
|
|
156
157
|
{ to: '/artifacts', icon: Package, label: 'Artifacts' },
|
|
158
|
+
{ to: '/gsd', icon: Target, label: 'GSD' },
|
|
157
159
|
{ to: '/mcps', icon: Plug, label: 'MCPs' },
|
|
158
160
|
{ to: '/sql', icon: Database, label: 'SQL' },
|
|
159
161
|
]
|
|
@@ -284,6 +286,7 @@ export default function App() {
|
|
|
284
286
|
<Route path="/artifacts" element={<Artifacts />} />
|
|
285
287
|
<Route path="/mcps" element={<MCPs />} />
|
|
286
288
|
<Route path="/sql" element={<SqlViewer />} />
|
|
289
|
+
<Route path="/gsd" element={<GSD />} />
|
|
287
290
|
<Route path="/settings" element={<Settings />} />
|
|
288
291
|
</Routes>
|
|
289
292
|
)}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { useState, useEffect, useRef } from 'react'
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
2
|
import { X, Download, Send, Search } from 'lucide-react'
|
|
3
3
|
import { fetchChat, BASE } from '../lib/api'
|
|
4
4
|
import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
|
|
5
5
|
import MessageContent, { ROLE_CONFIG } from './MessageRenderer'
|
|
6
|
+
import TokenTimeline from './TokenTimeline'
|
|
6
7
|
|
|
7
8
|
export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, username }) {
|
|
8
9
|
const [chat, setChat] = useState(null)
|
|
@@ -47,6 +48,17 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
|
|
|
47
48
|
setMsgFilter('')
|
|
48
49
|
}, [chatId])
|
|
49
50
|
|
|
51
|
+
const handleScrollToMessage = useCallback((msgIndex) => {
|
|
52
|
+
if (!scrollRef.current) return
|
|
53
|
+
const el = scrollRef.current.querySelector(`[data-msg-index="${msgIndex}"]`)
|
|
54
|
+
if (el) {
|
|
55
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
56
|
+
el.style.outline = '1.5px solid var(--c-accent)'
|
|
57
|
+
el.style.outlineOffset = '2px'
|
|
58
|
+
setTimeout(() => { el.style.outline = 'none' }, 1500)
|
|
59
|
+
}
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
50
62
|
if (!chatId) return null
|
|
51
63
|
|
|
52
64
|
return (
|
|
@@ -107,6 +119,13 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
|
|
|
107
119
|
{chat.stats.toolCalls?.length > 0 && <span>{chat.stats.toolCalls.length} tools</span>}
|
|
108
120
|
{chat.stats.totalInputTokens > 0 && <span>{formatNumber(chat.stats.totalInputTokens)} in</span>}
|
|
109
121
|
{chat.stats.totalOutputTokens > 0 && <span>{formatNumber(chat.stats.totalOutputTokens)} out</span>}
|
|
122
|
+
{chat.createdAt && chat.lastUpdatedAt && (() => {
|
|
123
|
+
const ms = new Date(chat.lastUpdatedAt).getTime() - new Date(chat.createdAt).getTime()
|
|
124
|
+
if (ms <= 0) return null
|
|
125
|
+
const mins = Math.floor(ms / 60000)
|
|
126
|
+
const label = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`
|
|
127
|
+
return <span>{label}</span>
|
|
128
|
+
})()}
|
|
110
129
|
{chat.stats.models?.length > 0 && (
|
|
111
130
|
<span className="ml-auto font-mono truncate" style={{ color: 'var(--c-accent)', opacity: 0.7 }}>
|
|
112
131
|
{[...new Set(chat.stats.models)].join(', ')}
|
|
@@ -115,6 +134,16 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
|
|
|
115
134
|
</div>
|
|
116
135
|
)}
|
|
117
136
|
|
|
137
|
+
{/* Token Timeline */}
|
|
138
|
+
{chat && chat.messages.length > 0 && (
|
|
139
|
+
<TokenTimeline
|
|
140
|
+
messages={chat.messages}
|
|
141
|
+
createdAt={chat.createdAt}
|
|
142
|
+
lastUpdatedAt={chat.lastUpdatedAt}
|
|
143
|
+
onScrollToMessage={handleScrollToMessage}
|
|
144
|
+
/>
|
|
145
|
+
)}
|
|
146
|
+
|
|
118
147
|
{/* Search bar */}
|
|
119
148
|
{chat && chat.messages.length > 0 && (
|
|
120
149
|
<div className="shrink-0 px-4 py-2" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
@@ -148,7 +177,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
|
|
|
148
177
|
const cfg = ROLE_CONFIG[msg.role] || ROLE_CONFIG.system
|
|
149
178
|
const Icon = cfg.icon
|
|
150
179
|
return (
|
|
151
|
-
<div key={i} className="rounded-r px-3 py-2" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
|
|
180
|
+
<div key={i} data-msg-index={i} className="rounded-r px-3 py-2" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg, transition: 'outline 0.3s' }}>
|
|
152
181
|
<div className="flex items-center gap-1.5 text-[11px] mb-1" style={{ color: 'var(--c-text2)' }}>
|
|
153
182
|
<Icon size={11} />
|
|
154
183
|
<span className="font-medium">{msg.role === 'user' && (username || chat?.username) ? (username || chat.username) : cfg.label}</span>
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useMemo } from 'react'
|
|
2
|
+
import { Activity, ChevronDown, ChevronUp, Clock } from 'lucide-react'
|
|
3
|
+
import { formatNumber } from '../lib/constants'
|
|
4
|
+
|
|
5
|
+
const CHART_H = 56
|
|
6
|
+
const POINT_STEP = 52
|
|
7
|
+
const PAD_X = 20
|
|
8
|
+
const PAD_Y = 6
|
|
9
|
+
const BUCKET_MINUTES = 5
|
|
10
|
+
const BUCKET_MSG_COUNT = 8
|
|
11
|
+
|
|
12
|
+
const AVG_TOKEN_SPEED = 80
|
|
13
|
+
|
|
14
|
+
function formatDuration(ms) {
|
|
15
|
+
if (ms < 60000) return `${Math.round(ms / 1000)}s`
|
|
16
|
+
const mins = Math.floor(ms / 60000)
|
|
17
|
+
if (mins < 60) return `${mins}m`
|
|
18
|
+
const hrs = Math.floor(mins / 60)
|
|
19
|
+
const rem = mins % 60
|
|
20
|
+
return rem > 0 ? `${hrs}h ${rem}m` : `${hrs}h`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function lerp(a, b, t) {
|
|
24
|
+
return a + (b - a) * Math.max(0, Math.min(1, t))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function tokenColor(ratio) {
|
|
28
|
+
const r = Math.round(lerp(52, 239, ratio))
|
|
29
|
+
const g = Math.round(lerp(211, 68, ratio))
|
|
30
|
+
const b = Math.round(lerp(153, 68, ratio))
|
|
31
|
+
return `rgb(${r},${g},${b})`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildTimeBuckets(messages, createdAt, lastUpdatedAt) {
|
|
35
|
+
const start = new Date(createdAt).getTime()
|
|
36
|
+
const end = new Date(lastUpdatedAt).getTime()
|
|
37
|
+
const duration = Math.max(end - start, 1)
|
|
38
|
+
const bucketMs = BUCKET_MINUTES * 60 * 1000
|
|
39
|
+
const bucketCount = Math.max(1, Math.ceil(duration / bucketMs))
|
|
40
|
+
|
|
41
|
+
const buckets = Array.from({ length: bucketCount }, (_, i) => ({
|
|
42
|
+
label: `${i * BUCKET_MINUTES}m`,
|
|
43
|
+
tokens: 0,
|
|
44
|
+
messageIndices: [],
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
messages.forEach((msg, idx) => {
|
|
48
|
+
const totalTokens = (msg.inputTokens || 0) + (msg.outputTokens || 0)
|
|
49
|
+
const msgTime = start + (idx / Math.max(messages.length - 1, 1)) * duration
|
|
50
|
+
const bucketIdx = Math.min(Math.floor((msgTime - start) / bucketMs), bucketCount - 1)
|
|
51
|
+
buckets[bucketIdx].tokens += totalTokens
|
|
52
|
+
buckets[bucketIdx].messageIndices.push(idx)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return buckets
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildSeqBuckets(messages) {
|
|
59
|
+
const bucketCount = Math.max(1, Math.ceil(messages.length / BUCKET_MSG_COUNT))
|
|
60
|
+
|
|
61
|
+
const buckets = Array.from({ length: bucketCount }, () => ({
|
|
62
|
+
label: '',
|
|
63
|
+
tokens: 0,
|
|
64
|
+
messageIndices: [],
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
messages.forEach((msg, idx) => {
|
|
68
|
+
const chars = typeof msg.content === 'string' ? msg.content.length : 0
|
|
69
|
+
const totalTokens = Math.round(chars / 4)
|
|
70
|
+
const bucketIdx = Math.min(Math.floor(idx / BUCKET_MSG_COUNT), bucketCount - 1)
|
|
71
|
+
buckets[bucketIdx].tokens += totalTokens
|
|
72
|
+
buckets[bucketIdx].messageIndices.push(idx)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return buckets
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default function TokenTimeline({ messages, createdAt, lastUpdatedAt, onScrollToMessage }) {
|
|
79
|
+
const [open, setOpen] = useState(true)
|
|
80
|
+
const [hoveredIdx, setHoveredIdx] = useState(null)
|
|
81
|
+
const scrollContainerRef = useRef(null)
|
|
82
|
+
|
|
83
|
+
const hasRealTokens = useMemo(() => {
|
|
84
|
+
if (!messages) return false
|
|
85
|
+
return messages.some(m => (m.inputTokens || 0) + (m.outputTokens || 0) > 0)
|
|
86
|
+
}, [messages])
|
|
87
|
+
|
|
88
|
+
const buckets = useMemo(() => {
|
|
89
|
+
if (!messages || messages.length === 0) return []
|
|
90
|
+
if (hasRealTokens) return buildTimeBuckets(messages, createdAt, lastUpdatedAt)
|
|
91
|
+
return buildSeqBuckets(messages)
|
|
92
|
+
}, [messages, createdAt, lastUpdatedAt, hasRealTokens])
|
|
93
|
+
|
|
94
|
+
const maxTokens = useMemo(() => Math.max(...buckets.map(b => b.tokens), 1), [buckets])
|
|
95
|
+
|
|
96
|
+
const totalTokens = useMemo(() => buckets.reduce((s, b) => s + b.tokens, 0), [buckets])
|
|
97
|
+
|
|
98
|
+
const durationLabel = useMemo(() => {
|
|
99
|
+
if (hasRealTokens && createdAt && lastUpdatedAt) {
|
|
100
|
+
const ms = new Date(lastUpdatedAt).getTime() - new Date(createdAt).getTime()
|
|
101
|
+
if (ms > 0) return formatDuration(ms)
|
|
102
|
+
}
|
|
103
|
+
if (totalTokens > 0) {
|
|
104
|
+
const estSeconds = totalTokens / AVG_TOKEN_SPEED
|
|
105
|
+
return '~' + formatDuration(estSeconds * 1000)
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}, [hasRealTokens, createdAt, lastUpdatedAt, totalTokens])
|
|
109
|
+
|
|
110
|
+
const handlePointClick = useCallback((bucket) => {
|
|
111
|
+
if (bucket.messageIndices.length > 0 && onScrollToMessage) {
|
|
112
|
+
onScrollToMessage(bucket.messageIndices[0])
|
|
113
|
+
}
|
|
114
|
+
}, [onScrollToMessage])
|
|
115
|
+
|
|
116
|
+
if (!messages || messages.length === 0 || totalTokens === 0) return null
|
|
117
|
+
|
|
118
|
+
const svgW = PAD_X * 2 + Math.max((buckets.length - 1) * POINT_STEP, 0)
|
|
119
|
+
const svgH = CHART_H + PAD_Y * 2 + (hasRealTokens ? 16 : 4)
|
|
120
|
+
|
|
121
|
+
const points = buckets.map((b, i) => {
|
|
122
|
+
const x = PAD_X + i * POINT_STEP
|
|
123
|
+
const ratio = b.tokens / maxTokens
|
|
124
|
+
const y = PAD_Y + CHART_H - ratio * CHART_H
|
|
125
|
+
return { x, y, ratio, bucket: b }
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
|
129
|
+
|
|
130
|
+
const areaPath = points.length > 0
|
|
131
|
+
? `${linePath} L ${points[points.length - 1].x} ${PAD_Y + CHART_H} L ${points[0].x} ${PAD_Y + CHART_H} Z`
|
|
132
|
+
: ''
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
136
|
+
{/* Toggle button */}
|
|
137
|
+
<button
|
|
138
|
+
onClick={() => setOpen(o => !o)}
|
|
139
|
+
className="flex items-center gap-2 w-full px-4 py-1.5 text-[11px] transition hover:bg-[var(--c-bg3)]"
|
|
140
|
+
style={{ color: 'var(--c-text2)' }}
|
|
141
|
+
>
|
|
142
|
+
<Activity size={12} style={{ color: 'var(--c-accent)' }} />
|
|
143
|
+
<span className="font-medium">Token Timeline</span>
|
|
144
|
+
<span style={{ color: 'var(--c-text3)' }}>
|
|
145
|
+
({formatNumber(totalTokens)} tokens{!hasRealTokens ? ', est.' : ''})
|
|
146
|
+
</span>
|
|
147
|
+
{durationLabel && (
|
|
148
|
+
<span className="inline-flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
|
|
149
|
+
<Clock size={10} /> {durationLabel}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
<span className="ml-auto">
|
|
153
|
+
{open ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
154
|
+
</span>
|
|
155
|
+
</button>
|
|
156
|
+
|
|
157
|
+
{/* Collapsible chart area */}
|
|
158
|
+
{open && (
|
|
159
|
+
<div className="px-2 pb-2 pt-0">
|
|
160
|
+
<div
|
|
161
|
+
ref={scrollContainerRef}
|
|
162
|
+
className="overflow-x-auto scrollbar-thin"
|
|
163
|
+
style={{ scrollBehavior: 'smooth' }}
|
|
164
|
+
>
|
|
165
|
+
<svg
|
|
166
|
+
width={svgW}
|
|
167
|
+
height={svgH}
|
|
168
|
+
style={{ display: 'block', minWidth: svgW }}
|
|
169
|
+
>
|
|
170
|
+
<defs>
|
|
171
|
+
<linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
|
172
|
+
<stop offset="0%" stopColor="var(--c-accent)" stopOpacity="0.15" />
|
|
173
|
+
<stop offset="100%" stopColor="var(--c-accent)" stopOpacity="0.02" />
|
|
174
|
+
</linearGradient>
|
|
175
|
+
</defs>
|
|
176
|
+
|
|
177
|
+
{/* Baseline */}
|
|
178
|
+
<line
|
|
179
|
+
x1={PAD_X} y1={PAD_Y + CHART_H}
|
|
180
|
+
x2={PAD_X + (buckets.length - 1) * POINT_STEP} y2={PAD_Y + CHART_H}
|
|
181
|
+
stroke="var(--c-border)" strokeWidth="1"
|
|
182
|
+
/>
|
|
183
|
+
|
|
184
|
+
{/* Area fill */}
|
|
185
|
+
{areaPath && <path d={areaPath} fill="url(#tokenAreaGrad)" />}
|
|
186
|
+
|
|
187
|
+
{/* Line */}
|
|
188
|
+
{points.length > 1 && (
|
|
189
|
+
<path d={linePath} fill="none" stroke="var(--c-accent)" strokeWidth="1.5" strokeLinejoin="round" />
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{/* Data points + labels */}
|
|
193
|
+
{points.map((p, i) => {
|
|
194
|
+
const isHovered = hoveredIdx === i
|
|
195
|
+
const color = tokenColor(p.ratio)
|
|
196
|
+
return (
|
|
197
|
+
<g
|
|
198
|
+
key={i}
|
|
199
|
+
style={{ cursor: p.bucket.tokens > 0 ? 'pointer' : 'default' }}
|
|
200
|
+
onMouseEnter={() => setHoveredIdx(i)}
|
|
201
|
+
onMouseLeave={() => setHoveredIdx(null)}
|
|
202
|
+
onClick={() => handlePointClick(p.bucket)}
|
|
203
|
+
>
|
|
204
|
+
<circle cx={p.x} cy={p.y} r={12} fill="transparent" />
|
|
205
|
+
|
|
206
|
+
<circle
|
|
207
|
+
cx={p.x} cy={p.y}
|
|
208
|
+
r={isHovered ? 5 : 3}
|
|
209
|
+
fill={p.bucket.tokens > 0 ? color : 'var(--c-border)'}
|
|
210
|
+
stroke={isHovered ? 'var(--c-white)' : 'none'}
|
|
211
|
+
strokeWidth={1.5}
|
|
212
|
+
style={{ transition: 'r 0.15s' }}
|
|
213
|
+
/>
|
|
214
|
+
|
|
215
|
+
{isHovered && (
|
|
216
|
+
<line
|
|
217
|
+
x1={p.x} y1={p.y} x2={p.x} y2={PAD_Y + CHART_H}
|
|
218
|
+
stroke="var(--c-text3)" strokeWidth="0.5" strokeDasharray="2,2"
|
|
219
|
+
/>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{isHovered && p.bucket.tokens > 0 && (
|
|
223
|
+
<g>
|
|
224
|
+
<rect
|
|
225
|
+
x={p.x - 32} y={p.y - 22}
|
|
226
|
+
width={64} height={16} rx={3}
|
|
227
|
+
fill="var(--c-bg)" stroke="var(--c-border)" strokeWidth="0.5"
|
|
228
|
+
/>
|
|
229
|
+
<text
|
|
230
|
+
x={p.x} y={p.y - 11}
|
|
231
|
+
textAnchor="middle" fontSize="9" fontFamily="monospace"
|
|
232
|
+
fill="var(--c-white)"
|
|
233
|
+
>
|
|
234
|
+
{formatNumber(p.bucket.tokens)} tok
|
|
235
|
+
</text>
|
|
236
|
+
</g>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{/* X label — only for time-based buckets */}
|
|
240
|
+
{hasRealTokens && p.bucket.label && (
|
|
241
|
+
<text
|
|
242
|
+
x={p.x} y={PAD_Y + CHART_H + 12}
|
|
243
|
+
textAnchor="middle" fontSize="9" fontFamily="monospace"
|
|
244
|
+
fill="var(--c-text3)"
|
|
245
|
+
>
|
|
246
|
+
{p.bucket.label}
|
|
247
|
+
</text>
|
|
248
|
+
)}
|
|
249
|
+
</g>
|
|
250
|
+
)
|
|
251
|
+
})}
|
|
252
|
+
</svg>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
)
|
|
258
|
+
}
|
package/ui/src/lib/api.js
CHANGED
|
@@ -234,6 +234,49 @@ export async function fetchMCPs() {
|
|
|
234
234
|
return res.json();
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// ── GSD API ──
|
|
238
|
+
|
|
239
|
+
export async function fetchGSDProjects() {
|
|
240
|
+
const res = await fetch(`${BASE}/api/gsd/projects`);
|
|
241
|
+
return res.json();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function fetchGSDPhases(folder) {
|
|
245
|
+
const q = new URLSearchParams({ folder });
|
|
246
|
+
const res = await fetch(`${BASE}/api/gsd/phases?${q}`);
|
|
247
|
+
return res.json();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function fetchGSDPlan(folder, phase) {
|
|
251
|
+
const q = new URLSearchParams({ folder, phase });
|
|
252
|
+
const res = await fetch(`${BASE}/api/gsd/plan?${q}`);
|
|
253
|
+
return res.json();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function fetchGSDOverview() {
|
|
257
|
+
const res = await fetch(`${BASE}/api/gsd/overview`);
|
|
258
|
+
return res.json();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function fetchGSDConfig(folder) {
|
|
262
|
+
const q = new URLSearchParams({ folder });
|
|
263
|
+
const res = await fetch(`${BASE}/api/gsd/config?${q}`);
|
|
264
|
+
return res.json();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function fetchGSDPhaseTokens(folder) {
|
|
268
|
+
const q = new URLSearchParams({ folder });
|
|
269
|
+
const res = await fetch(`${BASE}/api/gsd/phase-tokens?${q}`);
|
|
270
|
+
return res.json();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function fetchGSDFile(folder, type, phaseDir) {
|
|
274
|
+
const q = new URLSearchParams({ folder, type });
|
|
275
|
+
if (phaseDir) q.set('phase', phaseDir);
|
|
276
|
+
const res = await fetch(`${BASE}/api/gsd/file?${q}`);
|
|
277
|
+
return res.json();
|
|
278
|
+
}
|
|
279
|
+
|
|
237
280
|
// ── Relay API ──
|
|
238
281
|
|
|
239
282
|
export async function fetchMode() {
|