agentfit 0.1.0 → 0.1.2
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 +111 -0
- package/README.md +41 -38
- package/app/(dashboard)/daily/page.tsx +1 -1
- package/app/(dashboard)/data-management/page.tsx +180 -0
- package/app/(dashboard)/flow/page.tsx +17 -0
- package/app/(dashboard)/layout.tsx +2 -0
- package/app/(dashboard)/page.tsx +24 -5
- package/app/(dashboard)/reports/[id]/page.tsx +72 -0
- package/app/(dashboard)/reports/page.tsx +132 -0
- package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
- package/app/api/backup/route.ts +215 -0
- package/app/api/check/route.ts +11 -1
- package/app/api/command-insights/route.ts +13 -0
- package/app/api/commands/route.ts +55 -1
- package/app/api/images-analysis/route.ts +3 -4
- package/app/api/reports/[id]/route.ts +23 -0
- package/app/api/reports/route.ts +50 -0
- package/app/api/reset/route.ts +21 -0
- package/app/api/session/route.ts +40 -0
- package/app/api/usage/route.ts +26 -1
- package/app/layout.tsx +1 -1
- package/bin/agentfit.mjs +2 -2
- package/components/agent-coach.tsx +256 -129
- package/components/app-sidebar.tsx +45 -10
- package/components/backup-section.tsx +236 -0
- package/components/daily-chart.tsx +447 -83
- package/components/dashboard-shell.tsx +29 -31
- package/components/data-provider.tsx +88 -8
- package/components/fitness-score.tsx +95 -54
- package/components/overview-cards.tsx +148 -41
- package/components/report-view.tsx +307 -0
- package/components/screenshots-analysis.tsx +51 -46
- package/components/session-chatlog.tsx +124 -0
- package/components/session-timeline.tsx +184 -0
- package/components/session-workflow.tsx +183 -0
- package/components/sessions-table.tsx +9 -1
- package/components/tool-flow-graph.tsx +144 -0
- package/components/ui/carousel.tsx +242 -0
- package/components/ui/sidebar.tsx +1 -1
- package/components/ui/sonner.tsx +51 -0
- package/electron/entitlements.mac.plist +16 -0
- package/electron/init-db.mjs +37 -0
- package/electron/main.mjs +203 -0
- 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 +97 -2
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +21 -1
- package/generated/prisma/models/Report.ts +1219 -0
- package/generated/prisma/models/Session.ts +221 -1
- package/generated/prisma/models.ts +1 -0
- package/lib/coach.ts +571 -211
- package/lib/command-insights.ts +231 -0
- package/lib/db.ts +2 -2
- package/lib/parse-codex.ts +6 -0
- package/lib/parse-logs.ts +80 -1
- package/lib/queries-codex.ts +24 -0
- package/lib/queries.ts +45 -0
- package/lib/report.ts +156 -0
- package/lib/session-detail.ts +382 -0
- package/lib/sync.ts +87 -0
- package/lib/tool-flow.ts +71 -0
- package/next.config.mjs +6 -1
- package/package.json +17 -2
- package/plugins/cost-heatmap/component.tsx +72 -50
- package/prisma/migrations/20260401144555_add_system_prompt_edits/migration.sql +80 -0
- package/prisma/schema.prisma +18 -0
- package/prisma/schema.sql +81 -0
- package/.claude/settings.local.json +0 -26
- package/CONTRIBUTING.md +0 -209
- package/prisma/migrations/20260328152517_init/migration.sql +0 -41
- package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
- package/prisma.config.ts +0 -14
- package/setup.sh +0 -73
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
ReactFlow,
|
|
6
|
+
Background,
|
|
7
|
+
Controls,
|
|
8
|
+
type Node,
|
|
9
|
+
type Edge,
|
|
10
|
+
Position,
|
|
11
|
+
MarkerType,
|
|
12
|
+
} from '@xyflow/react'
|
|
13
|
+
import '@xyflow/react/dist/style.css'
|
|
14
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
15
|
+
import type { UsageData } from '@/lib/parse-logs'
|
|
16
|
+
import { computeToolFlow } from '@/lib/tool-flow'
|
|
17
|
+
import { formatNumber } from '@/lib/format'
|
|
18
|
+
|
|
19
|
+
const TOOL_COLORS: Record<string, string> = {
|
|
20
|
+
Read: '#6b9bd2',
|
|
21
|
+
Edit: '#7bc8a4',
|
|
22
|
+
Write: '#7bc8a4',
|
|
23
|
+
Bash: '#d4a574',
|
|
24
|
+
Grep: '#6b9bd2',
|
|
25
|
+
Glob: '#6b9bd2',
|
|
26
|
+
Agent: '#b8a9d4',
|
|
27
|
+
Skill: '#b8a9d4',
|
|
28
|
+
WebSearch: '#d4a5a5',
|
|
29
|
+
WebFetch: '#d4a5a5',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getToolColor(tool: string): string {
|
|
33
|
+
return TOOL_COLORS[tool] || '#94a3b8'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ToolFlowGraph({ data }: { data: UsageData }) {
|
|
37
|
+
const flow = useMemo(() => computeToolFlow(data), [data])
|
|
38
|
+
|
|
39
|
+
const { nodes, edges } = useMemo(() => {
|
|
40
|
+
if (flow.nodes.length === 0) return { nodes: [], edges: [] }
|
|
41
|
+
|
|
42
|
+
const maxCount = Math.max(...flow.nodes.map(n => n.count))
|
|
43
|
+
const centerX = 400
|
|
44
|
+
const centerY = 300
|
|
45
|
+
const radius = 220
|
|
46
|
+
|
|
47
|
+
const rfNodes: Node[] = flow.nodes.map((n, i) => {
|
|
48
|
+
const angle = (i / flow.nodes.length) * 2 * Math.PI - Math.PI / 2
|
|
49
|
+
const size = 40 + (n.count / maxCount) * 60
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
id: n.id,
|
|
53
|
+
position: {
|
|
54
|
+
x: centerX + radius * Math.cos(angle) - size / 2,
|
|
55
|
+
y: centerY + radius * Math.sin(angle) - size / 2,
|
|
56
|
+
},
|
|
57
|
+
data: {
|
|
58
|
+
label: (
|
|
59
|
+
<div className="flex flex-col items-center gap-0.5">
|
|
60
|
+
<span className="text-xs font-semibold">{n.id}</span>
|
|
61
|
+
<span className="text-[10px] text-muted-foreground">{formatNumber(n.count)}</span>
|
|
62
|
+
</div>
|
|
63
|
+
),
|
|
64
|
+
},
|
|
65
|
+
style: {
|
|
66
|
+
width: size,
|
|
67
|
+
height: size,
|
|
68
|
+
borderRadius: '50%',
|
|
69
|
+
display: 'flex',
|
|
70
|
+
alignItems: 'center',
|
|
71
|
+
justifyContent: 'center',
|
|
72
|
+
backgroundColor: getToolColor(n.id),
|
|
73
|
+
border: '2px solid rgba(255,255,255,0.2)',
|
|
74
|
+
color: '#fff',
|
|
75
|
+
fontSize: 11,
|
|
76
|
+
},
|
|
77
|
+
sourcePosition: Position.Right,
|
|
78
|
+
targetPosition: Position.Left,
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const maxEdgeCount = Math.max(...flow.edges.map(e => e.count), 1)
|
|
83
|
+
|
|
84
|
+
const rfEdges: Edge[] = flow.edges.map((e, i) => ({
|
|
85
|
+
id: `e-${i}`,
|
|
86
|
+
source: e.source,
|
|
87
|
+
target: e.target,
|
|
88
|
+
animated: e.count > maxEdgeCount * 0.5,
|
|
89
|
+
style: {
|
|
90
|
+
strokeWidth: 1 + (e.count / maxEdgeCount) * 4,
|
|
91
|
+
opacity: 0.3 + (e.count / maxEdgeCount) * 0.5,
|
|
92
|
+
stroke: getToolColor(e.source),
|
|
93
|
+
},
|
|
94
|
+
markerEnd: {
|
|
95
|
+
type: MarkerType.ArrowClosed,
|
|
96
|
+
width: 12,
|
|
97
|
+
height: 12,
|
|
98
|
+
},
|
|
99
|
+
label: e.count > maxEdgeCount * 0.2 ? String(e.count) : undefined,
|
|
100
|
+
labelStyle: { fontSize: 10, fill: '#94a3b8' },
|
|
101
|
+
}))
|
|
102
|
+
|
|
103
|
+
return { nodes: rfNodes, edges: rfEdges }
|
|
104
|
+
}, [flow])
|
|
105
|
+
|
|
106
|
+
if (flow.nodes.length === 0) {
|
|
107
|
+
return (
|
|
108
|
+
<Card>
|
|
109
|
+
<CardHeader>
|
|
110
|
+
<CardTitle>Tool Flow</CardTitle>
|
|
111
|
+
<CardDescription>Not enough tool data for visualization</CardDescription>
|
|
112
|
+
</CardHeader>
|
|
113
|
+
</Card>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<Card>
|
|
119
|
+
<CardHeader>
|
|
120
|
+
<CardTitle>Tool Flow Graph</CardTitle>
|
|
121
|
+
<CardDescription>
|
|
122
|
+
How tools connect — node size = usage frequency, edge thickness = co-occurrence strength.
|
|
123
|
+
Top {flow.nodes.length} tools with {flow.edges.length} connections.
|
|
124
|
+
</CardDescription>
|
|
125
|
+
</CardHeader>
|
|
126
|
+
<CardContent>
|
|
127
|
+
<div style={{ height: 600 }} className="rounded-lg border bg-background">
|
|
128
|
+
<ReactFlow
|
|
129
|
+
nodes={nodes}
|
|
130
|
+
edges={edges}
|
|
131
|
+
fitView
|
|
132
|
+
nodesDraggable
|
|
133
|
+
nodesConnectable={false}
|
|
134
|
+
minZoom={0.3}
|
|
135
|
+
maxZoom={2}
|
|
136
|
+
>
|
|
137
|
+
<Background gap={20} size={1} />
|
|
138
|
+
<Controls showInteractive={false} />
|
|
139
|
+
</ReactFlow>
|
|
140
|
+
</div>
|
|
141
|
+
</CardContent>
|
|
142
|
+
</Card>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import useEmblaCarousel, {
|
|
5
|
+
type UseEmblaCarouselType,
|
|
6
|
+
} from "embla-carousel-react"
|
|
7
|
+
|
|
8
|
+
import { cn } from "@/lib/utils"
|
|
9
|
+
import { Button } from "@/components/ui/button"
|
|
10
|
+
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"
|
|
11
|
+
|
|
12
|
+
type CarouselApi = UseEmblaCarouselType[1]
|
|
13
|
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
|
14
|
+
type CarouselOptions = UseCarouselParameters[0]
|
|
15
|
+
type CarouselPlugin = UseCarouselParameters[1]
|
|
16
|
+
|
|
17
|
+
type CarouselProps = {
|
|
18
|
+
opts?: CarouselOptions
|
|
19
|
+
plugins?: CarouselPlugin
|
|
20
|
+
orientation?: "horizontal" | "vertical"
|
|
21
|
+
setApi?: (api: CarouselApi) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type CarouselContextProps = {
|
|
25
|
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
|
26
|
+
api: ReturnType<typeof useEmblaCarousel>[1]
|
|
27
|
+
scrollPrev: () => void
|
|
28
|
+
scrollNext: () => void
|
|
29
|
+
canScrollPrev: boolean
|
|
30
|
+
canScrollNext: boolean
|
|
31
|
+
} & CarouselProps
|
|
32
|
+
|
|
33
|
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
|
34
|
+
|
|
35
|
+
function useCarousel() {
|
|
36
|
+
const context = React.useContext(CarouselContext)
|
|
37
|
+
|
|
38
|
+
if (!context) {
|
|
39
|
+
throw new Error("useCarousel must be used within a <Carousel />")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return context
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Carousel({
|
|
46
|
+
orientation = "horizontal",
|
|
47
|
+
opts,
|
|
48
|
+
setApi,
|
|
49
|
+
plugins,
|
|
50
|
+
className,
|
|
51
|
+
children,
|
|
52
|
+
...props
|
|
53
|
+
}: React.ComponentProps<"div"> & CarouselProps) {
|
|
54
|
+
const [carouselRef, api] = useEmblaCarousel(
|
|
55
|
+
{
|
|
56
|
+
...opts,
|
|
57
|
+
axis: orientation === "horizontal" ? "x" : "y",
|
|
58
|
+
},
|
|
59
|
+
plugins
|
|
60
|
+
)
|
|
61
|
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
|
62
|
+
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
|
63
|
+
|
|
64
|
+
const onSelect = React.useCallback((api: CarouselApi) => {
|
|
65
|
+
if (!api) return
|
|
66
|
+
setCanScrollPrev(api.canScrollPrev())
|
|
67
|
+
setCanScrollNext(api.canScrollNext())
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
const scrollPrev = React.useCallback(() => {
|
|
71
|
+
api?.scrollPrev()
|
|
72
|
+
}, [api])
|
|
73
|
+
|
|
74
|
+
const scrollNext = React.useCallback(() => {
|
|
75
|
+
api?.scrollNext()
|
|
76
|
+
}, [api])
|
|
77
|
+
|
|
78
|
+
const handleKeyDown = React.useCallback(
|
|
79
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
80
|
+
if (event.key === "ArrowLeft") {
|
|
81
|
+
event.preventDefault()
|
|
82
|
+
scrollPrev()
|
|
83
|
+
} else if (event.key === "ArrowRight") {
|
|
84
|
+
event.preventDefault()
|
|
85
|
+
scrollNext()
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
[scrollPrev, scrollNext]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
React.useEffect(() => {
|
|
92
|
+
if (!api || !setApi) return
|
|
93
|
+
setApi(api)
|
|
94
|
+
}, [api, setApi])
|
|
95
|
+
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
if (!api) return
|
|
98
|
+
onSelect(api)
|
|
99
|
+
api.on("reInit", onSelect)
|
|
100
|
+
api.on("select", onSelect)
|
|
101
|
+
|
|
102
|
+
return () => {
|
|
103
|
+
api?.off("select", onSelect)
|
|
104
|
+
}
|
|
105
|
+
}, [api, onSelect])
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<CarouselContext.Provider
|
|
109
|
+
value={{
|
|
110
|
+
carouselRef,
|
|
111
|
+
api: api,
|
|
112
|
+
opts,
|
|
113
|
+
orientation:
|
|
114
|
+
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
|
115
|
+
scrollPrev,
|
|
116
|
+
scrollNext,
|
|
117
|
+
canScrollPrev,
|
|
118
|
+
canScrollNext,
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<div
|
|
122
|
+
onKeyDownCapture={handleKeyDown}
|
|
123
|
+
className={cn("relative", className)}
|
|
124
|
+
role="region"
|
|
125
|
+
aria-roledescription="carousel"
|
|
126
|
+
data-slot="carousel"
|
|
127
|
+
{...props}
|
|
128
|
+
>
|
|
129
|
+
{children}
|
|
130
|
+
</div>
|
|
131
|
+
</CarouselContext.Provider>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
136
|
+
const { carouselRef, orientation } = useCarousel()
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div
|
|
140
|
+
ref={carouselRef}
|
|
141
|
+
className="overflow-hidden"
|
|
142
|
+
data-slot="carousel-content"
|
|
143
|
+
>
|
|
144
|
+
<div
|
|
145
|
+
className={cn(
|
|
146
|
+
"flex",
|
|
147
|
+
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
|
148
|
+
className
|
|
149
|
+
)}
|
|
150
|
+
{...props}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
|
157
|
+
const { orientation } = useCarousel()
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div
|
|
161
|
+
role="group"
|
|
162
|
+
aria-roledescription="slide"
|
|
163
|
+
data-slot="carousel-item"
|
|
164
|
+
className={cn(
|
|
165
|
+
"min-w-0 shrink-0 grow-0 basis-full",
|
|
166
|
+
orientation === "horizontal" ? "pl-4" : "pt-4",
|
|
167
|
+
className
|
|
168
|
+
)}
|
|
169
|
+
{...props}
|
|
170
|
+
/>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function CarouselPrevious({
|
|
175
|
+
className,
|
|
176
|
+
variant = "outline",
|
|
177
|
+
size = "icon-sm",
|
|
178
|
+
...props
|
|
179
|
+
}: React.ComponentProps<typeof Button>) {
|
|
180
|
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<Button
|
|
184
|
+
data-slot="carousel-previous"
|
|
185
|
+
variant={variant}
|
|
186
|
+
size={size}
|
|
187
|
+
className={cn(
|
|
188
|
+
"absolute touch-manipulation rounded-full",
|
|
189
|
+
orientation === "horizontal"
|
|
190
|
+
? "top-1/2 -left-12 -translate-y-1/2"
|
|
191
|
+
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
192
|
+
className
|
|
193
|
+
)}
|
|
194
|
+
disabled={!canScrollPrev}
|
|
195
|
+
onClick={scrollPrev}
|
|
196
|
+
{...props}
|
|
197
|
+
>
|
|
198
|
+
<IconChevronLeft />
|
|
199
|
+
<span className="sr-only">Previous slide</span>
|
|
200
|
+
</Button>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function CarouselNext({
|
|
205
|
+
className,
|
|
206
|
+
variant = "outline",
|
|
207
|
+
size = "icon-sm",
|
|
208
|
+
...props
|
|
209
|
+
}: React.ComponentProps<typeof Button>) {
|
|
210
|
+
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<Button
|
|
214
|
+
data-slot="carousel-next"
|
|
215
|
+
variant={variant}
|
|
216
|
+
size={size}
|
|
217
|
+
className={cn(
|
|
218
|
+
"absolute touch-manipulation rounded-full",
|
|
219
|
+
orientation === "horizontal"
|
|
220
|
+
? "top-1/2 -right-12 -translate-y-1/2"
|
|
221
|
+
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
222
|
+
className
|
|
223
|
+
)}
|
|
224
|
+
disabled={!canScrollNext}
|
|
225
|
+
onClick={scrollNext}
|
|
226
|
+
{...props}
|
|
227
|
+
>
|
|
228
|
+
<IconChevronRight />
|
|
229
|
+
<span className="sr-only">Next slide</span>
|
|
230
|
+
</Button>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export {
|
|
235
|
+
type CarouselApi,
|
|
236
|
+
Carousel,
|
|
237
|
+
CarouselContent,
|
|
238
|
+
CarouselItem,
|
|
239
|
+
CarouselPrevious,
|
|
240
|
+
CarouselNext,
|
|
241
|
+
useCarousel,
|
|
242
|
+
}
|
|
@@ -27,7 +27,7 @@ import { IconLayoutSidebar } from "@tabler/icons-react"
|
|
|
27
27
|
|
|
28
28
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
|
29
29
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
|
30
|
-
const SIDEBAR_WIDTH = "
|
|
30
|
+
const SIDEBAR_WIDTH = "14rem"
|
|
31
31
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
|
32
32
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
|
33
33
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useTheme } from "next-themes"
|
|
4
|
+
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
|
5
|
+
import { IconCircleCheck, IconInfoCircle, IconAlertTriangle, IconAlertOctagon, IconLoader } from "@tabler/icons-react"
|
|
6
|
+
|
|
7
|
+
const Toaster = ({ ...props }: ToasterProps) => {
|
|
8
|
+
const { theme = "system" } = useTheme()
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<Sonner
|
|
12
|
+
theme={theme as ToasterProps["theme"]}
|
|
13
|
+
className="toaster group"
|
|
14
|
+
position="top-center"
|
|
15
|
+
richColors
|
|
16
|
+
icons={{
|
|
17
|
+
success: (
|
|
18
|
+
<IconCircleCheck className="size-4" />
|
|
19
|
+
),
|
|
20
|
+
info: (
|
|
21
|
+
<IconInfoCircle className="size-4" />
|
|
22
|
+
),
|
|
23
|
+
warning: (
|
|
24
|
+
<IconAlertTriangle className="size-4" />
|
|
25
|
+
),
|
|
26
|
+
error: (
|
|
27
|
+
<IconAlertOctagon className="size-4" />
|
|
28
|
+
),
|
|
29
|
+
loading: (
|
|
30
|
+
<IconLoader className="size-4 animate-spin" />
|
|
31
|
+
),
|
|
32
|
+
}}
|
|
33
|
+
style={
|
|
34
|
+
{
|
|
35
|
+
"--normal-bg": "var(--popover)",
|
|
36
|
+
"--normal-text": "var(--popover-foreground)",
|
|
37
|
+
"--normal-border": "var(--border)",
|
|
38
|
+
"--border-radius": "var(--radius)",
|
|
39
|
+
} as React.CSSProperties
|
|
40
|
+
}
|
|
41
|
+
toastOptions={{
|
|
42
|
+
classNames: {
|
|
43
|
+
toast: "cn-toast",
|
|
44
|
+
},
|
|
45
|
+
}}
|
|
46
|
+
{...props}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { Toaster }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>com.apple.security.cs.allow-jit</key>
|
|
6
|
+
<true/>
|
|
7
|
+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
8
|
+
<true/>
|
|
9
|
+
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
|
10
|
+
<true/>
|
|
11
|
+
<key>com.apple.security.network.client</key>
|
|
12
|
+
<true/>
|
|
13
|
+
<key>com.apple.security.files.user-selected.read-write</key>
|
|
14
|
+
<true/>
|
|
15
|
+
</dict>
|
|
16
|
+
</plist>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database initialization script.
|
|
3
|
+
* Spawned by Electron main process with ELECTRON_RUN_AS_NODE=1.
|
|
4
|
+
* Reads schema.sql and executes it against the SQLite database.
|
|
5
|
+
* Uses @libsql/client which is already bundled — works on macOS, Linux, and Windows.
|
|
6
|
+
*
|
|
7
|
+
* Usage: node init-db.mjs <db-path> <schema-sql-path>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'fs'
|
|
11
|
+
import { createRequire } from 'module'
|
|
12
|
+
import path from 'path'
|
|
13
|
+
|
|
14
|
+
const [dbPath, schemaPath] = process.argv.slice(2)
|
|
15
|
+
|
|
16
|
+
// In packaged app, @libsql/client lives inside electron/server/node_modules
|
|
17
|
+
const serverDir = path.join(path.dirname(new URL(import.meta.url).pathname), 'server')
|
|
18
|
+
const require = createRequire(path.join(serverDir, 'package.json'))
|
|
19
|
+
const { createClient } = require('@libsql/client')
|
|
20
|
+
|
|
21
|
+
if (!dbPath || !schemaPath) {
|
|
22
|
+
console.error('Usage: node init-db.mjs <db-path> <schema-sql-path>')
|
|
23
|
+
process.exit(1)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const client = createClient({ url: `file:${dbPath}` })
|
|
27
|
+
const sql = readFileSync(schemaPath, 'utf-8')
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await client.executeMultiple(sql)
|
|
31
|
+
console.log('Database ready.')
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(`Database init error: ${err.message}`)
|
|
34
|
+
process.exit(1)
|
|
35
|
+
} finally {
|
|
36
|
+
client.close()
|
|
37
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { app, BrowserWindow, shell, dialog } from 'electron'
|
|
2
|
+
import { execSync, spawn } from 'child_process'
|
|
3
|
+
import { existsSync } from 'fs'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import http from 'http'
|
|
6
|
+
|
|
7
|
+
const isPacked = app.isPackaged
|
|
8
|
+
|
|
9
|
+
// In packed app, asarUnpacked content is at app.asar.unpacked
|
|
10
|
+
const SERVER_DIR = isPacked
|
|
11
|
+
? path.join(process.resourcesPath, 'app.asar.unpacked', 'electron', 'server')
|
|
12
|
+
: path.join(import.meta.dirname, 'server')
|
|
13
|
+
|
|
14
|
+
const PRISMA_DIR = isPacked
|
|
15
|
+
? path.join(process.resourcesPath, 'prisma')
|
|
16
|
+
: path.join(import.meta.dirname, '..', 'prisma')
|
|
17
|
+
|
|
18
|
+
const USER_DATA = app.getPath('userData')
|
|
19
|
+
const DB_PATH = path.join(USER_DATA, 'agentfit.db')
|
|
20
|
+
const PORT = 13749
|
|
21
|
+
|
|
22
|
+
let serverProcess = null
|
|
23
|
+
let mainWindow = null
|
|
24
|
+
|
|
25
|
+
function log(msg) {
|
|
26
|
+
try {
|
|
27
|
+
console.log(`[AgentFit] ${msg}`)
|
|
28
|
+
} catch {
|
|
29
|
+
// Ignore EPIPE — no terminal in packaged app
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Prevent uncaught EPIPE from crashing the app
|
|
34
|
+
process.on('uncaughtException', (err) => {
|
|
35
|
+
if (err.code === 'EPIPE') return
|
|
36
|
+
throw err
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function ensureDatabase() {
|
|
40
|
+
const schemaSQL = path.join(PRISMA_DIR, 'schema.sql')
|
|
41
|
+
const initScript = isPacked
|
|
42
|
+
? path.join(process.resourcesPath, 'app.asar.unpacked', 'electron', 'init-db.mjs')
|
|
43
|
+
: path.join(import.meta.dirname, 'init-db.mjs')
|
|
44
|
+
|
|
45
|
+
if (!existsSync(schemaSQL)) {
|
|
46
|
+
throw new Error(`schema.sql not found at ${schemaSQL}. Run "npm run prisma:schema-sql" first.`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
log(existsSync(DB_PATH) ? 'Checking database schema...' : 'Creating database...')
|
|
50
|
+
|
|
51
|
+
// Run init-db.mjs using Electron's bundled Node.js runtime.
|
|
52
|
+
// Uses @libsql/client (already bundled) — works on macOS, Linux, and Windows.
|
|
53
|
+
// schema.sql uses IF NOT EXISTS — safe to run on existing DBs.
|
|
54
|
+
try {
|
|
55
|
+
execSync(
|
|
56
|
+
`"${process.execPath}" "${initScript}" "${DB_PATH}" "${schemaSQL}"`,
|
|
57
|
+
{
|
|
58
|
+
stdio: 'pipe',
|
|
59
|
+
timeout: 15000,
|
|
60
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
log('Database ready.')
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const stderr = err.stderr?.toString() || err.message
|
|
66
|
+
log(`Database setup warning: ${stderr}`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function startServer() {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const serverJs = path.join(SERVER_DIR, 'server.js')
|
|
73
|
+
|
|
74
|
+
if (!existsSync(serverJs)) {
|
|
75
|
+
reject(new Error(`Server not found at ${serverJs}. Run "npm run electron:prepare" first.`))
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
log(`Starting server from ${serverJs}`)
|
|
80
|
+
|
|
81
|
+
// In packaged Electron, process.execPath is the Electron binary.
|
|
82
|
+
// We need to set ELECTRON_RUN_AS_NODE=1 so it acts as plain Node.js.
|
|
83
|
+
serverProcess = spawn(process.execPath, [serverJs], {
|
|
84
|
+
cwd: SERVER_DIR,
|
|
85
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
86
|
+
env: {
|
|
87
|
+
...process.env,
|
|
88
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
89
|
+
PORT: String(PORT),
|
|
90
|
+
HOSTNAME: '127.0.0.1',
|
|
91
|
+
DATABASE_URL: `file:${DB_PATH}`,
|
|
92
|
+
NODE_ENV: 'production',
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
serverProcess.stdout?.on('data', (d) => log(d.toString().trim()))
|
|
97
|
+
serverProcess.stderr?.on('data', (d) => log(d.toString().trim()))
|
|
98
|
+
serverProcess.on('error', (err) => {
|
|
99
|
+
log(`Server process error: ${err.message}`)
|
|
100
|
+
reject(err)
|
|
101
|
+
})
|
|
102
|
+
serverProcess.on('exit', (code) => {
|
|
103
|
+
if (code !== null && code !== 0) {
|
|
104
|
+
log(`Server exited with code ${code}`)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Poll until ready
|
|
109
|
+
let attempts = 0
|
|
110
|
+
const check = () => {
|
|
111
|
+
attempts++
|
|
112
|
+
http.get(`http://127.0.0.1:${PORT}`, () => resolve()).on('error', () => {
|
|
113
|
+
if (attempts >= 60) reject(new Error('Server failed to start within 30s'))
|
|
114
|
+
else setTimeout(check, 500)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
setTimeout(check, 1000)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function createWindow() {
|
|
122
|
+
mainWindow = new BrowserWindow({
|
|
123
|
+
width: 1400,
|
|
124
|
+
height: 900,
|
|
125
|
+
minWidth: 900,
|
|
126
|
+
minHeight: 600,
|
|
127
|
+
title: 'AgentFit',
|
|
128
|
+
webPreferences: {
|
|
129
|
+
nodeIntegration: false,
|
|
130
|
+
contextIsolation: true,
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
mainWindow.loadURL(`http://127.0.0.1:${PORT}`)
|
|
135
|
+
|
|
136
|
+
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
137
|
+
shell.openExternal(url)
|
|
138
|
+
return { action: 'deny' }
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
mainWindow.on('closed', () => {
|
|
142
|
+
mainWindow = null
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function showSplash() {
|
|
147
|
+
const splash = new BrowserWindow({
|
|
148
|
+
width: 400,
|
|
149
|
+
height: 300,
|
|
150
|
+
frame: false,
|
|
151
|
+
transparent: true,
|
|
152
|
+
resizable: false,
|
|
153
|
+
alwaysOnTop: true,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
splash.loadURL(`data:text/html,
|
|
157
|
+
<html>
|
|
158
|
+
<body style="display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:system-ui;background:rgba(0,0,0,0.85);color:white;border-radius:16px;-webkit-app-region:drag;">
|
|
159
|
+
<div style="text-align:center">
|
|
160
|
+
<h1 style="font-size:28px;margin-bottom:8px">AgentFit</h1>
|
|
161
|
+
<p style="opacity:0.7;font-size:14px">Starting server...</p>
|
|
162
|
+
</div>
|
|
163
|
+
</body>
|
|
164
|
+
</html>
|
|
165
|
+
`)
|
|
166
|
+
|
|
167
|
+
return splash
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
app.whenReady().then(async () => {
|
|
171
|
+
const splash = showSplash()
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
ensureDatabase()
|
|
175
|
+
await startServer()
|
|
176
|
+
splash.close()
|
|
177
|
+
createWindow()
|
|
178
|
+
} catch (err) {
|
|
179
|
+
splash.close()
|
|
180
|
+
log(`Startup error: ${err.message}`)
|
|
181
|
+
dialog.showErrorBox('AgentFit Startup Error', `Failed to start: ${err.message}`)
|
|
182
|
+
app.quit()
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
app.on('window-all-closed', () => {
|
|
187
|
+
if (serverProcess) {
|
|
188
|
+
serverProcess.kill('SIGTERM')
|
|
189
|
+
serverProcess = null
|
|
190
|
+
}
|
|
191
|
+
app.quit()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
app.on('before-quit', () => {
|
|
195
|
+
if (serverProcess) {
|
|
196
|
+
serverProcess.kill('SIGTERM')
|
|
197
|
+
serverProcess = null
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
app.on('activate', () => {
|
|
202
|
+
if (mainWindow === null) createWindow()
|
|
203
|
+
})
|