@porkytheblack/garage-dashboard 0.1.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/.env.example +3 -0
- package/DESIGN.md +90 -0
- package/README.md +62 -0
- package/app/(app)/credentials/add-profile-modal.tsx +116 -0
- package/app/(app)/credentials/add-provider-modal.tsx +172 -0
- package/app/(app)/credentials/edit-provider-modal.tsx +107 -0
- package/app/(app)/credentials/edit-reasoning-modal.tsx +96 -0
- package/app/(app)/credentials/form.tsx +132 -0
- package/app/(app)/credentials/model-combobox.tsx +151 -0
- package/app/(app)/credentials/page.tsx +126 -0
- package/app/(app)/keys/page.tsx +164 -0
- package/app/(app)/layout.tsx +53 -0
- package/app/(app)/namespaces/list.tsx +47 -0
- package/app/(app)/namespaces/mint-dialog.tsx +64 -0
- package/app/(app)/namespaces/page.tsx +137 -0
- package/app/(app)/page.tsx +86 -0
- package/app/(app)/provisioning/page.tsx +84 -0
- package/app/(app)/sessions/[id]/page.tsx +184 -0
- package/app/(app)/sessions/page.tsx +88 -0
- package/app/(app)/storage/page.tsx +164 -0
- package/app/(app)/storage/toggle.tsx +43 -0
- package/app/(app)/workspaces/list.tsx +34 -0
- package/app/(app)/workspaces/page.tsx +111 -0
- package/app/globals.css +297 -0
- package/app/layout.tsx +32 -0
- package/app/login/page.tsx +62 -0
- package/components/app-sidebar.tsx +120 -0
- package/components/app-topbar.tsx +76 -0
- package/components/brand.tsx +47 -0
- package/components/chat/composer.tsx +199 -0
- package/components/chat/conversation.tsx +86 -0
- package/components/chat/error-card.tsx +89 -0
- package/components/chat/markdown.tsx +12 -0
- package/components/chat/message.tsx +99 -0
- package/components/chat/permission-prompt.tsx +45 -0
- package/components/chat/slash-menu.tsx +54 -0
- package/components/chat/task-list.tsx +56 -0
- package/components/chat/tool-call.tsx +91 -0
- package/components/fleet/lanes.tsx +107 -0
- package/components/fleet/launch.tsx +99 -0
- package/components/fleet/onboarding-launch.tsx +46 -0
- package/components/fleet/onboarding-model.tsx +109 -0
- package/components/fleet/onboarding-provider.tsx +137 -0
- package/components/fleet/onboarding-shared.tsx +66 -0
- package/components/fleet/onboarding.tsx +75 -0
- package/components/primitives.tsx +65 -0
- package/components/provisioning/provision-dialog.tsx +130 -0
- package/components/session/agent-roster.tsx +121 -0
- package/components/session/files-panel.tsx +170 -0
- package/components/session/files-remote.tsx +63 -0
- package/components/session/inspector.tsx +148 -0
- package/components/session/model-switcher.tsx +59 -0
- package/components/session/new-session-dialog.tsx +139 -0
- package/components/session/reasoning-knob.tsx +100 -0
- package/components/session/session-switcher.tsx +62 -0
- package/components/shared.tsx +171 -0
- package/components/theme-toggle.tsx +26 -0
- package/components/ui/avatar.tsx +39 -0
- package/components/ui/badge.tsx +30 -0
- package/components/ui/button.tsx +45 -0
- package/components/ui/card.tsx +44 -0
- package/components/ui/command.tsx +90 -0
- package/components/ui/dialog.tsx +88 -0
- package/components/ui/dropdown-menu.tsx +77 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +22 -0
- package/components/ui/popover.tsx +33 -0
- package/components/ui/scroll-area.tsx +41 -0
- package/components/ui/select.tsx +100 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/skeleton.tsx +7 -0
- package/components/ui/sonner.tsx +28 -0
- package/components/ui/table.tsx +51 -0
- package/components/ui/tabs.tsx +50 -0
- package/components/ui/textarea.tsx +20 -0
- package/components/ui/tooltip.tsx +30 -0
- package/components.json +21 -0
- package/lib/api.ts +85 -0
- package/lib/auth.tsx +80 -0
- package/lib/format.ts +34 -0
- package/lib/hooks.ts +65 -0
- package/lib/launch.ts +25 -0
- package/lib/template.ts +34 -0
- package/lib/theme.ts +71 -0
- package/lib/types.ts +262 -0
- package/lib/useSession.ts +193 -0
- package/lib/utils.ts +7 -0
- package/lib/verify-provider.ts +31 -0
- package/next.config.mjs +16 -0
- package/package.json +49 -0
- package/postcss.config.mjs +6 -0
- package/tailwind.config.ts +94 -0
- package/tsconfig.json +21 -0
package/.env.example
ADDED
package/DESIGN.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Garage design language — "sap & sunlight"
|
|
2
|
+
|
|
3
|
+
Refined, product-grade UI in two modes: **dark "pine at dusk"** (green-black
|
|
4
|
+
surfaces, luminous sap green, solar amber) and **light "warm paper"** (cream
|
|
5
|
+
surfaces, pine ink, darkened sap). Calm confidence: deliberate hierarchy,
|
|
6
|
+
depth from hairline borders + a top sheen (never heavy drop shadows on
|
|
7
|
+
surfaces), one sap-green accent used sparingly. The two modes share ONE
|
|
8
|
+
structure — only hues shift, and they shift inside globals.css, never in
|
|
9
|
+
components. The reference implementations are the Fleet
|
|
10
|
+
console (`app/(app)/page.tsx`, `components/fleet/*`) and the app shell
|
|
11
|
+
(`components/app-sidebar.tsx`, `components/app-topbar.tsx`). When in doubt,
|
|
12
|
+
imitate those files.
|
|
13
|
+
|
|
14
|
+
## Tokens (globals.css / tailwind.config.ts)
|
|
15
|
+
|
|
16
|
+
- **Elevation ladder** — surfaces climb, never jump:
|
|
17
|
+
`bg-background` (page) → `bg-card` (containers) → `bg-surface-2` (bands,
|
|
18
|
+
hovers, insets) → `bg-elevated` (highest inline emphasis). Overlays
|
|
19
|
+
(dialogs/menus) sit on `bg-popover` + `shadow-elevated`.
|
|
20
|
+
- **Borders**: `border-border` everywhere; `border-border-strong` only for
|
|
21
|
+
emphasis/hover. Dividers inside lists: `divide-border/60`.
|
|
22
|
+
- **Brand**: `brand` (sap green) for primary actions, active nav, focus,
|
|
23
|
+
live accents; `brand-strong` for hover/links. Brand should touch few
|
|
24
|
+
pixels. Green/amber/teal values are darker in light mode for contrast on
|
|
25
|
+
cream — always use the token, never a literal, and it stays legible.
|
|
26
|
+
- **Quiet scale** (3 weights of gray text): `text-foreground` (content) →
|
|
27
|
+
`text-muted-foreground` (secondary) → `text-faint` (hints, icons, meta).
|
|
28
|
+
Decorative icons default to `text-faint`.
|
|
29
|
+
- **Semantic**: `success` (running/live), `warning` (solar amber —
|
|
30
|
+
provisioning/working/caution), `destructive` (red clay — errors/dangerous
|
|
31
|
+
actions). Tint surfaces with `/10`–`/[0.07]` alphas and `/25`–`/30`
|
|
32
|
+
borders, as in `ErrorState`.
|
|
33
|
+
|
|
34
|
+
## Utilities & shadows
|
|
35
|
+
|
|
36
|
+
- `.surface` = card + border + `shadow-card` (the standard container).
|
|
37
|
+
- `shadow-sheen` inner top highlight for small chips/buttons.
|
|
38
|
+
- Shadows + sheen + `.skeleton` shimmer are CSS variables (`--shadow-card`,
|
|
39
|
+
`--shadow-elevated`, `--sheen`, `--shimmer`) tuned per mode in globals.css
|
|
40
|
+
— never write a literal box-shadow with a baked-in tint.
|
|
41
|
+
- `shadow-elevated` for overlays only. `shadow-glow` for hero focus moments
|
|
42
|
+
(one per screen, e.g. the launch composer's `focus-within`).
|
|
43
|
+
- `.text-display` for hero headings; `.tnum` on every numeric column;
|
|
44
|
+
`.skeleton` for loading blocks; `.app-backdrop` only on full-screen roots
|
|
45
|
+
outside the app shell (e.g. login).
|
|
46
|
+
|
|
47
|
+
## Type scale
|
|
48
|
+
|
|
49
|
+
- Page title: `PageHeader` (22px semibold tight). Section: `SectionHeading`
|
|
50
|
+
(eyebrow 11px uppercase tracking-wider text-faint + 15px semibold title).
|
|
51
|
+
- Body 13.5px · secondary 13px · meta 12–12.5px · hints 11.5px `text-faint`
|
|
52
|
+
· mono `font-mono` 12–12.5px. Never `text-base` or larger outside heroes.
|
|
53
|
+
- Keyboard hints: `<kbd className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[10px]">`.
|
|
54
|
+
|
|
55
|
+
## Composition patterns
|
|
56
|
+
|
|
57
|
+
- Lists/tables live inside a `.surface` with `overflow-hidden`; optional
|
|
58
|
+
header band: `border-b border-border/70 bg-surface-2/40 px-3.5 py-2`.
|
|
59
|
+
- Dense rows: `px-3.5 py-2.5`, hover `hover:bg-surface-2`, trailing chevron
|
|
60
|
+
that nudges on hover (`group-hover:translate-x-0.5`). Numeric columns
|
|
61
|
+
right-aligned with `.tnum`.
|
|
62
|
+
- Live state: `SessionStatus` (pulse-ring on running states). Poll lists that
|
|
63
|
+
should feel alive: `useQuery(path, [], 4000)` — it refreshes silently.
|
|
64
|
+
- Empty/error/loading: always `EmptyState` / `ErrorState` / `Loading` or
|
|
65
|
+
`.skeleton` — never a bare "No data" string.
|
|
66
|
+
- Forms: `Label` 13px + control per row, `gap-4`+ between rows; dialogs get a
|
|
67
|
+
clear title + one-line description; primary action right-aligned in footer.
|
|
68
|
+
- Motion: `animate-fade-in` for page mounts (built into `Page`),
|
|
69
|
+
`animate-slide-up` for hero/section entrances, transitions ~150ms. No
|
|
70
|
+
animation on data refresh.
|
|
71
|
+
|
|
72
|
+
## Primitives — compose, don't reinvent
|
|
73
|
+
|
|
74
|
+
`Page`, `PageHeader`, `SectionHeading`, `Metric`, `EmptyState`, `ErrorState`,
|
|
75
|
+
`SessionStatus`, `Loading`, `Spinner`, `CopyButton`, `ConfirmButton`,
|
|
76
|
+
`SecretReveal` (from `components/shared.tsx`, `components/primitives.tsx`)
|
|
77
|
+
plus the shadcn primitives in `components/ui/`.
|
|
78
|
+
|
|
79
|
+
## Hard rules
|
|
80
|
+
|
|
81
|
+
- Two modes, one structure: every screen must hold up in light AND dark.
|
|
82
|
+
No new color literals — tokens only. Mode-specific styling means a token
|
|
83
|
+
(or `--var`) tuned in globals.css, not a `dark:` utility in a component.
|
|
84
|
+
- Theme plumbing: boot script in `app/layout.tsx`, `useTheme` in
|
|
85
|
+
`lib/theme.ts`, `ThemeToggle` in the topbar. localStorage `garage.theme`,
|
|
86
|
+
absent = follow OS.
|
|
87
|
+
- Keep every file under 200 lines; split before exceeding.
|
|
88
|
+
- Don't restyle by overriding primitives with long `className` chains — if a
|
|
89
|
+
pattern repeats three times, it belongs in a shared component.
|
|
90
|
+
- Visual changes must not alter API calls, data flow, or component contracts.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Garage Dashboard
|
|
2
|
+
|
|
3
|
+
A clean, professional console for the Glorp **Garage** orchestration layer. It
|
|
4
|
+
talks directly to a running Garage server's REST + WebSocket API and gives you
|
|
5
|
+
live observability and control over the core primitives:
|
|
6
|
+
|
|
7
|
+
- **Sessions** — list, create, destroy, and watch a session's live event stream;
|
|
8
|
+
send messages and abort runs.
|
|
9
|
+
- **Agents** — inspect and manage a session's multi-agent roster.
|
|
10
|
+
- **Messages** — the agent's latest answer and task list per session.
|
|
11
|
+
- **Namespaces** — create/delete tenant partitions and mint namespace-bound keys.
|
|
12
|
+
- **Workspaces** — manage the host directories sessions run against.
|
|
13
|
+
- **Provisioning** — launch sessions from declarative setup templates.
|
|
14
|
+
- **Credentials** — model providers and the profiles sessions inherit.
|
|
15
|
+
- **API Keys** — mint/revoke keys for the REST API and the MCP server.
|
|
16
|
+
|
|
17
|
+
## Auth
|
|
18
|
+
|
|
19
|
+
The dashboard signs in with the admin identity provisioned on the Garage server:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export GARAGE_ADMIN_USER=admin
|
|
23
|
+
export GARAGE_ADMIN_PASSWORD=change-me
|
|
24
|
+
export GARAGE_JWT_SECRET=$(openssl rand -hex 32) # optional; derived from the password if unset
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Login exchanges these for a short-lived JWT, which the browser then sends as a
|
|
28
|
+
Bearer token on every API request (and as `?api_key=` on the WebSocket).
|
|
29
|
+
|
|
30
|
+
## Run
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
cd dashboard
|
|
34
|
+
cp .env.example .env.local # point NEXT_PUBLIC_GARAGE_URL at your Garage
|
|
35
|
+
bun install
|
|
36
|
+
bun run dev # http://localhost:3270
|
|
37
|
+
|
|
38
|
+
# in another terminal, with admin env vars set:
|
|
39
|
+
glorp garage # the Garage API on http://127.0.0.1:4271
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For a non-loopback Garage bind, API-key auth is enforced automatically — the
|
|
43
|
+
admin JWT satisfies it (admin scope). On loopback, the API is open but the
|
|
44
|
+
dashboard still requires login when admin credentials are configured.
|
|
45
|
+
|
|
46
|
+
## Build
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bun run build && bun run start
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The dashboard holds no server-side secrets: it is a pure client of the Garage
|
|
53
|
+
API, configured via `NEXT_PUBLIC_GARAGE_URL` (baked into the bundle at build).
|
|
54
|
+
|
|
55
|
+
## Design system
|
|
56
|
+
|
|
57
|
+
Built on **Tailwind CSS + shadcn/ui** (Radix primitives) with a graphite
|
|
58
|
+
monochrome theme — tokens live in `app/globals.css`, primitives in
|
|
59
|
+
`components/ui/`, and the Garage brand mark in `components/brand.tsx`. The app is
|
|
60
|
+
session-centric: the session view (`/sessions/[id]`) hosts the live chat with
|
|
61
|
+
**Chat · Tasks · Agents · Details** tabs, so agents and messages aren't separate
|
|
62
|
+
pages.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Plus } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { api } from "@/lib/api";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog";
|
|
10
|
+
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
|
11
|
+
import { Field, FieldRow, ModalFooter } from "./form";
|
|
12
|
+
import { ModelCombobox } from "./model-combobox";
|
|
13
|
+
import type { Catalog, ProviderWire } from "@/lib/types";
|
|
14
|
+
|
|
15
|
+
/** Add a (provider, model) profile. Reasoning effort is configured afterwards on
|
|
16
|
+
* the profile row — so a model stays a single entry, the way the TUI works. */
|
|
17
|
+
export function AddProfileModal({ providers, catalog, onSaved }: { providers: ProviderWire[]; catalog: Catalog | null; onSaved: () => void }) {
|
|
18
|
+
const [open, setOpen] = React.useState(false);
|
|
19
|
+
const [providerId, setProviderId] = React.useState("");
|
|
20
|
+
const [model, setModel] = React.useState("");
|
|
21
|
+
const [label, setLabel] = React.useState("");
|
|
22
|
+
const [busy, setBusy] = React.useState(false);
|
|
23
|
+
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
if (open && !providerId && providers[0]) setProviderId(providers[0].id);
|
|
26
|
+
}, [open, providers, providerId]);
|
|
27
|
+
|
|
28
|
+
const suggestions = React.useMemo(() => {
|
|
29
|
+
const prov = providers.find((p) => p.id === providerId);
|
|
30
|
+
const key = prov?.based_on ?? providerId;
|
|
31
|
+
return catalog?.providers.find((c) => c.id === key)?.default_models ?? [];
|
|
32
|
+
}, [providers, catalog, providerId]);
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
const save = async () => {
|
|
36
|
+
if (!providerId || !model.trim()) {
|
|
37
|
+
toast.error("Provider and model are required.");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setBusy(true);
|
|
41
|
+
try {
|
|
42
|
+
await api("/models/profiles", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
body: {
|
|
45
|
+
providerId,
|
|
46
|
+
model: model.trim(),
|
|
47
|
+
label: label.trim() || undefined,
|
|
48
|
+
activate: true,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
toast.success("Profile added & activated");
|
|
52
|
+
onSaved();
|
|
53
|
+
setOpen(false);
|
|
54
|
+
setModel("");
|
|
55
|
+
setLabel("");
|
|
56
|
+
} catch (e) {
|
|
57
|
+
toast.error(e instanceof Error ? e.message : "Failed to add profile");
|
|
58
|
+
} finally {
|
|
59
|
+
setBusy(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
65
|
+
<DialogTrigger asChild>
|
|
66
|
+
<Button variant="secondary" size="sm" disabled={providers.length === 0}>
|
|
67
|
+
<Plus /> Add model
|
|
68
|
+
</Button>
|
|
69
|
+
</DialogTrigger>
|
|
70
|
+
<DialogContent>
|
|
71
|
+
<DialogHeader>
|
|
72
|
+
<DialogTitle>Add model</DialogTitle>
|
|
73
|
+
<DialogDescription>Pair a provider with a model. Set reasoning effort afterwards from the model’s row.</DialogDescription>
|
|
74
|
+
</DialogHeader>
|
|
75
|
+
|
|
76
|
+
<div className="space-y-5">
|
|
77
|
+
<FieldRow>
|
|
78
|
+
<Field label="Provider">
|
|
79
|
+
<Select
|
|
80
|
+
value={providerId}
|
|
81
|
+
onValueChange={(id) => {
|
|
82
|
+
setProviderId(id);
|
|
83
|
+
setModel("");
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<SelectTrigger>
|
|
87
|
+
<SelectValue placeholder="Select…" />
|
|
88
|
+
</SelectTrigger>
|
|
89
|
+
<SelectContent>
|
|
90
|
+
{providers.map((p) => (
|
|
91
|
+
<SelectItem key={p.id} value={p.id}>
|
|
92
|
+
{p.id}
|
|
93
|
+
</SelectItem>
|
|
94
|
+
))}
|
|
95
|
+
</SelectContent>
|
|
96
|
+
</Select>
|
|
97
|
+
</Field>
|
|
98
|
+
<Field label="Model">
|
|
99
|
+
<ModelCombobox providerId={providerId} catalog={suggestions} value={model} onChange={setModel} />
|
|
100
|
+
</Field>
|
|
101
|
+
</FieldRow>
|
|
102
|
+
<FieldRow>
|
|
103
|
+
<Field label="Label (optional)">
|
|
104
|
+
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder={`${providerId || "provider"} · ${model || "model"}`} />
|
|
105
|
+
</Field>
|
|
106
|
+
<Field label="Context window" hint="Detected automatically from the model catalog. Override per provider under its edit dialog if needed.">
|
|
107
|
+
<div className="flex h-9 items-center rounded-md border border-border/60 bg-surface-2/40 px-3 text-[13px] text-muted-foreground">Auto</div>
|
|
108
|
+
</Field>
|
|
109
|
+
</FieldRow>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<ModalFooter onCancel={() => setOpen(false)} onSubmit={save} submitLabel="Add model" busy={busy} />
|
|
113
|
+
</DialogContent>
|
|
114
|
+
</Dialog>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Plus } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { api } from "@/lib/api";
|
|
7
|
+
import { verifyProvider } from "@/lib/verify-provider";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Input } from "@/components/ui/input";
|
|
10
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog";
|
|
11
|
+
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
|
12
|
+
import { Field, FieldRow, KeyInput, ModalFooter, VerifyBanner, slugify } from "./form";
|
|
13
|
+
import type { Catalog } from "@/lib/types";
|
|
14
|
+
|
|
15
|
+
const CUSTOM = "__custom__";
|
|
16
|
+
const NONE = "__none__";
|
|
17
|
+
type Verdict = { ok: true; models: number } | { ok: false; message: string };
|
|
18
|
+
|
|
19
|
+
export function AddProviderModal({ catalog, onSaved }: { catalog: Catalog | null; onSaved: () => void }) {
|
|
20
|
+
const known = catalog?.providers ?? [];
|
|
21
|
+
const adapters = catalog?.adapters ?? [];
|
|
22
|
+
|
|
23
|
+
const [open, setOpen] = React.useState(false);
|
|
24
|
+
const [choice, setChoice] = React.useState("");
|
|
25
|
+
const [name, setName] = React.useState("");
|
|
26
|
+
const [apiKey, setApiKey] = React.useState("");
|
|
27
|
+
const [baseURL, setBaseURL] = React.useState("");
|
|
28
|
+
const [adapter, setAdapter] = React.useState(adapters[0]?.id ?? "openai-compat");
|
|
29
|
+
const [basedOn, setBasedOn] = React.useState(NONE);
|
|
30
|
+
const [contextLimit, setContextLimit] = React.useState("");
|
|
31
|
+
const [busy, setBusy] = React.useState(false);
|
|
32
|
+
const [verdict, setVerdict] = React.useState<Verdict | null>(null);
|
|
33
|
+
|
|
34
|
+
const isCustom = choice === CUSTOM;
|
|
35
|
+
const meta = known.find((p) => p.id === choice);
|
|
36
|
+
const slug = slugify(name);
|
|
37
|
+
const id = isCustom ? (slug ? `custom-${slug}` : "") : choice;
|
|
38
|
+
const needsKey = isCustom ? false : meta?.needs_api_key ?? true;
|
|
39
|
+
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (!open) {
|
|
42
|
+
setChoice(""); setName(""); setApiKey(""); setBaseURL(""); setBasedOn(NONE); setContextLimit(""); setVerdict(null);
|
|
43
|
+
}
|
|
44
|
+
}, [open]);
|
|
45
|
+
|
|
46
|
+
const save = async () => {
|
|
47
|
+
if (!id) {
|
|
48
|
+
toast.error(isCustom ? "Name the custom endpoint." : "Pick a provider.");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setBusy(true);
|
|
52
|
+
setVerdict(null);
|
|
53
|
+
try {
|
|
54
|
+
await api("/models/providers", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
body: {
|
|
57
|
+
id,
|
|
58
|
+
type: isCustom ? "custom" : "known",
|
|
59
|
+
apiKey: apiKey.trim() || undefined,
|
|
60
|
+
baseURL: isCustom ? baseURL.trim() || undefined : undefined,
|
|
61
|
+
...(isCustom ? { adapter } : {}),
|
|
62
|
+
...(isCustom && basedOn !== NONE ? { basedOn } : {}),
|
|
63
|
+
...(contextLimit.trim() ? { contextLimit: Number(contextLimit) } : {}),
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
onSaved();
|
|
67
|
+
const v = await verifyProvider(id);
|
|
68
|
+
if (v.ok) {
|
|
69
|
+
setVerdict({ ok: true, models: v.models.length });
|
|
70
|
+
setTimeout(() => setOpen(false), 900);
|
|
71
|
+
} else {
|
|
72
|
+
setVerdict({ ok: false, message: v.message });
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
toast.error(e instanceof Error ? e.message : "Failed to save provider");
|
|
76
|
+
} finally {
|
|
77
|
+
setBusy(false);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const failed = verdict && !verdict.ok;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
85
|
+
<DialogTrigger asChild>
|
|
86
|
+
<Button variant="secondary" size="sm">
|
|
87
|
+
<Plus /> Add provider
|
|
88
|
+
</Button>
|
|
89
|
+
</DialogTrigger>
|
|
90
|
+
<DialogContent>
|
|
91
|
+
<DialogHeader>
|
|
92
|
+
<DialogTitle>Add provider</DialogTitle>
|
|
93
|
+
<DialogDescription>Pick a known provider, or wire up a custom OpenAI-compatible endpoint.</DialogDescription>
|
|
94
|
+
</DialogHeader>
|
|
95
|
+
|
|
96
|
+
<div className="space-y-5">
|
|
97
|
+
<Field label="Provider" hint={meta?.description}>
|
|
98
|
+
<Select value={choice} onValueChange={(v) => { setChoice(v); setVerdict(null); }}>
|
|
99
|
+
<SelectTrigger>
|
|
100
|
+
<SelectValue placeholder="Select a provider…" />
|
|
101
|
+
</SelectTrigger>
|
|
102
|
+
<SelectContent>
|
|
103
|
+
{known.map((p) => (
|
|
104
|
+
<SelectItem key={p.id} value={p.id}>{p.label}</SelectItem>
|
|
105
|
+
))}
|
|
106
|
+
<SelectItem value={CUSTOM}>Custom (OpenAI-compatible)…</SelectItem>
|
|
107
|
+
</SelectContent>
|
|
108
|
+
</Select>
|
|
109
|
+
</Field>
|
|
110
|
+
|
|
111
|
+
{isCustom && (
|
|
112
|
+
<FieldRow>
|
|
113
|
+
<Field label="Name" hint={id ? <>id: <span className="font-mono text-faint">{id}</span></> : "Becomes custom-<slug>"}>
|
|
114
|
+
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="My proxy" />
|
|
115
|
+
</Field>
|
|
116
|
+
<Field label="Adapter" hint="OpenAI-compatible">
|
|
117
|
+
<Select value={adapter} onValueChange={setAdapter}>
|
|
118
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
119
|
+
<SelectContent>
|
|
120
|
+
{adapters.map((a) => (<SelectItem key={a.id} value={a.id}>{a.label}</SelectItem>))}
|
|
121
|
+
</SelectContent>
|
|
122
|
+
</Select>
|
|
123
|
+
</Field>
|
|
124
|
+
</FieldRow>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{(choice || isCustom) && (
|
|
128
|
+
<Field
|
|
129
|
+
label={needsKey ? "API key" : "API key (optional)"}
|
|
130
|
+
hint={!isCustom && meta?.env_var ? <>Usually the key from <span className="font-mono">${meta.env_var}</span>.</> : undefined}
|
|
131
|
+
>
|
|
132
|
+
<KeyInput value={apiKey} onChange={setApiKey} placeholder={needsKey ? "Paste the API key" : "Leave blank if none"} />
|
|
133
|
+
</Field>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{isCustom && (
|
|
137
|
+
<>
|
|
138
|
+
<Field label="Base URL">
|
|
139
|
+
<Input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="https://…/v1" />
|
|
140
|
+
</Field>
|
|
141
|
+
<FieldRow>
|
|
142
|
+
<Field label="Based on" hint="Borrow a known provider's models">
|
|
143
|
+
<Select value={basedOn} onValueChange={setBasedOn}>
|
|
144
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
145
|
+
<SelectContent>
|
|
146
|
+
<SelectItem value={NONE}>None</SelectItem>
|
|
147
|
+
{known.map((p) => (<SelectItem key={p.id} value={p.id}>{p.label}</SelectItem>))}
|
|
148
|
+
</SelectContent>
|
|
149
|
+
</Select>
|
|
150
|
+
</Field>
|
|
151
|
+
<Field label="Context limit">
|
|
152
|
+
<Input inputMode="numeric" value={contextLimit} onChange={(e) => setContextLimit(e.target.value)} placeholder="e.g. 200000" />
|
|
153
|
+
</Field>
|
|
154
|
+
</FieldRow>
|
|
155
|
+
</>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{verdict && <VerifyBanner state={verdict} />}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{failed ? (
|
|
162
|
+
<div className="flex justify-end gap-2 sm:flex-row">
|
|
163
|
+
<Button variant="ghost" onClick={() => setOpen(false)}>Keep anyway</Button>
|
|
164
|
+
<Button onClick={() => setVerdict(null)}>Fix key</Button>
|
|
165
|
+
</div>
|
|
166
|
+
) : (
|
|
167
|
+
<ModalFooter onCancel={() => setOpen(false)} onSubmit={save} submitLabel="Save provider" busy={busy} disabled={!!verdict} />
|
|
168
|
+
)}
|
|
169
|
+
</DialogContent>
|
|
170
|
+
</Dialog>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Pencil } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { api } from "@/lib/api";
|
|
7
|
+
import { verifyProvider } from "@/lib/verify-provider";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Input } from "@/components/ui/input";
|
|
10
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog";
|
|
11
|
+
import { Field, FieldRow, KeyInput, ModalFooter, VerifyBanner } from "./form";
|
|
12
|
+
import type { ProviderWire } from "@/lib/types";
|
|
13
|
+
|
|
14
|
+
type Verdict = { ok: true; models: number } | { ok: false; message: string };
|
|
15
|
+
|
|
16
|
+
/** Update an existing provider's API key (and optionally base URL / context
|
|
17
|
+
* limit). Other fields are preserved server-side, so leaving the key blank
|
|
18
|
+
* keeps the current one. A rotated key is verified live after saving. */
|
|
19
|
+
export function EditProviderModal({ provider, onSaved }: { provider: ProviderWire; onSaved: () => void }) {
|
|
20
|
+
const [open, setOpen] = React.useState(false);
|
|
21
|
+
const [apiKey, setApiKey] = React.useState("");
|
|
22
|
+
const [baseURL, setBaseURL] = React.useState(provider.base_url ?? "");
|
|
23
|
+
const [contextLimit, setContextLimit] = React.useState(provider.context_limit ? String(provider.context_limit) : "");
|
|
24
|
+
const [busy, setBusy] = React.useState(false);
|
|
25
|
+
const [verdict, setVerdict] = React.useState<Verdict | null>(null);
|
|
26
|
+
|
|
27
|
+
const rotated = apiKey.trim().length > 0;
|
|
28
|
+
|
|
29
|
+
const save = async () => {
|
|
30
|
+
setBusy(true);
|
|
31
|
+
setVerdict(null);
|
|
32
|
+
try {
|
|
33
|
+
await api("/models/providers", {
|
|
34
|
+
method: "POST",
|
|
35
|
+
body: {
|
|
36
|
+
id: provider.id,
|
|
37
|
+
...(rotated ? { apiKey: apiKey.trim() } : {}),
|
|
38
|
+
...(baseURL.trim() ? { baseURL: baseURL.trim() } : {}),
|
|
39
|
+
...(contextLimit.trim() ? { contextLimit: Number(contextLimit) } : {}),
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
onSaved();
|
|
43
|
+
if (!rotated) {
|
|
44
|
+
toast.success("Provider updated");
|
|
45
|
+
setOpen(false);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const v = await verifyProvider(provider.id);
|
|
49
|
+
if (v.ok) {
|
|
50
|
+
setVerdict({ ok: true, models: v.models.length });
|
|
51
|
+
setApiKey("");
|
|
52
|
+
setTimeout(() => setOpen(false), 900);
|
|
53
|
+
} else {
|
|
54
|
+
setVerdict({ ok: false, message: v.message });
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
toast.error(e instanceof Error ? e.message : "Update failed");
|
|
58
|
+
} finally {
|
|
59
|
+
setBusy(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const failed = verdict && !verdict.ok;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) { setApiKey(""); setVerdict(null); } }}>
|
|
67
|
+
<DialogTrigger asChild>
|
|
68
|
+
<Button variant="ghost" size="icon-sm" className="text-muted-foreground" title="Edit provider">
|
|
69
|
+
<Pencil />
|
|
70
|
+
</Button>
|
|
71
|
+
</DialogTrigger>
|
|
72
|
+
<DialogContent>
|
|
73
|
+
<DialogHeader>
|
|
74
|
+
<DialogTitle>Edit {provider.id}</DialogTitle>
|
|
75
|
+
<DialogDescription>Rotate the API key or adjust the endpoint. Anything left blank keeps its current value.</DialogDescription>
|
|
76
|
+
</DialogHeader>
|
|
77
|
+
<div className="space-y-5">
|
|
78
|
+
<Field label="API key">
|
|
79
|
+
<KeyInput
|
|
80
|
+
autoFocus
|
|
81
|
+
value={apiKey}
|
|
82
|
+
onChange={setApiKey}
|
|
83
|
+
placeholder={provider.has_api_key ? "•••• stored — leave blank to keep" : "Set an API key"}
|
|
84
|
+
/>
|
|
85
|
+
</Field>
|
|
86
|
+
<FieldRow>
|
|
87
|
+
<Field label="Base URL">
|
|
88
|
+
<Input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="https://…/v1" />
|
|
89
|
+
</Field>
|
|
90
|
+
<Field label="Context limit">
|
|
91
|
+
<Input inputMode="numeric" value={contextLimit} onChange={(e) => setContextLimit(e.target.value)} placeholder="e.g. 200000" />
|
|
92
|
+
</Field>
|
|
93
|
+
</FieldRow>
|
|
94
|
+
{verdict && <VerifyBanner state={verdict} />}
|
|
95
|
+
</div>
|
|
96
|
+
{failed ? (
|
|
97
|
+
<div className="flex justify-end gap-2 sm:flex-row">
|
|
98
|
+
<Button variant="ghost" onClick={() => setOpen(false)}>Keep anyway</Button>
|
|
99
|
+
<Button onClick={() => setVerdict(null)}>Fix key</Button>
|
|
100
|
+
</div>
|
|
101
|
+
) : (
|
|
102
|
+
<ModalFooter onCancel={() => setOpen(false)} onSubmit={save} submitLabel="Save" busy={busy} disabled={!!verdict} />
|
|
103
|
+
)}
|
|
104
|
+
</DialogContent>
|
|
105
|
+
</Dialog>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Sparkles } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { api } from "@/lib/api";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Spinner } from "@/components/shared";
|
|
9
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog";
|
|
10
|
+
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
|
11
|
+
import { Field, ModalFooter } from "./form";
|
|
12
|
+
import type { ProfileWire, ReasoningOption } from "@/lib/types";
|
|
13
|
+
|
|
14
|
+
/** Change a profile's reasoning effort in place (mirrors the TUI `r` cycle):
|
|
15
|
+
* one model entry, with reasoning as an editable attribute — not a duplicate. */
|
|
16
|
+
export function EditReasoningModal({ profile, onSaved }: { profile: ProfileWire; onSaved: () => void }) {
|
|
17
|
+
const [open, setOpen] = React.useState(false);
|
|
18
|
+
const [opts, setOpts] = React.useState<ReasoningOption[] | null>(null);
|
|
19
|
+
const [idx, setIdx] = React.useState("0");
|
|
20
|
+
const [busy, setBusy] = React.useState(false);
|
|
21
|
+
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
if (!open) return;
|
|
24
|
+
setOpts(null);
|
|
25
|
+
const q = `?provider=${encodeURIComponent(profile.provider_id)}&model=${encodeURIComponent(profile.model)}`;
|
|
26
|
+
api<{ options: ReasoningOption[] }>(`/models/reasoning-options${q}`)
|
|
27
|
+
.then((r) => {
|
|
28
|
+
setOpts(r.options);
|
|
29
|
+
const cur = JSON.stringify(profile.reasoning ?? { kind: "off" });
|
|
30
|
+
const found = r.options.findIndex((o) => JSON.stringify(o.value) === cur);
|
|
31
|
+
setIdx(String(found >= 0 ? found : 0));
|
|
32
|
+
})
|
|
33
|
+
.catch(() => setOpts([]));
|
|
34
|
+
}, [open, profile.provider_id, profile.model, profile.reasoning]);
|
|
35
|
+
|
|
36
|
+
const save = async () => {
|
|
37
|
+
if (!opts) return;
|
|
38
|
+
setBusy(true);
|
|
39
|
+
try {
|
|
40
|
+
await api(`/models/profiles/${profile.id}/reasoning`, { method: "POST", body: { reasoning: opts[Number(idx)]?.value ?? { kind: "off" } } });
|
|
41
|
+
toast.success("Reasoning updated");
|
|
42
|
+
onSaved();
|
|
43
|
+
setOpen(false);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
toast.error(e instanceof Error ? e.message : "Update failed");
|
|
46
|
+
} finally {
|
|
47
|
+
setBusy(false);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
53
|
+
<DialogTrigger asChild>
|
|
54
|
+
<Button variant="ghost" size="sm" className="text-muted-foreground" title="Reasoning effort">
|
|
55
|
+
<Sparkles /> Reasoning
|
|
56
|
+
</Button>
|
|
57
|
+
</DialogTrigger>
|
|
58
|
+
<DialogContent>
|
|
59
|
+
<DialogHeader>
|
|
60
|
+
<DialogTitle>Reasoning effort</DialogTitle>
|
|
61
|
+
<DialogDescription>
|
|
62
|
+
{profile.provider_id} · <span className="font-mono">{profile.model}</span> — sets the thinking effort this model uses.
|
|
63
|
+
</DialogDescription>
|
|
64
|
+
</DialogHeader>
|
|
65
|
+
<div className="space-y-5">
|
|
66
|
+
{opts === null ? (
|
|
67
|
+
<div className="flex items-center justify-center gap-2.5 py-6 text-[13px] text-muted-foreground">
|
|
68
|
+
<Spinner /> Loading effort levels…
|
|
69
|
+
</div>
|
|
70
|
+
) : opts.length === 0 ? (
|
|
71
|
+
<p className="rounded-lg border border-border bg-surface-2/40 px-4 py-3 text-[13px] text-muted-foreground">
|
|
72
|
+
This model doesn’t expose reasoning controls.
|
|
73
|
+
</p>
|
|
74
|
+
) : (
|
|
75
|
+
<Field label="Effort">
|
|
76
|
+
<Select value={idx} onValueChange={setIdx}>
|
|
77
|
+
<SelectTrigger>
|
|
78
|
+
<SelectValue />
|
|
79
|
+
</SelectTrigger>
|
|
80
|
+
<SelectContent>
|
|
81
|
+
{opts.map((o, i) => (
|
|
82
|
+
<SelectItem key={i} value={String(i)}>
|
|
83
|
+
{o.label}
|
|
84
|
+
{o.description ? ` — ${o.description}` : ""}
|
|
85
|
+
</SelectItem>
|
|
86
|
+
))}
|
|
87
|
+
</SelectContent>
|
|
88
|
+
</Select>
|
|
89
|
+
</Field>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
<ModalFooter onCancel={() => setOpen(false)} onSubmit={save} submitLabel="Save" busy={busy} disabled={!opts || opts.length === 0} />
|
|
93
|
+
</DialogContent>
|
|
94
|
+
</Dialog>
|
|
95
|
+
);
|
|
96
|
+
}
|