memhouse 0.0.1 → 0.3.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.
- package/LICENSE +22 -0
- package/README.md +59 -3
- package/bin/memhouse.js +487 -0
- package/cache.js +850 -0
- package/ch-ingest.js +329 -0
- package/ch-schema.js +152 -0
- package/clickhouse.js +87 -0
- package/editors/antigravity.js +1019 -0
- package/editors/base.js +321 -0
- package/editors/claude.js +386 -0
- package/editors/codebuff.js +338 -0
- package/editors/codex.js +537 -0
- package/editors/commandcode.js +166 -0
- package/editors/copilot-jetbrains.js +207 -0
- package/editors/copilot.js +260 -0
- package/editors/cursor-agent.js +202 -0
- package/editors/cursor.js +438 -0
- package/editors/gemini.js +231 -0
- package/editors/goose.js +358 -0
- package/editors/gsd.js +366 -0
- package/editors/index.js +179 -0
- package/editors/kiro.js +315 -0
- package/editors/opencode.js +374 -0
- package/editors/vscode.js +413 -0
- package/editors/windsurf.js +634 -0
- package/editors/zed.js +213 -0
- package/index.js +424 -2
- package/mcp-server.js +294 -0
- package/mem-house/DESIGN.md +119 -0
- package/mem-house/delivery/AGENT-INSTALL.md +75 -0
- package/mem-house/delivery/PROMPT.md +42 -0
- package/mem-house/delivery/install.sh +181 -0
- package/mem-house/delivery/kernel-install.md +76 -0
- package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
- package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
- package/mem-house/rls.sql +17 -0
- package/mem-house/schema.sql +145 -0
- package/mem-house/server/queries.js +878 -0
- package/mem-house/server/server.js +228 -0
- package/mem-house/shipper/ship.js +402 -0
- package/misc/syntax-check.js +38 -0
- package/package.json +56 -7
- package/pricing.js +88 -0
- package/pricing.json +992 -0
- package/public/assets/index-BsgjOSTv.js +75 -0
- package/public/assets/index-DV6ONi_F.css +2 -0
- package/public/index.html +16 -0
- package/relay-client.js +306 -0
- package/relay-server.js +577 -0
- package/server.js +860 -0
- package/share-image.js +367 -0
- package/ui/eslint.config.js +29 -0
- package/ui/index.html +15 -0
- package/ui/package-lock.json +4229 -0
- package/ui/package.json +38 -0
- package/ui/src/App.jsx +364 -0
- package/ui/src/components/ActivityHeatmap.jsx +206 -0
- package/ui/src/components/AiAuditCard.jsx +254 -0
- package/ui/src/components/AnimatedLoader.jsx +14 -0
- package/ui/src/components/AnimatedLogo.jsx +96 -0
- package/ui/src/components/ChatSidebar.jsx +226 -0
- package/ui/src/components/DateRangePicker.jsx +104 -0
- package/ui/src/components/EditorDot.jsx +11 -0
- package/ui/src/components/EditorIcon.jsx +117 -0
- package/ui/src/components/KpiCard.jsx +9 -0
- package/ui/src/components/LiveFeed.jsx +128 -0
- package/ui/src/components/LoginScreen.jsx +81 -0
- package/ui/src/components/MessageRenderer.jsx +148 -0
- package/ui/src/components/PageHeader.jsx +11 -0
- package/ui/src/components/SectionTitle.jsx +3 -0
- package/ui/src/components/ShareModal.jsx +305 -0
- package/ui/src/components/TokenTimeline.jsx +258 -0
- package/ui/src/hooks/useLive.jsx +15 -0
- package/ui/src/index.css +83 -0
- package/ui/src/lib/api.js +343 -0
- package/ui/src/lib/constants.js +88 -0
- package/ui/src/lib/theme.jsx +23 -0
- package/ui/src/main.jsx +16 -0
- package/ui/src/pages/Artifacts.jsx +575 -0
- package/ui/src/pages/Compare.jsx +232 -0
- package/ui/src/pages/CostAnalysis.jsx +354 -0
- package/ui/src/pages/Dashboard.jsx +529 -0
- package/ui/src/pages/DeepAnalysis.jsx +495 -0
- package/ui/src/pages/GSD.jsx +726 -0
- package/ui/src/pages/MCPs.jsx +744 -0
- package/ui/src/pages/ProjectDetail.jsx +325 -0
- package/ui/src/pages/Projects.jsx +249 -0
- package/ui/src/pages/RelayDashboard.jsx +590 -0
- package/ui/src/pages/RelayUserDetail.jsx +466 -0
- package/ui/src/pages/Sessions.jsx +535 -0
- package/ui/src/pages/Settings.jsx +241 -0
- package/ui/src/pages/SqlViewer.jsx +344 -0
- package/ui/src/pages/Subscriptions.jsx +411 -0
- package/ui/vite.config.js +18 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { ShieldCheck, Loader2, CheckCircle2, AlertTriangle, RefreshCw, Info, ChevronRight, ChevronDown, Brush, FileText, FlaskConical, Bot, Lock, Puzzle, Plug, Package } from 'lucide-react'
|
|
3
|
+
import { fetchCheckAi } from '../lib/api'
|
|
4
|
+
import SectionTitle from './SectionTitle'
|
|
5
|
+
|
|
6
|
+
const GRADE_COLORS = {
|
|
7
|
+
'A+': '#22c55e', A: '#22c55e',
|
|
8
|
+
'B+': '#4ade80', B: '#4ade80',
|
|
9
|
+
'C+': '#facc15', C: '#facc15',
|
|
10
|
+
'D+': '#f97316', D: '#f97316',
|
|
11
|
+
F: '#ef4444',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SECTION_ICONS = {
|
|
15
|
+
'Repo Hygiene': Brush,
|
|
16
|
+
'Grounding Docs': FileText,
|
|
17
|
+
'Testing': FlaskConical,
|
|
18
|
+
'Agent Configs': Bot,
|
|
19
|
+
'AI Context': Lock,
|
|
20
|
+
'Prompts & Skills': Puzzle,
|
|
21
|
+
'MCP': Plug,
|
|
22
|
+
'AI Dependencies': Package,
|
|
23
|
+
'AI Deps': Package,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Tip({ missing }) {
|
|
27
|
+
const [open, setOpen] = useState(false)
|
|
28
|
+
const ref = useRef(null)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!open) return
|
|
32
|
+
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
|
|
33
|
+
document.addEventListener('mousedown', handler)
|
|
34
|
+
return () => document.removeEventListener('mousedown', handler)
|
|
35
|
+
}, [open])
|
|
36
|
+
|
|
37
|
+
const weighted = missing.filter(f => f.weight > 0)
|
|
38
|
+
const items = weighted.length > 0 ? weighted : missing.slice(0, 5)
|
|
39
|
+
if (items.length === 0) return null
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<span ref={ref} className="relative flex-shrink-0">
|
|
43
|
+
<button
|
|
44
|
+
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
|
45
|
+
className="flex items-center justify-center w-4 h-4 rounded-full transition hover:opacity-80"
|
|
46
|
+
style={{ color: 'var(--c-text3)', background: 'var(--c-bg3)' }}
|
|
47
|
+
>
|
|
48
|
+
<Info size={10} />
|
|
49
|
+
</button>
|
|
50
|
+
{open && (
|
|
51
|
+
<div
|
|
52
|
+
className="absolute right-0 top-6 z-50 w-64 p-2.5 rounded shadow-lg"
|
|
53
|
+
style={{ background: 'var(--c-bg)', border: '1px solid var(--c-border)', boxShadow: '0 4px 24px rgba(0,0,0,0.3)' }}
|
|
54
|
+
>
|
|
55
|
+
<div className="text-[11px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>How to improve</div>
|
|
56
|
+
<div className="space-y-1">
|
|
57
|
+
{items.map(f => (
|
|
58
|
+
<div key={f.id} className="text-[11px]" style={{ color: 'var(--c-text2)' }}>
|
|
59
|
+
<span style={{ color: 'var(--c-text)' }}>+ {f.label}</span>
|
|
60
|
+
{f.weight > 0 && <span style={{ color: 'var(--c-text3)' }}> ({f.weight}pt)</span>}
|
|
61
|
+
</div>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</span>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default function AiAuditCard({ folder }) {
|
|
71
|
+
console.log('AiAuditCard rendering, folder:', folder)
|
|
72
|
+
const [audit, setAudit] = useState(null)
|
|
73
|
+
const [loading, setLoading] = useState(false)
|
|
74
|
+
const [error, setError] = useState(null)
|
|
75
|
+
const [expanded, setExpanded] = useState(new Set())
|
|
76
|
+
|
|
77
|
+
const runAudit = async () => {
|
|
78
|
+
if (!folder) return
|
|
79
|
+
setLoading(true)
|
|
80
|
+
setError(null)
|
|
81
|
+
try {
|
|
82
|
+
const result = await fetchCheckAi(folder)
|
|
83
|
+
if (result.error) throw new Error(result.error)
|
|
84
|
+
setAudit(result)
|
|
85
|
+
} catch (e) {
|
|
86
|
+
setError(e.message)
|
|
87
|
+
}
|
|
88
|
+
setLoading(false)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
console.log('AiAuditCard useEffect triggered, folder:', folder)
|
|
93
|
+
runAudit()
|
|
94
|
+
}, [folder])
|
|
95
|
+
|
|
96
|
+
if (loading) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="card p-4">
|
|
99
|
+
<div className="flex items-center gap-2">
|
|
100
|
+
<Loader2 size={14} className="animate-spin" style={{ color: 'var(--c-accent)' }} />
|
|
101
|
+
<span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Running AI readiness audit...</span>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (error) {
|
|
108
|
+
return (
|
|
109
|
+
<div className="card p-4">
|
|
110
|
+
<div className="flex items-center justify-between">
|
|
111
|
+
<div className="flex items-center gap-2">
|
|
112
|
+
<AlertTriangle size={14} style={{ color: '#ef4444' }} />
|
|
113
|
+
<span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Audit failed: {error}</span>
|
|
114
|
+
</div>
|
|
115
|
+
<button
|
|
116
|
+
onClick={runAudit}
|
|
117
|
+
className="flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded transition hover:opacity-80"
|
|
118
|
+
style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
|
|
119
|
+
>
|
|
120
|
+
<RefreshCw size={10} />
|
|
121
|
+
Retry
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!audit) return null
|
|
129
|
+
|
|
130
|
+
const gradeColor = GRADE_COLORS[audit.grade] || 'var(--c-text2)'
|
|
131
|
+
const sections = audit.sections || {}
|
|
132
|
+
const findings = audit.findings || []
|
|
133
|
+
|
|
134
|
+
const findingsBySection = {}
|
|
135
|
+
for (const f of findings) {
|
|
136
|
+
const sec = f.section || 'Other'
|
|
137
|
+
if (!findingsBySection[sec]) findingsBySection[sec] = []
|
|
138
|
+
findingsBySection[sec].push(f)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="card p-4 space-y-3">
|
|
143
|
+
{/* Header row */}
|
|
144
|
+
<div className="flex items-center gap-3">
|
|
145
|
+
<ShieldCheck size={16} style={{ color: gradeColor }} />
|
|
146
|
+
<div>
|
|
147
|
+
<SectionTitle>ai readiness audit</SectionTitle>
|
|
148
|
+
<div className="text-[10px] -mt-1.5" style={{ color: 'var(--c-text3)' }}>powered by <code style={{ fontFamily: 'JetBrains Mono, monospace' }}>npx check-ai</code></div>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="flex items-center gap-2 ml-auto">
|
|
151
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
|
|
152
|
+
{audit.checks?.passed || 0}/{audit.checks?.total || 0} checks
|
|
153
|
+
· {audit.points?.earned || 0}/{audit.points?.max || 0} pts
|
|
154
|
+
</span>
|
|
155
|
+
<button
|
|
156
|
+
onClick={runAudit}
|
|
157
|
+
className="flex items-center gap-1 px-2 py-1 text-[11px] rounded transition hover:opacity-80"
|
|
158
|
+
style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
|
|
159
|
+
>
|
|
160
|
+
<RefreshCw size={10} />
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{/* Score row */}
|
|
166
|
+
<div className="flex items-center gap-3">
|
|
167
|
+
<div
|
|
168
|
+
className="w-11 h-11 rounded-lg flex items-center justify-center text-base font-black flex-shrink-0"
|
|
169
|
+
style={{ background: `${gradeColor}15`, color: gradeColor, border: `1.5px solid ${gradeColor}30` }}
|
|
170
|
+
>
|
|
171
|
+
{audit.grade}
|
|
172
|
+
</div>
|
|
173
|
+
<div className="flex-1 min-w-0">
|
|
174
|
+
<div className="flex items-baseline gap-1.5">
|
|
175
|
+
<span className="text-base font-bold" style={{ color: 'var(--c-white)' }}>{audit.score}</span>
|
|
176
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>/10</span>
|
|
177
|
+
<span className="text-[11px] ml-1" style={{ color: 'var(--c-text2)' }}>{audit.label}</span>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="w-full h-2 rounded-full overflow-hidden mt-1" style={{ background: 'var(--c-code-bg)' }}>
|
|
180
|
+
<div className="h-full rounded-full" style={{ width: `${((audit.score || 0) / 10 * 100).toFixed(1)}%`, background: gradeColor }} />
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{/* Section rows */}
|
|
186
|
+
<div>
|
|
187
|
+
{Object.entries(sections).map(([name, sec]) => {
|
|
188
|
+
const Icon = SECTION_ICONS[name] || FileText
|
|
189
|
+
const sectionFindings = findingsBySection[name] || []
|
|
190
|
+
const passed = sectionFindings.filter(f => f.found)
|
|
191
|
+
const missing = sectionFindings.filter(f => !f.found)
|
|
192
|
+
const pct = sec.pct || 0
|
|
193
|
+
const barColor = pct >= 70 ? '#22c55e' : pct >= 40 ? '#facc15' : pct > 0 ? '#f97316' : 'var(--c-text3)'
|
|
194
|
+
|
|
195
|
+
// Build description from found findings
|
|
196
|
+
const details = []
|
|
197
|
+
for (const f of passed) {
|
|
198
|
+
if (f.detail) details.push(f.detail)
|
|
199
|
+
else if (f.matchedPath) details.push(f.matchedPath)
|
|
200
|
+
else if (f.matches && f.matches.length > 0) details.push(f.matches.join(', '))
|
|
201
|
+
else details.push(f.label)
|
|
202
|
+
}
|
|
203
|
+
const desc = details.length > 0
|
|
204
|
+
? details.slice(0, 3).join(' · ') + (details.length > 3 ? ' …' : '')
|
|
205
|
+
: 'none detected'
|
|
206
|
+
|
|
207
|
+
const isOpen = expanded.has(name)
|
|
208
|
+
const toggle = () => setExpanded(prev => {
|
|
209
|
+
const next = new Set(prev)
|
|
210
|
+
if (next.has(name)) next.delete(name)
|
|
211
|
+
else next.add(name)
|
|
212
|
+
return next
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div key={name} style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
217
|
+
{/* Main row — clickable */}
|
|
218
|
+
<div className="flex items-center gap-2.5 py-2 cursor-pointer transition hover:bg-[var(--c-bg3)]" onClick={toggle}>
|
|
219
|
+
{isOpen
|
|
220
|
+
? <ChevronDown size={12} style={{ color: 'var(--c-text3)', flexShrink: 0 }} />
|
|
221
|
+
: <ChevronRight size={12} style={{ color: 'var(--c-text3)', flexShrink: 0 }} />
|
|
222
|
+
}
|
|
223
|
+
<Icon size={14} style={{ color: 'var(--c-text3)', flexShrink: 0 }} />
|
|
224
|
+
<span className="text-[12px] font-medium w-32 flex-shrink-0" style={{ color: 'var(--c-white)' }}>{name}</span>
|
|
225
|
+
<span className="text-[11px] flex-1 truncate" style={{ color: passed.length > 0 ? 'var(--c-text2)' : 'var(--c-text3)' }}>
|
|
226
|
+
{desc}
|
|
227
|
+
</span>
|
|
228
|
+
<span className="text-[11px] flex-shrink-0" style={{ color: 'var(--c-text3)' }}>{passed.length}/{sectionFindings.length}</span>
|
|
229
|
+
<div className="w-20 h-2 rounded-full overflow-hidden flex-shrink-0" style={{ background: 'var(--c-code-bg)' }}>
|
|
230
|
+
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: barColor }} />
|
|
231
|
+
</div>
|
|
232
|
+
<span className="text-[11px] w-9 text-right font-bold flex-shrink-0" style={{ color: barColor }}>{pct}%</span>
|
|
233
|
+
{missing.length > 0 && <Tip missing={missing} />}
|
|
234
|
+
</div>
|
|
235
|
+
{/* Expanded: found findings */}
|
|
236
|
+
{isOpen && passed.length > 0 && (
|
|
237
|
+
<div className="flex flex-wrap gap-x-4 gap-y-0.5 pb-2 pl-12">
|
|
238
|
+
{passed.map(f => (
|
|
239
|
+
<span key={f.id} className="inline-flex items-center gap-1.5 text-[11px] py-0.5">
|
|
240
|
+
<CheckCircle2 size={10} style={{ color: '#22c55e', flexShrink: 0 }} />
|
|
241
|
+
<span style={{ color: 'var(--c-text)' }}>{f.label}</span>
|
|
242
|
+
{f.matchedPath && <span style={{ color: 'var(--c-text3)' }}>{f.matchedPath}</span>}
|
|
243
|
+
{f.detail && <span style={{ color: 'var(--c-text2)' }}>— {f.detail}</span>}
|
|
244
|
+
</span>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
})}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import AnimatedLogo from './AnimatedLogo'
|
|
2
|
+
|
|
3
|
+
export default function AnimatedLoader({ label = 'Loading...' }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
|
6
|
+
<div style={{ opacity: 0.7 }}>
|
|
7
|
+
<AnimatedLogo size={32} />
|
|
8
|
+
</div>
|
|
9
|
+
{label && (
|
|
10
|
+
<span className="text-[12px]" style={{ color: 'var(--c-text3)' }}>{label}</span>
|
|
11
|
+
)}
|
|
12
|
+
</div>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export default function AnimatedLogo({ size = 22 }) {
|
|
2
|
+
// Carousel: a horizontal strip of bots slides left continuously.
|
|
3
|
+
// The viewport shows one bot at a time. The next bot pushes the current one out.
|
|
4
|
+
// 5 slots (4 bots + repeat of first) for seamless loop.
|
|
5
|
+
const step = 28 // each bot cell width
|
|
6
|
+
// Keyframes: hold on each bot, then slide to the next.
|
|
7
|
+
// 4 stops: 0→1→2→3→0 (repeat first = slot 4)
|
|
8
|
+
// Each stop: 5% slide + 20% hold = 25% per bot
|
|
9
|
+
return (
|
|
10
|
+
<svg
|
|
11
|
+
viewBox="0 0 24 24"
|
|
12
|
+
width={size}
|
|
13
|
+
height={size}
|
|
14
|
+
fill="none"
|
|
15
|
+
style={{ overflow: 'hidden' }}
|
|
16
|
+
>
|
|
17
|
+
<style>{`
|
|
18
|
+
@keyframes carousel {
|
|
19
|
+
0% { transform: translateX(0); }
|
|
20
|
+
5% { transform: translateX(0); }
|
|
21
|
+
25% { transform: translateX(-${step}px); }
|
|
22
|
+
30% { transform: translateX(-${step}px); }
|
|
23
|
+
50% { transform: translateX(-${step * 2}px); }
|
|
24
|
+
55% { transform: translateX(-${step * 2}px); }
|
|
25
|
+
75% { transform: translateX(-${step * 3}px); }
|
|
26
|
+
80% { transform: translateX(-${step * 3}px); }
|
|
27
|
+
100% { transform: translateX(-${step * 4}px); }
|
|
28
|
+
}
|
|
29
|
+
.carousel-strip { animation: carousel 4s cubic-bezier(0.45, 0, 0.55, 1) infinite; }
|
|
30
|
+
`}</style>
|
|
31
|
+
|
|
32
|
+
<g className="carousel-strip">
|
|
33
|
+
{/* Slot 0: Bot 1 — indigo */}
|
|
34
|
+
<g transform="translate(0, 0)" stroke="#818cf8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
35
|
+
<path d="M12 8V4H8"/>
|
|
36
|
+
<rect width="16" height="12" x="4" y="8" rx="2"/>
|
|
37
|
+
<path d="M2 14h2"/>
|
|
38
|
+
<path d="M20 14h2"/>
|
|
39
|
+
<path d="M15 13v2"/>
|
|
40
|
+
<path d="M9 13v2"/>
|
|
41
|
+
</g>
|
|
42
|
+
|
|
43
|
+
{/* Slot 1: Bot 2 — pink */}
|
|
44
|
+
<g transform={`translate(${step}, 0)`} stroke="#f472b6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
45
|
+
<path d="M12 6V2H8"/>
|
|
46
|
+
<path d="M15 11v2"/>
|
|
47
|
+
<path d="M2 12h2"/>
|
|
48
|
+
<path d="M20 12h2"/>
|
|
49
|
+
<path d="M20 16a2 2 0 0 1-2 2H8.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 4 20.286V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z"/>
|
|
50
|
+
<path d="M9 11v2"/>
|
|
51
|
+
</g>
|
|
52
|
+
|
|
53
|
+
{/* Slot 2: Bot 3 — emerald */}
|
|
54
|
+
<g transform={`translate(${step * 2}, 0)`} stroke="#34d399" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
55
|
+
<path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2l0-4"/>
|
|
56
|
+
<path d="M12 2v2"/>
|
|
57
|
+
<path d="M9 12v9"/>
|
|
58
|
+
<path d="M15 12v9"/>
|
|
59
|
+
<path d="M5 16l4-2"/>
|
|
60
|
+
<path d="M15 14l4 2"/>
|
|
61
|
+
<path d="M9 18h6"/>
|
|
62
|
+
<path d="M10 8v.01"/>
|
|
63
|
+
<path d="M14 8v.01"/>
|
|
64
|
+
</g>
|
|
65
|
+
|
|
66
|
+
{/* Slot 3: Bot 4 — amber */}
|
|
67
|
+
<g transform={`translate(${step * 3}, 0)`} stroke="#fbbf24" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
68
|
+
<path d="M12 20v2"/>
|
|
69
|
+
<path d="M12 2v2"/>
|
|
70
|
+
<path d="M17 20v2"/>
|
|
71
|
+
<path d="M17 2v2"/>
|
|
72
|
+
<path d="M2 12h2"/>
|
|
73
|
+
<path d="M2 17h2"/>
|
|
74
|
+
<path d="M2 7h2"/>
|
|
75
|
+
<path d="M20 12h2"/>
|
|
76
|
+
<path d="M20 17h2"/>
|
|
77
|
+
<path d="M20 7h2"/>
|
|
78
|
+
<path d="M7 20v2"/>
|
|
79
|
+
<path d="M7 2v2"/>
|
|
80
|
+
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
|
81
|
+
<rect x="8" y="8" width="8" height="8" rx="1"/>
|
|
82
|
+
</g>
|
|
83
|
+
|
|
84
|
+
{/* Slot 4: Repeat Bot 1 for seamless loop — indigo */}
|
|
85
|
+
<g transform={`translate(${step * 4}, 0)`} stroke="#818cf8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
86
|
+
<path d="M12 8V4H8"/>
|
|
87
|
+
<rect width="16" height="12" x="4" y="8" rx="2"/>
|
|
88
|
+
<path d="M2 14h2"/>
|
|
89
|
+
<path d="M20 14h2"/>
|
|
90
|
+
<path d="M15 13v2"/>
|
|
91
|
+
<path d="M9 13v2"/>
|
|
92
|
+
</g>
|
|
93
|
+
</g>
|
|
94
|
+
</svg>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { X, Download, Send, Search } from 'lucide-react'
|
|
3
|
+
import { fetchChat, BASE } from '../lib/api'
|
|
4
|
+
import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
|
|
5
|
+
import MessageContent, { ROLE_CONFIG } from './MessageRenderer'
|
|
6
|
+
import TokenTimeline from './TokenTimeline'
|
|
7
|
+
|
|
8
|
+
export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, username }) {
|
|
9
|
+
const [chat, setChat] = useState(null)
|
|
10
|
+
const [loading, setLoading] = useState(true)
|
|
11
|
+
const [error, setError] = useState(null)
|
|
12
|
+
const [msgFilter, setMsgFilter] = useState('')
|
|
13
|
+
const scrollRef = useRef(null)
|
|
14
|
+
const msgCountRef = useRef(0)
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!chatId) return
|
|
18
|
+
setLoading(true)
|
|
19
|
+
setChat(null)
|
|
20
|
+
setError(null)
|
|
21
|
+
msgCountRef.current = 0
|
|
22
|
+
const doFetch = fetchFn || fetchChat
|
|
23
|
+
doFetch(chatId).then(data => {
|
|
24
|
+
setChat(data)
|
|
25
|
+
msgCountRef.current = data?.messages?.length || 0
|
|
26
|
+
setLoading(false)
|
|
27
|
+
}).catch(e => {
|
|
28
|
+
// Without this, a failed initial fetch leaves the spinner forever and
|
|
29
|
+
// surfaces as an unhandled rejection.
|
|
30
|
+
setError(e?.message || 'Failed to load session')
|
|
31
|
+
setLoading(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Poll for new messages while sidebar is open
|
|
35
|
+
const iv = setInterval(() => {
|
|
36
|
+
doFetch(chatId).then(data => {
|
|
37
|
+
if (data?.messages?.length !== msgCountRef.current) {
|
|
38
|
+
msgCountRef.current = data?.messages?.length || 0
|
|
39
|
+
setChat(data)
|
|
40
|
+
}
|
|
41
|
+
}).catch(() => {})
|
|
42
|
+
}, 10000)
|
|
43
|
+
return () => clearInterval(iv)
|
|
44
|
+
}, [chatId, fetchFn])
|
|
45
|
+
|
|
46
|
+
// Scroll to bottom when chat loads or new messages arrive
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!loading && chat && scrollRef.current) {
|
|
49
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
50
|
+
}
|
|
51
|
+
}, [loading, chat])
|
|
52
|
+
|
|
53
|
+
// Reset filter when chat changes
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
setMsgFilter('')
|
|
56
|
+
}, [chatId])
|
|
57
|
+
|
|
58
|
+
const handleScrollToMessage = useCallback((msgIndex) => {
|
|
59
|
+
if (!scrollRef.current) return
|
|
60
|
+
const el = scrollRef.current.querySelector(`[data-msg-index="${msgIndex}"]`)
|
|
61
|
+
if (el) {
|
|
62
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
63
|
+
el.style.outline = '1.5px solid var(--c-accent)'
|
|
64
|
+
el.style.outlineOffset = '2px'
|
|
65
|
+
setTimeout(() => { el.style.outline = 'none' }, 1500)
|
|
66
|
+
}
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
if (!chatId) return null
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
{/* Backdrop */}
|
|
74
|
+
<div
|
|
75
|
+
className="fixed inset-0 z-40 transition-opacity"
|
|
76
|
+
style={{ background: 'rgba(0,0,0,0.3)' }}
|
|
77
|
+
onClick={onClose}
|
|
78
|
+
/>
|
|
79
|
+
|
|
80
|
+
{/* Sidebar */}
|
|
81
|
+
<div
|
|
82
|
+
className="fixed top-0 right-0 bottom-0 z-50 flex flex-col shadow-2xl sidebar-slide-in"
|
|
83
|
+
style={{
|
|
84
|
+
width: 'min(580px, 90vw)',
|
|
85
|
+
background: 'var(--c-bg)',
|
|
86
|
+
borderLeft: '1px solid var(--c-border)',
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{/* Header */}
|
|
90
|
+
<div className="flex items-center gap-3 px-4 py-3 shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
91
|
+
<button onClick={onClose} className="p-1 rounded transition hover:bg-[var(--c-bg3)]" style={{ color: 'var(--c-text2)' }}>
|
|
92
|
+
<X size={14} />
|
|
93
|
+
</button>
|
|
94
|
+
{chat && (
|
|
95
|
+
<div className="flex-1 min-w-0">
|
|
96
|
+
<div className="text-sm font-medium truncate" style={{ color: 'var(--c-white)' }}>
|
|
97
|
+
{chat.name || '(untitled)'}
|
|
98
|
+
</div>
|
|
99
|
+
<div className="flex items-center gap-2 text-[11px]" style={{ color: 'var(--c-text2)' }}>
|
|
100
|
+
<span className="inline-flex items-center gap-1">
|
|
101
|
+
<span className="w-1.5 h-1.5 rounded-full" style={{ background: editorColor(chat.source) }} />
|
|
102
|
+
{editorLabel(chat.source)}
|
|
103
|
+
</span>
|
|
104
|
+
{chat.mode && <span>· {chat.mode}</span>}
|
|
105
|
+
<span>{formatDateTime(chat.createdAt)}</span>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
{chat && !fetchFn && (
|
|
110
|
+
<a
|
|
111
|
+
href={`${BASE}/api/chats/${encodeURIComponent(chat.id)}/markdown`}
|
|
112
|
+
download
|
|
113
|
+
className="flex items-center gap-1 px-2 py-1 text-[11px] transition shrink-0"
|
|
114
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
115
|
+
>
|
|
116
|
+
<Download size={11} /> .md
|
|
117
|
+
</a>
|
|
118
|
+
)}
|
|
119
|
+
{extraHeader}
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Stats row */}
|
|
123
|
+
{chat?.stats && (
|
|
124
|
+
<div className="flex items-center gap-3 px-4 py-2 text-[11px] shrink-0" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text2)' }}>
|
|
125
|
+
<span>{chat.stats.totalMessages} msgs</span>
|
|
126
|
+
{chat.stats.toolCalls?.length > 0 && <span>{chat.stats.toolCalls.length} tools</span>}
|
|
127
|
+
{chat.stats.totalInputTokens > 0 && <span>{formatNumber(chat.stats.totalInputTokens)} in</span>}
|
|
128
|
+
{chat.stats.totalOutputTokens > 0 && <span>{formatNumber(chat.stats.totalOutputTokens)} out</span>}
|
|
129
|
+
{chat.createdAt && chat.lastUpdatedAt && (() => {
|
|
130
|
+
const ms = new Date(chat.lastUpdatedAt).getTime() - new Date(chat.createdAt).getTime()
|
|
131
|
+
if (ms <= 0) return null
|
|
132
|
+
const mins = Math.floor(ms / 60000)
|
|
133
|
+
const label = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`
|
|
134
|
+
return <span>{label}</span>
|
|
135
|
+
})()}
|
|
136
|
+
{chat.stats.models?.length > 0 && (
|
|
137
|
+
<span className="ml-auto font-mono truncate" style={{ color: 'var(--c-accent)', opacity: 0.7 }}>
|
|
138
|
+
{[...new Set(chat.stats.models)].join(', ')}
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{/* Token Timeline */}
|
|
145
|
+
{chat && chat.messages.length > 0 && (
|
|
146
|
+
<TokenTimeline
|
|
147
|
+
messages={chat.messages}
|
|
148
|
+
createdAt={chat.createdAt}
|
|
149
|
+
lastUpdatedAt={chat.lastUpdatedAt}
|
|
150
|
+
onScrollToMessage={handleScrollToMessage}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{/* Search bar */}
|
|
155
|
+
{chat && chat.messages.length > 0 && (
|
|
156
|
+
<div className="shrink-0 px-4 py-2" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
157
|
+
<div className="relative">
|
|
158
|
+
<Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
159
|
+
<input
|
|
160
|
+
type="text"
|
|
161
|
+
placeholder="Filter messages..."
|
|
162
|
+
value={msgFilter}
|
|
163
|
+
onChange={e => setMsgFilter(e.target.value)}
|
|
164
|
+
className="w-full pl-7 pr-3 py-1 text-[12px] outline-none"
|
|
165
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* Messages */}
|
|
172
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin px-4 py-3 space-y-2">
|
|
173
|
+
{loading && (
|
|
174
|
+
<div className="text-[12px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>Loading conversation...</div>
|
|
175
|
+
)}
|
|
176
|
+
{!loading && !chat && (
|
|
177
|
+
<div className="text-[12px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>
|
|
178
|
+
{error ? `Failed to load conversation: ${error}` : 'Conversation not found.'}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
{!loading && chat && chat.messages.length === 0 && (
|
|
182
|
+
<div className="text-[12px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>
|
|
183
|
+
{chat.encrypted ? '🔒 This conversation is encrypted.' : 'No messages found.'}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
{!loading && chat && chat.messages
|
|
187
|
+
.filter(msg => !msgFilter || msg.content.toLowerCase().includes(msgFilter.toLowerCase()))
|
|
188
|
+
.map((msg, i) => {
|
|
189
|
+
const cfg = ROLE_CONFIG[msg.role] || ROLE_CONFIG.system
|
|
190
|
+
const Icon = cfg.icon
|
|
191
|
+
return (
|
|
192
|
+
<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' }}>
|
|
193
|
+
<div className="flex items-center gap-1.5 text-[11px] mb-1" style={{ color: 'var(--c-text2)' }}>
|
|
194
|
+
<Icon size={11} />
|
|
195
|
+
<span className="font-medium">{msg.role === 'user' && (username || chat?.username) ? (username || chat.username) : cfg.label}</span>
|
|
196
|
+
{msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>· {msg.model}</span>}
|
|
197
|
+
</div>
|
|
198
|
+
<div className="text-[12px]" style={{ color: 'var(--c-text)' }}>
|
|
199
|
+
<MessageContent content={msg.content} toolCallDetails={chat.toolCallDetails || []} />
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{/* Fake disabled chat input */}
|
|
207
|
+
<div className="shrink-0 px-4 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
208
|
+
<div
|
|
209
|
+
className="flex items-center gap-2 px-3 py-2.5 rounded-lg"
|
|
210
|
+
style={{
|
|
211
|
+
background: 'var(--c-bg3)',
|
|
212
|
+
border: '1px solid var(--c-border)',
|
|
213
|
+
opacity: 0.5,
|
|
214
|
+
cursor: 'not-allowed',
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
<span className="flex-1 text-[12px]" style={{ color: 'var(--c-text3)' }}>
|
|
218
|
+
Message is read-only...
|
|
219
|
+
</span>
|
|
220
|
+
<Send size={13} style={{ color: 'var(--c-text3)' }} />
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</>
|
|
225
|
+
)
|
|
226
|
+
}
|