@polpo-ai/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.
Files changed (135) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +44 -0
  3. package/app/(dashboard)/error.tsx +47 -0
  4. package/app/(dashboard)/layout.tsx +41 -0
  5. package/app/(dashboard)/loading.tsx +30 -0
  6. package/app/(dashboard)/projects/[id]/agents/[name]/memory/view.tsx +36 -0
  7. package/app/(dashboard)/projects/[id]/agents/[name]/models-view.tsx +30 -0
  8. package/app/(dashboard)/projects/[id]/agents/[name]/page.tsx +51 -0
  9. package/app/(dashboard)/projects/[id]/agents/[name]/prompt/view.tsx +211 -0
  10. package/app/(dashboard)/projects/[id]/agents/[name]/skills/view.tsx +84 -0
  11. package/app/(dashboard)/projects/[id]/agents/[name]/tools/view.tsx +525 -0
  12. package/app/(dashboard)/projects/[id]/agents/[name]/vault/view.tsx +61 -0
  13. package/app/(dashboard)/projects/[id]/agents/page.tsx +39 -0
  14. package/app/(dashboard)/projects/[id]/agents/view.tsx +283 -0
  15. package/app/(dashboard)/projects/[id]/layout.tsx +11 -0
  16. package/app/(dashboard)/projects/[id]/logs/page.tsx +27 -0
  17. package/app/(dashboard)/projects/[id]/logs/view.tsx +184 -0
  18. package/app/(dashboard)/projects/[id]/memory/page.tsx +40 -0
  19. package/app/(dashboard)/projects/[id]/memory/view.tsx +17 -0
  20. package/app/(dashboard)/projects/[id]/missions/[missionId]/page.tsx +88 -0
  21. package/app/(dashboard)/projects/[id]/missions/[missionId]/view.tsx +569 -0
  22. package/app/(dashboard)/projects/[id]/missions/page.tsx +32 -0
  23. package/app/(dashboard)/projects/[id]/missions/view.tsx +282 -0
  24. package/app/(dashboard)/projects/[id]/onboarding-checklist.tsx +359 -0
  25. package/app/(dashboard)/projects/[id]/page.tsx +68 -0
  26. package/app/(dashboard)/projects/[id]/playbooks/[name]/page.tsx +5 -0
  27. package/app/(dashboard)/projects/[id]/playbooks/[name]/view.tsx +433 -0
  28. package/app/(dashboard)/projects/[id]/playbooks/page.tsx +5 -0
  29. package/app/(dashboard)/projects/[id]/playbooks/view.tsx +222 -0
  30. package/app/(dashboard)/projects/[id]/schedules/page.tsx +44 -0
  31. package/app/(dashboard)/projects/[id]/schedules/view.tsx +385 -0
  32. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/page.tsx +55 -0
  33. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/raw-view.tsx +373 -0
  34. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/view.tsx +124 -0
  35. package/app/(dashboard)/projects/[id]/sessions/page.tsx +31 -0
  36. package/app/(dashboard)/projects/[id]/sessions/view.tsx +242 -0
  37. package/app/(dashboard)/projects/[id]/settings/page.tsx +52 -0
  38. package/app/(dashboard)/projects/[id]/skills/[name]/page.tsx +65 -0
  39. package/app/(dashboard)/projects/[id]/skills/[name]/view.tsx +227 -0
  40. package/app/(dashboard)/projects/[id]/skills/page.tsx +31 -0
  41. package/app/(dashboard)/projects/[id]/skills/view.tsx +243 -0
  42. package/app/(dashboard)/projects/[id]/storage/page.tsx +5 -0
  43. package/app/(dashboard)/projects/[id]/storage/view.tsx +385 -0
  44. package/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view.tsx +336 -0
  45. package/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view.tsx +113 -0
  46. package/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint.ts +72 -0
  47. package/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view.tsx +90 -0
  48. package/app/(dashboard)/projects/[id]/tasks/[taskId]/page.tsx +63 -0
  49. package/app/(dashboard)/projects/[id]/tasks/[taskId]/view.tsx +137 -0
  50. package/app/(dashboard)/projects/[id]/tasks/page.tsx +40 -0
  51. package/app/(dashboard)/projects/[id]/tasks/view.tsx +421 -0
  52. package/app/(dashboard)/projects/[id]/view.tsx +421 -0
  53. package/app/(dashboard)/projects/[id]/welcome-banner.tsx +156 -0
  54. package/app/(dashboard)/projects/projects-list.tsx +126 -0
  55. package/app/(playground)/error.tsx +42 -0
  56. package/app/(playground)/layout.tsx +27 -0
  57. package/app/(playground)/projects/[id]/playground/page.tsx +57 -0
  58. package/app/(playground)/projects/[id]/playground/view.tsx +167 -0
  59. package/app/(playground)/projects/[id]/playground-legacy/page.tsx +47 -0
  60. package/app/(playground)/projects/[id]/playground-legacy/skeleton.tsx +63 -0
  61. package/app/(playground)/projects/[id]/playground-legacy/view.tsx +237 -0
  62. package/app/favicon.ico +0 -0
  63. package/app/globals.css +385 -0
  64. package/app/icon.svg +6 -0
  65. package/app/layout.tsx +39 -0
  66. package/app/page.tsx +11 -0
  67. package/components/ai-elements/code-block.tsx +562 -0
  68. package/components/dashboard/agent-capabilities.tsx +326 -0
  69. package/components/dashboard/agent-identity-header.tsx +50 -0
  70. package/components/dashboard/agent-model-picker.tsx +195 -0
  71. package/components/dashboard/agent-model-selector.tsx +121 -0
  72. package/components/dashboard/agent-picker-card.tsx +46 -0
  73. package/components/dashboard/agent-studio.tsx +242 -0
  74. package/components/dashboard/agents-table.tsx +48 -0
  75. package/components/dashboard/breadcrumb.tsx +70 -0
  76. package/components/dashboard/builder-chat.tsx +418 -0
  77. package/components/dashboard/chat-shell.tsx +40 -0
  78. package/components/dashboard/client-picker.tsx +63 -0
  79. package/components/dashboard/command-snippet.tsx +77 -0
  80. package/components/dashboard/connect-dialog.tsx +680 -0
  81. package/components/dashboard/copy-button.tsx +45 -0
  82. package/components/dashboard/copy-card.tsx +48 -0
  83. package/components/dashboard/file-browser.tsx +161 -0
  84. package/components/dashboard/hint.tsx +22 -0
  85. package/components/dashboard/inference-mode.tsx +15 -0
  86. package/components/dashboard/manual-refresh-button.tsx +52 -0
  87. package/components/dashboard/markdown.tsx +31 -0
  88. package/components/dashboard/mcp-install-panel.tsx +196 -0
  89. package/components/dashboard/mission-graph.tsx +262 -0
  90. package/components/dashboard/nav-tabs.tsx +86 -0
  91. package/components/dashboard/polpo-chat.tsx +468 -0
  92. package/components/dashboard/project-copilot.tsx +253 -0
  93. package/components/dashboard/sdk-snippet-panel.tsx +194 -0
  94. package/components/dashboard/section-header.tsx +37 -0
  95. package/components/dashboard/settings-form.tsx +2477 -0
  96. package/components/dashboard/sidebar.tsx +385 -0
  97. package/components/dashboard/skeletons.tsx +1054 -0
  98. package/components/dashboard/skills-install-wizard.tsx +273 -0
  99. package/components/dashboard/swarm-runs.tsx +316 -0
  100. package/components/dashboard/tab-toggle.tsx +35 -0
  101. package/components/dashboard/task-studio.tsx +224 -0
  102. package/components/dashboard/top-header.tsx +203 -0
  103. package/components/dashboard/webhook-deliveries.tsx +234 -0
  104. package/components/icons/coding-agents.tsx +151 -0
  105. package/components/json-ld.tsx +8 -0
  106. package/components/providers.tsx +25 -0
  107. package/components/ui/badge.tsx +52 -0
  108. package/components/ui/button.tsx +60 -0
  109. package/components/ui/card.tsx +103 -0
  110. package/components/ui/chart.tsx +356 -0
  111. package/components/ui/command.tsx +196 -0
  112. package/components/ui/dialog.tsx +157 -0
  113. package/components/ui/input-group.tsx +158 -0
  114. package/components/ui/input.tsx +20 -0
  115. package/components/ui/multi-select.tsx +161 -0
  116. package/components/ui/popover.tsx +90 -0
  117. package/components/ui/select.tsx +201 -0
  118. package/components/ui/separator.tsx +25 -0
  119. package/components/ui/sheet.tsx +135 -0
  120. package/components/ui/skeleton.tsx +13 -0
  121. package/components/ui/tabs.tsx +56 -0
  122. package/components/ui/textarea.tsx +18 -0
  123. package/components/ui/tooltip.tsx +66 -0
  124. package/hooks/use-desktop-sidebar.tsx +50 -0
  125. package/hooks/use-mobile-sidebar.tsx +37 -0
  126. package/index.ts +14 -0
  127. package/lib/api.ts +194 -0
  128. package/lib/builder-context.ts +60 -0
  129. package/lib/data-client.ts +68 -0
  130. package/lib/get-query-client.ts +25 -0
  131. package/lib/polpo-client.tsx +49 -0
  132. package/lib/tool-catalog.ts +234 -0
  133. package/lib/use-event-catalog.ts +28 -0
  134. package/lib/utils.ts +6 -0
  135. package/package.json +99 -0
