@clowder-ai/companion 0.1.0-alpha.3 → 0.1.0-alpha.31

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.
Files changed (70) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +54 -5
  3. package/manifest.json +3 -3
  4. package/package.json +5 -3
  5. package/plugin.yaml +3 -3
  6. package/renderer/app-assist-controller.mjs +125 -0
  7. package/renderer/call-controls-presentation.mjs +179 -0
  8. package/renderer/call-duration.mjs +59 -0
  9. package/renderer/call-transcript-view.mjs +240 -0
  10. package/renderer/chat-unread.mjs +44 -0
  11. package/renderer/client.mjs +75 -3
  12. package/renderer/compact-size.mjs +8 -0
  13. package/renderer/companion-identity.mjs +42 -0
  14. package/renderer/conversation-draft.mjs +304 -0
  15. package/renderer/conversation-history.mjs +176 -0
  16. package/renderer/conversation-surface.mjs +201 -0
  17. package/renderer/conversation.mjs +155 -21
  18. package/renderer/decision-panel.mjs +122 -0
  19. package/renderer/decision-view.mjs +124 -0
  20. package/renderer/errors.mjs +4 -4
  21. package/renderer/index.html +202 -25
  22. package/renderer/living-body.mjs +147 -0
  23. package/renderer/living-edge.mjs +16 -0
  24. package/renderer/living-geometry.mjs +96 -0
  25. package/renderer/meeting-state.mjs +55 -0
  26. package/renderer/motion-facts.mjs +108 -0
  27. package/renderer/native-window-travel.mjs +89 -0
  28. package/renderer/native-work-motion.mjs +39 -0
  29. package/renderer/peer.mjs +1 -1
  30. package/renderer/pet-controls.mjs +139 -27
  31. package/renderer/pet-motion-assets.mjs +22 -0
  32. package/renderer/pet-motion.mjs +343 -0
  33. package/renderer/recent-bubble.mjs +93 -0
  34. package/renderer/reply-letter-controller.mjs +361 -0
  35. package/renderer/reply-letter-state.mjs +38 -0
  36. package/renderer/reply-letter-view.mjs +116 -0
  37. package/renderer/settings-controller.mjs +171 -0
  38. package/renderer/settings-view.mjs +241 -0
  39. package/renderer/skins/xianxian-codex.webp +0 -0
  40. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  41. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  42. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  43. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  44. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  45. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  46. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  47. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  48. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  49. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  50. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  51. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  52. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  53. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  54. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  55. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  56. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  57. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  58. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  59. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  60. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  61. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  62. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  63. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  64. package/renderer/skins/xianxian-working-row.png +0 -0
  65. package/renderer/style.css +325 -27
  66. package/renderer/surface.mjs +308 -51
  67. package/renderer/transcript-view.mjs +117 -15
  68. package/renderer/visible-text-receipt.mjs +241 -0
  69. package/renderer/work-overview.mjs +95 -0
  70. package/source-lock.json +122 -2
