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
|
@@ -37,6 +37,10 @@ jobs:
|
|
|
37
37
|
rm certificate.p12
|
|
38
38
|
|
|
39
39
|
- name: Build macOS DMGs
|
|
40
|
+
env:
|
|
41
|
+
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
42
|
+
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
|
43
|
+
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
40
44
|
run: npm run electron:build:mac
|
|
41
45
|
|
|
42
46
|
- name: Upload DMGs
|
package/README.md
CHANGED
|
@@ -31,7 +31,6 @@ npx agentfit
|
|
|
31
31
|
git clone https://github.com/harrywang/agentfit.git
|
|
32
32
|
cd agentfit
|
|
33
33
|
npm install
|
|
34
|
-
echo 'DATABASE_URL="file:./agentfit.db"' > .env
|
|
35
34
|
npx prisma migrate deploy
|
|
36
35
|
npm run build
|
|
37
36
|
npm start
|
|
@@ -101,7 +100,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. Quick version:
|
|
|
101
100
|
| Variable | Default | Description |
|
|
102
101
|
|----------|---------|-------------|
|
|
103
102
|
| `PORT` or `AGENTFIT_PORT` | `3000` | Server port |
|
|
104
|
-
| `DATABASE_URL` | `file:./agentfit.db` | SQLite database path |
|
|
105
103
|
|
|
106
104
|
## Credits
|
|
107
105
|
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
5
|
+
import { Badge } from '@/components/ui/badge'
|
|
6
|
+
import {
|
|
7
|
+
ChartContainer,
|
|
8
|
+
ChartTooltip,
|
|
9
|
+
ChartTooltipContent,
|
|
10
|
+
type ChartConfig,
|
|
11
|
+
} from '@/components/ui/chart'
|
|
12
|
+
import { Bar, BarChart, XAxis, YAxis, Cell } from 'recharts'
|
|
13
|
+
import { Sparkles, Loader2 } from 'lucide-react'
|
|
14
|
+
import { formatCost } from '@/lib/format'
|
|
15
|
+
import Link from 'next/link'
|
|
16
|
+
|
|
17
|
+
const CHART_COLORS = [
|
|
18
|
+
'var(--chart-1)',
|
|
19
|
+
'var(--chart-2)',
|
|
20
|
+
'var(--chart-3)',
|
|
21
|
+
'var(--chart-4)',
|
|
22
|
+
'var(--chart-5)',
|
|
23
|
+
'var(--chart-6)',
|
|
24
|
+
'var(--chart-7)',
|
|
25
|
+
'var(--chart-8)',
|
|
26
|
+
'var(--chart-9)',
|
|
27
|
+
'var(--chart-10)',
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
interface AggregateData {
|
|
31
|
+
totalMessages: number
|
|
32
|
+
messageTypes: Record<string, number>
|
|
33
|
+
roles: Record<string, number>
|
|
34
|
+
skillLevels: Record<string, number>
|
|
35
|
+
sentiments: Record<string, number>
|
|
36
|
+
roleByType: Record<string, Record<string, number>>
|
|
37
|
+
roleBySentiment: Record<string, Record<string, number>>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function DistributionChart({
|
|
41
|
+
title,
|
|
42
|
+
description,
|
|
43
|
+
data,
|
|
44
|
+
}: {
|
|
45
|
+
title: string
|
|
46
|
+
description: string
|
|
47
|
+
data: Record<string, number>
|
|
48
|
+
}) {
|
|
49
|
+
const chartData = Object.entries(data)
|
|
50
|
+
.sort((a, b) => b[1] - a[1])
|
|
51
|
+
.map(([name, count]) => ({ name, count }))
|
|
52
|
+
|
|
53
|
+
const config: ChartConfig = {
|
|
54
|
+
count: { label: 'Count', color: 'var(--chart-1)' },
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const total = chartData.reduce((sum, d) => sum + d.count, 0)
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Card>
|
|
61
|
+
<CardHeader className="pb-2">
|
|
62
|
+
<CardTitle className="text-sm">{title}</CardTitle>
|
|
63
|
+
<CardDescription>{description}</CardDescription>
|
|
64
|
+
</CardHeader>
|
|
65
|
+
<CardContent>
|
|
66
|
+
<ChartContainer
|
|
67
|
+
config={config}
|
|
68
|
+
className="w-full"
|
|
69
|
+
style={{ minHeight: Math.max(150, chartData.length * 32) }}
|
|
70
|
+
>
|
|
71
|
+
<BarChart data={chartData} layout="vertical" margin={{ left: 8 }} accessibilityLayer>
|
|
72
|
+
<XAxis type="number" tickLine={false} axisLine={false} />
|
|
73
|
+
<YAxis
|
|
74
|
+
type="category"
|
|
75
|
+
dataKey="name"
|
|
76
|
+
tickLine={false}
|
|
77
|
+
axisLine={false}
|
|
78
|
+
width={120}
|
|
79
|
+
tick={{ fontSize: 12 }}
|
|
80
|
+
/>
|
|
81
|
+
<ChartTooltip
|
|
82
|
+
content={
|
|
83
|
+
<ChartTooltipContent
|
|
84
|
+
formatter={(value) => {
|
|
85
|
+
const n = Number(value)
|
|
86
|
+
return `${n} (${((n / total) * 100).toFixed(1)}%)`
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
}
|
|
90
|
+
/>
|
|
91
|
+
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
|
92
|
+
{chartData.map((_, i) => (
|
|
93
|
+
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
|
94
|
+
))}
|
|
95
|
+
</Bar>
|
|
96
|
+
</BarChart>
|
|
97
|
+
</ChartContainer>
|
|
98
|
+
</CardContent>
|
|
99
|
+
</Card>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function CrossTab({
|
|
104
|
+
title,
|
|
105
|
+
description,
|
|
106
|
+
data,
|
|
107
|
+
}: {
|
|
108
|
+
title: string
|
|
109
|
+
description: string
|
|
110
|
+
data: Record<string, Record<string, number>>
|
|
111
|
+
}) {
|
|
112
|
+
const rows = Object.keys(data).sort()
|
|
113
|
+
const colSet = new Set<string>()
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
for (const col of Object.keys(data[row])) colSet.add(col)
|
|
116
|
+
}
|
|
117
|
+
const cols = [...colSet].sort()
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Card>
|
|
121
|
+
<CardHeader className="pb-2">
|
|
122
|
+
<CardTitle className="text-sm">{title}</CardTitle>
|
|
123
|
+
<CardDescription>{description}</CardDescription>
|
|
124
|
+
</CardHeader>
|
|
125
|
+
<CardContent>
|
|
126
|
+
<div className="overflow-x-auto">
|
|
127
|
+
<table className="w-full text-xs">
|
|
128
|
+
<thead>
|
|
129
|
+
<tr className="border-b">
|
|
130
|
+
<th className="text-left py-1.5 pr-3 font-medium text-muted-foreground" />
|
|
131
|
+
{cols.map((col) => (
|
|
132
|
+
<th
|
|
133
|
+
key={col}
|
|
134
|
+
className="text-center py-1.5 px-2 font-medium text-muted-foreground"
|
|
135
|
+
>
|
|
136
|
+
{col}
|
|
137
|
+
</th>
|
|
138
|
+
))}
|
|
139
|
+
</tr>
|
|
140
|
+
</thead>
|
|
141
|
+
<tbody>
|
|
142
|
+
{rows.map((row) => (
|
|
143
|
+
<tr key={row} className="border-b last:border-0">
|
|
144
|
+
<td className="py-1.5 pr-3 font-medium">{row}</td>
|
|
145
|
+
{cols.map((col) => (
|
|
146
|
+
<td key={col} className="text-center py-1.5 px-2 text-muted-foreground">
|
|
147
|
+
{data[row][col] || ''}
|
|
148
|
+
</td>
|
|
149
|
+
))}
|
|
150
|
+
</tr>
|
|
151
|
+
))}
|
|
152
|
+
</tbody>
|
|
153
|
+
</table>
|
|
154
|
+
</div>
|
|
155
|
+
</CardContent>
|
|
156
|
+
</Card>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export default function AIInsightsPage() {
|
|
161
|
+
const [aggregate, setAggregate] = useState<AggregateData | null>(null)
|
|
162
|
+
const [analyzedCount, setAnalyzedCount] = useState(0)
|
|
163
|
+
const [totalCost, setTotalCost] = useState(0)
|
|
164
|
+
const [loading, setLoading] = useState(true)
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
async function load() {
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetch('/api/analyze/aggregate')
|
|
170
|
+
const data = await res.json()
|
|
171
|
+
setAggregate(data.aggregate)
|
|
172
|
+
setAnalyzedCount(data.analyzedCount || 0)
|
|
173
|
+
setTotalCost(data.totalCostUSD || 0)
|
|
174
|
+
} catch {
|
|
175
|
+
// ignore
|
|
176
|
+
} finally {
|
|
177
|
+
setLoading(false)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
load()
|
|
181
|
+
}, [])
|
|
182
|
+
|
|
183
|
+
if (loading) {
|
|
184
|
+
return (
|
|
185
|
+
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
|
186
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading...
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!aggregate) {
|
|
192
|
+
return (
|
|
193
|
+
<Card>
|
|
194
|
+
<CardContent className="flex flex-col items-center justify-center py-16 space-y-4">
|
|
195
|
+
<Sparkles className="h-12 w-12 text-muted-foreground" />
|
|
196
|
+
<div className="text-center space-y-1">
|
|
197
|
+
<p className="text-lg font-medium">No AI analysis data yet</p>
|
|
198
|
+
<p className="text-sm text-muted-foreground">
|
|
199
|
+
Go to{' '}
|
|
200
|
+
<Link href="/sessions" className="text-primary hover:underline">
|
|
201
|
+
Sessions
|
|
202
|
+
</Link>{' '}
|
|
203
|
+
and analyze individual sessions, or use the "Analyze All" button.
|
|
204
|
+
</p>
|
|
205
|
+
<p className="text-sm text-muted-foreground">
|
|
206
|
+
Make sure to add your OpenAI API key in{' '}
|
|
207
|
+
<Link href="/settings" className="text-primary hover:underline">
|
|
208
|
+
Settings
|
|
209
|
+
</Link>{' '}
|
|
210
|
+
first.
|
|
211
|
+
</p>
|
|
212
|
+
</div>
|
|
213
|
+
</CardContent>
|
|
214
|
+
</Card>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div className="space-y-6">
|
|
220
|
+
<div className="flex items-center gap-3">
|
|
221
|
+
<div className="flex items-center gap-2">
|
|
222
|
+
<Badge variant="outline">
|
|
223
|
+
{analyzedCount} sessions analyzed
|
|
224
|
+
</Badge>
|
|
225
|
+
<Badge variant="outline">
|
|
226
|
+
{aggregate.totalMessages} messages classified
|
|
227
|
+
</Badge>
|
|
228
|
+
<Badge variant="outline">
|
|
229
|
+
Total cost: {formatCost(totalCost)}
|
|
230
|
+
</Badge>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
235
|
+
<DistributionChart
|
|
236
|
+
title="Message Type Distribution"
|
|
237
|
+
description="How you communicate with AI agents"
|
|
238
|
+
data={aggregate.messageTypes}
|
|
239
|
+
/>
|
|
240
|
+
<DistributionChart
|
|
241
|
+
title="Role Distribution"
|
|
242
|
+
description="Professional roles you play during sessions"
|
|
243
|
+
data={aggregate.roles}
|
|
244
|
+
/>
|
|
245
|
+
<DistributionChart
|
|
246
|
+
title="Skill Level Distribution"
|
|
247
|
+
description="Technical depth of your messages"
|
|
248
|
+
data={aggregate.skillLevels}
|
|
249
|
+
/>
|
|
250
|
+
<DistributionChart
|
|
251
|
+
title="Sentiment Distribution"
|
|
252
|
+
description="Emotional tone across all messages"
|
|
253
|
+
data={aggregate.sentiments}
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
258
|
+
<CrossTab
|
|
259
|
+
title="Role x Message Type"
|
|
260
|
+
description="What types of messages each role produces"
|
|
261
|
+
data={aggregate.roleByType}
|
|
262
|
+
/>
|
|
263
|
+
<CrossTab
|
|
264
|
+
title="Role x Sentiment"
|
|
265
|
+
description="Sentiment patterns by role"
|
|
266
|
+
data={aggregate.roleBySentiment}
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useData } from '@/components/data-provider'
|
|
4
|
+
import { ModelDistributionChart, ModelUsageOverTimeChart } from '@/components/model-usage-chart'
|
|
5
|
+
import { VersionLagChart } from '@/components/version-lag-chart'
|
|
6
|
+
|
|
7
|
+
export default function ModelsPage() {
|
|
8
|
+
const { data } = useData()
|
|
9
|
+
if (!data) return null
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<h1 className="text-2xl font-bold">Model Usage</h1>
|
|
14
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
15
|
+
<ModelDistributionChart sessions={data.sessions} />
|
|
16
|
+
<VersionLagChart sessions={data.sessions} />
|
|
17
|
+
</div>
|
|
18
|
+
<ModelUsageOverTimeChart sessions={data.sessions} />
|
|
19
|
+
</>
|
|
20
|
+
)
|
|
21
|
+
}
|
package/app/(dashboard)/page.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useData } from '@/components/data-provider'
|
|
|
4
4
|
import { FitnessScore } from '@/components/fitness-score'
|
|
5
5
|
import { OverviewCards } from '@/components/overview-cards'
|
|
6
6
|
import { DailyCostChart, TopCommandsChart, TokenUsageHeatmap, UserVsAssistantChart, InterruptionRateChart, ToolMixChart } from '@/components/daily-chart'
|
|
7
|
+
import { VersionLagChart } from '@/components/version-lag-chart'
|
|
7
8
|
import { RefreshCw } from 'lucide-react'
|
|
8
9
|
|
|
9
10
|
export default function DashboardPage() {
|
|
@@ -36,6 +37,7 @@ export default function DashboardPage() {
|
|
|
36
37
|
<UserVsAssistantChart daily={data.daily} />
|
|
37
38
|
<InterruptionRateChart daily={data.daily} />
|
|
38
39
|
<ToolMixChart daily={data.daily} />
|
|
40
|
+
<VersionLagChart sessions={data.sessions} />
|
|
39
41
|
</div>
|
|
40
42
|
</>
|
|
41
43
|
)
|
|
@@ -4,9 +4,10 @@ import { useEffect, useState } from 'react'
|
|
|
4
4
|
import { useParams } from 'next/navigation'
|
|
5
5
|
import Link from 'next/link'
|
|
6
6
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
7
|
-
import { ArrowLeft, Clock, Coins, MessageSquare, Wrench, CheckCircle, XCircle } from 'lucide-react'
|
|
7
|
+
import { ArrowLeft, Clock, Coins, MessageSquare, Wrench, CheckCircle, XCircle, Sparkles } from 'lucide-react'
|
|
8
8
|
import { SessionWorkflow } from '@/components/session-workflow'
|
|
9
9
|
import { SessionChatLog } from '@/components/session-chatlog'
|
|
10
|
+
import { SessionAIAnalysis } from '@/components/session-ai-analysis'
|
|
10
11
|
import type { SessionDetail } from '@/lib/session-detail'
|
|
11
12
|
|
|
12
13
|
export default function SessionDetailPage() {
|
|
@@ -15,7 +16,7 @@ export default function SessionDetailPage() {
|
|
|
15
16
|
const [detail, setDetail] = useState<SessionDetail | null>(null)
|
|
16
17
|
const [loading, setLoading] = useState(true)
|
|
17
18
|
const [error, setError] = useState<string | null>(null)
|
|
18
|
-
const [activeTab, setActiveTab] = useState<'workflow' | '
|
|
19
|
+
const [activeTab, setActiveTab] = useState<'dialog' | 'workflow' | 'ai'>('dialog')
|
|
19
20
|
|
|
20
21
|
useEffect(() => {
|
|
21
22
|
async function load() {
|
|
@@ -135,6 +136,17 @@ export default function SessionDetailPage() {
|
|
|
135
136
|
>
|
|
136
137
|
Workflow
|
|
137
138
|
</button>
|
|
139
|
+
<button
|
|
140
|
+
onClick={() => setActiveTab('ai')}
|
|
141
|
+
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors flex items-center gap-1 ${
|
|
142
|
+
activeTab === 'ai'
|
|
143
|
+
? 'bg-primary text-primary-foreground'
|
|
144
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
145
|
+
}`}
|
|
146
|
+
>
|
|
147
|
+
<Sparkles className="h-3 w-3" />
|
|
148
|
+
AI Analysis
|
|
149
|
+
</button>
|
|
138
150
|
</div>
|
|
139
151
|
|
|
140
152
|
{activeTab === 'dialog' && (
|
|
@@ -162,6 +174,8 @@ export default function SessionDetailPage() {
|
|
|
162
174
|
</CardContent>
|
|
163
175
|
</Card>
|
|
164
176
|
)}
|
|
177
|
+
|
|
178
|
+
{activeTab === 'ai' && <SessionAIAnalysis sessionId={sessionId} />}
|
|
165
179
|
</div>
|
|
166
180
|
)
|
|
167
181
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { Input } from '@/components/ui/input'
|
|
7
|
+
import { Badge } from '@/components/ui/badge'
|
|
8
|
+
import { Key, Eye, EyeOff, CheckCircle, XCircle, Loader2 } from 'lucide-react'
|
|
9
|
+
|
|
10
|
+
export default function SettingsPage() {
|
|
11
|
+
const [apiKey, setApiKey] = useState('')
|
|
12
|
+
const [maskedKey, setMaskedKey] = useState<string | null>(null)
|
|
13
|
+
const [saved, setSaved] = useState(false)
|
|
14
|
+
const [showKey, setShowKey] = useState(false)
|
|
15
|
+
const [testing, setTesting] = useState(false)
|
|
16
|
+
const [testResult, setTestResult] = useState<'valid' | 'invalid' | null>(null)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
fetch('/api/config')
|
|
20
|
+
.then((r) => r.json())
|
|
21
|
+
.then((data) => {
|
|
22
|
+
if (data.hasOpenAIKey) {
|
|
23
|
+
setMaskedKey(data.maskedKey)
|
|
24
|
+
setSaved(true)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
.catch(() => {})
|
|
28
|
+
}, [])
|
|
29
|
+
|
|
30
|
+
async function handleSave() {
|
|
31
|
+
if (!apiKey.trim()) return
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch('/api/config', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ apiKey: apiKey.trim() }),
|
|
37
|
+
})
|
|
38
|
+
if (res.ok) {
|
|
39
|
+
setSaved(true)
|
|
40
|
+
setMaskedKey(`${apiKey.trim().slice(0, 7)}...${apiKey.trim().slice(-4)}`)
|
|
41
|
+
setTestResult(null)
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function handleClear() {
|
|
49
|
+
try {
|
|
50
|
+
await fetch('/api/config', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ action: 'clear' }),
|
|
54
|
+
})
|
|
55
|
+
setApiKey('')
|
|
56
|
+
setMaskedKey(null)
|
|
57
|
+
setSaved(false)
|
|
58
|
+
setTestResult(null)
|
|
59
|
+
} catch {
|
|
60
|
+
// ignore
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function handleTest() {
|
|
65
|
+
// Save first if not saved
|
|
66
|
+
if (!saved && apiKey.trim()) await handleSave()
|
|
67
|
+
|
|
68
|
+
setTesting(true)
|
|
69
|
+
setTestResult(null)
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch('/api/analyze', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ test: true }),
|
|
75
|
+
})
|
|
76
|
+
// 401 = no key or invalid key, 400 = key works (missing sessionId is expected)
|
|
77
|
+
setTestResult(res.status === 401 ? 'invalid' : 'valid')
|
|
78
|
+
} catch {
|
|
79
|
+
setTestResult('invalid')
|
|
80
|
+
} finally {
|
|
81
|
+
setTesting(false)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="space-y-6 max-w-2xl">
|
|
87
|
+
<Card>
|
|
88
|
+
<CardHeader>
|
|
89
|
+
<div className="flex items-center gap-2">
|
|
90
|
+
<Key className="h-5 w-5 text-muted-foreground" />
|
|
91
|
+
<div>
|
|
92
|
+
<CardTitle>AI Analysis</CardTitle>
|
|
93
|
+
<CardDescription>
|
|
94
|
+
Configure OpenAI API access for AI-powered message classification
|
|
95
|
+
</CardDescription>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</CardHeader>
|
|
99
|
+
<CardContent className="space-y-4">
|
|
100
|
+
<div className="space-y-2">
|
|
101
|
+
<label className="text-sm font-medium">OpenAI API Key</label>
|
|
102
|
+
{saved && !apiKey && maskedKey && (
|
|
103
|
+
<p className="text-sm text-muted-foreground">
|
|
104
|
+
Current key: <code className="text-xs">{maskedKey}</code>
|
|
105
|
+
</p>
|
|
106
|
+
)}
|
|
107
|
+
<div className="flex gap-2">
|
|
108
|
+
<div className="relative flex-1">
|
|
109
|
+
<Input
|
|
110
|
+
type={showKey ? 'text' : 'password'}
|
|
111
|
+
value={apiKey}
|
|
112
|
+
onChange={(e) => {
|
|
113
|
+
setApiKey(e.target.value)
|
|
114
|
+
setSaved(false)
|
|
115
|
+
setTestResult(null)
|
|
116
|
+
}}
|
|
117
|
+
placeholder={saved ? 'Enter new key to replace...' : 'sk-...'}
|
|
118
|
+
/>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={() => setShowKey(!showKey)}
|
|
122
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
123
|
+
>
|
|
124
|
+
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
<Button onClick={handleSave} disabled={!apiKey.trim() || saved} size="sm">
|
|
128
|
+
Save
|
|
129
|
+
</Button>
|
|
130
|
+
{(apiKey || maskedKey) && (
|
|
131
|
+
<Button onClick={handleClear} variant="outline" size="sm">
|
|
132
|
+
Clear
|
|
133
|
+
</Button>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="flex items-center gap-3">
|
|
139
|
+
<Button
|
|
140
|
+
onClick={handleTest}
|
|
141
|
+
variant="outline"
|
|
142
|
+
size="sm"
|
|
143
|
+
disabled={(!apiKey.trim() && !maskedKey) || testing}
|
|
144
|
+
>
|
|
145
|
+
{testing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
|
146
|
+
Test Connection
|
|
147
|
+
</Button>
|
|
148
|
+
{testResult === 'valid' && (
|
|
149
|
+
<Badge variant="outline" className="text-green-600 border-green-600">
|
|
150
|
+
<CheckCircle className="mr-1 h-3 w-3" /> Valid
|
|
151
|
+
</Badge>
|
|
152
|
+
)}
|
|
153
|
+
{testResult === 'invalid' && (
|
|
154
|
+
<Badge variant="outline" className="text-destructive border-destructive">
|
|
155
|
+
<XCircle className="mr-1 h-3 w-3" /> Invalid
|
|
156
|
+
</Badge>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<p className="text-xs text-muted-foreground">
|
|
161
|
+
Your API key is stored in <code>~/.agentfit/config.json</code> on your machine and never
|
|
162
|
+
leaves your local server. Used with gpt-4.1-mini (~$0.001 per 100 messages).
|
|
163
|
+
</p>
|
|
164
|
+
</CardContent>
|
|
165
|
+
</Card>
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { prisma } from '@/lib/db'
|
|
3
|
+
import type { MessageClassification } from '@/lib/openai'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
export async function GET(request: NextRequest) {
|
|
8
|
+
const project = request.nextUrl.searchParams.get('project')
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
// Get all analyses, optionally filtered by project
|
|
12
|
+
let sessionIds: string[] | undefined
|
|
13
|
+
|
|
14
|
+
if (project) {
|
|
15
|
+
const sessions = await prisma.session.findMany({
|
|
16
|
+
where: { project },
|
|
17
|
+
select: { sessionId: true },
|
|
18
|
+
})
|
|
19
|
+
sessionIds = sessions.map((s) => s.sessionId)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const analyses = await prisma.sessionAnalysis.findMany({
|
|
23
|
+
where: sessionIds ? { sessionId: { in: sessionIds } } : undefined,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (analyses.length === 0) {
|
|
27
|
+
return NextResponse.json({ aggregate: null, analyzedCount: 0 })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Aggregate all classifications
|
|
31
|
+
const allClassifications: MessageClassification[] = []
|
|
32
|
+
let totalCostUSD = 0
|
|
33
|
+
let totalInputTokens = 0
|
|
34
|
+
let totalOutputTokens = 0
|
|
35
|
+
|
|
36
|
+
for (const a of analyses) {
|
|
37
|
+
const cls = JSON.parse(a.classifications) as MessageClassification[]
|
|
38
|
+
allClassifications.push(...cls)
|
|
39
|
+
totalCostUSD += a.costUSD
|
|
40
|
+
totalInputTokens += a.inputTokens
|
|
41
|
+
totalOutputTokens += a.outputTokens
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build distributions
|
|
45
|
+
const messageTypes: Record<string, number> = {}
|
|
46
|
+
const roles: Record<string, number> = {}
|
|
47
|
+
const skillLevels: Record<string, number> = {}
|
|
48
|
+
const sentiments: Record<string, number> = {}
|
|
49
|
+
|
|
50
|
+
for (const cls of allClassifications) {
|
|
51
|
+
messageTypes[cls.messageType] = (messageTypes[cls.messageType] || 0) + 1
|
|
52
|
+
roles[cls.role] = (roles[cls.role] || 0) + 1
|
|
53
|
+
skillLevels[cls.skillLevel] = (skillLevels[cls.skillLevel] || 0) + 1
|
|
54
|
+
sentiments[cls.sentiment] = (sentiments[cls.sentiment] || 0) + 1
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Cross-tabulations
|
|
58
|
+
const roleByType: Record<string, Record<string, number>> = {}
|
|
59
|
+
const roleBySentiment: Record<string, Record<string, number>> = {}
|
|
60
|
+
|
|
61
|
+
for (const cls of allClassifications) {
|
|
62
|
+
if (!roleByType[cls.role]) roleByType[cls.role] = {}
|
|
63
|
+
roleByType[cls.role][cls.messageType] = (roleByType[cls.role][cls.messageType] || 0) + 1
|
|
64
|
+
|
|
65
|
+
if (!roleBySentiment[cls.role]) roleBySentiment[cls.role] = {}
|
|
66
|
+
roleBySentiment[cls.role][cls.sentiment] =
|
|
67
|
+
(roleBySentiment[cls.role][cls.sentiment] || 0) + 1
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return NextResponse.json({
|
|
71
|
+
aggregate: {
|
|
72
|
+
totalMessages: allClassifications.length,
|
|
73
|
+
messageTypes,
|
|
74
|
+
roles,
|
|
75
|
+
skillLevels,
|
|
76
|
+
sentiments,
|
|
77
|
+
roleByType,
|
|
78
|
+
roleBySentiment,
|
|
79
|
+
},
|
|
80
|
+
analyzedCount: analyses.length,
|
|
81
|
+
totalCostUSD,
|
|
82
|
+
totalInputTokens,
|
|
83
|
+
totalOutputTokens,
|
|
84
|
+
})
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return NextResponse.json({ error: (error as Error).message }, { status: 500 })
|
|
87
|
+
}
|
|
88
|
+
}
|