pi-feats 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +508 -0
- package/extensions/README.md +27 -0
- package/extensions/api-server/PLAN.md +70 -0
- package/extensions/api-server/README.md +103 -0
- package/extensions/api-server/application-log-store.ts +21 -0
- package/extensions/api-server/application-runtime.ts +212 -0
- package/extensions/api-server/application-store.ts +30 -0
- package/extensions/api-server/index.ts +52 -0
- package/extensions/api-server/profile-store.ts +367 -0
- package/extensions/api-server/server.ts +863 -0
- package/extensions/cli-resources.ts +564 -0
- package/extensions/guardrails/index.ts +178 -0
- package/extensions/lib/application-handler-templates.ts +63 -0
- package/extensions/lib/profile-env.ts +61 -0
- package/extensions/lib/profile-sandbox.ts +197 -0
- package/extensions/lib/remote-hosts.ts +392 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +4 -0
- package/extensions/pi-console-webui/app/api/admin/config/[target]/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/admin/services/[service]/restart/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/auth/login/route.ts +9 -0
- package/extensions/pi-console-webui/app/api/auth/logout/route.ts +3 -0
- package/extensions/pi-console-webui/app/api/message/app/[slug]/route.ts +11 -0
- package/extensions/pi-console-webui/app/api/pi/[...path]/route.ts +31 -0
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +2 -0
- package/extensions/pi-console-webui/app/globals.css +41 -0
- package/extensions/pi-console-webui/app/icon.svg +1 -0
- package/extensions/pi-console-webui/app/layout.tsx +5 -0
- package/extensions/pi-console-webui/app/login/page.tsx +11 -0
- package/extensions/pi-console-webui/app/page.tsx +2 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +4 -0
- package/extensions/pi-console-webui/components/admin-config-form.tsx +16 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +39 -0
- package/extensions/pi-console-webui/components/application-logs.tsx +38 -0
- package/extensions/pi-console-webui/components/application-mappings.tsx +28 -0
- package/extensions/pi-console-webui/components/application-sessions.tsx +11 -0
- package/extensions/pi-console-webui/components/application-settings.tsx +60 -0
- package/extensions/pi-console-webui/components/application-workspace.tsx +14 -0
- package/extensions/pi-console-webui/components/applications.tsx +15 -0
- package/extensions/pi-console-webui/components/chat-workspace.tsx +42 -0
- package/extensions/pi-console-webui/components/console-page.tsx +23 -0
- package/extensions/pi-console-webui/components/console-state.tsx +30 -0
- package/extensions/pi-console-webui/components/console.tsx +115 -0
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +78 -0
- package/extensions/pi-console-webui/components/package-resources.tsx +13 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +41 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +35 -0
- package/extensions/pi-console-webui/components/skill-source-document-preview.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-source-import.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-sources.tsx +12 -0
- package/extensions/pi-console-webui/components/terminal-client.tsx +39 -0
- package/extensions/pi-console-webui/components/toast.tsx +18 -0
- package/extensions/pi-console-webui/components/ui/button.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/card.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/input.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/switch.tsx +6 -0
- package/extensions/pi-console-webui/components/ui/tabs.tsx +11 -0
- package/extensions/pi-console-webui/components.json +8 -0
- package/extensions/pi-console-webui/index.ts +33 -0
- package/extensions/pi-console-webui/lib/admin-config.ts +22 -0
- package/extensions/pi-console-webui/lib/auth.ts +21 -0
- package/extensions/pi-console-webui/lib/config.ts +15 -0
- package/extensions/pi-console-webui/lib/pi-api.ts +9 -0
- package/extensions/pi-console-webui/lib/utils.ts +3 -0
- package/extensions/pi-console-webui/next-env.d.ts +6 -0
- package/extensions/pi-console-webui/next.config.js +5 -0
- package/extensions/pi-console-webui/postcss.config.js +1 -0
- package/extensions/pi-console-webui/tailwind.config.ts +2 -0
- package/extensions/pi-console-webui/tsconfig.json +41 -0
- package/extensions/profiles.ts +439 -0
- package/extensions/pulse/index.ts +62 -0
- package/extensions/pulse/store.ts +105 -0
- package/extensions/sequential-workflow.ts +270 -0
- package/extensions/skill-sources/index.ts +4 -0
- package/extensions/skill-sources/store.ts +118 -0
- package/package.json +89 -0
- package/scripts/install-nono.sh +34 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
5
|
+
import ReactMarkdown from "react-markdown";
|
|
6
|
+
import remarkGfm from "remark-gfm";
|
|
7
|
+
import CodeMirror from "@uiw/react-codemirror";
|
|
8
|
+
import MDEditor from "@uiw/react-md-editor";
|
|
9
|
+
import { json as jsonLanguage } from "@codemirror/lang-json";
|
|
10
|
+
import { HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
|
|
11
|
+
import { tags } from "@lezer/highlight";
|
|
12
|
+
import { Activity, Bot, Box, Check, ChevronDown, Cog, Copy, Eye, FileCode2, GitBranch, Loader2, LogOut, MessageSquare, Plus, RotateCw, Save, Settings2, ShieldCheck, Sparkles, Terminal, Trash2, UserRound, Wrench, X } from "lucide-react";
|
|
13
|
+
import { Button } from "@/components/ui/button";
|
|
14
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
15
|
+
import { Input } from "@/components/ui/input";
|
|
16
|
+
import { Switch } from "@/components/ui/switch";
|
|
17
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
18
|
+
import { Applications } from "@/components/applications";
|
|
19
|
+
import { ApplicationWorkspace } from "@/components/application-workspace";
|
|
20
|
+
import { ApplicationSessions } from "@/components/application-sessions";
|
|
21
|
+
import "@uiw/react-md-editor/markdown-editor.css";
|
|
22
|
+
import "@uiw/react-markdown-preview/markdown.css";
|
|
23
|
+
import { ChatWorkspace } from "@/components/chat-workspace";
|
|
24
|
+
import { useConsoleState } from "@/components/console-state";
|
|
25
|
+
import { useToast } from "@/components/toast";
|
|
26
|
+
import { AdminConfigForm } from "@/components/admin-config-form";
|
|
27
|
+
import { GuardrailsPanel } from "@/components/guardrails-panel";
|
|
28
|
+
import { SkillResourcesPanel } from "@/components/skill-resources";
|
|
29
|
+
import { SkillSources } from "@/components/skill-sources";
|
|
30
|
+
import { PackageResources } from "@/components/package-resources";
|
|
31
|
+
import { PulseResources } from "@/components/pulse-resources";
|
|
32
|
+
|
|
33
|
+
type Profile = { name: string; path: string };
|
|
34
|
+
type Resource = { name: string; kind: string; path?: string; source: string; package?: string; enabled: boolean; protected?: boolean };
|
|
35
|
+
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 };
|
|
36
|
+
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; };
|
|
37
|
+
const getJson = async <T,>(path: string, init?: RequestInit) => (await request(path, init)).json() as Promise<T>;
|
|
38
|
+
type EnvState = { value: boolean };
|
|
39
|
+
const envLanguage = StreamLanguage.define<EnvState>({ startState: () => ({ value: false }), token(stream, state) { if (stream.sol()) state.value = false; if (stream.eatSpace()) return null; if (stream.match("#")) { stream.skipToEnd(); return "comment"; } if (!state.value && stream.match("export")) return "keyword"; if (!state.value && stream.match(/[A-Za-z_][A-Za-z0-9_]*/)) return "variableName"; if (!state.value && stream.match("=")) { state.value = true; return "operator"; } if (state.value && stream.match(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s#]+/)) return "string"; stream.next(); return null; } });
|
|
40
|
+
const envHighlighting = syntaxHighlighting(HighlightStyle.define([{ tag: tags.comment, color: "#64748b" }, { tag: tags.keyword, color: "#7c3aed" }, { tag: tags.variableName, color: "#1d4ed8" }, { tag: tags.operator, color: "#475569" }, { tag: tags.string, color: "#0f766e" }]));
|
|
41
|
+
function LegacyConsole() {
|
|
42
|
+
const [profiles, setProfiles] = useState<Profile[]>([]), [profile, setProfile] = useState("default"), [health, setHealth] = useState("Checking…"), [error, setError] = useState("");
|
|
43
|
+
const refreshProfiles = async () => { const data = await getJson<{ profiles: Profile[] }>("profiles"); setProfiles(data.profiles); if (!data.profiles.some(({ name }) => name === profile)) setProfile("default"); };
|
|
44
|
+
useEffect(() => { getJson<{ status: string }>("health").then(({ status }) => setHealth(status)).catch((cause) => { setHealth("Unavailable"); setError(cause.message); }); refreshProfiles().catch((cause) => setError(cause.message)); }, []);
|
|
45
|
+
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-blue-600 p-2 text-white"><Bot size={24}/></div><div><h1 className="text-2xl font-bold tracking-tight">Pi 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><select className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm" value={profile} onChange={(event) => setProfile(event.target.value)}>{profiles.map(({ name }) => <option key={name}>{name}</option>)}</select><Button className="bg-zinc-800 hover:bg-zinc-700" onClick={() => fetch("/api/auth/logout", { method: "POST" }).then(() => location.assign("/login"))}>Sign out</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>}<Tabs defaultValue="overview"><TabsList><TabsTrigger value="overview"><Activity className="mr-2 inline" size={15}/>Overview</TabsTrigger><TabsTrigger value="chat"><MessageSquare className="mr-2 inline" size={15}/>Chat</TabsTrigger><TabsTrigger value="profiles"><ShieldCheck className="mr-2 inline" size={15}/>Profiles</TabsTrigger><TabsTrigger value="settings"><Settings2 className="mr-2 inline" size={15}/>Settings</TabsTrigger><TabsTrigger value="soul"><Sparkles className="mr-2 inline" size={15}/>SOUL</TabsTrigger><TabsTrigger value="skills"><Sparkles className="mr-2 inline" size={15}/>Skills</TabsTrigger><TabsTrigger value="tools"><Settings2 className="mr-2 inline" size={15}/>Tools</TabsTrigger><TabsTrigger value="extensions"><FileCode2 className="mr-2 inline" size={15}/>Extensions</TabsTrigger></TabsList><TabsContent value="overview"><Overview health={health} profiles={profiles} profile={profile}/></TabsContent><TabsContent value="chat"><ChatWorkspace profile={profile}/></TabsContent><TabsContent value="profiles"><Profiles profile={profile} refresh={refreshProfiles}/></TabsContent><TabsContent value="settings"><Settings profile={profile}/></TabsContent><TabsContent value="soul"><MarkdownDocument profile={profile} type="soul" title="SOUL.md"/></TabsContent><TabsContent value="skills"><Resources profile={profile} kind="skills"/></TabsContent><TabsContent value="tools"><Resources profile={profile} kind="tools"/></TabsContent><TabsContent value="extensions"><Resources profile={profile} kind="extensions"/></TabsContent></Tabs></main>;
|
|
46
|
+
}
|
|
47
|
+
function Overview({ health, profiles, profile }: { health: string; profiles: Profile[]; profile: string }) { return <div className="grid gap-4 md:grid-cols-3"><Metric icon={<Activity/>} label="API status" value={health}/><Metric icon={<ShieldCheck/>} label="Profiles" value={String(profiles.length)}/><Metric icon={<Bot/>} label="Selected workspace" value={profile}/><ApplicationSessions profile={profile}/></div>; }
|
|
48
|
+
function Metric({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { return <Card><div className="flex items-center gap-3 text-blue-600">{icon}<span className="text-sm font-medium text-zinc-500">{label}</span></div><p className="mt-4 text-2xl font-semibold capitalize">{value}</p></Card>; }
|
|
49
|
+
function Chat({ profile }: { profile: string }) { const [session, setSession] = useState("webui"), [message, setMessage] = useState(""), [output, setOutput] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState(""); async function send(event: React.FormEvent) { event.preventDefault(); setBusy(true); setOutput(""); setError(""); try { const response = await request(`profile/${profile}/api/sessions/${encodeURIComponent(session)}/chat/stream`, { method: "POST", body: JSON.stringify({ message }) }); const reader = response.body?.getReader(), decoder = new TextDecoder(); let buffer = ""; while (reader) { const part = await reader.read(); if (part.done) break; buffer += decoder.decode(part.value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop() ?? ""; for (const eventText of events) { const data = eventText.split("\n").find((line) => line.startsWith("data: "))?.slice(6); if (!data) continue; const eventData = JSON.parse(data); if (eventData.text) setOutput((current) => current + eventData.text); } } } catch (cause) { setError((cause as Error).message); } finally { setBusy(false); } } return <div className="grid gap-4 lg:grid-cols-2"><Card><CardTitle>New message</CardTitle><p className="mt-1 text-sm text-zinc-500">Profile: {profile}</p><form className="mt-4 space-y-3" onSubmit={send}><Input value={session} onChange={(event) => setSession(event.target.value)} placeholder="Session ID"/><textarea className="min-h-48 w-full rounded-lg border border-zinc-200 p-3" value={message} onChange={(event) => setMessage(event.target.value)} placeholder="Write a message…"/><Button disabled={busy || !message.trim()}>{busy ? "Sending…" : "Send message"}</Button>{error && <p className="text-sm text-red-600">{error}</p>}</form></Card><Card><CardTitle>Response</CardTitle><pre className="mt-4 min-h-72 whitespace-pre-wrap rounded-lg bg-zinc-50 p-4 text-sm text-zinc-700">{output || "The streamed response will appear here."}</pre></Card></div>; }
|
|
50
|
+
function Profiles({ profile, refresh }: { profile: string; refresh: () => Promise<void> }) { const [name, setName] = useState(""), [notice, setNotice] = useState(""); return <Card className="max-w-2xl"><CardTitle>Profile management</CardTitle><p className="mt-1 text-sm text-zinc-500">Create isolated settings, sessions and instructions.</p><div className="mt-5 flex gap-2"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="New profile name"/><Button onClick={async () => { try { await request("profiles", { method: "POST", body: JSON.stringify({ name }) }); setName(""); await refresh(); setNotice("Profile created."); } catch (cause) { setNotice((cause as Error).message); } }}><Check className="mr-2" size={16}/>Create</Button></div><div className="mt-6 flex items-center justify-between rounded-lg border border-zinc-200 p-4"><div><p className="font-medium">{profile}</p><p className="text-sm text-zinc-500">Current selected profile</p></div><Button className="bg-red-600 hover:bg-red-500" disabled={profile === "default"} onClick={async () => { if (confirm(`Delete ${profile}?`)) { await request(`profiles/${profile}`, { method: "DELETE", body: JSON.stringify({ force: true }) }); await refresh(); } }}><Trash2 className="mr-2" size={16}/>Delete</Button></div>{notice && <p className="mt-4 text-sm text-blue-700">{notice}</p>}</Card>; }
|
|
51
|
+
function Settings({ profile, initial }: { profile: string; initial?: { settings: unknown } }) { const { toast } = useToast(); const [value, setValue] = useState(() => initial ? JSON.stringify(initial.settings, null, 2) : "{}"), [notice, setNotice] = useState(""); useEffect(() => { if (initial) { setValue(JSON.stringify(initial.settings, null, 2)); return; } getJson<{ settings: unknown }>(`profiles/${profile}/settings`).then(({ settings }) => setValue(JSON.stringify(settings, null, 2))).catch((cause) => setNotice(cause.message)); }, [profile, initial]); return <Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>settings.json</CardTitle><p className="text-sm text-zinc-500">Syntax-highlighted JSON editor</p></div><Button onClick={async () => { try { await request(`profiles/${profile}/settings`, { method: "PUT", body: value }); toast("Settings saved."); } catch (cause) { setNotice((cause as Error).message); } }} className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save settings"><Save size={17}/></Button></div>{notice && <p className="mt-3 text-sm text-amber-700">{notice}</p>}<div className="mt-4 overflow-hidden rounded-lg border border-zinc-200"><CodeMirror value={value} height="520px" extensions={[jsonLanguage()]} onChange={setValue}/></div></Card>; }
|
|
52
|
+
type Guardrail = { name: string; stage: "input" | "pre_tool" | "post_tool" | "output"; mode: "transform" | "evaluate" | "reflect"; order: number; file: string; enabled: boolean };
|
|
53
|
+
const guardrailStages: Guardrail["stage"][] = ["input", "pre_tool", "post_tool", "output"];
|
|
54
|
+
const guardrailModes: Guardrail["mode"][] = ["transform", "evaluate", "reflect"];
|
|
55
|
+
function LegacyGuardrails({ profile, initial }: { profile: string; initial?: { content: string } }) {
|
|
56
|
+
const { toast } = useToast();
|
|
57
|
+
const [items, setItems] = useState<Guardrail[]>([]), [selected, setSelected] = useState<Guardrail>(), [prompt, setPrompt] = useState(""), [notice, setNotice] = useState(""), [dialog, setDialog] = useState<"create" | "edit">();
|
|
58
|
+
const [name, setName] = useState(""), [stage, setStage] = useState<Guardrail["stage"]>("input"), [mode, setMode] = useState<Guardrail["mode"]>("evaluate"), [order, setOrder] = useState("10"), [newPrompt, setNewPrompt] = useState("");
|
|
59
|
+
const parse = (content: string) => { const value = JSON.parse(content) as { guardrails?: Guardrail[] }; if (!Array.isArray(value.guardrails)) throw new Error("guardrails.json must contain a guardrails array."); return value.guardrails; };
|
|
60
|
+
const load = async () => { try { const data = await getJson<{ content: string }>(`profiles/${profile}/guardrails`); setItems(parse(data.content)); setNotice(""); } catch (cause) { setNotice((cause as Error).message); } };
|
|
61
|
+
const save = async (next: Guardrail[]) => { await request(`profiles/${profile}/guardrails`, { method: "PUT", body: JSON.stringify({ content: `${JSON.stringify({ guardrails: next }, null, 2)}\n` }) }); setItems(next); };
|
|
62
|
+
const select = async (guardrail: Guardrail) => { try { setSelected(guardrail); setStage(guardrail.stage); setMode(guardrail.mode); setOrder(String(guardrail.order)); const data = await getJson<{ content: string }>(`profiles/${profile}/guardrails/${encodeURIComponent(guardrail.file)}/document`); setPrompt(data.content); } catch (cause) { setNotice((cause as Error).message); } };
|
|
63
|
+
useEffect(() => { if (initial) { try { setItems(parse(initial.content)); } catch (cause) { setNotice((cause as Error).message); } return; } void load(); }, [profile, initial]);
|
|
64
|
+
return <div><Card><div className="flex items-center justify-between gap-3"><div><CardTitle>Guardrails</CardTitle><p className="text-sm text-zinc-500">Rules are enabled per profile; their Markdown prompts are global.</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload guardrails" onClick={load}><RotateCw size={17}/></Button><Button title="Add guardrail" onClick={() => { setName(""); setStage("input"); setMode("evaluate"); setOrder("10"); setNewPrompt(""); setDialog("create"); }}><Plus className="mr-2" size={16}/>Add</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 ${selected?.name === guardrail.name ? "bg-zinc-50" : ""}`}><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">{guardrail.stage}</td><td className="p-3">{guardrail.mode}</td><td className="p-3">{guardrail.order}</td><td className="p-3"><Switch checked={guardrail.enabled !== false} onCheckedChange={async (enabled) => { try { await save(items.map((item) => item.name === guardrail.name ? { ...item, enabled } : item)); toast(`Guardrail ${enabled ? "enabled" : "disabled"}.`); } catch (cause) { setNotice((cause as Error).message); } }}/></td><td className="p-3"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${guardrail.name}`} onClick={async () => { await select(guardrail); setDialog("edit"); }}><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 save(items.filter((item) => item.name !== guardrail.name)); if (selected?.name === guardrail.name) { setSelected(undefined); setPrompt(""); } toast("Guardrail deleted from this profile."); } catch (cause) { setNotice((cause 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-[92vh] w-full max-w-3xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><div className="mb-5 flex items-center justify-between gap-3"><div><h2 className="text-lg font-semibold">{dialog === "create" ? "Create guardrail" : `Edit ${selected?.name}`}</h2><p className="text-sm text-zinc-500">{dialog === "create" ? "Create a profile rule and its global Markdown prompt." : "Edit the global Markdown prompt for this guardrail."}</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={() => setDialog(undefined)}><X size={17}/></Button></div><div className="grid gap-5">{dialog === "create" && <Card><CardTitle>Create guardrail</CardTitle><div className="mt-4 grid gap-5"><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Name</span><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="e.g. safe-output"/><span className="text-xs font-normal text-zinc-500">Lowercase letters, numbers and hyphens. The prompt file will be <code>{name || "name"}.md</code>.</span></label><div className="grid gap-4 sm:grid-cols-3"><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Stage</span><select className="rounded-md border border-zinc-200 px-3 py-2 text-sm font-normal" value={stage} onChange={(event) => setStage(event.target.value as Guardrail["stage"])}>{guardrailStages.map((value) => <option key={value}>{value}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Mode</span><select className="rounded-md border border-zinc-200 px-3 py-2 text-sm font-normal" value={mode} onChange={(event) => setMode(event.target.value as Guardrail["mode"])}>{guardrailModes.map((value) => <option key={value}>{value}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Order</span><Input className="font-normal" type="number" min="0" value={order} onChange={(event) => setOrder(event.target.value)}/></label></div><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Markdown instructions</span><span className="text-xs font-normal text-zinc-500">These instructions are global and can be shared by multiple profiles.</span></label><div data-color-mode="light"><MDEditor value={newPrompt} onChange={(value) => setNewPrompt(value ?? "")} preview="edit" height={280}/></div><Button disabled={!/^[a-z][a-z0-9-]{0,63}$/.test(name) || !Number.isInteger(Number(order))} onClick={async () => { const file = `${name}.md`; if (items.some((item) => item.name === name)) return setNotice("A guardrail with this name already exists."); try { await request(`profiles/${profile}/guardrails/${encodeURIComponent(file)}/document`, { method: "PUT", body: JSON.stringify({ content: newPrompt }) }); const guardrail: Guardrail = { name, stage, mode, order: Number(order), file, enabled: true }; await save([...items, guardrail]); setName(""); setNewPrompt(""); await select(guardrail); toast("Guardrail created and enabled."); } catch (cause) { setNotice((cause as Error).message); } }}><Check className="mr-2" size={16}/>Create</Button></div></Card>}{dialog === "edit" && selected && <Card><div className="flex items-center justify-between gap-3"><div><CardTitle>{selected.name}.md</CardTitle><p className="text-sm text-zinc-500">Global prompt shared by all profiles that reference this file.</p></div><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save guardrail" disabled={!Number.isInteger(Number(order))} onClick={async () => { try { await request(`profiles/${profile}/guardrails/${encodeURIComponent(selected.file)}/document`, { method: "PUT", body: JSON.stringify({ content: prompt }) }); const updated = { ...selected, stage, mode, order: Number(order) }; await save(items.map((item) => item.name === selected.name ? updated : item)); setSelected(updated); toast("Guardrail saved."); } catch (cause) { setNotice((cause as Error).message); } }}><Save size={17}/></Button></div><div className="mt-4 grid gap-4 sm:grid-cols-3"><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Stage</span><select className="rounded-md border border-zinc-200 px-3 py-2 text-sm font-normal" value={stage} onChange={(event) => setStage(event.target.value as Guardrail["stage"])}>{guardrailStages.map((value) => <option key={value}>{value}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Mode</span><select className="rounded-md border border-zinc-200 px-3 py-2 text-sm font-normal" value={mode} onChange={(event) => setMode(event.target.value as Guardrail["mode"])}>{guardrailModes.map((value) => <option key={value}>{value}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Order</span><Input className="font-normal" type="number" min="0" value={order} onChange={(event) => setOrder(event.target.value)}/></label></div><div className="mt-4 grid gap-1.5"><label className="text-sm font-medium text-zinc-700">Markdown instructions</label><p className="text-xs text-zinc-500">Syntax-highlighted Markdown editor. Saving updates the shared prompt file.</p><div data-color-mode="light"><MDEditor value={prompt} onChange={(value) => setPrompt(value ?? "")} preview="edit" height={420}/></div></div></Card>}</div></div></div>}</div>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Environment({ profile, initial }: { profile: string; initial?: { content: string; exists: boolean } }) { const { toast } = useToast(); const [value, setValue] = useState(initial?.content ?? ""), [exists, setExists] = useState(initial?.exists ?? false), [notice, setNotice] = useState(""); useEffect(() => { if (initial) { setValue(initial.content); setExists(initial.exists); return; } getJson<{ content: string; exists: boolean }>(`profiles/${profile}/env`).then((data) => { setValue(data.content); setExists(data.exists); }).catch((cause) => setNotice(cause.message)); }, [profile, initial]); return <Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>.env</CardTitle><p className="text-sm text-zinc-500">Profile environment variables. This file may contain secrets.</p></div><Button onClick={async () => { try { const saved = await request(`profiles/${profile}/env`, { method: "PUT", body: JSON.stringify({ content: value }) }); const data = await saved.json() as { exists: boolean }; setExists(data.exists); setNotice(""); toast("Environment saved. New profile runs use these values."); } catch (cause) { setNotice((cause as Error).message); } }} className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save environment"><Save size={17}/></Button></div><p className="mt-3 text-sm text-amber-700">{exists ? "Changes apply to new profile runs; do not share these values." : "No .env exists yet. Saving creates it with owner-only access."}</p>{notice && <p className="mt-3 text-sm text-red-700">{notice}</p>}<div className="mt-4 overflow-hidden rounded-lg border border-zinc-200"><CodeMirror value={value} height="520px" extensions={[envLanguage, envHighlighting]} onChange={setValue}/></div></Card>; }
|
|
68
|
+
function MarkdownDocument({ profile, type, title, initial }: { profile: string; type: "soul"; title: string; initial?: { content: string } }) { const { toast } = useToast(); const [value, setValue] = useState(initial?.content ?? ""), [notice, setNotice] = useState(""); useEffect(() => { if (initial) { setValue(initial.content); return; } getJson<{ content: string }>(`profiles/${profile}/${type}`).then(({ content }) => setValue(content)).catch((cause) => setNotice(cause.message)); }, [profile, type, initial]); return <Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>{title}</CardTitle><p className="text-sm text-zinc-500">Markdown editor with live preview</p></div><Button onClick={async () => { try { await request(`profiles/${profile}/${type}`, { method: "PUT", body: JSON.stringify({ content: value }) }); toast("Document saved."); } catch (cause) { setNotice((cause as Error).message); } }} className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save document"><Save size={17}/></Button></div>{notice && <p className="mt-3 text-sm text-amber-700">{notice}</p>}<div className="mt-4" data-color-mode="light"><MDEditor value={value} onChange={(next) => setValue(next ?? "")} preview="live" height={520}/></div></Card>; }
|
|
69
|
+
function Resources({ profile, kind, initial }: { profile: string; kind: "skills" | "tools" | "extensions"; initial?: { resources: Resource[] } }) { const [items, setItems] = useState<Resource[]>(initial?.resources ?? []), [notice, setNotice] = useState(""); const load = async () => { try { setItems((await getJson<{ resources: Resource[] }>(`profiles/${profile}/resources/${kind}`)).resources); } catch (cause) { setNotice((cause as Error).message); } }; useEffect(() => { if (initial) { setItems(initial.resources); return; } void load(); }, [profile, kind, initial]); return <Card><div><CardTitle>{kind[0].toUpperCase() + kind.slice(1)}</CardTitle><p className="text-sm text-zinc-500">Changes apply when the profile runtime is loaded again.</p></div>{notice && <p className="mt-3 text-sm text-amber-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">Source</th><th className="p-3">Path</th><th className="p-3">Status</th></tr></thead><tbody>{items.map((item) => <tr key={item.name} className="border-b border-zinc-100"><td className="p-3 font-medium">{item.name}{item.protected && <span className="ml-2 text-xs text-zinc-400">protected</span>}</td><td className="p-3 text-zinc-500">{item.source}{item.package && <span className="block text-xs text-sky-700">{item.package}</span>}</td><td className="max-w-xs truncate p-3 text-zinc-500">{item.path ?? "—"}</td><td className="p-3">{item.protected ? <span className="text-zinc-500">Required</span> : item.source === "package" ? <span className="text-zinc-500">Manage in Packages</span> : <label className="flex items-center gap-2"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { await request(`profiles/${profile}/resources/${kind}/${encodeURIComponent(item.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); }}/><span className={item.enabled ? "text-emerald-700/70" : "text-rose-700/70"}>{item.enabled ? "Enabled" : "Disabled"}</span></label>}</td></tr>)}</tbody></table></div></Card>; }
|
|
70
|
+
|
|
71
|
+
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>; }
|
|
72
|
+
|
|
73
|
+
const profileColors = ["bg-[#19a0dd]"];
|
|
74
|
+
const profileColor = (name: string) => profileColors[[...name].reduce((sum, char) => sum + char.charCodeAt(0), 0) % profileColors.length];
|
|
75
|
+
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>; }
|
|
76
|
+
|
|
77
|
+
const navigation = [
|
|
78
|
+
{ href: "/", label: "Overview", icon: Activity, color: "text-slate-600" },
|
|
79
|
+
{ href: "/settings", label: "Settings", icon: Settings2, color: "text-amber-600" },
|
|
80
|
+
{ href: "/env", label: "Environment", icon: Wrench, color: "text-lime-600" },
|
|
81
|
+
{ href: "/soul", label: "SOUL", icon: Sparkles, color: "text-purple-600" },
|
|
82
|
+
{ href: "/guardrails", label: "Guardrails", icon: ShieldCheck, color: "text-violet-600" },
|
|
83
|
+
{ href: "/packages", label: "Packages", icon: Box, color: "text-sky-600" },
|
|
84
|
+
{ href: "/skills", label: "Skills", icon: Sparkles, color: "text-emerald-600" },
|
|
85
|
+
{ href: "/tools", label: "Tools", icon: Wrench, color: "text-cyan-600" },
|
|
86
|
+
{ href: "/extensions", label: "Extensions", icon: FileCode2, color: "text-orange-600" },
|
|
87
|
+
{ href: "/chat", label: "Chat", icon: MessageSquare, color: "text-blue-600" },
|
|
88
|
+
{ href: "/pulses", label: "Pulses", icon: Activity, color: "text-rose-600" },
|
|
89
|
+
{ href: "/api-server", label: "API Server", icon: Wrench, color: "text-indigo-600", admin: true },
|
|
90
|
+
{ href: "/pi-console-webui", label: "Pi Console WebUI", icon: Cog, color: "text-slate-600", admin: true },
|
|
91
|
+
{ href: "/applications", label: "Applications", icon: Box, color: "text-emerald-600", admin: true },
|
|
92
|
+
{ href: "/skill-sources", label: "Skill Sources", icon: GitBranch, color: "text-[#d7c900]", admin: true },
|
|
93
|
+
] as const;
|
|
94
|
+
|
|
95
|
+
export function Console({ section = "overview", applicationSlug, initial = {} }: { section?: string; applicationSlug?: string; initial?: ConsoleInitialData }) {
|
|
96
|
+
const searchParams = useSearchParams();
|
|
97
|
+
const requestedProfile = searchParams.get("profile") ?? "default";
|
|
98
|
+
const administrative = searchParams.get("context") === "admin";
|
|
99
|
+
const { profiles, health, error, initialize, refreshProfiles } = useConsoleState();
|
|
100
|
+
const [profile, setProfile] = useState(requestedProfile), [profileMenu, setProfileMenu] = useState(false), [createDialog, setCreateDialog] = useState(false), [newProfile, setNewProfile] = useState(""), [profileError, setProfileError] = useState(""), [creatingProfile, setCreatingProfile] = useState(false);
|
|
101
|
+
useEffect(() => setProfile(requestedProfile), [requestedProfile]);
|
|
102
|
+
const router = useRouter();
|
|
103
|
+
const selectProfile = (name: string) => { const destination = administrative || section === "overview" ? "/" : `/${section}`; router.replace(`${destination}?profile=${encodeURIComponent(name)}`); };
|
|
104
|
+
const refresh = async () => { const data = await refreshProfiles(); if (!data.some((item) => item.name === profile)) setProfile("default"); };
|
|
105
|
+
useEffect(() => { void initialize(); }, [initialize]);
|
|
106
|
+
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 === "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 === "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 === "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 === "extensions" ? <Resources profile={profile} kind="extensions" initial={initial.resources}/> : <Overview health={health} profiles={profiles} profile={profile}/>;
|
|
107
|
+
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 w-72 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("/api-server?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><div className="max-h-60 overflow-y-auto">{profiles.map(({ name }) => <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 truncate">{name}</span></button>{name !== "default" && <button title={`Delete ${name}`} className="rounded px-2 py-1 text-xs text-zinc-400 hover:bg-red-50 hover:text-red-600" onClick={async () => { if (confirm(`Delete ${name}?`)) { await request(`profiles/${name}`, { method: "DELETE", body: JSON.stringify({ force: true }) }); await refresh(); if (profile === name) selectProfile("default"); } }}><Trash2 size={14}/></button>}</div>)}</div><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(""); 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/>{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)} onClick={async () => { if (creatingProfile) return; try { setCreatingProfile(true); await request("profiles", { method: "POST", body: JSON.stringify({ name: newProfile }) }); 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>}</main>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function SkillResources({ profile, initial }: { profile: string; initial?: { resources: Resource[] } }) {
|
|
111
|
+
const [items, setItems] = useState<Resource[]>(initial?.resources ?? []), [notice, setNotice] = useState(""), [document, setDocument] = useState<{ name: string; frontmatter: Record<string, string>; content: string }>();
|
|
112
|
+
const load = async () => { try { setItems((await getJson<{ resources: Resource[] }>(`profiles/${profile}/resources/skills`)).resources); } catch (cause) { setNotice((cause as Error).message); } };
|
|
113
|
+
useEffect(() => { if (initial) { setItems(initial.resources); return; } void load(); }, [profile, initial]);
|
|
114
|
+
return <Card><div><CardTitle>Skills</CardTitle><p className="text-sm text-zinc-500">Changes apply when the profile runtime is loaded again.</p></div>{notice && <p className="mt-3 text-sm text-amber-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">Source</th><th className="p-3">Path</th><th className="p-3">Status</th><th className="p-3"></th></tr></thead><tbody>{items.map((item) => <tr key={item.name} className="border-b border-zinc-100"><td className="p-3 font-medium">{item.name}</td><td className="p-3 text-zinc-500">{item.source}</td><td className="max-w-xs truncate p-3 text-zinc-500">{item.path ?? "—"}</td><td className="p-3"><label className="flex items-center gap-2"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { await request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); }}/><span className={item.enabled ? "text-emerald-700/70" : "text-rose-700/70"}>{item.enabled ? "Enabled" : "Disabled"}</span></label></td><td className="p-3"><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="View skill" onClick={async () => setDocument(await getJson(`profiles/${profile}/skills/${encodeURIComponent(item.name)}/document`))}><Eye size={17}/></button></td></tr>)}</tbody></table></div>{document && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="flex max-h-[85vh] w-full max-w-4xl flex-col overflow-hidden rounded-xl bg-white shadow-2xl"><header className="border-b border-zinc-200 p-5"><div className="flex items-start justify-between gap-4"><div className="min-w-0"><p className="text-xs font-medium uppercase tracking-wider text-zinc-400">Skill document</p><h2 className="mt-1 truncate text-xl font-semibold text-zinc-900">{document.name}</h2></div><div className="flex shrink-0 items-center gap-2"><button className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Copy SKILL.md" onClick={() => navigator.clipboard.writeText(`---\n${Object.entries(document.frontmatter).map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n\n${document.content}`)}><Copy size={18}/></button><button className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Close" onClick={() => setDocument(undefined)}><X size={19}/></button></div></div><div className="mt-3 flex flex-wrap gap-2">{Object.entries(document.frontmatter).map(([key, value]) => <span key={key} className="rounded-full bg-emerald-50 px-2.5 py-1 text-xs text-emerald-800"><b>{key}:</b> {value}</span>)}</div></header><article className="prose max-w-none overflow-y-auto p-6"><ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown></article></div></div>}</Card>;
|
|
115
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import CodeMirror from "@uiw/react-codemirror";
|
|
5
|
+
import { markdown } from "@codemirror/lang-markdown";
|
|
6
|
+
import { Check, ChevronDown, Eye, FileCode2, Loader2, MessageSquare, Plus, RotateCw, Save, ShieldCheck, Sparkles, Trash2, Wrench, X } from "lucide-react";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
9
|
+
import { Input } from "@/components/ui/input";
|
|
10
|
+
import { Switch } from "@/components/ui/switch";
|
|
11
|
+
import { useToast } from "@/components/toast";
|
|
12
|
+
|
|
13
|
+
type Stage = "input" | "pre_tool" | "post_tool" | "output";
|
|
14
|
+
type Mode = "transform" | "evaluate" | "reflect";
|
|
15
|
+
type Guardrail = { name: string; stage: Stage; mode: Mode; order: number; file: string; enabled?: boolean };
|
|
16
|
+
type Form = { name: string; stage: Stage; mode: Mode; order: string; prompt: string };
|
|
17
|
+
type Option<T extends string> = { value: T; label: string; description: string; icon: typeof MessageSquare };
|
|
18
|
+
|
|
19
|
+
const stages: Option<Stage>[] = [
|
|
20
|
+
{ value: "input", label: "On Input", description: "Before the agent processes a user message.", icon: MessageSquare },
|
|
21
|
+
{ value: "pre_tool", label: "Before Tool Execution", description: "Before a tool call is allowed to run.", icon: Wrench },
|
|
22
|
+
{ value: "post_tool", label: "After Tool Execution", description: "After a tool returns its result.", icon: FileCode2 },
|
|
23
|
+
{ value: "output", label: "On Output", description: "Handle the assistant response before delivery.", icon: Eye },
|
|
24
|
+
];
|
|
25
|
+
const modes: Option<Mode>[] = [
|
|
26
|
+
{ value: "transform", label: "Transform", description: "Rewrite content using the Markdown instructions.", icon: Sparkles },
|
|
27
|
+
{ value: "evaluate", label: "Evaluate", description: "Allow or block content with a safe user response.", icon: ShieldCheck },
|
|
28
|
+
{ value: "reflect", label: "Reflect", description: "Review a candidate response before finalizing it.", icon: Eye },
|
|
29
|
+
];
|
|
30
|
+
const emptyForm = (): Form => ({ name: "", stage: "input", mode: "evaluate", order: "10", prompt: "" });
|
|
31
|
+
|
|
32
|
+
async function request(path: string, init?: RequestInit): Promise<Response> {
|
|
33
|
+
const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } });
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
const body = await response.json().catch(() => ({}));
|
|
36
|
+
throw new Error(body.error?.message ?? body.error ?? "Request failed");
|
|
37
|
+
}
|
|
38
|
+
return response;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function OptionSelect<T extends string>({ label, value, options, onChange }: { label: string; value: T; options: Option<T>[]; onChange: (value: T) => void }) {
|
|
42
|
+
const [open, setOpen] = useState(false);
|
|
43
|
+
const selected = options.find((option) => option.value === value) ?? options[0];
|
|
44
|
+
const Icon = selected.icon;
|
|
45
|
+
return <label className="relative grid gap-1.5 text-sm font-semibold text-zinc-800"><span>{label}</span><button type="button" className="flex min-h-11 w-full items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 py-2 text-left font-normal shadow-sm hover:border-zinc-300" onClick={() => setOpen((current) => !current)}><Icon className="shrink-0 text-violet-600" size={17}/><span className="min-w-0 flex-1 truncate">{selected.label}</span><ChevronDown className="shrink-0 text-zinc-400" size={16}/></button>{open && <div className="absolute top-[4.65rem] z-20 w-full overflow-hidden rounded-lg border border-zinc-200 bg-white py-1 shadow-xl">{options.map((option) => { const ItemIcon = option.icon; return <button key={option.value} type="button" className="flex w-full items-start gap-3 px-3 py-2.5 text-left hover:bg-zinc-50" onClick={() => { onChange(option.value); setOpen(false); }}><ItemIcon className="mt-0.5 shrink-0 text-violet-600" size={17}/><span className="min-w-0 flex-1"><span className="block text-sm font-medium text-zinc-800">{option.label}</span><span className="mt-0.5 block text-xs font-normal leading-4 text-zinc-500">{option.description}</span></span>{option.value === value && <Check className="mt-0.5 shrink-0 text-emerald-600" size={16}/>}</button>; })}</div>}</label>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function GuardrailsPanel({ profile, initial }: { profile: string; initial?: { content: string } }) {
|
|
49
|
+
const { toast } = useToast();
|
|
50
|
+
const [items, setItems] = useState<Guardrail[]>([]);
|
|
51
|
+
const [notice, setNotice] = useState("");
|
|
52
|
+
const [dialog, setDialog] = useState<"create" | "edit">();
|
|
53
|
+
const [editing, setEditing] = useState<Guardrail>();
|
|
54
|
+
const [form, setForm] = useState<Form>(emptyForm());
|
|
55
|
+
const [saving, setSaving] = useState(false);
|
|
56
|
+
const parse = (content: string) => { const value = JSON.parse(content) as { guardrails?: Guardrail[] }; if (!Array.isArray(value.guardrails)) throw new Error("guardrails.json must contain a guardrails array."); return value.guardrails; };
|
|
57
|
+
const load = async () => { try { const response = await request(`profiles/${profile}/guardrails`); const data = await response.json() as { content: string }; setItems(parse(data.content)); setNotice(""); } catch (error) { setNotice((error as Error).message); } };
|
|
58
|
+
const saveConfig = async (next: Guardrail[]) => { await request(`profiles/${profile}/guardrails`, { method: "PUT", body: JSON.stringify({ content: `${JSON.stringify({ guardrails: next }, null, 2)}\n` }) }); setItems(next); };
|
|
59
|
+
const openCreate = () => { setForm(emptyForm()); setEditing(undefined); setDialog("create"); };
|
|
60
|
+
const openEdit = async (guardrail: Guardrail) => { try { const response = await request(`profiles/${profile}/guardrails/${encodeURIComponent(guardrail.file)}/document`); const data = await response.json() as { content: string }; setEditing(guardrail); setForm({ name: guardrail.name, stage: guardrail.stage, mode: guardrail.mode, order: String(guardrail.order), prompt: data.content }); setDialog("edit"); } catch (error) { setNotice((error as Error).message); } };
|
|
61
|
+
const close = () => { setDialog(undefined); setEditing(undefined); };
|
|
62
|
+
const valid = /^[a-z][a-z0-9-]{0,63}$/.test(form.name) && Number.isInteger(Number(form.order));
|
|
63
|
+
const saveDialog = async () => {
|
|
64
|
+
if (!valid || saving) return;
|
|
65
|
+
const file = editing?.file ?? `${form.name}.md`;
|
|
66
|
+
if (!editing && items.some((item) => item.name === form.name)) { setNotice("A guardrail with this name already exists."); return; }
|
|
67
|
+
try {
|
|
68
|
+
setSaving(true);
|
|
69
|
+
await request(`profiles/${profile}/guardrails/${encodeURIComponent(file)}/document`, { method: "PUT", body: JSON.stringify({ content: form.prompt }) });
|
|
70
|
+
const nextGuardrail: Guardrail = { name: form.name, stage: form.stage, mode: form.mode, order: Number(form.order), file, enabled: editing?.enabled ?? true };
|
|
71
|
+
await saveConfig(editing ? items.map((item) => item.name === editing.name ? nextGuardrail : item) : [...items, nextGuardrail]);
|
|
72
|
+
toast(editing ? "Guardrail saved." : "Guardrail created and enabled.");
|
|
73
|
+
close();
|
|
74
|
+
} catch (error) { setNotice((error as Error).message); } finally { setSaving(false); }
|
|
75
|
+
};
|
|
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 disabled={dialog === "edit"} 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>.</> : <>Names cannot change after creation. File: <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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
5
|
+
import { Switch } from "@/components/ui/switch";
|
|
6
|
+
|
|
7
|
+
type Package = { name: string; version?: string; description?: string; enabled: boolean; installed: boolean };
|
|
8
|
+
export function PackageResources({ profile, initial }: { profile: string; initial?: { packages: Package[] } }) {
|
|
9
|
+
const [items, setItems] = useState<Package[]>(initial?.packages ?? []), [notice, setNotice] = useState("");
|
|
10
|
+
const load = async () => { try { const response = await fetch(`/api/pi/profiles/${encodeURIComponent(profile)}/packages`); if (!response.ok) throw new Error("Unable to load packages."); setItems((await response.json() as { packages: Package[] }).packages); } catch (error) { setNotice((error as Error).message); } };
|
|
11
|
+
useEffect(() => { if (initial) { setItems(initial.packages); return; } void load(); }, [profile, initial]);
|
|
12
|
+
return <Card><div><CardTitle>Packages</CardTitle><p className="text-sm text-zinc-500">Globally installed npm packages. Enable them independently for this profile.</p></div>{notice && <p className="mt-3 text-sm text-amber-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">Version</th><th className="p-3">Description</th><th className="p-3">Status</th></tr></thead><tbody>{items.map((item) => <tr key={item.name} className="border-b border-zinc-100"><td className="p-3 font-medium">{item.name}</td><td className="p-3 text-zinc-500">{item.version ?? "—"}</td><td className="max-w-md truncate p-3 text-zinc-500">{item.description ?? "—"}</td><td className="p-3">{item.installed ? <label className="flex items-center gap-2"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { const response = await fetch(`/api/pi/profiles/${encodeURIComponent(profile)}/packages/${encodeURIComponent(item.name)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) }); if (!response.ok) throw new Error("Unable to update package."); await load(); } catch (error) { setNotice((error as Error).message); } }}/><span className={item.enabled ? "text-emerald-700/70" : "text-rose-700/70"}>{item.enabled ? "Enabled" : "Disabled"}</span></label> : <span className="text-rose-700/70">Not installed</span>}</td></tr>)}{items.length === 0 && <tr><td colSpan={4} className="p-5 text-zinc-500">No globally installed packages.</td></tr>}</tbody></table></div></Card>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, useEffect, useState } from "react";
|
|
3
|
+
import CodeMirror from "@uiw/react-codemirror";
|
|
4
|
+
import { markdown } from "@codemirror/lang-markdown";
|
|
5
|
+
import { Check, ChevronDown, ChevronRight, FilePenLine, Loader2, Plus, RotateCw, Save, Trash2, X } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
import { Switch } from "@/components/ui/switch";
|
|
10
|
+
import { useToast } from "@/components/toast";
|
|
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 };
|
|
13
|
+
type HistoryPulse = Pulse & { startedAt: string; finishedAt: string | null; status: string; error: string | null };
|
|
14
|
+
type Session = { id: string; name?: string | null };
|
|
15
|
+
type Form = { name: string; description: string; schedule: string; prompt: string; thread_session_id: string; enabled: boolean };
|
|
16
|
+
const blank = (): Form => ({ name: "", description: "", schedule: "*/15 * * * *", prompt: "", thread_session_id: "", enabled: true });
|
|
17
|
+
function scheduleDescription(schedule: string): { type: Pulse["type"]; text: string } {
|
|
18
|
+
if (schedule.startsWith("@once:")) return { type: "cron", text: `Runs once at ${schedule.slice(6)}.` };
|
|
19
|
+
const [minute = "", hour = "", day = "", month = "", weekDay = ""] = schedule.trim().split(/\s+/);
|
|
20
|
+
if (minute === "*" && hour === "*" && day === "*" && month === "*" && weekDay === "*") return { type: "heartbeat", text: "Runs every minute (UTC)." };
|
|
21
|
+
if (/^\d+$/.test(minute) && hour === "*" && day === "*" && month === "*" && weekDay === "*") return { type: "heartbeat", text: `Runs every hour at minute ${minute.padStart(2, "0")} (UTC).` };
|
|
22
|
+
const minuteStep = minute.match(/^\*\/(\d+)$/);
|
|
23
|
+
if (minuteStep) { const value = Number(minuteStep[1]); return { type: value < 240 ? "heartbeat" : "cron", text: `Runs every ${value} minute${value === 1 ? "" : "s"} (UTC).` }; }
|
|
24
|
+
const hourStep = hour.match(/^\*\/(\d+)$/);
|
|
25
|
+
if (hourStep && minute === "0") { const value = Number(hourStep[1]); return { type: value < 4 ? "heartbeat" : "cron", text: `Runs every ${value} hour${value === 1 ? "" : "s"}, on the hour (UTC).` }; }
|
|
26
|
+
if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && day === "*" && month === "*" && weekDay === "*") return { type: "cron", text: `Runs every day at ${hour.padStart(2, "0")}:${minute.padStart(2, "0")} UTC.` };
|
|
27
|
+
return { type: "cron", text: "Runs according to this cron schedule (UTC)." };
|
|
28
|
+
}
|
|
29
|
+
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; }
|
|
30
|
+
|
|
31
|
+
export function PulseResources({ profile, initial }: { profile: string; initial?: { pulses: Pulse[] } }) {
|
|
32
|
+
const { toast } = useToast(); const [items, setItems] = useState<Pulse[]>(initial?.pulses ?? []), [notice, setNotice] = useState(""), [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());
|
|
33
|
+
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); setNotice(""); } catch (cause) { setNotice((cause as Error).message); } };
|
|
34
|
+
useEffect(() => { void load(); }, [profile]);
|
|
35
|
+
const close = () => { setDialog(undefined); setEditing(undefined); };
|
|
36
|
+
const create = () => { setForm(blank()); setEditing(undefined); setDialog("create"); };
|
|
37
|
+
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"); };
|
|
38
|
+
const valid = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(form.name) && Boolean(form.description.trim() && form.schedule.trim() && form.prompt.trim());
|
|
39
|
+
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) { setNotice((cause as Error).message); } finally { setSaving(false); } };
|
|
40
|
+
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) { setNotice((cause as Error).message); } }}/><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>{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">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) { setNotice((cause 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={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={`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) { setNotice((cause as Error).message); } }}><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>{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>}</>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Copy, Eye, Loader2, RotateCw, Trash2, Upload, X } from "lucide-react";
|
|
5
|
+
import ReactMarkdown from "react-markdown";
|
|
6
|
+
import remarkGfm from "remark-gfm";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
9
|
+
import { Switch } from "@/components/ui/switch";
|
|
10
|
+
import { SkillSourceImport } from "@/components/skill-source-import";
|
|
11
|
+
import { useToast } from "@/components/toast";
|
|
12
|
+
|
|
13
|
+
type Source = "shared" | "profile";
|
|
14
|
+
type Skill = { name: string; path?: string; source: Source; enabled: boolean };
|
|
15
|
+
type Document = { name: string; path?: string; frontmatter: Record<string, unknown>; content: string };
|
|
16
|
+
type Sources = { shared: boolean; profile: boolean };
|
|
17
|
+
type SkillSource = { identifier: string; name: string; enabled: boolean; hasAccessToken: boolean };
|
|
18
|
+
function formatFrontmatterValue(value: unknown): string { return typeof value === "string" ? value : JSON.stringify(value) ?? String(value); }
|
|
19
|
+
async function request(path: string, init?: RequestInit): Promise<Response> { 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; }
|
|
20
|
+
async function getJson<T>(path: string): Promise<T> { return (await request(path)).json() as Promise<T>; }
|
|
21
|
+
|
|
22
|
+
export function SkillResourcesPanel({ profile, initial }: { profile: string; initial?: { resources: Array<Omit<Skill, "source"> & { source: string }> } }) {
|
|
23
|
+
const { toast } = useToast();
|
|
24
|
+
const [items, setItems] = useState<Skill[]>((initial?.resources ?? []) as Skill[]), [sources, setSources] = useState<Sources>({ shared: true, profile: false }), [document, setDocument] = useState<Document>();
|
|
25
|
+
const [publishing, setPublishing] = useState<Skill>(); const [syncing, setSyncing] = useState<Skill>(); const [targets, setTargets] = useState<SkillSource[]>([]), [target, setTarget] = useState(""), [busy, setBusy] = useState(false);
|
|
26
|
+
const isDefault = profile === "default";
|
|
27
|
+
const load = async () => { const [resources, sourceState] = await Promise.all([getJson<{ resources: Skill[] }>(`profiles/${profile}/resources/skills`), getJson<{ sources: Sources }>(`profiles/${profile}/skills/sources`)]); setItems(resources.resources); setSources(sourceState.sources); };
|
|
28
|
+
const refresh = () => void load().catch((error) => toast((error as Error).message, "error"));
|
|
29
|
+
const setSource = async (source: Source, enabled: boolean) => { const next = { ...sources, [source]: enabled }; if (!next.shared && !next.profile) return toast("Enable at least one skill source.", "error"); try { const response = await request(`profiles/${profile}/skills/sources`, { method: "PUT", body: JSON.stringify(next) }); setSources((await response.json() as { sources: Sources }).sources); await load(); } catch (error) { toast((error as Error).message, "error"); } };
|
|
30
|
+
const openPublish = async (skill: Skill) => { if (skill.source === "shared" && profile !== "default") return toast("Shared Skills can only be published by the default profile.", "error"); try { const available = (await getJson<{ sources: SkillSource[] }>("skill-sources")).sources.filter((source) => source.enabled && source.hasAccessToken); setTargets(available); setTarget(available[0]?.identifier ?? ""); setPublishing(skill); } catch (error) { toast((error as Error).message, "error"); } };
|
|
31
|
+
const publish = async () => { if (!publishing || !target) return; setBusy(true); try { const data = await (await request(`profiles/${profile}/skills/${encodeURIComponent(publishing.name)}/publish?source=${publishing.source}`, { method: "POST", body: JSON.stringify({ identifier: target }) })).json() as { publication: { action: string } }; toast(`Skill ${data.publication.action} in the selected Skill Source.`, "success"); setPublishing(undefined); await load(); } catch (error) { toast((error as Error).message, "error"); } finally { setBusy(false); } };
|
|
32
|
+
const syncFromSource = async () => { if (!syncing) return; setBusy(true); try { await request(`profiles/${profile}/skills/${encodeURIComponent(syncing.name)}/sync?source=${syncing.source}`, { method: "POST" }); toast("Skill updated from its Skill Source.", "success"); setSyncing(undefined); await load(); } catch (error) { toast((error as Error).message, "error"); } finally { setBusy(false); } };
|
|
33
|
+
useEffect(() => { refresh(); }, [profile]);
|
|
34
|
+
return <Card><div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-3"><CardTitle>Skills</CardTitle>{isDefault ? <span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600">Shared Skills</span> : <div className="ml-8 flex items-center gap-4 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:ml-16"><label className="flex items-center gap-2 text-sm font-medium text-zinc-700"><Switch className="h-4 w-7 data-[state=checked]:bg-[#55d175] [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3" checked={sources.shared} onCheckedChange={(enabled) => void setSource("shared", enabled)}/><span>Shared Skills</span></label><label className="flex items-center gap-2 text-sm font-medium text-zinc-700"><Switch className="h-4 w-7 data-[state=checked]:bg-[#6d28d9] [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3" checked={sources.profile} onCheckedChange={(enabled) => void setSource("profile", enabled)}/><span>Profile Skills</span></label></div>}</div><p className="mt-2 text-sm text-zinc-500">{isDefault ? "Shared skills are editable by the default profile." : "Shared skills are read-only; Profile Skills can be edited by this profile."}</p></div><div className="flex gap-2"><SkillSourceImport profile={profile} onImported={refresh}/><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload skills" onClick={refresh}><RotateCw 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">Source</th><th className="p-3">Path</th><th className="p-3">Status</th><th className="p-3"/></tr></thead><tbody>{items.map((item) => <tr key={`${item.source}:${item.name}`} className="border-b border-zinc-100"><td className="p-3 font-medium">{item.name}</td><td className="p-3"><span className={item.source === "profile" ? "text-[#6d28d9]" : "text-[#55d175]"}>{item.source === "profile" ? "Profile" : "Shared"}</span></td><td className="max-w-xs truncate p-3 text-zinc-500">{item.path ?? "—"}</td><td className="p-3"><label className="flex items-center gap-2"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}?source=${item.source}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); } catch (error) { toast((error as Error).message, "error"); } }}/><span className={item.enabled ? "text-emerald-700/70" : "text-rose-700/70"}>{item.enabled ? "Enabled" : "Disabled"}</span></label></td><td className="p-3"><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="View skill" onClick={() => void getJson<Document>(`profiles/${profile}/skills/${encodeURIComponent(item.name)}/document?source=${item.source}`).then((value) => setDocument({ ...value, path: item.path })).catch((error) => toast(error.message, "error"))}><Eye size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Apply version from Skill Source" onClick={() => setSyncing(item)}><RotateCw size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Publish to Skill Source" onClick={() => void openPublish(item)}><Upload size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-red-50 hover:text-red-700" title={item.source === "shared" && profile !== "default" ? "Remove from this profile" : "Delete skill"} onClick={() => { if (!confirm(item.source === "shared" && profile !== "default" ? `Remove '${item.name}' from this profile?` : `Delete '${item.name}'?`)) return; void request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}?source=${item.source}`, { method: "DELETE" }).then(refresh).catch((error) => toast(error.message, "error")); }}><Trash2 size={17}/></button></td></tr>)}{items.length === 0 && <tr><td colSpan={5} className="p-5 text-zinc-500">No skills are available from the enabled sources.</td></tr>}</tbody></table></div>{syncing && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="sync-skill-title"><div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="sync-skill-title" className="text-lg font-semibold">Apply Skill Source version</h2><p className="mt-1 text-sm text-zinc-500">Replace <b>{syncing.name}</b> with its current version from the installed Skill Source?</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" disabled={busy} onClick={() => setSyncing(undefined)}><X size={18}/></button></div><p className="mt-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-900">Local changes to this Skill, including auxiliary files, will be replaced.</p><div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" disabled={busy} onClick={() => setSyncing(undefined)}>Cancel</Button><Button className="inline-flex items-center justify-center gap-2" disabled={busy} onClick={() => void syncFromSource()}>{busy ? <Loader2 className="animate-spin" size={16}/> : <RotateCw size={16}/>}<span>Apply version</span></Button></div></div></div>}{publishing && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="publish-skill-title"><form onSubmit={(event) => { event.preventDefault(); void publish(); }} className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="publish-skill-title" className="text-lg font-semibold">Publish Skill</h2><p className="mt-1 text-sm text-zinc-500">Publish <b>{publishing.name}</b> to a Git-backed Skill Source.</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" onClick={() => setPublishing(undefined)}><X size={18}/></button></div><label className="mt-5 block text-sm font-medium text-zinc-700">Skill Source<select className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm" value={target} required onChange={(event) => setTarget(event.target.value)}>{targets.map((source) => <option key={source.identifier} value={source.identifier}>{source.name} ({source.identifier})</option>)}</select></label>{targets.length === 0 && <p className="mt-2 text-sm text-zinc-500">No enabled Skill Source with a write credential is available.</p>}<div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" onClick={() => setPublishing(undefined)}>Cancel</Button><Button type="submit" className="inline-flex items-center justify-center gap-2 bg-[#67c100] hover:bg-[#579f00]" disabled={!target || busy}>{busy ? <Loader2 className="animate-spin" size={16}/> : <Upload size={16}/>}<span>Publish</span></Button></div></form></div>}{document && <div className="fixed inset-0 z-50 bg-white" role="dialog" aria-modal="true"><div className="flex h-full flex-col"><header className="flex shrink-0 items-start justify-between gap-4 border-b border-zinc-200 px-6 py-4"><div className="min-w-0"><h2 className="truncate text-xl font-semibold text-slate-800">{document.name}</h2><p className="mt-0.5 truncate font-mono text-xs text-zinc-500">{document.path ?? `skills/${document.name}/SKILL.md`}</p></div><div className="flex shrink-0 items-center gap-2"><button className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Copy SKILL.md" onClick={() => navigator.clipboard.writeText(`---\n${Object.entries(document.frontmatter).map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n\n${document.content}`)}><Copy size={18}/></button><Button className="border border-zinc-300 bg-white text-zinc-700 shadow-sm hover:bg-zinc-100" onClick={() => setDocument(undefined)}>Close</Button></div></header><article className="overflow-y-auto px-4 py-6 sm:px-14"><div className="mx-auto max-w-5xl rounded-xl border border-zinc-200 bg-white p-6 shadow-sm"><section className="overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50"><div className="border-b border-zinc-200 bg-zinc-100 px-4 py-2 font-mono text-xs font-bold uppercase tracking-wide text-slate-500">Frontmatter</div><dl className="font-mono text-xs text-slate-800">{Object.entries(document.frontmatter).map(([key, value]) => <div key={key} className="grid grid-cols-[140px_1fr] border-b border-zinc-200 last:border-b-0"><dt className="bg-white/50 px-4 py-2 font-semibold text-slate-600">{key}</dt><dd className="whitespace-pre-wrap break-words px-4 py-2">{formatFrontmatterValue(value)}</dd></div>)}</dl></section><div className="skill-markdown prose mt-5 max-w-none prose-headings:text-slate-900 prose-p:text-slate-700"><ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown></div></div></article></div></div>}</Card>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Copy, X } from "lucide-react";
|
|
3
|
+
import ReactMarkdown from "react-markdown";
|
|
4
|
+
import remarkGfm from "remark-gfm";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
type Document={name:string;path:string;frontmatter:Record<string,string>;content:string;source:{identifier:string;name:string;commitHash:string|null}};
|
|
7
|
+
export function SkillSourceDocumentPreview({document,onClose}:{document:Document;onClose:()=>void}){return <div className="fixed inset-0 z-50 bg-white" role="dialog" aria-modal="true"><div className="flex h-full flex-col"><header className="flex shrink-0 items-start justify-between gap-4 border-b border-zinc-200 px-6 py-4"><div className="min-w-0"><h2 className="truncate text-xl font-semibold text-slate-800">{document.name}</h2><p className="mt-0.5 truncate font-mono text-xs text-zinc-500">{document.path}</p><p className="mt-1 text-xs text-zinc-500">Source: {document.source.name} · {document.source.identifier}{document.source.commitHash?` · ${document.source.commitHash.slice(0,12)}`:""}</p></div><div className="flex shrink-0 items-center gap-2"><button className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Copy SKILL.md" onClick={()=>navigator.clipboard.writeText(`---\n${Object.entries(document.frontmatter).map(([key,value])=>`${key}: ${value}`).join("\n")}\n---\n\n${document.content}`)}><Copy size={18}/></button><Button className="border border-zinc-300 bg-white text-zinc-700 shadow-sm hover:bg-zinc-100" onClick={onClose}><X className="mr-1" size={17}/>Close</Button></div></header><article className="overflow-y-auto px-4 py-6 sm:px-14"><div className="mx-auto max-w-5xl rounded-xl border border-zinc-200 bg-white p-6 shadow-sm"><section className="overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50"><div className="border-b border-zinc-200 bg-zinc-100 px-4 py-2 font-mono text-xs font-bold uppercase tracking-wide text-slate-500">Frontmatter</div><dl className="font-mono text-xs text-slate-800">{Object.entries(document.frontmatter).map(([key,value])=><div key={key} className="grid grid-cols-[140px_1fr] border-b border-zinc-200 last:border-b-0"><dt className="bg-white/50 px-4 py-2 font-semibold text-slate-600">{key}</dt><dd className="whitespace-pre-wrap break-words px-4 py-2">{value}</dd></div>)}</dl></section><div className="skill-markdown prose mt-5 max-w-none prose-headings:text-slate-900 prose-p:text-slate-700"><ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown></div></div></article></div></div>}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Download, Loader2, Plus, X } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { useToast } from "@/components/toast";
|
|
6
|
+
type Source={identifier:string;name:string;syncStatus:string};type Skill={name:string;relativePath:string;description:string|null;status:string};const api=async(path:string,init?:RequestInit)=>{const r=await fetch(`/api/pi/${path}`,{...init,headers:{"content-type":"application/json",...init?.headers}}),data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(data.error?.message??data.error??"Request failed");return data};
|
|
7
|
+
export function SkillSourceImport({profile,onImported}:{profile:string;onImported:()=>void}){const { toast }=useToast();const [open,setOpen]=useState(false),[sources,setSources]=useState<Source[]>([]),[skills,setSkills]=useState<Record<string,Skill[]>>({}),[busy,setBusy]=useState("");useEffect(()=>{if(!open)return;void api("skill-sources").then(async d=>{const available=(d.sources as Source[]).filter(s=>s.syncStatus==="succeeded"||s.syncStatus==="partial_failure");setSources(available);setSkills(Object.fromEntries(await Promise.all(available.map(async s=>[s.identifier,(await api(`skill-sources/${encodeURIComponent(s.identifier)}/skills`)).skills]))))}).catch(e=>toast(e.message,"error"))},[open]);const install=async(source:string,name:string)=>{try{setBusy(`${source}:${name}`);await api(`profiles/${encodeURIComponent(profile)}/skills/from-source/${encodeURIComponent(source)}/${encodeURIComponent(name)}`,{method:"POST"});onImported();setOpen(false)}catch(e){toast((e as Error).message,"error")}finally{setBusy("")}};return <><Button title="Add from Skill Sources" onClick={()=>setOpen(true)}><Plus size={17}/></Button>{open&&<div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4"><div className="max-h-[85vh] w-full max-w-3xl overflow-auto rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between"><div><h2 className="font-semibold">Add from Skill Sources</h2><p className="text-sm text-zinc-500">Imports a copy into {profile === "default" ? "Shared Skills" : `Profile Skills for ${profile}`}.</p></div><button onClick={()=>setOpen(false)}><X/></button></div><div className="mt-5 space-y-4">{sources.map(source=><section key={source.identifier}><h3 className="font-medium">{source.name} <span className="font-mono text-xs text-zinc-500">{source.identifier}</span></h3>{(skills[source.identifier]??[]).filter(s=>s.status==="synced").map(skill=><div className="mt-2 flex items-center justify-between rounded-lg border p-3" key={skill.name}><div><b>{skill.name}</b><p className="text-xs text-zinc-500">{skill.description??skill.relativePath}</p></div><Button className="!rounded-full !bg-[#09a4bb] hover:!bg-[#078a9d]" title={`Import ${skill.name}`} disabled={busy===`${source.identifier}:${skill.name}`} onClick={()=>void install(source.identifier,skill.name)}>{busy===`${source.identifier}:${skill.name}`?<Loader2 className="animate-spin" size={16}/>:<Download size={16}/>}</Button></div>)}</section>)}{sources.length===0&&<p className="text-sm text-zinc-500">No synchronized Skill Sources are available.</p>}</div></div></div>}</>}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Check, Eye, GitBranch, Loader2, Pencil, Plus, RefreshCw, Trash2, X } 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
|
+
import { SkillSourceDocumentPreview } from "@/components/skill-source-document-preview";
|
|
9
|
+
type Source={identifier:string;name:string;repoUrl:string;branch:string;basePath:string;username:string|null;hasAccessToken:boolean;enabled:boolean;lastCommitHash:string|null;lastSyncedAt:string|null;syncStatus:string;lastError:string|null};type Skill={name:string;relativePath:string;description:string|null;status:string};
|
|
10
|
+
const blank=()=>({name:"",identifier:"",repoUrl:"",branch:"main",basePath:"skills",username:"",accessToken:"",enabled:true});const normalizeIdentifier=(value:string)=>value.normalize("NFD").replace(/[\u0300-\u036f]/g,"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^[^a-z]+/,"").replace(/^-+|-+$/g,"").slice(0,64).replace(/-+$/g,"");const validIdentifier=(value:string)=>/^[a-z][a-z0-9-]{0,63}$/.test(value);
|
|
11
|
+
async function request(path:string,init?:RequestInit){const response=await fetch(`/api/pi/${path}`,{...init,headers:{"content-type":"application/json",...init?.headers}}),body=await response.json().catch(()=>({}));if(!response.ok)throw new Error(body.error?.message??body.error??"Request failed");return body}
|
|
12
|
+
export function SkillSources(){const { toast }=useToast();const[items,setItems]=useState<Source[]>([]),[form,setForm]=useState(blank()),[document,setDocument]=useState<any>(),[editing,setEditing]=useState<Source|null|undefined>(),[busy,setBusy]=useState(""),[details,setDetails]=useState<{source:Source;skills:Skill[]}>();const load=async()=>setItems((await request("skill-sources")).sources);useEffect(()=>{void load().catch(e=>toast(e.message,"error"))},[]);const open=(source?:Source)=>{setForm(source?{...source,username:source.username??"",accessToken:""}:blank());setEditing(source??null)};const save=async()=>{try{setBusy("save");await request("skill-sources",{method:"POST",body:JSON.stringify({...form,originalIdentifier:editing?.identifier})});setEditing(undefined);await load()}catch(e){toast((e as Error).message,"error")}finally{setBusy("")}};const sync=async(identifier:string)=>{try{setBusy(`sync:${identifier}`);const data=await request(`skill-sources/${encodeURIComponent(identifier)}/sync`,{method:"POST"});if(data.sync.status==="succeeded")toast(`${data.sync.discoveredCount} skill${data.sync.discoveredCount===1?"":"s"} synchronized successfully.`,"success");else if(data.sync.error)toast(data.sync.error,"error");await load()}catch(e){toast((e as Error).message,"error")}finally{setBusy("")}};return <Card><div className="flex items-start justify-between gap-4"><div><CardTitle>Skill Sources</CardTitle><p className="text-sm text-zinc-500">Synchronize Git catalogs. Skills are only available after an explicit profile import.</p></div><Button title="Add source" onClick={()=>open()}><Plus size={17}/></Button></div><div className="mt-5 space-y-3">{items.map(item=><article key={item.identifier} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="flex items-center gap-2 font-semibold"><GitBranch size={16}/>{item.name}<code className="text-xs font-normal text-zinc-500">{item.identifier}</code></div><p className="mt-1 break-all font-mono text-xs text-zinc-500">{item.repoUrl} · {item.branch} · {item.basePath}</p><p className="mt-1 text-xs text-zinc-500">{item.syncStatus} · {item.lastCommitHash?.slice(0,12)??"not synchronized"}</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Edit source" onClick={()=>open(item)}><Pencil size={16}/></Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Synchronize source" disabled={busy===`sync:${item.identifier}`} onClick={()=>void sync(item.identifier)}>{busy===`sync:${item.identifier}`?<Loader2 className="animate-spin" size={16}/>:<RefreshCw size={16}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" onClick={()=>void request(`skill-sources/${encodeURIComponent(item.identifier)}/skills`).then(data=>setDetails({source:item,skills:data.skills})).catch(e=>toast(e.message,"error"))}>Skills</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Delete source" onClick={()=>{if(confirm(`Delete ${item.name}?`))void request(`skill-sources/${encodeURIComponent(item.identifier)}`,{method:"DELETE"}).then(load).catch(e=>toast(e.message,"error"))}}><Trash2 size={16}/></Button></div></div></article>)}{items.length===0&&<p className="text-sm text-zinc-500">No Skill Sources registered.</p>}</div>{editing!==undefined&&<div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><form onSubmit={e=>{e.preventDefault();void save()}} className="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between"><div><h2 className="text-lg font-semibold">{editing?"Edit Skill Source":"Add Skill Source"}</h2><p className="text-sm text-zinc-500">The repository is synchronized into the managed Skill Sources directory.</p></div><div className="flex gap-2"><Button type="button" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={()=>setEditing(undefined)}><X size={17}/></Button><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" type="submit" title={editing?"Save Skill Source":"Add Skill Source"} disabled={busy==="save"||!validIdentifier(form.identifier)}>{busy==="save"?<Loader2 className="animate-spin" size={16}/>:<Check size={17}/>}</Button></div></div><div className="mt-5 grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">Name<Input className="mt-1" value={form.name} required onChange={e=>setForm({...form,name:e.target.value})} onBlur={e=>setForm(current=>({...current,identifier:normalizeIdentifier(e.target.value)}))}/></label><label className="text-sm font-medium">Repository identifier<Input className="mt-1 font-mono" value={form.identifier} required onChange={e=>setForm({...form,identifier:normalizeIdentifier(e.target.value)})}/><span className="mt-1 block text-xs font-normal text-zinc-500">Automatically derived from the name; you can edit it.</span></label><label className="text-sm font-medium sm:col-span-2">Repository URL<Input className="mt-1 font-mono" value={form.repoUrl} required onChange={e=>setForm({...form,repoUrl:e.target.value})}/></label><label className="text-sm font-medium">Branch<Input className="mt-1 font-mono" value={form.branch} required onChange={e=>setForm({...form,branch:e.target.value})}/></label><label className="text-sm font-medium">Base path<Input className="mt-1 font-mono" value={form.basePath} required onChange={e=>setForm({...form,basePath:e.target.value})}/></label><label className="text-sm font-medium">Username <span className="font-normal text-zinc-500">(optional)</span><Input className="mt-1 font-mono" value={form.username} onChange={e=>setForm({...form,username:e.target.value})}/></label><label className="text-sm font-medium">Access token <span className="font-normal text-zinc-500">(optional)</span><Input className="mt-1 font-mono" type="password" value={form.accessToken} onChange={e=>setForm({...form,accessToken:e.target.value})} placeholder={editing?.hasAccessToken?"Leave blank to keep the current token":""}/></label><label className="flex items-center gap-2 text-sm font-medium"><input type="checkbox" checked={form.enabled} onChange={e=>setForm({...form,enabled:e.target.checked})}/>Enabled</label></div></form></div>}{details&&<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-[85vh] w-full max-w-3xl overflow-auto rounded-xl bg-white p-6 shadow-2xl"><div className="flex justify-between"><h2 className="font-semibold">{details.source.name} skills</h2><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" onClick={()=>setDetails(undefined)}><X/></button></div><table className="mt-4 w-full text-left text-sm"><thead><tr className="border-b text-zinc-500"><th className="p-2">Skill</th><th className="p-2">Path</th><th className="p-2">Status</th><th className="p-2"/></tr></thead><tbody>{details.skills.map(skill=><tr key={skill.name} className="border-b"><td className="p-2"><b>{skill.name}</b><span className="block text-xs text-zinc-500">{skill.description}</span></td><td className="p-2 font-mono text-xs">{skill.relativePath}</td><td className="p-2">{skill.status}</td><td className="p-2">{skill.status==="synced"&&<button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="View skill" onClick={()=>void request(`skill-sources/${encodeURIComponent(details.source.identifier)}/skills/${encodeURIComponent(skill.name)}/document`).then(setDocument).catch(e=>toast(e.message,"error"))}><Eye size={17}/></button>}</td></tr>)}</tbody></table></div></div>}{document&&<SkillSourceDocumentPreview document={document} onClose={()=>setDocument(undefined)}/>}</Card>}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Terminal } from "@xterm/xterm";
|
|
4
|
+
import { FitAddon } from "@xterm/addon-fit";
|
|
5
|
+
import "@xterm/xterm/css/xterm.css";
|
|
6
|
+
|
|
7
|
+
export function TerminalClient() {
|
|
8
|
+
const host = useRef<HTMLDivElement>(null);
|
|
9
|
+
const [status, setStatus] = useState("Connecting…");
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!host.current) return;
|
|
12
|
+
const terminal = new Terminal({ cursorBlink: true, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: 14, theme: { background: "#09090b", foreground: "#e4e4e7" } });
|
|
13
|
+
const fit = new FitAddon();
|
|
14
|
+
terminal.loadAddon(fit);
|
|
15
|
+
terminal.open(host.current);
|
|
16
|
+
fit.fit();
|
|
17
|
+
let socket: WebSocket | undefined;
|
|
18
|
+
let disposed = false;
|
|
19
|
+
const resize = () => { fit.fit(); if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "resize", cols: terminal.cols, rows: terminal.rows })); };
|
|
20
|
+
window.addEventListener("resize", resize);
|
|
21
|
+
const observer = new ResizeObserver(resize);
|
|
22
|
+
observer.observe(host.current);
|
|
23
|
+
const input = terminal.onData((data) => { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "input", data })); });
|
|
24
|
+
void fetch("/api/pi/terminal/ticket", { method: "POST" }).then(async (response) => {
|
|
25
|
+
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error?.message ?? "Unable to start terminal.");
|
|
26
|
+
return response.json() as Promise<{ url: string; cwd: string }>;
|
|
27
|
+
}).then(({ url, cwd }) => {
|
|
28
|
+
if (disposed) return;
|
|
29
|
+
terminal.writeln(`\x1b[90mPi system terminal · ${cwd}\x1b[0m\r\n`);
|
|
30
|
+
socket = new WebSocket(url);
|
|
31
|
+
socket.onopen = () => { setStatus("Connected"); resize(); terminal.focus(); };
|
|
32
|
+
socket.onmessage = (event) => { const message = JSON.parse(event.data) as { type: string; data: string }; if (message.type === "output") terminal.write(message.data, () => terminal.scrollToBottom()); if (message.type === "error") { terminal.writeln(`\r\n\x1b[31m${message.data}\x1b[0m`); setStatus("Error"); } };
|
|
33
|
+
socket.onclose = () => { if (!disposed) setStatus("Disconnected"); };
|
|
34
|
+
socket.onerror = () => setStatus("Connection error");
|
|
35
|
+
}).catch((error) => { terminal.writeln(`\x1b[31m${error.message}\x1b[0m`); setStatus("Error"); });
|
|
36
|
+
return () => { disposed = true; observer.disconnect(); window.removeEventListener("resize", resize); input.dispose(); socket?.close(); terminal.dispose(); };
|
|
37
|
+
}, []);
|
|
38
|
+
return <main className="terminal-page flex h-screen flex-col"><header className="terminal-header flex items-center justify-between px-5 py-3 text-sm"><span>Pi System Terminal</span><span>{status}</span></header><div className="terminal-screen min-h-0 flex-1"><div ref={host} className="terminal-host"/></div></main>;
|
|
39
|
+
}
|