@gemboss/ui 0.21.0 → 0.30.2
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/CHANGELOG.md +328 -0
- package/README.md +433 -24
- package/catalog/editor.html +426 -10
- package/catalog/index.html +907 -33
- package/gemboss-ui.css +3 -3
- package/package.json +23 -3
- package/parts/components.css +5 -0
- package/parts/core.css +5 -0
- package/parts/shell.css +5 -0
- package/src/action-card.css +1 -0
- package/src/action-card.tsx +149 -0
- package/src/app-shell.css +1 -1
- package/src/assistant/chat-markdown.ts +279 -0
- package/src/assistant/chat-turns.ts +120 -0
- package/src/assistant/chat.tsx +871 -0
- package/src/assistant/engine-mark-art.ts +45 -0
- package/src/assistant/engine-mark.tsx +39 -0
- package/src/assistant/engine.tsx +697 -0
- package/src/assistant/host.tsx +3 -1
- package/src/assistant/index.ts +6 -0
- package/src/assistant-chat.css +1 -0
- package/src/assistant-engine.css +1 -0
- package/src/auth.css +1 -1
- package/src/auth.tsx +18 -2
- package/src/chat-composer.css +1 -1
- package/src/chat-plain.js +6 -0
- package/src/chat.css +1 -1
- package/src/collections.tsx +1 -0
- package/src/dialog.tsx +3 -135
- package/src/editor/controls.tsx +1 -0
- package/src/editor/frame.tsx +12 -4
- package/src/editor/index.ts +10 -1
- package/src/editor/layers.tsx +136 -0
- package/src/editor/panel.tsx +1 -0
- package/src/editor/panes.tsx +4 -3
- package/src/editor/sortable.tsx +79 -0
- package/src/editor/tree.tsx +61 -0
- package/src/editor-layers-plain.js +1 -0
- package/src/editor.css +1 -1
- package/src/engine-plain.js +1 -0
- package/src/index-table.tsx +1 -0
- package/src/index.ts +8 -3
- package/src/kit-plain.css +1 -1
- package/src/kit-plain.js +1 -1
- package/src/launcher.tsx +1 -0
- package/src/menu-layer.ts +9 -2
- package/src/minichart.css +1 -0
- package/src/minichart.tsx +165 -0
- package/src/portal.tsx +1 -0
- package/src/reactions.tsx +1 -0
- package/src/resource-list-select.tsx +1 -0
- package/src/search-dialog.tsx +146 -0
- package/src/selection.tsx +1 -0
- package/src/setup-guide.css +1 -0
- package/src/setup-guide.tsx +116 -0
- package/src/theme-script.ts +10 -0
- package/src/theme.tsx +5 -5
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assistant's markdown, PARSED (0.29.0): moved from the admin (apps/admin/src/components/chat/markdown-blocks.ts),
|
|
3
|
+
* where the chat bubble learned to draw what models actually write: headings, tables, lists, inline code, fenced code,
|
|
4
|
+
* bold, italic, links and pictures from hosts the fleet serves.
|
|
5
|
+
*
|
|
6
|
+
* Every surface had its own renderer before this (Chris 08/10/2026, "đồng bộ hết giao diện của AI assistant"): partner-
|
|
7
|
+
* radar and search-radar an escape-then-tag string fed to `dangerouslySetInnerHTML`, content-radar another one with its
|
|
8
|
+
* own classes, GemFeedback a third parser, gemwatcher react-markdown, gemcare none at all (plain text). Five readings of
|
|
9
|
+
* one model's answer. Now one, in two renderers that build the same markup from these tokens: `ChatMarkdown` (React,
|
|
10
|
+
* chat.tsx) and `gembossUI.chat.markdown` (plain DOM, chat-plain.js, which carries a copy of this file's rules held to it
|
|
11
|
+
* by chat-plain.test.ts).
|
|
12
|
+
*
|
|
13
|
+
* A parser rather than a dependency: the text is written by a model that has just read data other people can write, and
|
|
14
|
+
* every markdown library's interesting CVEs live in the HTML pass-through this does not want. NO RAW HTML, ever.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type Block =
|
|
18
|
+
/** `#` to `######`; level 4 to 6 come back as 3 (0.29.2): the bubble's type scale has three steps, and a `####` the
|
|
19
|
+
* model wrote is still a heading, not four hashes in front of a sentence. */
|
|
20
|
+
| { t: "heading"; level: 1 | 2 | 3; text: string }
|
|
21
|
+
| { t: "code"; lang: string; body: string }
|
|
22
|
+
| { t: "table"; head: string[]; rows: string[][] }
|
|
23
|
+
| { t: "list"; ordered: boolean; items: string[]; start?: number }
|
|
24
|
+
| { t: "quote"; lines: string[] }
|
|
25
|
+
| { t: "rule" }
|
|
26
|
+
| { t: "para"; lines: string[] };
|
|
27
|
+
|
|
28
|
+
export type Inline =
|
|
29
|
+
| { t: "text"; text: string }
|
|
30
|
+
| { t: "code"; text: string }
|
|
31
|
+
| { t: "bold"; text: string }
|
|
32
|
+
| { t: "italic"; text: string }
|
|
33
|
+
| { t: "link"; label: string; href: string; internal: boolean }
|
|
34
|
+
| { t: "image"; alt: string; src: string };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A host (and path prefix) whose pictures the bubble may LOAD. `host` left out (0.29.2) = the page's OWN origin: the
|
|
38
|
+
* picture is written as a root-relative path (``, content-radar), loaded from wherever the
|
|
39
|
+
* page is served, and allowed only under `path` as the browser will resolve it (`/api/img/../admin` is `/admin`: refused).
|
|
40
|
+
*/
|
|
41
|
+
export interface ImageHost { host?: string; path: string }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hosts the fleet serves pictures from. An `img src` is fetched the moment the answer renders, before anyone clicks, so an
|
|
45
|
+
* open one is a tracking pixel anyone who can write store data can plant; any other picture becomes a link to it.
|
|
46
|
+
* - gemwatcher's archive thumbnails (`/watch/artifacts/`),
|
|
47
|
+
* - gemmedia's generated assets, on each of its names (urls on every name are stored and still handed out),
|
|
48
|
+
* - Shopify's CDN (a store's own product photos).
|
|
49
|
+
* A surface adds its own with `imageHosts` (they are added to these, never replace them).
|
|
50
|
+
*/
|
|
51
|
+
export const FLEET_IMAGE_HOSTS: readonly ImageHost[] = [
|
|
52
|
+
{ host: "gemwatcher-api-production.up.railway.app", path: "/watch/artifacts/" },
|
|
53
|
+
{ host: "gemmedia-api-production.up.railway.app", path: "/" },
|
|
54
|
+
{ host: "media.gemboss.ai", path: "/v1/theme-media/assets/" },
|
|
55
|
+
{ host: "gemmedia.ops.gemboss.ai", path: "/v1/theme-media/assets/" },
|
|
56
|
+
{ host: "gemmedia.gemboss.ai", path: "/v1/theme-media/assets/" },
|
|
57
|
+
{ host: "cdn.shopify.com", path: "/" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where a link in the bubble may go: an in-app path, `http(s):` or `mailto:`. `null` for anything else (`javascript:`,
|
|
62
|
+
* `//other.host`, `/\other.host`): the caller keeps the label as text, so the words survive and only the link goes.
|
|
63
|
+
*/
|
|
64
|
+
export function linkTarget(raw: string): { href: string; internal: boolean } | null {
|
|
65
|
+
// Browsers strip tabs and newlines out of a URL before reading its scheme: `java\tscript:` is `javascript:` to them.
|
|
66
|
+
const href = raw.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
67
|
+
if (!href) return null;
|
|
68
|
+
if (href.startsWith("/")) {
|
|
69
|
+
if (href[1] === "/" || href[1] === "\\") return null;
|
|
70
|
+
return { href, internal: true };
|
|
71
|
+
}
|
|
72
|
+
if (/^(https?:\/\/|mailto:)/i.test(href)) return { href, internal: false };
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Any origin will do as the base for resolving a root-relative path the way a browser does: only the PATH is read back.
|
|
77
|
+
const SAME_ORIGIN = "https://same-origin.invalid";
|
|
78
|
+
// A host-less entry's path is a DIRECTORY below the root: "/api/img/", never "/" (every same-origin GET, cookies and all,
|
|
79
|
+
// fired by rendering an answer) nor "/api/img" (which would also open "/api/img-private/…"). Any other entry is ignored.
|
|
80
|
+
const OWN_PATH = /^\/[^/]+(\/[^/]+)*\/$/;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The picture's URL when its host is one the fleet serves pictures from (https, no credentials, no port); else null.
|
|
84
|
+
* A root-relative `src` (`/api/img/a.png`) is allowed only by an entry with no `host` (the page's own origin), and is
|
|
85
|
+
* checked AFTER the browser's normalisation (dot segments, `%2e`, `\` read as `/`): what comes back is the resolved path,
|
|
86
|
+
* which is exactly what the browser will fetch. What a server might still turn into a `..` the browser never saw is
|
|
87
|
+
* refused: an encoded slash or backslash, a double encoding (`%25…`), a `;` path parameter (`..;/`), and any `..` or
|
|
88
|
+
* `%2e` left after normalisation; so is a protocol-relative `//host` or `/\host` (another origin).
|
|
89
|
+
*/
|
|
90
|
+
export function imageSource(raw: string, extra: readonly ImageHost[] = []): string | null {
|
|
91
|
+
const src = raw.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
92
|
+
if (src.startsWith("/")) {
|
|
93
|
+
const own = extra.filter((h) => !h.host && OWN_PATH.test(h.path));
|
|
94
|
+
if (!own.length || src[1] === "/" || src.includes("\\") || /%(2f|5c|25)|;/i.test(src)) return null;
|
|
95
|
+
let r: URL;
|
|
96
|
+
try { r = new URL(src, SAME_ORIGIN); } catch { return null; }
|
|
97
|
+
if (r.origin !== SAME_ORIGIN || /\.\.|%2e/i.test(r.pathname)) return null;
|
|
98
|
+
// The resolved path + query + fragment: exactly what the browser fetches.
|
|
99
|
+
return own.some((h) => r.pathname.startsWith(h.path)) ? r.href.slice(SAME_ORIGIN.length) : null;
|
|
100
|
+
}
|
|
101
|
+
let u: URL;
|
|
102
|
+
try { u = new URL(src); } catch { return null; }
|
|
103
|
+
if (u.protocol !== "https:" || u.username || u.password || u.port) return null;
|
|
104
|
+
const host = u.hostname.toLowerCase();
|
|
105
|
+
return [...FLEET_IMAGE_HOSTS, ...extra].some((h) => h.host === host && u.pathname.startsWith(h.path)) ? u.href : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const count = (s: string, ch: string) => s.split(ch).length - 1;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A bare `https://…` in the prose (0.29.2): the URL it is, without what follows it in the sentence. As GitHub reads one:
|
|
112
|
+
* trailing `. , : ; ! ? _ ~ ' "` are the sentence's, and so is a `)` or `]` with no partner inside the URL ("(see
|
|
113
|
+
* https://x.test/a)" links `https://x.test/a`; a Wikipedia `…/Foo_(bar)` keeps its own). Returns the URL and the
|
|
114
|
+
* characters handed back to the text, or null when what is left is not an http(s) URL with a host (`https://.` stays
|
|
115
|
+
* text). Through the same `linkTarget` as a written link: nothing an autolink makes is something `[x](…)` could not.
|
|
116
|
+
* `trim: false` for `<https://…>`, whose brackets already say where it ends.
|
|
117
|
+
*/
|
|
118
|
+
export function bareUrl(raw: string, trim = true): { href: string; rest: string } | null {
|
|
119
|
+
// Counted once and kept up to date as characters go: a count per stripped character was quadratic, and a model echoing
|
|
120
|
+
// "https://x.test/" + 20 000 ")" froze the tab (review #3928).
|
|
121
|
+
let end = raw.length;
|
|
122
|
+
let open = count(raw, "("), close = count(raw, ")"), openSq = count(raw, "["), closeSq = count(raw, "]");
|
|
123
|
+
while (trim && end > 0) {
|
|
124
|
+
const last = raw[end - 1];
|
|
125
|
+
if (/[.,:;!?_~'"]/.test(last)) end -= 1;
|
|
126
|
+
else if (last === ")" && open < close) { end -= 1; close -= 1; }
|
|
127
|
+
else if (last === "]" && openSq < closeSq) { end -= 1; closeSq -= 1; }
|
|
128
|
+
else trim = false;
|
|
129
|
+
}
|
|
130
|
+
const url = raw.slice(0, end);
|
|
131
|
+
const target = linkTarget(url);
|
|
132
|
+
if (!target || target.internal) return null;
|
|
133
|
+
let u: URL;
|
|
134
|
+
try { u = new URL(target.href); } catch { return null; }
|
|
135
|
+
if (!/^https?:$/.test(u.protocol) || !u.hostname) return null;
|
|
136
|
+
return { href: target.href, rest: raw.slice(end) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Order matters: code first, so `**` inside a code span is not bold; an image before a link, because ``
|
|
140
|
+
// CONTAINS `[alt](src)` and would otherwise leave its "!" behind as text. Then `<https://…>` and a bare `https://…`
|
|
141
|
+
// (0.29.2): the earliest match wins, so a URL inside a code span, a written link or an image stays theirs. A bare URL
|
|
142
|
+
// stops at what cannot be in one as a model writes it: a space, `<>"\``, a `*` (the `**bold**` right after it), and
|
|
143
|
+
// CJK text or full-width / typographic punctuation (Chinese runs a URL into the sentence with no space: "访问https://x。然后").
|
|
144
|
+
const INLINE = /(`([^`]+)`)|(\*\*([^*]+)\*\*)|(\*([^*\n]+)\*)|(!\[([^\]]*)\]\(([^)\s]+)\))|(\[([^\]]+)\]\(([^)\s]+)\))|(<(https?:\/\/[^\s<>]+)>)|(https?:\/\/[^\s<>`"*\u2000-\u206f\u3000-\u9fff\uff00-\uffef]+)/i;
|
|
145
|
+
|
|
146
|
+
export interface InlineOptions {
|
|
147
|
+
imageHosts?: readonly ImageHost[];
|
|
148
|
+
/** A surface's own link scheme (gemcare's `customer:12`): return true and the link is kept as written, `internal`, for
|
|
149
|
+
* the renderer's `renderLink` to draw. Anything else still goes through `linkTarget`. */
|
|
150
|
+
ownLink?: (href: string) => boolean;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One line → inline tokens. A link `linkTarget` refuses comes back as TEXT carrying its label. */
|
|
154
|
+
export function parseInline(text: string, opts: InlineOptions = {}): Inline[] {
|
|
155
|
+
const out: Inline[] = [];
|
|
156
|
+
const pushText = (s: string) => {
|
|
157
|
+
if (!s) return;
|
|
158
|
+
const prev = out[out.length - 1];
|
|
159
|
+
if (prev?.t === "text") prev.text += s;
|
|
160
|
+
else out.push({ t: "text", text: s });
|
|
161
|
+
};
|
|
162
|
+
const re = new RegExp(INLINE.source, "gi");
|
|
163
|
+
let last = 0;
|
|
164
|
+
let m: RegExpExecArray | null;
|
|
165
|
+
while ((m = re.exec(text)) !== null) {
|
|
166
|
+
pushText(text.slice(last, m.index));
|
|
167
|
+
if (m[2] != null) out.push({ t: "code", text: m[2] });
|
|
168
|
+
else if (m[4] != null) out.push({ t: "bold", text: m[4] });
|
|
169
|
+
else if (m[6] != null) out.push({ t: "italic", text: m[6] });
|
|
170
|
+
else if (m[7] != null) {
|
|
171
|
+
// A picture from a fleet host is drawn; any other becomes a plain link to it (its alt as the label, or "image"), and
|
|
172
|
+
// an unusable target leaves the alt as text. The "!" never survives into the bubble.
|
|
173
|
+
const src = imageSource(m[9], opts.imageHosts);
|
|
174
|
+
const alt = m[8].trim();
|
|
175
|
+
if (src) out.push({ t: "image", alt, src });
|
|
176
|
+
else {
|
|
177
|
+
const target = linkTarget(m[9]);
|
|
178
|
+
if (target && !target.internal) out.push({ t: "link", label: alt || "image", ...target });
|
|
179
|
+
else pushText(alt);
|
|
180
|
+
}
|
|
181
|
+
} else if (m[10] != null) {
|
|
182
|
+
const raw = m[12];
|
|
183
|
+
const own = opts.ownLink?.(raw) ? { href: raw, internal: true } : null;
|
|
184
|
+
const target = own ?? linkTarget(raw);
|
|
185
|
+
if (target) out.push({ t: "link", label: m[11], ...target });
|
|
186
|
+
else pushText(m[11]);
|
|
187
|
+
} else {
|
|
188
|
+
// `<https://…>` is all URL (its brackets go); a bare one gives the sentence's punctuation back as text. A bare one
|
|
189
|
+
// glued to a word ("foohttps://…", a slug) is not a link: GitHub links one only where a word could start.
|
|
190
|
+
const glued = m[15] != null && m.index > 0 && /[a-z\d]/i.test(text[m.index - 1]);
|
|
191
|
+
const bare = glued ? null : m[14] != null ? bareUrl(m[14], false) : bareUrl(m[15]);
|
|
192
|
+
if (!bare) pushText(m[0]);
|
|
193
|
+
else {
|
|
194
|
+
out.push({ t: "link", label: bare.href, href: bare.href, internal: false });
|
|
195
|
+
pushText(bare.rest);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
last = m.index + m[0].length;
|
|
199
|
+
}
|
|
200
|
+
pushText(text.slice(last));
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const isTableRow = (l: string) => /^\s*\|.*\|\s*$/.test(l);
|
|
205
|
+
const isDivider = (l: string) => /^\s*\|?[\s:-]*-[-\s:|]*\|?\s*$/.test(l) && l.includes("-");
|
|
206
|
+
const cells = (l: string) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
|
207
|
+
|
|
208
|
+
/** Text → blocks. A fence still open at the end (a stream mid-answer) is a code block already. */
|
|
209
|
+
export function parseBlocks(text: string): Block[] {
|
|
210
|
+
const lines = (text ?? "").split("\n");
|
|
211
|
+
const out: Block[] = [];
|
|
212
|
+
let i = 0;
|
|
213
|
+
const para: string[] = [];
|
|
214
|
+
const flushPara = () => { if (para.length) out.push({ t: "para", lines: [...para] }); para.length = 0; };
|
|
215
|
+
|
|
216
|
+
while (i < lines.length) {
|
|
217
|
+
const line = lines[i];
|
|
218
|
+
const fence = /^\s*```([\w#+.-]*)\s*$/.exec(line);
|
|
219
|
+
if (fence) {
|
|
220
|
+
flushPara();
|
|
221
|
+
const body: string[] = [];
|
|
222
|
+
i += 1;
|
|
223
|
+
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { body.push(lines[i]); i += 1; }
|
|
224
|
+
i += 1;
|
|
225
|
+
out.push({ t: "code", lang: fence[1] ?? "", body: body.join("\n") });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
229
|
+
if (heading) {
|
|
230
|
+
flushPara();
|
|
231
|
+
// "#### " with nothing after it is a gap, not an empty <h5> a screen reader announces (review #3928).
|
|
232
|
+
if (heading[2].trim()) out.push({ t: "heading", level: Math.min(heading[1].length, 3) as 1 | 2 | 3, text: heading[2].trim() });
|
|
233
|
+
i += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) { flushPara(); out.push({ t: "rule" }); i += 1; continue; }
|
|
237
|
+
// A table needs a header row AND a divider under it, or every line of prose with two pipes is a one-cell table.
|
|
238
|
+
if (isTableRow(line) && i + 1 < lines.length && isDivider(lines[i + 1])) {
|
|
239
|
+
flushPara();
|
|
240
|
+
const head = cells(line);
|
|
241
|
+
i += 2;
|
|
242
|
+
const rows: string[][] = [];
|
|
243
|
+
while (i < lines.length && isTableRow(lines[i])) { rows.push(cells(lines[i])); i += 1; }
|
|
244
|
+
out.push({ t: "table", head, rows });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const bullet = /^\s*([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
248
|
+
if (bullet) {
|
|
249
|
+
flushPara();
|
|
250
|
+
const ordered = /\d/.test(bullet[1]);
|
|
251
|
+
// The number the model WROTE: a list split by a picture or a paragraph restarts at "2.", not "1." again.
|
|
252
|
+
const start = ordered ? Number.parseInt(bullet[1], 10) : undefined;
|
|
253
|
+
const items: string[] = [];
|
|
254
|
+
while (i < lines.length) {
|
|
255
|
+
const b = /^\s*([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]);
|
|
256
|
+
if (!b || /\d/.test(b[1]) !== ordered) break;
|
|
257
|
+
items.push(b[2]);
|
|
258
|
+
i += 1;
|
|
259
|
+
}
|
|
260
|
+
out.push({ t: "list", ordered, items, ...(start !== undefined && start !== 1 ? { start } : {}) });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (/^\s*>\s?/.test(line)) {
|
|
264
|
+
flushPara();
|
|
265
|
+
const q: string[] = [];
|
|
266
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) { q.push(lines[i].replace(/^\s*>\s?/, "")); i += 1; }
|
|
267
|
+
out.push({ t: "quote", lines: q });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (line.trim() === "") { flushPara(); i += 1; continue; }
|
|
271
|
+
para.push(line);
|
|
272
|
+
i += 1;
|
|
273
|
+
}
|
|
274
|
+
flushPara();
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Long code folds: a 200-line block should not push the answer off screen. */
|
|
279
|
+
export const CODE_FOLD_LINES = 12;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conversation's STATE, for a surface that streams turns into `AssistantChat` (0.29.0).
|
|
3
|
+
*
|
|
4
|
+
* Every surface kept its messages in an array and wrote into "the last message" while a turn streamed: partner-radar and
|
|
5
|
+
* content-radar patched the last ELEMENT, so a note that arrived mid-turn ("Bridge stopped", the bridge's reply to Stop came
|
|
6
|
+
* back after the next turn had started) received the turn's text, and the real answer stayed at "Answering" (review
|
|
7
|
+
* gemwatcher #415; partner-radar #929; content-radar #937, which moved to "the last assistant message" and wrote down what
|
|
8
|
+
* that still gets wrong: a write that lands after the NEXT turn opened goes into the new bubble). Here every message has an
|
|
9
|
+
* id and a turn is written BY ITS ID, so nothing that arrives in between can take its text.
|
|
10
|
+
*
|
|
11
|
+
* And what a failed turn means, in one place (content-radar #937, partner-radar #929): only a failure AT the bridge is the
|
|
12
|
+
* bridge's (the call to localhost threw: it is gone; or it answered 401: this browser's pairing code is not its code). Any
|
|
13
|
+
* other HTTP error from the bridge (400, 503) is the turn's, and so is a failure on the way to the server (a data token, an
|
|
14
|
+
* expired session): the surface marks the bridge's own call with `atBridge`, never guesses from "this turn used the bridge".
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
17
|
+
import type { ChatMessageData, ChatStep } from "./chat";
|
|
18
|
+
|
|
19
|
+
export const BRIDGE_GONE = "The bridge on your machine is not responding: it was stopped, or the machine just slept. The panel is checking again; rerun the install command if it does not come back.";
|
|
20
|
+
export const PAIRING_REJECTED = "The bridge on your machine rejected this browser's pairing code (it was installed with another code). Pair again in Setup.";
|
|
21
|
+
|
|
22
|
+
/** Mark an error thrown by THE call to the bridge (fetch / stream to localhost), with its HTTP status when it answered. */
|
|
23
|
+
export function atBridge<E>(e: E, status?: number): E {
|
|
24
|
+
if (e && typeof e === "object") {
|
|
25
|
+
(e as { atBridge?: boolean }).atBridge = true;
|
|
26
|
+
if (status !== undefined) (e as { status?: number }).status = status;
|
|
27
|
+
}
|
|
28
|
+
return e;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TurnError {
|
|
32
|
+
/** What to show; null when the user stopped the turn. */
|
|
33
|
+
error: string | null;
|
|
34
|
+
/** The bridge's failure: show it as a BridgeNotice (Open setup) and probe the bridge again, so the chip stops saying green. */
|
|
35
|
+
bridge: boolean;
|
|
36
|
+
/** The user stopped it (AbortError). */
|
|
37
|
+
stopped: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One failed turn, classified. `messages` overrides the two sentences (a surface's own words for its bridge). */
|
|
41
|
+
export function bridgeTurnError(e: unknown, messages: { gone?: string; rejected?: string } = {}): TurnError {
|
|
42
|
+
const err = e as { name?: string; atBridge?: boolean; status?: number; message?: string } | null | undefined;
|
|
43
|
+
if (err?.name === "AbortError") return { error: null, bridge: false, stopped: true };
|
|
44
|
+
if (err?.atBridge && err.status === 401) return { error: messages.rejected ?? PAIRING_REJECTED, bridge: true, stopped: false };
|
|
45
|
+
// Gone: the call threw (fetch to localhost failed), or it timed out (`AbortSignal.timeout` throws a TimeoutError; use it
|
|
46
|
+
// for the bridge's deadline, not an AbortController, whose AbortError reads as the user's Stop).
|
|
47
|
+
if (err?.atBridge && (e instanceof TypeError || err.name === "TimeoutError")) return { error: messages.gone ?? BRIDGE_GONE, bridge: true, stopped: false };
|
|
48
|
+
return { error: err?.message || String(e), bridge: false, stopped: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Patch = Partial<ChatMessageData> | ((m: ChatMessageData) => ChatMessageData);
|
|
52
|
+
|
|
53
|
+
export interface ChatTurns {
|
|
54
|
+
messages: ChatMessageData[];
|
|
55
|
+
/** Replace the list (a conversation loaded from storage or the server). Messages without an id get one. */
|
|
56
|
+
setMessages: (next: ChatMessageData[] | ((m: ChatMessageData[]) => ChatMessageData[])) => void;
|
|
57
|
+
/** The user's message. */
|
|
58
|
+
say: (text: string, extra?: Partial<ChatMessageData>) => string;
|
|
59
|
+
/** An assistant turn, pending. Its id is where everything about the turn is written. */
|
|
60
|
+
start: (extra?: Partial<ChatMessageData>) => string;
|
|
61
|
+
/** Write into ONE message, found by id (never by position). Unknown id: nothing happens. */
|
|
62
|
+
patch: (id: string, p: Patch) => void;
|
|
63
|
+
/** Append streamed text to the turn. */
|
|
64
|
+
write: (id: string, delta: string) => void;
|
|
65
|
+
/** Add a step, or update the one with the same step id. */
|
|
66
|
+
step: (id: string, s: ChatStep) => void;
|
|
67
|
+
/** The turn is done (pending off), with what the surface adds (meta, warn). */
|
|
68
|
+
finish: (id: string, extra?: Partial<ChatMessageData>) => void;
|
|
69
|
+
/** The turn failed: classified (`bridgeTurnError`), written to the turn, and returned (call your probe when `bridge`). */
|
|
70
|
+
fail: (id: string, e: unknown, messages?: { gone?: string; rejected?: string }) => TurnError;
|
|
71
|
+
/** A quiet line, or a BridgeNotice line (`tone` warn / down with an action). */
|
|
72
|
+
note: (text: string, extra?: Partial<ChatMessageData>) => string;
|
|
73
|
+
clear: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The messages of one conversation, with ids, written by id. Keep it once per conversation (a session switch: setMessages). */
|
|
77
|
+
export function useChatTurns(initial?: ChatMessageData[] | (() => ChatMessageData[])): ChatTurns {
|
|
78
|
+
const seq = useRef(0);
|
|
79
|
+
const nextId = useCallback(() => `m${Date.now().toString(36)}-${(seq.current += 1)}`, []);
|
|
80
|
+
const withIds = useCallback((ms: ChatMessageData[]) => ms.map((m) => (m.id === undefined || m.id === null ? { ...m, id: nextId() } : m)), [nextId]);
|
|
81
|
+
const [messages, setRaw] = useState<ChatMessageData[]>(() => withIds(typeof initial === "function" ? initial() : initial ?? []));
|
|
82
|
+
const setMessages = useCallback((next: ChatMessageData[] | ((m: ChatMessageData[]) => ChatMessageData[])) => {
|
|
83
|
+
setRaw((cur) => withIds(typeof next === "function" ? next(cur) : next));
|
|
84
|
+
}, [withIds]);
|
|
85
|
+
const add = useCallback((m: ChatMessageData) => { const id = nextId(); setRaw((cur) => [...cur, { ...m, id }]); return id; }, [nextId]);
|
|
86
|
+
const patch = useCallback((id: string, p: Patch) => {
|
|
87
|
+
setRaw((cur) => {
|
|
88
|
+
const i = cur.findIndex((m) => m.id === id);
|
|
89
|
+
if (i < 0) return cur;
|
|
90
|
+
const copy = cur.slice();
|
|
91
|
+
copy[i] = typeof p === "function" ? p({ ...cur[i] }) : { ...cur[i], ...p };
|
|
92
|
+
return copy;
|
|
93
|
+
});
|
|
94
|
+
}, []);
|
|
95
|
+
return useMemo<ChatTurns>(() => ({
|
|
96
|
+
messages,
|
|
97
|
+
setMessages,
|
|
98
|
+
say: (text, extra) => add({ ...extra, role: "user", text }),
|
|
99
|
+
start: (extra) => add({ ...extra, role: "assistant", pending: true }),
|
|
100
|
+
patch,
|
|
101
|
+
write: (id, delta) => patch(id, (m) => ({ ...m, text: (m.text ?? "") + delta })),
|
|
102
|
+
step: (id, s) => patch(id, (m) => {
|
|
103
|
+
const steps = [...(m.steps ?? [])];
|
|
104
|
+
const i = steps.findIndex((x) => x.id === s.id);
|
|
105
|
+
// A field the update leaves out (or sends as undefined) keeps its value: a tool's result event that carries no
|
|
106
|
+
// target must not erase the one its start event gave, nor a progress event without a state turn "running" into ✓.
|
|
107
|
+
const given = Object.fromEntries(Object.entries(s).filter(([, v]) => v !== undefined)) as ChatStep;
|
|
108
|
+
if (i >= 0) steps[i] = { ...steps[i], ...given }; else steps.push(given);
|
|
109
|
+
return { ...m, steps };
|
|
110
|
+
}),
|
|
111
|
+
finish: (id, extra) => patch(id, (m) => ({ ...m, ...extra, pending: false })),
|
|
112
|
+
fail: (id, e, msgs) => {
|
|
113
|
+
const r = bridgeTurnError(e, msgs);
|
|
114
|
+
patch(id, (m) => ({ ...m, pending: false, stopped: r.stopped || m.stopped, ...(r.error ? { error: r.error, bridge: r.bridge } : {}) }));
|
|
115
|
+
return r;
|
|
116
|
+
},
|
|
117
|
+
note: (text, extra) => add({ ...extra, role: "note", text }),
|
|
118
|
+
clear: () => setRaw([]),
|
|
119
|
+
}), [messages, setMessages, add, patch]);
|
|
120
|
+
}
|