@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.
- package/LICENSE +13 -0
- package/README.md +44 -0
- package/app/(dashboard)/error.tsx +47 -0
- package/app/(dashboard)/layout.tsx +41 -0
- package/app/(dashboard)/loading.tsx +30 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/memory/view.tsx +36 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/models-view.tsx +30 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/page.tsx +51 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/prompt/view.tsx +211 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/skills/view.tsx +84 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/tools/view.tsx +525 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/vault/view.tsx +61 -0
- package/app/(dashboard)/projects/[id]/agents/page.tsx +39 -0
- package/app/(dashboard)/projects/[id]/agents/view.tsx +283 -0
- package/app/(dashboard)/projects/[id]/layout.tsx +11 -0
- package/app/(dashboard)/projects/[id]/logs/page.tsx +27 -0
- package/app/(dashboard)/projects/[id]/logs/view.tsx +184 -0
- package/app/(dashboard)/projects/[id]/memory/page.tsx +40 -0
- package/app/(dashboard)/projects/[id]/memory/view.tsx +17 -0
- package/app/(dashboard)/projects/[id]/missions/[missionId]/page.tsx +88 -0
- package/app/(dashboard)/projects/[id]/missions/[missionId]/view.tsx +569 -0
- package/app/(dashboard)/projects/[id]/missions/page.tsx +32 -0
- package/app/(dashboard)/projects/[id]/missions/view.tsx +282 -0
- package/app/(dashboard)/projects/[id]/onboarding-checklist.tsx +359 -0
- package/app/(dashboard)/projects/[id]/page.tsx +68 -0
- package/app/(dashboard)/projects/[id]/playbooks/[name]/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/playbooks/[name]/view.tsx +433 -0
- package/app/(dashboard)/projects/[id]/playbooks/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/playbooks/view.tsx +222 -0
- package/app/(dashboard)/projects/[id]/schedules/page.tsx +44 -0
- package/app/(dashboard)/projects/[id]/schedules/view.tsx +385 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/page.tsx +55 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/raw-view.tsx +373 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/view.tsx +124 -0
- package/app/(dashboard)/projects/[id]/sessions/page.tsx +31 -0
- package/app/(dashboard)/projects/[id]/sessions/view.tsx +242 -0
- package/app/(dashboard)/projects/[id]/settings/page.tsx +52 -0
- package/app/(dashboard)/projects/[id]/skills/[name]/page.tsx +65 -0
- package/app/(dashboard)/projects/[id]/skills/[name]/view.tsx +227 -0
- package/app/(dashboard)/projects/[id]/skills/page.tsx +31 -0
- package/app/(dashboard)/projects/[id]/skills/view.tsx +243 -0
- package/app/(dashboard)/projects/[id]/storage/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/storage/view.tsx +385 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view.tsx +336 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view.tsx +113 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint.ts +72 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view.tsx +90 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/page.tsx +63 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/view.tsx +137 -0
- package/app/(dashboard)/projects/[id]/tasks/page.tsx +40 -0
- package/app/(dashboard)/projects/[id]/tasks/view.tsx +421 -0
- package/app/(dashboard)/projects/[id]/view.tsx +421 -0
- package/app/(dashboard)/projects/[id]/welcome-banner.tsx +156 -0
- package/app/(dashboard)/projects/projects-list.tsx +126 -0
- package/app/(playground)/error.tsx +42 -0
- package/app/(playground)/layout.tsx +27 -0
- package/app/(playground)/projects/[id]/playground/page.tsx +57 -0
- package/app/(playground)/projects/[id]/playground/view.tsx +167 -0
- package/app/(playground)/projects/[id]/playground-legacy/page.tsx +47 -0
- package/app/(playground)/projects/[id]/playground-legacy/skeleton.tsx +63 -0
- package/app/(playground)/projects/[id]/playground-legacy/view.tsx +237 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +385 -0
- package/app/icon.svg +6 -0
- package/app/layout.tsx +39 -0
- package/app/page.tsx +11 -0
- package/components/ai-elements/code-block.tsx +562 -0
- package/components/dashboard/agent-capabilities.tsx +326 -0
- package/components/dashboard/agent-identity-header.tsx +50 -0
- package/components/dashboard/agent-model-picker.tsx +195 -0
- package/components/dashboard/agent-model-selector.tsx +121 -0
- package/components/dashboard/agent-picker-card.tsx +46 -0
- package/components/dashboard/agent-studio.tsx +242 -0
- package/components/dashboard/agents-table.tsx +48 -0
- package/components/dashboard/breadcrumb.tsx +70 -0
- package/components/dashboard/builder-chat.tsx +418 -0
- package/components/dashboard/chat-shell.tsx +40 -0
- package/components/dashboard/client-picker.tsx +63 -0
- package/components/dashboard/command-snippet.tsx +77 -0
- package/components/dashboard/connect-dialog.tsx +680 -0
- package/components/dashboard/copy-button.tsx +45 -0
- package/components/dashboard/copy-card.tsx +48 -0
- package/components/dashboard/file-browser.tsx +161 -0
- package/components/dashboard/hint.tsx +22 -0
- package/components/dashboard/inference-mode.tsx +15 -0
- package/components/dashboard/manual-refresh-button.tsx +52 -0
- package/components/dashboard/markdown.tsx +31 -0
- package/components/dashboard/mcp-install-panel.tsx +196 -0
- package/components/dashboard/mission-graph.tsx +262 -0
- package/components/dashboard/nav-tabs.tsx +86 -0
- package/components/dashboard/polpo-chat.tsx +468 -0
- package/components/dashboard/project-copilot.tsx +253 -0
- package/components/dashboard/sdk-snippet-panel.tsx +194 -0
- package/components/dashboard/section-header.tsx +37 -0
- package/components/dashboard/settings-form.tsx +2477 -0
- package/components/dashboard/sidebar.tsx +385 -0
- package/components/dashboard/skeletons.tsx +1054 -0
- package/components/dashboard/skills-install-wizard.tsx +273 -0
- package/components/dashboard/swarm-runs.tsx +316 -0
- package/components/dashboard/tab-toggle.tsx +35 -0
- package/components/dashboard/task-studio.tsx +224 -0
- package/components/dashboard/top-header.tsx +203 -0
- package/components/dashboard/webhook-deliveries.tsx +234 -0
- package/components/icons/coding-agents.tsx +151 -0
- package/components/json-ld.tsx +8 -0
- package/components/providers.tsx +25 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +60 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/chart.tsx +356 -0
- package/components/ui/command.tsx +196 -0
- package/components/ui/dialog.tsx +157 -0
- package/components/ui/input-group.tsx +158 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/multi-select.tsx +161 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/select.tsx +201 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +135 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/tabs.tsx +56 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/hooks/use-desktop-sidebar.tsx +50 -0
- package/hooks/use-mobile-sidebar.tsx +37 -0
- package/index.ts +14 -0
- package/lib/api.ts +194 -0
- package/lib/builder-context.ts +60 -0
- package/lib/data-client.ts +68 -0
- package/lib/get-query-client.ts +25 -0
- package/lib/polpo-client.tsx +49 -0
- package/lib/tool-catalog.ts +234 -0
- package/lib/use-event-catalog.ts +28 -0
- package/lib/utils.ts +6 -0
- 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
|
+
}
|