@clowder-ai/companion 0.1.0-alpha.24 → 0.1.0-alpha.26

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
@@ -26,9 +26,9 @@ borrowing today's selected companion.
26
26
 
27
27
  ## Host integration
28
28
 
29
- This surface requires the additive desktop bridge **1.4.0**. The Host computes
29
+ This surface requires the additive desktop bridge **1.5.0**. The Host computes
30
30
  placement within the display work area, keeps the cat as its stable anchor, and
31
- owns bounded drag gestures and transparent-area hit testing. Hosts below 1.4
31
+ owns bounded drag gestures and transparent-area hit testing. Hosts below 1.5
32
32
  reject this manifest before installation. Media admission and capture
33
33
  remain in the trusted Host; this package receives neither streams nor provider
34
34
  SDP. Hiding releases media; the existing Clowder **聊聊** entry restores the cat.
@@ -41,6 +41,19 @@ contract as the transcript surfaces that supply them. A Host
41
41
  another current reference may be observed without retrying the rejected one.
42
42
  Other failures retry after 1 and 2 seconds, then leave the call's
43
43
  first-visible result unknown; later text never backfills a manufactured success.
44
+ Bridge 1.5 adds Host-bound conversation pages and versioned body continuations.
45
+ The renderer can only return opaque cursors issued by the Host; it cannot select
46
+ an owner, thread, message, call or byte offset. History progress, response phase
47
+ and body completeness remain separate, so a processing draft or truncated body
48
+ cannot masquerade as a completed reply.
49
+
50
+ It also binds the one-line composer to the Host-owned durable draft. Versioned
51
+ text submission carries the Host-issued conversation `scopeRef`, draft revision
52
+ and caller message identity. An unknown settlement is read back but never
53
+ automatically resent; an explicit retry keeps the original text, identity,
54
+ revision and route. If the canonical thread changes, the old unconfirmed input
55
+ stays visible as invalidated local text and is never replayed into the new scope.
56
+ The legacy text and conversation methods remain unchanged for older pairs.
44
57
 
45
58
  Xianxian uses one layered sitting body or one VP9 clip at a time. Expiring Host
46
59
  facts with task, result and event identities select tool work, workspace fetch,
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.24",
3
+ "version": "0.1.0-alpha.26",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -37,10 +37,10 @@
37
37
  "type": "desktop-window",
38
38
  "id": "companion",
39
39
  "role": "companion",
40
- "bridgeVersion": "1.4.0",
40
+ "bridgeVersion": "1.5.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-EFERDKvXnOw6Mbk1Kq7ZyX7w4FdswfX5W/B0w/KTLE8="
43
+ "integrity": "sha256-Q5N69iKFCZpX1mGm9KJWOvJaU3xd5mdGbojDBRZUTx4="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clowder-ai/companion",
3
- "version": "0.1.0-alpha.24",
3
+ "version": "0.1.0-alpha.26",
4
4
  "description": "The desktop voice body for your Clowder companion",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,8 +27,8 @@
27
27
  "prepack": "pnpm build"
28
28
  },
29
29
  "devDependencies": {
30
- "@clowder-ai/plugin-contract": "0.1.0-beta.27",
31
- "@clowder-ai/plugin-sdk": "0.1.0-beta.23",
30
+ "@clowder-ai/plugin-contract": "0.1.0-beta.28",
31
+ "@clowder-ai/plugin-sdk": "0.1.0-beta.24",
32
32
  "playwright": "1.61.1"
33
33
  },
