ghostykit 0.0.0-stage → 0.2.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 +173 -2
- package/dist/chunks/chunk-6U545WJG.js +36 -0
- package/dist/chunks/chunk-D6JXJWYM.js +373 -0
- package/dist/chunks/chunk-O6FCE4CS.js +670 -0
- package/dist/core/index.d.ts +7 -0
- package/dist/core/index.js +74 -0
- package/dist/core/narration.d.ts +17 -0
- package/dist/core/options.d.ts +14 -0
- package/dist/core/reconcile.d.ts +7 -0
- package/dist/core/reducer.d.ts +54 -0
- package/dist/core/steps.d.ts +30 -0
- package/dist/core/text.d.ts +15 -0
- package/dist/core/types.d.ts +236 -0
- package/dist/fonts.css +3 -0
- package/dist/pointer/index.d.ts +9 -0
- package/dist/pointer/index.js +27 -0
- package/dist/react/Bubble.d.ts +41 -0
- package/dist/react/Chips.d.ts +13 -0
- package/dist/react/Composer.d.ts +45 -0
- package/dist/react/ConfirmCard.d.ts +17 -0
- package/dist/react/InlineMarkdown.d.ts +5 -0
- package/dist/react/Markdown.d.ts +12 -0
- package/dist/react/Piloto.d.ts +92 -0
- package/dist/react/Status.d.ts +19 -0
- package/dist/react/Steps.d.ts +22 -0
- package/dist/react/TextAtPace.d.ts +29 -0
- package/dist/react/context.d.ts +34 -0
- package/dist/react/index.d.ts +15 -0
- package/dist/react/index.js +2799 -0
- package/dist/react/strings.d.ts +89 -0
- package/dist/react/usePilot.d.ts +67 -0
- package/dist/styles.css +2364 -0
- package/dist/transport/index.d.ts +32 -0
- package/dist/transport/index.js +20 -0
- package/dist/transport/postStream.d.ts +44 -0
- package/dist/transport/types.d.ts +42 -0
- package/package.json +91 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Héctor BlisS (FixterGeek)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,174 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Piloto UI · `ghostykit`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**An in-app copilot for React.** Piloto UI is the agent chat from
|
|
4
|
+
[Ghosty Studio](https://www.ghosty.studio), packaged as a library. You drop it into an app you already
|
|
5
|
+
have. It sees what the user has on screen, streams what the agent says and does, and **stops to ask
|
|
6
|
+
before anything risky** (human in the loop).
|
|
7
|
+
|
|
8
|
+
> El copiloto de tu app: el chat de Ghosty, probado en producción, como biblioteca. Español por default, inglés incluido.
|
|
9
|
+
|
|
10
|
+
**[Open the playground →](https://blissito.github.io/ghostykit/)** (a fake backend, a real chat)
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
## What you get
|
|
15
|
+
|
|
16
|
+
- **Live answers**: markdown streamed at a readable pace (Streamdown), with code blocks and tables. External links ask for confirmation before opening.
|
|
17
|
+
- **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.
|
|
18
|
+
- **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.
|
|
19
|
+
- **Context and references**: what the user is looking at travels with every turn. Rows from your app can be "@-mentioned" into the message.
|
|
20
|
+
- **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.
|
|
21
|
+
- **The composer**: attachments (you provide `upload`), voice notes (you provide `transcribe`), stop, model picker and reply-option buttons.
|
|
22
|
+
- **Solid core**: the whole chat state is a pure reducer, `reducePilotEvent(state, name, data)`, tested by replaying real SSE sessions.
|
|
23
|
+
|
|
24
|
+
## 30-second quickstart
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm i ghostykit motion streamdown
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { Piloto, useSseTransport } from "ghostykit";
|
|
32
|
+
import "ghostykit/styles.css";
|
|
33
|
+
import "ghostykit/fonts.css"; // optional: the house typefaces
|
|
34
|
+
|
|
35
|
+
export function Copilot() {
|
|
36
|
+
const transport = useSseTransport("/api/copilot/conversations/123");
|
|
37
|
+
return <Piloto transport={transport} />;
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`<Piloto transport={…} />` works with nothing else. Give its container a height: Piloto fills it.
|
|
42
|
+
|
|
43
|
+
## Transports
|
|
44
|
+
|
|
45
|
+
A transport is how Piloto talks to your agent. It is a small interface: `subscribe`, `send`, `cancel`,
|
|
46
|
+
plus optional `setModel`, `respondPermission` and `history`. Two are included.
|
|
47
|
+
|
|
48
|
+
### 1. Split stream: `createSseTransport(base)` (Ghosty's contract)
|
|
49
|
+
|
|
50
|
+
The event stream stays open (`EventSource`) and messages go by POST.
|
|
51
|
+
|
|
52
|
+
| | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `GET {base}/events` | Named SSE events: `started`, `status`, `chunk`, `thought`, `tool`, `permission`, `permission-resolved`, `usage`, `models`, `title`, `done{reposo?,interrumpido?,ultimo?}`, `error`… |
|
|
55
|
+
| `POST {base}/messages` | `{content, images, primerTurno, retomar?, context?}` → `{inyectado?, estado?, turnId?}` |
|
|
56
|
+
| `POST {base}/cancel` · `/model` · `/permission` | stop, switch model, answer a card |
|
|
57
|
+
|
|
58
|
+
Messages sent during a running turn are injected into it (steer). A silent connection during a turn is
|
|
59
|
+
reopened, and the server says whether the turn continued, finished or was cut short.
|
|
60
|
+
|
|
61
|
+
### 2. POST returns the stream: `createPostStreamTransport({ url })` (EasyBits' contract)
|
|
62
|
+
|
|
63
|
+
The POST that starts a turn *is* the SSE. A GET re-attaches to a running turn.
|
|
64
|
+
|
|
65
|
+
| | |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `POST {url}` | `{text, context?, turnId, continueTurnId?}` → SSE `started`, `chunk`, `tool{title,status}`, `permission{…, expiresAt}`, `done{turnId,text}`, `error`, `capacity`, `interrupted{turnId,canContinue}`, `idle` |
|
|
68
|
+
| `GET {url}?attach=1` | `started`, one `permission` per open card, `snapshot{turnId,text,tools}`, then live |
|
|
69
|
+
| `GET {url}` | `{messages:[{role,text}]}`, loaded as the thread |
|
|
70
|
+
|
|
71
|
+
A 503 (deploy draining) is retried with the same `turnId`. A 409 `turn_busy` re-attaches to the turn
|
|
72
|
+
that is already running. Approvals usually go through your own route:
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
<Piloto
|
|
76
|
+
transport={useMemo(() => createPostStreamTransport({ url: "/api/v2/admin/copilot/chat" }), [])}
|
|
77
|
+
context={{ page: "crons", job: selectedJob }}
|
|
78
|
+
onPermission={async (id, optionId) => {
|
|
79
|
+
const res = await fetch(`/api/v2/admin/agent-actions/${id}`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ decision: optionId === "approve" ? "approve" : "reject" }),
|
|
83
|
+
});
|
|
84
|
+
if (res.status === 409 || res.status === 404) return { resolved: false }; // expired or already answered
|
|
85
|
+
if (!res.ok) throw new Error("approval failed");
|
|
86
|
+
return { resolved: true, detail: (await res.json()).status };
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 3. Your own
|
|
92
|
+
|
|
93
|
+
Implement `PilotTransport` and emit the same event names (see `ghostykit/core` types). If you only need
|
|
94
|
+
the state, use the reducer directly: `ghostykit/core` has no React dependency.
|
|
95
|
+
|
|
96
|
+
## Make it yours
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
const ref = useRef<PilotoHandle>(null);
|
|
100
|
+
|
|
101
|
+
<Piloto
|
|
102
|
+
ref={ref}
|
|
103
|
+
transport={transport}
|
|
104
|
+
skin="easybits" // "ghosty" (default) | "easybits" | "neutral" | your own
|
|
105
|
+
theme="auto" // "light" | "dark" | "auto"
|
|
106
|
+
locale="en" // Spanish by default
|
|
107
|
+
agentName="Admin assistant"
|
|
108
|
+
welcome="How can I help with your crons?"
|
|
109
|
+
suggestions={["Why did the monitor fail?"]}
|
|
110
|
+
draft={{ text: "Why did the monitor fail?", key: row.id }} // prefilled + focused, not sent
|
|
111
|
+
context={() => ({ screen: "crons", selected })} // sent with EVERY turn
|
|
112
|
+
toolVerbs={{ run_cron: { one: "Ran a cron", many: "Ran %n crons", running: "Running the cron…" } }}
|
|
113
|
+
upload={async (file) => ({ url: await myUpload(file) })}
|
|
114
|
+
transcribe={async (audio) => myWhisper(audio)}
|
|
115
|
+
onTurnDone={({ text }) => refreshMyData()}
|
|
116
|
+
slots={{ renderDeliveries: (turn) => <MyFiles turn={turn} />, composerExtras: <MyButton /> }}
|
|
117
|
+
/>;
|
|
118
|
+
|
|
119
|
+
ref.current?.addReference({ id: "12", label: "#12 Rosa" }); // "@" a row into the message
|
|
120
|
+
ref.current?.send("Why did this fail?"); // ask on the user's behalf
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Other options: `strings`, `toolIcons`, `formatUserText`, `markdown`, `features`
|
|
124
|
+
(`attachments`, `retry`, `copy`, `usage`, `models`, `replyOptions`, `duration`), `references` /
|
|
125
|
+
`onReferencesChange`, `voiceNoteText`, `placeholder`, `avatar`, `initialTurns`, and the callbacks
|
|
126
|
+
`onTurnStart`, `onTitle`, `onNeedsLogin`, `onDisconnected`, `onEvent`, `onSend`. To read the state
|
|
127
|
+
outside the chat, call `usePilot(transport)` yourself and pass the result as `pilot`. Every piece is
|
|
128
|
+
exported (`AgentBubble`, `AgentSteps`, `Composer`, `ConfirmCard`, `Markdown`, …) if you want to build
|
|
129
|
+
your own layout.
|
|
130
|
+
|
|
131
|
+
### Skins and theming
|
|
132
|
+
|
|
133
|
+
`ghostykit/styles.css` is self-contained and scoped under `.piloto`. It works the same with
|
|
134
|
+
**Tailwind 3, Tailwind 4 or no Tailwind** (verified in a Tailwind 3 app: its utilities compute
|
|
135
|
+
identically with and without Piloto's CSS). Every color, radius, border width and shadow is a
|
|
136
|
+
variable:
|
|
137
|
+
|
|
138
|
+
```css
|
|
139
|
+
.my-app .piloto {
|
|
140
|
+
--piloto-accent: #0f766e;
|
|
141
|
+
--piloto-warning: #fde68a; /* the permission pass */
|
|
142
|
+
--piloto-radius-card: 6px;
|
|
143
|
+
--piloto-border-width: 3px;
|
|
144
|
+
--piloto-shadow-hero: 6px 6px 0 #0f766e;
|
|
145
|
+
--piloto-font: "Your Sans", sans-serif;
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
| Ghosty | EasyBits | Neutral |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
|  |  |  |
|
|
152
|
+
|
|
153
|
+
## Accessibility
|
|
154
|
+
|
|
155
|
+
- Keyboard-first: Enter sends, Shift+Enter adds a new line, Backspace on an empty box removes the last reference. Menus close with Escape, and focus rings are visible (`--piloto-focus`).
|
|
156
|
+
- The permission pass is a labelled region; the stamp is announced as status, and errors as alerts.
|
|
157
|
+
- `prefers-reduced-motion` turns off every animation, including the text-at-pace effect, the stamp and the "thinking" lamps.
|
|
158
|
+
- Dark mode is real, not inverted: hard shadows switch to a color so they stay visible on dark backgrounds.
|
|
159
|
+
|
|
160
|
+
## The repo
|
|
161
|
+
|
|
162
|
+
- `packages/ghostykit`: the package (`core`, `transport`, `react`, `pointer`, `styles.css`, `fonts.css`)
|
|
163
|
+
- `apps/demo`: the playground (Vite, simulated transport, skin switcher)
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npm install
|
|
167
|
+
npm test # reducer, both transports, parsers, SSR
|
|
168
|
+
npm run build # dist/ (JS + .d.ts + styles.css)
|
|
169
|
+
npm run demo # http://localhost:5173
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT © Héctor BlisS (FixterGeek)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/core/text.ts
|
|
2
|
+
var VOICE_NOTE = /\[Nota de voz transcrita[^\]]*\]\s*«([^»]*)»/;
|
|
3
|
+
function cleanUserText(raw) {
|
|
4
|
+
let t = raw;
|
|
5
|
+
const voice = t.match(VOICE_NOTE);
|
|
6
|
+
if (voice) t = t.replace(voice[0], voice[1]);
|
|
7
|
+
return t.replace(/\n{3,}/g, "\n\n").trim();
|
|
8
|
+
}
|
|
9
|
+
function systemNoteOf(text) {
|
|
10
|
+
if (!text.startsWith("\u23F0 ")) return null;
|
|
11
|
+
const cause = text.match(/^⏰ [^(\n]*\(([^)]+)\)/)?.[1] ?? text.split("\n")[0].replace(/^⏰ /, "").replace(/[.:].*$/, "").slice(0, 60);
|
|
12
|
+
return cause.toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
function applyReferences(text, refs) {
|
|
15
|
+
if (!refs.length) return text;
|
|
16
|
+
const lines = refs.filter((r) => r.prompt).map((r) => r.prompt);
|
|
17
|
+
const labels = refs.filter((r) => !r.prompt).map((r) => r.label);
|
|
18
|
+
let out = text;
|
|
19
|
+
if (labels.length) out = `[${labels.join(", ")}] ${out}`.trim();
|
|
20
|
+
if (lines.length) out = `${lines.join("\n")}
|
|
21
|
+
${out}`;
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function formatDuration(s) {
|
|
25
|
+
if (s < 60) return `${s} s`;
|
|
26
|
+
const min = Math.floor(s / 60);
|
|
27
|
+
if (min < 60) return `${min} min`;
|
|
28
|
+
return `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, "0")} min`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
cleanUserText,
|
|
33
|
+
systemNoteOf,
|
|
34
|
+
applyReferences,
|
|
35
|
+
formatDuration
|
|
36
|
+
};
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// src/transport/types.ts
|
|
2
|
+
var PilotHttpError = class extends Error {
|
|
3
|
+
constructor(message, status, needsLogin = false) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "PilotHttpError";
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.needsLogin = needsLogin;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/transport/postStream.ts
|
|
12
|
+
async function readSse(body, onMessage, onBytes) {
|
|
13
|
+
const reader = body.getReader();
|
|
14
|
+
const decoder = new TextDecoder();
|
|
15
|
+
let buffer = "";
|
|
16
|
+
let event = "message";
|
|
17
|
+
let data = [];
|
|
18
|
+
const flush = () => {
|
|
19
|
+
if (data.length) onMessage({ event, data: data.join("\n") });
|
|
20
|
+
event = "message";
|
|
21
|
+
data = [];
|
|
22
|
+
};
|
|
23
|
+
for (; ; ) {
|
|
24
|
+
const { value, done } = await reader.read();
|
|
25
|
+
if (done) break;
|
|
26
|
+
onBytes?.();
|
|
27
|
+
buffer += decoder.decode(value, { stream: true });
|
|
28
|
+
let nl;
|
|
29
|
+
while ((nl = buffer.search(/\r\n|\r|\n/)) >= 0) {
|
|
30
|
+
const line = buffer.slice(0, nl);
|
|
31
|
+
buffer = buffer.slice(nl + (buffer[nl] === "\r" && buffer[nl + 1] === "\n" ? 2 : 1));
|
|
32
|
+
if (line === "") flush();
|
|
33
|
+
else if (line.startsWith(":")) continue;
|
|
34
|
+
else {
|
|
35
|
+
const i = line.indexOf(":");
|
|
36
|
+
const field = i < 0 ? line : line.slice(0, i);
|
|
37
|
+
const val = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
|
|
38
|
+
if (field === "event") event = val;
|
|
39
|
+
else if (field === "data") data.push(val);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
flush();
|
|
44
|
+
}
|
|
45
|
+
var TERMINAL = /* @__PURE__ */ new Set(["done", "capacity", "interrupted", "idle"]);
|
|
46
|
+
function parse(raw) {
|
|
47
|
+
if (!raw) return null;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(raw);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function defaultHistory(json) {
|
|
55
|
+
const j = json;
|
|
56
|
+
const turns = (j?.messages ?? []).filter((m) => typeof m.text === "string").map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
|
|
57
|
+
const permissions = (j?.pending ?? []).filter((p) => typeof p === "object" && p !== null && typeof p.id === "string").map((p) => ({
|
|
58
|
+
id: p.id,
|
|
59
|
+
title: String(p.title ?? ""),
|
|
60
|
+
...typeof p.tool === "string" ? { tool: p.tool } : {},
|
|
61
|
+
options: Array.isArray(p.options) ? p.options : [],
|
|
62
|
+
...p.expiresAt != null ? { expiresAt: p.expiresAt } : {}
|
|
63
|
+
}));
|
|
64
|
+
return { turns, permissions };
|
|
65
|
+
}
|
|
66
|
+
function uuid() {
|
|
67
|
+
const c = globalThis.crypto;
|
|
68
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
69
|
+
return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
70
|
+
}
|
|
71
|
+
function createPostStreamTransport(target, options = {}) {
|
|
72
|
+
const url = typeof target === "string" ? target : target.url;
|
|
73
|
+
const opts = typeof target === "string" ? options : { ...target, ...options };
|
|
74
|
+
const doFetch = (...a) => (opts.fetch ?? fetch)(...a);
|
|
75
|
+
const credentials = opts.credentials ?? "same-origin";
|
|
76
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
77
|
+
const retries = opts.retries ?? 6;
|
|
78
|
+
const silenceMs = opts.silenceMs ?? 6e4;
|
|
79
|
+
const attachUrl = opts.attachUrl === void 0 ? `${url}${url.includes("?") ? "&" : "?"}attach=1` : opts.attachUrl;
|
|
80
|
+
const historyUrl = opts.historyUrl === void 0 ? url : opts.historyUrl;
|
|
81
|
+
const buildBody = opts.body ?? ((i) => ({
|
|
82
|
+
text: i.text,
|
|
83
|
+
...i.context !== void 0 ? { context: i.context } : {},
|
|
84
|
+
turnId: i.turnId,
|
|
85
|
+
...i.resume && i.resumeTurnId ? { continueTurnId: i.resumeTurnId } : {}
|
|
86
|
+
}));
|
|
87
|
+
let listener = null;
|
|
88
|
+
let isBusy = () => false;
|
|
89
|
+
let closed = false;
|
|
90
|
+
let current = null;
|
|
91
|
+
const emit = (name, data) => listener?.(name, data);
|
|
92
|
+
async function consume(res, ctrl) {
|
|
93
|
+
if (!res.body) return false;
|
|
94
|
+
let terminal = false;
|
|
95
|
+
let errored = false;
|
|
96
|
+
let lastByte = Date.now();
|
|
97
|
+
const watchdog = setInterval(() => {
|
|
98
|
+
if (Date.now() - lastByte > silenceMs) ctrl.abort();
|
|
99
|
+
}, Math.min(15e3, silenceMs));
|
|
100
|
+
try {
|
|
101
|
+
await readSse(
|
|
102
|
+
res.body,
|
|
103
|
+
({ event, data }) => {
|
|
104
|
+
if (ctrl.signal.aborted) return;
|
|
105
|
+
const d = parse(data);
|
|
106
|
+
emit(event, d);
|
|
107
|
+
if (event === "error") errored = true;
|
|
108
|
+
if (TERMINAL.has(event)) terminal = true;
|
|
109
|
+
},
|
|
110
|
+
() => {
|
|
111
|
+
lastByte = Date.now();
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
} finally {
|
|
116
|
+
clearInterval(watchdog);
|
|
117
|
+
}
|
|
118
|
+
if (errored && !terminal) {
|
|
119
|
+
emit("done", {});
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
return terminal;
|
|
123
|
+
}
|
|
124
|
+
async function attach() {
|
|
125
|
+
if (!attachUrl) return;
|
|
126
|
+
for (let attempt = 0; attempt <= retries && !closed; attempt++) {
|
|
127
|
+
const ctrl = new AbortController();
|
|
128
|
+
current?.abort();
|
|
129
|
+
current = ctrl;
|
|
130
|
+
try {
|
|
131
|
+
const res = await doFetch(attachUrl, { credentials, headers: { accept: "text/event-stream" }, signal: ctrl.signal });
|
|
132
|
+
if (res.ok && await consume(res, ctrl)) return;
|
|
133
|
+
if (ctrl.signal.aborted && current !== ctrl) return;
|
|
134
|
+
} catch {
|
|
135
|
+
if (current !== ctrl) return;
|
|
136
|
+
}
|
|
137
|
+
if (!isBusy()) return;
|
|
138
|
+
await sleep(Math.min(1e3 * 2 ** attempt, 1e4));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
// Sin steer: con un turno corriendo el servidor contesta 409. El composer no deja mandar.
|
|
143
|
+
steer: false,
|
|
144
|
+
subscribe(l, sub = {}) {
|
|
145
|
+
listener = l;
|
|
146
|
+
isBusy = sub.isBusy ?? (() => false);
|
|
147
|
+
closed = false;
|
|
148
|
+
void attach();
|
|
149
|
+
return () => {
|
|
150
|
+
closed = true;
|
|
151
|
+
listener = null;
|
|
152
|
+
current?.abort();
|
|
153
|
+
current = null;
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
async history() {
|
|
157
|
+
if (!historyUrl) return { turns: [] };
|
|
158
|
+
const res = await doFetch(historyUrl, { credentials, headers: { accept: "application/json" } });
|
|
159
|
+
if (!res.ok) return { turns: [] };
|
|
160
|
+
return (opts.parseHistory ?? defaultHistory)(await res.json().catch(() => null));
|
|
161
|
+
},
|
|
162
|
+
async send(input) {
|
|
163
|
+
const turnId = (opts.newTurnId ?? uuid)();
|
|
164
|
+
const body = JSON.stringify(buildBody({ ...input, turnId }));
|
|
165
|
+
for (let attempt = 0; ; attempt++) {
|
|
166
|
+
const ctrl = new AbortController();
|
|
167
|
+
current?.abort();
|
|
168
|
+
current = ctrl;
|
|
169
|
+
const res = await doFetch(url, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
credentials,
|
|
172
|
+
headers: { "content-type": "application/json", accept: "text/event-stream" },
|
|
173
|
+
body,
|
|
174
|
+
signal: ctrl.signal
|
|
175
|
+
});
|
|
176
|
+
if (res.status === 503 && attempt < retries) {
|
|
177
|
+
await sleep(Number(res.headers.get("Retry-After")) * 1e3 || 3e3);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
const json = await res.json().catch(() => null);
|
|
182
|
+
const err = new PilotHttpError(json?.message ?? json?.error ?? `Error ${res.status}`, res.status, res.headers.get("X-Needs-Login") != null);
|
|
183
|
+
if (json?.error) err.code = json.error;
|
|
184
|
+
if (res.status === 409 && json?.error === "turn_busy") void attach();
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
void consume(res, ctrl).then((ok) => {
|
|
188
|
+
if (!ok && current === ctrl && !closed) void attach();
|
|
189
|
+
});
|
|
190
|
+
return { turnId, state: "running" };
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
async cancel() {
|
|
194
|
+
if (opts.cancelUrl) await doFetch(opts.cancelUrl, { method: "POST", credentials }).catch(() => {
|
|
195
|
+
});
|
|
196
|
+
current?.abort();
|
|
197
|
+
current = null;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/transport/index.ts
|
|
203
|
+
var SSE_EVENTS = [
|
|
204
|
+
"started",
|
|
205
|
+
"status",
|
|
206
|
+
"caps",
|
|
207
|
+
"models",
|
|
208
|
+
"chunk",
|
|
209
|
+
"thought",
|
|
210
|
+
"tool",
|
|
211
|
+
"artifact",
|
|
212
|
+
"usage",
|
|
213
|
+
"title",
|
|
214
|
+
"done",
|
|
215
|
+
"error",
|
|
216
|
+
"warning",
|
|
217
|
+
"reemplazo",
|
|
218
|
+
"permission",
|
|
219
|
+
"permission-resolved",
|
|
220
|
+
"closed",
|
|
221
|
+
"page-plan",
|
|
222
|
+
"section-building",
|
|
223
|
+
"section-update",
|
|
224
|
+
"section",
|
|
225
|
+
"page-ready",
|
|
226
|
+
"page-error",
|
|
227
|
+
"page-planning",
|
|
228
|
+
"page-drop",
|
|
229
|
+
"review",
|
|
230
|
+
"page-version",
|
|
231
|
+
"page-edit",
|
|
232
|
+
// Los de un servidor de turnos durables (EasyBits): re-enganche, sin turno, cortado, sin lugar.
|
|
233
|
+
"snapshot",
|
|
234
|
+
"idle",
|
|
235
|
+
"interrupted",
|
|
236
|
+
"capacity"
|
|
237
|
+
];
|
|
238
|
+
var LIFE_EVENTS = /* @__PURE__ */ new Set([
|
|
239
|
+
"started",
|
|
240
|
+
"status",
|
|
241
|
+
"chunk",
|
|
242
|
+
"thought",
|
|
243
|
+
"tool",
|
|
244
|
+
"artifact",
|
|
245
|
+
"usage",
|
|
246
|
+
"done",
|
|
247
|
+
"error",
|
|
248
|
+
"warning",
|
|
249
|
+
"reemplazo",
|
|
250
|
+
"permission",
|
|
251
|
+
"page-plan",
|
|
252
|
+
"section-building",
|
|
253
|
+
"section-update",
|
|
254
|
+
"section",
|
|
255
|
+
"page-ready",
|
|
256
|
+
"page-error",
|
|
257
|
+
"page-planning",
|
|
258
|
+
"page-drop",
|
|
259
|
+
"review",
|
|
260
|
+
"page-version",
|
|
261
|
+
"page-edit",
|
|
262
|
+
"snapshot",
|
|
263
|
+
"idle",
|
|
264
|
+
"interrupted",
|
|
265
|
+
"capacity"
|
|
266
|
+
]);
|
|
267
|
+
var SILENCE_MAX_MS = 11 * 6e4;
|
|
268
|
+
var MISSED_BEATS = 4;
|
|
269
|
+
var MIN_SILENCE_MS = 9e4;
|
|
270
|
+
function parse2(raw) {
|
|
271
|
+
if (typeof raw !== "string" || !raw) return null;
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(raw);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function createSseTransport(base, opts = {}) {
|
|
279
|
+
const doFetch = (...args) => (opts.fetch ?? fetch)(...args);
|
|
280
|
+
const now = opts.now ?? Date.now;
|
|
281
|
+
const root = base.replace(/\/+$/, "");
|
|
282
|
+
let lastEventAt = now();
|
|
283
|
+
const post = (path, body) => doFetch(`${root}/${path}`, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: body === void 0 ? void 0 : { "content-type": "application/json" },
|
|
286
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
287
|
+
credentials: opts.withCredentials ? "include" : void 0
|
|
288
|
+
});
|
|
289
|
+
return {
|
|
290
|
+
subscribe(listener, sub = {}) {
|
|
291
|
+
const ES = opts.EventSource ?? globalThis.EventSource;
|
|
292
|
+
if (!ES) throw new Error("ghostykit: no hay EventSource en este entorno (pasa `EventSource` en las opciones)");
|
|
293
|
+
let es = null;
|
|
294
|
+
let heartbeatMs = null;
|
|
295
|
+
let closed = false;
|
|
296
|
+
const open = () => {
|
|
297
|
+
const source = new ES(`${root}/events`, { withCredentials: Boolean(opts.withCredentials) });
|
|
298
|
+
es = source;
|
|
299
|
+
const names = /* @__PURE__ */ new Set([...SSE_EVENTS, ...opts.events ?? []]);
|
|
300
|
+
for (const name of names) {
|
|
301
|
+
source.addEventListener(name, (e) => {
|
|
302
|
+
if (es !== source) return;
|
|
303
|
+
if (LIFE_EVENTS.has(name)) lastEventAt = now();
|
|
304
|
+
const raw = e.data;
|
|
305
|
+
if (name === "error" && (raw === void 0 || raw === "")) return;
|
|
306
|
+
const data = parse2(raw);
|
|
307
|
+
if (name === "started") {
|
|
308
|
+
const d = data;
|
|
309
|
+
heartbeatMs = typeof d?.latidoMs === "number" && d.latidoMs > 0 ? d.latidoMs : null;
|
|
310
|
+
}
|
|
311
|
+
listener(name, data);
|
|
312
|
+
if (name === "closed") source.close();
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
open();
|
|
317
|
+
const timer = setInterval(() => {
|
|
318
|
+
if (closed || !sub.isBusy?.()) return;
|
|
319
|
+
const cap = heartbeatMs ? Math.max(heartbeatMs * MISSED_BEATS, MIN_SILENCE_MS) : SILENCE_MAX_MS;
|
|
320
|
+
if (now() - lastEventAt < cap) return;
|
|
321
|
+
lastEventAt = now();
|
|
322
|
+
es?.close();
|
|
323
|
+
open();
|
|
324
|
+
}, opts.checkEveryMs ?? 15e3);
|
|
325
|
+
return () => {
|
|
326
|
+
closed = true;
|
|
327
|
+
clearInterval(timer);
|
|
328
|
+
es?.close();
|
|
329
|
+
es = null;
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
async send(input) {
|
|
333
|
+
lastEventAt = now();
|
|
334
|
+
const res = await post("messages", {
|
|
335
|
+
content: input.text,
|
|
336
|
+
images: input.attachments,
|
|
337
|
+
// Lo que hace que el hilo se BAUTICE al cerrar este turno.
|
|
338
|
+
primerTurno: input.firstTurn,
|
|
339
|
+
...input.resume ? { retomar: true } : {},
|
|
340
|
+
...input.context !== void 0 ? { context: input.context } : {}
|
|
341
|
+
});
|
|
342
|
+
if (!res.ok) {
|
|
343
|
+
const text = await res.text().catch(() => "");
|
|
344
|
+
throw new PilotHttpError(text || `Error ${res.status}`, res.status, Boolean(res.headers.get("X-Needs-Login")));
|
|
345
|
+
}
|
|
346
|
+
const r = await res.json().catch(() => ({}));
|
|
347
|
+
return { injected: r.inyectado, state: r.estado, turnId: r.turnId };
|
|
348
|
+
},
|
|
349
|
+
async cancel() {
|
|
350
|
+
await post("cancel").catch(() => {
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
async setModel(value) {
|
|
354
|
+
await post("model", { value });
|
|
355
|
+
},
|
|
356
|
+
async respondPermission(id, optionId) {
|
|
357
|
+
const res = await post("permission", { id, optionId });
|
|
358
|
+
const r = await res.json().catch(() => ({}));
|
|
359
|
+
return { resolved: Boolean(r.resuelto ?? r.resolved) };
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export {
|
|
365
|
+
PilotHttpError,
|
|
366
|
+
readSse,
|
|
367
|
+
createPostStreamTransport,
|
|
368
|
+
SSE_EVENTS,
|
|
369
|
+
SILENCE_MAX_MS,
|
|
370
|
+
MISSED_BEATS,
|
|
371
|
+
MIN_SILENCE_MS,
|
|
372
|
+
createSseTransport
|
|
373
|
+
};
|