agentfit 0.1.2 → 0.1.5
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/.github/workflows/release.yml +4 -0
- package/README.md +0 -2
- package/app/(dashboard)/ai-insights/page.tsx +271 -0
- package/app/(dashboard)/models/page.tsx +21 -0
- package/app/(dashboard)/page.tsx +2 -0
- package/app/(dashboard)/sessions/[id]/page.tsx +16 -2
- package/app/(dashboard)/settings/page.tsx +168 -0
- package/app/api/analyze/aggregate/route.ts +88 -0
- package/app/api/analyze/estimate/route.ts +62 -0
- package/app/api/analyze/route.ts +142 -0
- package/app/api/cc-versions/route.ts +84 -0
- package/app/api/config/route.ts +35 -0
- package/bin/agentfit.mjs +22 -13
- package/components/analyze-confirm-dialog.tsx +81 -0
- package/components/app-sidebar.tsx +14 -0
- package/components/data-provider.tsx +4 -2
- package/components/model-usage-chart.tsx +216 -0
- package/components/overview-cards.tsx +1 -1
- package/components/session-ai-analysis.tsx +318 -0
- package/components/sessions-table.tsx +169 -15
- package/components/version-lag-chart.tsx +284 -0
- package/electron/main.mjs +81 -59
- package/generated/prisma/browser.ts +5 -0
- package/generated/prisma/client.ts +5 -0
- package/generated/prisma/internal/class.ts +14 -4
- package/generated/prisma/internal/prismaNamespace.ts +95 -2
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +19 -1
- package/generated/prisma/models/Session.ts +57 -1
- package/generated/prisma/models/SessionAnalysis.ts +1321 -0
- package/generated/prisma/models.ts +1 -0
- package/lib/config.ts +45 -0
- package/lib/db.ts +1 -1
- package/lib/openai.ts +253 -0
- package/lib/parse-codex.ts +2 -0
- package/lib/parse-logs.ts +21 -7
- package/lib/queries.ts +5 -1
- package/lib/sync.ts +17 -5
- package/package.json +2 -1
- package/prisma/migrations/20260404151230_add_session_analysis/migration.sql +18 -0
- package/prisma/migrations/20260405230736_add_cli_version/migration.sql +41 -0
- package/prisma/migrations/20260406205546_add_model_counts/migration.sql +42 -0
- package/prisma/schema.prisma +16 -0
- package/prisma/schema.sql +20 -0
- /package/prisma/migrations/{20260401144555_add_system_prompt_edits → 20260403214556_init}/migration.sql +0 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Bar,
|
|
6
|
+
BarChart,
|
|
7
|
+
Area,
|
|
8
|
+
AreaChart,
|
|
9
|
+
CartesianGrid,
|
|
10
|
+
XAxis,
|
|
11
|
+
YAxis,
|
|
12
|
+
Cell,
|
|
13
|
+
} from 'recharts'
|
|
14
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
15
|
+
import {
|
|
16
|
+
ChartContainer,
|
|
17
|
+
ChartTooltip,
|
|
18
|
+
ChartTooltipContent,
|
|
19
|
+
type ChartConfig,
|
|
20
|
+
} from '@/components/ui/chart'
|
|
21
|
+
import type { SessionSummary, DailyUsage } from '@/lib/parse-logs'
|
|
22
|
+
|
|
23
|
+
// ─── 1. Model Distribution (horizontal bar) ────────────────────────
|
|
24
|
+
|
|
25
|
+
const barConfig = {
|
|
26
|
+
messages: { label: 'Messages', color: 'var(--chart-1)' },
|
|
27
|
+
} satisfies ChartConfig
|
|
28
|
+
|
|
29
|
+
const CHART_COLORS = [
|
|
30
|
+
'var(--chart-1)',
|
|
31
|
+
'var(--chart-2)',
|
|
32
|
+
'var(--chart-3)',
|
|
33
|
+
'var(--chart-4)',
|
|
34
|
+
'var(--chart-5)',
|
|
35
|
+
'var(--chart-6)',
|
|
36
|
+
'var(--chart-7)',
|
|
37
|
+
'var(--chart-8)',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
function shortenModelName(name: string): string {
|
|
41
|
+
return name
|
|
42
|
+
.replace('claude-', '')
|
|
43
|
+
.replace('anthropic/', '')
|
|
44
|
+
.replace(/-\d{8}$/, '')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ModelDistributionChart({ sessions }: { sessions: SessionSummary[] }) {
|
|
48
|
+
const data = useMemo(() => {
|
|
49
|
+
const counts: Record<string, number> = {}
|
|
50
|
+
for (const s of sessions) {
|
|
51
|
+
for (const [m, count] of Object.entries(s.modelCounts || {})) {
|
|
52
|
+
if (m !== 'unknown') {
|
|
53
|
+
counts[m] = (counts[m] || 0) + count
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return Object.entries(counts)
|
|
58
|
+
.sort((a, b) => b[1] - a[1])
|
|
59
|
+
.map(([name, messages], i) => ({
|
|
60
|
+
name: shortenModelName(name),
|
|
61
|
+
fullName: name,
|
|
62
|
+
messages,
|
|
63
|
+
fill: CHART_COLORS[i % CHART_COLORS.length],
|
|
64
|
+
}))
|
|
65
|
+
}, [sessions])
|
|
66
|
+
|
|
67
|
+
if (data.length === 0) {
|
|
68
|
+
return (
|
|
69
|
+
<Card>
|
|
70
|
+
<CardHeader>
|
|
71
|
+
<CardTitle>Model Distribution</CardTitle>
|
|
72
|
+
<CardDescription>Messages per model</CardDescription>
|
|
73
|
+
</CardHeader>
|
|
74
|
+
<CardContent>
|
|
75
|
+
<div className="flex min-h-[200px] items-center justify-center text-sm text-muted-foreground">
|
|
76
|
+
No model data available
|
|
77
|
+
</div>
|
|
78
|
+
</CardContent>
|
|
79
|
+
</Card>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const total = data.reduce((a, d) => a + d.messages, 0)
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Card>
|
|
87
|
+
<CardHeader>
|
|
88
|
+
<CardTitle>Model Distribution</CardTitle>
|
|
89
|
+
<CardDescription>{total.toLocaleString()} total assistant messages across {data.length} model{data.length !== 1 ? 's' : ''}</CardDescription>
|
|
90
|
+
</CardHeader>
|
|
91
|
+
<CardContent>
|
|
92
|
+
<ChartContainer config={barConfig} className="w-full" style={{ minHeight: Math.max(200, data.length * 40) }}>
|
|
93
|
+
<BarChart data={data} layout="vertical" margin={{ left: 8 }} accessibilityLayer>
|
|
94
|
+
<XAxis type="number" tickLine={false} axisLine={false} />
|
|
95
|
+
<YAxis type="category" dataKey="name" tickLine={false} axisLine={false} width={160} />
|
|
96
|
+
<ChartTooltip
|
|
97
|
+
content={({ active, payload }) => {
|
|
98
|
+
if (!active || !payload?.length) return null
|
|
99
|
+
const d = payload[0].payload
|
|
100
|
+
const pct = ((d.messages / total) * 100).toFixed(1)
|
|
101
|
+
return (
|
|
102
|
+
<div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md">
|
|
103
|
+
<div className="font-semibold">{d.fullName}</div>
|
|
104
|
+
<div className="mt-1 text-muted-foreground">
|
|
105
|
+
<span className="font-mono font-medium text-foreground">{d.messages.toLocaleString()}</span> messages ({pct}%)
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}}
|
|
110
|
+
/>
|
|
111
|
+
<Bar dataKey="messages" radius={[0, 4, 4, 0]}>
|
|
112
|
+
{data.map((entry, i) => (
|
|
113
|
+
<Cell key={i} fill={entry.fill} />
|
|
114
|
+
))}
|
|
115
|
+
</Bar>
|
|
116
|
+
</BarChart>
|
|
117
|
+
</ChartContainer>
|
|
118
|
+
</CardContent>
|
|
119
|
+
</Card>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── 2. Model Usage Over Time (stacked area) ───────────────────────
|
|
124
|
+
|
|
125
|
+
export function ModelUsageOverTimeChart({ sessions }: { sessions: SessionSummary[] }) {
|
|
126
|
+
const { data, topModels, config } = useMemo(() => {
|
|
127
|
+
// Find top models by total message count
|
|
128
|
+
const totalCounts: Record<string, number> = {}
|
|
129
|
+
for (const s of sessions) {
|
|
130
|
+
for (const [m, count] of Object.entries(s.modelCounts || {})) {
|
|
131
|
+
if (m !== 'unknown') {
|
|
132
|
+
totalCounts[m] = (totalCounts[m] || 0) + count
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const sorted = Object.entries(totalCounts).sort((a, b) => b[1] - a[1])
|
|
137
|
+
const top = sorted.slice(0, 6).map(([name]) => name)
|
|
138
|
+
|
|
139
|
+
// Group by date
|
|
140
|
+
const byDate = new Map<string, Record<string, number>>()
|
|
141
|
+
for (const s of sessions) {
|
|
142
|
+
const date = s.startTime.slice(0, 10)
|
|
143
|
+
if (!byDate.has(date)) byDate.set(date, {})
|
|
144
|
+
const day = byDate.get(date)!
|
|
145
|
+
for (const [m, count] of Object.entries(s.modelCounts || {})) {
|
|
146
|
+
if (m !== 'unknown') {
|
|
147
|
+
day[m] = (day[m] || 0) + count
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const sortedDates = Array.from(byDate.keys()).sort()
|
|
153
|
+
const points = sortedDates.map((date) => {
|
|
154
|
+
const day = byDate.get(date)!
|
|
155
|
+
const row: Record<string, string | number> = { date: date.slice(5) }
|
|
156
|
+
for (const m of top) {
|
|
157
|
+
row[shortenModelName(m)] = day[m] || 0
|
|
158
|
+
}
|
|
159
|
+
return row
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const cfg: ChartConfig = {}
|
|
163
|
+
for (let i = 0; i < top.length; i++) {
|
|
164
|
+
const short = shortenModelName(top[i])
|
|
165
|
+
cfg[short] = { label: short, color: CHART_COLORS[i % CHART_COLORS.length] }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { data: points, topModels: top.map(shortenModelName), config: cfg }
|
|
169
|
+
}, [sessions])
|
|
170
|
+
|
|
171
|
+
if (data.length === 0) {
|
|
172
|
+
return (
|
|
173
|
+
<Card>
|
|
174
|
+
<CardHeader>
|
|
175
|
+
<CardTitle>Model Usage Over Time</CardTitle>
|
|
176
|
+
<CardDescription>Daily messages by model</CardDescription>
|
|
177
|
+
</CardHeader>
|
|
178
|
+
<CardContent>
|
|
179
|
+
<div className="flex min-h-[250px] items-center justify-center text-sm text-muted-foreground">
|
|
180
|
+
No model data available
|
|
181
|
+
</div>
|
|
182
|
+
</CardContent>
|
|
183
|
+
</Card>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<Card>
|
|
189
|
+
<CardHeader>
|
|
190
|
+
<CardTitle>Model Usage Over Time</CardTitle>
|
|
191
|
+
<CardDescription>Daily assistant messages by model</CardDescription>
|
|
192
|
+
</CardHeader>
|
|
193
|
+
<CardContent>
|
|
194
|
+
<ChartContainer config={config} className="min-h-[250px] w-full">
|
|
195
|
+
<AreaChart data={data} accessibilityLayer>
|
|
196
|
+
<CartesianGrid vertical={false} />
|
|
197
|
+
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
|
|
198
|
+
<YAxis tickLine={false} axisLine={false} />
|
|
199
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
200
|
+
{topModels.map((m) => (
|
|
201
|
+
<Area
|
|
202
|
+
key={m}
|
|
203
|
+
dataKey={m}
|
|
204
|
+
type="monotone"
|
|
205
|
+
stackId="1"
|
|
206
|
+
stroke={config[m]?.color}
|
|
207
|
+
fill={config[m]?.color}
|
|
208
|
+
fillOpacity={0.4}
|
|
209
|
+
/>
|
|
210
|
+
))}
|
|
211
|
+
</AreaChart>
|
|
212
|
+
</ChartContainer>
|
|
213
|
+
</CardContent>
|
|
214
|
+
</Card>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
@@ -219,7 +219,7 @@ export function OverviewCards({ overview, sessions }: { overview: OverviewStats;
|
|
|
219
219
|
},
|
|
220
220
|
{
|
|
221
221
|
title: 'Top Model',
|
|
222
|
-
value: Object.entries(overview.models).sort((a, b) => b[1] - a[1])[0]?.[0] || 'N/A',
|
|
222
|
+
value: Object.entries(overview.models).filter(([k]) => k !== 'unknown').sort((a, b) => b[1] - a[1])[0]?.[0] || 'N/A',
|
|
223
223
|
icon: Cpu,
|
|
224
224
|
explanation: 'The Claude model used in the most sessions. Determined by the model field in assistant responses. If a session uses multiple models, the last one seen is recorded.',
|
|
225
225
|
},
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from 'react'
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { Badge } from '@/components/ui/badge'
|
|
7
|
+
import {
|
|
8
|
+
ChartContainer,
|
|
9
|
+
ChartTooltip,
|
|
10
|
+
ChartTooltipContent,
|
|
11
|
+
type ChartConfig,
|
|
12
|
+
} from '@/components/ui/chart'
|
|
13
|
+
import { Bar, BarChart, XAxis, YAxis, Cell } from 'recharts'
|
|
14
|
+
import { Sparkles, Loader2, AlertCircle } from 'lucide-react'
|
|
15
|
+
import { AnalyzeConfirmDialog } from './analyze-confirm-dialog'
|
|
16
|
+
import { formatCost } from '@/lib/format'
|
|
17
|
+
import type { MessageClassification } from '@/lib/openai'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
const CHART_COLORS = [
|
|
21
|
+
'var(--chart-1)',
|
|
22
|
+
'var(--chart-2)',
|
|
23
|
+
'var(--chart-3)',
|
|
24
|
+
'var(--chart-4)',
|
|
25
|
+
'var(--chart-5)',
|
|
26
|
+
'var(--chart-6)',
|
|
27
|
+
'var(--chart-7)',
|
|
28
|
+
'var(--chart-8)',
|
|
29
|
+
'var(--chart-9)',
|
|
30
|
+
'var(--chart-10)',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
function DistributionChart({
|
|
34
|
+
title,
|
|
35
|
+
description,
|
|
36
|
+
data,
|
|
37
|
+
}: {
|
|
38
|
+
title: string
|
|
39
|
+
description: string
|
|
40
|
+
data: Record<string, number>
|
|
41
|
+
}) {
|
|
42
|
+
const chartData = Object.entries(data)
|
|
43
|
+
.sort((a, b) => b[1] - a[1])
|
|
44
|
+
.map(([name, count]) => ({ name, count }))
|
|
45
|
+
|
|
46
|
+
const config: ChartConfig = {
|
|
47
|
+
count: { label: 'Count', color: 'var(--chart-1)' },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const total = chartData.reduce((sum, d) => sum + d.count, 0)
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Card>
|
|
54
|
+
<CardHeader className="pb-2">
|
|
55
|
+
<CardTitle className="text-sm">{title}</CardTitle>
|
|
56
|
+
<CardDescription>{description}</CardDescription>
|
|
57
|
+
</CardHeader>
|
|
58
|
+
<CardContent>
|
|
59
|
+
<ChartContainer
|
|
60
|
+
config={config}
|
|
61
|
+
className="w-full"
|
|
62
|
+
style={{ minHeight: Math.max(150, chartData.length * 32) }}
|
|
63
|
+
>
|
|
64
|
+
<BarChart data={chartData} layout="vertical" margin={{ left: 8 }} accessibilityLayer>
|
|
65
|
+
<XAxis type="number" tickLine={false} axisLine={false} />
|
|
66
|
+
<YAxis
|
|
67
|
+
type="category"
|
|
68
|
+
dataKey="name"
|
|
69
|
+
tickLine={false}
|
|
70
|
+
axisLine={false}
|
|
71
|
+
width={110}
|
|
72
|
+
tick={{ fontSize: 12 }}
|
|
73
|
+
/>
|
|
74
|
+
<ChartTooltip
|
|
75
|
+
content={
|
|
76
|
+
<ChartTooltipContent
|
|
77
|
+
formatter={(value) => {
|
|
78
|
+
const n = Number(value)
|
|
79
|
+
return `${n} (${((n / total) * 100).toFixed(1)}%)`
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
|
85
|
+
{chartData.map((_, i) => (
|
|
86
|
+
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
|
87
|
+
))}
|
|
88
|
+
</Bar>
|
|
89
|
+
</BarChart>
|
|
90
|
+
</ChartContainer>
|
|
91
|
+
</CardContent>
|
|
92
|
+
</Card>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface AnalysisData {
|
|
97
|
+
classifications: MessageClassification[]
|
|
98
|
+
model: string
|
|
99
|
+
totalMessages: number
|
|
100
|
+
inputTokens: number
|
|
101
|
+
outputTokens: number
|
|
102
|
+
costUSD: number
|
|
103
|
+
analyzedAt: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function SessionAIAnalysis({ sessionId }: { sessionId: string }) {
|
|
107
|
+
const [analysis, setAnalysis] = useState<AnalysisData | null>(null)
|
|
108
|
+
const [loading, setLoading] = useState(true)
|
|
109
|
+
const [analyzing, setAnalyzing] = useState(false)
|
|
110
|
+
const [error, setError] = useState<string | null>(null)
|
|
111
|
+
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
112
|
+
const [estimate, setEstimate] = useState<{
|
|
113
|
+
sessionCount: number
|
|
114
|
+
messageCount: number
|
|
115
|
+
estimatedCostUSD: number
|
|
116
|
+
} | null>(null)
|
|
117
|
+
|
|
118
|
+
const loadAnalysis = useCallback(async () => {
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(`/api/analyze?sessionId=${sessionId}`)
|
|
121
|
+
const data = await res.json()
|
|
122
|
+
if (data.analysis) setAnalysis(data.analysis)
|
|
123
|
+
} catch {
|
|
124
|
+
// No analysis yet — that's fine
|
|
125
|
+
} finally {
|
|
126
|
+
setLoading(false)
|
|
127
|
+
}
|
|
128
|
+
}, [sessionId])
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
loadAnalysis()
|
|
132
|
+
}, [loadAnalysis])
|
|
133
|
+
|
|
134
|
+
async function handleAnalyzeClick() {
|
|
135
|
+
// Fetch estimate
|
|
136
|
+
setEstimate(null)
|
|
137
|
+
setConfirmOpen(true)
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch('/api/analyze/estimate', {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'Content-Type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({ sessionId }),
|
|
143
|
+
})
|
|
144
|
+
const data = await res.json()
|
|
145
|
+
setEstimate({
|
|
146
|
+
sessionCount: 1,
|
|
147
|
+
messageCount: data.messageCount,
|
|
148
|
+
estimatedCostUSD: data.estimatedCostUSD,
|
|
149
|
+
})
|
|
150
|
+
} catch {
|
|
151
|
+
setError('Failed to estimate cost')
|
|
152
|
+
setConfirmOpen(false)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function handleConfirm() {
|
|
157
|
+
setAnalyzing(true)
|
|
158
|
+
setError(null)
|
|
159
|
+
setConfirmOpen(false)
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch('/api/analyze', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ sessionId }),
|
|
165
|
+
})
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
const data = await res.json()
|
|
168
|
+
throw new Error(data.error || 'Analysis failed')
|
|
169
|
+
}
|
|
170
|
+
const data = await res.json()
|
|
171
|
+
setAnalysis(data.analysis)
|
|
172
|
+
} catch (e) {
|
|
173
|
+
setError((e as Error).message)
|
|
174
|
+
} finally {
|
|
175
|
+
setAnalyzing(false)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (loading) {
|
|
180
|
+
return (
|
|
181
|
+
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
|
182
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading...
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!analysis) {
|
|
188
|
+
return (
|
|
189
|
+
<Card>
|
|
190
|
+
<CardContent className="flex flex-col items-center justify-center py-12 space-y-4">
|
|
191
|
+
<Sparkles className="h-10 w-10 text-muted-foreground" />
|
|
192
|
+
<div className="text-center space-y-1">
|
|
193
|
+
<p className="font-medium">No AI analysis yet</p>
|
|
194
|
+
<p className="text-sm text-muted-foreground">
|
|
195
|
+
Classify user messages by type, role, skill level, and sentiment
|
|
196
|
+
</p>
|
|
197
|
+
</div>
|
|
198
|
+
{error && (
|
|
199
|
+
<div className="flex items-center gap-2 text-sm text-destructive">
|
|
200
|
+
<AlertCircle className="h-4 w-4" /> {error}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
<Button onClick={handleAnalyzeClick} disabled={analyzing}>
|
|
204
|
+
{analyzing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
|
205
|
+
<Sparkles className="mr-1 h-3 w-3" />
|
|
206
|
+
Analyze with AI
|
|
207
|
+
</Button>
|
|
208
|
+
<AnalyzeConfirmDialog
|
|
209
|
+
open={confirmOpen}
|
|
210
|
+
onOpenChange={setConfirmOpen}
|
|
211
|
+
onConfirm={handleConfirm}
|
|
212
|
+
loading={analyzing}
|
|
213
|
+
estimate={estimate}
|
|
214
|
+
/>
|
|
215
|
+
</CardContent>
|
|
216
|
+
</Card>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Build distributions from classifications
|
|
221
|
+
const messageTypes: Record<string, number> = {}
|
|
222
|
+
const roles: Record<string, number> = {}
|
|
223
|
+
const skillLevels: Record<string, number> = {}
|
|
224
|
+
const sentiments: Record<string, number> = {}
|
|
225
|
+
|
|
226
|
+
for (const cls of analysis.classifications) {
|
|
227
|
+
messageTypes[cls.messageType] = (messageTypes[cls.messageType] || 0) + 1
|
|
228
|
+
roles[cls.role] = (roles[cls.role] || 0) + 1
|
|
229
|
+
skillLevels[cls.skillLevel] = (skillLevels[cls.skillLevel] || 0) + 1
|
|
230
|
+
sentiments[cls.sentiment] = (sentiments[cls.sentiment] || 0) + 1
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div className="space-y-4">
|
|
235
|
+
<div className="flex items-center justify-between">
|
|
236
|
+
<div className="flex items-center gap-2">
|
|
237
|
+
<Badge variant="outline" className="text-xs">
|
|
238
|
+
{analysis.totalMessages} messages classified
|
|
239
|
+
</Badge>
|
|
240
|
+
<Badge variant="outline" className="text-xs">
|
|
241
|
+
{analysis.model}
|
|
242
|
+
</Badge>
|
|
243
|
+
<Badge variant="outline" className="text-xs">
|
|
244
|
+
Cost: {formatCost(analysis.costUSD)}
|
|
245
|
+
</Badge>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
250
|
+
<DistributionChart
|
|
251
|
+
title="Message Type"
|
|
252
|
+
description="What kind of messages you sent"
|
|
253
|
+
data={messageTypes}
|
|
254
|
+
/>
|
|
255
|
+
<DistributionChart
|
|
256
|
+
title="Role"
|
|
257
|
+
description="Professional role implied by each message"
|
|
258
|
+
data={roles}
|
|
259
|
+
/>
|
|
260
|
+
<DistributionChart
|
|
261
|
+
title="Skill Level"
|
|
262
|
+
description="Technical skill level of each message"
|
|
263
|
+
data={skillLevels}
|
|
264
|
+
/>
|
|
265
|
+
<DistributionChart
|
|
266
|
+
title="Sentiment"
|
|
267
|
+
description="Emotional tone of each message"
|
|
268
|
+
data={sentiments}
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<Card>
|
|
273
|
+
<CardHeader className="pb-2">
|
|
274
|
+
<CardTitle className="text-sm">Message Classifications</CardTitle>
|
|
275
|
+
<CardDescription>Per-message breakdown</CardDescription>
|
|
276
|
+
</CardHeader>
|
|
277
|
+
<CardContent>
|
|
278
|
+
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
279
|
+
{analysis.classifications.map((cls, i) => (
|
|
280
|
+
<div
|
|
281
|
+
key={i}
|
|
282
|
+
className="flex items-start gap-2 rounded-md border p-2 text-xs"
|
|
283
|
+
>
|
|
284
|
+
<span className="shrink-0 text-muted-foreground w-6 text-right">
|
|
285
|
+
{i + 1}.
|
|
286
|
+
</span>
|
|
287
|
+
<span className="flex-1 line-clamp-1">{cls.messagePreview}</span>
|
|
288
|
+
<div className="flex gap-1 shrink-0">
|
|
289
|
+
<Badge variant="secondary" className="text-[10px] px-1.5">
|
|
290
|
+
{cls.messageType}
|
|
291
|
+
</Badge>
|
|
292
|
+
<Badge variant="outline" className="text-[10px] px-1.5">
|
|
293
|
+
{cls.role}
|
|
294
|
+
</Badge>
|
|
295
|
+
<Badge variant="outline" className="text-[10px] px-1.5">
|
|
296
|
+
{cls.skillLevel}
|
|
297
|
+
</Badge>
|
|
298
|
+
<Badge
|
|
299
|
+
variant="outline"
|
|
300
|
+
className={`text-[10px] px-1.5 ${
|
|
301
|
+
cls.sentiment === 'frustrated'
|
|
302
|
+
? 'border-destructive text-destructive'
|
|
303
|
+
: cls.sentiment === 'positive'
|
|
304
|
+
? 'border-green-600 text-green-600'
|
|
305
|
+
: ''
|
|
306
|
+
}`}
|
|
307
|
+
>
|
|
308
|
+
{cls.sentiment}
|
|
309
|
+
</Badge>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
</CardContent>
|
|
315
|
+
</Card>
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
}
|