@@ -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
+ }
@@ -0,0 +1,201 @@
1
+ import { explainError } from './errors.mjs';
2
+ import { ConversationHistory } from './conversation-history.mjs';
3
+ import { HostConversationDraft } from './conversation-draft.mjs';
4
+ import { ReplyLetterController } from './reply-letter-controller.mjs';
5
+ import { ReplyLetterView } from './reply-letter-view.mjs';
6
+
7
+ /** Owns the chat panel's history, durable draft and reply-letter interaction. */
8
+ export class ConversationSurface {
9
+ constructor({ document, client, controls, bubble, transcript,
10
+ onAccepted, retryLegacy, status, scheduleInterval = setInterval,
11
+ cancelInterval = clearInterval }) {
12
+ this.$ = id => document.getElementById(id);
13
+ Object.assign(this, { client, controls, bubble, transcript,
14
+ onAccepted, retryLegacy, status });
15
+ this.scheduleInterval = (...args) => Reflect.apply(scheduleInterval, globalThis, args);
16
+ this.cancelInterval = (...args) => Reflect.apply(cancelInterval, globalThis, args);
17
+ this.activePageRead = null;
18
+ this.pendingOlderRead = false;
19
+ this.bodyReads = new Set();
20
+ this.replyMode = 'idle';
21
+ this.historySelection = null;
22
+ this.controller = new ReplyLetterController({
23
+ requestContinuation: value => void this.readBody(value.messageId, value.continuation),
24
+ render: value => this.renderReply(value),
25
+ });
26
+ this.view = new ReplyLetterView({
27
+ document,
28
+ controller: this.controller,
29
+ retry: draft => void (draft.submissionRevision === undefined
30
+ ? this.retryLegacy(draft.text) : this.draft.retry(draft)),
31
+ });
32
+ this.lastRenderedDraft = '';
33
+ this.draft = new HostConversationDraft({
34
+ client,
35
+ changed: value => this.renderDraft(value),
36
+ delivery: value => {
37
+ this.controller.recordDelivery(value.receipt, value.text, value);
38
+ void this.readHistory();
39
+ },
40
+ invalidated: value => this.controller.invalidateDraft(value),
41
+ });
42
+ this.history = new ConversationHistory({ changed: value => this.renderHistory(value) });
43
+ this.bind();
44
+ }
45
+
46
+ bind() {
47
+ this.$('message').addEventListener('input', () => this.draft.update(this.$('message').value));
48
+ this.$('history').onclick = () => void this.client.openConversation().then(result => {
49
+ if (result.delivery === 'unconfirmed') this.status('打开聊天尚未确认 · 请从 Clowder 查看');
50
+ }).catch(error => this.status(explainError(error)));
51
+ this.$('reply-history-open').addEventListener('click', () => {
52
+ void this.controls.show('chat');
53
+ void this.readHistory();
54
+ });
55
+ this.$('reply-return-latest').addEventListener('click', () => {
56
+ this.$('chat-transcript').scrollTop = this.$('chat-transcript').scrollHeight;
57
+ });
58
+ this.$('chat-transcript').addEventListener('scroll', () => {
59
+ if (this.controller.mode === 'history' && this.$('chat-transcript').scrollTop < 24) {
60
+ void this.readOlderHistory();
61
+ }
62
+ });
63
+ this.$('chat-transcript').addEventListener('wheel', event => {
64
+ if (event.deltaY < 0 && this.controller.mode === 'history'
65
+ && this.$('chat-transcript').scrollTop <= 0) void this.readOlderHistory();
66
+ }, { passive: true });
67
+ this.$('compose').onsubmit = event => this.submit(event);
68
+ }
69
+
70
+ renderReply(value) {
71
+ const previous = this.replyMode;
72
+ const previousSelection = this.historySelection;
73
+ if (previous === 'history' && value.mode !== 'history') {
74
+ this.transcript.leaveHistory(previousSelection);
75
+ }
76
+ this.replyMode = value.mode;
77
+ this.view.render(value);
78
+ this.controls.remeasure?.();
79
+ if (value.mode === 'history'
80
+ && (previous !== 'history' || previousSelection !== value.selectedMessageId)) {
81
+ this.transcript.enterHistory(value.selectedMessageId);
82
+ }
83
+ this.historySelection = value.mode === 'history' ? value.selectedMessageId : null;
84
+ if (value.mode === 'reply' && previous !== 'reply' && this.controls.panel !== 'chat') {
85
+ void this.controls.show('chat');
86
+ }
87
+ if (value.mode === 'idle' && previous === 'reply' && this.controls.panel === 'chat') {
88
+ void this.controls.show('none');
89
+ }
90
+ }
91
+
92
+ renderDraft(value) {
93
+ const input = this.$('message');
94
+ if (input.value !== this.lastRenderedDraft && input.value !== value.localText) {
95
+ this.draft.update(input.value);
96
+ return;
97
+ }
98
+ input.value = value.localText;
99
+ this.lastRenderedDraft = value.localText;
100
+ this.controller.setDraftNotice(value.status);
101
+ }
102
+
103
+ renderHistory(value) {
104
+ this.threadTitle = value.threadTitle || this.threadTitle;
105
+ if (this.threadTitle) this.$('chat-name').textContent = this.threadTitle;
106
+ this.bubble.load(value.messages);
107
+ this.transcript.load(value.messages);
108
+ this.controller.loadHistory(value.messages);
109
+ this.$('history').textContent = value.hasMoreOlder
110
+ ? '在 Cat Café 中查看更早聊天 ↗' : '在 Cat Café 中查看完整聊天 ↗';
111
+ }
112
+
113
+ openWrite(callActive) {
114
+ if (callActive) this.controller.openHistory();
115
+ else this.controller.openCompose();
116
+ }
117
+
118
+ closeHistory() {
119
+ if (this.controller.mode === 'history') this.controller.close();
120
+ }
121
+
122
+ openHistory() { this.controller.openHistory(); }
123
+
124
+ async submit(event) {
125
+ event.preventDefault();
126
+ this.draft.update(this.$('message').value);
127
+ const receipt = await this.draft.submit();
128
+ if (receipt?.delivery !== 'accepted') return;
129
+ this.onAccepted();
130
+ void this.readHistory();
131
+ }
132
+
133
+ async readHistory({ before, older = false } = {}) {
134
+ const readKind = older ? 'older' : 'latest';
135
+ if (this.activePageRead) {
136
+ if (readKind === 'older' && this.activePageRead === 'latest') this.pendingOlderRead = true;
137
+ return;
138
+ }
139
+ this.activePageRead = readKind;
140
+ try {
141
+ const page = await this.client.readConversationPage(before === undefined ? {} : { before });
142
+ this.history.loadPage(page, { older });
143
+ this.$('chat-connection-status').textContent = page.status === 'stale'
144
+ ? '聊天位置已变化 · 请重新往上翻' : '';
145
+ } catch (error) {
146
+ if (error?.code === 'permission_required') {
147
+ this.history.clearProtected('permission_required');
148
+ this.controller.clearProtected('permission_required');
149
+ this.$('chat-connection-status').textContent = '聊天权限已撤销';
150
+ } else this.$('chat-connection-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示';
151
+ } finally {
152
+ const readPendingOlder = readKind === 'latest' && this.pendingOlderRead;
153
+ if (readKind === 'latest') this.pendingOlderRead = false;
154
+ this.activePageRead = null;
155
+ if (readPendingOlder) void this.readOlderHistory();
156
+ }
157
+ }
158
+
159
+ async readOlderHistory() {
160
+ const { hasMoreOlder, olderCursor, pageStatus } = this.history.snapshot();
161
+ if (this.controller.mode !== 'history' || pageStatus !== 'available' || !hasMoreOlder || !olderCursor) return;
162
+ await this.readHistory({ before: olderCursor, older: true });
163
+ }
164
+
165
+ async readBody(messageId, continuation) {
166
+ if (this.bodyReads.has(continuation) || this.history.bodyContinuation(messageId) !== continuation) return;
167
+ this.bodyReads.add(continuation);
168
+ try {
169
+ const result = await this.client.readConversationBody(continuation);
170
+ this.history.applyBody(continuation, result);
171
+ if (result.status === 'stale') {
172
+ this.controller.releaseContinuation(messageId, continuation);
173
+ this.$('chat-connection-status').textContent = '回复正文已变化 · 正在重新读取';
174
+ void this.readHistory();
175
+ }
176
+ if (result.status === 'withdrawn') this.controller.withdrawReply(messageId);
177
+ } catch (error) {
178
+ if (error?.code === 'permission_required') {
179
+ this.history.clearProtected('permission_required');
180
+ this.controller.clearProtected('permission_required');
181
+ this.$('chat-connection-status').textContent = '聊天权限已撤销';
182
+ } else {
183
+ this.controller.releaseContinuation(messageId, continuation);
184
+ this.$('chat-connection-status').textContent = '完整回复暂未读完 · 稍后重试';
185
+ }
186
+ } finally { this.bodyReads.delete(continuation); }
187
+ }
188
+
189
+ start() {
190
+ void this.readHistory();
191
+ void this.draft.load();
192
+ this.historyMonitor = this.scheduleInterval(() => void this.readHistory(), 3000);
193
+ this.draftMonitor = this.scheduleInterval(() => void this.draft.load(), 3000);
194
+ }
195
+
196
+ close() {
197
+ this.cancelInterval(this.historyMonitor);
198
+ this.cancelInterval(this.draftMonitor);
199
+ this.draft.close();
200
+ }
201
+ }