@hachej/boring-ui-kit 0.1.110 → 0.2.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/LICENSE +1 -1
- package/README.md +255 -239
- package/THIRD_PARTY_NOTICES.md +85 -0
- package/dist/anchor-conformance.d.ts +78 -0
- package/dist/anchor-conformance.js +288 -0
- package/dist/canvas-document.d.ts +25 -0
- package/dist/canvas-document.js +141 -0
- package/dist/canvas-editor-native.d.ts +22 -0
- package/dist/canvas-editor-native.js +112 -0
- package/dist/canvas-editor.d.ts +6 -0
- package/dist/canvas-editor.js +13 -0
- package/dist/canvas-mounted.d.ts +41 -0
- package/dist/canvas-mounted.js +180 -0
- package/dist/canvas-proposals.d.ts +11 -0
- package/dist/canvas-proposals.js +45 -0
- package/dist/canvas-records.d.ts +3 -0
- package/dist/canvas-records.js +39 -0
- package/dist/canvas.d.ts +92 -0
- package/dist/canvas.js +264 -0
- package/dist/contracts.d.ts +125 -0
- package/dist/contracts.js +1 -0
- package/dist/experience-catalog.d.ts +142 -0
- package/dist/experience-catalog.js +25 -0
- package/dist/experience-compose.d.ts +83 -0
- package/dist/experience-compose.js +218 -0
- package/dist/experience-composition-schema.d.ts +29 -0
- package/dist/experience-composition-schema.js +22 -0
- package/dist/experience-document-viewer.d.ts +13 -0
- package/dist/experience-document-viewer.js +54 -0
- package/dist/experience-document.d.ts +64 -0
- package/dist/experience-document.js +234 -0
- package/dist/experience-region-text.d.ts +3 -0
- package/dist/experience-region-text.js +84 -0
- package/dist/experience-region-tree.d.ts +22 -0
- package/dist/experience-region-tree.js +50 -0
- package/dist/experience-regions.d.ts +9 -0
- package/dist/experience-regions.js +122 -0
- package/dist/experience-renderer.d.ts +16 -0
- package/dist/experience-renderer.js +37 -0
- package/dist/experience.d.ts +12 -0
- package/dist/experience.js +23 -0
- package/dist/file-tree-shadcn.d.ts +8 -0
- package/dist/file-tree-shadcn.js +14 -0
- package/dist/file-tree-view.d.ts +64 -0
- package/dist/file-tree-view.js +256 -0
- package/dist/file-tree.d.ts +57 -0
- package/dist/file-tree.js +204 -0
- package/dist/html-preview.d.ts +2 -0
- package/dist/html-preview.js +47 -0
- package/dist/html-viewer.d.ts +13 -0
- package/dist/html-viewer.js +69 -0
- package/dist/html.d.ts +21 -0
- package/dist/html.js +21 -0
- package/dist/index.d.ts +2 -472
- package/dist/index.js +1 -2671
- package/dist/markdown-editor.d.ts +28 -0
- package/dist/markdown-editor.js +230 -0
- package/dist/markdown-extensions.d.ts +16 -0
- package/dist/markdown-extensions.js +96 -0
- package/dist/markdown-icons.d.ts +244 -0
- package/dist/markdown-icons.js +32 -0
- package/dist/markdown-mounted.d.ts +63 -0
- package/dist/markdown-mounted.js +263 -0
- package/dist/markdown-parser.d.ts +4 -0
- package/dist/markdown-parser.js +33 -0
- package/dist/markdown-proposal-diff.d.ts +5 -0
- package/dist/markdown-proposal-diff.js +122 -0
- package/dist/markdown-safety.d.ts +21 -0
- package/dist/markdown-safety.js +135 -0
- package/dist/markdown.d.ts +86 -0
- package/dist/markdown.js +121 -0
- package/dist/native-chat.d.ts +150 -0
- package/dist/native-chat.js +376 -0
- package/dist/pi.d.ts +12 -0
- package/dist/pi.js +1 -0
- package/dist/public-text-buffer.d.ts +5 -0
- package/dist/public-text-buffer.js +1 -0
- package/dist/remote-chat.d.ts +76 -0
- package/dist/remote-chat.js +324 -0
- package/dist/resources.d.ts +34 -0
- package/dist/resources.js +1 -0
- package/dist/text-buffer-state.d.ts +24 -0
- package/dist/text-buffer-state.js +1 -0
- package/dist/text-buffer.d.ts +52 -0
- package/dist/text-buffer.js +315 -0
- package/dist/text-draft-controls.d.ts +9 -0
- package/dist/text-draft-controls.js +33 -0
- package/dist/text-draft-types.d.ts +111 -0
- package/dist/text-draft-types.js +1 -0
- package/dist/text-drafts.d.ts +29 -0
- package/dist/text-drafts.js +378 -0
- package/package.json +226 -58
- package/styles/file-tree.css +46 -0
- package/dist/styles.css +0 -2308
- package/dist/tokens.css +0 -155
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { randomUUID } from '@hachej/boring-files/platform';
|
|
2
|
+
function draft(text, attachments, version) {
|
|
3
|
+
if (typeof text !== 'string' || attachments.some(item => typeof item.id !== 'string' || !item.id || typeof item.name !== 'string'
|
|
4
|
+
|| item.content.type !== 'image' || typeof item.content.data !== 'string' || typeof item.content.mimeType !== 'string'))
|
|
5
|
+
throw new TypeError('Invalid chat draft');
|
|
6
|
+
if (new Set(attachments.map(item => item.id)).size !== attachments.length)
|
|
7
|
+
throw new TypeError('Attachment identifiers must be unique');
|
|
8
|
+
return Object.freeze({ text, attachments: Object.freeze(attachments.map(item => Object.freeze({ ...item, content: Object.freeze({ ...item.content }) }))), version });
|
|
9
|
+
}
|
|
10
|
+
/** Local presentation/draft state over a borrowed native conversation and one owned native watch. */
|
|
11
|
+
export function createNativeChatController(options) {
|
|
12
|
+
const { conversation, context } = options;
|
|
13
|
+
const historySource = options.history === false ? undefined : options.history ?? (options.source ? undefined : conversation);
|
|
14
|
+
if (historySource && historySource.id !== conversation.id)
|
|
15
|
+
throw new TypeError('History belongs to another conversation');
|
|
16
|
+
const readHistory = historySource?.entries.bind(historySource);
|
|
17
|
+
const emptyHistory = () => Object.freeze({ kind: readHistory ? 'idle' : 'disabled' });
|
|
18
|
+
const identity = Object.freeze({ runtimeId: options.identity.runtimeId, scopeId: options.identity.scopeId, principalId: options.identity.principalId });
|
|
19
|
+
if (Object.values(identity).some(value => typeof value !== 'string' || !value))
|
|
20
|
+
throw new TypeError('Explicit runtime, scope and principal identity are required');
|
|
21
|
+
let snapshot = { identity, conversationId: conversation.id, connection: { kind: 'idle' },
|
|
22
|
+
draft: draft('', [], 0), outbox: Object.freeze([]), history: emptyHistory(), send: { kind: 'idle' }, stop: 'idle', disposed: false };
|
|
23
|
+
const listeners = new Set();
|
|
24
|
+
let watch, opening, disposing;
|
|
25
|
+
const publish = (change) => {
|
|
26
|
+
snapshot = Object.freeze({ ...snapshot, ...change, ...(change.send ? { send: Object.freeze(change.send) } : {}),
|
|
27
|
+
...(change.connection ? { connection: Object.freeze(change.connection) } : {}),
|
|
28
|
+
...(change.history ? { history: Object.freeze(change.history) } : {}), ...(change.outbox ? { outbox: Object.freeze(change.outbox) } : {}) });
|
|
29
|
+
for (const listener of [...listeners]) {
|
|
30
|
+
try {
|
|
31
|
+
listener();
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
queueMicrotask(() => { if (options.onListenerError)
|
|
35
|
+
options.onListenerError(error);
|
|
36
|
+
else
|
|
37
|
+
throw error; });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
snapshot = Object.freeze({ ...snapshot, send: Object.freeze(snapshot.send), connection: Object.freeze(snapshot.connection) });
|
|
42
|
+
const live = () => ({ kind: options.link?.reconnecting() ? 'reconnecting' : 'connected' });
|
|
43
|
+
const open = () => snapshot.connection.kind === 'connected' || snapshot.connection.kind === 'reconnecting';
|
|
44
|
+
const unlink = options.link?.subscribe(() => {
|
|
45
|
+
if (snapshot.disposed || !watch || !open())
|
|
46
|
+
return;
|
|
47
|
+
const next = live();
|
|
48
|
+
if (next.kind !== snapshot.connection.kind)
|
|
49
|
+
publish({ connection: next });
|
|
50
|
+
});
|
|
51
|
+
let stopping, retrying;
|
|
52
|
+
let historyGeneration = 0, historyPending;
|
|
53
|
+
let historyScan;
|
|
54
|
+
const invalidateHistory = () => {
|
|
55
|
+
historyGeneration++;
|
|
56
|
+
historyPending = undefined;
|
|
57
|
+
historyScan = undefined;
|
|
58
|
+
return emptyHistory();
|
|
59
|
+
};
|
|
60
|
+
const acceptView = (view) => {
|
|
61
|
+
const previous = snapshot.view?.entries;
|
|
62
|
+
const appendOnly = !previous || previous.every((entry, index) => view.entries[index]?.id === entry.id);
|
|
63
|
+
publish({ view, ...(!appendOnly ? { history: invalidateHistory() } : {}) });
|
|
64
|
+
};
|
|
65
|
+
const stillPending = (attempt) => (snapshot.send.kind === 'unknown' || snapshot.send.kind === 'submitting') && snapshot.send.attempt.requestId === attempt.requestId;
|
|
66
|
+
const active = () => { if (snapshot.disposed)
|
|
67
|
+
throw new Error('Chat controller is disposed'); };
|
|
68
|
+
const editable = () => { active(); return snapshot.draft; };
|
|
69
|
+
// The composer is emptied when a message is sent, so a confirmation never has to clear it (text typed meanwhile is a new draft).
|
|
70
|
+
const markAdmitted = (attempt, submissionId, record) => {
|
|
71
|
+
if (!stillPending(attempt))
|
|
72
|
+
return;
|
|
73
|
+
publish({ send: { kind: 'admitted', attempt, submissionId, ...(record ? { record } : {}) } });
|
|
74
|
+
};
|
|
75
|
+
/** Messages taken from the composer that wait for the current send (`outbox` in the snapshot). */
|
|
76
|
+
const waiting = [];
|
|
77
|
+
let sending = false;
|
|
78
|
+
/** What goes back into the composer when a sent message is refused (default: the message itself). */
|
|
79
|
+
const restoreOf = new WeakMap();
|
|
80
|
+
/**
|
|
81
|
+
* Puts refused messages back into the composer, followed by every message still waiting behind them (never submitted now) and then
|
|
82
|
+
* whatever was typed since, one per line.
|
|
83
|
+
*/
|
|
84
|
+
const restore = (drafts, change = {}) => {
|
|
85
|
+
const left = waiting.splice(0);
|
|
86
|
+
for (const item of left)
|
|
87
|
+
item.resolve(undefined);
|
|
88
|
+
if (snapshot.disposed)
|
|
89
|
+
return;
|
|
90
|
+
const current = snapshot.draft, back = [...drafts, ...left.map(item => item.draft)].map(item => restoreOf.get(item) ?? item);
|
|
91
|
+
const text = [...back.map(item => item.text), current.text].filter(value => value.trim()).join('\n');
|
|
92
|
+
const attachments = [...back.flatMap(item => item.attachments), ...current.attachments].filter((item, index, all) => all.findIndex(other => other.id === item.id) === index);
|
|
93
|
+
publish({ ...change, ...(left.length || snapshot.outbox.length ? { outbox: Object.freeze([]) } : {}), ...(back.length ? { draft: draft(text, attachments, current.version + 1) } : {}) });
|
|
94
|
+
};
|
|
95
|
+
function definitelyNotAdmitted(error) {
|
|
96
|
+
try {
|
|
97
|
+
return options.definitelyNotAdmitted?.(error) === true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function submitAttempt(attempt) {
|
|
104
|
+
publish({ send: { kind: 'submitting', attempt }, ...(!stopping && snapshot.stop !== 'idle' ? { stop: 'idle' } : {}) });
|
|
105
|
+
if (snapshot.disposed)
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
const content = attempt.draft.attachments.length
|
|
109
|
+
? [{ type: 'text', text: attempt.draft.text }, ...attempt.draft.attachments.map(item => ({ ...item.content }))]
|
|
110
|
+
: attempt.draft.text;
|
|
111
|
+
const submission = await conversation.submit({ type: 'input', requestId: attempt.requestId, content, whenBusy: attempt.whenBusy }, context);
|
|
112
|
+
markAdmitted(attempt, submission.id);
|
|
113
|
+
return submission;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
const denied = definitelyNotAdmitted(error);
|
|
117
|
+
if (!stillPending(attempt))
|
|
118
|
+
return;
|
|
119
|
+
// Refused: the message goes back into the composer. Unknown: it stays held by the attempt for `reconcile`/`retrySameRequest`.
|
|
120
|
+
if (denied)
|
|
121
|
+
restore([attempt.draft], { send: { kind: 'blocked', reason: error instanceof Error ? error.message : 'Submission was not admitted' } });
|
|
122
|
+
else
|
|
123
|
+
publish({ send: { kind: 'unknown', attempt } });
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Validates and submits one message; a validation refusal puts it back into the composer. */
|
|
128
|
+
async function dispatch(item) {
|
|
129
|
+
const selected = item.draft;
|
|
130
|
+
publish({ send: { kind: 'validating', draft: selected }, ...(!stopping && snapshot.stop !== 'idle' ? { stop: 'idle' } : {}) });
|
|
131
|
+
if (snapshot.disposed)
|
|
132
|
+
return;
|
|
133
|
+
try {
|
|
134
|
+
const reason = await options.beforeSubmit?.(selected);
|
|
135
|
+
if (snapshot.disposed)
|
|
136
|
+
return;
|
|
137
|
+
if (reason !== undefined) {
|
|
138
|
+
restore([selected], { send: { kind: 'blocked', reason: typeof reason === 'string' && reason ? reason : 'Submission validation did not return success' } });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (!snapshot.disposed)
|
|
144
|
+
restore([selected], { send: { kind: 'blocked', reason: error instanceof Error ? error.message : 'Submission validation failed' } });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (snapshot.disposed)
|
|
148
|
+
return;
|
|
149
|
+
return submitAttempt(Object.freeze({ requestId: randomUUID(), draft: selected, whenBusy: item.whenBusy }));
|
|
150
|
+
}
|
|
151
|
+
const controller = {
|
|
152
|
+
conversation,
|
|
153
|
+
getSnapshot: () => snapshot,
|
|
154
|
+
subscribe: (listener) => { if (snapshot.disposed)
|
|
155
|
+
return () => { }; listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
156
|
+
setText: (text) => { const current = editable(); publish({ draft: draft(text, current.attachments, current.version + 1) }); },
|
|
157
|
+
setAttachments: (attachments) => { const current = editable(); publish({ draft: draft(current.text, attachments, current.version + 1) }); },
|
|
158
|
+
clearHistory: () => { active(); publish({ history: invalidateHistory() }); },
|
|
159
|
+
loadEarlier: () => {
|
|
160
|
+
active();
|
|
161
|
+
if (!readHistory)
|
|
162
|
+
throw new Error('Conversation history is not enabled');
|
|
163
|
+
if (!open())
|
|
164
|
+
throw new Error('Connect before loading conversation history');
|
|
165
|
+
if (historyPending)
|
|
166
|
+
return historyPending;
|
|
167
|
+
const previous = snapshot.history;
|
|
168
|
+
if (previous.kind === 'ready' && !previous.hasMore)
|
|
169
|
+
return Promise.resolve();
|
|
170
|
+
if (!historyScan) {
|
|
171
|
+
let maxEntryId;
|
|
172
|
+
for (const entry of snapshot.view?.entries ?? [])
|
|
173
|
+
if (maxEntryId === undefined || entry.id > maxEntryId)
|
|
174
|
+
maxEntryId = entry.id;
|
|
175
|
+
if (maxEntryId === undefined) {
|
|
176
|
+
publish({ history: { kind: 'ready', entries: Object.freeze([]), hasMore: false } });
|
|
177
|
+
return Promise.resolve();
|
|
178
|
+
}
|
|
179
|
+
historyScan = { maxEntryId };
|
|
180
|
+
}
|
|
181
|
+
const scan = historyScan, generation = historyGeneration;
|
|
182
|
+
const entries = 'entries' in previous ? previous.entries : Object.freeze([]);
|
|
183
|
+
const current = () => !snapshot.disposed && historyGeneration === generation && open();
|
|
184
|
+
const pending = Promise.resolve().then(async () => {
|
|
185
|
+
try {
|
|
186
|
+
if (!current())
|
|
187
|
+
return;
|
|
188
|
+
if (historySource?.id !== conversation.id)
|
|
189
|
+
throw new Error('History conversation identity changed');
|
|
190
|
+
const page = await readHistory({ maxEntryId: scan.maxEntryId }, 40, scan.cursor === undefined ? undefined : structuredClone(scan.cursor), context);
|
|
191
|
+
if (!current())
|
|
192
|
+
return;
|
|
193
|
+
if (historySource?.id !== conversation.id)
|
|
194
|
+
throw new Error('History conversation identity changed');
|
|
195
|
+
if (page.items.length > 40 || !page.items.length && page.next !== undefined)
|
|
196
|
+
throw new Error('History page did not respect its bounds');
|
|
197
|
+
let previousId = scan.lastId;
|
|
198
|
+
for (const entry of page.items) {
|
|
199
|
+
if (entry.id > scan.maxEntryId || previousId !== undefined && entry.id >= previousId)
|
|
200
|
+
throw new Error('History page did not progress in native entry order');
|
|
201
|
+
previousId = entry.id;
|
|
202
|
+
}
|
|
203
|
+
const cursor = page.next === undefined ? undefined : structuredClone(page.next);
|
|
204
|
+
historyScan = { maxEntryId: scan.maxEntryId, ...(cursor === undefined ? {} : { cursor }), ...(previousId === undefined ? {} : { lastId: previousId }) };
|
|
205
|
+
publish({ history: { kind: 'ready', entries: Object.freeze([...page.items].reverse()), hasMore: cursor !== undefined } });
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (current())
|
|
209
|
+
publish({ history: { kind: 'error', entries, hasMore: true, error } });
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
if (historyGeneration === generation)
|
|
213
|
+
historyPending = undefined;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
historyPending = pending;
|
|
217
|
+
publish({ history: { kind: 'loading', entries, hasMore: true } });
|
|
218
|
+
return pending;
|
|
219
|
+
},
|
|
220
|
+
connect: () => {
|
|
221
|
+
active();
|
|
222
|
+
if (opening)
|
|
223
|
+
return opening;
|
|
224
|
+
if (watch)
|
|
225
|
+
return Promise.resolve();
|
|
226
|
+
opening = Promise.resolve().then(async () => {
|
|
227
|
+
try {
|
|
228
|
+
const acquired = await (options.source ? options.source.open() : conversation.watch(context));
|
|
229
|
+
if (snapshot.disposed) {
|
|
230
|
+
await acquired.stop();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (acquired.value.conversation.id !== conversation.id) {
|
|
234
|
+
await acquired.stop();
|
|
235
|
+
throw new Error('Chat source returned another conversation');
|
|
236
|
+
}
|
|
237
|
+
watch = acquired;
|
|
238
|
+
publish({ connection: live(), view: acquired.value });
|
|
239
|
+
acquired.start(async (view) => {
|
|
240
|
+
if (snapshot.disposed || watch !== acquired)
|
|
241
|
+
return;
|
|
242
|
+
if (view.conversation.id !== conversation.id)
|
|
243
|
+
throw new Error('Chat source changed conversation identity');
|
|
244
|
+
acceptView(view);
|
|
245
|
+
});
|
|
246
|
+
void acquired.closed.then(end => {
|
|
247
|
+
if (watch !== acquired)
|
|
248
|
+
return;
|
|
249
|
+
watch = undefined;
|
|
250
|
+
publish({ connection: { kind: 'closed', end }, history: invalidateHistory() });
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (!snapshot.disposed)
|
|
255
|
+
publish({ connection: { kind: 'error', error } });
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
opening = undefined;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
publish({ connection: { kind: 'connecting' }, history: invalidateHistory() });
|
|
262
|
+
return opening;
|
|
263
|
+
},
|
|
264
|
+
/**
|
|
265
|
+
* Sends the composer draft (or `input`) and empties the composer at once. While another send is still being confirmed the message
|
|
266
|
+
* waits in `outbox` and is submitted, in order, after it. A refused message, and any waiting behind it, go back into the composer
|
|
267
|
+
* ahead of what was typed since; nothing is dropped or merged into another message. Resolves with the native submission, or
|
|
268
|
+
* undefined when the message was not admitted (refused, unknown, or the controller was disposed).
|
|
269
|
+
*/
|
|
270
|
+
send: async (whenBusy = 'followUp', input) => {
|
|
271
|
+
active();
|
|
272
|
+
if (snapshot.send.kind === 'unknown')
|
|
273
|
+
throw new Error('Reconcile the existing submission before sending another');
|
|
274
|
+
const current = snapshot.draft;
|
|
275
|
+
const selected = input ? draft(input.text, input.attachments ?? [], 0) : current;
|
|
276
|
+
if (!selected.text.trim() && !selected.attachments.length) {
|
|
277
|
+
if (!sending)
|
|
278
|
+
publish({ send: { kind: 'blocked', reason: 'Enter a message or attach an image' } });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (input?.restore)
|
|
282
|
+
restoreOf.set(selected, draft(input.restore.text, input.restore.attachments ?? [], 0));
|
|
283
|
+
const cleared = input ? {} : { draft: draft('', [], current.version + 1) };
|
|
284
|
+
if (sending) {
|
|
285
|
+
return new Promise(resolve => {
|
|
286
|
+
waiting.push({ draft: selected, whenBusy, resolve });
|
|
287
|
+
publish({ ...cleared, outbox: Object.freeze(waiting.map(item => item.draft)) });
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
sending = true;
|
|
291
|
+
publish(cleared);
|
|
292
|
+
try {
|
|
293
|
+
let next = { draft: selected, whenBusy, resolve: () => { } };
|
|
294
|
+
const first = await dispatch(next);
|
|
295
|
+
let result = first;
|
|
296
|
+
while (result && (next = waiting.shift())) {
|
|
297
|
+
publish({ outbox: Object.freeze(waiting.map(item => item.draft)) });
|
|
298
|
+
result = await dispatch(next);
|
|
299
|
+
next.resolve(result);
|
|
300
|
+
}
|
|
301
|
+
return first;
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
sending = false;
|
|
305
|
+
// Whatever still waits (behind an unknown submission, or after disposal) was never submitted: it goes back into the composer.
|
|
306
|
+
if (waiting.length)
|
|
307
|
+
restore([]);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
reconcile: async () => {
|
|
311
|
+
active();
|
|
312
|
+
if (snapshot.send.kind !== 'unknown')
|
|
313
|
+
throw new Error('No unconfirmed submission to reconcile');
|
|
314
|
+
const { attempt } = snapshot.send;
|
|
315
|
+
const record = await conversation.commit(tx => tx.submissionByRequest(conversation.id, attempt.requestId), context);
|
|
316
|
+
if (record) {
|
|
317
|
+
if (record.conversationId !== conversation.id || record.requestId !== attempt.requestId || record.type !== 'input')
|
|
318
|
+
throw new Error('Native submission lookup returned a different binding');
|
|
319
|
+
markAdmitted(attempt, record.id, record);
|
|
320
|
+
}
|
|
321
|
+
return record;
|
|
322
|
+
},
|
|
323
|
+
retrySameRequest: () => {
|
|
324
|
+
active();
|
|
325
|
+
if (retrying)
|
|
326
|
+
return retrying;
|
|
327
|
+
if (snapshot.send.kind !== 'unknown')
|
|
328
|
+
throw new Error('No unconfirmed submission to retry');
|
|
329
|
+
const { attempt } = snapshot.send;
|
|
330
|
+
retrying = Promise.resolve().then(async () => {
|
|
331
|
+
try {
|
|
332
|
+
const reason = await options.beforeSubmit?.(attempt.draft);
|
|
333
|
+
if (reason !== undefined)
|
|
334
|
+
throw new Error(typeof reason === 'string' && reason ? reason : 'Submission validation did not return success');
|
|
335
|
+
if (snapshot.disposed || !stillPending(attempt))
|
|
336
|
+
return;
|
|
337
|
+
return await submitAttempt(attempt);
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
retrying = undefined;
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
return retrying;
|
|
344
|
+
},
|
|
345
|
+
stop: () => {
|
|
346
|
+
active();
|
|
347
|
+
if (stopping)
|
|
348
|
+
return stopping;
|
|
349
|
+
stopping = Promise.resolve().then(async () => {
|
|
350
|
+
try {
|
|
351
|
+
await conversation.abort(context);
|
|
352
|
+
publish({ stop: 'confirmed' });
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
publish({ stop: 'unconfirmed' });
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
stopping = undefined;
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
publish({ stop: 'requested' });
|
|
363
|
+
return stopping;
|
|
364
|
+
},
|
|
365
|
+
dispose: () => {
|
|
366
|
+
if (disposing)
|
|
367
|
+
return disposing;
|
|
368
|
+
disposing = Promise.resolve().then(async () => { await opening; const owned = watch; watch = undefined; return owned?.stop(); });
|
|
369
|
+
unlink?.();
|
|
370
|
+
publish({ disposed: true, connection: { kind: 'closed' }, history: invalidateHistory() });
|
|
371
|
+
listeners.clear();
|
|
372
|
+
return disposing;
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
return controller;
|
|
376
|
+
}
|
package/dist/pi.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Optional native Pi presentation integration, not part of headless UI root.
|
|
2
|
+
* Preserve native asynchronous backpressure, exact operations and close reason.
|
|
3
|
+
*/
|
|
4
|
+
import type { ConversationWatch } from '@earendil-works/pi-durable';
|
|
5
|
+
export type { ConversationView, ConversationWatch, WatchEnd } from '@earendil-works/pi-durable';
|
|
6
|
+
export interface ChatSource {
|
|
7
|
+
/** Host binds authentication/native Context to the selected conversation.
|
|
8
|
+
* No new SnapshotSubscription or copied native watch lifecycle.
|
|
9
|
+
*/
|
|
10
|
+
readonly open: (signal?: AbortSignal) => Promise<ConversationWatch>;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=pi.d.ts.map
|
package/dist/pi.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createTextBuffer } from './text-buffer.js';
|
|
2
|
+
export type { TextBufferOptions, TextBufferSource, TextBufferState } from './text-buffer.js';
|
|
3
|
+
export type TextBuffer = ReturnType<typeof import('./text-buffer.js').createTextBuffer>;
|
|
4
|
+
export type { TextDraft, TextDraftKey, TextDraftRef, TextDraftStore, TextDraftOptions, TextDraftBinding, TextDraftFailure, TextDraftChoice, TextDraftChoiceSelection, TextDraftDiscovery, TextDraftCheckpoint, TextDraftRecoveryState, TextDraftCheckpointResult, TextDraftChoiceFailure, TextDraftActions } from './text-draft-types.js';
|
|
5
|
+
//# sourceMappingURL=public-text-buffer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createTextBuffer } from './text-buffer.js';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Context } from '@earendil-works/chord';
|
|
2
|
+
import type { SubmissionRecord } from '@earendil-works/pi-durable';
|
|
3
|
+
import type { ChatConversation, ChatLink } from './native-chat.js';
|
|
4
|
+
/** Browser client for the `@hachej/boring-agent/chat-transport` handler. Bundles only Chord's standalone delta replay, no Pi runtime. */
|
|
5
|
+
export interface RemoteChatOptions {
|
|
6
|
+
/** Handler URL. Existing query parameters (for example a conversation selector) are preserved. */
|
|
7
|
+
readonly endpoint: string | URL;
|
|
8
|
+
/** The host adds credentials here. */
|
|
9
|
+
readonly fetch: (request: Request) => Promise<Response>;
|
|
10
|
+
/** Submission status poll interval for `Submission.wait`. */
|
|
11
|
+
readonly pollMs?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Release the watch stream while the page is hidden and reopen it when visible again (default true; only effective where
|
|
14
|
+
* `document.visibilityState` exists). Browsers cap concurrent HTTP/1.1 connections per host, so one idle stream per background tab
|
|
15
|
+
* can starve every other request. A reopened watch begins with a complete view, so nothing is lost. Submissions, answers and
|
|
16
|
+
* running turns are separate requests and are never affected. Pass false to keep the stream open while hidden.
|
|
17
|
+
*/
|
|
18
|
+
readonly pauseWhenHidden?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Backoff for reopening a lost watch stream: the delay doubles from `baseDelayMs` (default 1000) up to `maxDelayMs` (default
|
|
21
|
+
* 30000), each with random jitter of up to half its length. Reopening stops when the watch is stopped (controller disposed).
|
|
22
|
+
*/
|
|
23
|
+
readonly reconnect?: {
|
|
24
|
+
readonly baseDelayMs?: number;
|
|
25
|
+
readonly maxDelayMs?: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export interface RemoteChat {
|
|
29
|
+
/** The members of the native conversation the chat controller uses, served by the remote host. Nothing else is pretended. */
|
|
30
|
+
readonly conversation: ChatConversation;
|
|
31
|
+
/** Placeholder for the controller's Context argument. The server owns the real native Context. */
|
|
32
|
+
readonly context: Context;
|
|
33
|
+
/** Answer an assistant-entry-bound question ID from the transcript. The host authenticates and resolves it. */
|
|
34
|
+
readonly answer: (callId: string, answer: string) => Promise<{
|
|
35
|
+
kind: 'answered';
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'denied' | 'conflict' | 'unknown-question';
|
|
38
|
+
reason?: string;
|
|
39
|
+
}>;
|
|
40
|
+
/** Ask the host to change this conversation's model or thinking level. The host decides: a refusal carries its reason. */
|
|
41
|
+
readonly configure: (change: {
|
|
42
|
+
readonly model?: {
|
|
43
|
+
readonly provider: string;
|
|
44
|
+
readonly modelId: string;
|
|
45
|
+
};
|
|
46
|
+
readonly thinkingLevel?: string;
|
|
47
|
+
}) => Promise<{
|
|
48
|
+
kind: 'configured';
|
|
49
|
+
} | {
|
|
50
|
+
kind: 'refused';
|
|
51
|
+
reason?: string;
|
|
52
|
+
}>;
|
|
53
|
+
/** Stop a watch that the controller never claimed. Safe to call at any time; the controller's own watch is its to stop. */
|
|
54
|
+
readonly close: () => Promise<void>;
|
|
55
|
+
/** Withdraw a queued message before it runs. `already_placed` and `settled` mean it was too late. */
|
|
56
|
+
readonly withdraw: (submissionId: SubmissionRecord['id']) => Promise<'aborted' | 'already_placed' | 'settled'>;
|
|
57
|
+
/**
|
|
58
|
+
* True for a `SubmissionRefused` error: the input was definitely not admitted, so the controller shows the host's refusal as a
|
|
59
|
+
* blocked send instead of reconciling an unknown one. Spreading the remote chat into the controller options passes it.
|
|
60
|
+
*/
|
|
61
|
+
readonly definitelyNotAdmitted: (error: unknown) => boolean;
|
|
62
|
+
/** Whether a lost watch stream is being reopened. Spreading the remote chat into the controller options passes it, so the controller reads `reconnecting`. */
|
|
63
|
+
readonly link: ChatLink;
|
|
64
|
+
}
|
|
65
|
+
/** The host refused a submitted input before it reached the conversation (status 402, `submission-refused`); `message` is the host's words. */
|
|
66
|
+
export declare class SubmissionRefused extends Error {
|
|
67
|
+
readonly code = "submission-refused";
|
|
68
|
+
constructor(message: string);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Connect a browser to one remote native conversation. Resolves after the first view so the conversation identity is the
|
|
72
|
+
* server's. A dropped or silent stream is reopened automatically with backoff (see `reconnect` and `link`); it never means the
|
|
73
|
+
* native task stopped.
|
|
74
|
+
*/
|
|
75
|
+
export declare function createRemoteChat(options: RemoteChatOptions): Promise<RemoteChat>;
|
|
76
|
+
//# sourceMappingURL=remote-chat.d.ts.map
|