pi-feats 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/pi-feats-preview.png +0 -0
- package/extensions/api-server/application-log-store.ts +31 -0
- package/extensions/api-server/application-runtime.ts +80 -18
- package/extensions/api-server/application-store.ts +6 -2
- package/extensions/api-server/profile-store.ts +47 -20
- package/extensions/api-server/server.ts +182 -47
- package/extensions/browser-harness/cdp/commands.ts +260 -0
- package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
- package/extensions/browser-harness/cdp/discovery.ts +198 -0
- package/extensions/browser-harness/cdp/errors.ts +23 -0
- package/extensions/browser-harness/cdp/events.ts +170 -0
- package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
- package/extensions/browser-harness/cdp/ownership.ts +73 -0
- package/extensions/browser-harness/cdp/record-store.ts +61 -0
- package/extensions/browser-harness/cdp/session.ts +403 -0
- package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
- package/extensions/browser-harness/cdp/target-factory.ts +153 -0
- package/extensions/browser-harness/cdp/types.ts +82 -0
- package/extensions/browser-harness/client.ts +366 -0
- package/extensions/browser-harness/daemon/bridge.ts +531 -0
- package/extensions/browser-harness/daemon/index.ts +86 -0
- package/extensions/browser-harness/daemon/protocol.ts +110 -0
- package/extensions/browser-harness/daemon/queue.ts +85 -0
- package/extensions/browser-harness/daemon/server.ts +170 -0
- package/extensions/browser-harness/daemon/spawn.ts +124 -0
- package/extensions/browser-harness/daemon/transport.ts +178 -0
- package/extensions/browser-harness/domains/ax-tree.ts +179 -0
- package/extensions/browser-harness/domains/box.ts +51 -0
- package/extensions/browser-harness/domains/cdp-call.ts +55 -0
- package/extensions/browser-harness/domains/click.ts +97 -0
- package/extensions/browser-harness/domains/console.ts +219 -0
- package/extensions/browser-harness/domains/dialog.ts +34 -0
- package/extensions/browser-harness/domains/drag.ts +67 -0
- package/extensions/browser-harness/domains/element-call.ts +117 -0
- package/extensions/browser-harness/domains/files.ts +219 -0
- package/extensions/browser-harness/domains/fill-engine.ts +80 -0
- package/extensions/browser-harness/domains/form-batch.ts +65 -0
- package/extensions/browser-harness/domains/form.ts +283 -0
- package/extensions/browser-harness/domains/headers.ts +89 -0
- package/extensions/browser-harness/domains/history.ts +80 -0
- package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
- package/extensions/browser-harness/domains/js.ts +241 -0
- package/extensions/browser-harness/domains/keyboard.ts +197 -0
- package/extensions/browser-harness/domains/navigate.ts +161 -0
- package/extensions/browser-harness/domains/network.ts +301 -0
- package/extensions/browser-harness/domains/page.ts +154 -0
- package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
- package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
- package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
- package/extensions/browser-harness/domains/render.ts +43 -0
- package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
- package/extensions/browser-harness/domains/screenshot.ts +76 -0
- package/extensions/browser-harness/domains/scroll.ts +59 -0
- package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
- package/extensions/browser-harness/domains/search/web-search.ts +144 -0
- package/extensions/browser-harness/domains/setup.ts +35 -0
- package/extensions/browser-harness/domains/snapshot.ts +256 -0
- package/extensions/browser-harness/domains/tabs.ts +202 -0
- package/extensions/browser-harness/domains/target.ts +35 -0
- package/extensions/browser-harness/domains/viewport.ts +42 -0
- package/extensions/browser-harness/index.ts +88 -0
- package/extensions/browser-harness/profile/bind.ts +90 -0
- package/extensions/browser-harness/profile/browser-process.ts +320 -0
- package/extensions/browser-harness/profile/command.ts +189 -0
- package/extensions/browser-harness/profile/launch.ts +80 -0
- package/extensions/browser-harness/profile/list.ts +131 -0
- package/extensions/browser-harness/profile/paths.ts +103 -0
- package/extensions/browser-harness/profile/store.ts +84 -0
- package/extensions/browser-harness/prompt.ts +16 -0
- package/extensions/browser-harness/registry.ts +80 -0
- package/extensions/browser-harness/schemas/common.ts +11 -0
- package/extensions/browser-harness/schemas/parse.ts +23 -0
- package/extensions/browser-harness/setup.ts +47 -0
- package/extensions/browser-harness/state.ts +51 -0
- package/extensions/browser-harness/steel.ts +215 -0
- package/extensions/browser-harness/util/debug.ts +8 -0
- package/extensions/browser-harness/util/guards.ts +30 -0
- package/extensions/browser-harness/util/js-template.ts +12 -0
- package/extensions/browser-harness/util/mutex.ts +20 -0
- package/extensions/browser-harness/util/paths.ts +31 -0
- package/extensions/browser-harness/util/result.ts +9 -0
- package/extensions/browser-harness/util/sharp-shim.ts +42 -0
- package/extensions/browser-harness/util/tool.ts +185 -0
- package/extensions/browser-harness/util/truncate.ts +41 -0
- package/extensions/cli-resources.ts +35 -16
- package/extensions/composio/index.ts +163 -0
- package/extensions/context-memory/index.ts +58 -0
- package/extensions/lib/application-context.ts +22 -0
- package/extensions/lib/application-handler-templates.ts +17 -1
- package/extensions/lib/context-memory.ts +165 -0
- package/extensions/lib/profile-sandbox.ts +3 -0
- package/extensions/lib/workflow-harness.ts +238 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
- package/extensions/pi-console-webui/app/layout.tsx +6 -1
- package/extensions/pi-console-webui/app/login/page.tsx +5 -9
- package/extensions/pi-console-webui/app/not-found.tsx +5 -0
- package/extensions/pi-console-webui/app/page.tsx +6 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
- package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
- package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
- package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
- package/extensions/pi-console-webui/components/console-page.tsx +3 -1
- package/extensions/pi-console-webui/components/console.tsx +53 -7
- package/extensions/pi-console-webui/components/login-form.tsx +14 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
- package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
- package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
- package/extensions/profiles.ts +18 -7
- package/extensions/pulse/store.ts +5 -2
- package/extensions/sequential-workflow.ts +340 -256
- package/extensions/skill-sources/store.ts +24 -3
- package/licenses/pi-browser-harness-MIT.txt +21 -0
- package/package.json +25 -4
- package/schemas/sequential-workflow-template.schema.json +62 -0
- package/skills/browser-harness/SKILL.md +33 -0
- package/skills/composio-connections/SKILL.md +75 -0
- package/skills/sequential-workflow/SKILL.md +70 -0
- package/skills/sequential-workflow-template/SKILL.md +44 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
/** Session ownership and execution gates. Prompts explain state; these gates enforce it. */
|
|
5
|
+
export function workflowHarness(host: ExtensionAPI, db: DatabaseSync) {
|
|
6
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
7
|
+
if (!(db.prepare("PRAGMA table_info(workflows)").all() as any[]).some(c => c.name === "session_id")) {
|
|
8
|
+
db.exec("ALTER TABLE workflows ADD COLUMN session_id TEXT");
|
|
9
|
+
}
|
|
10
|
+
db.exec(`CREATE INDEX IF NOT EXISTS workflow_session_idx ON workflows(session_id);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS workflow_focus (session_id TEXT PRIMARY KEY, stack TEXT NOT NULL);
|
|
12
|
+
CREATE TABLE IF NOT EXISTS workflow_collect_checkpoint (task_id INTEGER PRIMARY KEY REFERENCES workflow_tasks(id), user_entry_id TEXT);`);
|
|
13
|
+
const context = new AsyncLocalStorage<ExtensionContext>();
|
|
14
|
+
const session = () => {
|
|
15
|
+
const id = context.getStore()?.sessionManager.getSessionId();
|
|
16
|
+
if (!id)
|
|
17
|
+
throw new Error("Workflow operations require a session context.");
|
|
18
|
+
return id;
|
|
19
|
+
};
|
|
20
|
+
const row = (id: number) => db.prepare("SELECT * FROM workflows WHERE id = ?").get(id) as any;
|
|
21
|
+
const task = (id: number) => db.prepare("SELECT * FROM workflow_tasks WHERE workflow_id = ? AND status NOT IN ('accepted', 'failed') ORDER BY position LIMIT 1").get(id) as any;
|
|
22
|
+
const active = (w: any) => w && ["running", "awaiting_user", "evaluating"].includes(w.status);
|
|
23
|
+
const save = (stack: number[]) => db.prepare("INSERT INTO workflow_focus VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET stack=excluded.stack").run(session(), JSON.stringify(stack));
|
|
24
|
+
const stack = (): number[] => {
|
|
25
|
+
const saved = db.prepare("SELECT stack FROM workflow_focus WHERE session_id = ?").get(session()) as any;
|
|
26
|
+
return (saved ? JSON.parse(saved.stack) : []).filter((id: number) => {
|
|
27
|
+
const w = row(id);
|
|
28
|
+
return active(w) && w.session_id === session();
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
const focus = () => stack().at(-1);
|
|
32
|
+
const own = (id: number) => {
|
|
33
|
+
const w = row(id);
|
|
34
|
+
if (!w || w.session_id !== session())
|
|
35
|
+
throw new Error("Workflow does not belong to this session. Use /workflow-adopt explicitly to transfer a workflow tree.");
|
|
36
|
+
return w;
|
|
37
|
+
};
|
|
38
|
+
const userEntry = () => context.getStore()!.sessionManager.getBranch().filter((e: any) => e.type === "message" && e.message.role === "user").at(-1)?.id ?? null;
|
|
39
|
+
const checkpoint = () => {
|
|
40
|
+
const id = focus();
|
|
41
|
+
const t = id ? task(id) : undefined;
|
|
42
|
+
if (t?.type === "collect" && t.status === "awaiting_user")
|
|
43
|
+
db.prepare("INSERT OR IGNORE INTO workflow_collect_checkpoint VALUES (?, ?)").run(t.id, userEntry());
|
|
44
|
+
};
|
|
45
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
46
|
+
const transaction = <T>(ctx: ExtensionContext, work: () => Promise<T> | T): Promise<T> => {
|
|
47
|
+
const next = queue.then(() => context.run(ctx, async () => {
|
|
48
|
+
db.exec("BEGIN IMMEDIATE");
|
|
49
|
+
try {
|
|
50
|
+
const result = await work();
|
|
51
|
+
save(stack());
|
|
52
|
+
checkpoint();
|
|
53
|
+
db.exec("COMMIT");
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
db.exec("ROLLBACK");
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}));
|
|
61
|
+
queue = next.catch(() => undefined);
|
|
62
|
+
return next;
|
|
63
|
+
};
|
|
64
|
+
let namedFailure = false;
|
|
65
|
+
const assertOperation = (name: string, params: any) => {
|
|
66
|
+
if (namedFailure && (name === "sequential_workflow_create" || name.endsWith("create_from_template")))
|
|
67
|
+
throw new Error("Named activation failed; a new user request is required before creating a replacement.");
|
|
68
|
+
if (params.workflowId !== undefined && name !== "sequential_workflow_cancel")
|
|
69
|
+
own(params.workflowId);
|
|
70
|
+
if (name.endsWith("record_result") || name.endsWith("evaluate")) {
|
|
71
|
+
if (focus() !== params.workflowId)
|
|
72
|
+
throw new Error("Only the focused workflow can advance. Use /workflow-focus explicitly.");
|
|
73
|
+
const t = task(params.workflowId);
|
|
74
|
+
if (!t || t.id !== params.taskId)
|
|
75
|
+
throw new Error("Stale or incorrect taskId; inspect the current task.");
|
|
76
|
+
if (name.endsWith("record_result") && t.type === "collect") {
|
|
77
|
+
const saved = db.prepare("SELECT user_entry_id FROM workflow_collect_checkpoint WHERE task_id = ?").get(t.id) as any;
|
|
78
|
+
if (!saved || !userEntry() || saved.user_entry_id === userEntry())
|
|
79
|
+
throw new Error("Collect requires a new user response after activation.");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (name === "sequential_workflow_create" || name.endsWith("create_from_template")) {
|
|
83
|
+
if (params.parentWorkflowId !== undefined)
|
|
84
|
+
own(params.parentWorkflowId);
|
|
85
|
+
if (params.parentTaskId !== undefined) {
|
|
86
|
+
const t = db.prepare("SELECT * FROM workflow_tasks WHERE id = ?").get(params.parentTaskId) as any;
|
|
87
|
+
if (!t || own(t.workflow_id).id !== focus() || task(t.workflow_id)?.id !== t.id)
|
|
88
|
+
throw new Error("Child must belong to the current focused task.");
|
|
89
|
+
}
|
|
90
|
+
else if (focus() !== undefined)
|
|
91
|
+
throw new Error("Suspend the current workflow with /workflow-suspend before creating an independent execution.");
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const tools = new Map<string, any>();
|
|
95
|
+
const api = new Proxy(host, { get(target, key) {
|
|
96
|
+
if (key !== "registerTool")
|
|
97
|
+
return Reflect.get(target, key);
|
|
98
|
+
return (definition: any) => {
|
|
99
|
+
const wrapped = { ...definition, async execute(id: string, params: any, signal: any, update: any, ctx: ExtensionContext) {
|
|
100
|
+
return transaction(ctx, async () => {
|
|
101
|
+
assertOperation(definition.name, params);
|
|
102
|
+
const result = await definition.execute(id, params, signal, update, ctx);
|
|
103
|
+
if (definition.name === "sequential_workflow_create" || definition.name.endsWith("create_from_template")) {
|
|
104
|
+
save([...stack(), result.details.workflowId]);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
});
|
|
108
|
+
} };
|
|
109
|
+
tools.set(definition.name, wrapped);
|
|
110
|
+
host.registerTool(wrapped);
|
|
111
|
+
};
|
|
112
|
+
} });
|
|
113
|
+
let namedActivation = false;
|
|
114
|
+
host.on("before_agent_start", (_event, ctx) => context.run(ctx, () => {
|
|
115
|
+
if (!namedActivation)
|
|
116
|
+
return;
|
|
117
|
+
namedActivation = false;
|
|
118
|
+
const id = focus();
|
|
119
|
+
const t = id ? task(id) : undefined;
|
|
120
|
+
if (t?.type === "collect")
|
|
121
|
+
db.prepare("UPDATE workflow_collect_checkpoint SET user_entry_id=? WHERE task_id=?").run(userEntry(), t.id);
|
|
122
|
+
}));
|
|
123
|
+
// Named activation is resolved before the model can invent a replacement plan.
|
|
124
|
+
host.on("input", async (event, ctx) => {
|
|
125
|
+
namedFailure = false;
|
|
126
|
+
const match = event.text.trim().match(/^(?:ative|execute|inicie|activate|run|start)\s+(?:(?:o|the)\s+)?sequential[ -]workflow\s+([\w.-]+)\s*$/i);
|
|
127
|
+
if (!match)
|
|
128
|
+
return;
|
|
129
|
+
try {
|
|
130
|
+
const result = await tools.get("sequential_workflow_create_from_template").execute("named-start", { path: match[1] }, undefined, undefined, ctx);
|
|
131
|
+
namedActivation = true;
|
|
132
|
+
return { action: "transform" as const, text: `${event.text}\n${result.content[0].text}\nThe named template was executed by the harness. Do not create another workflow; follow its current task.` };
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
namedFailure = true;
|
|
136
|
+
ctx.ui.notify(String(error), "error");
|
|
137
|
+
return { action: "transform" as const, text: `${event.text}\nNamed workflow activation failed: ${String(error)}. Report the failure; do not invent a substitute workflow.` };
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
let continuationKey = "";
|
|
141
|
+
let continuations = 0;
|
|
142
|
+
host.on("input", (event) => { if (event.source !== "extension")
|
|
143
|
+
continuations = 0; });
|
|
144
|
+
host.on("agent_end", (event, ctx) => context.run(ctx, () => {
|
|
145
|
+
const last = event.messages.filter(m => m.role === "assistant").at(-1);
|
|
146
|
+
if (last?.role === "assistant" && ["aborted", "error"].includes(last.stopReason))
|
|
147
|
+
return;
|
|
148
|
+
const id = focus();
|
|
149
|
+
const t = id ? task(id) : undefined;
|
|
150
|
+
if (!t || t.status === "awaiting_user" || t.status === "waiting_subworkflow")
|
|
151
|
+
return;
|
|
152
|
+
const key = `${session()}:${id}:${t.id}:${t.status}:${t.attempts}`;
|
|
153
|
+
if (key !== continuationKey) {
|
|
154
|
+
continuationKey = key;
|
|
155
|
+
continuations = 0;
|
|
156
|
+
}
|
|
157
|
+
if (continuations++ >= 2) {
|
|
158
|
+
ctx.ui.notify("Workflow remains pending: automatic continuation limit reached. Inspect the current task before resuming.", "warning");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
host.sendMessage({ customType: "sequential-workflow-state", display: false, content: `Workflow ${id}, task ${t.id} is still ${t.status}. Complete the current phase and record its transition; do not claim workflow completion.` }, { triggerTurn: true, deliverAs: "followUp" });
|
|
162
|
+
}));
|
|
163
|
+
// Pi preflights sibling calls before executing them. Allow only one call per
|
|
164
|
+
// model turn while focused (including a create call that establishes focus).
|
|
165
|
+
let dispatched = false;
|
|
166
|
+
host.on("turn_start", () => { dispatched = false; });
|
|
167
|
+
host.on("tool_call", (event, ctx) => context.run(ctx, () => {
|
|
168
|
+
const id = focus();
|
|
169
|
+
if (id === undefined && !event.toolName.startsWith("sequential_workflow_create")) {
|
|
170
|
+
dispatched = true;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (dispatched)
|
|
174
|
+
return { block: true, reason: "Sequential Workflow requires one tool call per model turn." };
|
|
175
|
+
const t = id ? task(id) : undefined;
|
|
176
|
+
const workflowTool = event.toolName.startsWith("sequential_workflow_");
|
|
177
|
+
if (t && !workflowTool && (t.type !== "action" || t.status !== "running"))
|
|
178
|
+
return { block: true, reason: `Task ${t.id} is ${t.type}/${t.status}; external tools are blocked until the required workflow transition.` };
|
|
179
|
+
try {
|
|
180
|
+
if (workflowTool)
|
|
181
|
+
assertOperation(event.toolName, event.input);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return { block: true, reason: String(error) };
|
|
185
|
+
}
|
|
186
|
+
dispatched = true;
|
|
187
|
+
}));
|
|
188
|
+
host.on("context", (event, ctx) => context.run(ctx, () => {
|
|
189
|
+
const messages = event.messages.filter(m => !(m.role === "custom" && m.customType === "sequential-workflow-state"));
|
|
190
|
+
const id = focus();
|
|
191
|
+
if (id !== undefined)
|
|
192
|
+
messages.push({ role: "custom", customType: "sequential-workflow-state", display: false, timestamp: Date.now(), content: `Session workflow focus: ${id}. Current task: ${JSON.stringify(task(id))}. Work only on this task. Pass workflowId and taskId for transitions. Collect requires a new user response. External tools are blocked outside a running Action. Use one tool per turn. User commands /workflow-suspend and /workflow-focus control focus.` });
|
|
193
|
+
return { messages };
|
|
194
|
+
}));
|
|
195
|
+
const command = (name: string, description: string, work: (args: string) => void) => host.registerCommand(name, { description, handler: async (args, ctx) => {
|
|
196
|
+
try {
|
|
197
|
+
await transaction(ctx, () => work(args.trim()));
|
|
198
|
+
ctx.ui.notify("Workflow state updated.", "info");
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
ctx.ui.notify(String(error), "error");
|
|
202
|
+
}
|
|
203
|
+
} });
|
|
204
|
+
command("workflow-suspend", "Suspend this session's focused workflow without cancelling it", () => { save([]); });
|
|
205
|
+
command("workflow-focus", "Focus an owned workflow by ID", args => {
|
|
206
|
+
let w = own(Number(args));
|
|
207
|
+
if (!active(w))
|
|
208
|
+
throw new Error("Workflow is terminal.");
|
|
209
|
+
const chain = [w.id];
|
|
210
|
+
while (task(w.id)?.status === "waiting_subworkflow") {
|
|
211
|
+
w = own(task(w.id).child_workflow_id);
|
|
212
|
+
if (chain.includes(w.id) || !active(w))
|
|
213
|
+
throw new Error("Invalid child dependency.");
|
|
214
|
+
chain.push(w.id);
|
|
215
|
+
}
|
|
216
|
+
save(chain);
|
|
217
|
+
});
|
|
218
|
+
command("workflow-adopt", "Explicitly transfer a root workflow and its descendants to this session", args => {
|
|
219
|
+
const w = row(Number(args));
|
|
220
|
+
if (!active(w) || w.parent_workflow_id)
|
|
221
|
+
throw new Error("Specify an active root workflow.");
|
|
222
|
+
const ids = (db.prepare("WITH RECURSIVE tree(id) AS (SELECT ? UNION SELECT w.id FROM workflows w JOIN tree t ON w.parent_workflow_id=t.id) SELECT id FROM tree").all(w.id) as any[]).map(x => x.id);
|
|
223
|
+
for (const id of ids) {
|
|
224
|
+
db.prepare("UPDATE workflows SET session_id=? WHERE id=?").run(session(), id);
|
|
225
|
+
db.prepare("INSERT INTO workflow_events(workflow_id,phase,payload) VALUES (?, 'adopted', ?)").run(id, JSON.stringify({ fromSession: w.session_id, toSession: session() }));
|
|
226
|
+
}
|
|
227
|
+
let leaf = w.id;
|
|
228
|
+
const chain = [leaf];
|
|
229
|
+
while (task(leaf)?.status === "waiting_subworkflow") {
|
|
230
|
+
leaf = task(leaf).child_workflow_id;
|
|
231
|
+
if (chain.includes(leaf))
|
|
232
|
+
throw new Error("Invalid cycle.");
|
|
233
|
+
chain.push(leaf);
|
|
234
|
+
}
|
|
235
|
+
save(chain);
|
|
236
|
+
});
|
|
237
|
+
return { api, session, own, transaction, context, checkpoint };
|
|
238
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { notFound } from "next/navigation";
|
|
2
2
|
import { ConsolePage } from "@/components/console-page";
|
|
3
|
-
|
|
3
|
+
import { sectionMetadata } from "@/lib/page-metadata";
|
|
4
|
+
const sections = new Set(["chat", "profiles", "settings", "env", "soul", "context-memory", "guardrails", "skills", "tools", "packages", "pulses", "extensions", "applications", "api-server", "pi-console-webui", "skill-sources"]);
|
|
5
|
+
export async function generateMetadata({ params, searchParams }: { params: Promise<{ section: string }>; searchParams: Promise<{ profile?: string }> }) {
|
|
6
|
+
return sectionMetadata((await params).section, (await searchParams).profile);
|
|
7
|
+
}
|
|
8
|
+
|
|
4
9
|
export default async function SectionPage({ params, searchParams }: { params: Promise<{ section: string }>; searchParams: Promise<{ profile?: string }> }) { const { section } = await params; if (!sections.has(section)) notFound(); return <ConsolePage section={section} profile={(await searchParams).profile}/>; }
|
|
@@ -1,2 +1,17 @@
|
|
|
1
1
|
import { ConsolePage } from "@/components/console-page";
|
|
2
|
+
import { pageMetadata } from "@/lib/page-metadata";
|
|
3
|
+
import { piGet } from "@/lib/pi-api";
|
|
4
|
+
|
|
5
|
+
type ApplicationResponse = { application: { name?: string } };
|
|
6
|
+
|
|
7
|
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
|
8
|
+
const { slug } = await params;
|
|
9
|
+
try {
|
|
10
|
+
const { application } = await piGet<ApplicationResponse>(`applications/${encodeURIComponent(slug)}`);
|
|
11
|
+
return pageMetadata(application.name?.trim() || `Application: ${slug}`);
|
|
12
|
+
} catch {
|
|
13
|
+
return pageMetadata(`Application: ${slug}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
2
17
|
export default async function ApplicationPage({ params }: { params: Promise<{ slug: string }> }) { return <ConsolePage section="applications" applicationSlug={(await params).slug}/>; }
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import "./globals.css";
|
|
2
2
|
import { ToastProvider } from "@/components/toast";
|
|
3
3
|
import { ConsoleStateProvider } from "@/components/console-state";
|
|
4
|
-
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: { default: "π Console", template: "%s | π Console" },
|
|
8
|
+
description: "Pi API console",
|
|
9
|
+
};
|
|
5
10
|
export default function Layout({ children }: Readonly<{ children: React.ReactNode }>) { return <html lang="pt-BR"><body><ToastProvider><ConsoleStateProvider>{children}</ConsoleStateProvider></ToastProvider></body></html>; }
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { Input } from "@/components/ui/input";
|
|
6
|
-
import { Loader2 } from "lucide-react";
|
|
1
|
+
import { LoginForm } from "@/components/login-form";
|
|
2
|
+
|
|
3
|
+
export const metadata = { title: "Sign in" };
|
|
4
|
+
|
|
7
5
|
export default function Login() {
|
|
8
|
-
|
|
9
|
-
async function submit(event: React.FormEvent) { event.preventDefault(); if (loading) return; setError(""); setLoading(true); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ username, password }) }); if (response.ok) router.push("/"); else { setError("Invalid credentials."); window.setTimeout(() => setError(""), 4_000); } } catch { setError("Unable to sign in."); window.setTimeout(() => setError(""), 4_000); } finally { setLoading(false); } }
|
|
10
|
-
return <main className="grid min-h-screen place-items-center p-4"><form onSubmit={submit} className="w-full max-w-sm space-y-4 rounded-xl border border-zinc-800 bg-zinc-950 p-6 shadow-xl"><h1 className="text-2xl font-bold">Pi Console</h1><p className="text-sm text-zinc-400">Sign in to manage Pi.</p><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" autoFocus /><Input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="Password" />{error && <p className="text-sm text-red-400">{error}</p>}<Button className="w-full" disabled={loading}>{loading ? <><Loader2 className="mr-2 animate-spin" size={16}/>Signing in…</> : "Sign in"}</Button></form></main>;
|
|
6
|
+
return <LoginForm/>;
|
|
11
7
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const metadata = { title: "Page not found" };
|
|
2
|
+
|
|
3
|
+
export default function NotFound() {
|
|
4
|
+
return <main className="grid min-h-screen place-items-center p-6"><div className="text-center"><h1 className="text-2xl font-semibold">Page not found</h1><p className="mt-2 text-sm text-zinc-500">The requested Console page does not exist.</p></div></main>;
|
|
5
|
+
}
|
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
import { ConsolePage } from "@/components/console-page";
|
|
2
|
+
import { sectionMetadata } from "@/lib/page-metadata";
|
|
3
|
+
|
|
4
|
+
export async function generateMetadata({ searchParams }: { searchParams: Promise<{ profile?: string }> }) {
|
|
5
|
+
return sectionMetadata("overview", (await searchParams).profile);
|
|
6
|
+
}
|
|
7
|
+
|
|
2
8
|
export default async function Page({ searchParams }: { searchParams: Promise<{ profile?: string }> }) { return <ConsolePage section="overview" profile={(await searchParams).profile}/>; }
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { redirect } from "next/navigation";
|
|
2
2
|
import { authenticated } from "@/lib/auth";
|
|
3
3
|
import { TerminalClient } from "@/components/terminal-client";
|
|
4
|
+
|
|
5
|
+
export const metadata = { title: "Terminal" };
|
|
6
|
+
|
|
4
7
|
export default async function TerminalPage() { if (!(await authenticated())) redirect("/login"); return <TerminalClient/>; }
|
|
@@ -11,14 +11,14 @@ import { Input } from "@/components/ui/input";
|
|
|
11
11
|
import { useToast } from "@/components/toast";
|
|
12
12
|
import { applicationHandlerTemplate } from "../../lib/application-handler-templates";
|
|
13
13
|
|
|
14
|
-
type HandlerType = "inbound" | "outbound" | "transform";
|
|
14
|
+
type HandlerType = "inbound" | "outbound" | "transform" | "context-memory";
|
|
15
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." }];
|
|
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." }, { value: "context-memory", label: "Context Memory", description: "Resolves private persistent context for an Application identity." }];
|
|
17
17
|
const validName = (value: string) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(value);
|
|
18
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
19
|
const templateFor = (type: HandlerType) => applicationHandlerTemplate(type);
|
|
20
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";
|
|
21
|
+
const typeForPath = (path: string): HandlerType => path.startsWith("transforms/") ? "transform" : path.startsWith("context-memory/") ? "context-memory" : fileName(path) === "outbound" ? "outbound" : "inbound";
|
|
22
22
|
|
|
23
23
|
export function ApplicationHandlerEditor({ name, embedded = false }: { name: string; embedded?: boolean }) {
|
|
24
24
|
const { toast } = useToast(); const base = `applications/${name}`;
|
|
@@ -30,7 +30,7 @@ export function ApplicationHandlerEditor({ name, embedded = false }: { name: str
|
|
|
30
30
|
}, [name, selected]);
|
|
31
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
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"); } };
|
|
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` : handlerType === "context-memory" ? `context-memory/${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
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]); }
|
|
@@ -3,15 +3,16 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
3
3
|
import CodeMirror from "@uiw/react-codemirror";
|
|
4
4
|
import { json as jsonLanguage } from "@codemirror/lang-json";
|
|
5
5
|
import Link from "next/link";
|
|
6
|
-
import { ArrowLeft, Check, Copy, Filter, Trash2 } from "lucide-react";
|
|
6
|
+
import { ArrowLeft, Check, Copy, Filter, Loader2, Trash2 } from "lucide-react";
|
|
7
7
|
import { Button } from "@/components/ui/button";
|
|
8
8
|
import { Card, CardTitle } from "@/components/ui/card";
|
|
9
|
-
|
|
9
|
+
import { useToast } from "@/components/toast";
|
|
10
|
+
type Stage = { id: string; label: string; type: string; payload?: unknown; input?: unknown; output?: unknown; stdout: string[]; stderr: string[]; error?: string; completed?: boolean };
|
|
10
11
|
type Call = { id: string; receivedAt: string; origin: string; status: string; stages: Stage[]; error?: string };
|
|
11
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(); };
|
|
12
13
|
const json = (value: unknown) => JSON.stringify(value, null, 2) ?? "No data recorded.";
|
|
13
14
|
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 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
16
|
const stageColor = (type: string) => stageFilters.find((stage) => stage.value === type)?.className ?? "border-zinc-300 bg-zinc-50 text-zinc-700";
|
|
16
17
|
function JsonView({ value }: { value: unknown }) {
|
|
17
18
|
const [copied, setCopied] = useState(false);
|
|
@@ -28,11 +29,14 @@ function FlowArrow({ output, nextInput }: { output: unknown; nextInput: unknown
|
|
|
28
29
|
|
|
29
30
|
export function ApplicationLogs({ name, embedded = false }: { name: string; embedded?: boolean }) {
|
|
30
31
|
const base = `applications/${name}`;
|
|
31
|
-
const
|
|
32
|
+
const { toast } = useToast();
|
|
33
|
+
const [calls, setCalls] = useState<Call[]>([]), [selected, setSelected] = useState<Call>(), [stage, setStage] = useState<Stage>(), [loading, setLoading] = useState(true), [loadingDetail, setLoadingDetail] = useState(false), [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
34
|
const toggle = (value: string, setter: React.Dispatch<React.SetStateAction<string[]>>) => setter((items) => items.includes(value) ? items.filter((item) => item !== value) : [...items, value]);
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
+
const selectCall = async (call: Call) => { try { setLoadingDetail(true); const data = await api(`${base}/logs/calls/${encodeURIComponent(call.id)}`) as { call: Call }; setSelected(data.call); setStage(data.call.stages.find((item) => item.error) ?? data.call.stages.at(-1) ?? data.call.stages[0]); } catch (error) { toast((error as Error).message, "error"); } finally { setLoadingDetail(false); } };
|
|
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
|
+
useEffect(() => { void load(); }, [name, start, end]);
|
|
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]);
|
|
35
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]);
|
|
36
|
-
const current = selected && filtered.find((call) => call.id === selected.id) ? selected :
|
|
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>;
|
|
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>;
|
|
38
42
|
}
|
|
@@ -8,7 +8,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[] };
|
|
11
|
+
type Settings = { inboundHandler: string; outboundHandler?: string; transformHandlers?: string[]; 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(); };
|
|
@@ -48,6 +48,10 @@ export function ApplicationSettings({ slug }: { slug: string }) {
|
|
|
48
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
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
50
|
</Card>
|
|
51
|
+
<Card className="space-y-4">
|
|
52
|
+
<div><CardTitle>Message coalescing</CardTitle><p className="mt-1 text-sm text-zinc-500">Collects messages from the same resolved identity after inbound handling. Only the last message after the silence interval dispatches the combined content to Pi.</p></div>
|
|
53
|
+
<div className="grid gap-4 md:grid-cols-2"><label className="flex items-center gap-2 text-sm font-medium"><Switch checked={settings.messageCoalescing?.enabled === true} onCheckedChange={(enabled) => updateSettings({ messageCoalescing: { enabled, silenceDebounceSeconds: settings.messageCoalescing?.silenceDebounceSeconds ?? 10 } })}/>Enabled</label><label className="text-sm font-medium">Silence debounce (seconds)<Input className="mt-1" type="number" min="1" max="3600" disabled={!settings.messageCoalescing?.enabled} value={settings.messageCoalescing?.silenceDebounceSeconds ?? 10} onChange={(event) => updateSettings({ messageCoalescing: { enabled: settings.messageCoalescing?.enabled === true, silenceDebounceSeconds: Number(event.target.value) } })}/></label></div>
|
|
54
|
+
</Card>
|
|
51
55
|
<Card className="space-y-4">
|
|
52
56
|
<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
57
|
<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>
|
|
@@ -7,6 +7,7 @@ import { Bot, Check, ChevronDown, ChevronRight, CircleStop, Copy, Loader2, Messa
|
|
|
7
7
|
import { Button } from "@/components/ui/button";
|
|
8
8
|
import { Card } from "@/components/ui/card";
|
|
9
9
|
import { Input } from "@/components/ui/input";
|
|
10
|
+
import { useToast } from "@/components/toast";
|
|
10
11
|
|
|
11
12
|
type Session = { id: string; name?: string; preview: string; updatedAt: string; messageCount: number };
|
|
12
13
|
type Block = { type: "text" | "thinking"; text: string };
|
|
@@ -16,11 +17,11 @@ const ago = (date: string) => { const minutes = Math.max(0, Math.round((Date.now
|
|
|
16
17
|
const text = (entry: Entry) => entry.content?.filter((block) => block.type === "text").map((block) => block.text).join("\n") ?? "";
|
|
17
18
|
|
|
18
19
|
export function ChatWorkspace({ profile }: { profile: string }) {
|
|
19
|
-
const searchParams = useSearchParams(); const router = useRouter();
|
|
20
|
+
const searchParams = useSearchParams(); const router = useRouter(); const { toast } = useToast();
|
|
20
21
|
// Profile selection updates the route asynchronously. Never resolve a
|
|
21
22
|
// session from the previous URL against the newly selected profile.
|
|
22
23
|
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 [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);
|
|
24
25
|
const abortRef = useRef<AbortController | undefined>(undefined); const bottom = useRef<HTMLDivElement>(null); const conversation = useRef<HTMLDivElement>(null); const loadingSession = useRef<string | undefined>(undefined);
|
|
25
26
|
const loadSessions = async () => { const data = await (await call(`profiles/${profile}/sessions`)).json() as { sessions: Session[] }; setSessions(data.sessions); return data.sessions; };
|
|
26
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" })); };
|
|
@@ -33,10 +34,12 @@ export function ChatWorkspace({ profile }: { profile: string }) {
|
|
|
33
34
|
const activeSession = sessions.find((session) => session.id === selected);
|
|
34
35
|
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
36
|
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
|
+
function create() { setNewMenu(false); router.push(`/chat?profile=${encodeURIComponent(profile)}`); loadingSession.current = undefined; setSelected(undefined); setCopiedSessionId(false); setEditingName(false); setEntries([]); setHasMore(false); setNextBefore(null); setStreaming(""); setMessage(""); setError(""); }
|
|
38
|
+
async function openApplicationDialog() { setNewMenu(false); try { const data = await (await call("applications")).json() as { applications: Array<{ slug: string; name: string; enabled?: boolean }> }; setApplications(data.applications.filter((item) => item.enabled !== false)); setApplication((current) => current || data.applications[0]?.slug || ""); setIdentityKey(""); setApplicationDialog(true); } catch (cause) { setError((cause as Error).message); } }
|
|
39
|
+
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); } }
|
|
37
40
|
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
41
|
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
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 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
|
|
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>;
|
|
41
44
|
}
|
|
42
45
|
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>; }
|
|
@@ -17,7 +17,9 @@ export async function ConsolePage({ section, applicationSlug, profile = "default
|
|
|
17
17
|
else if (section === "guardrails") initial.guardrails = await piGet(`profiles/${profile}/guardrails`);
|
|
18
18
|
else if (section === "packages") initial.packages = await piGet(`profiles/${profile}/packages`);
|
|
19
19
|
else if (section === "pulses") initial.pulses = await piGet(`profiles/${profile}/pulses`);
|
|
20
|
-
|
|
20
|
+
// Tool discovery can require booting a sandboxed Pi runtime. Let the client
|
|
21
|
+
// render the Tools workspace immediately and load its catalog asynchronously.
|
|
22
|
+
else if (section === "skills" || section === "extensions") initial.resources = await piGet(`profiles/${profile}/resources/${section}`);
|
|
21
23
|
} catch (error) { initial.error = error instanceof Error ? error.message : String(error); }
|
|
22
24
|
return <Console section={section} applicationSlug={applicationSlug} initial={initial}/>;
|
|
23
25
|
}
|