pi-feats 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +508 -0
- package/extensions/README.md +27 -0
- package/extensions/api-server/PLAN.md +70 -0
- package/extensions/api-server/README.md +103 -0
- package/extensions/api-server/application-log-store.ts +21 -0
- package/extensions/api-server/application-runtime.ts +212 -0
- package/extensions/api-server/application-store.ts +30 -0
- package/extensions/api-server/index.ts +52 -0
- package/extensions/api-server/profile-store.ts +367 -0
- package/extensions/api-server/server.ts +863 -0
- package/extensions/cli-resources.ts +564 -0
- package/extensions/guardrails/index.ts +178 -0
- package/extensions/lib/application-handler-templates.ts +63 -0
- package/extensions/lib/profile-env.ts +61 -0
- package/extensions/lib/profile-sandbox.ts +197 -0
- package/extensions/lib/remote-hosts.ts +392 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +4 -0
- package/extensions/pi-console-webui/app/api/admin/config/[target]/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/admin/services/[service]/restart/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/auth/login/route.ts +9 -0
- package/extensions/pi-console-webui/app/api/auth/logout/route.ts +3 -0
- package/extensions/pi-console-webui/app/api/message/app/[slug]/route.ts +11 -0
- package/extensions/pi-console-webui/app/api/pi/[...path]/route.ts +31 -0
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +2 -0
- package/extensions/pi-console-webui/app/globals.css +41 -0
- package/extensions/pi-console-webui/app/icon.svg +1 -0
- package/extensions/pi-console-webui/app/layout.tsx +5 -0
- package/extensions/pi-console-webui/app/login/page.tsx +11 -0
- package/extensions/pi-console-webui/app/page.tsx +2 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +4 -0
- package/extensions/pi-console-webui/components/admin-config-form.tsx +16 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +39 -0
- package/extensions/pi-console-webui/components/application-logs.tsx +38 -0
- package/extensions/pi-console-webui/components/application-mappings.tsx +28 -0
- package/extensions/pi-console-webui/components/application-sessions.tsx +11 -0
- package/extensions/pi-console-webui/components/application-settings.tsx +60 -0
- package/extensions/pi-console-webui/components/application-workspace.tsx +14 -0
- package/extensions/pi-console-webui/components/applications.tsx +15 -0
- package/extensions/pi-console-webui/components/chat-workspace.tsx +42 -0
- package/extensions/pi-console-webui/components/console-page.tsx +23 -0
- package/extensions/pi-console-webui/components/console-state.tsx +30 -0
- package/extensions/pi-console-webui/components/console.tsx +115 -0
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +78 -0
- package/extensions/pi-console-webui/components/package-resources.tsx +13 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +41 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +35 -0
- package/extensions/pi-console-webui/components/skill-source-document-preview.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-source-import.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-sources.tsx +12 -0
- package/extensions/pi-console-webui/components/terminal-client.tsx +39 -0
- package/extensions/pi-console-webui/components/toast.tsx +18 -0
- package/extensions/pi-console-webui/components/ui/button.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/card.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/input.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/switch.tsx +6 -0
- package/extensions/pi-console-webui/components/ui/tabs.tsx +11 -0
- package/extensions/pi-console-webui/components.json +8 -0
- package/extensions/pi-console-webui/index.ts +33 -0
- package/extensions/pi-console-webui/lib/admin-config.ts +22 -0
- package/extensions/pi-console-webui/lib/auth.ts +21 -0
- package/extensions/pi-console-webui/lib/config.ts +15 -0
- package/extensions/pi-console-webui/lib/pi-api.ts +9 -0
- package/extensions/pi-console-webui/lib/utils.ts +3 -0
- package/extensions/pi-console-webui/next-env.d.ts +6 -0
- package/extensions/pi-console-webui/next.config.js +5 -0
- package/extensions/pi-console-webui/postcss.config.js +1 -0
- package/extensions/pi-console-webui/tailwind.config.ts +2 -0
- package/extensions/pi-console-webui/tsconfig.json +41 -0
- package/extensions/profiles.ts +439 -0
- package/extensions/pulse/index.ts +62 -0
- package/extensions/pulse/store.ts +105 -0
- package/extensions/sequential-workflow.ts +270 -0
- package/extensions/skill-sources/index.ts +4 -0
- package/extensions/skill-sources/store.ts +118 -0
- package/package.json +89 -0
- package/scripts/install-nono.sh +34 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import CodeMirror from "@uiw/react-codemirror";
|
|
5
|
+
import { javascript } from "@codemirror/lang-javascript";
|
|
6
|
+
import { json } from "@codemirror/lang-json";
|
|
7
|
+
import { ArrowLeft, FileCode2, MoreHorizontal, Play, Plus, RotateCcw, Save, X } from "lucide-react";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { useToast } from "@/components/toast";
|
|
12
|
+
import { applicationHandlerTemplate } from "../../lib/application-handler-templates";
|
|
13
|
+
|
|
14
|
+
type HandlerType = "inbound" | "outbound" | "transform";
|
|
15
|
+
type TestResult = { ok: boolean; output?: unknown; logs: { stdout: string[]; stderr: string[] }; durationMs: number; error?: string };
|
|
16
|
+
const handlerTypes: Array<{ value: HandlerType; label: string; description: string }> = [{ value: "inbound", label: "Inbound", description: "Converts incoming messages into a Pi profile and payload." }, { value: "outbound", label: "Outbound", description: "Converts Pi responses for the external destination." }, { value: "transform", label: "Transform", description: "Transforms payloads before the inbound handler." }];
|
|
17
|
+
const validName = (value: string) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(value);
|
|
18
|
+
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
|
+
const templateFor = (type: HandlerType) => applicationHandlerTemplate(type);
|
|
20
|
+
const fileName = (path: string) => path.split("/").pop()!.replace(/\.ts$/, "");
|
|
21
|
+
const typeForPath = (path: string): HandlerType => path.startsWith("transforms/") ? "transform" : fileName(path) === "outbound" ? "outbound" : "inbound";
|
|
22
|
+
|
|
23
|
+
export function ApplicationHandlerEditor({ name, embedded = false }: { name: string; embedded?: boolean }) {
|
|
24
|
+
const { toast } = useToast(); const base = `applications/${name}`;
|
|
25
|
+
const [files, setFiles] = useState<string[]>([]), [selected, setSelected] = useState(""), [content, setContent] = useState(""), [loading, setLoading] = useState(true), [createDialog, setCreateDialog] = useState(false), [handlerType, setHandlerType] = useState<HandlerType>("inbound"), [newName, setNewName] = useState(""), [renamePath, setRenamePath] = useState<string>(), [renameName, setRenameName] = useState(""), [menuPath, setMenuPath] = useState<string>();
|
|
26
|
+
const [payload, setPayload] = useState('{\n "message": "Hello"\n}'), [output, setOutput] = useState(""), [logs, setLogs] = useState(""), [testing, setTesting] = useState(false);
|
|
27
|
+
useEffect(() => { api(`${base}/handlers`).then(({ files }) => { setFiles(files); if (files[0]) setSelected(files[0]); }).catch((error) => toast(error.message, "error")).finally(() => setLoading(false)); // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
28
|
+
}, [name]);
|
|
29
|
+
useEffect(() => { if (selected) api(`${base}/handler?path=${encodeURIComponent(selected)}`).then(({ content, testPayload }) => { setContent(content); setPayload(JSON.stringify(testPayload ?? { message: "Hello" }, null, 2)); }).catch((error) => toast(error.message, "error")); // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
30
|
+
}, [name, selected]);
|
|
31
|
+
const save = async () => { try { await api(`${base}/handler?path=${encodeURIComponent(selected)}`, { method: "PUT", body: JSON.stringify({ content }) }); toast("Handler saved. Changes apply to the next request."); } catch (error) { toast((error as Error).message, "error"); } };
|
|
32
|
+
const test = async () => { if (!selected) return; let input: unknown; try { input = JSON.parse(payload); } catch { toast("Input must be valid JSON.", "error"); return; } if (!input || typeof input !== "object" || Array.isArray(input)) { toast("Input must be a JSON object.", "error"); return; } try { setTesting(true); setOutput(""); setLogs(""); const result = await api(`${base}/handler/test?path=${encodeURIComponent(selected)}`, { method: "POST", body: JSON.stringify({ payload: input, content }) }) as TestResult; setOutput(JSON.stringify(result.ok ? result.output ?? null : { error: result.error ?? "Handler test failed." }, null, 2)); const lines = [`Duration: ${result.durationMs} ms`, ...result.logs.stdout.map((line) => `[info] ${line}`), ...result.logs.stderr.map((line) => `[error] ${line}`)]; setLogs(lines.join("\n") || "No logs emitted."); } catch (error) { setLogs((error as Error).message); toast((error as Error).message, "error"); } finally { setTesting(false); } };
|
|
33
|
+
const create = async () => { if (!validName(newName)) return toast("Use a name beginning with a letter; only letters, numbers, hyphens and underscores are allowed.", "error"); const path = handlerType === "transform" ? `transforms/${newName}.ts` : `${newName}.ts`; if (files.includes(path)) return toast("A handler with this name already exists.", "error"); try { const template = templateFor(handlerType); await api(`${base}/handler?path=${encodeURIComponent(path)}`, { method: "PUT", body: JSON.stringify({ content: template }) }); setFiles((items) => [...new Set([...items, path])].sort()); setSelected(path); setContent(template); setCreateDialog(false); toast("Handler created. Configure it in Application Settings when ready."); } catch (error) { toast((error as Error).message, "error"); } };
|
|
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
|
+
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
|
+
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><Input className="mt-4" value={newName} onChange={(event) => setNewName(event.target.value)} placeholder="e.g. whatsapp-inbound" autoFocus/><div className="mt-4 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-medium">{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><Input className="mt-4" value={renameName} onChange={(event) => setRenameName(event.target.value)} autoFocus/><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
|
+
}
|
|
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>; }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import CodeMirror from "@uiw/react-codemirror";
|
|
4
|
+
import { json as jsonLanguage } from "@codemirror/lang-json";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { ArrowLeft, Check, Copy, Filter, Trash2 } from "lucide-react";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
9
|
+
type Stage = { id: string; label: string; type: string; payload?: unknown; input?: unknown; output?: unknown; stdout: string[]; stderr: string[]; error?: string };
|
|
10
|
+
type Call = { id: string; receivedAt: string; origin: string; status: string; stages: Stage[]; error?: string };
|
|
11
|
+
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(); };
|
|
12
|
+
const json = (value: unknown) => JSON.stringify(value, null, 2) ?? "No data recorded.";
|
|
13
|
+
const localDateTime = (date: Date) => { const offset = date.getTimezoneOffset() * 60_000; return new Date(date.getTime() - offset).toISOString().slice(0, 16); };
|
|
14
|
+
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: "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 stageColor = (type: string) => stageFilters.find((stage) => stage.value === type)?.className ?? "border-zinc-300 bg-zinc-50 text-zinc-700";
|
|
16
|
+
function JsonView({ value }: { value: unknown }) {
|
|
17
|
+
const [copied, setCopied] = useState(false);
|
|
18
|
+
const copy = async () => {
|
|
19
|
+
try {
|
|
20
|
+
await navigator.clipboard.writeText(json(value));
|
|
21
|
+
setCopied(true);
|
|
22
|
+
window.setTimeout(() => setCopied(false), 2_000);
|
|
23
|
+
} catch { /* Keep the copy action available for a retry. */ }
|
|
24
|
+
};
|
|
25
|
+
return <div className="json-view relative overflow-hidden rounded-lg border border-zinc-200"><button type="button" className="absolute right-2 top-2 z-10 rounded-md bg-white/90 p-1.5 text-zinc-500 shadow-sm ring-1 ring-zinc-200 hover:bg-zinc-100 hover:text-zinc-900" title="Copy JSON" aria-label="Copy JSON" onClick={() => void copy()}>{copied ? <Check size={15}/> : <Copy size={15}/>}</button><CodeMirror value={json(value)} extensions={[jsonLanguage()]} editable={false} basicSetup={{ lineNumbers: false, foldGutter: false }} /></div>;
|
|
26
|
+
}
|
|
27
|
+
function FlowArrow({ output, nextInput }: { output: unknown; nextInput: unknown }) { const [position, setPosition] = useState<{ x: number; y: number }>(); return <div className="py-3 text-center text-4xl leading-none text-zinc-500"><span className="cursor-help" onMouseEnter={(event) => setPosition({ x: event.clientX, y: event.clientY })} onMouseMove={(event) => setPosition({ x: event.clientX, y: event.clientY })} onMouseLeave={() => setPosition(undefined)}>↓</span>{position && <div className="pointer-events-none fixed z-50 w-96 rounded-lg border border-[#b9e6e8] bg-[#f6fdff]/90 p-3 text-left text-xs text-zinc-900 shadow-xl backdrop-blur-sm" style={{ left: Math.min(position.x + 16, window.innerWidth - 400), top: Math.min(position.y + 16, window.innerHeight - 280) }}><p className="mb-1 font-semibold text-blue-950">Previous output → next input</p><pre className="log-json log-hover-json max-h-56 overflow-auto">{json({ output, nextInput })}</pre></div>}</div>; }
|
|
28
|
+
|
|
29
|
+
export function ApplicationLogs({ name, embedded = false }: { name: string; embedded?: boolean }) {
|
|
30
|
+
const base = `applications/${name}`;
|
|
31
|
+
const [calls, setCalls] = useState<Call[]>([]), [selected, setSelected] = useState<Call>(), [stage, setStage] = useState<Stage>(), [statuses, setStatuses] = useState<string[]>([]), [stages, setStages] = useState<string[]>([]), [start, setStart] = useState(() => localDateTime(new Date(Date.now() - 3 * 60 * 60 * 1000))), [end, setEnd] = useState(() => localDateTime(new Date(Date.now() + 60 * 60 * 1000)));
|
|
32
|
+
const toggle = (value: string, setter: React.Dispatch<React.SetStateAction<string[]>>) => setter((items) => items.includes(value) ? items.filter((item) => item !== value) : [...items, value]);
|
|
33
|
+
const load = () => api(`${base}/logs`).then((data) => { setCalls(data.calls); setSelected((current) => data.calls.find((call: Call) => call.id === current?.id) ?? data.calls[0]); });
|
|
34
|
+
useEffect(() => { void load(); let socket: WebSocket | undefined; api(`${base}/logs/ticket`, { method: "POST" }).then(({ url }) => { socket = new WebSocket(url); socket.onmessage = () => void load(); }).catch(() => {}); return () => socket?.close(); }, [name]);
|
|
35
|
+
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]);
|
|
36
|
+
const current = selected && filtered.find((call) => call.id === selected.id) ? selected : filtered[0]; const detail = stage && current?.stages.some((item) => item.id === stage.id) ? stage : current?.stages[0]; const selectCall = (call: Call) => { setSelected(call); setStage(call.stages.find((item) => item.error) ?? call.stages.at(-1) ?? call.stages[0]); };
|
|
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 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">{filtered.map((call) => <button key={call.id} onClick={() => 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>)}{!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.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>{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>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Info, Loader2, Pencil, Plus, Trash2, X } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { useToast } from "@/components/toast";
|
|
9
|
+
|
|
10
|
+
type Mapping = { identityKey: string; profile: string; sessionMode: "fixed" | "automatic"; sessionPrefix: string | null };
|
|
11
|
+
type Form = { identityKey: string; profile: string; prefix: string };
|
|
12
|
+
const emptyForm = (): Form => ({ identityKey: "", profile: "", prefix: "" });
|
|
13
|
+
async function api(path: string, init?: RequestInit) { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error?.message ?? data.error ?? "Request failed"); return data; }
|
|
14
|
+
|
|
15
|
+
export function ApplicationMappings({ slug }: { slug: string }) {
|
|
16
|
+
const { toast } = useToast();
|
|
17
|
+
const [items, setItems] = useState<Mapping[]>([]), [profiles, setProfiles] = useState<string[]>([]), [dialog, setDialog] = useState<"create" | "edit">(), [editing, setEditing] = useState<Mapping>(), [form, setForm] = useState<Form>(emptyForm()), [saving, setSaving] = useState(false);
|
|
18
|
+
const wildcard = form.identityKey.trim() === "*", wildcardExists = items.some((item) => item.identityKey === "*");
|
|
19
|
+
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((error as Error).message, "error")); }, [slug]);
|
|
21
|
+
const close = () => { setDialog(undefined); setEditing(undefined); setSaving(false); };
|
|
22
|
+
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 valid = Boolean(form.identityKey.trim() && form.profile && (wildcard || form.prefix.trim()));
|
|
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); } };
|
|
26
|
+
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; it creates an automatic, isolated session prefix.</p></div><Button className="inline-flex items-center justify-center gap-2" title="Add identity mapping" onClick={openCreate}><Plus size={17}/><span>Add mapping</span></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.profile}</td><td className="p-3 font-mono">{item.sessionMode === "automatic" ? <span className="inline-flex items-center gap-1.5">Automatic <span title="The system derives a stable, isolated session prefix from the provided identity key."><Info className="text-sky-600" size={15} aria-label="Information"/></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">{dialog === "create" ? "Route an identity key to a profile and session prefix." : "Update the profile or session configuration for this identity."}</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 })}/><span className="text-xs font-normal text-zinc-500">{dialog === "edit" ? "Changing this key routes future messages to a new identity and session." : "Use * only as the fallback mapping."}</span></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 })}>{profiles.map((profile) => <option key={profile}>{profile}</option>)}</select></label><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 })}/><span className="text-xs font-normal text-zinc-500">{wildcard ? "The system derives a stable, isolated prefix for each unknown identity." : "This prefix groups sessions for the mapped identity."}</span></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="inline-flex items-center justify-center gap-2" disabled={!valid || saving}>{saving && <Loader2 className="animate-spin" size={16}/>}{dialog === "create" ? "Add mapping" : "Save changes"}</Button></footer></form></div>}</>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Loader2, RotateCw, Trash2 } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { useToast } from "@/components/toast";
|
|
8
|
+
type Session = { application: string; profile: string; sessionPrefix: string; sessionId: string; messageCount: number; updatedAt: string; lastIdentityKey: string | null };
|
|
9
|
+
type Filters = { profile: string; application: string; identityKey: string; prefix: string; session: string };
|
|
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">Messages</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">{session.messageCount}</td><td className="p-3"><div className="flex gap-2"><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={7}>No active Application sessions match these filters.</td></tr>}</tbody></table></div></Card>; }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { ArrowDown, ArrowUp, Check, Copy, Loader2, Plus, Trash2 } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { Switch } from "@/components/ui/switch";
|
|
9
|
+
import { useToast } from "@/components/toast";
|
|
10
|
+
|
|
11
|
+
type Settings = { inboundHandler: string; outboundHandler?: string; transformHandlers?: string[] };
|
|
12
|
+
type App = { name: string; slug: string; enabled: boolean; responseMode: "ack" | "result"; defaultProfile: string | null; routingPolicy: "default_as_fallback" | "drop"; settings: Settings };
|
|
13
|
+
type HandlerOptions = { inbound: string[]; outbound: string[]; transform: string[] };
|
|
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(); };
|
|
15
|
+
const handlerName = (path: string) => path.replace(/^transforms\//, "").replace(/\.ts$/, "");
|
|
16
|
+
|
|
17
|
+
export function ApplicationSettings({ slug }: { slug: string }) {
|
|
18
|
+
const { toast } = useToast(); const router = useRouter(), isNew = slug === "new";
|
|
19
|
+
const [app, setApp] = useState<App>();
|
|
20
|
+
const [profiles, setProfiles] = useState<string[]>([]);
|
|
21
|
+
const [handlers, setHandlers] = useState<HandlerOptions>({ inbound: [], outbound: [], transform: [] });
|
|
22
|
+
const [saving, setSaving] = useState(false), [error, setError] = useState(""), [copyMenu, setCopyMenu] = useState(false);
|
|
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; }
|
|
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
|
+
}, [slug, isNew]);
|
|
27
|
+
if (!app) return <Card>{error || "Loading Application settings…"}</Card>;
|
|
28
|
+
|
|
29
|
+
const settings = app.settings;
|
|
30
|
+
const update = (patch: Partial<App>) => setApp({ ...app, ...patch });
|
|
31
|
+
const updateSettings = (patch: Partial<Settings>) => update({ settings: { ...settings, ...patch } });
|
|
32
|
+
const transformHandlers = settings.transformHandlers ?? [];
|
|
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
|
+
const setTransform = (index: number, value: string) => { const next = [...transformHandlers]; next[index] = value; updateSettings({ transformHandlers: next }); };
|
|
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 copy = async (format: "url" | "curl") => { const url = `${window.location.origin}/api/message/app/${app.slug}`; try { await navigator.clipboard.writeText(format === "url" ? url : `curl --request POST '${url}' \\\n --header 'Content-Type: application/json' \\\n --data '{"message":"Hello"}'`); toast(format === "url" ? "Application URL copied." : "Example cURL copied."); } catch { toast("Unable to copy to the clipboard.", "error"); } };
|
|
37
|
+
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
|
+
|
|
39
|
+
return <div className="space-y-5">
|
|
40
|
+
{error && <p className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
|
|
41
|
+
<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("url"); }}>Simply 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); void copy("curl"); }}>Example cURL</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
|
+
<label className="text-sm font-medium">Name<Input className="mt-1" value={app.name} onChange={(event) => update({ name: event.target.value })}/></label>
|
|
44
|
+
<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
|
+
<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>
|
|
46
|
+
<label className="text-sm font-medium">Default profile<select className="mt-1 w-full rounded-lg border border-zinc-200 px-3 py-2" value={app.defaultProfile ?? ""} onChange={(event) => update({ defaultProfile: event.target.value || null })}><option value="">None</option>{profiles.map((profile) => <option key={profile}>{profile}</option>)}</select></label>
|
|
47
|
+
<label className="text-sm font-medium">Response mode<select className="mt-1 w-full rounded-lg border border-zinc-200 px-3 py-2" value={app.responseMode} onChange={(event) => update({ responseMode: event.target.value as App["responseMode"] })}><option value="ack">Acknowledgement (ack)</option><option value="result">Wait for result</option></select></label>
|
|
48
|
+
<label className="text-sm font-medium">Routing policy<select className="mt-1 w-full rounded-lg border border-zinc-200 px-3 py-2" value={app.routingPolicy} onChange={(event) => update({ routingPolicy: event.target.value as App["routingPolicy"] })}><option value="default_as_fallback">Use default profile as fallback</option><option value="drop">Reject unresolved routes</option></select></label>
|
|
49
|
+
<label className="flex items-center gap-2 text-sm font-medium md:col-span-2"><Switch checked={app.enabled} onCheckedChange={(enabled) => update({ enabled })}/>Enabled</label>
|
|
50
|
+
</Card>
|
|
51
|
+
<Card className="space-y-4">
|
|
52
|
+
<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>
|
|
53
|
+
<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>
|
|
54
|
+
<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>
|
|
55
|
+
<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>
|
|
56
|
+
<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>
|
|
57
|
+
</div>
|
|
58
|
+
</Card>
|
|
59
|
+
</div>;
|
|
60
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
4
|
+
import { Activity, ArrowLeft, FileCode2, Settings2 } from "lucide-react";
|
|
5
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
6
|
+
import { ApplicationHandlerEditor } from "@/components/application-handler-editor";
|
|
7
|
+
import { ApplicationLogs } from "@/components/application-logs";
|
|
8
|
+
import { ApplicationSettings } from "@/components/application-settings";
|
|
9
|
+
import { ApplicationMappings } from "@/components/application-mappings";
|
|
10
|
+
const tab = (value: string | null): "settings" | "handlers" | "mappings" | "logs" => value === "handlers" || value === "mappings" || value === "logs" ? value : "settings";
|
|
11
|
+
export function ApplicationWorkspace({ slug }: { slug: string }) {
|
|
12
|
+
const router = useRouter(), params = useSearchParams(), selected = tab(params.get("tab")), isNew = slug === "new";
|
|
13
|
+
return <div className="space-y-5"><header className="flex items-center gap-3"><Link href="/applications?context=admin" className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100"><ArrowLeft size={18}/></Link><div><h2 className="text-xl font-semibold">{isNew ? "Create Application" : `Application: ${slug}`}</h2><p className="text-sm text-zinc-500">{isNew ? "Configure a new public Pi endpoint." : "Manage endpoint settings, handlers and execution logs."}</p></div></header>{isNew ? <ApplicationSettings slug="new"/> : <Tabs value={selected} onValueChange={(value) => router.replace(`/applications/${encodeURIComponent(slug)}?context=admin&tab=${tab(value)}`)}><TabsList><TabsTrigger value="settings"><Settings2 className="mr-2 text-sky-600" size={16}/>Settings</TabsTrigger><TabsTrigger value="handlers"><FileCode2 className="mr-2 text-violet-600" size={16}/>Handlers</TabsTrigger><TabsTrigger value="mappings"><Settings2 className="mr-2 text-amber-600" size={16}/>Identity mappings</TabsTrigger><TabsTrigger value="logs"><Activity className="mr-2 text-emerald-600" size={16}/>Logs</TabsTrigger></TabsList><TabsContent value="settings"><ApplicationSettings slug={slug}/></TabsContent><TabsContent value="handlers"><ApplicationHandlerEditor name={slug} embedded/></TabsContent><TabsContent value="mappings"><ApplicationMappings slug={slug}/></TabsContent><TabsContent value="logs"><ApplicationLogs name={slug} embedded/></TabsContent></Tabs>}</div>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { Copy, Plus, Trash2, Wrench } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { useToast } from "@/components/toast";
|
|
8
|
+
type Application = { name: string; slug: string; enabled: boolean; responseMode: string; defaultProfile: string | null };
|
|
9
|
+
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(); };
|
|
10
|
+
export function Applications() {
|
|
11
|
+
const { toast } = useToast(); const [items, setItems] = useState<Application[]>([]), [error, setError] = useState("");
|
|
12
|
+
useEffect(() => { api("applications").then((data) => setItems(data.applications)).catch((cause) => setError(cause.message)); }, []);
|
|
13
|
+
const remove = async (value: Application) => { if (!confirm(`Delete Application '${value.name}'? Its handlers and logs will be deleted.`)) return; try { await api(`applications/${encodeURIComponent(value.slug)}`, { method: "DELETE" }); setItems((current) => current.filter((item) => item.slug !== value.slug)); } catch (cause) { setError((cause as Error).message); } };
|
|
14
|
+
return <Card><div className="flex items-center justify-between gap-3"><div><CardTitle>Applications</CardTitle><p className="mt-1 text-sm text-zinc-500">Independent public endpoints and Pi message pipelines.</p></div><Link href="/applications/new?context=admin"><Button title="Create Application"><Plus size={17}/></Button></Link></div>{error && <p className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}<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">Name</th><th className="p-3">Slug</th><th className="p-3">Profile</th><th className="p-3">Mode</th><th className="p-3">Status</th><th className="p-3"/></tr></thead><tbody>{items.map((item) => <tr key={item.slug} className="border-b border-zinc-100"><td className="p-3 font-medium">{item.name}</td><td className="p-3 font-mono text-zinc-500">{item.slug}</td><td className="p-3">{item.defaultProfile ?? "—"}</td><td className="p-3">{item.responseMode}</td><td className="p-3">{item.enabled ? "Enabled" : "Disabled"}</td><td className="p-3"><div className="flex gap-1"><Link href={`/applications/${encodeURIComponent(item.slug)}?context=admin`}><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Manage"><Wrench size={16}/></Button></Link><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Copy endpoint" onClick={() => navigator.clipboard.writeText(`${location.origin}/api/message/app/${item.slug}`).then(() => toast("Endpoint copied."))}><Copy size={16}/></Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Delete" onClick={() => remove(item)}><Trash2 size={16}/></Button></div></td></tr>)}{!items.length && <tr><td colSpan={6} className="p-6 text-center text-zinc-500">No Applications configured.</td></tr>}</tbody></table></div></Card>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
4
|
+
import ReactMarkdown from "react-markdown";
|
|
5
|
+
import remarkGfm from "remark-gfm";
|
|
6
|
+
import { Bot, Check, ChevronDown, ChevronRight, CircleStop, Copy, Loader2, MessageSquare, PanelLeft, Pencil, Plus, Search, SendHorizontal, Trash2, User, X } from "lucide-react";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Card } from "@/components/ui/card";
|
|
9
|
+
import { Input } from "@/components/ui/input";
|
|
10
|
+
|
|
11
|
+
type Session = { id: string; name?: string; preview: string; updatedAt: string; messageCount: number };
|
|
12
|
+
type Block = { type: "text" | "thinking"; text: string };
|
|
13
|
+
type Entry = { id: string; type: string; timestamp: string; role?: string; content?: Block[]; tools?: Array<{ name: string; arguments: unknown }>; toolName?: string; isError?: boolean; summary?: string };
|
|
14
|
+
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; };
|
|
15
|
+
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`; };
|
|
16
|
+
const text = (entry: Entry) => entry.content?.filter((block) => block.type === "text").map((block) => block.text).join("\n") ?? "";
|
|
17
|
+
|
|
18
|
+
export function ChatWorkspace({ profile }: { profile: string }) {
|
|
19
|
+
const searchParams = useSearchParams(); const router = useRouter();
|
|
20
|
+
// Profile selection updates the route asynchronously. Never resolve a
|
|
21
|
+
// session from the previous URL against the newly selected profile.
|
|
22
|
+
const requestedSession = (searchParams.get("profile") ?? "default") === profile ? searchParams.get("session") : null;
|
|
23
|
+
const [sessions, setSessions] = useState<Session[]>([]), [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);
|
|
24
|
+
const abortRef = useRef<AbortController | undefined>(undefined); const bottom = useRef<HTMLDivElement>(null); const conversation = useRef<HTMLDivElement>(null); const loadingSession = useRef<string | undefined>(undefined);
|
|
25
|
+
const loadSessions = async () => { const data = await (await call(`profiles/${profile}/sessions`)).json() as { sessions: Session[] }; setSessions(data.sessions); return data.sessions; };
|
|
26
|
+
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" })); };
|
|
27
|
+
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); } };
|
|
28
|
+
useEffect(() => { loadingSession.current = undefined; setSelected(undefined); setEntries([]); setHasMore(false); setNextBefore(null); setStreaming(""); setError(""); loadSessions().catch((cause) => setError(cause.message)); }, [profile]);
|
|
29
|
+
useEffect(() => { if (requestedSession) loadConversation(requestedSession).catch((cause) => setError(cause.message)); }, [profile, requestedSession]);
|
|
30
|
+
useEffect(() => { if (busy || streaming) bottom.current?.scrollIntoView({ behavior: "auto" }); }, [streaming, busy]);
|
|
31
|
+
useEffect(() => { const timer = setTimeout(() => setDebouncedQuery(query), 600); return () => clearTimeout(timer); }, [query]);
|
|
32
|
+
const filtered = useMemo(() => sessions.filter((session) => `${session.name ?? ""} ${session.preview} ${session.id}`.toLowerCase().includes(debouncedQuery.toLowerCase())), [sessions, debouncedQuery]);
|
|
33
|
+
const activeSession = sessions.find((session) => session.id === selected);
|
|
34
|
+
async function copySessionId() { if (!selected) return; try { await navigator.clipboard.writeText(selected); setCopiedSessionId(true); window.setTimeout(() => setCopiedSessionId(false), 2_000); } catch (cause) { setError((cause as Error).message || "Unable to copy session ID."); } }
|
|
35
|
+
function openConversation(id: string) { router.push(`/chat?profile=${encodeURIComponent(profile)}&session=${encodeURIComponent(id)}`); }
|
|
36
|
+
function create() { router.push(`/chat?profile=${encodeURIComponent(profile)}`); loadingSession.current = undefined; setSelected(undefined); setCopiedSessionId(false); setEditingName(false); setEntries([]); setHasMore(false); setNextBefore(null); setStreaming(""); setMessage(""); setError(""); }
|
|
37
|
+
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); } }
|
|
38
|
+
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); } }
|
|
39
|
+
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 userEntry: Entry = { id: `local-${Date.now()}`, type: "message", role: "user", timestamp: new Date().toISOString(), content: [{ type: "text", text: message }] }; setEntries((current) => [...current, userEntry]); const prompt = message; setMessage(""); setBusy(true); setStreaming(""); 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); if (payload.text) setStreaming((current) => current + payload.text); if (typeof payload.response === "string") { setStreaming(payload.response); complete = true; break; } } } await loadConversation(id); await loadSessions(); } catch (cause) { if ((cause as Error).name !== "AbortError") setError((cause as Error).message); } finally { abortRef.current = undefined; setBusy(false); setStreaming(""); } }
|
|
40
|
+
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><Button title="New chat" onClick={create}><Plus size={17}/></Button></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></Card>;
|
|
41
|
+
}
|
|
42
|
+
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-blue-600 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-blue-600 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>; }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
import { Console, type ConsoleInitialData } from "@/components/console";
|
|
3
|
+
import { authenticated } from "@/lib/auth";
|
|
4
|
+
import { piGet } from "@/lib/pi-api";
|
|
5
|
+
import { readAdminConfig } from "@/lib/admin-config";
|
|
6
|
+
|
|
7
|
+
type Props = { section: string; applicationSlug?: string; profile?: string };
|
|
8
|
+
export async function ConsolePage({ section, applicationSlug, profile = "default" }: Props) {
|
|
9
|
+
if (!(await authenticated())) redirect("/login");
|
|
10
|
+
let initial: ConsoleInitialData = {};
|
|
11
|
+
try {
|
|
12
|
+
if (section === "api-server") initial.adminConfig = await readAdminConfig("api-server");
|
|
13
|
+
else if (section === "pi-console-webui") initial.adminConfig = await readAdminConfig("pi-console-webui");
|
|
14
|
+
else if (section === "settings") initial.settings = await piGet(`profiles/${profile}/settings`);
|
|
15
|
+
else if (section === "env") initial.env = await piGet(`profiles/${profile}/env`);
|
|
16
|
+
else if (section === "soul") initial.document = await piGet(`profiles/${profile}/${section}`);
|
|
17
|
+
else if (section === "guardrails") initial.guardrails = await piGet(`profiles/${profile}/guardrails`);
|
|
18
|
+
else if (section === "packages") initial.packages = await piGet(`profiles/${profile}/packages`);
|
|
19
|
+
else if (section === "pulses") initial.pulses = await piGet(`profiles/${profile}/pulses`);
|
|
20
|
+
else if (section === "skills" || section === "tools" || section === "extensions") initial.resources = await piGet(`profiles/${profile}/resources/${section}`);
|
|
21
|
+
} catch (error) { initial.error = error instanceof Error ? error.message : String(error); }
|
|
22
|
+
return <Console section={section} applicationSlug={applicationSlug} initial={initial}/>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
type Profile = { name: string; path: string };
|
|
5
|
+
type ConsoleState = { profiles: Profile[]; health: string; error: string; initialize: () => Promise<void>; refreshProfiles: () => Promise<Profile[]> };
|
|
6
|
+
const StateContext = createContext<ConsoleState | undefined>(undefined);
|
|
7
|
+
|
|
8
|
+
async function getJson<T>(path: string): Promise<T> {
|
|
9
|
+
const response = await fetch(`/api/pi/${path}`);
|
|
10
|
+
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message ?? body.error ?? "Request failed"); }
|
|
11
|
+
return response.json() as Promise<T>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ConsoleStateProvider({ children }: { children: React.ReactNode }) {
|
|
15
|
+
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
16
|
+
const [health, setHealth] = useState("Checking…");
|
|
17
|
+
const [error, setError] = useState("");
|
|
18
|
+
const initialized = useRef(false);
|
|
19
|
+
const refreshProfiles = useCallback(async () => { const data = await getJson<{ profiles: Profile[] }>("profiles"); setProfiles(data.profiles); return data.profiles; }, []);
|
|
20
|
+
const initialize = useCallback(async () => {
|
|
21
|
+
if (initialized.current) return;
|
|
22
|
+
initialized.current = true;
|
|
23
|
+
try {
|
|
24
|
+
const [healthData, profileData] = await Promise.all([getJson<{ status: string }>("health"), getJson<{ profiles: Profile[] }>("profiles")]);
|
|
25
|
+
setHealth(healthData.status); setProfiles(profileData.profiles);
|
|
26
|
+
} catch (cause) { setHealth("Unavailable"); setError((cause as Error).message); }
|
|
27
|
+
}, []);
|
|
28
|
+
return <StateContext.Provider value={{ profiles, health, error, initialize, refreshProfiles }}>{children}</StateContext.Provider>;
|
|
29
|
+
}
|
|
30
|
+
export function useConsoleState() { const state = useContext(StateContext); if (!state) throw new Error("useConsoleState must be used inside ConsoleStateProvider"); return state; }
|