ghostykit 0.3.2 → 0.4.0

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 CHANGED
@@ -1,12 +1,17 @@
1
- # Piloto UI · `ghostykit`
1
+ # Ghosty Autopilot · `ghostykit`
2
2
 
3
- **An in-app copilot for React.** Piloto UI is the agent chat from
3
+ **An autopilot for your React app, not a copilot or kit.** You don't drive: you say where you're going and
4
+ the agent flies the app for you, stopping only to ask before anything risky. Ghosty Autopilot is the agent chat from
4
5
  [Ghosty Studio](https://www.ghosty.studio) (`/c`), packaged as a library: same components, same
5
6
  markup, same look ([side by side](https://raw.githubusercontent.com/blissito/ghostykit/main/docs/screenshots/parity/desktop-done-gs-vs-ghostykit.png)). You drop it into an app you already
6
7
  have. It sees what the user has on screen, streams what the agent says and does, and **stops to ask
7
8
  before anything risky** (human in the loop).
8
9
 
9
- > El copiloto de tu app: el chat de Ghosty, probado en producción, como biblioteca. Español por default, inglés incluido.
10
+ It speaks **[AG-UI](https://docs.ag-ui.com)**, the open agent ↔ app protocol, so it works with
11
+ **LangGraph**, Mastra, CrewAI, Pydantic AI, Google ADK, or your own backend. No adapter of ours in
12
+ between.
13
+
14
+ > El piloto de tu app, no el copiloto: tú no manejas, vas de pasajero y le dices a dónde ir. Es el chat de Ghosty, probado en producción, como biblioteca. Español por default, inglés incluido.
10
15
 
11
16
  **[Open the playground →](https://blissito.github.io/ghostykit/)** (a fake backend, a real chat)
12
17
 
@@ -18,9 +23,13 @@ before anything risky** (human in the loop).
18
23
  - **Agent steps**: every tool call as a line ("Read 2 files", "Searched the web"). Steps fold into a card when the turn ends, and you can show your own labels and icons.
19
24
  - **The permission pass**: risky actions arrive as a card with a countdown, *Approve* and *Reject*. A stamp lands when you answer, and the decision stays recorded under the reply.
20
25
  - **Context and references**: what the user is looking at travels with every turn. Rows from your app can be "@-mentioned" into the message.
21
- - **Resilience**: reconnects after a dropped network or a deploy, re-attaches to a running turn, retries while the server drains, and offers *Resume* when a turn is cut short.
22
- - **The composer**: attachments (you provide `upload`), voice notes (you provide `transcribe`), stop, model picker and reply-option buttons.
23
- - **Solid core**: the whole chat state is a pure reducer, `reducePilotEvent(state, name, data)`, tested by replaying real SSE sessions.
26
+ - **Resilience**: the thread lives on your server. A dropped connection or a reload asks for it again
27
+ and repaints from `MESSAGES_SNAPSHOT`, pending approvals included; a deploy (`503`) is retried with
28
+ the same run.
29
+ - **The composer**: attachments (you provide `upload`), voice notes (you provide `transcribe`), stop and reply-option buttons.
30
+ - **Solid core**: the whole chat state is a pure reducer over AG-UI events, `reduceAgUiEvent(state, event)`, tested by replaying streams recorded from a real LangGraph agent.
31
+
32
+ > **En español:** Ghosty Autopilot le pone piloto a tu app con un solo componente: la persona dice qué quiere y el agente lo hace por ella. Tus usuarios ven la respuesta escribirse en vivo y cada paso del agente («Leí 2 archivos»), y aprueban o rechazan cualquier acción riesgosa con una tarjeta antes de que ocurra. El agente sabe qué pantalla y qué registro está viendo la persona. Habla AG-UI, el protocolo abierto entre agentes y apps, así que funciona con LangGraph, Mastra, CrewAI o tu propio backend. El chat se recupera solo de cortes de red y de deploys, y trae adjuntos, notas de voz y botón para detener. Por dentro, todo el estado es una función pura probada con sesiones reales, así que se puede actualizar sin miedo.
24
33
 
25
34
  ## 30-second quickstart
26
35
 
@@ -29,70 +38,47 @@ npm i ghostykit motion streamdown
29
38
  ```
30
39
 
31
40
  ```tsx
32
- import { Piloto, useSseTransport } from "ghostykit";
41
+ import { Piloto, createAgUiTransport } from "ghostykit";
33
42
  import "ghostykit/styles.css";
34
- import "ghostykit/fonts.css"; // optional: the house typefaces
35
43
 
36
- export function Copilot() {
37
- const transport = useSseTransport("/api/copilot/conversations/123");
44
+ const transport = createAgUiTransport({ url: "/api/agent" }); // any AG-UI endpoint
45
+
46
+ export function Pilot() {
38
47
  return <Piloto transport={transport} />;
39
48
  }
40
49
  ```
41
50
 
42
51
  `<Piloto transport={…} />` works with nothing else. Give its container a height: Piloto fills it.
52
+ The product is **Ghosty Autopilot**; the component is `<Piloto>`.
43
53
 
44
- ## Transports
45
-
46
- A transport is how Piloto talks to your agent. It is a small interface: `subscribe`, `send`, `cancel`,
47
- plus optional `setModel`, `respondPermission` and `history`. Two are included.
48
-
49
- ### 1. Split stream: `createSseTransport(base)` (Ghosty's contract)
50
-
51
- The event stream stays open (`EventSource`) and messages go by POST.
54
+ ## Works with LangGraph (and any AG-UI agent)
52
55
 
53
- | | |
54
- |---|---|
55
- | `GET {base}/events` | Named SSE events: `started`, `status`, `chunk`, `thought`, `tool`, `permission`, `permission-resolved`, `usage`, `models`, `title`, `done{reposo?,interrumpido?,ultimo?}`, `error`… |
56
- | `POST {base}/messages` | `{content, images, primerTurno, retomar?, context?}` → `{inyectado?, estado?, turnId?}` |
57
- | `POST {base}/cancel` · `/model` · `/permission` | stop, switch model, answer a card |
56
+ Serve your LangGraph graph with its official AG-UI adapter and point Piloto at it:
58
57
 
59
- Messages sent during a running turn are injected into it (steer). A silent connection during a turn is
60
- reopened, and the server says whether the turn continued, finished or was cut short.
58
+ ```ts
59
+ // server: one POST endpoint (full file in examples/langgraph/src/server.ts)
60
+ import { LangGraphAgent } from "@ag-ui/langgraph";
61
+ import { EventEncoder } from "@ag-ui/encoder";
61
62
 
62
- ### 2. POST returns the stream: `createPostStreamTransport({ url })` (EasyBits' contract)
63
-
64
- The POST that starts a turn *is* the SSE. A GET re-attaches to a running turn.
65
-
66
- | | |
67
- |---|---|
68
- | `POST {url}` | `{text, context?, turnId, continueTurnId?}` → SSE `started`, `chunk`, `tool{title,status}`, `permission{…, expiresAt}`, `done{turnId,text}`, `error`, `capacity`, `interrupted{turnId,canContinue}`, `idle` |
69
- | `GET {url}?attach=1` | `started`, one `permission` per open card, `snapshot{turnId,text,tools}`, then live |
70
- | `GET {url}` | `{messages:[{role,text}]}`, loaded as the thread |
71
-
72
- A 503 (deploy draining) is retried with the same `turnId`. A 409 `turn_busy` re-attaches to the turn
73
- that is already running. Approvals usually go through your own route:
63
+ const agent = new LangGraphAgent({ graphId: "crons", deploymentUrl: "http://localhost:2024", emitInterruptOutcome: true });
64
+ // POST /agent → agent.run(runAgentInput).subscribe(e => res.write(new EventEncoder().encode(e)))
65
+ ```
74
66
 
75
67
  ```tsx
76
- <Piloto
77
- transport={useMemo(() => createPostStreamTransport({ url: "/api/v2/admin/copilot/chat" }), [])}
78
- context={{ page: "crons", job: selectedJob }}
79
- onPermission={async (id, optionId) => {
80
- const res = await fetch(`/api/v2/admin/agent-actions/${id}`, {
81
- method: "POST",
82
- headers: { "content-type": "application/json" },
83
- body: JSON.stringify({ decision: optionId === "approve" ? "approve" : "reject" }),
84
- });
85
- if (res.status === 409 || res.status === 404) return { resolved: false }; // expired or already answered
86
- if (!res.ok) throw new Error("approval failed");
87
- return { resolved: true, detail: (await res.json()).status };
88
- }}
89
- />
68
+ <Piloto transport={createAgUiTransport({ url: "http://localhost:8000/agent" })} />
90
69
  ```
91
70
 
92
- ### 3. Your own
71
+ `interrupt()` in your graph becomes the approval card; *Approve*/*Reject* resume it
72
+ (`Command(resume={approved})` on the graph side). A runnable example with a tool and an approval is in
73
+ [`examples/langgraph`](examples/langgraph); the playground can point at it with
74
+ `?backend=agui&url=http://localhost:8000/agent`.
75
+
76
+ ## The contract
93
77
 
94
- Implement `PilotTransport` and emit the same event names (see `ghostykit/core` types). If you only need
95
- the state, use the reducer directly: `ghostykit/core` has no React dependency.
78
+ One `POST <url>` with an AG-UI `RunAgentInput`, answered by a stream of standard AG-UI events. That's
79
+ all a backend serves. **[docs/protocol.md](docs/protocol.md)** is the exact spec: request, events,
80
+ approvals (`RUN_FINISHED.outcome: interrupt` + `resume`), reopening a thread (`MESSAGES_SNAPSHOT`),
81
+ stopping and errors.
96
82
 
97
83
  ## Make it yours
98
84
 
@@ -109,7 +95,8 @@ const ref = useRef<PilotoHandle>(null);
109
95
  welcome="How can I help with your crons?"
110
96
  suggestions={["Why did the monitor fail?"]}
111
97
  draft={{ text: "Why did the monitor fail?", key: row.id }} // prefilled + focused, not sent
112
- context={() => ({ screen: "crons", selected })} // sent with EVERY turn
98
+ threadId={conversationId} // reopen a stored thread
99
+ context={() => ({ screen: "crons", selected })} // AG-UI `context`, every run
113
100
  toolVerbs={{ run_cron: { one: "Ran a cron", many: "Ran %n crons", running: "Running the cron…" } }}
114
101
  upload={async (file) => ({ url: await myUpload(file) })}
115
102
  transcribe={async (audio) => myWhisper(audio)}
@@ -122,9 +109,9 @@ ref.current?.send("Why did this fail?"); // ask on the user'
122
109
  ```
123
110
 
124
111
  Other options: `strings`, `toolIcons`, `formatUserText`, `markdown`, `features`
125
- (`attachments`, `retry`, `copy`, `usage`, `models`, `replyOptions`, `duration`), `references` /
112
+ (`attachments`, `retry`, `copy`, `usage`, `replyOptions`, `duration`), `references` /
126
113
  `onReferencesChange`, `voiceNoteText`, `placeholder`, `avatar`, `initialTurns`, and the callbacks
127
- `onTurnStart`, `onTitle`, `onNeedsLogin`, `onDisconnected`, `onEvent`, `onSend`. To read the state
114
+ `onTurnStart`, `onTitle`, `onEvent` (every raw AG-UI event: STATE_*, CUSTOM…), `onSend`. To read the state
128
115
  outside the chat, call `usePilot(transport)` yourself and pass the result as `pilot`. Every piece is
129
116
  exported (`AgentBubble`, `AgentSteps`, `Composer`, `ConfirmCard`, `Markdown`, …) if you want to build
130
117
  your own layout.
@@ -0,0 +1,85 @@
1
+ // src/transport/index.ts
2
+ var PilotHttpError = class extends Error {
3
+ constructor(message, status, code, retryAfter) {
4
+ super(message);
5
+ this.name = "PilotHttpError";
6
+ this.status = status;
7
+ this.code = code;
8
+ this.retryAfter = retryAfter;
9
+ }
10
+ };
11
+ async function readSse(body, onMessage) {
12
+ const reader = body.getReader();
13
+ const decoder = new TextDecoder();
14
+ let buffer = "";
15
+ let event = "message";
16
+ let data = [];
17
+ const flush = () => {
18
+ if (data.length) onMessage({ event, data: data.join("\n") });
19
+ event = "message";
20
+ data = [];
21
+ };
22
+ for (; ; ) {
23
+ const { value, done } = await reader.read();
24
+ if (done) break;
25
+ buffer += decoder.decode(value, { stream: true });
26
+ let nl;
27
+ while ((nl = buffer.search(/\r\n|\r|\n/)) >= 0) {
28
+ const line = buffer.slice(0, nl);
29
+ buffer = buffer.slice(nl + (buffer[nl] === "\r" && buffer[nl + 1] === "\n" ? 2 : 1));
30
+ if (line === "") flush();
31
+ else if (line.startsWith(":")) continue;
32
+ else {
33
+ const i = line.indexOf(":");
34
+ const field = i < 0 ? line : line.slice(0, i);
35
+ const val = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
36
+ if (field === "event") event = val;
37
+ else if (field === "data") data.push(val);
38
+ }
39
+ }
40
+ }
41
+ flush();
42
+ }
43
+ function createAgUiTransport(opts) {
44
+ const doFetch = (...a) => (opts.fetch ?? fetch)(...a);
45
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
46
+ const retries = opts.retries ?? 6;
47
+ return {
48
+ async run(input, onEvent, signal) {
49
+ const body = JSON.stringify(input);
50
+ for (let attempt = 0; ; attempt++) {
51
+ const res = await doFetch(opts.url, {
52
+ method: "POST",
53
+ credentials: opts.credentials ?? "same-origin",
54
+ headers: { "content-type": "application/json", accept: "text/event-stream", ...opts.headers },
55
+ body,
56
+ signal
57
+ });
58
+ const retryAfter = Number(res.headers.get("Retry-After")) || void 0;
59
+ if (res.status === 503 && attempt < retries) {
60
+ await sleep((retryAfter ?? 3) * 1e3);
61
+ continue;
62
+ }
63
+ if (!res.ok) {
64
+ const json = await res.json().catch(() => null);
65
+ throw new PilotHttpError(json?.message ?? json?.error ?? `Error ${res.status}`, res.status, json?.error, retryAfter);
66
+ }
67
+ if (!res.body) return;
68
+ await readSse(res.body, ({ data }) => {
69
+ if (!data) return;
70
+ try {
71
+ onEvent(JSON.parse(data));
72
+ } catch {
73
+ }
74
+ });
75
+ return;
76
+ }
77
+ }
78
+ };
79
+ }
80
+
81
+ export {
82
+ PilotHttpError,
83
+ readSse,
84
+ createAgUiTransport
85
+ };