@@ -0,0 +1,326 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Agent capabilities — one full-width card per modality
5
+ * (Text / Image / Audio / Video). Reads as "here's the model behind
6
+ * each capability". There is no on/off here: whether a capability is
7
+ * usable is governed per-tool in the Tools tab.
8
+ *
9
+ * Layout per card:
10
+ * [icon + name] [description]
11
+ * [model pickers grid]
12
+ *
13
+ * Each slot is a `<ModelSelectorCompact>` (ghost appearance) — an inline
14
+ * trigger that opens a command-palette dropdown (search + keyboard nav +
15
+ * cost tiers), no modal. On change we PATCH the matching
16
+ * agent field and `router.refresh()` so the server-rendered page picks
17
+ * up the new value.
18
+ *
19
+ * Catalogs:
20
+ * - chat fields (model, vision) → Vercel AI Gateway catalog
21
+ * (`/api/gateway/models`), fetched once here and shared.
22
+ * - media fields (image/video/voice) → `DEFAULT_MEDIA_MODELS`
23
+ * filtered by kind.
24
+ */
25
+
26
+ import { useEffect, useMemo, useState } from "react";
27
+ import { useRouter } from "next/navigation";
28
+ import { Image as ImageIcon, Video, Volume2, Type, Loader2 } from "lucide-react";
29
+ import type { AgentConfig } from "@polpo-ai/core";
30
+ import {
31
+ ModelSelectorCompact,
32
+ DEFAULT_MEDIA_MODELS,
33
+ DEFAULT_MEDIA_PROVIDERS,
34
+ DEFAULT_PROVIDERS,
35
+ type Model,
36
+ } from "@lumea-labs/llm";
37
+
38
+ /** The provider-entry shape both selectors accept (logo + colour). */
39
+ type Provider = (typeof DEFAULT_PROVIDERS)[number];
40
+
41
+ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
42
+
43
+ /** Free, local (no-key) text-to-speech engine. Not in the media catalog, so
44
+ * we surface it explicitly as the default TTS option. */
45
+ const EDGE_TTS_MODEL: Model = {
46
+ id: "edge/edge-tts",
47
+ name: "Edge TTS — free",
48
+ providerId: "edge",
49
+ kind: "voice",
50
+ };
51
+
52
+ /** Which agent field a model slot writes to, and how to resolve it. */
53
+ interface ModelSlot {
54
+ /** Short uppercase label, e.g. "GENERATION". */
55
+ label: string;
56
+ /** The AgentConfig key this slot patches, e.g. "image_model". */
57
+ field: keyof AgentConfig;
58
+ /** Effective value (agent override or default). */
59
+ value: string;
60
+ /** True when the value comes from agent config (not the default). */
61
+ configured: boolean;
62
+ /** Media catalog kind for resolution / selector filtering. */
63
+ kind: Model["kind"];
64
+ /** "chat" slots use the gateway catalog; "media" use DEFAULT_MEDIA_MODELS. */
65
+ catalog: "chat" | "media";
66
+ }
67
+
68
+ interface CapabilityCard {
69
+ name: "Text" | "Image" | "Audio" | "Video";
70
+ description: string;
71
+ icon: typeof ImageIcon;
72
+ slots: ModelSlot[];
73
+ }
74
+
75
+ interface GatewayModel {
76
+ id: string;
77
+ owned_by?: string;
78
+ }
79
+
80
+ /** Resolve a model id against DEFAULT_MEDIA_MODELS; synthesise a minimal
81
+ * entry (provider logo + raw name) when not in the catalog. */
82
+ function resolveMediaModel(modelId: string, kind: Model["kind"]): Model {
83
+ const lower = modelId.toLowerCase();
84
+ const exact = DEFAULT_MEDIA_MODELS.find((m) => m.id.toLowerCase() === lower);
85
+ if (exact) return exact;
86
+ const suffix = DEFAULT_MEDIA_MODELS.find(
87
+ (m) => lower.endsWith(`/${m.id.toLowerCase()}`) || lower.endsWith(m.id.toLowerCase()),
88
+ );
89
+ if (suffix) return suffix;
90
+ const slashIdx = modelId.indexOf("/");
91
+ const providerId = slashIdx > 0 ? modelId.slice(0, slashIdx) : "other";
92
+ const remainder = slashIdx > 0 ? modelId.slice(slashIdx + 1) : modelId;
93
+ return { id: modelId, name: remainder, providerId, kind };
94
+ }
95
+
96
+ /** A single selectable model slot — a clean badge trigger that opens a
97
+ * searchable model picker in a dialog. */
98
+ function ModelSlotPicker({
99
+ slot,
100
+ projectId,
101
+ agentName,
102
+ gatewayModels,
103
+ gatewayProviders,
104
+ }: {
105
+ slot: ModelSlot;
106
+ projectId: string;
107
+ agentName: string;
108
+ gatewayModels: Model[];
109
+ gatewayProviders: Provider[];
110
+ }) {
111
+ const router = useRouter();
112
+ const [saving, setSaving] = useState(false);
113
+ const [error, setError] = useState<string | null>(null);
114
+
115
+ const isChat = slot.catalog === "chat";
116
+ const providers = isChat ? gatewayProviders : DEFAULT_MEDIA_PROVIDERS;
117
+ let baseModels = isChat
118
+ ? gatewayModels
119
+ : DEFAULT_MEDIA_MODELS.filter((m) => m.kind === slot.kind);
120
+ // Edge TTS is a free, local (no-key) text-to-speech engine that isn't in
121
+ // the media catalog — surface it as a first-class, default option on the
122
+ // text-to-speech slot so it can be picked without a provider key.
123
+ if (slot.field === "tts_model") {
124
+ baseModels = [EDGE_TTS_MODEL, ...baseModels.filter((m) => m.id !== EDGE_TTS_MODEL.id)];
125
+ }
126
+ // Guarantee the current value renders in the trigger even when it's a
127
+ // custom / not-in-catalog id — synthesise a minimal entry and prepend it.
128
+ const models = baseModels.some((m) => m.id === slot.value)
129
+ ? baseModels
130
+ : [resolveMediaModel(slot.value, slot.kind), ...baseModels];
131
+
132
+ async function handleChange(modelId: string) {
133
+ setSaving(true);
134
+ setError(null);
135
+ try {
136
+ const res = await fetch(
137
+ `${API_URL}/v1/projects/${projectId}/data/v1/agents/${encodeURIComponent(agentName)}`,
138
+ {
139
+ method: "PATCH",
140
+ credentials: "include",
141
+ headers: { "Content-Type": "application/json" },
142
+ body: JSON.stringify({ [slot.field]: modelId }),
143
+ },
144
+ );
145
+ if (!res.ok) {
146
+ const body = await res.json().catch(() => ({}));
147
+ setError(body.error ?? `Update failed (${res.status})`);
148
+ setSaving(false);
149
+ return;
150
+ }
151
+ router.refresh();
152
+ } catch (err) {
153
+ setError((err as Error).message ?? "Update failed");
154
+ }
155
+ setSaving(false);
156
+ }
157
+
158
+ return (
159
+ <div className="flex min-w-0 flex-col gap-1">
160
+ <div className="flex items-center gap-4">
161
+ {/* Field label — what this model powers. */}
162
+ <span className="flex w-44 shrink-0 items-center gap-1.5 whitespace-nowrap text-[13px] font-medium text-foreground">
163
+ {slot.label}
164
+ {saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
165
+ </span>
166
+ {/* Bordered select-style trigger → reads as an editable control.
167
+ Opens a command palette (search + keyboard nav + cost tiers). */}
168
+ <div className="min-w-0 flex-1">
169
+ <ModelSelectorCompact
170
+ models={models}
171
+ value={slot.value}
172
+ onChange={handleChange}
173
+ providers={providers}
174
+ appearance="button"
175
+ showCost
176
+ activeFirst
177
+ className="w-full justify-between"
178
+ />
179
+ </div>
180
+ {!slot.configured && (
181
+ <span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground/50">
182
+ default
183
+ </span>
184
+ )}
185
+ </div>
186
+
187
+ {error && (
188
+ <span className="block pl-48 text-[11px] text-destructive" title={error}>
189
+ {error.length > 48 ? `${error.slice(0, 48)}…` : error}
190
+ </span>
191
+ )}
192
+ </div>
193
+ );
194
+ }
195
+
196
+ export function AgentCapabilities({
197
+ agent,
198
+ projectId,
199
+ agentName,
200
+ }: {
201
+ agent: AgentConfig;
202
+ projectId: string;
203
+ agentName: string;
204
+ }) {
205
+ // Gateway catalog — fetched once, shared by all chat slots (text + vision).
206
+ const [gatewayModels, setGatewayModels] = useState<Model[]>([]);
207
+ useEffect(() => {
208
+ let cancelled = false;
209
+ fetch("/api/gateway/models")
210
+ .then((r) => r.json())
211
+ .then((data) => {
212
+ if (cancelled) return;
213
+ const raw = (data.data ?? data ?? []) as GatewayModel[];
214
+ setGatewayModels(
215
+ raw
216
+ .filter((m) => m.id.includes("/"))
217
+ .map((m) => {
218
+ const [providerId, ...rest] = m.id.split("/");
219
+ return { id: m.id, name: rest.join("/"), providerId } as Model;
220
+ }),
221
+ );
222
+ })
223
+ .catch(() => {});
224
+ return () => {
225
+ cancelled = true;
226
+ };
227
+ }, []);
228
+
229
+ const gatewayProviders = useMemo<Provider[]>(() => {
230
+ const seen = new Set(DEFAULT_PROVIDERS.map((p) => p.id));
231
+ const extras = Array.from(
232
+ new Set(gatewayModels.map((m) => m.providerId).filter((p) => !seen.has(p))),
233
+ ).map((id) => ({ id, name: id, color: "#888" }) as Provider);
234
+ return [...DEFAULT_PROVIDERS, ...extras];
235
+ }, [gatewayModels]);
236
+
237
+ const slot = (
238
+ label: string,
239
+ field: keyof AgentConfig,
240
+ fallback: string,
241
+ kind: Model["kind"],
242
+ catalog: "chat" | "media",
243
+ ): ModelSlot => {
244
+ const configured = agent[field] as string | undefined;
245
+ return {
246
+ label,
247
+ field,
248
+ value: configured ?? fallback,
249
+ configured: !!configured,
250
+ kind,
251
+ catalog,
252
+ };
253
+ };
254
+
255
+ const cards: CapabilityCard[] = [
256
+ {
257
+ name: "Text",
258
+ description: "Core reasoning and chat — every agent talks and thinks in text.",
259
+ icon: Type,
260
+ slots: [slot("Generation model", "model", "xai/grok-4.1-fast-non-reasoning", "chat", "chat")],
261
+ },
262
+ {
263
+ name: "Image",
264
+ description: "Generate + analyze with vision models.",
265
+ icon: ImageIcon,
266
+ slots: [
267
+ slot("Generation model", "image_model", "fal/fal-ai/flux/dev", "image", "media"),
268
+ slot("Vision model", "vision_model", "openai/gpt-4o-mini", "chat", "chat"),
269
+ ],
270
+ },
271
+ {
272
+ name: "Audio",
273
+ description: "Transcribe and synthesize speech.",
274
+ icon: Volume2,
275
+ slots: [
276
+ slot("Speech-to-text model", "transcribe_model", "openai/whisper-1", "voice", "media"),
277
+ slot("Text-to-speech model", "tts_model", "edge/edge-tts", "voice", "media"),
278
+ ],
279
+ },
280
+ {
281
+ name: "Video",
282
+ description: "Generate videos from prompts.",
283
+ icon: Video,
284
+ slots: [slot("Generation model", "video_model", "fal/luma-ray-2-flash", "video", "media")],
285
+ },
286
+ ];
287
+
288
+ // Settings form — each modality is a titled section (name + one-line
289
+ // description) over its labelled model fields. No card chrome; the bordered
290
+ // pickers read as editable controls.
291
+ return (
292
+ <div className="flex flex-col divide-y divide-border">
293
+ {cards.map((card) => {
294
+ const Icon = card.icon;
295
+ return (
296
+ <div key={card.name} className="py-7 first:pt-0 last:pb-0">
297
+ {/* Section header: icon + name + what it's for. The divider sits
298
+ after the whole block (via divide-y), not under the title. */}
299
+ <div className="mb-4">
300
+ <div className="flex items-center gap-2">
301
+ <Icon className="h-[18px] w-[18px] shrink-0 text-foreground" strokeWidth={2} />
302
+ <span className="text-base font-bold tracking-tight text-foreground">
303
+ {card.name}
304
+ </span>
305
+ </div>
306
+ <p className="mt-1.5 text-xs text-muted-foreground">{card.description}</p>
307
+ </div>
308
+ {/* Fields */}
309
+ <div className="flex flex-col gap-2.5">
310
+ {card.slots.map((s) => (
311
+ <ModelSlotPicker
312
+ key={s.field as string}
313
+ slot={s}
314
+ projectId={projectId}
315
+ agentName={agentName}
316
+ gatewayModels={gatewayModels}
317
+ gatewayProviders={gatewayProviders}
318
+ />
319
+ ))}
320
+ </div>
321
+ </div>
322
+ );
323
+ })}
324
+ </div>
325
+ );
326
+ }
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from "react";
2
+ import { Users } from "lucide-react";
3
+
4
+ /**
5
+ * Shared agent identity header — monogram + name + optional team badge +
6
+ * role, with a right-aligned actions slot. One implementation used by the
7
+ * agent detail page layout AND the builder's right pane, so the two never
8
+ * diverge (DRY). Presentational only (no hooks) → safe in server + client.
9
+ */
10
+ export function AgentIdentityHeader({
11
+ name,
12
+ role,
13
+ team,
14
+ actions,
15
+ }: {
16
+ name: string;
17
+ role?: string | null;
18
+ team?: string | null;
19
+ actions?: ReactNode;
20
+ }) {
21
+ const initials = name.slice(0, 2).toUpperCase();
22
+ return (
23
+ <div className="flex items-start gap-3">
24
+ <div className="grid size-10 shrink-0 place-items-center bg-foreground font-mono text-xs font-bold text-background">
25
+ {initials}
26
+ </div>
27
+ <div className="min-w-0 flex-1">
28
+ <div className="flex flex-wrap items-center gap-2">
29
+ <h2 className="font-mono text-base font-extrabold tracking-tight">
30
+ {name}
31
+ </h2>
32
+ {team && (
33
+ <span className="inline-flex shrink-0 items-center gap-1 border border-border bg-secondary px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-muted-foreground">
34
+ <Users className="h-3 w-3" strokeWidth={2} />
35
+ {team}
36
+ </span>
37
+ )}
38
+ </div>
39
+ <p
40
+ className={`mt-0.5 text-sm italic ${role ? "text-muted-foreground" : "text-muted-foreground/40"}`}
41
+ >
42
+ {role || "No role set"}
43
+ </p>
44
+ </div>
45
+ {actions && (
46
+ <div className="flex shrink-0 items-center gap-2">{actions}</div>
47
+ )}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,195 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Agent model picker — shows the current model as a `<ModelBadge>`
5
+ * with an edit button. Click → swap the badge for
6
+ * `<ModelSelectorCompact>` to change. On selection (or blur/escape),
7
+ * collapses back to badge.
8
+ *
9
+ * Catalog: pulled from `/api/gateway/models` (Vercel AI Gateway
10
+ * proxy). Each entry's `id` is `<provider>/<name>` — we split on `/`
11
+ * to derive providerId + name and reuse `DEFAULT_PROVIDERS` for the
12
+ * logo tiles.
13
+ *
14
+ * Persist: `PATCH /v1/projects/:id/data/v1/agents/:name` with
15
+ * `{ model }`. On success we `router.refresh()` so the layout's
16
+ * server-fetched `agent.model` updates without a hard reload.
17
+ */
18
+
19
+ import { useEffect, useMemo, useState } from "react";
20
+ import { useRouter } from "next/navigation";
21
+ import { Loader2, Pencil } from "lucide-react";
22
+ import {
23
+ ModelBadge,
24
+ ModelSelectorCompact,
25
+ DEFAULT_PROVIDERS,
26
+ type Model,
27
+ } from "@lumea-labs/llm";
28
+
29
+ interface GatewayModel {
30
+ id: string;
31
+ owned_by?: string;
32
+ context_window?: number;
33
+ }
34
+
35
+ interface Props {
36
+ projectId: string;
37
+ agentName: string;
38
+ currentModel: string | undefined;
39
+ }
40
+
41
+ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
42
+
43
+ export function AgentModelPicker({ projectId, agentName, currentModel }: Props) {
44
+ const router = useRouter();
45
+ const [models, setModels] = useState<Model[]>([]);
46
+ const [editing, setEditing] = useState(false);
47
+ const [saving, setSaving] = useState(false);
48
+ const [error, setError] = useState<string | null>(null);
49
+
50
+ useEffect(() => {
51
+ let cancelled = false;
52
+ fetch("/api/gateway/models")
53
+ .then((r) => r.json())
54
+ .then((data) => {
55
+ if (cancelled) return;
56
+ const raw = (data.data ?? data ?? []) as GatewayModel[];
57
+ const mapped: Model[] = raw
58
+ .filter((m) => m.id.includes("/"))
59
+ .map((m) => {
60
+ const [providerId, ...rest] = m.id.split("/");
61
+ return {
62
+ id: m.id,
63
+ name: rest.join("/"),
64
+ providerId,
65
+ };
66
+ });
67
+ setModels(mapped);
68
+ })
69
+ .catch(() => {});
70
+ return () => {
71
+ cancelled = true;
72
+ };
73
+ }, []);
74
+
75
+ // Derive providers from the catalog so brand new ones (e.g.
76
+ // "fireworks/...") show up too. Falls back to DEFAULT_PROVIDERS
77
+ // for the known ones (logos, colours).
78
+ const providers = useMemo(() => {
79
+ const seen = new Set(DEFAULT_PROVIDERS.map((p) => p.id));
80
+ const extras = Array.from(
81
+ new Set(models.map((m) => m.providerId).filter((p) => !seen.has(p))),
82
+ ).map((id) => ({
83
+ id,
84
+ name: id,
85
+ color: "#888",
86
+ }));
87
+ return [...DEFAULT_PROVIDERS, ...extras];
88
+ }, [models]);
89
+
90
+ // Resolve the currently selected model into the Model shape needed
91
+ // by <ModelBadge>. If it's not in the gateway catalog (e.g. custom
92
+ // provider), build a minimal entry on the fly so the badge still
93
+ // renders the raw id without crashing.
94
+ const currentModelEntry = useMemo<Model | null>(() => {
95
+ if (!currentModel) return null;
96
+ const found = models.find((m) => m.id === currentModel);
97
+ if (found) return found;
98
+ const [providerId, ...rest] = currentModel.split("/");
99
+ return {
100
+ id: currentModel,
101
+ name: rest.length > 0 ? rest.join("/") : currentModel,
102
+ providerId: providerId ?? "other",
103
+ };
104
+ }, [currentModel, models]);
105
+
106
+ async function handleChange(modelId: string) {
107
+ setSaving(true);
108
+ setError(null);
109
+ try {
110
+ const res = await fetch(
111
+ `${API_URL}/v1/projects/${projectId}/data/v1/agents/${encodeURIComponent(agentName)}`,
112
+ {
113
+ method: "PATCH",
114
+ credentials: "include",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify({ model: modelId }),
117
+ },
118
+ );
119
+ if (!res.ok) {
120
+ const body = await res.json().catch(() => ({}));
121
+ setError(body.error ?? `Update failed (${res.status})`);
122
+ setSaving(false);
123
+ return;
124
+ }
125
+ // Collapse back to badge once the new model is committed.
126
+ setEditing(false);
127
+ router.refresh();
128
+ } catch (err) {
129
+ setError((err as Error).message ?? "Update failed");
130
+ }
131
+ setSaving(false);
132
+ }
133
+
134
+ if (models.length === 0) {
135
+ return (
136
+ <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground/40 font-mono">
137
+ <Loader2 className="h-3 w-3 animate-spin" />
138
+ loading models…
139
+ </span>
140
+ );
141
+ }
142
+
143
+ // Editing mode — full selector with cancel.
144
+ if (editing) {
145
+ return (
146
+ <div className="inline-flex items-center gap-2">
147
+ <ModelSelectorCompact
148
+ models={models}
149
+ value={currentModel}
150
+ onChange={handleChange}
151
+ providers={providers}
152
+ appearance="ghost"
153
+ showProvider
154
+ showLogo
155
+ />
156
+ {saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
157
+ <button
158
+ type="button"
159
+ onClick={() => {
160
+ setEditing(false);
161
+ setError(null);
162
+ }}
163
+ className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
164
+ >
165
+ Cancel
166
+ </button>
167
+ {error && (
168
+ <span className="text-[11px] text-destructive" title={error}>
169
+ {error.length > 40 ? `${error.slice(0, 40)}…` : error}
170
+ </span>
171
+ )}
172
+ </div>
173
+ );
174
+ }
175
+
176
+ // Default mode — read-only badge + edit button.
177
+ return (
178
+ <div className="inline-flex items-center gap-1.5">
179
+ {currentModelEntry ? (
180
+ <ModelBadge model={currentModelEntry} providers={providers} showLogo />
181
+ ) : (
182
+ <span className="text-xs text-muted-foreground/40 italic">no model set</span>
183
+ )}
184
+ <button
185
+ type="button"
186
+ onClick={() => setEditing(true)}
187
+ aria-label="Change model"
188
+ title="Change model"
189
+ className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground"
190
+ >
191
+ <Pencil className="h-3 w-3" />
192
+ </button>
193
+ </div>
194
+ );
195
+ }
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { ChevronDown } from "lucide-react";
5
+ import type { AgentConfig } from "@polpo-ai/core";
6
+
7
+ /**
8
+ * Agent picker with the model shown on a second line inside the same
9
+ * control. One bordered block, two-line items: agent display name on
10
+ * top, model id (monospace, muted) below. Selected item gets a brand
11
+ * accent bar in the dropdown.
12
+ *
13
+ * Shared between the /playground page and the inline playground tab
14
+ * inside the onboarding checklist.
15
+ */
16
+ export function AgentModelSelector({
17
+ agents,
18
+ selected,
19
+ onSelect,
20
+ }: {
21
+ agents: AgentConfig[];
22
+ selected: string | undefined;
23
+ onSelect: (name: string) => void;
24
+ }) {
25
+ const [open, setOpen] = useState(false);
26
+ const rootRef = useRef<HTMLDivElement | null>(null);
27
+
28
+ useEffect(() => {
29
+ if (!open) return;
30
+ function handleClick(e: MouseEvent) {
31
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
32
+ setOpen(false);
33
+ }
34
+ }
35
+ document.addEventListener("mousedown", handleClick);
36
+ return () => document.removeEventListener("mousedown", handleClick);
37
+ }, [open]);
38
+
39
+ const selectedConfig =
40
+ agents.find((a) => a.name === selected) ?? agents[0];
41
+
42
+ return (
43
+ <div ref={rootRef} className="relative">
44
+ <button
45
+ type="button"
46
+ onClick={() => setOpen((o) => !o)}
47
+ className="group flex items-center gap-3 border border-border bg-card px-3 py-2 text-left transition-colors hover:border-foreground/30"
48
+ >
49
+ <AgentAvatar name={selectedConfig?.name ?? "?"} />
50
+ <AgentModelPair
51
+ name={selectedConfig?.identity?.displayName ?? selectedConfig?.name ?? "—"}
52
+ model={selectedConfig?.model}
53
+ />
54
+ <ChevronDown
55
+ className={`ml-2 size-3.5 text-muted-foreground transition-transform ${
56
+ open ? "rotate-180" : ""
57
+ }`}
58
+ />
59
+ </button>
60
+
61
+ {open && (
62
+ <div className="absolute right-0 top-full z-50 mt-1 min-w-[240px] border border-border bg-card py-1 shadow-lg">
63
+ {agents.map((a) => {
64
+ const isSelected = a.name === selected;
65
+ return (
66
+ <button
67
+ key={a.name}
68
+ type="button"
69
+ onClick={() => {
70
+ onSelect(a.name);
71
+ setOpen(false);
72
+ }}
73
+ className={`flex w-full items-center gap-3 border-l-2 px-3 py-2 text-left transition-colors hover:bg-accent ${
74
+ isSelected
75
+ ? "border-l-brand bg-accent/40"
76
+ : "border-l-transparent"
77
+ }`}
78
+ >
79
+ <AgentAvatar name={a.name} />
80
+ <AgentModelPair
81
+ name={a.identity?.displayName ?? a.name}
82
+ model={a.model}
83
+ />
84
+ </button>
85
+ );
86
+ })}
87
+ </div>
88
+ )}
89
+ </div>
90
+ );
91
+ }
92
+
93
+ function AgentModelPair({
94
+ name,
95
+ model,
96
+ }: {
97
+ name: string;
98
+ model?: string;
99
+ }) {
100
+ return (
101
+ <div className="flex min-w-0 flex-col">
102
+ <span className="truncate text-sm font-medium leading-tight text-foreground">
103
+ {name}
104
+ </span>
105
+ {model && (
106
+ <span className="mt-0.5 truncate font-mono text-[10px] leading-none text-muted-foreground">
107
+ {model}
108
+ </span>
109
+ )}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function AgentAvatar({ name }: { name: string }) {
115
+ const letter = name.charAt(0).toUpperCase();
116
+ return (
117
+ <span className="flex size-6 shrink-0 items-center justify-center border border-border bg-background text-[10px] font-semibold text-foreground">
118
+ {letter}
119
+ </span>
120
+ );
121
+ }