@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,361 @@
1
+ import { newestHistoryAnchor, projectReplyBody, selectReplyPresentation } from './reply-letter-state.mjs';
2
+
3
+ const replyVisibleDuration = text => Math.min(30_000, 6_000 + Math.ceil([...text].length / 8) * 1_000);
4
+
5
+ /**
6
+ * Transient presentation state for a Host-owned conversation.
7
+ *
8
+ * Inputs are normalized bridge rows. In particular, inputMessageIds must be
9
+ * supplied by the trusted Host projection; this controller never guesses a
10
+ * relationship from timestamps, call ids, adjacency or equal text.
11
+ */
12
+ export class ReplyLetterController {
13
+ constructor({ render, requestContinuation = () => {}, now = Date.now,
14
+ schedule = setTimeout, cancel = clearTimeout }) {
15
+ Object.assign(this, { render, requestContinuation, now });
16
+ this.schedule = (...args) => Reflect.apply(schedule, globalThis, args);
17
+ this.cancel = (...args) => Reflect.apply(cancel, globalThis, args);
18
+ this.mode = 'idle';
19
+ this.pending = new Map();
20
+ this.settled = [];
21
+ this.pairs = new Map();
22
+ this.history = [];
23
+ this.draft = null;
24
+ this.letter = null;
25
+ this.reply = null;
26
+ this.retained = false;
27
+ this.hasNewer = false;
28
+ this.historyPresentation = null;
29
+ this.selectedMessageId = null;
30
+ this.pauseReasons = new Set();
31
+ this.requestedContinuations = new Set();
32
+ this.dismissedReplies = new Set();
33
+ this.timerHandle = null;
34
+ this.timerStartedAt = null;
35
+ this.timerRemainingMs = 0;
36
+ this.blockedReason = null;
37
+ this.draftNotice = null;
38
+ }
39
+
40
+ recordDelivery(receipt, text, context = {}) {
41
+ this.blockedReason = null;
42
+ if (receipt.delivery !== 'accepted') {
43
+ this.draft = {
44
+ text,
45
+ clientMessageId: receipt.clientMessageId,
46
+ ...(context.submissionRevision === undefined ? {} : { submissionRevision: context.submissionRevision }),
47
+ ...(context.scopeRef === undefined ? {} : { scopeRef: context.scopeRef }),
48
+ settlement: receipt.delivery === 'rejected' ? receipt.reason : 'unconfirmed',
49
+ };
50
+ if (this.mode !== 'history') this.mode = 'compose';
51
+ this.emit();
52
+ return;
53
+ }
54
+ const letter = { text, clientMessageId: receipt.clientMessageId, inputMessageId: receipt.messageId };
55
+ const known = this.pending.get(receipt.messageId) ?? this.pairs.get(receipt.messageId)?.letter;
56
+ if (known?.clientMessageId === receipt.clientMessageId) {
57
+ if (this.draft?.clientMessageId === receipt.clientMessageId) this.draft = null;
58
+ this.emit();
59
+ return;
60
+ }
61
+ this.pending.set(receipt.messageId, letter);
62
+ if (context.restored === false || this.draft?.clientMessageId === receipt.clientMessageId) this.draft = null;
63
+ if (this.mode !== 'history') {
64
+ this.stopTimer();
65
+ this.reply = null;
66
+ this.letter = letter;
67
+ this.retained = false;
68
+ this.mode = 'sent';
69
+ }
70
+ this.emit();
71
+ }
72
+
73
+ invalidateDraft(value) {
74
+ this.draft = {
75
+ text: value.text,
76
+ clientMessageId: value.clientMessageId,
77
+ submissionRevision: value.submissionRevision,
78
+ scopeRef: value.scopeRef,
79
+ settlement: value.reason ?? 'scope_changed',
80
+ };
81
+ if (this.mode !== 'history') this.mode = 'compose';
82
+ this.emit();
83
+ }
84
+
85
+ setDraftNotice(value) {
86
+ if (this.draftNotice === value) return;
87
+ this.draftNotice = value;
88
+ this.emit();
89
+ }
90
+
91
+ openCompose() {
92
+ if (this.reply) this.dismissedReplies.add(this.reply.id);
93
+ this.stopTimer();
94
+ this.reply = null;
95
+ this.letter = null;
96
+ this.mode = 'compose';
97
+ this.retained = false;
98
+ this.hasNewer = false;
99
+ this.historyPresentation = null;
100
+ this.selectedMessageId = null;
101
+ this.pauseReasons.clear();
102
+ this.emit();
103
+ }
104
+
105
+ loadHistory(rows) {
106
+ this.history = rows.map(row => ({
107
+ id: row.id,
108
+ role: row.role,
109
+ text: row.text,
110
+ body: projectReplyBody(row),
111
+ inputMessageIds: [...(row.inputMessageIds ?? [])],
112
+ responsePhase: row.responsePhase ?? 'unknown',
113
+ deliveryReceipt: row.deliveryReceipt ? { ...row.deliveryReceipt } : undefined,
114
+ }));
115
+ let restoredLetter = null;
116
+ for (const row of this.history) {
117
+ const receipt = row.deliveryReceipt;
118
+ if (row.role !== 'user' || receipt?.delivery !== 'accepted' || receipt.messageId !== row.id) continue;
119
+ if (this.pending.has(row.id) || this.pairs.has(row.id)) continue;
120
+ restoredLetter = {
121
+ text: row.text,
122
+ clientMessageId: receipt.clientMessageId,
123
+ inputMessageId: row.id,
124
+ };
125
+ this.pending.set(row.id, restoredLetter);
126
+ }
127
+ let newestPair = null;
128
+ const settledNow = [];
129
+ for (const row of this.history) {
130
+ if (row.role !== 'assistant') continue;
131
+ if (row.responsePhase === 'completed') this.requestMissingBody(row);
132
+ for (const inputMessageId of row.inputMessageIds) {
133
+ const pending = this.pending.get(inputMessageId);
134
+ const existing = this.pairs.get(inputMessageId);
135
+ if (!pending && !existing) continue;
136
+ if (row.responsePhase !== 'completed') {
137
+ if (pending) {
138
+ const updated = { ...pending, responsePhase: row.responsePhase };
139
+ this.pending.set(inputMessageId, updated);
140
+ if (this.letter?.inputMessageId === inputMessageId) this.letter = updated;
141
+ }
142
+ continue;
143
+ }
144
+ const letter = pending ?? existing.letter;
145
+ const pair = { inputMessageId, replyId: row.id, letter, reply: this.replyValue(row) };
146
+ if (!existing) {
147
+ this.pending.delete(inputMessageId);
148
+ this.settled.push({ inputMessageId, replyId: row.id });
149
+ settledNow.push(pair);
150
+ }
151
+ this.pairs.set(inputMessageId, pair);
152
+ newestPair = pair;
153
+ }
154
+ }
155
+
156
+ const presentationPair = selectReplyPresentation({ mode: this.mode, letter: this.letter,
157
+ reply: this.reply, settledPairs: settledNow, pairs: this.pairs,
158
+ dismissedReplies: this.dismissedReplies });
159
+ if (newestPair && this.mode === 'history') {
160
+ if (newestPair.replyId !== this.selectedMessageId) this.hasNewer = true;
161
+ } else if (presentationPair) {
162
+ const changedReply = this.reply?.id !== presentationPair.replyId;
163
+ const becameComplete = this.reply?.id === presentationPair.replyId
164
+ && this.reply.complete === false && presentationPair.reply.complete === true;
165
+ this.letter = presentationPair.letter;
166
+ this.reply = presentationPair.reply;
167
+ this.selectedMessageId = presentationPair.replyId;
168
+ this.mode = 'reply';
169
+ this.historyPresentation = null;
170
+ if (changedReply) {
171
+ this.retained = false;
172
+ this.pauseReasons.clear();
173
+ this.stopTimer();
174
+ }
175
+ if ((changedReply || becameComplete) && presentationPair.reply.complete) {
176
+ this.timerRemainingMs = replyVisibleDuration(presentationPair.reply.text);
177
+ this.startTimer();
178
+ }
179
+ } else if (this.mode === 'sent') {
180
+ this.letter = [...this.pending.values()].at(-1) ?? null;
181
+ if (!this.letter) this.mode = 'idle';
182
+ } else if (restoredLetter && this.mode === 'idle') {
183
+ this.letter = restoredLetter;
184
+ this.mode = 'sent';
185
+ }
186
+ this.emit();
187
+ }
188
+
189
+ replyValue(row) {
190
+ return { id: row.id, text: row.text, complete: row.body.complete,
191
+ responsePhase: row.responsePhase };
192
+ }
193
+
194
+ requestMissingBody(row) {
195
+ const reference = row.body.continuation;
196
+ if (row.body.complete || !reference) {
197
+ for (const key of this.requestedContinuations) {
198
+ if (key.startsWith(`${row.id}\u0000`)) this.requestedContinuations.delete(key);
199
+ }
200
+ return;
201
+ }
202
+ const key = `${row.id}\u0000${reference}`;
203
+ if (this.requestedContinuations.has(key)) return;
204
+ this.requestedContinuations.add(key);
205
+ this.requestContinuation({ messageId: row.id, continuation: reference });
206
+ }
207
+
208
+ releaseContinuation(messageId, continuation) {
209
+ this.requestedContinuations.delete(`${messageId}\u0000${continuation}`);
210
+ }
211
+
212
+ pause(reason) {
213
+ if (this.pauseReasons.has(reason)) return;
214
+ this.pauseReasons.add(reason);
215
+ if (this.timerHandle !== null) {
216
+ this.timerRemainingMs = Math.max(0, this.timerRemainingMs - (this.now() - this.timerStartedAt));
217
+ this.stopTimer(false);
218
+ }
219
+ this.emit();
220
+ }
221
+
222
+ resume(reason) {
223
+ if (!this.pauseReasons.delete(reason)) return;
224
+ if (this.pauseReasons.size === 0) this.startTimer();
225
+ this.emit();
226
+ }
227
+
228
+ pin() {
229
+ if (this.mode !== 'reply') return;
230
+ this.retained = true;
231
+ this.stopTimer();
232
+ this.emit();
233
+ }
234
+
235
+ close() {
236
+ if (this.reply) this.dismissedReplies.add(this.reply.id);
237
+ this.stopTimer();
238
+ this.reply = null;
239
+ this.retained = false;
240
+ this.historyPresentation = null;
241
+ this.selectedMessageId = null;
242
+ this.letter = [...this.pending.values()].at(-1) ?? null;
243
+ this.mode = this.letter ? 'sent' : 'idle';
244
+ this.emit();
245
+ }
246
+
247
+ withdrawReply(replyId) {
248
+ let restored = null;
249
+ for (const [inputMessageId, pair] of this.pairs) {
250
+ if (pair.replyId !== replyId) continue;
251
+ restored = { ...pair.letter, responsePhase: 'withdrawn' };
252
+ this.pairs.delete(inputMessageId);
253
+ this.pending.set(inputMessageId, restored);
254
+ }
255
+ this.settled = this.settled.filter(value => value.replyId !== replyId);
256
+ this.history = this.history.filter(value => value.id !== replyId);
257
+ this.dismissedReplies.delete(replyId);
258
+ for (const key of this.requestedContinuations) {
259
+ if (key.startsWith(`${replyId}\u0000`)) this.requestedContinuations.delete(key);
260
+ }
261
+ if (this.reply?.id === replyId) {
262
+ this.stopTimer();
263
+ this.reply = null;
264
+ this.retained = false;
265
+ this.historyPresentation = null;
266
+ this.selectedMessageId = null;
267
+ this.letter = restored ?? [...this.pending.values()].at(-1) ?? null;
268
+ this.mode = this.letter ? 'sent' : 'idle';
269
+ }
270
+ this.emit();
271
+ }
272
+
273
+ clearProtected(reason) {
274
+ this.stopTimer();
275
+ this.pending.clear();
276
+ this.settled = [];
277
+ this.pairs.clear();
278
+ this.history = [];
279
+ this.letter = null;
280
+ this.reply = null;
281
+ this.retained = false;
282
+ this.hasNewer = false;
283
+ this.historyPresentation = null;
284
+ this.selectedMessageId = null;
285
+ this.requestedContinuations.clear();
286
+ this.dismissedReplies.clear();
287
+ this.blockedReason = reason;
288
+ this.mode = this.draft ? 'compose' : 'idle';
289
+ this.emit();
290
+ }
291
+
292
+ openHistory(messageId = newestHistoryAnchor(this.selectedMessageId, this.settled, this.history)) {
293
+ this.stopTimer();
294
+ this.mode = 'history';
295
+ this.retained = true;
296
+ this.historyPresentation = 'conversation';
297
+ this.selectedMessageId = messageId;
298
+ this.hasNewer = false;
299
+ this.emit();
300
+ }
301
+
302
+ openReplyHistory(messageId = newestHistoryAnchor(this.selectedMessageId, this.settled, this.history)) {
303
+ this.stopTimer();
304
+ this.mode = 'history';
305
+ this.retained = true;
306
+ this.historyPresentation = 'reply';
307
+ this.selectedMessageId = messageId;
308
+ this.hasNewer = false;
309
+ this.emit();
310
+ }
311
+
312
+ returnLatest() {
313
+ this.selectedMessageId = this.settled.at(-1)?.replyId ?? this.history.at(-1)?.id ?? null;
314
+ this.hasNewer = false;
315
+ this.emit();
316
+ }
317
+
318
+ startTimer() {
319
+ if (this.timerHandle !== null || this.timerRemainingMs <= 0 || this.retained
320
+ || this.mode !== 'reply' || !this.reply?.complete || this.pauseReasons.size > 0) return;
321
+ this.timerStartedAt = this.now();
322
+ this.timerHandle = this.schedule(() => {
323
+ this.timerHandle = null;
324
+ this.timerStartedAt = null;
325
+ this.timerRemainingMs = 0;
326
+ this.close();
327
+ }, this.timerRemainingMs);
328
+ }
329
+
330
+ stopTimer(reset = true) {
331
+ if (this.timerHandle !== null) this.cancel(this.timerHandle);
332
+ this.timerHandle = null;
333
+ this.timerStartedAt = null;
334
+ if (reset) this.timerRemainingMs = 0;
335
+ }
336
+
337
+ snapshot() {
338
+ const elapsed = this.timerHandle === null ? 0 : this.now() - this.timerStartedAt;
339
+ return {
340
+ mode: this.mode,
341
+ draft: this.draft,
342
+ letter: this.letter,
343
+ reply: this.reply,
344
+ retained: this.retained,
345
+ historyPresentation: this.mode === 'history' ? this.historyPresentation : null,
346
+ selectedMessageId: this.selectedMessageId,
347
+ hasNewer: this.hasNewer,
348
+ blockedReason: this.blockedReason,
349
+ draftNotice: this.draftNotice,
350
+ pendingInputMessageIds: [...this.pending.keys()],
351
+ settled: this.settled.map(value => ({ ...value })),
352
+ history: this.history.map(row => ({ ...row, body: { ...row.body }, inputMessageIds: [...row.inputMessageIds] })),
353
+ timer: {
354
+ running: this.timerHandle !== null,
355
+ remainingMs: Math.max(0, this.timerRemainingMs - elapsed),
356
+ },
357
+ };
358
+ }
359
+
360
+ emit() { this.render(this.snapshot()); }
361
+ }
@@ -0,0 +1,38 @@
1
+ export const projectReplyBody = row => ({
2
+ revision: row.body?.revision ?? '',
3
+ complete: row.body?.complete === true,
4
+ continuation: row.body?.continuation ?? null,
5
+ });
6
+
7
+ function newestSettledPair(settledPairs, dismissedReplies, inputMessageId) {
8
+ for (let index = settledPairs.length - 1; index >= 0; index -= 1) {
9
+ const pair = settledPairs[index];
10
+ if (dismissedReplies.has(pair.replyId)) continue;
11
+ if (inputMessageId !== undefined && pair.inputMessageId !== inputMessageId) continue;
12
+ return pair;
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ /** Only a newly settled relevant reply, or the reply already on screen, may take presentation focus. */
18
+ export function selectReplyPresentation({ mode, letter, reply, settledPairs, pairs, dismissedReplies }) {
19
+ if (mode === 'compose' || mode === 'history') return undefined;
20
+ if (mode === 'sent') {
21
+ if (!letter) return undefined;
22
+ return newestSettledPair(settledPairs, dismissedReplies, letter.inputMessageId);
23
+ }
24
+ const freshPair = newestSettledPair(settledPairs, dismissedReplies);
25
+ if (mode === 'idle' || (mode === 'reply' && freshPair)) return freshPair;
26
+ if (mode !== 'reply' || !reply) return undefined;
27
+ for (const pair of pairs.values()) {
28
+ if (pair.replyId === reply.id) return pair;
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ export function newestHistoryAnchor(selectedMessageId, settled, history) {
34
+ if (selectedMessageId !== null) return selectedMessageId;
35
+ const settledReplyId = settled.at(-1)?.replyId;
36
+ if (settledReplyId !== undefined) return settledReplyId;
37
+ return history.at(-1)?.id ?? null;
38
+ }
@@ -0,0 +1,116 @@
1
+ const phaseLabel = phase => ({
2
+ processing: '正在回复',
3
+ failed: '回复失败',
4
+ interrupted: '回复中断',
5
+ canceled: '回复已取消',
6
+ withdrawn: '回复已撤回',
7
+ }[phase] ?? '已发送');
8
+
9
+ const draftStatus = settlement => ({
10
+ unconfirmed: '发送结果未确认',
11
+ scope_changed: '原聊天已变化 · 这条消息没有重发',
12
+ pending_limit: '待确认消息已达 32 条 · 这条没有发送',
13
+ draft_changed: '草稿已在别处变化 · 请核对后再发送',
14
+ unsupported_draft: '当前草稿含附件或过长 · 请回 Cat Café 继续编辑',
15
+ conflict: '草稿已在别处变化 · 本地输入仍保留',
16
+ readonly: '当前草稿含附件或过长 · 请回 Cat Café 继续编辑',
17
+ permission_required: '聊天权限已撤销 · 本地输入仍保留',
18
+ unavailable: '草稿暂未同步 · 本地输入仍保留',
19
+ }[settlement] ?? '');
20
+
21
+ /** DOM projection for compose → sent letter → complete reply → retained history. */
22
+ export class ReplyLetterView {
23
+ constructor({ document, controller, retry, schedule = setTimeout, cancel = clearTimeout }) {
24
+ this.$ = id => document.getElementById(id);
25
+ Object.assign(this, { controller, retry });
26
+ this.schedule = (...args) => Reflect.apply(schedule, globalThis, args);
27
+ this.cancel = (...args) => Reflect.apply(cancel, globalThis, args);
28
+ this.bind();
29
+ }
30
+
31
+ bind() {
32
+ const card = this.$('reply-card');
33
+ card.addEventListener('pointerenter', () => this.controller.pause('hover'));
34
+ card.addEventListener('pointerleave', () => this.controller.resume('hover'));
35
+ card.addEventListener('focusin', () => this.controller.pause('focus'));
36
+ card.addEventListener('focusout', event => {
37
+ if (!event.relatedTarget || !card.contains?.(event.relatedTarget)) this.controller.resume('focus');
38
+ });
39
+ const pauseScroll = () => {
40
+ this.controller.pause('scroll');
41
+ if (this.scrollTimer !== undefined) this.cancel(this.scrollTimer);
42
+ this.scrollTimer = this.schedule(() => {
43
+ this.scrollTimer = undefined;
44
+ this.controller.resume('scroll');
45
+ }, 500);
46
+ this.scrollTimer?.unref?.();
47
+ };
48
+ card.addEventListener('wheel', pauseScroll);
49
+ card.addEventListener('scroll', pauseScroll);
50
+ card.addEventListener('pointerdown', () => this.controller.pause('pointer'));
51
+ card.addEventListener('pointerup', () => this.controller.resume('pointer'));
52
+ card.addEventListener('pointercancel', () => this.controller.resume('pointer'));
53
+ this.$('reply-retain').onclick = () => this.controller.pin();
54
+ this.$('reply-close').onclick = () => this.controller.close();
55
+ this.$('reply-history-open').onclick = () => this.controller.openReplyHistory();
56
+ this.$('reply-return-latest').onclick = () => this.controller.returnLatest();
57
+ this.$('reply-retry').onclick = () => { if (this.value?.draft) this.retry(this.value.draft); };
58
+ }
59
+
60
+ render(value) {
61
+ this.value = value;
62
+ const history = value.mode === 'history';
63
+ const replyHistory = history && value.historyPresentation === 'reply';
64
+ const conversationHistory = history && !replyHistory;
65
+ const compose = value.mode === 'compose' || conversationHistory;
66
+ this.$('compose').hidden = !compose;
67
+ this.$('chat-history-header').hidden = !conversationHistory;
68
+ this.$('chat-name').hidden = replyHistory;
69
+ this.$('chat-history-dismiss').hidden = replyHistory;
70
+ this.$('reply-history-close').hidden = !replyHistory;
71
+ this.$('chat-transcript-shell').hidden = !history;
72
+ this.$('chat-transcript-shell').dataset.hasNewer = String(history && value.hasNewer);
73
+ this.$('chat-transcript').hidden = !history;
74
+ this.$('chat-scope').hidden = !conversationHistory;
75
+ this.$('chat-connection-status').hidden = replyHistory;
76
+ this.$('reply-sent').hidden = value.mode !== 'sent';
77
+ this.$('reply-card').hidden = value.mode !== 'reply';
78
+ this.$('reply-return-latest').hidden = !history || !value.hasNewer;
79
+
80
+ const retrying = value.mode === 'compose' && value.draft?.settlement === 'unconfirmed';
81
+ this.$('reply-retry').hidden = !retrying;
82
+ const feedback = value.draft ? draftStatus(value.draft.settlement)
83
+ : value.draftNotice ? draftStatus(value.draftNotice)
84
+ : value.blockedReason === 'permission_required' ? '聊天权限已撤销' : '';
85
+ this.$('chat-status').textContent = feedback;
86
+ this.$('chat-feedback').hidden = !compose || (!feedback && !retrying);
87
+ if (value.draft) {
88
+ const input = this.$('message');
89
+ if (!input.value || input.value === this.lastRenderedDraft) input.value = value.draft.text;
90
+ this.lastRenderedDraft = value.draft.text;
91
+ }
92
+
93
+ if (value.letter) {
94
+ this.$('reply-letter-text').textContent = '';
95
+ this.$('reply-letter-text').hidden = true;
96
+ this.$('reply-input-text').textContent = value.letter.text;
97
+ this.$('reply-sent-status').textContent = phaseLabel(value.letter.responsePhase);
98
+ }
99
+ if (value.reply) {
100
+ this.$('reply-body').textContent = value.reply.text;
101
+ this.$('reply-body-status').textContent = value.reply.complete ? '' : '正在载入完整回复…';
102
+ } else {
103
+ this.$('reply-body').textContent = '';
104
+ this.$('reply-body-status').textContent = '';
105
+ }
106
+
107
+ const canRetain = value.mode === 'reply' && value.reply?.complete === true && !value.retained;
108
+ this.$('reply-retain').hidden = !canRetain;
109
+ this.$('reply-close').hidden = value.mode !== 'reply' || !value.retained;
110
+ this.$('reply-retain').dataset.running = String(canRetain && value.timer.running);
111
+ this.$('reply-retain').style.setProperty('--reply-visible-ms', `${value.timer.remainingMs}ms`);
112
+ this.$('chat').dataset.replyMode = value.mode;
113
+ this.$('chat').dataset.historyPresentation = history
114
+ ? value.historyPresentation ?? 'conversation' : '';
115
+ }
116
+ }
@@ -0,0 +1,171 @@
1
+ const sameValue = (left, right) => Object.is(left, right);
2
+ const canStopCall = field => ['dutyCatProfileId', 'householdReadsAllowed'].includes(field);
3
+
4
+ /**
5
+ * Renderer-side settings state only. The Host remains the single source of
6
+ * persisted values, roster truth, media stopping and plugin lifecycle.
7
+ */
8
+ export class SettingsController {
9
+ constructor({ client, changed = () => {} }) {
10
+ this.client = client;
11
+ this.changed = changed;
12
+ this.readOperation = 0;
13
+ this.writeOperation = 0;
14
+ this.state = { phase: 'idle', page: 'main', saving: false, notice: null, confirmation: null, retry: null };
15
+ }
16
+
17
+ publish(patch = {}) {
18
+ this.state = { ...this.state, ...patch };
19
+ this.changed(this.state);
20
+ }
21
+
22
+ applySettings(reply, patch = {}) {
23
+ if (reply?.kind !== 'settings' || reply.status !== 'available') return false;
24
+ this.publish({
25
+ phase: this.state.saving ? 'saving' : 'ready',
26
+ values: reply.values,
27
+ companions: reply.companions,
28
+ selectedCompanionStatus: reply.selectedCompanionStatus,
29
+ ...patch,
30
+ });
31
+ return true;
32
+ }
33
+
34
+ async load({ preserveSettlement = false } = {}) {
35
+ // A panel reopen is presentation work, not authority to cancel an in-flight
36
+ // write. The write performs its own canonical readback before settlement.
37
+ if (this.state.saving) return false;
38
+ const operation = ++this.readOperation;
39
+ this.publish({ phase: 'loading', ...(preserveSettlement ? {} : { notice: null, retry: null }) });
40
+ try {
41
+ const reply = await this.client.readSettings();
42
+ if (operation !== this.readOperation) return false;
43
+ const clearsUnknown = preserveSettlement && this.state.notice?.kind === 'unconfirmed';
44
+ if (this.applySettings(reply, clearsUnknown ? { notice: null, retry: null } : {})) return true;
45
+ this.publish({ phase: 'unavailable', reason: reply.reason, saving: false });
46
+ } catch (error) {
47
+ if (operation !== this.readOperation) return false;
48
+ this.publish({ phase: 'unavailable', saving: false,
49
+ reason: error?.code === 'invalid_request' ? 'host_upgrade_required' : 'temporarily_unavailable' });
50
+ }
51
+ return false;
52
+ }
53
+
54
+ navigate(page) {
55
+ this.publish({ page, confirmation: null });
56
+ }
57
+
58
+ async requestUpdate(field, value, { confirm = false } = {}) {
59
+ if (this.state.saving || sameValue(this.state.values?.[field], value)) return false;
60
+ if (confirm) {
61
+ this.publish({ page: 'confirm',
62
+ confirmation: { kind: 'setting', field, value, returnPage: this.state.page },
63
+ notice: null, retry: null });
64
+ return true;
65
+ }
66
+ await this.commitUpdate(field, value, this.state.page);
67
+ return true;
68
+ }
69
+
70
+ async readCanonical(operation) {
71
+ try {
72
+ const reply = await this.client.readSettings();
73
+ if (operation !== this.writeOperation || reply?.kind !== 'settings' || reply.status !== 'available') return null;
74
+ return reply;
75
+ } catch { return null; }
76
+ }
77
+
78
+ async commitUpdate(field, value, returnPage = this.state.page) {
79
+ if (this.state.saving) return { callStopped: null };
80
+ const operation = ++this.writeOperation;
81
+ this.publish({ phase: 'saving', saving: true, notice: null, retry: null });
82
+ let receipt;
83
+ try {
84
+ receipt = await this.client.updateSetting(field, value);
85
+ } catch {
86
+ if (operation !== this.writeOperation) return { callStopped: null };
87
+ const canonical = await this.readCanonical(operation);
88
+ if (operation !== this.writeOperation) return { callStopped: null };
89
+ if (canonical) this.applySettings(canonical);
90
+ const saved = canonical && sameValue(canonical.values[field], value);
91
+ const callStopped = canStopCall(field) ? null : false;
92
+ this.publish({ phase: canonical ? 'ready' : this.state.values ? 'ready' : 'unavailable', saving: false,
93
+ page: returnPage, confirmation: null,
94
+ retry: canonical && !saved ? { field, value } : null,
95
+ notice: canonical
96
+ ? { kind: saved ? 'reconciled_saved' : 'reconciled_not_saved', field, callStopped }
97
+ : { kind: 'unconfirmed', field, callStopped } });
98
+ return { callStopped };
99
+ }
100
+ if (operation !== this.writeOperation) return { callStopped: null };
101
+ const canonical = await this.readCanonical(operation);
102
+ if (operation !== this.writeOperation) return { callStopped: null };
103
+ if (canonical) this.applySettings(canonical);
104
+ const callStopped = receipt.callStatus === 'stopped';
105
+ let notice;
106
+ if (receipt.outcome === 'saved') {
107
+ notice = { kind: 'saved', field, callStopped, applies: receipt.applies };
108
+ } else if (receipt.outcome === 'rejected') {
109
+ notice = { kind: 'rejected', field, callStopped, reason: receipt.reason };
110
+ } else if (!canonical) {
111
+ notice = { kind: 'unconfirmed', field, callStopped };
112
+ } else {
113
+ notice = {
114
+ kind: sameValue(canonical.values[field], value) ? 'reconciled_saved' : 'reconciled_not_saved',
115
+ field,
116
+ callStopped,
117
+ };
118
+ }
119
+ const retry = (receipt.outcome === 'rejected' && ['save_failed', 'call_stop_failed'].includes(receipt.reason))
120
+ || (receipt.outcome === 'unconfirmed' && canonical && !sameValue(canonical.values[field], value))
121
+ ? { field, value } : null;
122
+ this.publish({ phase: canonical ? 'ready' : this.state.values ? 'ready' : 'unavailable', saving: false,
123
+ page: returnPage, confirmation: null, notice, retry });
124
+ return { callStopped };
125
+ }
126
+
127
+ async confirm() {
128
+ const confirmation = this.state.confirmation;
129
+ if (!confirmation || this.state.saving) return { callStopped: null };
130
+ if (confirmation.kind === 'setting') {
131
+ return this.commitUpdate(confirmation.field, confirmation.value, confirmation.returnPage);
132
+ }
133
+ const operation = ++this.writeOperation;
134
+ this.publish({ phase: 'saving', saving: true, notice: null });
135
+ try {
136
+ await this.client.disableCompanion();
137
+ if (operation !== this.writeOperation) return { callStopped: null };
138
+ this.publish({ phase: 'disabled', saving: false, confirmation: null });
139
+ return { callStopped: true };
140
+ } catch {
141
+ if (operation !== this.writeOperation) return { callStopped: null };
142
+ this.publish({ phase: this.state.values ? 'ready' : 'unavailable', saving: false,
143
+ page: confirmation.returnPage, confirmation: null, notice: { kind: 'disable_failed' } });
144
+ return { callStopped: null };
145
+ }
146
+ }
147
+
148
+ requestDisable() {
149
+ if (this.state.saving) return;
150
+ this.publish({ page: 'disable', confirmation: { kind: 'disable', returnPage: this.state.page }, notice: null });
151
+ }
152
+
153
+ async retry({ confirm = false } = {}) {
154
+ const retry = this.state.retry;
155
+ if (!retry || this.state.saving) return;
156
+ if (confirm && canStopCall(retry.field)) {
157
+ this.publish({ page: 'confirm',
158
+ confirmation: { kind: 'setting', field: retry.field, value: retry.value, returnPage: this.state.page } });
159
+ return;
160
+ }
161
+ await this.commitUpdate(retry.field, retry.value, this.state.page);
162
+ }
163
+
164
+ async resetPosition() {
165
+ if (this.state.saving) return;
166
+ try {
167
+ await this.client.resetPosition();
168
+ this.publish({ notice: { kind: 'position_reset' } });
169
+ } catch { this.publish({ notice: { kind: 'position_reset_failed' } }); }
170
+ }
171
+ }