pi-feats 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -9
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-log-store.ts +4 -2
- package/extensions/api-server/application-runtime.ts +79 -8
- package/extensions/api-server/application-store.ts +23 -10
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +109 -39
- package/extensions/api-server/server.ts +220 -44
- package/extensions/cli-resources.ts +144 -113
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/conversation-search/index.ts +42 -0
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/builtin-tools.ts +24 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/conversation-search.ts +33 -0
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +14 -3
- package/extensions/lib/remote-hosts.ts +7 -5
- package/extensions/lib/workflow-evaluator.ts +113 -0
- package/extensions/lib/workflow-harness.ts +409 -43
- package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
- package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
- package/extensions/pi-console-webui/app/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
- package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
- package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
- package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
- package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
- package/extensions/pi-console-webui/components/console-page.tsx +4 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +32 -11
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
- package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +7 -6
- package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
- package/extensions/pi-console-webui/index.ts +2 -1
- package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
- package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
- package/extensions/pi-console-webui/next.config.js +1 -0
- package/extensions/profile-ssh.ts +274 -0
- package/extensions/profiles.ts +69 -25
- package/extensions/pulse/index.ts +148 -16
- package/extensions/pulse/store.ts +132 -18
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +21 -9
- package/scripts/build-console-webui.mjs +7 -0
- package/scripts/console-webui-cache.mjs +79 -0
- package/scripts/ensure-console-build.mjs +43 -0
- package/skills/profile-ssh/SKILL.md +65 -0
- package/skills/sequential-workflow/SKILL.md +4 -3
- package/themes/legacy-dark.json +85 -0
|
@@ -34,6 +34,6 @@ export function ApplicationHandlerEditor({ name, embedded = false }: { name: str
|
|
|
34
34
|
const renameFile = async () => { if (!renamePath || !validName(renameName)) return toast("Use a valid handler name.", "error"); const path = renamePath.includes("/") ? `${renamePath.slice(0, renamePath.lastIndexOf("/") + 1)}${renameName}.ts` : `${renameName}.ts`; try { const data = await api(`${base}/handler?path=${encodeURIComponent(renamePath)}`, { method: "PATCH", body: JSON.stringify({ newPath: path }) }); setFiles((items) => items.map((item) => item === renamePath ? data.path : item).sort()); setSelected(data.path); setRenamePath(undefined); toast("Handler renamed and Application references updated."); } catch (error) { toast((error as Error).message, "error"); } };
|
|
35
35
|
const deleteFile = async (path: string) => { if (!confirm(`Delete ${path}? This cannot be undone.`)) return; try { await fetch(`/api/pi/${base}/handler?path=${encodeURIComponent(path)}`, { method: "DELETE" }).then(async (response) => { if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error?.message ?? "Unable to delete handler"); }); setFiles((items) => items.filter((item) => item !== path)); if (selected === path) { const next = files.find((item) => item !== path) ?? ""; setSelected(next); setContent(""); } toast("Handler deleted."); } catch (error) { toast((error as Error).message, "error"); } };
|
|
36
36
|
const groups = new Map<string, string[]>(); for (const file of files) { const index = file.lastIndexOf("/"); const folder = index < 0 ? "handlers" : `handlers/${file.slice(0, index)}`; groups.set(folder, [...(groups.get(folder) ?? []), file]); }
|
|
37
|
-
return <div className="space-y-4">{!embedded && <div className="flex items-center gap-3"><Link href="/applications?context=admin" className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100" title="Back to Applications"><ArrowLeft size={18}/></Link><div><h2 className="text-xl font-semibold">Edit handlers: {name}</h2><p className="text-sm text-zinc-500">Changes are loaded on the next Application request.</p></div></div>}<div className="grid min-h-[650px] gap-4 lg:grid-cols-[320px_minmax(0,1fr)]"><Card className="overflow-auto"><div className="flex items-center justify-between gap-2"><CardTitle>Files</CardTitle><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Add handler" onClick={() => { setHandlerType("inbound"); setNewName(""); setCreateDialog(true); }}><Plus size={17}/></Button></div><div className="mt-4 space-y-4">{[...groups].map(([folder, paths]) => <div key={folder}><p className="mb-1 text-xs font-semibold text-zinc-500">{folder}</p>{paths.map((path) => <div key={path} className={`flex items-center rounded-md ${selected === path ? "bg-blue-50 text-blue-800" : "hover:bg-zinc-100"}`}><button onClick={() => setSelected(path)} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-2 text-left text-sm"><FileCode2 size={15}/><span className="truncate" title={path.split("/").at(-1)}>{path.split("/").at(-1)}</span></button><div className="relative"><button title={`Actions for ${path}`} className="rounded p-2 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900" onClick={() => setMenuPath((current) => current === path ? undefined : path)}><MoreHorizontal size={16}/></button>{menuPath === path && <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={() => { setRenamePath(path); setRenameName(fileName(path)); setMenuPath(undefined); }}>Rename</button><button className="w-full rounded px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50" onClick={() => { setMenuPath(undefined); void deleteFile(path); }}>Delete</button></div>}</div></div>)}</div>)}{!loading && !files.length && <p className="text-sm text-zinc-500">No handler files found.</p>}</div></Card><Card className="flex min-w-0 flex-col"><div className="flex items-center justify-between gap-3"><div><CardTitle>{selected || "Select a handler"}</CardTitle><p className="text-sm text-zinc-500">TypeScript source</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Restore editor to the handler template without saving" disabled={!selected} onClick={() => setContent(templateFor(typeForPath(selected)))}><RotateCcw size={17}/></Button><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save handler" disabled={!selected} onClick={save}><Save size={17}/></Button></div></div>{selected && <div className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200"><CodeMirror value={content} height="560px" extensions={[javascript({ typescript: true })]} onChange={setContent}/></div>}</Card></div><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 / PAYLOAD</p><CodeMirror value={payload} height="260px" extensions={[json()]} onChange={setPayload}/></div><Button className="self-center !bg-[#2bbb77] hover:!bg-[#249b63]" title="Run handler test" disabled={!selected || testing} onClick={test}><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={output} height="260px" extensions={[json()]} editable={false}/></div></div></Card><Card><CardTitle>Logs</CardTitle><pre className="handler-test-logs mt-4 min-h-28">{logs || "Run a test to view handler logs."}</pre></Card>{createDialog && <Dialog title="New handler" close={() => setCreateDialog(false)}><p className="text-sm text-zinc-500">Choose a type and provide a unique handler name.</p><
|
|
37
|
+
return <div className="space-y-4">{!embedded && <div className="flex items-center gap-3"><Link href="/applications?context=admin" className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100" title="Back to Applications"><ArrowLeft size={18}/></Link><div><h2 className="text-xl font-semibold">Edit handlers: {name}</h2><p className="text-sm text-zinc-500">Changes are loaded on the next Application request.</p></div></div>}<div className="grid min-h-[650px] gap-4 lg:grid-cols-[320px_minmax(0,1fr)]"><Card className="overflow-auto"><div className="flex items-center justify-between gap-2"><CardTitle>Files</CardTitle><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Add handler" onClick={() => { setHandlerType("inbound"); setNewName(""); setCreateDialog(true); }}><Plus size={17}/></Button></div><div className="mt-4 space-y-4">{[...groups].map(([folder, paths]) => <div key={folder}><p className="mb-1 text-xs font-semibold text-zinc-500">{folder}</p>{paths.map((path) => <div key={path} className={`flex items-center rounded-md ${selected === path ? "bg-blue-50 text-blue-800" : "hover:bg-zinc-100"}`}><button onClick={() => setSelected(path)} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-2 text-left text-sm"><FileCode2 size={15}/><span className="truncate" title={path.split("/").at(-1)}>{path.split("/").at(-1)}</span></button><div className="relative"><button title={`Actions for ${path}`} className="rounded p-2 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900" onClick={() => setMenuPath((current) => current === path ? undefined : path)}><MoreHorizontal size={16}/></button>{menuPath === path && <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={() => { setRenamePath(path); setRenameName(fileName(path)); setMenuPath(undefined); }}>Rename</button><button className="w-full rounded px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50" onClick={() => { setMenuPath(undefined); void deleteFile(path); }}>Delete</button></div>}</div></div>)}</div>)}{!loading && !files.length && <p className="text-sm text-zinc-500">No handler files found.</p>}</div></Card><Card className="flex min-w-0 flex-col"><div className="flex items-center justify-between gap-3"><div><CardTitle>{selected || "Select a handler"}</CardTitle><p className="text-sm text-zinc-500">TypeScript source</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Restore editor to the handler template without saving" disabled={!selected} onClick={() => setContent(templateFor(typeForPath(selected)))}><RotateCcw size={17}/></Button><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save handler" disabled={!selected} onClick={save}><Save size={17}/></Button></div></div>{selected && <div className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200"><CodeMirror value={content} height="560px" extensions={[javascript({ typescript: true })]} onChange={setContent}/></div>}</Card></div><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 / PAYLOAD</p><CodeMirror value={payload} height="260px" extensions={[json()]} onChange={setPayload}/></div><Button className="self-center !bg-[#2bbb77] hover:!bg-[#249b63]" title="Run handler test" disabled={!selected || testing} onClick={test}><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={output} height="260px" extensions={[json()]} editable={false}/></div></div></Card><Card><CardTitle>Logs</CardTitle><pre className="handler-test-logs mt-4 min-h-28">{logs || "Run a test to view handler logs."}</pre></Card>{createDialog && <Dialog title="New handler" close={() => setCreateDialog(false)}><p className="text-sm text-zinc-500">Choose a type and provide a unique handler name.</p><label className="mt-4 block text-sm">Handler name<Input className="mt-1" value={newName} onChange={(event) => setNewName(event.target.value)} placeholder="e.g. whatsapp-inbound" autoFocus/></label><p className="mt-4 text-sm font-semibold">Handler type</p><div className="mt-2 space-y-2">{handlerTypes.map((type) => <button key={type.value} onClick={() => setHandlerType(type.value)} className={`w-full rounded-lg border p-3 text-left ${handlerType === type.value ? "border-blue-500 bg-blue-50" : "border-zinc-200 hover:bg-zinc-50"}`}><span className="block text-sm font-normal">{type.label}</span><span className="mt-1 block text-xs text-zinc-500">{type.description}</span></button>)}</div><div className="mt-5 flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setCreateDialog(false)}>Cancel</Button><Button onClick={create}>Create handler</Button></div></Dialog>}{renamePath && <Dialog title="Rename handler" close={() => setRenamePath(undefined)}><p className="text-sm text-zinc-500">{renamePath.includes("/") ? `Folder: ${renamePath.slice(0, renamePath.lastIndexOf("/"))}` : "Folder: handlers"}</p><label className="mt-4 block text-sm">Handler name<Input className="mt-1" 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={() => setRenamePath(undefined)}>Cancel</Button><Button onClick={renameFile}>Rename handler</Button></div></Dialog>}</div>;
|
|
38
38
|
}
|
|
39
39
|
function Dialog({ title, close, children }: { title: string; close: () => void; children: React.ReactNode }) { return <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"><div className="flex items-start justify-between gap-3"><h3 className="text-lg font-semibold">{title}</h3><button title="Close" className="rounded-lg p-1 text-zinc-500 hover:bg-zinc-100" onClick={close}><X size={18}/></button></div>{children}</div></div>; }
|
|
@@ -8,11 +8,11 @@ import { Button } from "@/components/ui/button";
|
|
|
8
8
|
import { Card, CardTitle } from "@/components/ui/card";
|
|
9
9
|
import { useToast } from "@/components/toast";
|
|
10
10
|
type Stage = { id: string; label: string; type: string; payload?: unknown; input?: unknown; output?: unknown; stdout: string[]; stderr: string[]; error?: string; completed?: boolean };
|
|
11
|
-
type Call = { id: string; receivedAt: string; origin: string; status: string; stages: Stage[]; error?: string };
|
|
11
|
+
type Call = { id: string; receivedAt: string; origin: string; status: string; outcome?: "ignored"; stages: Stage[]; error?: string };
|
|
12
12
|
const api = async (path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, init); if (!response.ok) throw new Error("Request failed"); return response.json(); };
|
|
13
13
|
const json = (value: unknown) => JSON.stringify(value, null, 2) ?? "No data recorded.";
|
|
14
14
|
const localDateTime = (date: Date) => { const offset = date.getTimezoneOffset() * 60_000; return new Date(date.getTime() - offset).toISOString().slice(0, 16); };
|
|
15
|
-
const stageFilters = [{ value: "request", label: "Request", className: "border-sky-400 bg-sky-50 text-sky-700" }, { value: "transform", label: "Transform", className: "border-amber-400 bg-amber-50 text-amber-800" }, { value: "inbound-handler", label: "Inbound handler", className: "border-emerald-400 bg-emerald-50 text-emerald-800" }, { value: "coalescing", label: "Coalescing", className: "border-cyan-400 bg-cyan-50 text-cyan-800" }, { value: "pi-agent", label: "Pi agent", className: "border-indigo-400 bg-indigo-50 text-indigo-800" }, { value: "outbound-handler", label: "Outbound handler", className: "border-fuchsia-400 bg-fuchsia-50 text-fuchsia-800" }, { value: "final-response", label: "Final response", className: "border-teal-400 bg-teal-50 text-teal-800" }];
|
|
15
|
+
const stageFilters = [{ value: "request", label: "Request", className: "border-sky-400 bg-sky-50 text-sky-700" }, { value: "transform", label: "Transform", className: "border-amber-400 bg-amber-50 text-amber-800" }, { value: "inbound-handler", label: "Inbound handler", className: "border-emerald-400 bg-emerald-50 text-emerald-800" }, { value: "identity-miss", label: "Identity miss", className: "border-zinc-300 bg-zinc-50 text-zinc-700" }, { value: "identity-ignored", label: "Ignored by mapping", className: "border-amber-400 bg-amber-50 text-amber-800" }, { value: "coalescing", label: "Coalescing", className: "border-cyan-400 bg-cyan-50 text-cyan-800" }, { value: "pi-agent", label: "Pi agent", className: "border-indigo-400 bg-indigo-50 text-indigo-800" }, { value: "outbound-handler", label: "Outbound handler", className: "border-fuchsia-400 bg-fuchsia-50 text-fuchsia-800" }, { value: "final-response", label: "Final response", className: "border-teal-400 bg-teal-50 text-teal-800" }];
|
|
16
16
|
const stageColor = (type: string) => stageFilters.find((stage) => stage.value === type)?.className ?? "border-zinc-300 bg-zinc-50 text-zinc-700";
|
|
17
17
|
function JsonView({ value }: { value: unknown }) {
|
|
18
18
|
const [copied, setCopied] = useState(false);
|
|
@@ -36,7 +36,7 @@ export function ApplicationLogs({ name, embedded = false }: { name: string; embe
|
|
|
36
36
|
const load = async () => { try { setLoading(true); const params = new URLSearchParams({ summary: "1", limit: "250" }); if (start) params.set("start", new Date(start).toISOString()); if (end) params.set("end", new Date(end).toISOString()); const data = await api(`${base}/logs?${params}`) as { calls: Call[] }; setCalls(data.calls); const current = data.calls.find((call) => call.id === selected?.id) ?? data.calls[0]; if (current && current.id !== selected?.id) void selectCall(current); if (!current) { setSelected(undefined); setStage(undefined); } } catch (error) { toast((error as Error).message, "error"); } finally { setLoading(false); } };
|
|
37
37
|
useEffect(() => { void load(); }, [name, start, end]);
|
|
38
38
|
useEffect(() => { let socket: WebSocket | undefined; api(`${base}/logs/ticket`, { method: "POST" }).then(({ url }) => { socket = new WebSocket(url); socket.onmessage = () => void load(); }).catch((error) => toast((error as Error).message, "error")); return () => socket?.close(); }, [name]);
|
|
39
|
-
const filtered = useMemo(() => calls.filter((call) => (statuses.length === 0 || statuses.includes(call.status)) && (stages.length === 0 || call.stages.some((item) => stages.includes(item.type))) && (!start || new Date(call.receivedAt) >= new Date(start)) && (!end || new Date(call.receivedAt) <= new Date(end))), [calls, statuses, stages, start, end]);
|
|
39
|
+
const filtered = useMemo(() => calls.filter((call) => (statuses.length === 0 || statuses.includes(call.status) || (statuses.includes("ignored") && call.outcome === "ignored")) && (stages.length === 0 || call.stages.some((item) => stages.includes(item.type))) && (!start || new Date(call.receivedAt) >= new Date(start)) && (!end || new Date(call.receivedAt) <= new Date(end))), [calls, statuses, stages, start, end]);
|
|
40
40
|
const current = selected && filtered.find((call) => call.id === selected.id) ? selected : undefined; const detail = stage && current?.stages.some((item) => item.id === stage.id) ? stage : current?.stages[0];
|
|
41
|
-
return <div className="space-y-4">{!embedded && <div className="flex items-center gap-3"><Link href="/applications?context=admin" className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100" title="Back to Applications"><ArrowLeft size={18}/></Link><div className="mr-auto"><h2 className="text-xl font-semibold">Call logs: {name}</h2><p className="text-sm text-zinc-500">Live Application calls retained for seven days.</p></div><Button className="bg-red-600 hover:bg-red-700" title="Clear all logs" onClick={async () => { if (confirm("Delete all logs for this Application? This cannot be undone.")) { await fetch(`/api/pi/${base}/logs`, { method: "DELETE" }); setCalls([]); setSelected(undefined); setStage(undefined); } }}><Trash2 size={17}/></Button></div>}<Card className="space-y-4"><div className="flex items-center gap-2 text-sm font-semibold"><Filter size={16} className="text-emerald-600"/>Log filters</div><div className="flex flex-wrap gap-2">{stageFilters.map((filter) => <button key={filter.value} onClick={() => toggle(filter.value, setStages)} className={`rounded-full border px-3 py-1 text-xs font-medium transition ${filter.className} ${stages.includes(filter.value) ? "ring-2 ring-offset-1 ring-zinc-400" : "opacity-70 hover:opacity-100"}`}>{filter.label}</button>)}{["success", "error", "running"].map((value) => <button key={value} onClick={() => toggle(value, setStatuses)} className={`rounded-full border border-zinc-500 px-3 py-1 text-xs font-medium capitalize ${statuses.includes(value) ? "bg-zinc-800 text-white" : "bg-white text-zinc-700"}`}>{value === "error" ? "Errors" : value}</button>)}</div><div className="grid max-w-xl gap-4 sm:grid-cols-2"><label className="grid gap-1 text-sm font-medium">Start (browser time)<input type="datetime-local" value={start} onChange={(event) => setStart(event.target.value)} className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm"/></label><label className="grid gap-1 text-sm font-medium">End (browser time)<input type="datetime-local" value={end} onChange={(event) => setEnd(event.target.value)} className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm"/></label></div></Card><div className="grid gap-4 lg:grid-cols-[280px_minmax(300px,1fr)_minmax(340px,1fr)]"><Card className="max-h-[650px] overflow-auto"><CardTitle>Calls</CardTitle><div className="mt-3 space-y-2">{loading && <div className="flex items-center gap-2 py-1 text-sm text-zinc-500"><Loader2 className="animate-spin" size={16}/>Loading log calls…</div>}{filtered.map((call) => <button key={call.id} onClick={() => void selectCall(call)} className={`w-full rounded-lg border p-3 text-left text-sm ${current?.id === call.id ? "border-emerald-400 bg-emerald-50" : "border-zinc-200 hover:bg-zinc-50"}`}><b>{new Date(call.receivedAt).toLocaleString()}</b><p className="truncate text-zinc-500">{call.origin}</p><p className={call.status === "error" ? "font-medium text-red-700" : call.status === "running" ? "text-amber-700" : "text-emerald-700"}>{call.status}</p>{call.error && <p className="mt-1 line-clamp-2 text-xs text-red-700">{call.error.split("\n")[0]}</p>}</button>)}{!loading && !filtered.length && <p className="text-sm text-zinc-500">No calls match the filters.</p>}</div></Card><Card className="max-h-[650px] overflow-auto"><CardTitle>Workflow</CardTitle><div className="mt-4 space-y-2">{current?.stages.map((item, index) => <div key={item.id}><button onClick={() => setStage(item)} className={`w-full rounded-lg border p-3 text-left transition ${item.error ? "border-red-300 bg-red-50 text-red-800" : `${stageColor(item.type)} hover:brightness-95`} ${detail?.id === item.id ? "ring-2 ring-offset-1 ring-zinc-400" : ""}`}><b>{item.label}</b><p className={item.error ? "text-xs text-red-700" : "text-xs opacity-75"}>{item.error ? item.error.split("\n")[0] : item.completed || item.output !== undefined || item.payload !== undefined ? "Completed" : "Running"}</p></button>{index < current.stages.length - 1 && <FlowArrow output={item.output} nextInput={current.stages[index + 1].input}/>}</div>)}{current?.error && <div className="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800"><b>Call error</b><p className="mt-1 whitespace-pre-wrap">{current.error}</p></div>}</div></Card><Card className="max-h-[650px] overflow-auto"><CardTitle>Details</CardTitle>{loadingDetail ? <div className="mt-4 flex min-h-32 items-center justify-center gap-2 text-sm text-zinc-500"><Loader2 className="animate-spin" size={17}/>Loading call details…</div> : detail ? <><p className="mt-4 text-sm font-medium">{detail.label}</p><div className="mt-3 space-y-3"><section><p className="mb-1 text-xs font-semibold uppercase text-zinc-500">{detail.payload !== undefined ? "Payload" : "Input"}</p><JsonView value={detail.payload !== undefined ? detail.payload : detail.input}/></section>{detail.output !== undefined && <section><p className="mb-1 text-xs font-semibold uppercase text-zinc-500">Output</p><JsonView value={detail.output}/></section>}{(detail.stdout.length > 0 || detail.stderr.length > 0) && <section><p className="mb-1 text-xs font-semibold uppercase text-zinc-500">Handler console</p><JsonView value={{ stdout: detail.stdout, stderr: detail.stderr }}/></section>}{detail.error && <section><p className="mb-1 text-xs font-semibold uppercase text-red-700">Error</p><pre className="log-json border-red-300">{detail.error}</pre></section>}</div></> : <p className="mt-4 text-sm text-zinc-500">Select a call and a stage to inspect its data.</p>}</Card></div></div>;
|
|
41
|
+
return <div className="space-y-4">{!embedded && <div className="flex items-center gap-3"><Link href="/applications?context=admin" className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100" title="Back to Applications"><ArrowLeft size={18}/></Link><div className="mr-auto"><h2 className="text-xl font-semibold">Call logs: {name}</h2><p className="text-sm text-zinc-500">Live Application calls retained for seven days.</p></div><Button className="bg-red-600 hover:bg-red-700" title="Clear all logs" onClick={async () => { if (confirm("Delete all logs for this Application? This cannot be undone.")) { await fetch(`/api/pi/${base}/logs`, { method: "DELETE" }); setCalls([]); setSelected(undefined); setStage(undefined); } }}><Trash2 size={17}/></Button></div>}<Card className="space-y-4"><div className="flex items-center gap-2 text-sm font-semibold"><Filter size={16} className="text-emerald-600"/>Log filters</div><div className="flex flex-wrap gap-2">{stageFilters.map((filter) => <button key={filter.value} onClick={() => toggle(filter.value, setStages)} className={`rounded-full border px-3 py-1 text-xs font-medium transition ${filter.className} ${stages.includes(filter.value) ? "ring-2 ring-offset-1 ring-zinc-400" : "opacity-70 hover:opacity-100"}`}>{filter.label}</button>)}{["success", "ignored", "error", "running"].map((value) => <button key={value} onClick={() => toggle(value, setStatuses)} className={`rounded-full border border-zinc-500 px-3 py-1 text-xs font-medium capitalize ${statuses.includes(value) ? "bg-zinc-800 text-white" : "bg-white text-zinc-700"}`}>{value === "error" ? "Errors" : value === "ignored" ? "Identity miss" : value}</button>)}</div><div className="grid max-w-xl gap-4 sm:grid-cols-2"><label className="grid gap-1 text-sm font-medium">Start (browser time)<input type="datetime-local" value={start} onChange={(event) => setStart(event.target.value)} className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm"/></label><label className="grid gap-1 text-sm font-medium">End (browser time)<input type="datetime-local" value={end} onChange={(event) => setEnd(event.target.value)} className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm"/></label></div></Card><div className="grid gap-4 lg:grid-cols-[280px_minmax(300px,1fr)_minmax(340px,1fr)]"><Card className="max-h-[650px] overflow-auto"><CardTitle>Calls</CardTitle><div className="mt-3 space-y-2">{loading && <div className="flex items-center gap-2 py-1 text-sm text-zinc-500"><Loader2 className="animate-spin" size={16}/>Loading log calls…</div>}{filtered.map((call) => <button key={call.id} onClick={() => void selectCall(call)} className={`w-full rounded-lg border p-3 text-left text-sm ${current?.id === call.id ? "border-emerald-400 bg-emerald-50" : "border-zinc-200 hover:bg-zinc-50"}`}><b>{new Date(call.receivedAt).toLocaleString()}</b><p className="truncate text-zinc-500">{call.origin}</p><p className={call.status === "error" ? "font-medium text-red-700" : call.status === "running" ? "text-amber-700" : call.outcome === "ignored" ? "text-amber-700" : call.status === "ignored" ? "text-zinc-600" : "text-emerald-700"}>{call.outcome === "ignored" ? "Ignored by mapping" : call.status === "ignored" ? "Identity miss" : call.status}</p>{call.error && <p className="mt-1 line-clamp-2 text-xs text-red-700">{call.error.split("\n")[0]}</p>}</button>)}{!loading && !filtered.length && <p className="text-sm text-zinc-500">No calls match the filters.</p>}</div></Card><Card className="max-h-[650px] overflow-auto"><CardTitle>Workflow</CardTitle><div className="mt-4 space-y-2">{current?.stages.map((item, index) => <div key={item.id}><button onClick={() => setStage(item)} className={`w-full rounded-lg border p-3 text-left transition ${item.error ? "border-red-300 bg-red-50 text-red-800" : `${stageColor(item.type)} hover:brightness-95`} ${detail?.id === item.id ? "ring-2 ring-offset-1 ring-zinc-400" : ""}`}><b>{item.label}</b><p className={item.error ? "text-xs text-red-700" : "text-xs opacity-75"}>{item.error ? item.error.split("\n")[0] : item.completed || item.output !== undefined || item.payload !== undefined ? "Completed" : "Running"}</p></button>{index < current.stages.length - 1 && <FlowArrow output={item.output} nextInput={current.stages[index + 1].input}/>}</div>)}{current?.error && <div className="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800"><b>Call error</b><p className="mt-1 whitespace-pre-wrap">{current.error}</p></div>}</div></Card><Card className="max-h-[650px] overflow-auto"><CardTitle>Details</CardTitle>{loadingDetail ? <div className="mt-4 flex min-h-32 items-center justify-center gap-2 text-sm text-zinc-500"><Loader2 className="animate-spin" size={17}/>Loading call details…</div> : detail ? <><p className="mt-4 text-sm font-medium">{detail.label}</p><div className="mt-3 space-y-3"><section><p className="mb-1 text-xs font-semibold uppercase text-zinc-500">{detail.payload !== undefined ? "Payload" : "Input"}</p><JsonView value={detail.payload !== undefined ? detail.payload : detail.input}/></section>{detail.output !== undefined && <section><p className="mb-1 text-xs font-semibold uppercase text-zinc-500">Output</p><JsonView value={detail.output}/></section>}{(detail.stdout.length > 0 || detail.stderr.length > 0) && <section><p className="mb-1 text-xs font-semibold uppercase text-zinc-500">Handler console</p><JsonView value={{ stdout: detail.stdout, stderr: detail.stderr }}/></section>}{detail.error && <section><p className="mb-1 text-xs font-semibold uppercase text-red-700">Error</p><pre className="log-json border-red-300">{detail.error}</pre></section>}</div></> : <p className="mt-4 text-sm text-zinc-500">Select a call and a stage to inspect its data.</p>}</Card></div></div>;
|
|
42
42
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
|
|
3
2
|
import { useEffect, useState } from "react";
|
|
4
3
|
import { Info, Loader2, Pencil, Plus, Trash2, X } from "lucide-react";
|
|
5
4
|
import { Button } from "@/components/ui/button";
|
|
@@ -7,22 +6,21 @@ import { Card, CardTitle } from "@/components/ui/card";
|
|
|
7
6
|
import { Input } from "@/components/ui/input";
|
|
8
7
|
import { useToast } from "@/components/toast";
|
|
9
8
|
|
|
10
|
-
type Mapping = { identityKey: string; profile: string; sessionMode: "fixed" | "automatic"; sessionPrefix: string | null };
|
|
9
|
+
type Mapping = { identityKey: string; profile: string | null; sessionMode: "fixed" | "automatic" | "ignore"; sessionPrefix: string | null };
|
|
11
10
|
type Form = { identityKey: string; profile: string; prefix: string };
|
|
11
|
+
const ignore = "__ignore__";
|
|
12
12
|
const emptyForm = (): Form => ({ identityKey: "", profile: "", prefix: "" });
|
|
13
|
-
async
|
|
13
|
+
const api = async (path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { headers: { "content-type": "application/json" }, ...init }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error ?? "Request failed"); return body; };
|
|
14
14
|
|
|
15
15
|
export function ApplicationMappings({ slug }: { slug: string }) {
|
|
16
|
-
const { toast } = useToast();
|
|
17
|
-
const
|
|
18
|
-
const wildcard = form.identityKey.trim() === "*", wildcardExists = items.some((item) => item.identityKey === "*");
|
|
16
|
+
const { toast } = useToast(); const [items, setItems] = useState<Mapping[]>([]), [profiles, setProfiles] = useState<string[]>([]), [form, setForm] = useState<Form>(emptyForm()), [dialog, setDialog] = useState<"create" | "edit">(), [editing, setEditing] = useState<Mapping>(), [saving, setSaving] = useState(false);
|
|
17
|
+
const wildcard = form.identityKey.trim() === "*", ignoring = form.profile === ignore, wildcardExists = items.some((item) => item.identityKey === "*" && item.identityKey !== editing?.identityKey), valid = Boolean(form.identityKey.trim() && (ignoring || (form.profile && (wildcard || form.prefix.trim()))));
|
|
19
18
|
const load = async () => { const [mappings, profileData] = await Promise.all([api(`applications/${slug}/identity-mappings`), api("profiles")]); setItems(mappings.mappings); const values = profileData.profiles.map((item: { name: string }) => item.name); setProfiles(values); setForm((current) => current.profile ? current : { ...current, profile: values[0] ?? "" }); };
|
|
20
|
-
useEffect(() => { void load().catch((error) => toast(
|
|
21
|
-
const close = () => { setDialog(undefined); setEditing(undefined);
|
|
19
|
+
useEffect(() => { void load().catch((error) => toast(error.message, "error")); }, [slug]);
|
|
20
|
+
const close = () => { setDialog(undefined); setEditing(undefined); setForm(emptyForm()); };
|
|
22
21
|
const openCreate = () => { setEditing(undefined); setForm({ ...emptyForm(), profile: profiles[0] ?? "" }); setDialog("create"); };
|
|
23
|
-
const openEdit = (mapping: Mapping) => { setEditing(mapping); setForm({ identityKey: mapping.identityKey, profile: mapping.profile, prefix: mapping.sessionPrefix ?? "" }); setDialog("edit"); };
|
|
24
|
-
const
|
|
25
|
-
const save = async () => { if (!valid || saving) return; if (!editing && wildcard && wildcardExists) return toast("A wildcard mapping already exists.", "error"); try { setSaving(true); await api(`applications/${slug}/identity-mappings/${encodeURIComponent(editing?.identityKey ?? form.identityKey.trim())}`, { method: "PUT", body: JSON.stringify({ identityKey: form.identityKey.trim(), previousIdentityKey: editing?.identityKey, profile: form.profile, sessionMode: wildcard ? "automatic" : "fixed", sessionPrefix: wildcard ? undefined : form.prefix.trim() }) }); await load(); toast(editing ? "Identity mapping updated." : "Identity mapping created."); close(); } catch (error) { toast((error as Error).message, "error"); } finally { setSaving(false); } };
|
|
22
|
+
const openEdit = (mapping: Mapping) => { setEditing(mapping); setForm({ identityKey: mapping.identityKey, profile: mapping.sessionMode === "ignore" ? ignore : mapping.profile ?? "", prefix: mapping.sessionPrefix ?? "" }); setDialog("edit"); };
|
|
23
|
+
const save = async () => { if (!valid || saving) return; if (!editing && wildcard && wildcardExists) return toast("A wildcard mapping already exists.", "error"); try { setSaving(true); await api(`applications/${slug}/identity-mappings/${encodeURIComponent(editing?.identityKey ?? form.identityKey.trim())}`, { method: "PUT", body: JSON.stringify({ identityKey: form.identityKey.trim(), previousIdentityKey: editing?.identityKey, profile: ignoring ? null : form.profile, sessionMode: ignoring ? "ignore" : wildcard ? "automatic" : "fixed", sessionPrefix: ignoring || wildcard ? undefined : form.prefix.trim() }) }); await load(); toast(editing ? "Identity mapping updated." : "Identity mapping created."); close(); } catch (error) { toast((error as Error).message, "error"); } finally { setSaving(false); } };
|
|
26
24
|
const remove = async (mapping: Mapping) => { if (!confirm(`Delete identity mapping '${mapping.identityKey}'?`)) return; try { await api(`applications/${slug}/identity-mappings/${encodeURIComponent(mapping.identityKey)}`, { method: "DELETE" }); await load(); toast("Identity mapping deleted."); } catch (error) { toast((error as Error).message, "error"); } };
|
|
27
|
-
return <><Card><div className="flex flex-wrap items-start justify-between gap-3"><div><CardTitle>Identity key mappings</CardTitle><p className="mt-1 text-sm text-zinc-500">Exact identity keys win. Use <code>*</code> as the fallback for every unknown identity
|
|
25
|
+
return <><Card><div className="flex flex-wrap items-start justify-between gap-3"><div><CardTitle>Identity key mappings</CardTitle><p className="mt-1 text-sm text-zinc-500">Exact identity keys win. Use <code>*</code> as the fallback for every unknown identity.</p></div><Button className="px-2.5" title="Add identity mapping" aria-label="Add identity mapping" onClick={openCreate}><Plus size={17}/></Button></div><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-zinc-500"><tr><th className="p-3">Identity key</th><th className="p-3">Profile</th><th className="p-3">Session prefix</th><th className="p-3"/></tr></thead><tbody>{items.map((item) => <tr className="border-b border-zinc-100" key={item.identityKey}><td className="p-3 font-mono">{item.identityKey}</td><td className="p-3">{item.sessionMode === "ignore" ? <span className="rounded-full border border-amber-300 bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-800">None (Ignore)</span> : item.profile}</td><td className="p-3 font-mono">{item.sessionMode === "ignore" ? <span className="font-sans text-amber-700">Ignored</span> : item.sessionMode === "automatic" ? <span className="inline-flex items-center gap-1.5 font-sans">Automatic <span title="The system derives a stable, isolated session prefix from the provided identity key."><Info className="text-sky-600" size={15}/></span></span> : item.sessionPrefix}</td><td className="whitespace-nowrap p-3 text-right"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${item.identityKey}`} onClick={() => openEdit(item)}><Pencil size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-red-50 hover:text-red-600" title={`Delete ${item.identityKey}`} onClick={() => void remove(item)}><Trash2 size={17}/></button></td></tr>)}{items.length === 0 && <tr><td colSpan={4} className="p-5 text-zinc-500">No identity mappings configured.</td></tr>}</tbody></table></div></Card>{dialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="identity-mapping-dialog-title"><form className="w-full max-w-xl rounded-xl bg-white p-6 shadow-2xl" onSubmit={(event) => { event.preventDefault(); void save(); }}><header className="flex items-start justify-between gap-4"><div><h2 id="identity-mapping-dialog-title" className="text-xl font-semibold">{dialog === "create" ? "Add identity mapping" : "Edit identity mapping"}</h2><p className="mt-1 text-sm text-zinc-500">Route an identity key to a profile, or intentionally ignore it after inbound handling.</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" onClick={close}><X size={18}/></button></header><div className="mt-6 grid gap-5"><label className="grid gap-1.5 text-sm font-semibold text-zinc-800"><span>Identity key</span><Input value={form.identityKey} placeholder="e.g. 5511999999999@s.whatsapp.net or *" onChange={(event) => setForm({ ...form, identityKey: event.target.value })}/></label><label className="grid gap-1.5 text-sm font-semibold text-zinc-800"><span>Profile</span><select className="rounded-lg border border-zinc-200 px-3 py-2 font-normal" value={form.profile} onChange={(event) => setForm({ ...form, profile: event.target.value })}><option value={ignore}>None (Ignore)</option>{profiles.map((profile) => <option key={profile}>{profile}</option>)}</select><span className="text-xs font-normal text-zinc-500">{ignoring ? "The inbound handler will run, then this request will be ignored without calling Pi." : "Select the profile that receives this identity."}</span></label>{!ignoring && <label className="grid gap-1.5 text-sm font-semibold text-zinc-800"><span>Session prefix</span><Input value={wildcard ? "Automatic" : form.prefix} readOnly={wildcard} disabled={wildcard} placeholder="e.g. customer-a" onChange={(event) => setForm({ ...form, prefix: event.target.value })}/></label>}</div><footer className="mt-7 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" onClick={close}>Cancel</Button><Button type="submit" className="!bg-[#2bbb77] hover:!bg-[#249b63]" disabled={!valid || saving}>{saving && <Loader2 className="animate-spin" size={16}/>}{dialog === "create" ? "Add mapping" : "Save changes"}</Button></footer></form></div>}</>;
|
|
28
26
|
}
|
|
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
|
|
|
5
5
|
import { Card, CardTitle } from "@/components/ui/card";
|
|
6
6
|
import { Input } from "@/components/ui/input";
|
|
7
7
|
import { useToast } from "@/components/toast";
|
|
8
|
-
type Session = { application: string; profile: string; sessionPrefix: string; sessionId: string; messageCount: number; updatedAt: string; lastIdentityKey: string | null };
|
|
8
|
+
type Session = { application: string; profile: string; sessionPrefix: string; sessionId: string; sessionType: "stateful" | "stateless"; messageCount: number; updatedAt: string; lastIdentityKey: string | null };
|
|
9
9
|
type Filters = { profile: string; application: string; identityKey: string; prefix: string; session: string };
|
|
10
10
|
const api = async (path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error?.message ?? "Request failed"); return response.status === 204 ? undefined : response.json(); };
|
|
11
|
-
export function ApplicationSessions({ profile }: { profile: string }) { const { toast } = useToast(); const [sessions, setSessions] = useState<Session[]>([]), [busy, setBusy] = useState<string>(), [filters, setFilters] = useState<Filters>({ profile, application: "", identityKey: "", prefix: "", session: "" }); const load = async (current = filters) => { const query = new URLSearchParams(Object.entries(current).filter(([, value]) => value.trim())); setSessions((await api(`application-sessions?${query}`)).sessions); }; useEffect(() => { setFilters((current) => ({ ...current, profile })); }, [profile]); useEffect(() => { void load().catch((cause) => toast(cause.message, "error")); }, [filters]); const update = (key: keyof Filters, value: string) => setFilters((current) => ({ ...current, [key]: value })); const rollover = async (session: Session) => { try { setBusy(session.sessionId); await api(`applications/${session.application}/session-rollover`, { method: "POST", body: JSON.stringify({ profile: session.profile, sessionPrefix: session.sessionPrefix }) }); await load(); toast("Session rolled over.", "success"); } catch (cause) { toast((cause as Error).message, "error"); } finally { setBusy(undefined); } }; const remove = async (session: Session) => { if (!confirm(`Remove active session '${session.sessionId}'? Its Pi session history will be kept.`)) return; try { setBusy(session.sessionId); await api(`application-sessions/${encodeURIComponent(session.sessionId)}?${new URLSearchParams({ application: session.application, profile: session.profile })}`, { method: "DELETE" }); await load(); toast("Active session removed.", "success"); } catch (cause) { toast((cause as Error).message, "error"); } finally { setBusy(undefined); } }; return <Card className="md:col-span-3"><div className="flex items-center justify-between gap-3"><CardTitle>Active Application Sessions</CardTitle><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Refresh sessions" onClick={() => void load().catch((cause) => toast(cause.message, "error"))}><RotateCw size={16}/></Button></div><div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{([['profile','Profile'],['application','Application'],['identityKey','Identity key'],['prefix','Prefix'],['session','Session']] as Array<[keyof Filters,string]>).map(([key,label]) => <label key={key} className="text-xs font-medium text-zinc-600">{label}<Input className="mt-1 h-9" value={filters[key]} onChange={(event) => update(key, event.target.value)}/></label>)}</div><div className="mt-4 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-zinc-500"><tr><th className="p-3">Application</th><th className="p-3">Identity key</th><th className="p-3">Profile</th><th className="p-3">Prefix</th><th className="p-3">Session</th><th className="p-3">
|
|
11
|
+
export function ApplicationSessions({ profile }: { profile: string }) { const { toast } = useToast(); const [sessions, setSessions] = useState<Session[]>([]), [busy, setBusy] = useState<string>(), [filters, setFilters] = useState<Filters>({ profile, application: "", identityKey: "", prefix: "", session: "" }); const load = async (current = filters) => { const query = new URLSearchParams(Object.entries(current).filter(([, value]) => value.trim())); setSessions((await api(`application-sessions?${query}`)).sessions); }; useEffect(() => { setFilters((current) => ({ ...current, profile })); }, [profile]); useEffect(() => { void load().catch((cause) => toast(cause.message, "error")); }, [filters]); const update = (key: keyof Filters, value: string) => setFilters((current) => ({ ...current, [key]: value })); const rollover = async (session: Session) => { try { setBusy(session.sessionId); await api(`applications/${session.application}/session-rollover`, { method: "POST", body: JSON.stringify({ profile: session.profile, sessionPrefix: session.sessionPrefix }) }); await load(); toast("Session rolled over.", "success"); } catch (cause) { toast((cause as Error).message, "error"); } finally { setBusy(undefined); } }; const remove = async (session: Session) => { if (!confirm(`Remove active session '${session.sessionId}'? Its Pi session history will be kept.`)) return; try { setBusy(session.sessionId); await api(`application-sessions/${encodeURIComponent(session.sessionId)}?${new URLSearchParams({ application: session.application, profile: session.profile })}`, { method: "DELETE" }); await load(); toast("Active session removed.", "success"); } catch (cause) { toast((cause as Error).message, "error"); } finally { setBusy(undefined); } }; return <Card className="md:col-span-3"><div className="flex items-center justify-between gap-3"><CardTitle>Active Application Sessions</CardTitle><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Refresh sessions" onClick={() => void load().catch((cause) => toast(cause.message, "error"))}><RotateCw size={16}/></Button></div><div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{([['profile','Profile'],['application','Application'],['identityKey','Identity key'],['prefix','Prefix'],['session','Session']] as Array<[keyof Filters,string]>).map(([key,label]) => <label key={key} className="text-xs font-medium text-zinc-600">{label}<Input className="mt-1 h-9" value={filters[key]} onChange={(event) => update(key, event.target.value)}/></label>)}</div><div className="mt-4 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-zinc-500"><tr><th className="p-3">Application</th><th className="p-3">Identity key</th><th className="p-3">Profile</th><th className="p-3">Prefix</th><th className="p-3">Session</th><th className="p-3">Type</th><th className="p-3">Count</th><th className="p-3"><span className="sr-only">Actions</span></th></tr></thead><tbody>{sessions.map((session) => <tr className="border-b border-zinc-100" key={session.sessionId}><td className="p-3">{session.application}</td><td className="p-3">{session.lastIdentityKey ?? "—"}</td><td className="p-3">{session.profile}</td><td className="p-3 font-mono text-xs">{session.sessionPrefix}</td><td className="p-3 font-mono text-xs">{session.sessionId}</td><td className="p-3"><span className="rounded-full px-2 py-1 text-xs font-medium" style={session.sessionType === "stateless" ? { backgroundColor: "#ebfcff", color: "#0a6b51" } : { backgroundColor: "#eff6ff", color: "#1e40af" }}>{session.sessionType}</span></td><td className="p-3">{session.messageCount} {session.sessionType === "stateless" ? "requests" : "messages"}</td><td className="p-3"><div className="flex gap-2">{session.sessionType === "stateful" && <Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Rollover session" disabled={busy === session.sessionId} onClick={() => rollover(session)}>{busy === session.sessionId ? <Loader2 className="animate-spin" size={16}/> : <RotateCw size={16}/>}</Button>}<Button className="bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Remove active session" disabled={busy === session.sessionId} onClick={() => void remove(session)}><Trash2 size={16}/></Button></div></td></tr>)}{!sessions.length && <tr><td className="p-5 text-zinc-500" colSpan={8}>No active Application sessions match these filters.</td></tr>}</tbody></table></div></Card>; }
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { useRouter } from "next/navigation";
|
|
4
|
-
import { ArrowDown, ArrowUp, Check, Copy, Loader2, Plus, Trash2 } from "lucide-react";
|
|
4
|
+
import { ArrowDown, ArrowUp, Check, Copy, Loader2, Plus, Trash2, X } from "lucide-react";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
6
|
import { Card, CardTitle } from "@/components/ui/card";
|
|
7
7
|
import { Input } from "@/components/ui/input";
|
|
8
8
|
import { Switch } from "@/components/ui/switch";
|
|
9
9
|
import { useToast } from "@/components/toast";
|
|
10
10
|
|
|
11
|
-
type Settings = { inboundHandler: string; outboundHandler?: string; transformHandlers?: string[]; messageCoalescing?: { enabled?: boolean; silenceDebounceSeconds?: number } };
|
|
11
|
+
type Settings = { inboundHandler: string; outboundHandler?: string; transformHandlers?: string[]; directMode?: { enabled?: boolean }; messageCoalescing?: { enabled?: boolean; silenceDebounceSeconds?: number } };
|
|
12
12
|
type App = { name: string; slug: string; enabled: boolean; responseMode: "ack" | "result"; defaultProfile: string | null; routingPolicy: "default_as_fallback" | "drop"; settings: Settings };
|
|
13
13
|
type HandlerOptions = { inbound: string[]; outbound: string[]; transform: string[] };
|
|
14
14
|
const api = async (path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error?.message ?? "Request failed"); return response.json(); };
|
|
@@ -19,9 +19,9 @@ export function ApplicationSettings({ slug }: { slug: string }) {
|
|
|
19
19
|
const [app, setApp] = useState<App>();
|
|
20
20
|
const [profiles, setProfiles] = useState<string[]>([]);
|
|
21
21
|
const [handlers, setHandlers] = useState<HandlerOptions>({ inbound: [], outbound: [], transform: [] });
|
|
22
|
-
const [saving, setSaving] = useState(false), [error, setError] = useState(""), [copyMenu, setCopyMenu] = useState(false);
|
|
22
|
+
const [saving, setSaving] = useState(false), [error, setError] = useState(""), [copyMenu, setCopyMenu] = useState(false), [examplesOpen, setExamplesOpen] = useState(false);
|
|
23
23
|
useEffect(() => {
|
|
24
|
-
if (isNew) { api("profiles").then((profileData) => { setProfiles(profileData.profiles.map((item: { name: string }) => item.name)); setApp({ name: "", slug: "", enabled: true, responseMode: "ack", defaultProfile: null, routingPolicy: "default_as_fallback", settings: { inboundHandler: "inbound", transformHandlers: [] } }); setHandlers({ inbound: ["inbound"], outbound: [], transform: [] }); }).catch((cause) => setError(cause.message)); return; }
|
|
24
|
+
if (isNew) { api("profiles").then((profileData) => { setProfiles(profileData.profiles.map((item: { name: string }) => item.name)); setApp({ name: "", slug: "", enabled: true, responseMode: "ack", defaultProfile: null, routingPolicy: "default_as_fallback", settings: { inboundHandler: "inbound", transformHandlers: [], directMode: { enabled: true } } }); setHandlers({ inbound: ["inbound"], outbound: [], transform: [] }); }).catch((cause) => setError(cause.message)); return; }
|
|
25
25
|
Promise.all([api(`applications/${slug}`), api("profiles"), api(`applications/${slug}/handlers`)]).then(([application, profileData, handlerData]) => { setApp(application.application); setProfiles(profileData.profiles.map((item: { name: string }) => item.name)); const files = handlerData.files as string[]; const regular = files.filter((file) => !file.startsWith("transforms/")).map(handlerName); setHandlers({ inbound: regular, outbound: regular, transform: files.filter((file) => file.startsWith("transforms/")).map(handlerName) }); }).catch((cause) => setError(cause.message));
|
|
26
26
|
}, [slug, isNew]);
|
|
27
27
|
if (!app) return <Card>{error || "Loading Application settings…"}</Card>;
|
|
@@ -33,13 +33,17 @@ export function ApplicationSettings({ slug }: { slug: string }) {
|
|
|
33
33
|
const moveTransform = (from: number, to: number) => { if (to < 0 || to >= transformHandlers.length) return; const next = [...transformHandlers]; [next[from], next[to]] = [next[to], next[from]]; updateSettings({ transformHandlers: next }); };
|
|
34
34
|
const setTransform = (index: number, value: string) => { const next = [...transformHandlers]; next[index] = value; updateSettings({ transformHandlers: next }); };
|
|
35
35
|
const save = async () => { try { setSaving(true); setError(""); if (isNew) { const result = await api("applications", { method: "POST", body: JSON.stringify(app) }); toast("Application created."); router.replace(`/applications/${encodeURIComponent(result.application.slug)}?context=admin&tab=settings`); return; } const result = await api(`applications/${slug}`, { method: "PUT", body: JSON.stringify(app) }); setApp(result.application); toast("Application saved."); } catch (cause) { const message = (cause as Error).message; setError(message); toast(message, "error"); } finally { setSaving(false); } };
|
|
36
|
-
const
|
|
36
|
+
const endpoint = typeof window === "undefined" ? `/api/message/app/${app.slug}` : `${window.location.origin}/api/message/app/${app.slug}`;
|
|
37
|
+
const copy = async (text: string, message = "Example cURL copied.") => { try { await navigator.clipboard.writeText(text); toast(message); } catch { toast("Unable to copy to the clipboard.", "error"); } };
|
|
38
|
+
const examples = [{ title: "Conventional Application request", description: "Runs the configured transform, inbound, and outbound handlers.", command: `curl --request POST '${endpoint}' \\\n --header 'Content-Type: application/json' \\\n --data '{"message":"Hello"}'` }, { title: "Direct Mode — stateful", description: "Identity routing selects the profile and Pi keeps the conversation in its Application session. sessionType defaults to stateful.", command: `curl --request POST '${endpoint}' \\\n --header 'Content-Type: application/json' \\\n --data '{"identityKey":"customer@example.com","message":"Where is my order?"}'` }, { title: "Direct Mode — stateful, explicit session", description: "Provide sessionId when the integration needs to create or adopt a specific persistent Application session.", command: `curl --request POST '${endpoint}' \\
|
|
39
|
+
--header 'Content-Type: application/json' \\
|
|
40
|
+
--data '{"identityKey":"customer@example.com","sessionId":"support_customer_2026-03-25-01","message":"Where is my order?"}'` }, { title: "Direct Mode — stateless, empty context", description: "Uses the Application route but does not reuse a Pi transcript. No history means an empty conversation context.", command: `curl --request POST '${endpoint}' \\\n --header 'Content-Type: application/json' \\\n --data '{"identityKey":"customer@example.com","sessionType":"stateless","message":"Hello"}'` }, { title: "Direct Mode — stateless, client history", description: "The caller owns history and must send every turn that Pi should consider.", command: `curl --request POST '${endpoint}' \\\n --header 'Content-Type: application/json' \\\n --data '{"identityKey":"customer@example.com","sessionType":"stateless","history":[{"role":"user","content":"Hello"},{"role":"assistant","content":"How can I help?"}],"message":"Continue."}'` }];
|
|
37
41
|
const renderHandlerOptions = (available: string[], current?: string, blank = "Select a handler") => <>{!current && <option value="">{blank}</option>}{current && !available.includes(current) && <option value={current}>{current} (missing)</option>}{available.map((name) => <option key={name} value={name}>{name}</option>)}</>;
|
|
38
42
|
|
|
39
43
|
return <div className="space-y-5">
|
|
40
44
|
{error && <p className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
|
|
41
45
|
<Card className="grid gap-4 md:grid-cols-2">
|
|
42
|
-
<div className="flex items-center justify-between gap-3 md:col-span-2"><CardTitle>{isNew ? "Create Application" : "General"}</CardTitle><div className="flex gap-2"><div className="relative"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Copy public endpoint" aria-expanded={copyMenu} onClick={() => setCopyMenu((open) => !open)}><Copy size={17}/></Button>{copyMenu && <div className="absolute right-0 z-20 mt-1 w-48 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 text-zinc-700 hover:bg-zinc-100" onClick={() => { setCopyMenu(false); void copy("
|
|
46
|
+
<div className="flex items-center justify-between gap-3 md:col-span-2"><CardTitle>{isNew ? "Create Application" : "General"}</CardTitle><div className="flex gap-2"><div className="relative"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Copy public endpoint" aria-expanded={copyMenu} disabled={!app.slug} onClick={() => setCopyMenu((open) => !open)}><Copy size={17}/></Button>{copyMenu && <div className="absolute right-0 z-20 mt-1 w-48 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 text-zinc-700 hover:bg-zinc-100" onClick={() => { setCopyMenu(false); void copy(endpoint, "Application URL copied."); }}>Simple Application URL</button><button className="w-full rounded px-3 py-2 text-left text-sm text-zinc-700 hover:bg-zinc-100" onClick={() => { setCopyMenu(false); setExamplesOpen(true); }}>Example cURLs</button></div>}</div><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save Application" disabled={saving} onClick={save}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/>}</Button></div></div>
|
|
43
47
|
<label className="text-sm font-medium">Name<Input className="mt-1" value={app.name} onChange={(event) => update({ name: event.target.value })}/></label>
|
|
44
48
|
<label className="text-sm font-medium">Slug{isNew ? <Input className="mt-1 font-mono" value={app.slug} placeholder="my-application" onChange={(event) => update({ slug: event.target.value.toLowerCase() })}/> : <div className="mt-1 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 font-mono text-sm">{app.slug}</div>}</label>
|
|
45
49
|
<label className="text-sm font-medium">Public endpoint<div className="mt-1 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 font-mono text-sm">/api/message/app/{app.slug || "…"}</div></label>
|
|
@@ -54,11 +58,13 @@ export function ApplicationSettings({ slug }: { slug: string }) {
|
|
|
54
58
|
</Card>
|
|
55
59
|
<Card className="space-y-4">
|
|
56
60
|
<div><CardTitle>Pipeline</CardTitle><p className="mt-1 text-sm text-zinc-500">{isNew ? "Save the Application before creating and selecting custom handlers." : "Handlers are managed in the Handlers tab. Transform order is execution order."}</p></div>
|
|
61
|
+
<div className="flex items-start justify-between gap-4 rounded-lg border border-zinc-200 p-3"><div><p className="text-sm font-medium">Direct Mode</p><p className="mt-1 text-xs text-zinc-500">A root-level <code>identityKey</code> and <code>message</code> bypass Transform, Inbound, and Outbound handlers while Identity Mappings still select the profile. Optional <code>sessionId</code> and transient <code>contextMemory</code> are supported. Ack mode requires <code>responseWebhookUrl</code>. Existing handler-based requests continue normally.</p></div><Switch className="mt-0.5 shrink-0" checked={settings.directMode?.enabled !== false} onCheckedChange={(enabled) => updateSettings({ directMode: { enabled } })}/></div>
|
|
57
62
|
<label className="block text-sm font-medium">Inbound handler<select className="mt-1 w-full rounded-lg border border-zinc-200 px-3 py-2" value={settings.inboundHandler} onChange={(event) => updateSettings({ inboundHandler: event.target.value })}>{renderHandlerOptions(handlers.inbound, settings.inboundHandler)}</select></label>
|
|
58
63
|
<label className="block text-sm font-medium">Outbound handler<select className="mt-1 w-full rounded-lg border border-zinc-200 px-3 py-2" value={settings.outboundHandler ?? ""} onChange={(event) => updateSettings({ outboundHandler: event.target.value || undefined })}>{renderHandlerOptions(handlers.outbound, settings.outboundHandler, "None")}</select></label>
|
|
59
64
|
<div><div className="flex items-center justify-between gap-3"><div><p className="text-sm font-medium">Transform handlers</p><p className="text-xs text-zinc-500">They run from top to bottom before the inbound handler.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Add transform handler" disabled={handlers.transform.length === 0 || transformHandlers.length >= handlers.transform.length} onClick={() => { const next = handlers.transform.find((name) => !transformHandlers.includes(name)); if (next) updateSettings({ transformHandlers: [...transformHandlers, next] }); }}><Plus size={16}/></Button></div>
|
|
60
65
|
<div className="mt-3 space-y-2">{transformHandlers.map((handler, index) => <div className="flex gap-2" key={`${handler}-${index}`}><select className="min-w-0 flex-1 rounded-lg border border-zinc-200 px-3 py-2" value={handler} onChange={(event) => setTransform(index, event.target.value)}>{renderHandlerOptions(handlers.transform.filter((name) => name === handler || !transformHandlers.includes(name)), handler)}</select><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Move transform up" disabled={index === 0} onClick={() => moveTransform(index, index - 1)}><ArrowUp size={16}/></Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Move transform down" disabled={index === transformHandlers.length - 1} onClick={() => moveTransform(index, index + 1)}><ArrowDown size={16}/></Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Remove transform handler" onClick={() => updateSettings({ transformHandlers: transformHandlers.filter((_, item) => item !== index) })}><Trash2 size={16}/></Button></div>)}{handlers.transform.length === 0 && <p className="rounded-lg border border-dashed border-zinc-200 p-3 text-sm text-zinc-500">No transform handlers are registered. Create one in the Handlers tab.</p>}{handlers.transform.length > 0 && transformHandlers.length === 0 && <p className="text-sm text-zinc-500">No transforms configured.</p>}</div>
|
|
61
66
|
</div>
|
|
62
67
|
</Card>
|
|
68
|
+
{examplesOpen && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="curl-examples-title"><div className="max-h-[85vh] w-full max-w-3xl overflow-auto rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="curl-examples-title" className="text-lg font-semibold">Example cURLs</h2><p className="mt-1 text-sm text-zinc-500">Choose the request style that matches your integration.</p></div><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" onClick={() => setExamplesOpen(false)}><X size={18}/></button></div><div className="mt-5 space-y-5">{examples.map((example) => <section key={example.title} className="rounded-lg border border-zinc-200 p-4"><div className="flex items-start justify-between gap-3"><div><h3 className="font-medium">{example.title}</h3><p className="mt-1 text-sm text-zinc-500">{example.description}</p></div><Button className="shrink-0 bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title={`Copy ${example.title}`} onClick={() => void copy(example.command)}><Copy size={16}/></Button></div><pre className="mt-3 overflow-x-auto rounded-lg p-3 text-xs" style={{ backgroundColor: "#f4f4f5", color: "#18181b" }}><code style={{ color: "inherit" }}>{example.command}</code></pre></section>)}</div><div className="mt-6 flex justify-end"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setExamplesOpen(false)}>Close</Button></div></div></div>}
|
|
63
69
|
</div>;
|
|
64
70
|
}
|
|
@@ -9,24 +9,30 @@ import { Card } from "@/components/ui/card";
|
|
|
9
9
|
import { Input } from "@/components/ui/input";
|
|
10
10
|
import { useToast } from "@/components/toast";
|
|
11
11
|
|
|
12
|
-
type Session = { id: string; name?: string; preview: string; updatedAt: string; messageCount: number };
|
|
12
|
+
type Session = { id: string; name?: string; preview: string; updatedAt: string; messageCount: number; kind?: "conversation" | "pulse-execution" | "pulse-only"; pulse?: { name?: string; type?: "cron" | "heartbeat"; status?: "running" | "success" | "error"; source?: "execution" | "injected-result" } };
|
|
13
13
|
type Block = { type: "text" | "thinking"; text: string };
|
|
14
14
|
type Entry = { id: string; type: string; timestamp: string; role?: string; content?: Block[]; tools?: Array<{ name: string; arguments: unknown }>; toolName?: string; isError?: boolean; summary?: string };
|
|
15
|
+
type Activity = { id: string; kind: "tool" | "thinking" | "agent"; status: "started" | "running" | "completed" | "failed" | "interrupted"; name: string; summary: string; parentToolCallId?: string };
|
|
15
16
|
const call = async (path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message ?? body.error ?? "Request failed"); } return response; };
|
|
16
17
|
const ago = (date: string) => { const minutes = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 60000)); return minutes < 1 ? "now" : minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.round(minutes / 60)}h` : `${Math.round(minutes / 1440)}d`; };
|
|
17
18
|
const text = (entry: Entry) => entry.content?.filter((block) => block.type === "text").map((block) => block.text).join("\n") ?? "";
|
|
19
|
+
const mergeConversationEntries = (current: Entry[], persisted: Entry[]) => {
|
|
20
|
+
const pending = current.filter((entry) => entry.id.startsWith("local-") && !persisted.some((saved) => saved.role === entry.role && text(saved) === text(entry)));
|
|
21
|
+
return [...persisted, ...pending];
|
|
22
|
+
};
|
|
18
23
|
|
|
19
24
|
export function ChatWorkspace({ profile }: { profile: string }) {
|
|
20
25
|
const searchParams = useSearchParams(); const router = useRouter(); const { toast } = useToast();
|
|
21
26
|
// Profile selection updates the route asynchronously. Never resolve a
|
|
22
27
|
// session from the previous URL against the newly selected profile.
|
|
23
28
|
const requestedSession = (searchParams.get("profile") ?? "default") === profile ? searchParams.get("session") : null;
|
|
24
|
-
const [sessions, setSessions] = useState<Session[]>([]), [newMenu, setNewMenu] = useState(false), [applicationDialog, setApplicationDialog] = useState(false), [creatingApplicationSession, setCreatingApplicationSession] = useState(false), [applications, setApplications] = useState<Array<{ slug: string; name: string }>>([]), [application, setApplication] = useState(""), [identityKey, setIdentityKey] = useState(""), [selected, setSelected] = useState<string>(), [entries, setEntries] = useState<Entry[]>([]), [hasMore, setHasMore] = useState(false), [nextBefore, setNextBefore] = useState<string | null>(null), [loadingOlder, setLoadingOlder] = useState(false), [copiedSessionId, setCopiedSessionId] = useState(false), [editingName, setEditingName] = useState(false), [sessionName, setSessionName] = useState(""), [query, setQuery] = useState(""), [debouncedQuery, setDebouncedQuery] = useState(""), [message, setMessage] = useState(""), [streaming, setStreaming] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState(""), [sidebar, setSidebar] = useState(true);
|
|
29
|
+
const [sessions, setSessions] = useState<Session[]>([]), [showPulseExecutions, setShowPulseExecutions] = useState(false), [newMenu, setNewMenu] = useState(false), [applicationDialog, setApplicationDialog] = useState(false), [creatingApplicationSession, setCreatingApplicationSession] = useState(false), [applications, setApplications] = useState<Array<{ slug: string; name: string }>>([]), [application, setApplication] = useState(""), [identityKey, setIdentityKey] = useState(""), [selected, setSelected] = useState<string>(), [entries, setEntries] = useState<Entry[]>([]), [hasMore, setHasMore] = useState(false), [nextBefore, setNextBefore] = useState<string | null>(null), [loadingOlder, setLoadingOlder] = useState(false), [copiedSessionId, setCopiedSessionId] = useState(false), [editingName, setEditingName] = useState(false), [sessionName, setSessionName] = useState(""), [query, setQuery] = useState(""), [debouncedQuery, setDebouncedQuery] = useState(""), [message, setMessage] = useState(""), [streaming, setStreaming] = useState(""), [activities, setActivities] = useState<Activity[]>([]), [busy, setBusy] = useState(false), [error, setError] = useState(""), [sidebar, setSidebar] = useState(true);
|
|
25
30
|
const abortRef = useRef<AbortController | undefined>(undefined); const bottom = useRef<HTMLDivElement>(null); const conversation = useRef<HTMLDivElement>(null); const loadingSession = useRef<string | undefined>(undefined);
|
|
26
|
-
const loadSessions = async () => { const data = await (await call(`profiles/${profile}/sessions`)).json() as { sessions: Session[] }; setSessions(data.sessions); return data.sessions; };
|
|
27
|
-
const loadConversation = async (id: string) => { const key = `${profile}\u0000${id}`; loadingSession.current = key; setSelected(id); setEditingName(false); setEntries([]); setHasMore(false); setNextBefore(null); setStreaming(""); const data = await (await call(`profiles/${profile}/sessions/${id}?limit=16`)).json() as { entries: Entry[]; hasMore: boolean; nextBefore: string | null }; if (loadingSession.current !== key) return; setEntries(data.entries); setHasMore(data.hasMore); setNextBefore(data.nextBefore); requestAnimationFrame(() => bottom.current?.scrollIntoView({ behavior: "auto" })); };
|
|
31
|
+
const loadSessions = async () => { const data = await (await call(`profiles/${profile}/sessions${showPulseExecutions ? "?includePulseSessions=true" : ""}`)).json() as { sessions: Session[] }; setSessions(data.sessions); return data.sessions; };
|
|
32
|
+
const loadConversation = async (id: string, replace = true) => { const key = `${profile}\u0000${id}`; loadingSession.current = key; setSelected(id); setEditingName(false); if (replace) { setEntries([]); setHasMore(false); setNextBefore(null); } setStreaming(""); const data = await (await call(`profiles/${profile}/sessions/${id}?limit=16`)).json() as { entries: Entry[]; hasMore: boolean; nextBefore: string | null }; if (loadingSession.current !== key) return; setEntries((current) => replace ? data.entries : mergeConversationEntries(current, data.entries)); setHasMore(data.hasMore); setNextBefore(data.nextBefore); requestAnimationFrame(() => bottom.current?.scrollIntoView({ behavior: "auto" })); };
|
|
28
33
|
const loadOlder = async () => { if (!selected || !hasMore || !nextBefore || loadingOlder) return; const id = selected, key = `${profile}\u0000${id}`, element = conversation.current, height = element?.scrollHeight ?? 0, top = element?.scrollTop ?? 0; setLoadingOlder(true); try { const data = await (await call(`profiles/${profile}/sessions/${id}?limit=16&before=${encodeURIComponent(nextBefore)}`)).json() as { entries: Entry[]; hasMore: boolean; nextBefore: string | null }; if (loadingSession.current !== key) return; setEntries((current) => [...data.entries, ...current]); setHasMore(data.hasMore); setNextBefore(data.nextBefore); requestAnimationFrame(() => { if (element) element.scrollTop = top + element.scrollHeight - height; }); } catch (cause) { setError((cause as Error).message); } finally { setLoadingOlder(false); } };
|
|
29
34
|
useEffect(() => { loadingSession.current = undefined; setSelected(undefined); setEntries([]); setHasMore(false); setNextBefore(null); setStreaming(""); setError(""); loadSessions().catch((cause) => setError(cause.message)); }, [profile]);
|
|
35
|
+
useEffect(() => { loadSessions().catch((cause) => setError(cause.message)); }, [showPulseExecutions]);
|
|
30
36
|
useEffect(() => { if (requestedSession) loadConversation(requestedSession).catch((cause) => setError(cause.message)); }, [profile, requestedSession]);
|
|
31
37
|
useEffect(() => { if (busy || streaming) bottom.current?.scrollIntoView({ behavior: "auto" }); }, [streaming, busy]);
|
|
32
38
|
useEffect(() => { const timer = setTimeout(() => setDebouncedQuery(query), 600); return () => clearTimeout(timer); }, [query]);
|
|
@@ -39,7 +45,11 @@ export function ChatWorkspace({ profile }: { profile: string }) {
|
|
|
39
45
|
async function createApplicationSession() { if (!application || !identityKey.trim()) { toast("Select an Application and enter an Identity key.", "error"); return; } try { setCreatingApplicationSession(true); const created = await (await call(`profiles/${profile}/application-sessions`, { method: "POST", body: JSON.stringify({ application, identityKey: identityKey.trim() }) })).json() as { id: string }; setApplicationDialog(false); await loadSessions(); openConversation(created.id); } catch (cause) { toast((cause as Error).message, "error"); } finally { setCreatingApplicationSession(false); } }
|
|
40
46
|
async function remove() { if (!selected || busy || !window.confirm("Delete this conversation permanently?")) return; try { await call(`profiles/${profile}/sessions/${selected}`, { method: "DELETE" }); setSelected(undefined); setEntries([]); setStreaming(""); setError(""); await loadSessions(); } catch (cause) { setError((cause as Error).message); } }
|
|
41
47
|
async function rename() { if (!selected || !sessionName.trim()) return; try { await call(`profiles/${profile}/sessions/${selected}`, { method: "PATCH", body: JSON.stringify({ name: sessionName.trim() }) }); setEditingName(false); await loadSessions(); } catch (cause) { setError((cause as Error).message); } }
|
|
42
|
-
async function send(event: React.FormEvent) { event.preventDefault(); if (!message.trim() || busy) return; let id = selected; try { setError(""); if (!id) { const created = await (await call(`profiles/${profile}/sessions`, { method: "POST", body: JSON.stringify({}) })).json() as { id: string }; id = created.id; setSelected(id); } const
|
|
43
|
-
return <Card className="h-[calc(100vh-215px)] min-h-[520px] p-0"><div className="flex h-full min-h-0 overflow-hidden rounded-xl"><aside className={`${sidebar ? "w-96" : "w-0"} flex shrink-0 flex-col overflow-hidden border-r border-zinc-200 bg-zinc-50 transition-all`}><div className="flex h-full w-96 min-h-0 flex-col p-4"><div className="mb-4 flex items-center justify-between"><h2 className="font-semibold">Conversations</h2><div className="relative"><Button title="New session" onClick={() => setNewMenu((current) => !current)}><Plus size={17}/></Button>{newMenu && <div className="absolute right-0 z-20 mt-1 w-48 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={create}>Regular session</button><button className="w-full rounded px-3 py-2 text-left text-sm hover:bg-zinc-100" onClick={() => void openApplicationDialog()}>Application session</button></div>}</div></div><div className="relative mb-3"><Search className="absolute left-3 top-2.5 text-zinc-400" size={16}/><Input className="pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search sessions"/></div><div className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1">{filtered.map((session) => <button key={session.id} onClick={() => { setCopiedSessionId(false); openConversation(session.id); }} className={`w-full rounded-lg p-3 text-left hover:bg-white ${selected === session.id ? "bg-white shadow-sm ring-1 ring-blue-200" : ""}`}><div className="flex items-center gap-2"><MessageSquare size={15} className="text-blue-600"/><span className="min-w-0 flex-1 truncate text-sm font-medium">{session.name || session.preview || "New conversation"}</span><span className="text-xs text-zinc-400">{ago(session.updatedAt)}</span></div><p className="mt-1 truncate text-xs text-zinc-500">{session.preview || `${session.messageCount} messages`}</p><p className="mt-1 truncate font-mono text-[10px] text-zinc-400">{session.id}</p></button>)}{filtered.length === 0 && <p className="p-4 text-center text-sm text-zinc-500">No sessions found.</p>}</div></div></aside><section className="flex min-h-0 min-w-0 flex-1 flex-col"><header className="flex items-center gap-3 border-b border-zinc-200 px-5 py-4"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" onClick={() => setSidebar((current) => !current)}><PanelLeft size={17}/></Button><div className="min-w-0"><div className="flex items-center gap-1">{!editingName && <h2 className="truncate font-semibold">{selected ? activeSession?.name || activeSession?.preview || "Untitled session" : "New conversation"}</h2>}{selected && !editingName && <button className="shrink-0 rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title="Rename session" onClick={() => { setSessionName(activeSession?.name || activeSession?.preview || ""); setEditingName(true); }}><Pencil size={14}/></button>}</div>{selected && editingName && <div className="mt-2 flex items-center gap-1"><Input autoFocus className="h-8 min-w-64" value={sessionName} onChange={(event) => setSessionName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void rename(); if (event.key === "Escape") setEditingName(false); }}/><button className="rounded p-1.5 text-emerald-700 hover:bg-emerald-50" title="Save session name" onClick={() => void rename()}><Check size={16}/></button><button className="rounded p-1.5 text-zinc-500 hover:bg-zinc-100" title="Cancel" onClick={() => setEditingName(false)}><X size={16}/></button></div>}<p className="text-xs text-zinc-500">Profile: {profile}</p>{selected && <div className="mt-1 flex items-center gap-1 text-xs text-zinc-400"><span className="truncate font-mono">{selected}</span><button className="shrink-0 rounded p-1 hover:bg-zinc-100" title="Copy session ID" onClick={() => void copySessionId()}>{copiedSessionId ? <Check size={13}/> : <Copy size={13}/>}</button></div>}</div>{selected && <Button className="ml-auto bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Delete conversation" onClick={remove} disabled={busy}><Trash2 size={17}/></Button>}</header><div ref={conversation} onScroll={(event) => { if (event.currentTarget.scrollTop < 80) void loadOlder(); }} className="min-h-0 flex-1 space-y-5 overflow-y-auto bg-white px-5 py-6">{loadingOlder && <div className="py-2 text-center text-xs text-zinc-400">Loading older messages…</div>}{hasMore && !loadingOlder && <div className="py-2 text-center text-xs text-zinc-400">Scroll up to load older messages</div>}{!selected && entries.length === 0 && <div className="mx-auto mt-28 max-w-md text-center"><div className="mx-auto mb-4 w-fit rounded-2xl bg-blue-50 p-4 text-blue-600"><Bot size={30}/></div><h3 className="text-xl font-semibold">Start a conversation</h3><p className="mt-2 text-sm text-zinc-500">Write a message below or choose a previous session.</p></div>}{entries.filter((entry) => entry.role !== "toolResult").map((entry) => <Message key={entry.id} entry={entry}/>) }{busy && <Message entry={{ id: "streaming", type: "message", role: "assistant", timestamp: new Date().toISOString(), content: [{ type: "text", text: streaming }] }} streaming/>}<div ref={bottom}/></div>{error && <p className="border-t border-red-200 bg-red-50 px-5 py-2 text-sm text-red-700">{error}</p>}<form onSubmit={send} className="border-t border-zinc-200 bg-zinc-50 p-4"><div className="flex gap-3"><textarea className="min-h-14 flex-1 resize-none rounded-xl border border-zinc-200 bg-white p-3 text-sm outline-none focus:border-blue-500" value={message} onChange={(event) => setMessage(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.altKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } }} placeholder="Message Pi… (Enter to send, Shift+Enter for a new line)"/>{busy ? <Button type="button" className="bg-zinc-700 hover:bg-zinc-600" title="Stop generation" onClick={() => abortRef.current?.abort()}><CircleStop size={17}/></Button> : <Button className="bg-slate-600 hover:bg-slate-500" title="Send message"><SendHorizontal size={17}/></Button>}</div></form></section></div>{applicationDialog && <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"><h3 className="text-lg font-semibold">Application session</h3><p className="mt-1 text-sm text-zinc-500">Creates a manual Console session with Context Memory for the selected Application and identity.</p><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)}><option value="">Select an Application</option>{applications.map((item) => <option key={item.slug} value={item.slug}>{item.name}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium">Identity key<Input value={identityKey} onChange={(event) => setIdentityKey(event.target.value)} placeholder="e.g. person@example.com"/></label><div className="flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setApplicationDialog(false)}>Cancel</Button><Button disabled={creatingApplicationSession} onClick={() => void createApplicationSession()}>{creatingApplicationSession ? <Loader2 className="animate-spin" size={17}/> : "Create session"}</Button></div></div></Card></div>}</Card>;
|
|
48
|
+
async function send(event: React.FormEvent) { event.preventDefault(); if (!message.trim() || busy) return; let id = selected; try { setError(""); if (!id) { const created = await (await call(`profiles/${profile}/sessions`, { method: "POST", body: JSON.stringify({}) })).json() as { id: string }; id = created.id; setSelected(id); } const localEntryId = `local-${Date.now()}`; const userEntry: Entry = { id: localEntryId, type: "message", role: "user", timestamp: new Date().toISOString(), content: [{ type: "text", text: message }] }; setEntries((current) => [...current, userEntry]); const prompt = message; setMessage(""); setBusy(true); setStreaming(""); setActivities([{ id: "agent", kind: "agent", status: "running", name: "Pi", summary: "Pi is working…" }]); const controller = new AbortController(); abortRef.current = controller; const response = await call(`profile/${profile}/api/sessions/${id}/chat/stream`, { method: "POST", body: JSON.stringify({ message: prompt }), signal: controller.signal }); const reader = response.body?.getReader(), decoder = new TextDecoder(); let buffer = "", complete = false; while (reader && !complete) { const part = await reader.read(); if (part.done) break; buffer += decoder.decode(part.value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop() ?? ""; for (const item of events) { const data = item.split("\n").find((line) => line.startsWith("data: "))?.slice(6); if (!data) continue; const payload = JSON.parse(data); if (typeof payload.error === "string") throw new Error(payload.error); const eventName = item.split("\n").find((line) => line.startsWith("event: "))?.slice(7); if (eventName === "activity" && typeof payload.id === "string") setActivities((current) => { const next = payload as Activity, index = current.findIndex((activity) => activity.id === next.id); return index < 0 ? [...current, next] : current.map((activity, itemIndex) => itemIndex === index ? { ...activity, ...next } : activity); }); if (payload.text) setStreaming((current) => current + payload.text); if (typeof payload.response === "string") { setStreaming(payload.response); complete = true; break; } } } await loadConversation(id, false); setEntries((current) => current.filter((entry) => entry.id !== localEntryId)); await loadSessions(); } catch (cause) { if ((cause as Error).name !== "AbortError") setError((cause as Error).message); } finally { abortRef.current = undefined; setBusy(false); setStreaming(""); setActivities([]); } }
|
|
49
|
+
return <Card className="h-[calc(100vh-215px)] min-h-[520px] p-0"><div className="flex h-full min-h-0 overflow-hidden rounded-xl"><aside className={`${sidebar ? "w-96" : "w-0"} flex shrink-0 flex-col overflow-hidden border-r border-zinc-200 bg-zinc-50 transition-all`}><div className="flex h-full w-96 min-h-0 flex-col p-4"><div className="mb-4 flex items-center justify-between"><h2 className="font-semibold">Conversations</h2><div className="relative"><Button title="New session" onClick={() => setNewMenu((current) => !current)}><Plus size={17}/></Button>{newMenu && <div className="absolute right-0 z-20 mt-1 w-48 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={create}>Regular session</button><button className="w-full rounded px-3 py-2 text-left text-sm hover:bg-zinc-100" onClick={() => void openApplicationDialog()}>Application session</button></div>}</div></div><label className="mb-3 flex items-center gap-2 text-xs text-zinc-600"><input type="checkbox" checked={showPulseExecutions} onChange={(event) => setShowPulseExecutions(event.target.checked)}/>Show Pulse sessions</label><div className="relative mb-3"><Search className="absolute left-3 top-2.5 text-zinc-400" size={16}/><Input className="pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search sessions"/></div><div className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1">{filtered.map((session) => <button key={session.id} onClick={() => { setCopiedSessionId(false); openConversation(session.id); }} className={`w-full rounded-lg p-3 text-left hover:bg-white ${selected === session.id ? "bg-white shadow-sm ring-1 ring-blue-200" : ""}`}><div className="flex items-center gap-2"><MessageSquare size={15} className="text-blue-600"/><span className="min-w-0 flex-1 truncate text-sm font-medium">{session.kind !== "conversation" ? session.pulse?.name || (session.kind === "pulse-only" ? "Pulse result" : "Pulse execution") : session.name || session.preview || "New conversation"}</span><span className="text-xs text-zinc-400">{ago(session.updatedAt)}</span></div><p className="mt-1 truncate text-xs text-zinc-500">{session.kind !== "conversation" ? session.kind === "pulse-only" ? "Injected Pulse result" : `${session.pulse?.type} · ${session.pulse?.status}` : session.preview || `${session.messageCount} messages`}</p><p className="mt-1 truncate font-mono text-[10px] text-zinc-400">{session.id}</p></button>)}{filtered.length === 0 && <p className="p-4 text-center text-sm text-zinc-500">No sessions found.</p>}</div></div></aside><section className="flex min-h-0 min-w-0 flex-1 flex-col"><header className="flex items-center gap-3 border-b border-zinc-200 px-5 py-4"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" onClick={() => setSidebar((current) => !current)}><PanelLeft size={17}/></Button><div className="min-w-0"><div className="flex items-center gap-1">{!editingName && <h2 className="truncate font-semibold">{selected ? activeSession?.name || activeSession?.preview || "Untitled session" : "New conversation"}</h2>}{selected && !editingName && <button className="shrink-0 rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title="Rename session" onClick={() => { setSessionName(activeSession?.name || activeSession?.preview || ""); setEditingName(true); }}><Pencil size={14}/></button>}</div>{selected && editingName && <div className="mt-2 flex items-center gap-1"><Input autoFocus className="h-8 min-w-64" value={sessionName} onChange={(event) => setSessionName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void rename(); if (event.key === "Escape") setEditingName(false); }}/><button className="rounded p-1.5 text-emerald-700 hover:bg-emerald-50" title="Save session name" onClick={() => void rename()}><Check size={16}/></button><button className="rounded p-1.5 text-zinc-500 hover:bg-zinc-100" title="Cancel" onClick={() => setEditingName(false)}><X size={16}/></button></div>}<p className="text-xs text-zinc-500">Profile: {profile}</p>{selected && <div className="mt-1 flex items-center gap-1 text-xs text-zinc-400"><span className="truncate font-mono">{selected}</span><button className="shrink-0 rounded p-1 hover:bg-zinc-100" title="Copy session ID" onClick={() => void copySessionId()}>{copiedSessionId ? <Check size={13}/> : <Copy size={13}/>}</button></div>}</div>{selected && <Button className="ml-auto bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Delete conversation" onClick={remove} disabled={busy}><Trash2 size={17}/></Button>}</header><div ref={conversation} onScroll={(event) => { if (event.currentTarget.scrollTop < 80) void loadOlder(); }} className="min-h-0 flex-1 space-y-5 overflow-y-auto bg-white px-5 py-6">{loadingOlder && <div className="py-2 text-center text-xs text-zinc-400">Loading older messages…</div>}{hasMore && !loadingOlder && <div className="py-2 text-center text-xs text-zinc-400">Scroll up to load older messages</div>}{!selected && entries.length === 0 && <div className="mx-auto mt-28 max-w-md text-center"><div className="mx-auto mb-4 w-fit rounded-2xl bg-blue-50 p-4 text-blue-600"><Bot size={30}/></div><h3 className="text-xl font-semibold">Start a conversation</h3><p className="mt-2 text-sm text-zinc-500">Write a message below or choose a previous session.</p></div>}{entries.filter((entry) => entry.role !== "toolResult").map((entry) => <Message key={entry.id} entry={entry}/>) }{busy && <><ActivityFeed activities={activities}/>{streaming && <Message entry={{ id: "streaming", type: "message", role: "assistant", timestamp: new Date().toISOString(), content: [{ type: "text", text: streaming }] }} streaming/>}</>}<div ref={bottom}/></div>{error && <p className="border-t border-red-200 bg-red-50 px-5 py-2 text-sm text-red-700">{error}</p>}<form onSubmit={send} className="border-t border-zinc-200 bg-zinc-50 p-4"><div className="flex gap-3"><textarea className="min-h-14 flex-1 resize-none rounded-xl border border-zinc-200 bg-white p-3 text-sm outline-none focus:border-blue-500" value={message} onChange={(event) => setMessage(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.altKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } }} placeholder="Message Pi… (Enter to send, Shift+Enter for a new line)"/>{busy ? <Button type="button" className="bg-zinc-700 hover:bg-zinc-600" title="Stop generation" onClick={() => abortRef.current?.abort()}><CircleStop size={17}/></Button> : <Button className="bg-slate-600 hover:bg-slate-500" title="Send message"><SendHorizontal size={17}/></Button>}</div></form></section></div>{applicationDialog && <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"><h3 className="text-lg font-semibold">Application session</h3><p className="mt-1 text-sm text-zinc-500">Creates a manual Console session with Context Memory for the selected Application and identity.</p><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)}><option value="">Select an Application</option>{applications.map((item) => <option key={item.slug} value={item.slug}>{item.name}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium">Identity key<Input value={identityKey} onChange={(event) => setIdentityKey(event.target.value)} placeholder="e.g. person@example.com"/></label><div className="flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setApplicationDialog(false)}>Cancel</Button><Button disabled={creatingApplicationSession} onClick={() => void createApplicationSession()}>{creatingApplicationSession ? <Loader2 className="animate-spin" size={17}/> : "Create session"}</Button></div></div></Card></div>}</Card>;
|
|
44
50
|
}
|
|
45
|
-
function
|
|
51
|
+
function ActivityFeed({ activities }: { activities: Activity[] }) {
|
|
52
|
+
return <div className="max-w-[80%] space-y-1 rounded-xl border border-zinc-200 bg-zinc-50 p-3 text-xs text-zinc-600">{activities.map((activity) => <div key={activity.id} className="flex items-center gap-2"><span className={`size-2 rounded-full ${activity.status === "failed" ? "bg-red-500" : activity.status === "completed" ? "bg-emerald-500" : "animate-pulse bg-[#085c8b]"}`}/><span className="font-medium text-zinc-700">{activity.name}</span><span className="truncate">{activity.summary}</span></div>)}</div>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function Message({ entry, streaming = false }: { entry: Entry; streaming?: boolean }) { const [copied, setCopied] = useState(false); const copy = async () => { try { await navigator.clipboard.writeText(text(entry)); setCopied(true); window.setTimeout(() => setCopied(false), 2_000); } catch { /* The copy icon remains available for retry. */ } }; if (entry.type !== "message") return <div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">{entry.type.replace("_", " ")}: {entry.summary || "Conversation context updated."}</div>; const user = entry.role === "user", assistant = entry.role === "assistant"; const thinking = entry.content?.filter((block) => block.type === "thinking") ?? []; return <article className={`flex gap-3 ${user ? "justify-end" : "justify-start"}`}><div className={`mt-1 grid size-8 shrink-0 place-items-center rounded-full ${user ? "order-2 bg-[#085c8b] text-white" : "bg-zinc-100 text-zinc-600"}`}>{user ? <User size={16}/> : <Bot size={16}/>}</div><div className={`min-w-0 max-w-[80%] overflow-hidden break-words [overflow-wrap:anywhere] rounded-2xl px-4 py-3 text-sm ${user ? "bg-[#085c8b] text-white" : entry.isError ? "bg-red-50 text-red-800" : entry.tools?.length ? "bg-[#f7fbfb] text-zinc-800" : "bg-zinc-100 text-zinc-800"}`}><div className={`prose prose-sm max-w-none break-words [&_pre]:whitespace-pre-wrap [&_pre]:break-all ${user ? "prose-invert" : ""}`}><ReactMarkdown remarkPlugins={[remarkGfm]}>{text(entry) || (streaming ? "" : entry.toolName ? `Tool result: ${entry.toolName}` : "")}</ReactMarkdown></div>{streaming && <Loader2 className="mt-2 animate-spin" size={15}/>} {entry.tools?.map((tool, index) => <details open key={`${tool.name}-${index}`} className="mt-3 rounded-lg p-2"><summary className="cursor-pointer font-medium">Tool: {tool.name}</summary><pre className="mt-2 overflow-auto text-xs">{JSON.stringify(tool.arguments, null, 2)}</pre></details>)}{thinking.length > 0 && <details className="mt-3 rounded-lg bg-black/5 p-2"><summary className="flex cursor-pointer items-center gap-1 font-medium">Reasoning <ChevronDown size={14}/></summary>{thinking.map((block, index) => <p key={index} className="mt-2 whitespace-pre-wrap text-xs opacity-80">{block.text}</p>)}</details>}<div className={`mt-2 flex items-center gap-2 text-[11px] ${user ? "text-blue-100" : "text-zinc-400"}`}><span>{entry.role === "toolResult" ? "Tool result" : user ? "You" : "Pi"}</span><span>{new Date(entry.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>{text(entry) && <button className="ml-auto" title="Copy" onClick={() => void copy()}>{copied ? <Check size={13}/> : <Copy size={13}/>}</button>}</div></div></article>; }
|
|
@@ -3,6 +3,7 @@ import { Console, type ConsoleInitialData } from "@/components/console";
|
|
|
3
3
|
import { authenticated } from "@/lib/auth";
|
|
4
4
|
import { piGet } from "@/lib/pi-api";
|
|
5
5
|
import { readAdminConfig } from "@/lib/admin-config";
|
|
6
|
+
import { readModelsConfig } from "@/lib/admin-models";
|
|
6
7
|
|
|
7
8
|
type Props = { section: string; applicationSlug?: string; profile?: string };
|
|
8
9
|
export async function ConsolePage({ section, applicationSlug, profile = "default" }: Props) {
|
|
@@ -11,12 +12,15 @@ export async function ConsolePage({ section, applicationSlug, profile = "default
|
|
|
11
12
|
try {
|
|
12
13
|
if (section === "api-server") initial.adminConfig = await readAdminConfig("api-server");
|
|
13
14
|
else if (section === "pi-console-webui") initial.adminConfig = await readAdminConfig("pi-console-webui");
|
|
15
|
+
else if (section === "models") initial.models = await readModelsConfig();
|
|
14
16
|
else if (section === "settings") initial.settings = await piGet(`profiles/${profile}/settings`);
|
|
17
|
+
else if (section === "model") initial.profileModel = await piGet(`profiles/${profile}/model`);
|
|
15
18
|
else if (section === "env") initial.env = await piGet(`profiles/${profile}/env`);
|
|
16
19
|
else if (section === "soul") initial.document = await piGet(`profiles/${profile}/${section}`);
|
|
17
20
|
else if (section === "guardrails") initial.guardrails = await piGet(`profiles/${profile}/guardrails`);
|
|
18
21
|
else if (section === "packages") initial.packages = await piGet(`profiles/${profile}/packages`);
|
|
19
22
|
else if (section === "pulses") initial.pulses = await piGet(`profiles/${profile}/pulses`);
|
|
23
|
+
else if (section === "sequential-workflows") initial.sequentialWorkflows = await piGet(`profiles/${profile}/sequential-workflows/config`);
|
|
20
24
|
// Tool discovery can require booting a sandboxed Pi runtime. Let the client
|
|
21
25
|
// render the Tools workspace immediately and load its catalog asynchronously.
|
|
22
26
|
else if (section === "skills" || section === "extensions") initial.resources = await piGet(`profiles/${profile}/resources/${section}`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
|
3
3
|
|
|
4
|
-
type Profile = { name: string; path: string };
|
|
4
|
+
type Profile = { name: string; path: string; description: string; tags: string[] };
|
|
5
5
|
type ConsoleState = { profiles: Profile[]; health: string; error: string; initialize: () => Promise<void>; refreshProfiles: () => Promise<Profile[]> };
|
|
6
6
|
const StateContext = createContext<ConsoleState | undefined>(undefined);
|
|
7
7
|
|