34
34
  "repository": {
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.24",
3
+ "version": "0.1.0-alpha.26",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -37,10 +37,10 @@
37
37
  "type": "desktop-window",
38
38
  "id": "companion",
39
39
  "role": "companion",
40
- "bridgeVersion": "1.4.0",
40
+ "bridgeVersion": "1.5.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-EFERDKvXnOw6Mbk1Kq7ZyX7w4FdswfX5W/B0w/KTLE8="
43
+ "integrity": "sha256-Q5N69iKFCZpX1mGm9KJWOvJaU3xd5mdGbojDBRZUTx4="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
@@ -16,6 +16,50 @@ export function createCompanionClient(bridge) {
16
16
  throw new CompanionRequestError('unavailable');
17
17
  return reply;
18
18
  }
19
+ async function readConversation() {
20
+ const response = await invoke({ kind: 'conversation.read' }, 'conversation');
21
+ if ('v' in response)
22
+ throw new CompanionRequestError('unavailable');
23
+ return response;
24
+ }
25
+ async function readConversationPage(options = {}) {
26
+ const command = options.before === undefined
27
+ ? options.limit === undefined
28
+ ? { kind: 'conversation.read', v: 2 }
29
+ : { kind: 'conversation.read', v: 2, limit: options.limit }
30
+ : options.limit === undefined
31
+ ? { kind: 'conversation.read', v: 2, before: options.before }
32
+ : { kind: 'conversation.read', v: 2, before: options.before, limit: options.limit };
33
+ const response = await invoke(command, 'conversation');
34
+ if (!('v' in response) || response.v !== 2)
35
+ throw new CompanionRequestError('unavailable');
36
+ return response;
37
+ }
38
+ async function readConversationBody(continuation) {
39
+ return invoke({ kind: 'conversation.body.read', v: 2, continuation }, 'conversation-body');
40
+ }
41
+ async function readConversationDraft() {
42
+ return invoke({ kind: 'conversation.draft.read', v: 1 }, 'conversation-draft');
43
+ }
44
+ async function saveConversationDraft(expectedRevision, text, scopeRef) {
45
+ return invoke({
46
+ kind: 'conversation.draft.save', v: 1, scopeRef, expectedRevision, text,
47
+ }, 'conversation-draft');
48
+ }
49
+ async function invokeDeliveryV2(command) {
50
+ const response = await invoke(command, 'delivery');
51
+ if (!('v' in response) || response.v !== 2)
52
+ throw new CompanionRequestError('unavailable');
53
+ return response;
54
+ }
55
+ async function submitConversationText(text, clientMessageId, submissionRevision, scopeRef) {
56
+ return invokeDeliveryV2({
57
+ kind: 'text', v: 2, scopeRef, text, clientMessageId, submissionRevision,
58
+ });
59
+ }
60
+ async function readConversationDelivery(clientMessageId, scopeRef) {
61
+ return invokeDeliveryV2({ kind: 'conversation.delivery.read', v: 1, scopeRef, clientMessageId });
62
+ }
19
63
  return {
20
64
  state: () => invoke({ kind: 'state' }, 'state'),
21
65
  prepare: () => invoke({ kind: 'prepare' }, 'state'),
@@ -26,6 +70,8 @@ export function createCompanionClient(bridge) {
26
70
  stop: () => invoke({ kind: 'stop' }, 'ok'),
27
71
  text: async (text, clientMessageId) => {
28
72
  const receipt = await invoke({ kind: 'text', text, clientMessageId }, 'delivery');
73
+ if ('v' in receipt)
74
+ throw new CompanionRequestError('unavailable');
29
75
  if (receipt.delivery !== 'accepted')
30
76
  throw new CompanionRequestError('unconfirmed');
31
77
  return receipt;
@@ -56,7 +102,13 @@ export function createCompanionClient(bridge) {
56
102
  readSettings: () => invoke({ kind: 'settings.read' }, 'settings'),
57
103
  updateSetting: (field, value) => invoke({ kind: 'settings.update', field, value }, 'settings-update'),
58
104
  disableCompanion: () => invoke({ kind: 'companion.disable' }, 'companion-lifecycle'),
59
- readConversation: () => invoke({ kind: 'conversation.read' }, 'conversation'),
105
+ readConversation,
106
+ readConversationPage,
107
+ readConversationBody,
108
+ readConversationDraft,
109
+ saveConversationDraft,
110
+ submitConversationText,
111
+ readConversationDelivery,
60
112
  readTranscript: () => invoke({ kind: 'transcript.read' }, 'transcript'),
61
113
  subscribe: (listener) => bridge.subscribe(listener),
62
114
  };
@@ -0,0 +1,304 @@
1
+ const receiptFingerprint = receipt => [
2
+ receipt.delivery,
3
+ receipt.messageId ?? '',
4
+ receipt.reason ?? '',
5
+ receipt.draftRevision ?? '',
6
+ ].join('\u0000');
7
+
8
+ const copySubmission = submission => ({
9
+ clientMessageId: submission.clientMessageId,
10
+ text: submission.text,
11
+ submissionRevision: submission.submissionRevision,
12
+ receipt: { ...submission.receipt },
13
+ });
14
+
15
+ /**
16
+ * Renderer coordinator for the Host-owned composer draft.
17
+ *
18
+ * It keeps the current local edit separate from the last canonical Host text.
19
+ * Reads may reconcile receipts, but only submit() and retry() can send text.
20
+ */
21
+ export class HostConversationDraft {
22
+ constructor({ client, changed = () => {}, delivery = () => {}, invalidated = () => {},
23
+ uuid = () => crypto.randomUUID(), schedule = setTimeout, cancel = clearTimeout,
24
+ debounceMs = 180 }) {
25
+ Object.assign(this, { client, changed, delivery, invalidated, uuid, debounceMs });
26
+ this.schedule = (...args) => Reflect.apply(schedule, globalThis, args);
27
+ this.cancel = (...args) => Reflect.apply(cancel, globalThis, args);
28
+ this.scopeRef = null;
29
+ this.revision = null;
30
+ this.canonicalText = '';
31
+ this.localText = '';
32
+ this.localVersion = 0;
33
+ this.localEdited = false;
34
+ this.writable = false;
35
+ this.status = 'loading';
36
+ this.submissions = new Map();
37
+ this.reportedReceipts = new Map();
38
+ }
39
+
40
+ snapshot() {
41
+ return {
42
+ scopeRef: this.scopeRef,
43
+ revision: this.revision,
44
+ canonicalText: this.canonicalText,
45
+ localText: this.localText,
46
+ writable: this.writable,
47
+ status: this.status,
48
+ submissions: [...this.submissions.values()].map(copySubmission),
49
+ };
50
+ }
51
+
52
+ canonicalReply() {
53
+ if (this.scopeRef === null || this.revision === null) return null;
54
+ return {
55
+ kind: 'conversation-draft', v: 1, status: 'available', scopeRef: this.scopeRef,
56
+ revision: this.revision, text: this.canonicalText, writable: this.writable,
57
+ submissions: [...this.submissions.values()].map(copySubmission),
58
+ };
59
+ }
60
+
61
+ emit() { this.changed(this.snapshot()); }
62
+
63
+ update(text) {
64
+ this.localText = text;
65
+ this.localEdited = true;
66
+ ++this.localVersion;
67
+ if (!['scope_changed', 'readonly'].includes(this.status)) this.status = 'editing';
68
+ this.emit();
69
+ if (this.saveTimer !== undefined) this.cancel(this.saveTimer);
70
+ this.saveTimer = this.schedule(() => {
71
+ this.saveTimer = undefined;
72
+ void this.save();
73
+ }, this.debounceMs);
74
+ this.saveTimer?.unref?.();
75
+ }
76
+
77
+ async load(options = {}) {
78
+ if (this.loading) return this.loading;
79
+ const operation = this.loadOnce(options);
80
+ this.loading = operation;
81
+ try { return await operation; }
82
+ finally { if (this.loading === operation) this.loading = undefined; }
83
+ }
84
+
85
+ async loadOnce({ preserveStatus = null } = {}) {
86
+ try {
87
+ const response = await this.client.readConversationDraft();
88
+ if (response.status !== 'available') {
89
+ this.revision = response.revision;
90
+ this.writable = false;
91
+ this.status = response.status;
92
+ this.emit();
93
+ return response;
94
+ }
95
+ if (response.scopeRef === this.scopeRef && this.revision !== null
96
+ && response.revision < this.revision) return response;
97
+ const scopeChanged = this.scopeRef !== null && this.scopeRef !== response.scopeRef;
98
+ const previousCanonical = this.canonicalText;
99
+ const previousSubmissions = [...this.submissions.values()];
100
+ if (scopeChanged) {
101
+ const unconfirmed = previousSubmissions.filter(value => value.receipt.delivery === 'unconfirmed');
102
+ const latest = unconfirmed.at(-1);
103
+ if (latest && (!this.localEdited || this.localText === previousCanonical)) {
104
+ this.localText = latest.text;
105
+ this.localEdited = true;
106
+ ++this.localVersion;
107
+ }
108
+ for (const submission of unconfirmed) this.invalidated({
109
+ ...copySubmission(submission), scopeRef: this.scopeRef, reason: 'scope_changed',
110
+ });
111
+ this.reportedReceipts.clear();
112
+ } else if (!this.localEdited || this.localText === previousCanonical) {
113
+ this.localText = response.text;
114
+ this.localEdited = false;
115
+ }
116
+ this.scopeRef = response.scopeRef;
117
+ this.revision = response.revision;
118
+ this.canonicalText = response.text;
119
+ this.writable = response.writable;
120
+ this.submissions = new Map(response.submissions.map(value => [value.clientMessageId, copySubmission(value)]));
121
+ this.status = scopeChanged ? 'scope_changed'
122
+ : preserveStatus ?? (response.writable ? 'ready' : 'readonly');
123
+ for (const submission of this.submissions.values()) this.report(submission);
124
+ this.emit();
125
+ if (!scopeChanged) await this.reconcileUnconfirmed();
126
+ return response;
127
+ } catch (error) {
128
+ this.status = error?.code ?? 'unavailable';
129
+ this.emit();
130
+ return null;
131
+ }
132
+ }
133
+
134
+ report(submission, restored = true) {
135
+ const key = `${this.scopeRef}\u0000${submission.clientMessageId}`;
136
+ const fingerprint = receiptFingerprint(submission.receipt);
137
+ if (this.reportedReceipts.get(key) === fingerprint) return;
138
+ this.reportedReceipts.set(key, fingerprint);
139
+ this.delivery({ ...copySubmission(submission), scopeRef: this.scopeRef, restored });
140
+ }
141
+
142
+ async reconcileUnconfirmed() {
143
+ const scopeRef = this.scopeRef;
144
+ for (const submission of [...this.submissions.values()]) {
145
+ if (submission.receipt.delivery !== 'unconfirmed' || scopeRef !== this.scopeRef) continue;
146
+ try {
147
+ const receipt = await this.client.readConversationDelivery(submission.clientMessageId, scopeRef);
148
+ if (scopeRef !== this.scopeRef) return;
149
+ const updated = { ...submission, receipt };
150
+ if (receipt.delivery !== 'rejected') this.submissions.set(submission.clientMessageId, updated);
151
+ this.report(updated);
152
+ } catch (error) {
153
+ if (error?.code === 'invalid_request' && scopeRef !== this.scopeRef) return;
154
+ this.status = error?.code ?? 'unavailable';
155
+ }
156
+ }
157
+ this.emit();
158
+ }
159
+
160
+ async save() {
161
+ if (this.saving) {
162
+ this.saveAgain = true;
163
+ return this.saving;
164
+ }
165
+ const operation = (async () => {
166
+ let result;
167
+ do {
168
+ this.saveAgain = false;
169
+ result = await this.saveOnce();
170
+ } while (this.saveAgain);
171
+ return result;
172
+ })();
173
+ this.saving = operation;
174
+ try { return await operation; }
175
+ finally { if (this.saving === operation) this.saving = undefined; }
176
+ }
177
+
178
+ async saveOnce() {
179
+ if (this.scopeRef === null) await this.load();
180
+ const text = this.localText;
181
+ const version = this.localVersion;
182
+ if (this.scopeRef === null || this.revision === null) return null;
183
+ if (!this.writable || text.length > 8000) {
184
+ this.status = 'readonly';
185
+ this.emit();
186
+ return null;
187
+ }
188
+ if (text === this.canonicalText) return this.canonicalReply();
189
+ const expectedRevision = this.revision;
190
+ const scopeRef = this.scopeRef;
191
+ this.status = 'saving';
192
+ this.emit();
193
+ try {
194
+ const response = await this.client.saveConversationDraft(expectedRevision, text, scopeRef);
195
+ if (response.status !== 'available') {
196
+ this.revision = response.revision;
197
+ this.status = response.status;
198
+ this.emit();
199
+ await this.load({ preserveStatus: response.status });
200
+ return response;
201
+ }
202
+ if (response.scopeRef !== scopeRef) {
203
+ await this.load({ preserveStatus: 'scope_changed' });
204
+ return null;
205
+ }
206
+ if (this.revision !== null && response.revision < this.revision) return this.canonicalReply();
207
+ const preserveStatus = version === this.localVersion ? 'ready' : 'editing';
208
+ this.applySaved(response, preserveStatus);
209
+ return response;
210
+ } catch (error) {
211
+ this.status = error?.code ?? 'unavailable';
212
+ this.emit();
213
+ return null;
214
+ }
215
+ }
216
+
217
+ applySaved(response, status) {
218
+ this.scopeRef = response.scopeRef;
219
+ this.revision = response.revision;
220
+ this.canonicalText = response.text;
221
+ this.writable = response.writable;
222
+ this.submissions = new Map(response.submissions.map(value => [value.clientMessageId, copySubmission(value)]));
223
+ this.status = response.writable ? status : 'readonly';
224
+ for (const submission of this.submissions.values()) this.report(submission);
225
+ this.emit();
226
+ }
227
+
228
+ async flush() {
229
+ if (this.saveTimer !== undefined) {
230
+ this.cancel(this.saveTimer);
231
+ this.saveTimer = undefined;
232
+ }
233
+ return this.save();
234
+ }
235
+
236
+ async submit() {
237
+ const text = this.localText.trim();
238
+ if (!text) return null;
239
+ if (text !== this.localText) {
240
+ this.localText = text;
241
+ this.localEdited = true;
242
+ ++this.localVersion;
243
+ this.emit();
244
+ }
245
+ const submittedVersion = this.localVersion;
246
+ const saved = await this.flush();
247
+ if (!saved || saved.status !== 'available' || saved.text !== text || !saved.writable) return null;
248
+ const submission = {
249
+ clientMessageId: this.uuid(), text, submissionRevision: saved.revision,
250
+ receipt: null,
251
+ };
252
+ return this.send(submission, submittedVersion);
253
+ }
254
+
255
+ async retry(submission) {
256
+ const current = this.submissions.get(submission.clientMessageId);
257
+ if (!current || current.receipt.delivery !== 'unconfirmed'
258
+ || current.text !== submission.text
259
+ || current.submissionRevision !== submission.submissionRevision) return null;
260
+ return this.send(current, null);
261
+ }
262
+
263
+ async send(submission, submittedVersion) {
264
+ const scopeRef = this.scopeRef;
265
+ if (scopeRef === null) return null;
266
+ try {
267
+ const receipt = await this.client.submitConversationText(
268
+ submission.text, submission.clientMessageId, submission.submissionRevision, scopeRef,
269
+ );
270
+ if (scopeRef !== this.scopeRef) return null;
271
+ const updated = { ...submission, receipt };
272
+ if (receipt.delivery !== 'rejected') this.submissions.set(submission.clientMessageId, updated);
273
+ this.reportedReceipts.delete(`${scopeRef}\u0000${submission.clientMessageId}`);
274
+ this.report(updated, false);
275
+ if (receipt.delivery === 'accepted') {
276
+ if (submittedVersion !== null && submittedVersion === this.localVersion
277
+ && this.localText === submission.text) {
278
+ this.localText = '';
279
+ this.localEdited = false;
280
+ ++this.localVersion;
281
+ }
282
+ this.status = 'accepted';
283
+ this.emit();
284
+ await this.load();
285
+ } else if (receipt.delivery === 'unconfirmed') {
286
+ this.status = 'unconfirmed';
287
+ this.emit();
288
+ } else {
289
+ this.status = receipt.reason;
290
+ this.emit();
291
+ }
292
+ return receipt;
293
+ } catch (error) {
294
+ this.status = error?.code ?? 'unavailable';
295
+ this.emit();
296
+ return null;
297
+ }
298
+ }
299
+
300
+ close() {
301
+ if (this.saveTimer !== undefined) this.cancel(this.saveTimer);
302
+ this.saveTimer = undefined;
303
+ }
304
+ }
@@ -0,0 +1,176 @@
1
+ const copyRow = row => ({
2
+ ...row,
3
+ body: { ...row.body },
4
+ inputMessageIds: [...row.inputMessageIds],
5
+ });
6
+
7
+ /** In-memory presentation cache for the Host-owned v2 conversation projection. */
8
+ export class ConversationHistory {
9
+ constructor({ changed = () => {} } = {}) {
10
+ this.changed = changed;
11
+ this.rows = new Map();
12
+ this.order = [];
13
+ this.latestIds = new Set();
14
+ this.tokens = new Map();
15
+ this.consumed = new Map();
16
+ this.threadTitle = '';
17
+ this.hasMoreOlder = false;
18
+ this.olderCursor = null;
19
+ this.pageStatus = 'idle';
20
+ this.loadedOlder = false;
21
+ }
22
+
23
+ loadPage(page, { older = false } = {}) {
24
+ if (page.status !== 'available') {
25
+ this.pageStatus = 'stale';
26
+ this.emit();
27
+ return false;
28
+ }
29
+ this.pageStatus = 'available';
30
+ this.threadTitle = page.threadTitle;
31
+ const pageIds = [];
32
+ for (const value of page.messages) {
33
+ pageIds.push(value.id);
34
+ this.mergeRow(value);
35
+ }
36
+ const inPage = new Set(pageIds);
37
+ if (older) {
38
+ this.order = [...pageIds, ...this.order.filter(id => !inPage.has(id))];
39
+ } else {
40
+ const overlapsLatest = pageIds.some(id => this.latestIds.has(id));
41
+ const keepEarlier = this.latestIds.size === 0 || overlapsLatest || this.loadedOlder;
42
+ this.order = [...(keepEarlier ? this.order.filter(id => !inPage.has(id)) : []), ...pageIds];
43
+ this.latestIds = inPage;
44
+ for (const id of [...this.rows.keys()]) {
45
+ if (!this.order.includes(id)) this.remove(id);
46
+ }
47
+ }
48
+ if (older || !this.loadedOlder) {
49
+ this.hasMoreOlder = page.hasMoreOlder;
50
+ this.olderCursor = page.olderCursor;
51
+ }
52
+ if (older) this.loadedOlder = true;
53
+ this.emit();
54
+ return true;
55
+ }
56
+
57
+ mergeRow(value) {
58
+ const previous = this.rows.get(value.id);
59
+ const next = {
60
+ ...value,
61
+ body: { ...value.body },
62
+ inputMessageIds: [...value.inputMessageIds],
63
+ bodyStatus: value.body.complete ? 'complete' : 'loading',
64
+ };
65
+ if (!previous || previous.body.revision !== value.body.revision) {
66
+ this.forgetMessageTokens(value.id);
67
+ this.consumed.set(value.id, new Set());
68
+ this.rows.set(value.id, next);
69
+ this.registerContinuation(next);
70
+ return;
71
+ }
72
+
73
+ if (previous.text.startsWith(value.text)) {
74
+ next.text = previous.text;
75
+ next.body = previous.body;
76
+ next.bodyStatus = previous.bodyStatus;
77
+ } else if (!value.text.startsWith(previous.text)) {
78
+ next.text = previous.text;
79
+ next.body = previous.body;
80
+ next.bodyStatus = 'stale';
81
+ }
82
+ this.rows.set(value.id, next);
83
+ this.registerContinuation(next);
84
+ }
85
+
86
+ bodyContinuation(messageId) {
87
+ const value = this.rows.get(messageId);
88
+ return value?.bodyStatus === 'loading' ? value.body.continuation : null;
89
+ }
90
+
91
+ applyBody(continuation, reply) {
92
+ const binding = this.tokens.get(continuation);
93
+ if (!binding) return false;
94
+ const value = this.rows.get(binding.messageId);
95
+ if (!value || value.body.revision !== binding.revision) return false;
96
+ if (reply.status === 'withdrawn') {
97
+ this.remove(binding.messageId);
98
+ this.emit();
99
+ return true;
100
+ }
101
+ if (reply.status === 'stale') {
102
+ value.bodyStatus = 'stale';
103
+ this.emit();
104
+ return true;
105
+ }
106
+ const used = this.consumed.get(binding.messageId) ?? new Set();
107
+ if (used.has(continuation)) return true;
108
+ if (reply.messageId !== binding.messageId || reply.body.revision !== binding.revision
109
+ || value.body.continuation !== continuation) {
110
+ value.bodyStatus = 'stale';
111
+ this.emit();
112
+ return false;
113
+ }
114
+ used.add(continuation);
115
+ this.consumed.set(binding.messageId, used);
116
+ value.text += reply.text;
117
+ value.body = { ...reply.body };
118
+ value.bodyStatus = reply.body.complete ? 'complete' : 'loading';
119
+ if (!reply.body.complete && used.has(reply.body.continuation)) value.bodyStatus = 'stale';
120
+ this.registerContinuation(value);
121
+ this.emit();
122
+ return value.bodyStatus !== 'stale';
123
+ }
124
+
125
+ registerContinuation(value) {
126
+ const continuation = value.body.continuation;
127
+ if (!continuation || value.bodyStatus !== 'loading') return;
128
+ this.tokens.set(continuation, { messageId: value.id, revision: value.body.revision });
129
+ }
130
+
131
+ forgetMessageTokens(messageId) {
132
+ for (const [token, binding] of this.tokens) {
133
+ if (binding.messageId === messageId) this.tokens.delete(token);
134
+ }
135
+ }
136
+
137
+ remove(messageId) {
138
+ this.rows.delete(messageId);
139
+ this.order = this.order.filter(id => id !== messageId);
140
+ this.forgetMessageTokens(messageId);
141
+ this.consumed.delete(messageId);
142
+ }
143
+
144
+ clearProtected(reason) {
145
+ this.rows.clear();
146
+ this.order = [];
147
+ this.latestIds.clear();
148
+ this.tokens.clear();
149
+ this.consumed.clear();
150
+ this.threadTitle = '';
151
+ this.hasMoreOlder = false;
152
+ this.olderCursor = null;
153
+ this.pageStatus = reason;
154
+ this.emit();
155
+ }
156
+
157
+ message(messageId) {
158
+ const value = this.rows.get(messageId);
159
+ return value ? copyRow(value) : undefined;
160
+ }
161
+
162
+ snapshot() {
163
+ return {
164
+ threadTitle: this.threadTitle,
165
+ messages: this.order.flatMap(id => {
166
+ const value = this.rows.get(id);
167
+ return value ? [copyRow(value)] : [];
168
+ }),
169
+ hasMoreOlder: this.hasMoreOlder,
170
+ olderCursor: this.olderCursor,
171
+ pageStatus: this.pageStatus,
172
+ };
173
+ }
174
+
175
+ emit() { this.changed(this.snapshot()); }
176
+ }