pi-feats 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/pi-feats-preview.png +0 -0
- package/extensions/api-server/application-log-store.ts +31 -0
- package/extensions/api-server/application-runtime.ts +80 -18
- package/extensions/api-server/application-store.ts +6 -2
- package/extensions/api-server/profile-store.ts +47 -20
- package/extensions/api-server/server.ts +182 -47
- package/extensions/browser-harness/cdp/commands.ts +260 -0
- package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
- package/extensions/browser-harness/cdp/discovery.ts +198 -0
- package/extensions/browser-harness/cdp/errors.ts +23 -0
- package/extensions/browser-harness/cdp/events.ts +170 -0
- package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
- package/extensions/browser-harness/cdp/ownership.ts +73 -0
- package/extensions/browser-harness/cdp/record-store.ts +61 -0
- package/extensions/browser-harness/cdp/session.ts +403 -0
- package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
- package/extensions/browser-harness/cdp/target-factory.ts +153 -0
- package/extensions/browser-harness/cdp/types.ts +82 -0
- package/extensions/browser-harness/client.ts +366 -0
- package/extensions/browser-harness/daemon/bridge.ts +531 -0
- package/extensions/browser-harness/daemon/index.ts +86 -0
- package/extensions/browser-harness/daemon/protocol.ts +110 -0
- package/extensions/browser-harness/daemon/queue.ts +85 -0
- package/extensions/browser-harness/daemon/server.ts +170 -0
- package/extensions/browser-harness/daemon/spawn.ts +124 -0
- package/extensions/browser-harness/daemon/transport.ts +178 -0
- package/extensions/browser-harness/domains/ax-tree.ts +179 -0
- package/extensions/browser-harness/domains/box.ts +51 -0
- package/extensions/browser-harness/domains/cdp-call.ts +55 -0
- package/extensions/browser-harness/domains/click.ts +97 -0
- package/extensions/browser-harness/domains/console.ts +219 -0
- package/extensions/browser-harness/domains/dialog.ts +34 -0
- package/extensions/browser-harness/domains/drag.ts +67 -0
- package/extensions/browser-harness/domains/element-call.ts +117 -0
- package/extensions/browser-harness/domains/files.ts +219 -0
- package/extensions/browser-harness/domains/fill-engine.ts +80 -0
- package/extensions/browser-harness/domains/form-batch.ts +65 -0
- package/extensions/browser-harness/domains/form.ts +283 -0
- package/extensions/browser-harness/domains/headers.ts +89 -0
- package/extensions/browser-harness/domains/history.ts +80 -0
- package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
- package/extensions/browser-harness/domains/js.ts +241 -0
- package/extensions/browser-harness/domains/keyboard.ts +197 -0
- package/extensions/browser-harness/domains/navigate.ts +161 -0
- package/extensions/browser-harness/domains/network.ts +301 -0
- package/extensions/browser-harness/domains/page.ts +154 -0
- package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
- package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
- package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
- package/extensions/browser-harness/domains/render.ts +43 -0
- package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
- package/extensions/browser-harness/domains/screenshot.ts +76 -0
- package/extensions/browser-harness/domains/scroll.ts +59 -0
- package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
- package/extensions/browser-harness/domains/search/web-search.ts +144 -0
- package/extensions/browser-harness/domains/setup.ts +35 -0
- package/extensions/browser-harness/domains/snapshot.ts +256 -0
- package/extensions/browser-harness/domains/tabs.ts +202 -0
- package/extensions/browser-harness/domains/target.ts +35 -0
- package/extensions/browser-harness/domains/viewport.ts +42 -0
- package/extensions/browser-harness/index.ts +88 -0
- package/extensions/browser-harness/profile/bind.ts +90 -0
- package/extensions/browser-harness/profile/browser-process.ts +320 -0
- package/extensions/browser-harness/profile/command.ts +189 -0
- package/extensions/browser-harness/profile/launch.ts +80 -0
- package/extensions/browser-harness/profile/list.ts +131 -0
- package/extensions/browser-harness/profile/paths.ts +103 -0
- package/extensions/browser-harness/profile/store.ts +84 -0
- package/extensions/browser-harness/prompt.ts +16 -0
- package/extensions/browser-harness/registry.ts +80 -0
- package/extensions/browser-harness/schemas/common.ts +11 -0
- package/extensions/browser-harness/schemas/parse.ts +23 -0
- package/extensions/browser-harness/setup.ts +47 -0
- package/extensions/browser-harness/state.ts +51 -0
- package/extensions/browser-harness/steel.ts +215 -0
- package/extensions/browser-harness/util/debug.ts +8 -0
- package/extensions/browser-harness/util/guards.ts +30 -0
- package/extensions/browser-harness/util/js-template.ts +12 -0
- package/extensions/browser-harness/util/mutex.ts +20 -0
- package/extensions/browser-harness/util/paths.ts +31 -0
- package/extensions/browser-harness/util/result.ts +9 -0
- package/extensions/browser-harness/util/sharp-shim.ts +42 -0
- package/extensions/browser-harness/util/tool.ts +185 -0
- package/extensions/browser-harness/util/truncate.ts +41 -0
- package/extensions/cli-resources.ts +35 -16
- package/extensions/composio/index.ts +163 -0
- package/extensions/context-memory/index.ts +58 -0
- package/extensions/lib/application-context.ts +22 -0
- package/extensions/lib/application-handler-templates.ts +17 -1
- package/extensions/lib/context-memory.ts +165 -0
- package/extensions/lib/profile-sandbox.ts +3 -0
- package/extensions/lib/workflow-harness.ts +238 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
- package/extensions/pi-console-webui/app/layout.tsx +6 -1
- package/extensions/pi-console-webui/app/login/page.tsx +5 -9
- package/extensions/pi-console-webui/app/not-found.tsx +5 -0
- package/extensions/pi-console-webui/app/page.tsx +6 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
- package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
- package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
- package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
- package/extensions/pi-console-webui/components/console-page.tsx +3 -1
- package/extensions/pi-console-webui/components/console.tsx +53 -7
- package/extensions/pi-console-webui/components/login-form.tsx +14 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
- package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
- package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
- package/extensions/profiles.ts +18 -7
- package/extensions/pulse/store.ts +5 -2
- package/extensions/sequential-workflow.ts +340 -256
- package/extensions/skill-sources/store.ts +24 -3
- package/licenses/pi-browser-harness-MIT.txt +21 -0
- package/package.json +25 -4
- package/schemas/sequential-workflow-template.schema.json +62 -0
- package/skills/browser-harness/SKILL.md +33 -0
- package/skills/composio-connections/SKILL.md +75 -0
- package/skills/sequential-workflow/SKILL.md +70 -0
- package/skills/sequential-workflow-template/SKILL.md +44 -0
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
5
5
|
import ReactMarkdown from "react-markdown";
|
|
6
6
|
import remarkGfm from "remark-gfm";
|
|
7
|
+
import remarkBreaks from "remark-breaks";
|
|
7
8
|
import CodeMirror from "@uiw/react-codemirror";
|
|
8
9
|
import MDEditor from "@uiw/react-md-editor";
|
|
9
10
|
import { json as jsonLanguage } from "@codemirror/lang-json";
|
|
11
|
+
import { javascript } from "@codemirror/lang-javascript";
|
|
10
12
|
import { HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
|
|
11
13
|
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";
|
|
14
|
+
import { Activity, Bot, Box, Check, ChevronDown, Cog, Copy, Eye, FileCode2, GitBranch, Loader2, LogOut, MessageSquare, MoreHorizontal, Play, Plus, RotateCw, Save, Search, Settings2, ShieldCheck, Sparkles, Terminal, Trash2, UserRound, Wrench, X } from "lucide-react";
|
|
13
15
|
import { Button } from "@/components/ui/button";
|
|
14
16
|
import { Card, CardTitle } from "@/components/ui/card";
|
|
15
17
|
import { Input } from "@/components/ui/input";
|
|
@@ -29,6 +31,7 @@ import { SkillResourcesPanel } from "@/components/skill-resources";
|
|
|
29
31
|
import { SkillSources } from "@/components/skill-sources";
|
|
30
32
|
import { PackageResources } from "@/components/package-resources";
|
|
31
33
|
import { PulseResources } from "@/components/pulse-resources";
|
|
34
|
+
import { applicationHandlerTemplate } from "../../lib/application-handler-templates";
|
|
32
35
|
|
|
33
36
|
type Profile = { name: string; path: string };
|
|
34
37
|
type Resource = { name: string; kind: string; path?: string; source: string; package?: string; enabled: boolean; protected?: boolean };
|
|
@@ -65,8 +68,27 @@ function LegacyGuardrails({ profile, initial }: { profile: string; initial?: { c
|
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
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[] } }) {
|
|
71
|
+
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" previewOptions={{ remarkPlugins: [remarkGfm, remarkBreaks] }} height={520}/></div></Card>; }
|
|
72
|
+
function Resources({ profile, kind, initial }: { profile: string; kind: "skills" | "tools" | "extensions"; initial?: { resources: Resource[] } }) {
|
|
73
|
+
const { toast } = useToast();
|
|
74
|
+
const [items, setItems] = useState<Resource[]>(initial?.resources ?? []);
|
|
75
|
+
const [loading, setLoading] = useState(!initial);
|
|
76
|
+
const load = async () => {
|
|
77
|
+
setLoading(true);
|
|
78
|
+
try {
|
|
79
|
+
setItems((await getJson<{ resources: Resource[] }>(`profiles/${profile}/resources/${kind}`)).resources);
|
|
80
|
+
} catch (cause) {
|
|
81
|
+
toast((cause as Error).message, "error");
|
|
82
|
+
} finally {
|
|
83
|
+
setLoading(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (initial) { setItems(initial.resources); setLoading(false); return; }
|
|
88
|
+
void load();
|
|
89
|
+
}, [profile, kind, initial]);
|
|
90
|
+
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>{loading ? <div className="flex min-h-48 items-center justify-center gap-3 text-sm text-zinc-500"><Loader2 className="animate-spin" size={18}/>Loading available resources…</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></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" && kind !== "extensions" ? <span className="text-zinc-500">Manage in Packages</span> : <label className="flex items-center gap-2"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/resources/${kind}/${encodeURIComponent(item.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); toast(`${item.name} ${enabled ? "enabled" : "disabled"}.`); } 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></tr>)}</tbody></table></div>}</Card>;
|
|
91
|
+
}
|
|
70
92
|
|
|
71
93
|
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
94
|
|
|
@@ -74,11 +96,32 @@ const profileColors = ["bg-[#19a0dd]"];
|
|
|
74
96
|
const profileColor = (name: string) => profileColors[[...name].reduce((sum, char) => sum + char.charCodeAt(0), 0) % profileColors.length];
|
|
75
97
|
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
98
|
|
|
99
|
+
type ContextMemoryItem = { id: string; label: string; kind: "operational" | "profile" | "handler"; application?: string; path?: string; content?: string };
|
|
100
|
+
function ContextMemory({ profile }: { profile: string }) {
|
|
101
|
+
const { toast } = useToast();
|
|
102
|
+
const [mode, setMode] = useState<"none" | "profile" | "identity" | "handler">("none"), [handler, setHandler] = useState("");
|
|
103
|
+
const [items, setItems] = useState<ContextMemoryItem[]>([]), [applications, setApplications] = useState<Array<{ slug: string; name: string }>>([]), [selected, setSelected] = useState("operational"), [content, setContent] = useState(""), [createOpen, setCreateOpen] = useState(false), [renameOpen, setRenameOpen] = useState(false), [renameName, setRenameName] = useState(""), [deleteOpen, setDeleteOpen] = useState(false), [handlerMenu, setHandlerMenu] = useState<string>(), [testPayload, setTestPayload] = useState('{\n "identityKey": "example-identity",\n "profile": "default",\n "sessionId": "test-session"\n}'), [testOutput, setTestOutput] = useState(""), [testLogs, setTestLogs] = useState(""), [testing, setTesting] = useState(false), [application, setApplication] = useState(""), [newHandler, setNewHandler] = useState("");
|
|
104
|
+
const current = items.find((item) => item.id === selected);
|
|
105
|
+
const load = async () => { const [config, op, personal, apps] = await Promise.all([getJson<{ config: { mode: "file"; target: "profile" | "identity" } | { mode: "handler"; handler: string } | null }>(`profiles/${profile}/context-memory`), getJson<{ content: string }>(`profiles/${profile}/context-memory/operational`), getJson<{ content: string }>(`profiles/${profile}/context-memory/profile`), getJson<{ applications: Array<{ slug: string; name: string }> }>("applications")]); const handlers = (await Promise.all(apps.applications.map(async (app) => ({ app, files: (await getJson<{ files: string[] }>(`applications/${encodeURIComponent(app.slug)}/handlers`)).files })))).flatMap(({ app, files }) => files.filter((file) => file.startsWith("context-memory/")).map((path) => ({ id: `handler:${app.slug}:${path}`, label: `${app.name} / ${path.replace(/^context-memory\//, "")}`, kind: "handler" as const, application: app.slug, path })));
|
|
106
|
+
setMode(config.config?.mode === "handler" ? "handler" : config.config?.target ?? "none"); setHandler(config.config?.mode === "handler" ? config.config.handler : ""); setApplications(apps.applications); setItems([{ id: "operational", label: "OPERATIONAL.md", kind: "operational", content: op.content }, { id: "profile", label: "PROFILE.md", kind: "profile", content: personal.content }, ...handlers]); setApplication((value) => value || apps.applications[0]?.slug || ""); };
|
|
107
|
+
useEffect(() => { void load().catch((error) => toast(error.message, "error")); }, [profile]);
|
|
108
|
+
useEffect(() => { if (!current) return; if (current.kind !== "handler") { setContent(current.content ?? ""); return; } setTestPayload(JSON.stringify({ identityKey: "example-identity", profile, sessionId: "test-session" }, null, 2)); setTestOutput(""); setTestLogs(""); void getJson<{ content: string }>(`applications/${encodeURIComponent(current.application!)}/handler?path=${encodeURIComponent(current.path!)}`).then((value) => setContent(value.content)).catch((error) => toast(error.message, "error")); }, [selected, items]);
|
|
109
|
+
const count = Array.from(content).length, limit = current?.kind === "operational" ? 2750 : current?.kind === "profile" ? 1375 : undefined;
|
|
110
|
+
const save = async () => { if (!current) return; try { const config = mode === "none" ? null : mode === "handler" ? { mode: "handler", handler } : { mode: "file", target: mode }; await request(`profiles/${profile}/context-memory`, { method: "PUT", body: JSON.stringify({ config }) }); if (current.kind === "handler") await request(`applications/${encodeURIComponent(current.application!)}/handler?path=${encodeURIComponent(current.path!)}`, { method: "PUT", body: JSON.stringify({ content }) }); else await request(`profiles/${profile}/context-memory/${current.kind}`, { method: "PUT", body: JSON.stringify({ content }) }); setItems((all) => all.map((item) => item.id === current.id ? { ...item, content } : item)); toast("Context Memory saved. Changes apply to new sessions."); } catch (error) { toast((error as Error).message, "error"); } };
|
|
111
|
+
const create = async () => { if (!application || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(newHandler)) return toast("Select an Application and use a valid handler name.", "error"); try { const path = `context-memory/${newHandler}.ts`; const template = "export async function resolveContextMemory(\n context: { application: { id: string; name: string; slug: string }; identityKey?: string; profile: string; sessionId: string },\n env: Record<string, string | undefined>,\n): Promise<string> {\n // Example: const baseUrl = env.CRM_BASE_URL;\n return \"\";\n}\n"; await request(`applications/${encodeURIComponent(application)}/handler?path=${encodeURIComponent(path)}`, { method: "PUT", body: JSON.stringify({ content: template }) }); setCreateOpen(false); setNewHandler(""); await load(); setSelected(`handler:${application}:${path}`); toast("Context Memory handler created."); } catch (error) { toast((error as Error).message, "error"); } };
|
|
112
|
+
const renameHandler = async () => { if (current?.kind !== "handler" || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(renameName)) return toast("Use a valid handler name.", "error"); try { const path = `context-memory/${renameName}.ts`; await request(`applications/${encodeURIComponent(current.application!)}/handler?path=${encodeURIComponent(current.path!)}`, { method: "PATCH", body: JSON.stringify({ newPath: path }) }); setRenameOpen(false); await load(); setSelected(`handler:${current.application}:${path}`); toast("Context Memory handler renamed."); } catch (error) { toast((error as Error).message, "error"); } };
|
|
113
|
+
const deleteHandler = async () => { if (current?.kind !== "handler") return; try { await request(`applications/${encodeURIComponent(current.application!)}/handler?path=${encodeURIComponent(current.path!)}`, { method: "DELETE" }); setDeleteOpen(false); setSelected("operational"); await load(); toast("Context Memory handler deleted."); } catch (error) { toast((error as Error).message, "error"); } };
|
|
114
|
+
const testHandler = async () => { if (current?.kind !== "handler") return; let payload: unknown; try { payload = JSON.parse(testPayload); } catch { return toast("Test input must be valid JSON.", "error"); } if (!payload || typeof payload !== "object" || Array.isArray(payload)) return toast("Test input must be a JSON object.", "error"); try { setTesting(true); setTestOutput(""); setTestLogs(""); const result = await getJson<{ ok: boolean; output?: unknown; error?: string; logs: { stdout: string[]; stderr: string[] }; durationMs: number }>(`applications/${encodeURIComponent(current.application!)}/handler/test?path=${encodeURIComponent(current.path!)}`, { method: "POST", body: JSON.stringify({ payload, content }) }); setTestOutput(JSON.stringify(result.ok ? result.output ?? null : { error: result.error ?? "Handler test failed." }, null, 2)); setTestLogs([`Duration: ${result.durationMs} ms`, ...result.logs.stdout.map((line) => `[info] ${line}`), ...result.logs.stderr.map((line) => `[error] ${line}`)].join("\n") || "No logs emitted."); } catch (error) { toast((error as Error).message, "error"); } finally { setTesting(false); } };
|
|
115
|
+
const handlerItems = items.filter((item) => item.kind === "handler");
|
|
116
|
+
return <div className="space-y-5"><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Context Memory</CardTitle><p className="text-sm text-zinc-500">Persistent context is captured when a new session starts.</p></div><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save selected Context Memory item" disabled={!current || (limit !== undefined && count > limit)} onClick={save}><Save size={17}/></Button></div><div className="mt-4 grid gap-4 md:grid-cols-2"><label className="grid gap-1.5 text-sm font-medium">Personal context source<select className="rounded-md border border-zinc-200 px-3 py-2 font-normal" value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="none">None</option><option value="profile">Profile file</option><option value="identity">Identity file</option><option value="handler">Application handler</option></select></label>{mode === "handler" && <label className="grid gap-1.5 text-sm font-medium">Context Memory handler<select className="rounded-md border border-zinc-200 px-3 py-2 font-normal" value={handler} onChange={(event) => setHandler(event.target.value)}><option value="">Select a handler</option>{handlerItems.map((item) => <option key={item.id} value={item.path!.replace(/^context-memory\//, "").replace(/\.ts$/, "")}>{item.label}</option>)}</select></label>}</div></Card><div className="grid min-h-[620px] gap-4 lg:grid-cols-[320px_minmax(0,1fr)]"><Card className="overflow-auto"><div className="flex items-center justify-between"><CardTitle>Files and handlers</CardTitle><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Create Context Memory handler" onClick={() => setCreateOpen(true)}><Plus size={17}/></Button></div><div className="mt-4 space-y-1"><p className="px-2 text-xs font-semibold uppercase text-zinc-500">Files</p>{items.filter((item) => item.kind !== "handler").map((item) => <button key={item.id} onClick={() => setSelected(item.id)} className={`block w-full rounded-md px-3 py-2 text-left text-sm ${item.id === selected ? "bg-blue-50 text-blue-800" : "hover:bg-zinc-100"}`}><span className="block font-medium">{item.label}</span><span className="text-xs text-zinc-500">Profile file</span></button>)}<p className="mt-4 px-2 text-xs font-semibold uppercase text-zinc-500">Application handlers</p>{handlerItems.map((item) => <div key={item.id} className={`flex items-center rounded-md ${item.id === selected ? "bg-blue-50 text-blue-800" : "hover:bg-zinc-100"}`}><button onClick={() => setSelected(item.id)} className="min-w-0 flex-1 px-3 py-2 text-left text-sm"><span className="block truncate font-medium">{item.label}</span><span className="text-xs text-zinc-500">Application handler</span></button><div className="relative"><button title={`Actions for ${item.label}`} className="rounded p-2 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900" onClick={() => setHandlerMenu((current) => current === item.id ? undefined : item.id)}><MoreHorizontal size={16}/></button>{handlerMenu === item.id && <div className="absolute right-0 z-20 mt-1 w-32 rounded-lg border border-zinc-200 bg-white p-1 shadow-lg"><button className="w-full rounded px-3 py-2 text-left text-sm hover:bg-zinc-100" onClick={() => { setSelected(item.id); setRenameName(item.path!.replace(/^context-memory\//, "").replace(/\.ts$/, "")); setHandlerMenu(undefined); setRenameOpen(true); }}>Rename</button><button className="w-full rounded px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50" onClick={() => { setSelected(item.id); setHandlerMenu(undefined); setDeleteOpen(true); }}>Delete</button></div>}</div></div>)}</div></Card><Card className="flex min-w-0 flex-col"><div className="flex items-center justify-between gap-3"><div><CardTitle>{current?.label ?? "Select an item"}</CardTitle><p className={`mt-1 text-xs ${limit !== undefined && count > limit ? "text-red-700" : "text-zinc-500"}`}>{limit === undefined ? "TypeScript handler" : `${count} / ${limit} characters`}</p></div><div className="flex gap-2">{current?.kind === "handler" && <><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Restore Context Memory handler template" onClick={() => setContent(applicationHandlerTemplate("context-memory"))}><RotateCw size={17}/></Button></>}<Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save selected item" disabled={!current || (limit !== undefined && count > limit)} onClick={save}><Save size={17}/></Button></div></div><div className="mt-4 min-h-0 flex-1 overflow-auto" data-color-mode="light">{current?.kind === "handler" ? <CodeMirror value={content} height="500px" extensions={[javascript({ typescript: true })]} onChange={setContent}/> : <MDEditor value={content} onChange={(value) => setContent(value ?? "")} preview="edit" height={500}/>}</div></Card></div>{current?.kind === "handler" && <><Card><div className="mb-4"><CardTitle>Test handler</CardTitle><p className="text-sm text-zinc-500">Runs the current editor content without saving it.</p></div><div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] lg:items-center"><div className="overflow-hidden rounded-lg border border-zinc-200"><p className="border-b border-zinc-200 bg-zinc-50 px-3 py-2 text-xs font-semibold text-zinc-600">INPUT / CONTEXT</p><CodeMirror value={testPayload} height="260px" extensions={[jsonLanguage()]} onChange={setTestPayload}/></div><Button className="self-center !bg-[#2bbb77] hover:!bg-[#249b63]" title="Run handler test" disabled={testing} onClick={() => void testHandler()}><Play size={18}/></Button><div className="overflow-hidden rounded-lg border border-zinc-200"><p className="border-b border-zinc-200 bg-zinc-50 px-3 py-2 text-xs font-semibold text-zinc-600">OUTPUT</p><CodeMirror value={testOutput} height="260px" extensions={[jsonLanguage()]} editable={false}/></div></div></Card><Card><CardTitle>Logs</CardTitle><pre className="handler-test-logs mt-4 min-h-28">{testLogs || "Run a test to view handler logs."}</pre></Card></>}{createOpen && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><Card className="w-full max-w-lg"><CardTitle>New Context Memory handler</CardTitle><div className="mt-4 grid gap-4"><label className="grid gap-1.5 text-sm font-medium">Application<select className="rounded-md border border-zinc-200 px-3 py-2 font-normal" value={application} onChange={(event) => setApplication(event.target.value)}>{applications.map((app) => <option key={app.slug} value={app.slug}>{app.name}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium">Handler name<Input value={newHandler} onChange={(event) => setNewHandler(event.target.value)} placeholder="e.g. customer-context"/></label><div className="flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setCreateOpen(false)}>Cancel</Button><Button onClick={create}>Create</Button></div></div></Card></div>}{renameOpen && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><Card className="w-full max-w-md"><CardTitle>Rename Context Memory handler</CardTitle><label className="mt-4 grid gap-1.5 text-sm font-medium">Handler name<Input value={renameName} onChange={(event) => setRenameName(event.target.value)} autoFocus/></label><div className="mt-5 flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setRenameOpen(false)}>Cancel</Button><Button onClick={() => void renameHandler()}>Rename</Button></div></Card></div>}{deleteOpen && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><Card className="w-full max-w-md"><CardTitle>Delete Context Memory handler</CardTitle><p className="mt-3 text-sm text-zinc-500">Delete {current?.label}? This cannot be undone.</p><div className="mt-5 flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setDeleteOpen(false)}>Cancel</Button><Button className="bg-red-600 hover:bg-red-500" onClick={() => void deleteHandler()}>Delete</Button></div></Card></div>}</div>;
|
|
117
|
+
}
|
|
118
|
+
|
|
77
119
|
const navigation = [
|
|
78
120
|
{ href: "/", label: "Overview", icon: Activity, color: "text-slate-600" },
|
|
79
121
|
{ href: "/settings", label: "Settings", icon: Settings2, color: "text-amber-600" },
|
|
80
122
|
{ href: "/env", label: "Environment", icon: Wrench, color: "text-lime-600" },
|
|
81
123
|
{ href: "/soul", label: "SOUL", icon: Sparkles, color: "text-purple-600" },
|
|
124
|
+
{ href: "/context-memory", label: "Context Memory", icon: UserRound, color: "text-indigo-600" },
|
|
82
125
|
{ href: "/guardrails", label: "Guardrails", icon: ShieldCheck, color: "text-violet-600" },
|
|
83
126
|
{ href: "/packages", label: "Packages", icon: Box, color: "text-sky-600" },
|
|
84
127
|
{ href: "/skills", label: "Skills", icon: Sparkles, color: "text-emerald-600" },
|
|
@@ -97,14 +140,17 @@ export function Console({ section = "overview", applicationSlug, initial = {} }:
|
|
|
97
140
|
const requestedProfile = searchParams.get("profile") ?? "default";
|
|
98
141
|
const administrative = searchParams.get("context") === "admin";
|
|
99
142
|
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);
|
|
143
|
+
const [profile, setProfile] = useState(requestedProfile), [profileMenu, setProfileMenu] = useState(false), [profileFilterInput, setProfileFilterInput] = useState(""), [profileFilter, setProfileFilter] = useState(""), [createDialog, setCreateDialog] = useState(false), [newProfile, setNewProfile] = useState(""), [profileError, setProfileError] = useState(""), [creatingProfile, setCreatingProfile] = useState(false);
|
|
144
|
+
const profileFilterRef = useRef<HTMLInputElement>(null);
|
|
101
145
|
useEffect(() => setProfile(requestedProfile), [requestedProfile]);
|
|
146
|
+
useEffect(() => { if (!profileMenu) return; setProfileFilterInput(""); setProfileFilter(""); const frame = requestAnimationFrame(() => profileFilterRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [profileMenu]);
|
|
147
|
+
useEffect(() => { const timer = setTimeout(() => setProfileFilter(profileFilterInput), 500); return () => clearTimeout(timer); }, [profileFilterInput]);
|
|
102
148
|
const router = useRouter();
|
|
103
149
|
const selectProfile = (name: string) => { const destination = administrative || section === "overview" ? "/" : `/${section}`; router.replace(`${destination}?profile=${encodeURIComponent(name)}`); };
|
|
104
150
|
const refresh = async () => { const data = await refreshProfiles(); if (!data.some((item) => item.name === profile)) setProfile("default"); };
|
|
105
151
|
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-
|
|
152
|
+
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 === "context-memory" ? <ContextMemory profile={profile}/> : section === "skills" ? <SkillResourcesPanel profile={profile} initial={initial.resources}/> : section === "tools" ? <Resources profile={profile} kind="tools" initial={initial.resources}/> : section === "packages" ? <PackageResources profile={profile} initial={initial.packages}/> : section === "pulses" ? <PulseResources profile={profile} initial={initial.pulses}/> : section === "extensions" ? <Resources profile={profile} kind="extensions" initial={initial.resources}/> : <Overview health={health} profiles={profiles} profile={profile}/>;
|
|
153
|
+
return <main className="mx-auto w-full max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6"><header className="mb-7 flex flex-wrap items-center gap-4"><div className="mr-auto flex items-center gap-3"><div className="rounded-xl bg-slate-700 p-2 text-white"><Bot size={24}/></div><div><h1 className="text-2xl font-bold tracking-tight">π Console</h1><p className="text-sm text-zinc-500">Manage your Pi API workspace</p></div></div><span className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm ${health === "running" ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-red-700"}`}><Activity size={15}/>{health}</span><div className="relative"><Button className="flex h-10 w-56 items-center justify-start bg-white text-zinc-700 ring-1 ring-zinc-200 hover:bg-zinc-50" title="Select profile" onClick={() => setProfileMenu((current) => !current)}>{administrative ? <span className="grid size-7 shrink-0 place-items-center rounded-full bg-zinc-700 text-white"><Cog size={15}/></span> : <UserRound className="shrink-0 text-zinc-500" size={16}/>}<span className="ml-2 min-w-0 flex-1 truncate text-left">{administrative ? "Administrative Settings" : profile}</span><ChevronDown className="ml-2 shrink-0 text-zinc-400" size={16}/></Button>{profileMenu && <div className="absolute right-0 z-30 mt-2 flex w-[22rem] max-h-[min(680px,calc(100vh-2rem))] flex-col rounded-xl border border-zinc-200 bg-white p-2 shadow-xl"><button className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm ${administrative ? "bg-zinc-100" : "hover:bg-zinc-50"}`} onClick={() => { router.replace("/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><label className="mb-2 grid gap-1 px-1 text-xs font-medium text-zinc-600"><span>Filter profiles</span><span className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={15}/><Input ref={profileFilterRef} className="h-9 border-zinc-300 bg-white pl-9 text-zinc-900" value={profileFilterInput} onChange={(event) => setProfileFilterInput(event.target.value)} placeholder="Search by name"/></span></label><div className="max-h-[calc(min(680px,100vh-2rem)-184px)] overflow-y-auto">{profiles.filter(({ name }) => name.toLocaleLowerCase().includes(profileFilter.trim().toLocaleLowerCase())).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>{profiles.filter(({ name }) => name.toLocaleLowerCase().includes(profileFilter.trim().toLocaleLowerCase())).length === 0 && <p className="px-3 py-6 text-center text-sm text-zinc-500">No profiles match this filter.</p>}<button className="mt-2 flex w-full items-center justify-center rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-zinc-600 hover:bg-zinc-50" title="New profile" onClick={() => { setProfileMenu(false); setCreateDialog(true); setNewProfile(""); 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
154
|
}
|
|
109
155
|
|
|
110
156
|
function SkillResources({ profile, initial }: { profile: string; initial?: { resources: Resource[] } }) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Loader2 } from "lucide-react";
|
|
8
|
+
import { useToast } from "@/components/toast";
|
|
9
|
+
|
|
10
|
+
export function LoginForm() {
|
|
11
|
+
const router = useRouter(); const { toast } = useToast(); const [username, setUsername] = useState(""), [password, setPassword] = useState(""), [loading, setLoading] = useState(false);
|
|
12
|
+
async function submit(event: React.FormEvent) { event.preventDefault(); if (loading) return; setLoading(true); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ username, password }) }); if (response.ok) router.push("/"); else toast("Invalid credentials.", "error"); } catch { toast("Unable to sign in.", "error"); } finally { setLoading(false); } }
|
|
13
|
+
return <main className="grid min-h-screen place-items-center p-4"><form onSubmit={submit} className="w-full max-w-sm space-y-5 rounded-xl border border-zinc-200 bg-white p-6 shadow-xl"><div className="space-y-1"><h1 className="text-2xl font-bold text-zinc-900">Pi Console</h1><p className="text-sm text-zinc-600">Sign in to manage Pi.</p></div><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Username</span><Input className="border-zinc-300 bg-white text-zinc-900 placeholder:text-zinc-400 focus:border-[#00b9ed]" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus /></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Password</span><Input className="border-zinc-300 bg-white text-zinc-900 placeholder:text-zinc-400 focus:border-[#00b9ed]" value={password} onChange={(e) => setPassword(e.target.value)} type="password" /></label><Button className="w-full" disabled={loading}>{loading ? <><Loader2 className="mr-2 animate-spin" size={16}/>Signing in…</> : "Sign in"}</Button></form></main>;
|
|
14
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Fragment, useEffect, useState } from "react";
|
|
3
3
|
import CodeMirror from "@uiw/react-codemirror";
|
|
4
4
|
import { markdown } from "@codemirror/lang-markdown";
|
|
5
|
-
import { Check, ChevronDown, ChevronRight, FilePenLine, Loader2, Plus, RotateCw,
|
|
5
|
+
import { Check, ChevronDown, ChevronRight, FilePenLine, Loader2, Plus, RotateCw, ScrollText, Trash2, X } from "lucide-react";
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
7
|
import { Card, CardTitle } from "@/components/ui/card";
|
|
8
8
|
import { Input } from "@/components/ui/input";
|
|
@@ -10,10 +10,12 @@ import { Switch } from "@/components/ui/switch";
|
|
|
10
10
|
import { useToast } from "@/components/toast";
|
|
11
11
|
|
|
12
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 };
|
|
13
|
+
type HistoryPulse = Pulse & { startedAt: string; finishedAt: string | null; status: string; error: string | null };
|
|
14
|
+
type PulseRun = { id: string; startedAt: string; finishedAt: string | null; status: "running" | "success" | "error"; response: string | null; error: string | null };
|
|
14
15
|
type Session = { id: string; name?: string | null };
|
|
15
16
|
type Form = { name: string; description: string; schedule: string; prompt: string; thread_session_id: string; enabled: boolean };
|
|
16
17
|
const blank = (): Form => ({ name: "", description: "", schedule: "*/15 * * * *", prompt: "", thread_session_id: "", enabled: true });
|
|
18
|
+
function localDateTime(value: Date): string { const pad = (part: number) => String(part).padStart(2, "0"); return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`; }
|
|
17
19
|
function scheduleDescription(schedule: string): { type: Pulse["type"]; text: string } {
|
|
18
20
|
if (schedule.startsWith("@once:")) return { type: "cron", text: `Runs once at ${schedule.slice(6)}.` };
|
|
19
21
|
const [minute = "", hour = "", day = "", month = "", weekDay = ""] = schedule.trim().split(/\s+/);
|
|
@@ -29,13 +31,15 @@ function scheduleDescription(schedule: string): { type: Pulse["type"]; text: str
|
|
|
29
31
|
async function request(path: string, init?: RequestInit) { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message ?? body.error ?? "Request failed"); } return response; }
|
|
30
32
|
|
|
31
33
|
export function PulseResources({ profile, initial }: { profile: string; initial?: { pulses: Pulse[] } }) {
|
|
32
|
-
const { toast } = useToast(); const [items, setItems] = useState<Pulse[]>(initial?.pulses ?? []), [
|
|
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);
|
|
34
|
+
const { toast } = useToast(); const [items, setItems] = useState<Pulse[]>(initial?.pulses ?? []), [tickRunning, setTickRunning] = useState(false), [expanded, setExpanded] = useState<string>(), [showHistory, setShowHistory] = useState(false), [history, setHistory] = useState<HistoryPulse[] | undefined>(); const [saving, setSaving] = useState(false); const [dialog, setDialog] = useState<"create" | "edit">(), [editing, setEditing] = useState<Pulse>(), [form, setForm] = useState<Form>(blank()); const [logPulse, setLogPulse] = useState<Pulse>(), [runs, setRuns] = useState<PulseRun[]>(), [logsLoading, setLogsLoading] = useState(false), [logStart, setLogStart] = useState(() => localDateTime(new Date(Date.now() - 24 * 60 * 60 * 1_000))), [logEnd, setLogEnd] = useState(() => localDateTime(new Date()));
|
|
35
|
+
const load = async () => { try { const [pulses, tick] = await Promise.all([request(`profiles/${profile}/pulses`).then((r) => r.json() as Promise<{ pulses: Pulse[] }>), request("pulse/tick/status").then((r) => r.json() as Promise<{ running: boolean }>)]); setItems(pulses.pulses); setTickRunning(tick.running); } catch (cause) { toast((cause as Error).message, "error"); } };
|
|
36
|
+
const loadRuns = async (pulse = logPulse) => { if (!pulse) return; try { setLogsLoading(true); const start = new Date(logStart), end = new Date(logEnd); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start > end) throw new Error("Choose a valid chronological date range."); const response = await request(`profiles/${profile}/pulses/${encodeURIComponent(pulse.name)}/runs?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`); setRuns((await response.json() as { runs: PulseRun[] }).runs); } catch (cause) { toast((cause as Error).message, "error"); } finally { setLogsLoading(false); } };
|
|
37
|
+
const openLogs = (pulse: Pulse) => { setLogPulse(pulse); setRuns(undefined); const end = new Date(), start = new Date(end.getTime() - 24 * 60 * 60 * 1_000); setLogStart(localDateTime(start)); setLogEnd(localDateTime(end)); setTimeout(() => void loadRuns(pulse), 0); };
|
|
34
38
|
useEffect(() => { void load(); }, [profile]);
|
|
35
39
|
const close = () => { setDialog(undefined); setEditing(undefined); };
|
|
36
40
|
const create = () => { setForm(blank()); setEditing(undefined); setDialog("create"); };
|
|
37
41
|
const edit = (pulse: Pulse) => { setEditing(pulse); setForm({ name: pulse.name, description: pulse.description, schedule: pulse.schedule, prompt: pulse.prompt, thread_session_id: pulse.thread_session_id, enabled: pulse.enabled }); setDialog("edit"); };
|
|
38
42
|
const valid = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(form.name) && Boolean(form.description.trim() && form.schedule.trim() && form.prompt.trim());
|
|
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) {
|
|
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>}</>;
|
|
43
|
+
const save = async () => { if (!valid || saving) return; try { setSaving(true); const { enabled, ...payload } = form; const response = await request(`profiles/${profile}/pulses${editing ? `/${encodeURIComponent(editing.name)}` : ""}`, { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) }); const saved = await response.json() as { pulse: Pulse }; if (saved.pulse.enabled !== enabled) await request(`profiles/${profile}/pulses/${encodeURIComponent(saved.pulse.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); toast(editing ? "Pulse saved." : `Pulse created and ${enabled ? "enabled" : "disabled"}.`); close(); } catch (cause) { toast((cause as Error).message, "error"); } finally { setSaving(false); } };
|
|
44
|
+
return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Pulses</CardTitle><p className="mt-1 text-sm text-zinc-500">Scheduled cron jobs and stateful heartbeats, executed by the Pulse Tick service.</p></div><div className="flex items-center gap-3"><label className="mr-6 flex items-center gap-2 text-sm font-medium text-zinc-600"><Switch className="data-[state=checked]:!bg-[#00b9ed]" checked={tickRunning} onCheckedChange={async (enabled) => { try { const response = await request("pulse/tick", { method: "POST", body: JSON.stringify({ enabled }) }); const data = await response.json() as { running: boolean }; setTickRunning(data.running); toast(`Pulse Tick ${data.running ? "started" : "stopped"}.`); } catch (cause) { toast((cause as Error).message, "error"); } }}/><span>Pulse Service</span></label><label className="flex items-center gap-2 text-sm font-medium text-zinc-600"><Switch className="data-[state=checked]:!bg-[#00b9ed]" checked={showHistory} onCheckedChange={setShowHistory}/><span>Show History</span></label><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload pulses" onClick={() => void load()}><RotateCw size={17}/></Button><Button title="Add pulse" onClick={create}><Plus size={17}/></Button></div></div><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Type</th><th className="p-3">Schedule</th><th className="p-3">Thread session</th><th className="p-3">Next run</th><th className="p-3">Enabled</th><th className="p-3"/></tr></thead><tbody>{items.filter((item) => showHistory || !(item.schedule.startsWith("@once:") && item.lastRunAt)).map((item) => <Fragment key={item.id}><tr className="border-b border-zinc-100"><td className="p-3"><p className="font-medium">{item.name}</p><p className="max-w-sm truncate text-xs text-zinc-500">{item.description}</p></td><td className="p-3"><span className={`inline-flex rounded-md border px-2.5 py-1 text-xs font-medium ${item.type === "heartbeat" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-blue-200 bg-blue-50 text-blue-800"}`}>{item.type === "heartbeat" ? "Heartbeat" : "Cron"}</span></td><td className="p-3 font-mono text-xs">{item.schedule}</td><td className="p-3 font-mono text-xs">{item.thread_session_id}</td><td className="p-3 text-xs text-zinc-500">{item.nextRunAt ?? "—"}</td><td className="p-3"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/pulses/${encodeURIComponent(item.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); toast(`Pulse ${enabled ? "enabled" : "disabled"}.`); } catch (cause) { toast((cause as Error).message, "error"); } }}/></td><td className="whitespace-nowrap p-3"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={expanded === item.id ? "Hide last result" : "Show last result"} onClick={() => setExpanded((current) => current === item.id ? undefined : item.id)}>{expanded === item.id ? <ChevronDown size={17}/> : <ChevronRight size={17}/>}</button><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`View execution logs for ${item.name}`} onClick={() => openLogs(item)}><ScrollText size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${item.name}`} onClick={() => edit(item)}><FilePenLine size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-red-50 hover:text-red-600" title={`Delete ${item.name}`} onClick={async () => { if (!confirm(`Delete pulse '${item.name}'?`)) return; try { await request(`profiles/${profile}/pulses/${encodeURIComponent(item.name)}`, { method: "DELETE" }); await load(); toast("Pulse deleted."); } catch (cause) { toast((cause as Error).message, "error"); } }}><Trash2 size={17}/></button></td></tr>{expanded === item.id && <tr className="border-b border-zinc-100 bg-zinc-50"><td colSpan={7} className="p-4"><p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Last result</p>{item.result ? <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-zinc-200 bg-white p-3 font-mono text-xs text-zinc-800">{item.result}</pre> : <p className="text-sm text-zinc-500">No result available yet.</p>}</td></tr>}</Fragment>)}{!items.filter((item) => showHistory || !(item.schedule.startsWith("@once:") && item.lastRunAt)).length && <tr><td colSpan={7} className="p-5 text-zinc-500">No pulses are configured for this view.</td></tr>}</tbody></table></div></Card>{logPulse && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="pulse-logs-title"><div className="max-h-[90vh] w-full max-w-5xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-5 flex items-start justify-between gap-4"><div><h2 id="pulse-logs-title" className="text-xl font-semibold">Execution logs — {logPulse.name}</h2><p className="mt-1 text-sm text-zinc-500">Results recorded by the Pulse Tick service.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close execution logs" onClick={() => setLogPulse(undefined)}><X size={17}/></Button></header><div className="mb-5 grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]"><label className="grid gap-1 text-sm font-medium text-zinc-700">Start<Input type="datetime-local" value={logStart} onChange={(event) => setLogStart(event.target.value)}/></label><label className="grid gap-1 text-sm font-medium text-zinc-700">End<Input type="datetime-local" value={logEnd} onChange={(event) => setLogEnd(event.target.value)}/></label><Button className="self-end" disabled={logsLoading} onClick={() => void loadRuns()}>{logsLoading ? <Loader2 className="animate-spin" size={17}/> : "Apply filters"}</Button><Button className="self-end bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Refresh execution logs" disabled={logsLoading} onClick={() => void loadRuns()}><RotateCw className={logsLoading ? "animate-spin" : ""} size={17}/></Button></div>{logsLoading && !runs ? <div className="py-12 text-center text-sm text-zinc-500">Loading execution logs…</div> : runs?.length ? <div className="space-y-3">{runs.map((run) => <article key={run.id} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-center justify-between gap-3"><div><p className="font-medium">{new Date(run.startedAt).toLocaleString()}</p><p className="text-xs text-zinc-500">{run.finishedAt ? `Duration: ${Math.max(0, new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000}s` : "Still running"}</p></div><span className={`rounded-md px-2 py-1 text-xs font-medium ${run.status === "success" ? "bg-emerald-50 text-emerald-800" : run.status === "error" ? "bg-red-50 text-red-800" : "bg-amber-50 text-amber-800"}`}>{run.status}</span></div>{run.response && <pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-zinc-200 bg-zinc-50 p-3 font-mono text-xs text-zinc-800">{run.response}</pre>}{run.error && <pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-red-200 bg-red-50 p-3 font-mono text-xs text-red-800">{run.error}</pre>}</article>)}</div> : <div className="py-12 text-center text-sm text-zinc-500">No executions were recorded in this period.</div>}</div></div>}{history && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-5 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">Completed one-time jobs</h2><p className="mt-1 text-sm text-zinc-500">Executed jobs with an <code>@once</code> schedule.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close history" onClick={() => setHistory(undefined)}><X size={17}/></Button></header>{history.length ? <div className="space-y-3">{history.map((item) => <article key={item.id} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-center justify-between gap-2"><div><p className="font-medium">{item.name}</p><p className="text-xs text-zinc-500">{item.description}</p></div><span className={`rounded-md px-2 py-1 text-xs font-medium ${item.status === "success" ? "bg-emerald-50 text-emerald-800" : "bg-red-50 text-red-800"}`}>{item.status}</span></div><p className="mt-3 text-xs text-zinc-500">Executed: {item.finishedAt ?? item.startedAt}</p>{item.result && <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-zinc-50 p-3 font-mono text-xs">{item.result}</pre>}{item.error && <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-red-50 p-3 font-mono text-xs text-red-800">{item.error}</pre>}</article>)}</div> : <p className="py-10 text-center text-sm text-zinc-500">No completed one-time jobs yet.</p>}</div></div>}{dialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[94vh] w-full max-w-3xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-6 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">{dialog === "create" ? "Create pulse" : `Edit ${editing?.name}`}</h2><p className="mt-1 text-sm text-zinc-500">Heartbeats retain state and must repeat in less than four hours. Cron jobs do not retain state.</p></div><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save pulse" disabled={!valid || saving} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={close}><X size={17}/></Button></div></header><div className="grid gap-5 md:grid-cols-2"><label className="grid content-start gap-1.5 text-sm font-semibold md:col-span-2">Name<Input disabled={dialog === "edit"} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="e.g. daily-report"/></label><label className="grid gap-1.5 text-sm font-semibold md:col-span-2">Description<Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="What this pulse is responsible for"/></label><label className="grid content-start gap-1.5 text-sm font-semibold">Schedule<Input value={form.schedule} onChange={(event) => setForm({ ...form, schedule: event.target.value })} placeholder="*/15 * * * * or @once:2026-03-12T14:30:00Z"/><span className="text-xs font-normal text-zinc-500">{scheduleDescription(form.schedule).text}</span></label><label className="grid content-start gap-1.5 text-sm font-semibold">Type<Input readOnly value={scheduleDescription(form.schedule).type === "heartbeat" ? "Heartbeat" : "Cron"} className={scheduleDescription(form.schedule).type === "heartbeat" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-blue-200 bg-blue-50 text-blue-800"}/><span className="text-xs font-normal text-zinc-500">Calculated automatically from the schedule.</span></label><label className="flex items-center gap-3 text-sm font-semibold md:col-span-2"><Switch checked={form.enabled} onCheckedChange={(enabled) => setForm({ ...form, enabled })}/><span>Enabled</span><span className="font-normal text-zinc-500">Run this pulse when the Pulse Tick service is active.</span></label>{editing?.thread_session_id && <label className="grid gap-1.5 text-sm font-semibold md:col-span-2">Thread session<Input readOnly value={editing.thread_session_id}/><span className="text-xs font-normal text-zinc-500">This pulse was registered from this conversation and receives its results there.</span></label>}<section className="grid gap-1.5 md:col-span-2"><label className="text-sm font-semibold">Prompt</label><p className="text-xs text-zinc-500">Markdown instructions executed on every pulse.</p><div className="overflow-hidden rounded-md border border-zinc-200"><CodeMirror value={form.prompt} height="300px" extensions={[markdown()]} onChange={(prompt) => setForm({ ...form, prompt })}/></div></section></div></div></div>}</>;
|
|
41
45
|
}
|
|
@@ -10,8 +10,8 @@ import { Switch } from "@/components/ui/switch";
|
|
|
10
10
|
import { SkillSourceImport } from "@/components/skill-source-import";
|
|
11
11
|
import { useToast } from "@/components/toast";
|
|
12
12
|
|
|
13
|
-
type Source = "shared" | "profile";
|
|
14
|
-
type Skill = { name: string; path?: string; source: Source; enabled: boolean };
|
|
13
|
+
type Source = "shared" | "profile" | "package";
|
|
14
|
+
type Skill = { name: string; path?: string; source: Source; package?: string; enabled: boolean };
|
|
15
15
|
type Document = { name: string; path?: string; frontmatter: Record<string, unknown>; content: string };
|
|
16
16
|
type Sources = { shared: boolean; profile: boolean };
|
|
17
17
|
type SkillSource = { identifier: string; name: string; enabled: boolean; hasAccessToken: boolean };
|
|
@@ -31,5 +31,5 @@ export function SkillResourcesPanel({ profile, initial }: { profile: string; ini
|
|
|
31
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
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
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
|
|
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]" : item.source === "package" ? "text-blue-600" : "text-[#55d175]"}>{item.source === "profile" ? "Profile" : item.source === "package" ? `Package: ${item.package ?? "unknown"}` : "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>{item.source !== "package" && <><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
35
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
const sectionLabels: Record<string, string> = {
|
|
4
|
+
overview: "Overview",
|
|
5
|
+
chat: "Chat",
|
|
6
|
+
profiles: "Profiles",
|
|
7
|
+
settings: "Settings",
|
|
8
|
+
env: "Environment",
|
|
9
|
+
soul: "SOUL",
|
|
10
|
+
"context-memory": "Context Memory",
|
|
11
|
+
guardrails: "Guardrails",
|
|
12
|
+
skills: "Skills",
|
|
13
|
+
tools: "Tools",
|
|
14
|
+
packages: "Packages",
|
|
15
|
+
pulses: "Pulses",
|
|
16
|
+
extensions: "Extensions",
|
|
17
|
+
applications: "Applications",
|
|
18
|
+
"api-server": "API Server",
|
|
19
|
+
"pi-console-webui": "Console WebUI",
|
|
20
|
+
"skill-sources": "Skill Sources",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const administrativeSections = new Set(["applications", "api-server", "pi-console-webui", "skill-sources"]);
|
|
24
|
+
|
|
25
|
+
export function sectionMetadata(section: string, profile?: string): Metadata {
|
|
26
|
+
const label = sectionLabels[section] ?? "Pi Console";
|
|
27
|
+
const selectedProfile = profile?.trim() || "default";
|
|
28
|
+
return { title: administrativeSections.has(section) ? label : `${label} — ${selectedProfile}` };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function pageMetadata(title: string): Metadata {
|
|
32
|
+
return { title };
|
|
33
|
+
}
|
package/extensions/profiles.ts
CHANGED
|
@@ -16,6 +16,7 @@ type ProfilePolicy = {
|
|
|
16
16
|
enabledProfileSkills?: string[];
|
|
17
17
|
enabledExtensions?: string[];
|
|
18
18
|
skillSources?: { shared?: boolean; profile?: boolean };
|
|
19
|
+
contextMemory?: { mode: "file"; target: "profile" | "identity" } | { mode: "handler"; handler: string };
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
type ProfileSettings = Record<string, unknown> & { profile?: ProfilePolicy };
|
|
@@ -130,14 +131,24 @@ function packageInstallPath(root: string, source: string): string | undefined {
|
|
|
130
131
|
|
|
131
132
|
async function rootRuntimeSources(root: string, base: ProfileSettings): Promise<string[]> {
|
|
132
133
|
const sources = new Set<string>();
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
const enabled = base.profile?.enabledExtensions;
|
|
135
|
+
const extensionName = (path: string) => basename(path).replace(/\.(?:ts|js)$/, "");
|
|
136
|
+
const add = (path: string) => {
|
|
137
|
+
if (!existsSync(path)) return;
|
|
138
|
+
const name = extensionName(path);
|
|
139
|
+
if (!enabled || enabled.includes("*") || enabled.includes(name)) sources.add(path);
|
|
138
140
|
};
|
|
139
|
-
if (Array.isArray(base.packages)) for (const entry of base.packages)
|
|
140
|
-
|
|
141
|
+
if (Array.isArray(base.packages)) for (const entry of base.packages) {
|
|
142
|
+
const source = typeof entry === "object" && entry !== null ? (entry as { source?: unknown }).source : entry;
|
|
143
|
+
if (typeof source !== "string") continue;
|
|
144
|
+
const packagePath = packageInstallPath(root, source);
|
|
145
|
+
if (!packagePath) { add(source.startsWith(".") ? resolve(root, source) : source); continue; }
|
|
146
|
+
try {
|
|
147
|
+
const manifest = JSON.parse(await readFile(join(packagePath, "package.json"), "utf8")) as { pi?: { extensions?: unknown } };
|
|
148
|
+
if (Array.isArray(manifest.pi?.extensions)) for (const extension of manifest.pi.extensions) if (typeof extension === "string") add(join(packagePath, extension));
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(base.extensions)) for (const entry of base.extensions) if (typeof entry === "string") add(entry.startsWith(".") ? resolve(root, entry) : entry);
|
|
141
152
|
// Pi auto-discovers these for the default profile. Add them explicitly when
|
|
142
153
|
// re-executing a named profile, where that profile's extensions directory is
|
|
143
154
|
// intentionally empty.
|
|
@@ -5,7 +5,8 @@ import { dirname } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
export type PulseType = "cron" | "heartbeat";
|
|
7
7
|
export type Pulse = { id: string; name: string; description: string; type: PulseType; schedule: string; prompt: string; thread_session_id: string; result: string | null; profile: string; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null };
|
|
8
|
-
export type PulseHistory = Pulse & { startedAt: string; finishedAt: string | null; status: string; error: string | null };
|
|
8
|
+
export type PulseHistory = Pulse & { startedAt: string; finishedAt: string | null; status: string; error: string | null };
|
|
9
|
+
export type PulseRun = { id: string; startedAt: string; finishedAt: string | null; status: "running" | "success" | "error"; response: string | null; error: string | null };
|
|
9
10
|
type Row = Record<string, unknown>;
|
|
10
11
|
const iso = (date = new Date()) => date.toISOString();
|
|
11
12
|
|
|
@@ -60,7 +61,8 @@ export class PulseStore {
|
|
|
60
61
|
this.db.exec(`CREATE TABLE IF NOT EXISTS pulses (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('cron','heartbeat')), schedule TEXT NOT NULL, prompt TEXT NOT NULL, thread_session_id TEXT NOT NULL, result TEXT);
|
|
61
62
|
CREATE TABLE IF NOT EXISTS pulse_control (pulse_id TEXT PRIMARY KEY REFERENCES pulses(id) ON DELETE CASCADE, profile TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, next_run_at TEXT, last_run_at TEXT, updated_at TEXT NOT NULL);
|
|
62
63
|
CREATE TABLE IF NOT EXISTS pulse_state (pulse_id TEXT PRIMARY KEY REFERENCES pulses(id) ON DELETE CASCADE, handoff TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL);
|
|
63
|
-
CREATE TABLE IF NOT EXISTS pulse_runs (id TEXT PRIMARY KEY, pulse_id TEXT NOT NULL REFERENCES pulses(id) ON DELETE CASCADE, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, response TEXT, error TEXT)
|
|
64
|
+
CREATE TABLE IF NOT EXISTS pulse_runs (id TEXT PRIMARY KEY, pulse_id TEXT NOT NULL REFERENCES pulses(id) ON DELETE CASCADE, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, response TEXT, error TEXT);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS pulse_runs_pulse_started ON pulse_runs(pulse_id, started_at DESC);`);
|
|
64
66
|
// Migrate databases created before the denormalized latest result column.
|
|
65
67
|
const columns = this.db.prepare("PRAGMA table_info(pulses)").all() as Row[];
|
|
66
68
|
if (!columns.some((column) => column.name === "result")) this.db.exec("ALTER TABLE pulses ADD COLUMN result TEXT");
|
|
@@ -72,6 +74,7 @@ export class PulseStore {
|
|
|
72
74
|
list(profile?: string): Pulse[] { const query = `SELECT p.*, c.profile, c.enabled, c.next_run_at, c.last_run_at FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id${profile ? " WHERE c.profile=?" : ""} ORDER BY p.name`; return this.db.prepare(query).all(...(profile ? [profile] : [])) .map((row) => this.pulse(row as Row)); }
|
|
73
75
|
get(name: string, profile?: string): Pulse | undefined { return this.list(profile).find((item) => item.name === name); }
|
|
74
76
|
history(profile: string): PulseHistory[] { return this.db.prepare(`SELECT p.*, c.profile, c.enabled, c.next_run_at, c.last_run_at, r.started_at AS startedAt, r.finished_at AS finishedAt, r.status, r.error FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id JOIN pulse_runs r ON r.id=(SELECT id FROM pulse_runs WHERE pulse_id=p.id AND finished_at IS NOT NULL ORDER BY started_at DESC LIMIT 1) WHERE c.profile=? AND p.schedule LIKE '@once:%' ORDER BY r.started_at DESC`).all(profile).map((row) => { const record = row as Row; return { ...this.pulse(record), startedAt: String(record.startedAt), finishedAt: record.finishedAt == null ? null : String(record.finishedAt), status: String(record.status), error: record.error == null ? null : String(record.error) }; }); }
|
|
77
|
+
runs(profile: string, name: string, start: string, end: string, limit = 100): PulseRun[] { const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found."); return this.db.prepare("SELECT id, started_at, finished_at, status, response, error FROM pulse_runs WHERE pulse_id=? AND started_at>=? AND started_at<=? ORDER BY started_at DESC LIMIT ?").all(pulse.id, start, end, limit).map((row) => { const item = row as Row; return { id: String(item.id), startedAt: String(item.started_at), finishedAt: item.finished_at == null ? null : String(item.finished_at), status: item.status as PulseRun["status"], response: item.response == null ? null : String(item.response), error: item.error == null ? null : String(item.error) }; }); }
|
|
75
78
|
create(input: Omit<Pulse, "id" | "enabled" | "nextRunAt" | "lastRunAt" | "type" | "result">): Pulse {
|
|
76
79
|
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(input.name)) throw new Error("Invalid pulse name.");
|
|
77
80
|
if (!validSchedule(input.schedule)) throw new Error("Invalid schedule. Use a five-field UTC cron expression or @once:<ISO-8601>.");
|