pi-feats 0.1.4 → 0.1.7
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/README.md +90 -9
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-log-store.ts +4 -2
- package/extensions/api-server/application-runtime.ts +79 -8
- package/extensions/api-server/application-store.ts +23 -10
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +109 -39
- package/extensions/api-server/server.ts +220 -44
- package/extensions/cli-resources.ts +144 -113
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/conversation-search/index.ts +42 -0
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/builtin-tools.ts +24 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/conversation-search.ts +33 -0
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +14 -3
- package/extensions/lib/remote-hosts.ts +7 -5
- package/extensions/lib/workflow-evaluator.ts +113 -0
- package/extensions/lib/workflow-harness.ts +409 -43
- package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
- package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
- package/extensions/pi-console-webui/app/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
- package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
- package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
- package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
- package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
- package/extensions/pi-console-webui/components/console-page.tsx +4 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +32 -11
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
- package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +7 -6
- package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
- package/extensions/pi-console-webui/index.ts +2 -1
- package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
- package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
- package/extensions/pi-console-webui/next.config.js +1 -0
- package/extensions/profile-ssh.ts +274 -0
- package/extensions/profiles.ts +69 -25
- package/extensions/pulse/index.ts +148 -16
- package/extensions/pulse/store.ts +132 -18
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +21 -9
- package/scripts/build-console-webui.mjs +7 -0
- package/scripts/console-webui-cache.mjs +79 -0
- package/scripts/ensure-console-build.mjs +43 -0
- package/skills/profile-ssh/SKILL.md +65 -0
- package/skills/sequential-workflow/SKILL.md +4 -3
- package/themes/legacy-dark.json +85 -0
|
@@ -11,7 +11,7 @@ import { json as jsonLanguage } from "@codemirror/lang-json";
|
|
|
11
11
|
import { javascript } from "@codemirror/lang-javascript";
|
|
12
12
|
import { HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
|
|
13
13
|
import { tags } from "@lezer/highlight";
|
|
14
|
-
import { Activity, Bot, Box, Check, ChevronDown, Cog, Copy, Eye, FileCode2, GitBranch, Loader2, LogOut, MessageSquare, MoreHorizontal, Play, Plus, RotateCw, Save, Search, Settings2, ShieldCheck, Sparkles, Terminal, Trash2, UserRound, Wrench, X } from "lucide-react";
|
|
14
|
+
import { Activity, Bot, Box, Check, ChevronDown, Cog, Copy, Eye, FileCode2, GitBranch, Loader2, LogOut, MessageSquare, MoreHorizontal, Pencil, Play, Plus, RotateCw, Save, Search, Settings2, ShieldCheck, Sparkles, Terminal, Trash2, UserRound, Wrench, X } from "lucide-react";
|
|
15
15
|
import { Button } from "@/components/ui/button";
|
|
16
16
|
import { Card, CardTitle } from "@/components/ui/card";
|
|
17
17
|
import { Input } from "@/components/ui/input";
|
|
@@ -26,16 +26,19 @@ import { ChatWorkspace } from "@/components/chat-workspace";
|
|
|
26
26
|
import { useConsoleState } from "@/components/console-state";
|
|
27
27
|
import { useToast } from "@/components/toast";
|
|
28
28
|
import { AdminConfigForm } from "@/components/admin-config-form";
|
|
29
|
+
import { ModelsConfigForm } from "@/components/models-config-form";
|
|
30
|
+
import { ProfileModelForm } from "@/components/profile-model-form";
|
|
29
31
|
import { GuardrailsPanel } from "@/components/guardrails-panel";
|
|
30
32
|
import { SkillResourcesPanel } from "@/components/skill-resources";
|
|
31
33
|
import { SkillSources } from "@/components/skill-sources";
|
|
32
34
|
import { PackageResources } from "@/components/package-resources";
|
|
33
35
|
import { PulseResources } from "@/components/pulse-resources";
|
|
36
|
+
import { SequentialWorkflowConfig } from "@/components/sequential-workflow-config";
|
|
34
37
|
import { applicationHandlerTemplate } from "../../lib/application-handler-templates";
|
|
35
38
|
|
|
36
|
-
type Profile = { name: string; path: string };
|
|
39
|
+
type Profile = { name: string; path: string; description: string; tags: string[] };
|
|
37
40
|
type Resource = { name: string; kind: string; path?: string; source: string; package?: string; enabled: boolean; protected?: boolean };
|
|
38
|
-
export type ConsoleInitialData = { settings?: { settings: unknown }; env?: { content: string; exists: boolean }; document?: { content: string }; guardrails?: { content: string }; resources?: { resources: Resource[] }; packages?: { packages: Array<{ name: string; version?: string; description?: string; enabled: boolean; installed: boolean }> }; pulses?: { pulses: Array<{ id: string; name: string; description: string; type: "cron" | "heartbeat"; schedule: string; prompt: string; thread_session_id: string; result: string | null; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null }> }; adminConfig?: Record<string, unknown>; error?: string };
|
|
41
|
+
export type ConsoleInitialData = { settings?: { settings: unknown }; env?: { content: string; exists: boolean }; document?: { content: string }; guardrails?: { content: string }; resources?: { resources: Resource[] }; packages?: { packages: Array<{ name: string; version?: string; description?: string; enabled: boolean; installed: boolean }> }; pulses?: { pulses: Array<{ id: string; name: string; description: string; type: "cron" | "heartbeat"; schedule: string; prompt: string; thread_session_id: string; insertIntoApiSession: boolean; result: string | null; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null }> }; sequentialWorkflows?: { evaluation: { modelType: "llm" } | { modelType: "system_one"; systemOne: { provider: string; model: string; onUncertain: "block" } } }; adminConfig?: Record<string, unknown>; models?: string; profileModel?: { providers: Array<{ id: string; models: Array<{ id: string; name: string }> }>; settings?: Record<string, unknown> }; error?: string };
|
|
39
42
|
const request = async (path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message ?? body.error ?? "Request failed"); } return response; };
|
|
40
43
|
const getJson = async <T,>(path: string, init?: RequestInit) => (await request(path, init)).json() as Promise<T>;
|
|
41
44
|
type EnvState = { value: boolean };
|
|
@@ -92,9 +95,22 @@ function Resources({ profile, kind, initial }: { profile: string; kind: "skills"
|
|
|
92
95
|
|
|
93
96
|
function AdminConfig({ target, title, initial }: { target: "api-server" | "pi-console-webui"; title: string; initial?: Record<string, unknown> }) { const { toast } = useToast(); const [value, setValue] = useState(() => JSON.stringify(initial ?? {}, null, 2)); const save = async () => { try { await fetch(`/api/admin/config/${target}`, { method: "PUT", headers: { "content-type": "application/json" }, body: value }).then(async (response) => { if (!response.ok) throw new Error((await response.json()).error ?? "Unable to save configuration"); }); toast("Configuration saved."); } catch (cause) { toast((cause as Error).message, "error"); } }; const restart = async () => { try { const response = await fetch(`/api/admin/services/${target}/restart`, { method: "POST" }); if (!response.ok) throw new Error("Unable to restart service"); toast("Service restart requested."); } catch (cause) { toast((cause as Error).message, "error"); } }; return <Card><div className="flex items-center justify-between gap-3"><div><CardTitle>{title}</CardTitle><p className="text-sm text-zinc-500">The API token is redacted and preserved when saved.</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Restart service" onClick={restart}><RotateCw size={17}/></Button><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save configuration" onClick={save}><Save size={17}/></Button></div></div><div className="mt-4 overflow-hidden rounded-lg border border-zinc-200"><CodeMirror value={value} height="520px" extensions={[jsonLanguage()]} onChange={setValue}/></div></Card>; }
|
|
94
97
|
|
|
95
|
-
const profileColors = ["bg-
|
|
96
|
-
const
|
|
98
|
+
const profileColors = ["bg-sky-500", "bg-emerald-500", "bg-pink-500", "bg-amber-500", "bg-violet-500", "bg-cyan-500", "bg-fuchsia-500", "bg-rose-500"];
|
|
99
|
+
const tagColors = [
|
|
100
|
+
{ normal: "border-sky-200 bg-sky-50 text-sky-800", selected: "border-sky-600 bg-sky-600 text-white" },
|
|
101
|
+
{ normal: "border-emerald-200 bg-emerald-50 text-emerald-800", selected: "border-emerald-600 bg-emerald-600 text-white" },
|
|
102
|
+
{ normal: "border-pink-200 bg-pink-50 text-pink-800", selected: "border-pink-600 bg-pink-600 text-white" },
|
|
103
|
+
{ normal: "border-amber-200 bg-amber-50 text-amber-800", selected: "border-amber-600 bg-amber-600 text-white" },
|
|
104
|
+
{ normal: "border-violet-200 bg-violet-50 text-violet-800", selected: "border-violet-600 bg-violet-600 text-white" },
|
|
105
|
+
{ normal: "border-cyan-200 bg-cyan-50 text-cyan-800", selected: "border-cyan-600 bg-cyan-600 text-white" },
|
|
106
|
+
{ normal: "border-fuchsia-200 bg-fuchsia-50 text-fuchsia-800", selected: "border-fuchsia-600 bg-fuchsia-600 text-white" },
|
|
107
|
+
{ normal: "border-rose-200 bg-rose-50 text-rose-800", selected: "border-rose-600 bg-rose-600 text-white" },
|
|
108
|
+
];
|
|
109
|
+
const colorIndex = (value: string, length: number) => [...value].reduce((sum, char) => sum + char.charCodeAt(0), 0) % length;
|
|
110
|
+
const profileColor = (name: string) => profileColors[colorIndex(name, profileColors.length)];
|
|
111
|
+
const tagColor = (tag: string, selected = false) => tagColors[colorIndex(tag.toLocaleLowerCase(), tagColors.length)][selected ? "selected" : "normal"];
|
|
97
112
|
function ProfileAvatar({ name, size = "size-7" }: { name: string; size?: string }) { return <span className={`grid ${size} shrink-0 place-items-center rounded-full ${profileColor(name)} text-xs font-semibold text-white`}>{name.slice(0, 1).toUpperCase()}</span>; }
|
|
113
|
+
function ProfileTagsEditor({ tags, onChange }: { tags: string[]; onChange: (tags: string[]) => void }) { const [value, setValue] = useState(""), inputRef = useRef<HTMLInputElement>(null); const add = () => { const additions = value.split(",").map((tag) => tag.trim()).filter(Boolean); if (additions.length) onChange([...new Map([...tags, ...additions].map((tag) => [tag.toLocaleLowerCase(), tag])).values()]); setValue(""); }; return <div className="rounded-md border border-zinc-200 p-2"><div className="flex flex-wrap gap-1.5">{tags.map((tag) => <span key={tag} className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs ${tagColor(tag)}`}>{tag}<button type="button" title={`Remove ${tag}`} onClick={() => onChange(tags.filter((item) => item !== tag))}><X size={12}/></button></span>)}</div><Input ref={inputRef} className="mt-2 h-8 border-0 p-0 shadow-none focus-visible:ring-0" value={value} onChange={(event) => setValue(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); add(); } if (event.key === "Tab" && value.trim()) { event.preventDefault(); add(); requestAnimationFrame(() => inputRef.current?.focus()); } }} onBlur={add} placeholder="Type a tag and press Enter"/></div>; }
|
|
98
114
|
|
|
99
115
|
type ContextMemoryItem = { id: string; label: string; kind: "operational" | "profile" | "handler"; application?: string; path?: string; content?: string };
|
|
100
116
|
function ContextMemory({ profile }: { profile: string }) {
|
|
@@ -119,6 +135,7 @@ function ContextMemory({ profile }: { profile: string }) {
|
|
|
119
135
|
const navigation = [
|
|
120
136
|
{ href: "/", label: "Overview", icon: Activity, color: "text-slate-600" },
|
|
121
137
|
{ href: "/settings", label: "Settings", icon: Settings2, color: "text-amber-600" },
|
|
138
|
+
{ href: "/model", label: "Model", icon: Bot, color: "text-violet-600" },
|
|
122
139
|
{ href: "/env", label: "Environment", icon: Wrench, color: "text-lime-600" },
|
|
123
140
|
{ href: "/soul", label: "SOUL", icon: Sparkles, color: "text-purple-600" },
|
|
124
141
|
{ href: "/context-memory", label: "Context Memory", icon: UserRound, color: "text-indigo-600" },
|
|
@@ -129,10 +146,12 @@ const navigation = [
|
|
|
129
146
|
{ href: "/extensions", label: "Extensions", icon: FileCode2, color: "text-orange-600" },
|
|
130
147
|
{ href: "/chat", label: "Chat", icon: MessageSquare, color: "text-blue-600" },
|
|
131
148
|
{ href: "/pulses", label: "Pulses", icon: Activity, color: "text-rose-600" },
|
|
132
|
-
{ href: "/
|
|
133
|
-
{ href: "/pi-console-webui", label: "Pi Console WebUI", icon: Cog, color: "text-slate-600", admin: true },
|
|
149
|
+
{ href: "/sequential-workflows", label: "Sequential Workflows", icon: GitBranch, color: "text-violet-600" },
|
|
134
150
|
{ href: "/applications", label: "Applications", icon: Box, color: "text-emerald-600", admin: true },
|
|
151
|
+
{ href: "/models", label: "Models", icon: Bot, color: "text-violet-600", admin: true },
|
|
135
152
|
{ href: "/skill-sources", label: "Skill Sources", icon: GitBranch, color: "text-[#d7c900]", admin: true },
|
|
153
|
+
{ href: "/api-server", label: "API Server", icon: Wrench, color: "text-indigo-600", admin: true },
|
|
154
|
+
{ href: "/pi-console-webui", label: "Pi Console WebUI", icon: Cog, color: "text-slate-600", admin: true },
|
|
136
155
|
] as const;
|
|
137
156
|
|
|
138
157
|
export function Console({ section = "overview", applicationSlug, initial = {} }: { section?: string; applicationSlug?: string; initial?: ConsoleInitialData }) {
|
|
@@ -140,17 +159,19 @@ export function Console({ section = "overview", applicationSlug, initial = {} }:
|
|
|
140
159
|
const requestedProfile = searchParams.get("profile") ?? "default";
|
|
141
160
|
const administrative = searchParams.get("context") === "admin";
|
|
142
161
|
const { profiles, health, error, initialize, refreshProfiles } = useConsoleState();
|
|
143
|
-
const [profile, setProfile] = useState(requestedProfile), [profileMenu, setProfileMenu] = useState(false), [profileFilterInput, setProfileFilterInput] = useState(""), [profileFilter, setProfileFilter] = useState(""), [createDialog, setCreateDialog] = useState(false), [newProfile, setNewProfile] = useState(""), [profileError, setProfileError] = useState(""), [creatingProfile, setCreatingProfile] = useState(false);
|
|
162
|
+
const [profile, setProfile] = useState(requestedProfile), [profileMenu, setProfileMenu] = useState(false), [profileFilterInput, setProfileFilterInput] = useState(""), [profileFilter, setProfileFilter] = useState(""), [selectedProfileTags, setSelectedProfileTags] = useState<string[]>([]), [createDialog, setCreateDialog] = useState(false), [editProfile, setEditProfile] = useState<Profile>(), [newProfile, setNewProfile] = useState(""), [newProfileDescription, setNewProfileDescription] = useState(""), [newProfileTags, setNewProfileTags] = useState<string[]>([]), [cloneProfile, setCloneProfile] = useState(false), [cloneFrom, setCloneFrom] = useState(""), [profileDescription, setProfileDescription] = useState(""), [profileTags, setProfileTags] = useState<string[]>([]), [profileError, setProfileError] = useState(""), [creatingProfile, setCreatingProfile] = useState(false), [savingProfile, setSavingProfile] = useState(false);
|
|
144
163
|
const profileFilterRef = useRef<HTMLInputElement>(null);
|
|
145
164
|
useEffect(() => setProfile(requestedProfile), [requestedProfile]);
|
|
146
|
-
useEffect(() => { if (!profileMenu) return; setProfileFilterInput(""); setProfileFilter(""); const frame = requestAnimationFrame(() => profileFilterRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [profileMenu]);
|
|
165
|
+
useEffect(() => { if (!profileMenu) return; setProfileFilterInput(""); setProfileFilter(""); setSelectedProfileTags([]); const frame = requestAnimationFrame(() => profileFilterRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [profileMenu]);
|
|
147
166
|
useEffect(() => { const timer = setTimeout(() => setProfileFilter(profileFilterInput), 500); return () => clearTimeout(timer); }, [profileFilterInput]);
|
|
148
167
|
const router = useRouter();
|
|
149
168
|
const selectProfile = (name: string) => { const destination = administrative || section === "overview" ? "/" : `/${section}`; router.replace(`${destination}?profile=${encodeURIComponent(name)}`); };
|
|
150
169
|
const refresh = async () => { const data = await refreshProfiles(); if (!data.some((item) => item.name === profile)) setProfile("default"); };
|
|
151
170
|
useEffect(() => { void initialize(); }, [initialize]);
|
|
152
|
-
const
|
|
153
|
-
|
|
171
|
+
const availableProfileTags = [...new Map(profiles.flatMap((item) => item.tags).map((tag) => [tag.toLocaleLowerCase(), tag])).values()].sort((left, right) => left.localeCompare(right));
|
|
172
|
+
const filteredProfiles = profiles.filter(({ name, tags }) => [name, ...tags].some((value) => value.toLocaleLowerCase().includes(profileFilter.trim().toLocaleLowerCase())) && selectedProfileTags.every((selected) => tags.some((tag) => tag.toLocaleLowerCase() === selected.toLocaleLowerCase())));
|
|
173
|
+
const content = section === "applications" ? applicationSlug ? <ApplicationWorkspace slug={applicationSlug}/> : <Applications/> : administrative && section === "api-server" ? <AdminConfigForm target="api-server" initial={initial.adminConfig}/> : administrative && section === "pi-console-webui" ? <AdminConfigForm target="pi-console-webui" initial={initial.adminConfig}/> : administrative && section === "models" ? <ModelsConfigForm initial={initial.models}/> : administrative && section === "skill-sources" ? <SkillSources/> : section === "chat" ? <ChatWorkspace key={profile} profile={profile}/> : section === "profiles" ? <Profiles profile={profile} refresh={refresh}/> : section === "settings" ? <Settings profile={profile} initial={initial.settings}/> : section === "model" ? <ProfileModelForm profile={profile} initial={initial.profileModel}/> : section === "env" ? <Environment profile={profile} initial={initial.env}/> : section === "soul" ? <MarkdownDocument profile={profile} type="soul" title="SOUL.md" initial={initial.document}/> : section === "guardrails" ? <GuardrailsPanel profile={profile} initial={initial.guardrails}/> : section === "context-memory" ? <ContextMemory profile={profile}/> : section === "skills" ? <SkillResourcesPanel profile={profile} initial={initial.resources}/> : section === "tools" ? <Resources profile={profile} kind="tools" initial={initial.resources}/> : section === "packages" ? <PackageResources profile={profile} initial={initial.packages}/> : section === "pulses" ? <PulseResources profile={profile} initial={initial.pulses}/> : section === "sequential-workflows" ? <SequentialWorkflowConfig profile={profile} initial={initial.sequentialWorkflows}/> : section === "extensions" ? <Resources profile={profile} kind="extensions" initial={initial.resources}/> : <Overview health={health} profiles={profiles} profile={profile}/>;
|
|
174
|
+
return <main className="mx-auto w-full max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6"><header className="mb-7 flex flex-wrap items-center gap-4"><div className="mr-auto flex items-center gap-3"><div className="rounded-xl bg-slate-700 p-2 text-white"><Bot size={24}/></div><div><h1 className="text-2xl font-bold tracking-tight">π Console</h1><p className="text-sm text-zinc-500">Manage your Pi API workspace</p></div></div><span className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm ${health === "running" ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-red-700"}`}><Activity size={15}/>{health}</span><div className="relative"><Button className="flex h-10 w-56 items-center justify-start bg-white text-zinc-700 ring-1 ring-zinc-200 hover:bg-zinc-50" title="Select profile" onClick={() => setProfileMenu((current) => !current)}>{administrative ? <span className="grid size-7 shrink-0 place-items-center rounded-full bg-zinc-700 text-white"><Cog size={15}/></span> : <UserRound className="shrink-0 text-zinc-500" size={16}/>}<span className="ml-2 min-w-0 flex-1 truncate text-left">{administrative ? "Administrative Settings" : profile}</span><ChevronDown className="ml-2 shrink-0 text-zinc-400" size={16}/></Button>{profileMenu && <div className="absolute right-0 z-30 mt-2 flex w-[min(42rem,calc(100vw-2rem))] max-h-[min(760px,calc(100vh-2rem))] flex-col rounded-xl border border-zinc-200 bg-white p-2 shadow-xl"><button className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm ${administrative ? "bg-zinc-100" : "hover:bg-zinc-50"}`} onClick={() => { router.replace("/applications?context=admin"); setProfileMenu(false); }}><Cog size={16}/><span>Administrative Settings</span></button><div className="my-2 border-t border-zinc-200"/><p className="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Profiles</p><label className="mb-2 grid gap-1 px-1 text-xs font-medium text-zinc-600"><span>Filter profiles</span><span className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={15}/><Input ref={profileFilterRef} className="h-9 border-zinc-300 bg-white pl-9 text-zinc-900" value={profileFilterInput} onChange={(event) => setProfileFilterInput(event.target.value)} placeholder="Search profiles or tags"/></span></label>{availableProfileTags.length > 0 && <section className="mb-2 border-y border-zinc-100 px-1 py-3"><div className="mb-2 flex items-center justify-between px-2"><p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Filter tags</p>{selectedProfileTags.length > 0 && <button className="text-xs text-sky-700 hover:text-sky-900" onClick={() => setSelectedProfileTags([])}>Clear</button>}</div><div className="flex max-h-24 flex-wrap gap-1.5 overflow-y-auto px-2">{availableProfileTags.map((tag) => { const selected = selectedProfileTags.some((item) => item.toLocaleLowerCase() === tag.toLocaleLowerCase()); return <button key={tag} className={`rounded-full border px-2 py-1 text-xs transition-colors ${tagColor(tag, selected)} ${selected ? "" : "hover:brightness-95"}`} onClick={() => setSelectedProfileTags((current) => selected ? current.filter((item) => item.toLocaleLowerCase() !== tag.toLocaleLowerCase()) : [...current, tag])}>{tag}</button>; })}</div></section>}<div className="max-h-[calc(min(760px,100vh-2rem)-280px)] overflow-y-auto">{filteredProfiles.map((item) => { const { name, tags } = item; return <div key={name} className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm ${name === profile ? "bg-zinc-100" : "hover:bg-zinc-50"}`}><button className="flex min-w-0 flex-1 items-center gap-2 text-left" onClick={() => { selectProfile(name); setProfileMenu(false); }}><ProfileAvatar name={name}/><span className="min-w-0 flex-1"><span className="block truncate font-medium">{name}</span>{item.description && <span className="mt-0.5 block truncate text-xs text-zinc-500">{item.description}</span>}{tags.length > 0 && <span className="mt-1 flex flex-wrap gap-1">{tags.map((tag) => <span key={tag} className={`rounded-full border px-1.5 py-0.5 text-[10px] ${tagColor(tag)}`}>{tag}</span>)}</span>}</span></button><button title={`Edit ${name}`} className="rounded-md p-2 text-zinc-400 transition-colors hover:bg-sky-50 hover:text-sky-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500" onClick={(event) => { event.stopPropagation(); setEditProfile(item); setProfileDescription(item.description); setProfileTags(item.tags); setProfileError(""); }}><Pencil size={14}/></button></div>; })}</div>{filteredProfiles.length === 0 && <p className="px-3 py-6 text-center text-sm text-zinc-500">No profiles match this filter.</p>}<button className="mt-2 flex w-full items-center justify-center rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-zinc-600 hover:bg-zinc-50" title="New profile" onClick={() => { setProfileMenu(false); setCreateDialog(true); setNewProfile(""); setNewProfileDescription(""); setNewProfileTags([]); setCloneProfile(false); setCloneFrom(""); setProfileError(""); }}><Plus size={16}/></button></div>}</div><Button className="!bg-[#575d65] hover:!bg-[#454a51]" title="Open system terminal" onClick={() => { const terminal = window.open("/terminal", "pi-console-terminal", "popup=yes,width=1100,height=720,noopener,noreferrer"); terminal?.focus(); }}><Terminal size={17}/></Button><Button className="!bg-[#ddc37b] hover:!bg-[#c7ae68]" title="Sign out" onClick={() => fetch("/api/auth/logout", { method: "POST" }).then(() => location.assign("/login"))}><LogOut size={17}/></Button></header>{error && <div className="mb-5 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">{error}</div>}<nav className="mb-5 flex flex-wrap gap-1 rounded-lg bg-zinc-100 p-1">{navigation.filter((item) => Boolean((item as { admin?: boolean }).admin) === administrative).map((item) => { const Icon = item.icon; const active = section === (item.href === "/" ? "overview" : item.href.slice(1)); return <Link key={item.href} href={`${item.href}?${administrative ? "context=admin" : `profile=${encodeURIComponent(profile)}`}`} className={`rounded-md px-3 py-2 text-sm font-medium ${active ? "bg-white shadow-sm" : "hover:bg-white/70"}`}><Icon className={`mr-2 inline ${item.color}`} size={16}/>{item.label}</Link>; })}</nav>{content}{createDialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl"><h2 className="text-lg font-semibold">Create profile</h2><p className="mt-1 text-sm text-zinc-500">Use letters, numbers, hyphens or underscores. The name must start with a letter.</p><Input className="mt-4" value={newProfile} onChange={(event) => { const value = event.target.value; setNewProfile(value); setProfileError(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value) ? "" : "Use a valid profile name."); }} placeholder="e.g. work_profile" autoFocus/><label className="mt-4 grid gap-1.5 text-sm font-medium text-zinc-700">Description<textarea className="min-h-24 resize-y rounded-md border border-zinc-200 px-3 py-2 text-sm font-normal outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500" value={newProfileDescription} onChange={(event) => setNewProfileDescription(event.target.value)} maxLength={500} placeholder="What is this profile for?"/></label><label className="mt-4 grid gap-1.5 text-sm font-medium text-zinc-700">Tags<ProfileTagsEditor tags={newProfileTags} onChange={setNewProfileTags}/></label>{profiles.some((item) => item.name !== "default") && <div className="mt-4 rounded-lg border border-zinc-200 p-3"><label className="flex items-center justify-between gap-3 text-sm font-medium text-zinc-700"><span>Clone an existing profile</span><Switch checked={cloneProfile} onCheckedChange={(enabled) => { setCloneProfile(enabled); if (!enabled) setCloneFrom(""); }}/></label>{cloneProfile && <label className="mt-3 grid gap-1.5 text-sm font-medium text-zinc-700">Source profile<select className="rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-normal" value={cloneFrom} onChange={(event) => setCloneFrom(event.target.value)}><option value="">Select a profile</option>{profiles.filter((item) => item.name !== "default").map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label>}</div>}{profileError && <p className="mt-2 text-sm text-red-600">{profileError}</p>}<div className="mt-5 flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" title="Cancel" onClick={() => setCreateDialog(false)}><X size={17}/></Button><Button title="Create profile" disabled={creatingProfile || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(newProfile) || (cloneProfile && !cloneFrom)} onClick={async () => { if (creatingProfile) return; try { setCreatingProfile(true); await request("profiles", { method: "POST", body: JSON.stringify({ name: newProfile, description: newProfileDescription, tags: newProfileTags, ...(cloneProfile ? { cloneFrom } : {}) }) }); await refresh(); selectProfile(newProfile); setCreateDialog(false); } catch (cause) { setProfileError((cause as Error).message); } finally { setCreatingProfile(false); } }}>{creatingProfile ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/> }</Button></div></div></div>}{editProfile && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 className="text-lg font-semibold">Edit <span className="text-sky-700">{editProfile.name}</span></h2><p className="mt-1 text-sm text-zinc-500">Update this profile's description and tags.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={() => setEditProfile(undefined)}><X size={17}/></Button></div><div className="mt-5 grid gap-4"><label className="grid gap-1.5 text-sm font-medium text-zinc-700">Description<textarea className="min-h-28 resize-y rounded-md border border-zinc-200 px-3 py-2 text-sm font-normal outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500" value={profileDescription} onChange={(event) => setProfileDescription(event.target.value)} maxLength={500} placeholder="What is this profile for?"/></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700">Tags<ProfileTagsEditor tags={profileTags} onChange={setProfileTags}/></label>{profileError && <p className="text-sm text-red-600">{profileError}</p>}</div><div className="mt-6 flex items-center justify-between gap-2"><Button className="bg-red-600 hover:bg-red-500" title={`Delete ${editProfile.name}`} disabled={editProfile.name === "default"} onClick={async () => { if (!confirm(`Delete ${editProfile.name}?`)) return; try { await request(`profiles/${editProfile.name}`, { method: "DELETE", body: JSON.stringify({ force: true }) }); await refresh(); if (profile === editProfile.name) selectProfile("default"); setEditProfile(undefined); } catch (cause) { setProfileError((cause as Error).message); } }}><Trash2 size={17}/></Button><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save profile" disabled={savingProfile} onClick={async () => { try { setSavingProfile(true); await request(`profiles/${editProfile.name}`, { method: "PATCH", body: JSON.stringify({ description: profileDescription, tags: profileTags }) }); await refresh(); setEditProfile(undefined); } catch (cause) { setProfileError((cause as Error).message); } finally { setSavingProfile(false); } }}>{savingProfile ? <Loader2 className="animate-spin" size={17}/> : <Save size={17}/>}</Button></div></div></div></div>}</main>;
|
|
154
175
|
}
|
|
155
176
|
|
|
156
177
|
function SkillResources({ profile, initial }: { profile: string; initial?: { resources: Resource[] } }) {
|
|
@@ -63,7 +63,7 @@ export function GuardrailsPanel({ profile, initial }: { profile: string; initial
|
|
|
63
63
|
const saveDialog = async () => {
|
|
64
64
|
if (!valid || saving) return;
|
|
65
65
|
const file = editing?.file ?? `${form.name}.md`;
|
|
66
|
-
if (
|
|
66
|
+
if (items.some((item) => item.name === form.name && item.name !== editing?.name)) { setNotice("A guardrail with this name already exists."); return; }
|
|
67
67
|
try {
|
|
68
68
|
setSaving(true);
|
|
69
69
|
await request(`profiles/${profile}/guardrails/${encodeURIComponent(file)}/document`, { method: "PUT", body: JSON.stringify({ content: form.prompt }) });
|
|
@@ -74,5 +74,5 @@ export function GuardrailsPanel({ profile, initial }: { profile: string; initial
|
|
|
74
74
|
} catch (error) { setNotice((error as Error).message); } finally { setSaving(false); }
|
|
75
75
|
};
|
|
76
76
|
useEffect(() => { if (initial) { try { setItems(parse(initial.content)); } catch (error) { setNotice((error as Error).message); } return; } void load(); }, [profile, initial]);
|
|
77
|
-
return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Guardrails</CardTitle><p className="mt-1 text-sm text-zinc-500">Rules are enabled per profile. Markdown prompts are global and may be shared.</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload guardrails" onClick={() => void load()}><RotateCw size={17}/></Button><Button title="Add guardrail" onClick={openCreate}><Plus size={17}/></Button></div></div>{notice && <p className="mt-3 text-sm text-red-700">{notice}</p>}<div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Stage</th><th className="p-3">Mode</th><th className="p-3">Order</th><th className="p-3">Enabled</th><th className="p-3"/></tr></thead><tbody>{[...items].sort((a, b) => a.stage.localeCompare(b.stage) || a.order - b.order || a.name.localeCompare(b.name)).map((guardrail) => <tr key={guardrail.name} className="border-b border-zinc-100"><td className="p-3"><p className="font-medium">{guardrail.name}</p><p className="text-xs text-zinc-400">{guardrail.file}</p></td><td className="p-3">{stages.find((option) => option.value === guardrail.stage)?.label}</td><td className="p-3">{modes.find((option) => option.value === guardrail.mode)?.label}</td><td className="p-3">{guardrail.order}</td><td className="p-3"><Switch checked={guardrail.enabled !== false} onCheckedChange={async (enabled) => { try { await saveConfig(items.map((item) => item.name === guardrail.name ? { ...item, enabled } : item)); toast(`Guardrail ${enabled ? "enabled" : "disabled"}.`); } catch (error) { setNotice((error as Error).message); } }}/></td><td className="whitespace-nowrap p-3"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${guardrail.name}`} onClick={() => void openEdit(guardrail)}><FileCode2 size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-red-50 hover:text-red-600" title={`Delete ${guardrail.name}`} onClick={async () => { if (!confirm(`Delete guardrail '${guardrail.name}' from profile '${profile}'? Its global prompt file will be kept for other profiles.`)) return; try { await saveConfig(items.filter((item) => item.name !== guardrail.name)); toast("Guardrail deleted from this profile."); } catch (error) { setNotice((error as Error).message); } }}><Trash2 size={17}/></button></td></tr>)}{items.length === 0 && <tr><td className="p-5 text-zinc-500" colSpan={6}>No guardrails configured for this profile.</td></tr>}</tbody></table></div></Card>{dialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[94vh] w-full max-w-6xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-6 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">{dialog === "create" ? "Create guardrail" : `Edit ${editing?.name}`}</h2><p className="mt-1 text-sm text-zinc-500">{dialog === "create" ? "Configure the profile rule and its global Markdown instructions." : "Changes to the Markdown prompt affect every profile that uses this file."}</p></div><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title={dialog === "create" ? "Create guardrail" : "Save all guardrail changes"} disabled={!valid} onClick={() => void saveDialog()}>{<Check size={17}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={close}><X size={17}/></Button></div></header><div className="grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]"><section className="grid content-start gap-5"><label className="grid gap-1.5 text-sm font-semibold text-zinc-800"><span>Name</span><Input
|
|
77
|
+
return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Guardrails</CardTitle><p className="mt-1 text-sm text-zinc-500">Rules are enabled per profile. Markdown prompts are global and may be shared.</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload guardrails" onClick={() => void load()}><RotateCw size={17}/></Button><Button title="Add guardrail" onClick={openCreate}><Plus size={17}/></Button></div></div>{notice && <p className="mt-3 text-sm text-red-700">{notice}</p>}<div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Stage</th><th className="p-3">Mode</th><th className="p-3">Order</th><th className="p-3">Enabled</th><th className="p-3"/></tr></thead><tbody>{[...items].sort((a, b) => a.stage.localeCompare(b.stage) || a.order - b.order || a.name.localeCompare(b.name)).map((guardrail) => <tr key={guardrail.name} className="border-b border-zinc-100"><td className="p-3"><p className="font-medium">{guardrail.name}</p><p className="text-xs text-zinc-400">{guardrail.file}</p></td><td className="p-3">{stages.find((option) => option.value === guardrail.stage)?.label}</td><td className="p-3">{modes.find((option) => option.value === guardrail.mode)?.label}</td><td className="p-3">{guardrail.order}</td><td className="p-3"><Switch checked={guardrail.enabled !== false} onCheckedChange={async (enabled) => { try { await saveConfig(items.map((item) => item.name === guardrail.name ? { ...item, enabled } : item)); toast(`Guardrail ${enabled ? "enabled" : "disabled"}.`); } catch (error) { setNotice((error as Error).message); } }}/></td><td className="whitespace-nowrap p-3"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${guardrail.name}`} onClick={() => void openEdit(guardrail)}><FileCode2 size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-red-50 hover:text-red-600" title={`Delete ${guardrail.name}`} onClick={async () => { if (!confirm(`Delete guardrail '${guardrail.name}' from profile '${profile}'? Its global prompt file will be kept for other profiles.`)) return; try { await saveConfig(items.filter((item) => item.name !== guardrail.name)); toast("Guardrail deleted from this profile."); } catch (error) { setNotice((error as Error).message); } }}><Trash2 size={17}/></button></td></tr>)}{items.length === 0 && <tr><td className="p-5 text-zinc-500" colSpan={6}>No guardrails configured for this profile.</td></tr>}</tbody></table></div></Card>{dialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[94vh] w-full max-w-6xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-6 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">{dialog === "create" ? "Create guardrail" : `Edit ${editing?.name}`}</h2><p className="mt-1 text-sm text-zinc-500">{dialog === "create" ? "Configure the profile rule and its global Markdown instructions." : "Changes to the Markdown prompt affect every profile that uses this file."}</p></div><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title={dialog === "create" ? "Create guardrail" : "Save all guardrail changes"} disabled={!valid} onClick={() => void saveDialog()}>{<Check size={17}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={close}><X size={17}/></Button></div></header><div className="grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]"><section className="grid content-start gap-5"><label className="grid gap-1.5 text-sm font-semibold text-zinc-800"><span>Name</span><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="e.g. safe-output"/><span className="text-xs font-normal text-zinc-500">{dialog === "create" ? <>Uses lowercase letters, numbers and hyphens. File: <code>{form.name || "name"}.md</code>.</> : <>Uses lowercase letters, numbers and hyphens. The shared Markdown file remains <code>{editing?.file}</code>.</>}</span></label><OptionSelect label="Stage" value={form.stage} options={stages} onChange={(stage) => setForm({ ...form, stage })}/><OptionSelect label="Mode" value={form.mode} options={modes} onChange={(mode) => setForm({ ...form, mode })}/><label className="grid gap-1.5 text-sm font-semibold text-zinc-800"><span>Order</span><Input type="number" min="0" value={form.order} onChange={(event) => setForm({ ...form, order: event.target.value })}/><span className="text-xs font-normal text-zinc-500">Lower values run first within the same stage.</span></label></section><section className="min-w-0"><label className="mb-2 block text-sm font-semibold text-zinc-800">Markdown instructions</label><p className="mb-3 text-xs text-zinc-500">Syntax-highlighted Markdown editor. This is instruction text only; preview and rich editing are intentionally disabled.</p><div className="overflow-hidden rounded-lg border border-zinc-200"><CodeMirror value={form.prompt} height="580px" extensions={[markdown()]} onChange={(prompt) => setForm({ ...form, prompt })}/></div></section></div></div></div>}</>;
|
|
78
78
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import CodeMirror from "@uiw/react-codemirror";
|
|
5
|
+
import { json as jsonLanguage } from "@codemirror/lang-json";
|
|
6
|
+
import { Loader2, Save } from "lucide-react";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
9
|
+
import { useToast } from "@/components/toast";
|
|
10
|
+
|
|
11
|
+
export function ModelsConfigForm({ initial }: { initial?: string }) {
|
|
12
|
+
const { toast } = useToast();
|
|
13
|
+
const [content, setContent] = useState(initial ?? "{\n \"providers\": {}\n}\n");
|
|
14
|
+
const [saving, setSaving] = useState(false);
|
|
15
|
+
const save = async () => {
|
|
16
|
+
if (saving) return;
|
|
17
|
+
try {
|
|
18
|
+
setSaving(true);
|
|
19
|
+
const response = await fetch("/api/admin/models", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ content }) });
|
|
20
|
+
const body = await response.json();
|
|
21
|
+
if (!response.ok) throw new Error(body.error ?? "Unable to save models.json");
|
|
22
|
+
setContent(body.content);
|
|
23
|
+
toast("models.json saved. Open /model again to load the updated catalog.");
|
|
24
|
+
} catch (error) {
|
|
25
|
+
toast((error as Error).message, "error");
|
|
26
|
+
} finally {
|
|
27
|
+
setSaving(false);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
return <Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Models</CardTitle><p className="text-sm text-zinc-500">Shared <code>models.json</code> catalog for every profile. Provider credentials may be present.</p></div><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save models.json" disabled={saving} onClick={save}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Save size={17}/>}</Button></div><div className="mt-4"><label id="models-json-label" className="mb-1.5 block text-sm font-medium text-zinc-700">models.json</label><div aria-labelledby="models-json-label" className="overflow-hidden rounded-lg border border-zinc-200"><CodeMirror value={content} height="520px" extensions={[jsonLanguage()]} onChange={setContent} aria-label="models.json editor"/></div></div></Card>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { Loader2, RefreshCw, Save } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { useToast } from "@/components/toast";
|
|
8
|
+
|
|
9
|
+
type Provider = { id: string; models: Array<{ id: string; name: string }> };
|
|
10
|
+
type ModelCatalog = { providers: Provider[] };
|
|
11
|
+
type ApplicationSession = { application: string; sessionPrefix: string; sessionId: string; sessionType: "stateful" | "stateless" };
|
|
12
|
+
|
|
13
|
+
export function ProfileModelForm({ profile, initial }: { profile: string; initial?: ModelCatalog & { settings?: Record<string, unknown> } }) {
|
|
14
|
+
const { toast } = useToast();
|
|
15
|
+
const [catalog, setCatalog] = useState<ModelCatalog>({ providers: initial?.providers ?? [] });
|
|
16
|
+
const [provider, setProvider] = useState(String(initial?.settings?.defaultProvider ?? initial?.providers[0]?.id ?? ""));
|
|
17
|
+
const models = useMemo(() => catalog.providers.find((item) => item.id === provider)?.models ?? [], [catalog, provider]);
|
|
18
|
+
const [model, setModel] = useState(String(initial?.settings?.defaultModel ?? ""));
|
|
19
|
+
const [loading, setLoading] = useState(!initial), [saving, setSaving] = useState(false), [rollingOver, setRollingOver] = useState(false);
|
|
20
|
+
const [rolloverDialog, setRolloverDialog] = useState(false), [activeSessions, setActiveSessions] = useState<ApplicationSession[]>([]);
|
|
21
|
+
const load = async () => { try { setLoading(true); const response = await fetch(`/api/pi/profiles/${encodeURIComponent(profile)}/model`); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? "Unable to load models"); setCatalog(body); setProvider((current) => current && body.providers.some((item: Provider) => item.id === current) ? current : body.providers[0]?.id ?? ""); } catch (error) { toast((error as Error).message, "error"); } finally { setLoading(false); } };
|
|
22
|
+
useEffect(() => { if (!initial) { void load(); return; } setCatalog({ providers: initial.providers }); setProvider(String(initial.settings?.defaultProvider ?? initial.providers[0]?.id ?? "")); setModel(String(initial.settings?.defaultModel ?? "")); setLoading(false); }, [profile, initial]);
|
|
23
|
+
useEffect(() => { if (!models.some((item) => item.id === model)) setModel(models[0]?.id ?? ""); }, [models, model]);
|
|
24
|
+
const save = async () => {
|
|
25
|
+
if (!provider || !model || saving) return;
|
|
26
|
+
try {
|
|
27
|
+
setSaving(true);
|
|
28
|
+
const response = await fetch(`/api/pi/profiles/${encodeURIComponent(profile)}/model`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider, model }) });
|
|
29
|
+
const body = await response.json();
|
|
30
|
+
if (!response.ok) throw new Error(body.error ?? "Unable to save model");
|
|
31
|
+
const sessionsResponse = await fetch(`/api/pi/application-sessions?${new URLSearchParams({ profile })}`);
|
|
32
|
+
const sessionsBody = await sessionsResponse.json();
|
|
33
|
+
if (!sessionsResponse.ok) throw new Error(sessionsBody.error ?? "Model saved, but active sessions could not be loaded.");
|
|
34
|
+
const unique = new Map<string, ApplicationSession>();
|
|
35
|
+
for (const session of sessionsBody.sessions as ApplicationSession[]) if (session.sessionType === "stateful") unique.set(`${session.application}:${session.sessionPrefix}`, session);
|
|
36
|
+
setActiveSessions([...unique.values()]);
|
|
37
|
+
setRolloverDialog(true);
|
|
38
|
+
} catch (error) { toast((error as Error).message, "error"); } finally { setSaving(false); }
|
|
39
|
+
};
|
|
40
|
+
const later = () => { setRolloverDialog(false); toast("Model saved. It will be used by new sessions."); };
|
|
41
|
+
const rollover = async () => {
|
|
42
|
+
try {
|
|
43
|
+
setRollingOver(true);
|
|
44
|
+
const outcomes = await Promise.allSettled(activeSessions.map(async (session) => {
|
|
45
|
+
const response = await fetch(`/api/pi/applications/${encodeURIComponent(session.application)}/session-rollover`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ profile, sessionPrefix: session.sessionPrefix }) });
|
|
46
|
+
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error ?? "Unable to roll over session"); }
|
|
47
|
+
}));
|
|
48
|
+
const failed = outcomes.filter((outcome) => outcome.status === "rejected").length;
|
|
49
|
+
setRolloverDialog(false);
|
|
50
|
+
toast(failed ? `Model saved. ${activeSessions.length - failed} session(s) rolled over; ${failed} failed.` : "Model saved and active sessions rolled over.", failed ? "error" : "success");
|
|
51
|
+
} finally { setRollingOver(false); }
|
|
52
|
+
};
|
|
53
|
+
return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Model</CardTitle><p className="text-sm text-zinc-500">Choose the default provider and model for this profile.</p></div><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save model" disabled={loading || saving || !provider || !model} onClick={save}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Save size={17}/>}</Button></div>{loading ? <div className="flex min-h-40 items-center justify-center gap-3 text-sm text-zinc-500"><Loader2 className="animate-spin" size={18}/>Loading models…</div> : <div className="mt-5 grid gap-4 md:grid-cols-2"><label className="grid gap-1.5 text-sm font-medium text-zinc-700">Provider<select className="rounded-md border border-zinc-200 px-3 py-2 font-normal" value={provider} onChange={(event) => setProvider(event.target.value)} disabled={catalog.providers.length === 0}>{catalog.providers.map((item) => <option key={item.id} value={item.id}>{item.id}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700">Model<select className="rounded-md border border-zinc-200 px-3 py-2 font-normal" value={model} onChange={(event) => setModel(event.target.value)} disabled={models.length === 0}>{models.map((item) => <option key={item.id} value={item.id}>{item.name === item.id ? item.id : `${item.name} (${item.id})`}</option>)}</select></label>{catalog.providers.length === 0 && <p className="text-sm text-zinc-500 md:col-span-2">No models are available for this profile.</p>}</div>}</Card>{rolloverDialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="rollover-title"><Card className="w-full max-w-lg"><h2 id="rollover-title" className="text-lg font-semibold">Roll over active sessions?</h2><p className="mt-2 text-sm text-zinc-600">The new model is saved and will be used by new sessions. Roll over active Application sessions now so they start using this model.</p>{activeSessions.length > 0 ? <ul className="mt-4 max-h-40 space-y-1 overflow-auto rounded-md border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-600">{activeSessions.map((session) => <li key={`${session.application}:${session.sessionPrefix}`}><span className="font-medium text-zinc-800">{session.application}</span> · {session.sessionPrefix}</li>)}</ul> : <p className="mt-4 text-sm text-zinc-500">There are no active Application sessions for this profile.</p>}<div className="mt-6 flex justify-end gap-2">{activeSessions.length > 0 && <Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Roll over active sessions" disabled={rollingOver} onClick={() => void rollover()}>{rollingOver ? <Loader2 className="animate-spin" size={17}/> : <RefreshCw size={17}/>}<span className="ml-2">Roll over now</span></Button>}<Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title={activeSessions.length > 0 ? "Roll over later" : "Close"} disabled={rollingOver} onClick={later}>{activeSessions.length > 0 ? "Later" : "Close"}</Button></div></Card></div>}</>;
|
|
54
|
+
}
|
|
@@ -9,12 +9,12 @@ import { Input } from "@/components/ui/input";
|
|
|
9
9
|
import { Switch } from "@/components/ui/switch";
|
|
10
10
|
import { useToast } from "@/components/toast";
|
|
11
11
|
|
|
12
|
-
type Pulse = { id: string; name: string; description: string; type: "cron" | "heartbeat"; schedule: string; prompt: string; thread_session_id: string; result: string | null; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null };
|
|
12
|
+
type Pulse = { id: string; name: string; description: string; type: "cron" | "heartbeat"; schedule: string; prompt: string; thread_session_id: string; insertIntoApiSession: boolean; result: string | null; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null };
|
|
13
13
|
type HistoryPulse = Pulse & { startedAt: string; finishedAt: string | null; status: string; error: string | null };
|
|
14
14
|
type PulseRun = { id: string; startedAt: string; finishedAt: string | null; status: "running" | "success" | "error"; response: string | null; error: string | null };
|
|
15
15
|
type Session = { id: string; name?: string | null };
|
|
16
|
-
type Form = { name: string; description: string; schedule: string; prompt: string; thread_session_id: string; enabled: boolean };
|
|
17
|
-
const blank = (): Form => ({ name: "", description: "", schedule: "*/15 * * * *", prompt: "", thread_session_id: "", enabled: true });
|
|
16
|
+
type Form = { name: string; description: string; schedule: string; prompt: string; thread_session_id: string; insertIntoApiSession: boolean; enabled: boolean };
|
|
17
|
+
const blank = (): Form => ({ name: "", description: "", schedule: "*/15 * * * *", prompt: "", thread_session_id: "", insertIntoApiSession: true, enabled: true });
|
|
18
18
|
function localDateTime(value: Date): string { const pad = (part: number) => String(part).padStart(2, "0"); return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`; }
|
|
19
19
|
function scheduleDescription(schedule: string): { type: Pulse["type"]; text: string } {
|
|
20
20
|
if (schedule.startsWith("@once:")) return { type: "cron", text: `Runs once at ${schedule.slice(6)}.` };
|
|
@@ -31,15 +31,16 @@ function scheduleDescription(schedule: string): { type: Pulse["type"]; text: str
|
|
|
31
31
|
async function request(path: string, init?: RequestInit) { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message ?? body.error ?? "Request failed"); } return response; }
|
|
32
32
|
|
|
33
33
|
export function PulseResources({ profile, initial }: { profile: string; initial?: { pulses: Pulse[] } }) {
|
|
34
|
-
const { toast } = useToast(); const [items, setItems] = useState<Pulse[]>(initial?.pulses ?? []), [tickRunning, setTickRunning] = useState(false), [expanded, setExpanded] = useState<string>(), [showHistory, setShowHistory] = useState(false), [history, setHistory] = useState<HistoryPulse[] | undefined>(); const [saving, setSaving] = useState(false); const [dialog, setDialog] = useState<"create" | "edit">(), [editing, setEditing] = useState<Pulse>(), [form, setForm] = useState<Form>(blank()); const [logPulse, setLogPulse] = useState<Pulse>(), [runs, setRuns] = useState<PulseRun[]>(), [logsLoading, setLogsLoading] = useState(false), [logStart, setLogStart] = useState(() => localDateTime(new Date(Date.now() - 24 * 60 * 60 * 1_000))), [logEnd, setLogEnd] = useState(() => localDateTime(new Date()));
|
|
34
|
+
const { toast } = useToast(); const [items, setItems] = useState<Pulse[]>(initial?.pulses ?? []), [tickRunning, setTickRunning] = useState(false), [expanded, setExpanded] = useState<string>(), [showHistory, setShowHistory] = useState(false), [history, setHistory] = useState<HistoryPulse[] | undefined>(); const [saving, setSaving] = useState(false); const [dialog, setDialog] = useState<"create" | "edit">(), [editing, setEditing] = useState<Pulse>(), [form, setForm] = useState<Form>(blank()); const [logPulse, setLogPulse] = useState<Pulse>(), [runs, setRuns] = useState<PulseRun[]>(), [logsLoading, setLogsLoading] = useState(false), [finalizingRun, setFinalizingRun] = useState<string>(), [logStart, setLogStart] = useState(() => localDateTime(new Date(Date.now() - 24 * 60 * 60 * 1_000))), [logEnd, setLogEnd] = useState(() => localDateTime(new Date()));
|
|
35
35
|
const load = async () => { try { const [pulses, tick] = await Promise.all([request(`profiles/${profile}/pulses`).then((r) => r.json() as Promise<{ pulses: Pulse[] }>), request("pulse/tick/status").then((r) => r.json() as Promise<{ running: boolean }>)]); setItems(pulses.pulses); setTickRunning(tick.running); } catch (cause) { toast((cause as Error).message, "error"); } };
|
|
36
36
|
const loadRuns = async (pulse = logPulse) => { if (!pulse) return; try { setLogsLoading(true); const start = new Date(logStart), end = new Date(logEnd); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start > end) throw new Error("Choose a valid chronological date range."); const response = await request(`profiles/${profile}/pulses/${encodeURIComponent(pulse.name)}/runs?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`); setRuns((await response.json() as { runs: PulseRun[] }).runs); } catch (cause) { toast((cause as Error).message, "error"); } finally { setLogsLoading(false); } };
|
|
37
37
|
const openLogs = (pulse: Pulse) => { setLogPulse(pulse); setRuns(undefined); const end = new Date(), start = new Date(end.getTime() - 24 * 60 * 60 * 1_000); setLogStart(localDateTime(start)); setLogEnd(localDateTime(end)); setTimeout(() => void loadRuns(pulse), 0); };
|
|
38
38
|
useEffect(() => { void load(); }, [profile]);
|
|
39
39
|
const close = () => { setDialog(undefined); setEditing(undefined); };
|
|
40
40
|
const create = () => { setForm(blank()); setEditing(undefined); setDialog("create"); };
|
|
41
|
-
const edit = (pulse: Pulse) => { setEditing(pulse); setForm({ name: pulse.name, description: pulse.description, schedule: pulse.schedule, prompt: pulse.prompt, thread_session_id: pulse.thread_session_id, enabled: pulse.enabled }); setDialog("edit"); };
|
|
41
|
+
const edit = (pulse: Pulse) => { setEditing(pulse); setForm({ name: pulse.name, description: pulse.description, schedule: pulse.schedule, prompt: pulse.prompt, thread_session_id: pulse.thread_session_id, insertIntoApiSession: pulse.insertIntoApiSession, enabled: pulse.enabled }); setDialog("edit"); };
|
|
42
42
|
const valid = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(form.name) && Boolean(form.description.trim() && form.schedule.trim() && form.prompt.trim());
|
|
43
43
|
const save = async () => { if (!valid || saving) return; try { setSaving(true); const { enabled, ...payload } = form; const response = await request(`profiles/${profile}/pulses${editing ? `/${encodeURIComponent(editing.name)}` : ""}`, { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) }); const saved = await response.json() as { pulse: Pulse }; if (saved.pulse.enabled !== enabled) await request(`profiles/${profile}/pulses/${encodeURIComponent(saved.pulse.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); toast(editing ? "Pulse saved." : `Pulse created and ${enabled ? "enabled" : "disabled"}.`); close(); } catch (cause) { toast((cause as Error).message, "error"); } finally { setSaving(false); } };
|
|
44
|
-
return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Pulses</CardTitle><p className="mt-1 text-sm text-zinc-500">Scheduled cron jobs and stateful heartbeats, executed by the Pulse Tick service.</p></div><div className="flex items-center gap-3"><label className="mr-6 flex items-center gap-2 text-sm font-medium text-zinc-600"><Switch className="data-[state=checked]:!bg-[#00b9ed]" checked={tickRunning} onCheckedChange={async (enabled) => { try { const response = await request("pulse/tick", { method: "POST", body: JSON.stringify({ enabled }) }); const data = await response.json() as { running: boolean }; setTickRunning(data.running); toast(`Pulse Tick ${data.running ? "started" : "stopped"}.`); } catch (cause) { toast((cause as Error).message, "error"); } }}/><span>Pulse Service</span></label><label className="flex items-center gap-2 text-sm font-medium text-zinc-600"><Switch className="data-[state=checked]:!bg-[#00b9ed]" checked={showHistory} onCheckedChange={setShowHistory}/><span>Show History</span></label><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload pulses" onClick={() => void load()}><RotateCw size={17}/></Button><Button title="Add pulse" onClick={create}><Plus size={17}/></Button></div></div><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Type</th><th className="p-3">Schedule</th><th className="p-3">Thread session</th><th className="p-3">Next run</th><th className="p-3">Enabled</th><th className="p-3"/></tr></thead><tbody>{items.filter((item) => showHistory || !(item.schedule.startsWith("@once:") && item.lastRunAt)).map((item) => <Fragment key={item.id}><tr className="border-b border-zinc-100"><td className="p-3"><p className="font-medium">{item.name}</p><p className="max-w-sm truncate text-xs text-zinc-500">{item.description}</p></td><td className="p-3"><span className={`inline-flex rounded-md border px-2.5 py-1 text-xs font-medium ${item.type === "heartbeat" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-blue-200 bg-blue-50 text-blue-800"}`}>{item.type === "heartbeat" ? "Heartbeat" : "Cron"}</span></td><td className="p-3 font-mono text-xs">{item.schedule}</td><td className="p-3 font-mono text-xs">{item.thread_session_id}</td><td className="p-3 text-xs text-zinc-500">{item.nextRunAt ?? "—"}</td><td className="p-3"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/pulses/${encodeURIComponent(item.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); toast(`Pulse ${enabled ? "enabled" : "disabled"}.`); } catch (cause) { toast((cause as Error).message, "error"); } }}/></td><td className="whitespace-nowrap p-3"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={expanded === item.id ? "Hide last result" : "Show last result"} onClick={() => setExpanded((current) => current === item.id ? undefined : item.id)}>{expanded === item.id ? <ChevronDown size={17}/> : <ChevronRight size={17}/>}</button><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`View execution logs for ${item.name}`} onClick={() => openLogs(item)}><ScrollText size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${item.name}`} onClick={() => edit(item)}><FilePenLine size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-red-50 hover:text-red-600" title={`Delete ${item.name}`} onClick={async () => { if (!confirm(`Delete pulse '${item.name}'?`)) return; try { await request(`profiles/${profile}/pulses/${encodeURIComponent(item.name)}`, { method: "DELETE" }); await load(); toast("Pulse deleted."); } catch (cause) { toast((cause as Error).message, "error"); } }}><Trash2 size={17}/></button></td></tr>{expanded === item.id && <tr className="border-b border-zinc-100 bg-zinc-50"><td colSpan={7} className="p-4"><p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Last result</p>{item.result ? <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-zinc-200 bg-white p-3 font-mono text-xs text-zinc-800">{item.result}</pre> : <p className="text-sm text-zinc-500">No result available yet.</p>}</td></tr>}</Fragment>)}{!items.filter((item) => showHistory || !(item.schedule.startsWith("@once:") && item.lastRunAt)).length && <tr><td colSpan={7} className="p-5 text-zinc-500">No pulses are configured for this view.</td></tr>}</tbody></table></div></Card>{logPulse && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="pulse-logs-title"><div className="max-h-[90vh] w-full max-w-5xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-5 flex items-start justify-between gap-4"><div><h2 id="pulse-logs-title" className="text-xl font-semibold">Execution logs — {logPulse.name}</h2><p className="mt-1 text-sm text-zinc-500">Results recorded by the Pulse Tick service.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close execution logs" onClick={() => setLogPulse(undefined)}><X size={17}/></Button></header><div className="mb-5 grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]"><label className="grid gap-1 text-sm font-medium text-zinc-700">Start<Input type="datetime-local" value={logStart} onChange={(event) => setLogStart(event.target.value)}/></label><label className="grid gap-1 text-sm font-medium text-zinc-700">End<Input type="datetime-local" value={logEnd} onChange={(event) => setLogEnd(event.target.value)}/></label><Button className="self-end" disabled={logsLoading} onClick={() => void loadRuns()}>{logsLoading ? <Loader2 className="animate-spin" size={17}/> : "Apply filters"}</Button><Button className="self-end bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Refresh execution logs" disabled={logsLoading} onClick={() => void loadRuns()}><RotateCw className={logsLoading ? "animate-spin" : ""} size={17}/></Button></div>{logsLoading && !runs ? <div className="py-12 text-center text-sm text-zinc-500">Loading execution logs…</div> : runs?.length ? <div className="space-y-3">{runs.map((run) => <article key={run.id} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-center justify-between gap-3"><div><p className="font-medium">{new Date(run.startedAt).toLocaleString()}</p><p className="text-xs text-zinc-500">{run.finishedAt ? `Duration: ${Math.max(0, new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000}s` : "Still running"}</p></div><span className={`rounded-md px-2 py-1 text-xs font-medium ${run.status === "success" ? "bg-emerald-50 text-emerald-800" : run.status === "error" ? "bg-red-50 text-red-800" : "bg-amber-50 text-amber-800"}`}>{run.status}</span></div>{run.response && <pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-zinc-200 bg-zinc-50 p-3 font-mono text-xs text-zinc-800">{run.response}</pre>}{run.error && <pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-red-200 bg-red-50 p-3 font-mono text-xs text-red-800">{run.error}</pre>}</article>)}</div> : <div className="py-12 text-center text-sm text-zinc-500">No executions were recorded in this period.</div>}</div></div>}{history && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-5 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">Completed one-time jobs</h2><p className="mt-1 text-sm text-zinc-500">Executed jobs with an <code>@once</code> schedule.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close history" onClick={() => setHistory(undefined)}><X size={17}/></Button></header>{history.length ? <div className="space-y-3">{history.map((item) => <article key={item.id} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-center justify-between gap-2"><div><p className="font-medium">{item.name}</p><p className="text-xs text-zinc-500">{item.description}</p></div><span className={`rounded-md px-2 py-1 text-xs font-medium ${item.status === "success" ? "bg-emerald-50 text-emerald-800" : "bg-red-50 text-red-800"}`}>{item.status}</span></div><p className="mt-3 text-xs text-zinc-500">Executed: {item.finishedAt ?? item.startedAt}</p>{item.result && <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-zinc-50 p-3 font-mono text-xs">{item.result}</pre>}{item.error && <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-red-50 p-3 font-mono text-xs text-red-800">{item.error}</pre>}</article>)}</div> : <p className="py-10 text-center text-sm text-zinc-500">No completed one-time jobs yet.</p>}</div></div>}{dialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[94vh] w-full max-w-3xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-6 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">{dialog === "create" ? "Create pulse" : `Edit ${editing?.name}`}</h2><p className="mt-1 text-sm text-zinc-500">Heartbeats retain state and must repeat in less than four hours. Cron jobs do not retain state.</p></div><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save pulse" disabled={!valid || saving} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={close}><X size={17}/></Button></div></header><div className="grid gap-5 md:grid-cols-2"><label className="grid content-start gap-1.5 text-sm font-semibold md:col-span-2">Name<Input disabled={dialog === "edit"} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="e.g. daily-report"/></label><label className="grid gap-1.5 text-sm font-semibold md:col-span-2">Description<Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="What this pulse is responsible for"/></label><label className="grid content-start gap-1.5 text-sm font-semibold">Schedule<Input value={form.schedule} onChange={(event) => setForm({ ...form, schedule: event.target.value })} placeholder="*/15 * * * * or @once:2026-03-12T14:30:00Z"/><span className="text-xs font-normal text-zinc-500">{scheduleDescription(form.schedule).text}</span></label><label className="grid content-start gap-1.5 text-sm font-semibold">Type<Input readOnly value={scheduleDescription(form.schedule).type === "heartbeat" ? "Heartbeat" : "Cron"} className={scheduleDescription(form.schedule).type === "heartbeat" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-blue-200 bg-blue-50 text-blue-800"}/><span className="text-xs font-normal text-zinc-500">Calculated automatically from the schedule.</span></label><label className="flex items-center gap-3 text-sm font-semibold md:col-span-2"><Switch checked={form.enabled} onCheckedChange={(enabled) => setForm({ ...form, enabled })}/><span>Enabled</span><span className="font-normal text-zinc-500">Run this pulse when the Pulse Tick service is active.</span></label>{editing?.thread_session_id && <label className="grid gap-1.5 text-sm font-semibold md:col-span-2">Thread session<Input readOnly value={editing.thread_session_id}/><span className="text-xs font-normal text-zinc-500">This pulse was registered from this conversation and receives its results there.</span></label>}<section className="grid gap-1.5 md:col-span-2"><label className="text-sm font-semibold">Prompt</label><p className="text-xs text-zinc-500">Markdown instructions executed on every pulse.</p><div className="overflow-hidden rounded-md border border-zinc-200"><CodeMirror value={form.prompt} height="300px" extensions={[markdown()]} onChange={(prompt) => setForm({ ...form, prompt })}/></div></section></div></div></div>}</>;
|
|
44
|
+
const finalizeRun = async (run: PulseRun, status: "success" | "error") => { if (!logPulse || finalizingRun || !confirm(`Mark this running execution as ${status}?`)) return; try { setFinalizingRun(run.id); await request(`profiles/${profile}/pulses/${encodeURIComponent(logPulse.name)}/runs/${encodeURIComponent(run.id)}/finalize`, { method: "POST", body: JSON.stringify({ status }) }); await loadRuns(logPulse); await load(); toast(`Pulse execution marked as ${status}.`); } catch (cause) { toast((cause as Error).message, "error"); } finally { setFinalizingRun(undefined); } };
|
|
45
|
+
return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Pulses</CardTitle><p className="mt-1 text-sm text-zinc-500">Scheduled cron jobs and stateful heartbeats, executed by the Pulse Tick service.</p></div><div className="flex items-center gap-3"><label className="mr-6 flex items-center gap-2 text-sm font-medium text-zinc-600"><Switch className="data-[state=checked]:!bg-[#00b9ed]" checked={tickRunning} onCheckedChange={async (enabled) => { try { const response = await request("pulse/tick", { method: "POST", body: JSON.stringify({ enabled }) }); const data = await response.json() as { running: boolean }; setTickRunning(data.running); toast(`Pulse Tick ${data.running ? "started" : "stopped"}.`); } catch (cause) { toast((cause as Error).message, "error"); } }}/><span>Pulse Service</span></label><label className="flex items-center gap-2 text-sm font-medium text-zinc-600"><Switch className="data-[state=checked]:!bg-[#00b9ed]" checked={showHistory} onCheckedChange={setShowHistory}/><span>Show History</span></label><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload pulses" onClick={() => void load()}><RotateCw size={17}/></Button><Button title="Add pulse" onClick={create}><Plus size={17}/></Button></div></div><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Type</th><th className="p-3">Schedule</th><th className="p-3">Thread session</th><th className="p-3">Next run</th><th className="p-3">Enabled</th><th className="p-3"/></tr></thead><tbody>{items.filter((item) => showHistory || !(item.schedule.startsWith("@once:") && item.lastRunAt)).map((item) => <Fragment key={item.id}><tr className="border-b border-zinc-100"><td className="p-3"><p className="font-medium">{item.name}</p><p className="max-w-sm truncate text-xs text-zinc-500">{item.description}</p></td><td className="p-3"><span className={`inline-flex rounded-md border px-2.5 py-1 text-xs font-medium ${item.type === "heartbeat" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-blue-200 bg-blue-50 text-blue-800"}`}>{item.type === "heartbeat" ? "Heartbeat" : "Cron"}</span></td><td className="p-3 font-mono text-xs">{item.schedule}</td><td className="p-3 font-mono text-xs">{item.thread_session_id}</td><td className="p-3 text-xs text-zinc-500">{item.nextRunAt ?? "—"}</td><td className="p-3"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/pulses/${encodeURIComponent(item.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); toast(`Pulse ${enabled ? "enabled" : "disabled"}.`); } catch (cause) { toast((cause as Error).message, "error"); } }}/></td><td className="whitespace-nowrap p-3"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={expanded === item.id ? "Hide last result" : "Show last result"} onClick={() => setExpanded((current) => current === item.id ? undefined : item.id)}>{expanded === item.id ? <ChevronDown size={17}/> : <ChevronRight size={17}/>}</button><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`View execution logs for ${item.name}`} onClick={() => openLogs(item)}><ScrollText size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${item.name}`} onClick={() => edit(item)}><FilePenLine size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-red-50 hover:text-red-600" title={`Delete ${item.name}`} onClick={async () => { if (!confirm(`Delete pulse '${item.name}'?`)) return; try { await request(`profiles/${profile}/pulses/${encodeURIComponent(item.name)}`, { method: "DELETE" }); await load(); toast("Pulse deleted."); } catch (cause) { toast((cause as Error).message, "error"); } }}><Trash2 size={17}/></button></td></tr>{expanded === item.id && <tr className="border-b border-zinc-100 bg-zinc-50"><td colSpan={7} className="p-4"><p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Last result</p>{item.result ? <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-zinc-200 bg-white p-3 font-mono text-xs text-zinc-800">{item.result}</pre> : <p className="text-sm text-zinc-500">No result available yet.</p>}</td></tr>}</Fragment>)}{!items.filter((item) => showHistory || !(item.schedule.startsWith("@once:") && item.lastRunAt)).length && <tr><td colSpan={7} className="p-5 text-zinc-500">No pulses are configured for this view.</td></tr>}</tbody></table></div></Card>{logPulse && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="pulse-logs-title"><div className="max-h-[90vh] w-full max-w-5xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-5 flex items-start justify-between gap-4"><div><h2 id="pulse-logs-title" className="text-xl font-semibold">Execution logs — {logPulse.name}</h2><p className="mt-1 text-sm text-zinc-500">Results recorded by the Pulse Tick service.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close execution logs" onClick={() => setLogPulse(undefined)}><X size={17}/></Button></header><div className="mb-5 grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]"><label className="grid gap-1 text-sm font-medium text-zinc-700">Start<Input type="datetime-local" value={logStart} onChange={(event) => setLogStart(event.target.value)}/></label><label className="grid gap-1 text-sm font-medium text-zinc-700">End<Input type="datetime-local" value={logEnd} onChange={(event) => setLogEnd(event.target.value)}/></label><Button className="self-end" disabled={logsLoading} onClick={() => void loadRuns()}>{logsLoading ? <Loader2 className="animate-spin" size={17}/> : "Apply filters"}</Button><Button className="self-end bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Refresh execution logs" disabled={logsLoading} onClick={() => void loadRuns()}><RotateCw className={logsLoading ? "animate-spin" : ""} size={17}/></Button></div>{logsLoading && !runs ? <div className="py-12 text-center text-sm text-zinc-500">Loading execution logs…</div> : runs?.length ? <div className="space-y-3">{runs.map((run) => <article key={run.id} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-center justify-between gap-3"><div><p className="font-medium">{new Date(run.startedAt).toLocaleString()}</p><p className="text-xs text-zinc-500">{run.finishedAt ? `Duration: ${Math.max(0, new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000}s` : "Still running"}</p></div><div className="flex items-center gap-2"><span className={`rounded-md px-2 py-1 text-xs font-medium ${run.status === "success" ? "bg-emerald-50 text-emerald-800" : run.status === "error" ? "bg-red-50 text-red-800" : "bg-amber-50 text-amber-800"}`}>{run.status}</span>{run.status === "running" && <><button className="rounded p-2 text-emerald-600 hover:bg-emerald-50 disabled:opacity-50" title="Mark execution as successful" disabled={Boolean(finalizingRun)} onClick={() => void finalizeRun(run, "success")}>{finalizingRun === run.id ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/>}</button><button className="rounded p-2 text-red-600 hover:bg-red-50 disabled:opacity-50" title="Mark execution as failed" disabled={Boolean(finalizingRun)} onClick={() => void finalizeRun(run, "error")}><X size={17}/></button></>}</div></div>{run.response && <pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-zinc-200 bg-zinc-50 p-3 font-mono text-xs text-zinc-800">{run.response}</pre>}{run.error && <pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-red-200 bg-red-50 p-3 font-mono text-xs text-red-800">{run.error}</pre>}</article>)}</div> : <div className="py-12 text-center text-sm text-zinc-500">No executions were recorded in this period.</div>}</div></div>}{history && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-5 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">Completed one-time jobs</h2><p className="mt-1 text-sm text-zinc-500">Executed jobs with an <code>@once</code> schedule.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close history" onClick={() => setHistory(undefined)}><X size={17}/></Button></header>{history.length ? <div className="space-y-3">{history.map((item) => <article key={item.id} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-center justify-between gap-2"><div><p className="font-medium">{item.name}</p><p className="text-xs text-zinc-500">{item.description}</p></div><span className={`rounded-md px-2 py-1 text-xs font-medium ${item.status === "success" ? "bg-emerald-50 text-emerald-800" : "bg-red-50 text-red-800"}`}>{item.status}</span></div><p className="mt-3 text-xs text-zinc-500">Executed: {item.finishedAt ?? item.startedAt}</p>{item.result && <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-zinc-50 p-3 font-mono text-xs">{item.result}</pre>}{item.error && <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-red-50 p-3 font-mono text-xs text-red-800">{item.error}</pre>}</article>)}</div> : <p className="py-10 text-center text-sm text-zinc-500">No completed one-time jobs yet.</p>}</div></div>}{dialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[94vh] w-full max-w-3xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-6 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">{dialog === "create" ? "Create pulse" : `Edit ${editing?.name}`}</h2><p className="mt-1 text-sm text-zinc-500">Heartbeats retain state and must repeat in less than four hours. Cron jobs do not retain state.</p></div><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save pulse" disabled={!valid || saving} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={close}><X size={17}/></Button></div></header><div className="grid gap-5 md:grid-cols-2"><label className="grid content-start gap-1.5 text-sm font-semibold md:col-span-2">Name<Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="e.g. daily-report"/></label><label className="grid gap-1.5 text-sm font-semibold md:col-span-2">Description<Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="What this pulse is responsible for"/></label><label className="grid content-start gap-1.5 text-sm font-semibold">Schedule<Input value={form.schedule} onChange={(event) => setForm({ ...form, schedule: event.target.value })} placeholder="*/15 * * * * or @once:2026-03-12T14:30:00Z"/><span className="text-xs font-normal text-zinc-500">{scheduleDescription(form.schedule).text}</span></label><label className="grid content-start gap-1.5 text-sm font-semibold">Type<Input readOnly value={scheduleDescription(form.schedule).type === "heartbeat" ? "Heartbeat" : "Cron"} className={scheduleDescription(form.schedule).type === "heartbeat" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-blue-200 bg-blue-50 text-blue-800"}/><span className="text-xs font-normal text-zinc-500">Calculated automatically from the schedule.</span></label><label className="flex items-center gap-3 text-sm font-semibold md:col-span-2"><Switch checked={form.insertIntoApiSession} onCheckedChange={(insertIntoApiSession) => setForm({ ...form, insertIntoApiSession })}/><span>Insert result into active API sessions</span><span className="font-normal text-zinc-500">Records the result in every active Application session for this profile; it does not send through an outbound handler.</span></label><label className="flex items-center gap-3 text-sm font-semibold md:col-span-2"><Switch checked={form.enabled} onCheckedChange={(enabled) => setForm({ ...form, enabled })}/><span>Enabled</span><span className="font-normal text-zinc-500">Run this pulse when the Pulse Tick service is active.</span></label>{editing?.thread_session_id && <label className="grid gap-1.5 text-sm font-semibold md:col-span-2">Thread session<Input readOnly value={editing.thread_session_id}/><span className="text-xs font-normal text-zinc-500">This pulse was registered from this conversation and receives its results there.</span></label>}<section className="grid gap-1.5 md:col-span-2"><label className="text-sm font-semibold">Prompt</label><p className="text-xs text-zinc-500">Markdown instructions executed on every pulse.</p><div className="overflow-hidden rounded-md border border-zinc-200"><CodeMirror value={form.prompt} height="300px" extensions={[markdown()]} onChange={(prompt) => setForm({ ...form, prompt })}/></div></section></div></div></div>}</>;
|
|
45
46
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Save } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { useToast } from "@/components/toast";
|
|
8
|
+
|
|
9
|
+
type Evaluation = { modelType: "llm" } | { modelType: "system_one"; systemOne: { provider: string; model: string; endpoint?: { baseUrl: string; path: string }; onUncertain: "block" } };
|
|
10
|
+
const defaultOpenRouterEndpoint = { baseUrl: "https://openrouter.ai/api/", path: "alpha/decisions" };
|
|
11
|
+
type Provider = { id: string; models: Array<{ id: string; name: string }> };
|
|
12
|
+
type Config = { evaluation: Evaluation };
|
|
13
|
+
type ModelCatalog = { providers: Provider[] };
|
|
14
|
+
type Props = { profile: string; initial?: Config };
|
|
15
|
+
const api = async <T,>(path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error?.message ?? body.error ?? "Request failed"); return body as T; };
|
|
16
|
+
|
|
17
|
+
export function SequentialWorkflowConfig({ profile, initial }: Props) {
|
|
18
|
+
const { toast } = useToast();
|
|
19
|
+
const [evaluation, setEvaluation] = useState<Evaluation>(initial?.evaluation ?? { modelType: "llm" });
|
|
20
|
+
const [credentialConfigured, setCredentialConfigured] = useState<boolean>();
|
|
21
|
+
const [catalog, setCatalog] = useState<ModelCatalog>({ providers: [] });
|
|
22
|
+
const [notice, setNotice] = useState("");
|
|
23
|
+
const load = async () => { try { const [config, status, models] = await Promise.all([api<Config>(`profiles/${profile}/sequential-workflows/config`), api<{ credentialConfigured: boolean }>(`profiles/${profile}/sequential-workflows/status`), api<ModelCatalog>(`profiles/${profile}/model`)]); setEvaluation(config.evaluation); setCredentialConfigured(status.credentialConfigured); setCatalog({ providers: models.providers }); setNotice(""); } catch (error) { setNotice((error as Error).message); } };
|
|
24
|
+
useEffect(() => { if (initial) { setEvaluation(initial.evaluation); } void load(); }, [profile, initial]);
|
|
25
|
+
const defaultProvider = catalog.providers[0];
|
|
26
|
+
const systemOne = evaluation.modelType === "system_one" ? evaluation.systemOne : { provider: defaultProvider?.id ?? "", model: defaultProvider?.models[0]?.id ?? "", endpoint: defaultOpenRouterEndpoint, onUncertain: "block" as const };
|
|
27
|
+
const endpoint = systemOne.endpoint ?? defaultOpenRouterEndpoint;
|
|
28
|
+
const selectedProvider = catalog.providers.find((provider) => provider.id === systemOne.provider) ?? defaultProvider;
|
|
29
|
+
const models = selectedProvider?.models ?? [];
|
|
30
|
+
const save = async () => { try { const result = await api<{ evaluation: Evaluation }>(`profiles/${profile}/sequential-workflows/config`, { method: "PUT", body: JSON.stringify({ evaluation }) }); setEvaluation(result.evaluation); await load(); toast("Sequential Workflow configuration saved."); } catch (error) { setNotice((error as Error).message); } };
|
|
31
|
+
return <div className="space-y-5"><Card><div className="flex flex-wrap items-start justify-between gap-4"><div><CardTitle>Sequential Workflows</CardTitle><p className="mt-1 text-sm text-zinc-500">Configure how this profile evaluates workflow requirements and criteria.</p></div><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save Sequential Workflow configuration" onClick={() => void save()}><Save size={17}/></Button></div>{notice && <p className="mt-4 text-sm text-red-700">{notice}</p>}<div className="mt-5"><label className="grid max-w-[calc(50%-0.5rem)] gap-1.5 text-sm font-medium text-zinc-700"><span>Evaluation model type</span><select className="rounded-md border border-zinc-200 bg-white px-3 py-2 font-normal" value={evaluation.modelType} onChange={(event) => setEvaluation(event.target.value === "system_one" ? { modelType: "system_one", systemOne } : { modelType: "llm" })}><option value="llm">LLM</option><option value="system_one">System One Model</option></select></label></div>{evaluation.modelType === "llm" ? <p className="mt-4 rounded-lg bg-sky-50 p-3 text-sm text-sky-900">Uses the active Pi session model for Sequential Workflow decisions.</p> : <div className="mt-4 space-y-3"><div className="grid gap-4 md:grid-cols-2"><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Provider</span><select className="rounded-md border border-zinc-200 bg-white px-3 py-2 font-normal" value={systemOne.provider} onChange={(event) => { const provider = event.target.value; const next = catalog.providers.find((item) => item.id === provider); setEvaluation({ modelType: "system_one", systemOne: { provider, model: next?.models[0]?.id ?? "", endpoint, onUncertain: "block" } }); }} disabled={catalog.providers.length === 0}>{catalog.providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.id}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>System One model/version</span><Input list={`system-one-models-${profile}`} value={systemOne.model} onChange={(event) => setEvaluation({ modelType: "system_one", systemOne: { ...systemOne, model: event.target.value } })} placeholder="Enter a model or version"/><datalist id={`system-one-models-${profile}`}>{models.map((model) => <option key={model.id} value={model.id}>{model.name}</option>)}</datalist></label></div><div className="grid gap-4 md:grid-cols-2"><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Decision API base URL</span><Input value={endpoint.baseUrl} onChange={(event) => setEvaluation({ modelType: "system_one", systemOne: { ...systemOne, endpoint: { ...endpoint, baseUrl: event.target.value } } })} placeholder="https://openrouter.ai/api/"/></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Decision API path</span><Input value={endpoint.path} onChange={(event) => setEvaluation({ modelType: "system_one", systemOne: { ...systemOne, endpoint: { ...endpoint, path: event.target.value } } })} placeholder="alpha/decisions"/></label></div><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Uncertain decision policy</span><Input value="Block and require review" disabled/></label><p className={`rounded-lg p-3 text-sm ${credentialConfigured ? "bg-emerald-50 text-emerald-900" : "bg-amber-50 text-amber-900"}`}>{credentialConfigured ? "OpenRouter credential is configured for the running environment." : "OpenRouter credential is missing. System One decisions will remain blocked until it is configured."}</p></div>}</Card><Card><CardTitle>Active behavior</CardTitle><dl className="mt-4 grid gap-3 text-sm md:grid-cols-2"><div><dt className="font-medium text-zinc-700">Requirement and template routing</dt><dd className="text-zinc-500">{evaluation.modelType === "system_one" ? "TypeSafe System One" : "Active Pi LLM"}</dd></div><div><dt className="font-medium text-zinc-700">Collect and task criteria</dt><dd className="text-zinc-500">{evaluation.modelType === "system_one" ? "Automatic typed decisions" : "Current agent evaluation flow"}</dd></div><div><dt className="font-medium text-zinc-700">Uncertain or unavailable decisions</dt><dd className="text-zinc-500">Blocked and recorded by the harness.</dd></div><div><dt className="font-medium text-zinc-700">Credentials</dt><dd className="text-zinc-500">Never stored or displayed in settings.</dd></div></dl></Card></div>;
|
|
32
|
+
}
|