@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 +15 -2
- package/manifest.json +3 -3
- package/package.json +3 -3
- package/plugin.yaml +3 -3
- package/renderer/client.mjs +53 -1
- package/renderer/conversation-draft.mjs +304 -0
- package/renderer/conversation-history.mjs +176 -0
- package/renderer/conversation-surface.mjs +188 -0
- package/renderer/conversation.mjs +16 -2
- package/renderer/index.html +21 -4
- package/renderer/pet-controls.mjs +8 -2
- package/renderer/reply-letter-controller.mjs +343 -0
- package/renderer/reply-letter-state.mjs +38 -0
- package/renderer/reply-letter-view.mjs +103 -0
- package/renderer/style.css +36 -6
- package/renderer/surface.mjs +33 -34
|
@@ -0,0 +1,188 @@
|
|
|
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, getCallPhase,
|
|
10
|
+
onAccepted, retryLegacy, status, scheduleInterval = setInterval,
|
|
11
|
+
cancelInterval = clearInterval }) {
|
|
12
|
+
this.$ = id => document.getElementById(id);
|
|
13
|
+
Object.assign(this, { client, controls, bubble, transcript, getCallPhase,
|
|
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.controller = new ReplyLetterController({
|
|
22
|
+
requestContinuation: value => void this.readBody(value.messageId, value.continuation),
|
|
23
|
+
render: value => this.renderReply(value),
|
|
24
|
+
});
|
|
25
|
+
this.view = new ReplyLetterView({
|
|
26
|
+
document,
|
|
27
|
+
controller: this.controller,
|
|
28
|
+
retry: draft => void (draft.submissionRevision === undefined
|
|
29
|
+
? this.retryLegacy(draft.text) : this.draft.retry(draft)),
|
|
30
|
+
});
|
|
31
|
+
this.lastRenderedDraft = '';
|
|
32
|
+
this.draft = new HostConversationDraft({
|
|
33
|
+
client,
|
|
34
|
+
changed: value => this.renderDraft(value),
|
|
35
|
+
delivery: value => {
|
|
36
|
+
this.controller.recordDelivery(value.receipt, value.text, value);
|
|
37
|
+
void this.readHistory();
|
|
38
|
+
},
|
|
39
|
+
invalidated: value => this.controller.invalidateDraft(value),
|
|
40
|
+
});
|
|
41
|
+
this.history = new ConversationHistory({ changed: value => this.renderHistory(value) });
|
|
42
|
+
this.bind();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
bind() {
|
|
46
|
+
this.$('message').addEventListener('input', () => this.draft.update(this.$('message').value));
|
|
47
|
+
this.$('history').onclick = () => void this.client.openConversation().then(result => {
|
|
48
|
+
if (result.delivery === 'unconfirmed') this.status('打开聊天尚未确认 · 请从 Clowder 查看');
|
|
49
|
+
}).catch(error => this.status(explainError(error)));
|
|
50
|
+
this.$('reply-history-open').addEventListener('click', () => {
|
|
51
|
+
void this.controls.show('chat');
|
|
52
|
+
void this.readHistory();
|
|
53
|
+
});
|
|
54
|
+
this.$('reply-return-latest').addEventListener('click', () => {
|
|
55
|
+
this.$('chat-transcript').scrollTop = this.$('chat-transcript').scrollHeight;
|
|
56
|
+
});
|
|
57
|
+
this.$('chat-transcript').addEventListener('scroll', () => {
|
|
58
|
+
if (this.controller.mode === 'history' && this.$('chat-transcript').scrollTop < 24) {
|
|
59
|
+
void this.readOlderHistory();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
this.$('compose').onsubmit = event => this.submit(event);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
renderReply(value) {
|
|
66
|
+
const previous = this.replyMode;
|
|
67
|
+
this.replyMode = value.mode;
|
|
68
|
+
this.view.render(value);
|
|
69
|
+
this.controls.remeasure?.();
|
|
70
|
+
if (value.mode === 'reply' && previous !== 'reply' && this.controls.panel !== 'chat') {
|
|
71
|
+
void this.controls.show('chat');
|
|
72
|
+
}
|
|
73
|
+
if (value.mode === 'idle' && previous === 'reply' && this.controls.panel === 'chat') {
|
|
74
|
+
void this.controls.show('none');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
renderDraft(value) {
|
|
79
|
+
const input = this.$('message');
|
|
80
|
+
if (input.value !== this.lastRenderedDraft && input.value !== value.localText) {
|
|
81
|
+
this.draft.update(input.value);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
input.value = value.localText;
|
|
85
|
+
this.lastRenderedDraft = value.localText;
|
|
86
|
+
this.controller.setDraftNotice(value.status);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
renderHistory(value) {
|
|
90
|
+
this.threadTitle = value.threadTitle || this.threadTitle;
|
|
91
|
+
if (this.threadTitle) this.$('chat-name').textContent = this.threadTitle;
|
|
92
|
+
this.bubble.load(value.messages);
|
|
93
|
+
this.transcript.load(value.messages);
|
|
94
|
+
this.controller.loadHistory(value.messages);
|
|
95
|
+
this.$('history').textContent = value.hasMoreOlder
|
|
96
|
+
? '在 Cat Café 中查看更早聊天 ↗' : '在 Cat Café 中查看完整聊天 ↗';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
openWrite(callActive) {
|
|
100
|
+
if (callActive) this.controller.openHistory();
|
|
101
|
+
else this.controller.openCompose();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
closeHistory() {
|
|
105
|
+
if (this.controller.mode === 'history') this.controller.close();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
openHistory() { this.controller.openHistory(); }
|
|
109
|
+
|
|
110
|
+
async submit(event) {
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
this.draft.update(this.$('message').value);
|
|
113
|
+
const receipt = await this.draft.submit();
|
|
114
|
+
if (receipt?.delivery !== 'accepted') return;
|
|
115
|
+
this.onAccepted();
|
|
116
|
+
void this.readHistory();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async readHistory({ before, older = false } = {}) {
|
|
120
|
+
const readKind = older ? 'older' : 'latest';
|
|
121
|
+
if (this.activePageRead) {
|
|
122
|
+
if (readKind === 'older' && this.activePageRead === 'latest') this.pendingOlderRead = true;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.activePageRead = readKind;
|
|
126
|
+
try {
|
|
127
|
+
const page = await this.client.readConversationPage(before === undefined ? {} : { before });
|
|
128
|
+
this.history.loadPage(page, { older });
|
|
129
|
+
void this.controls.setAmbient(this.getCallPhase() === 'idle' ? 'none' : 'actions');
|
|
130
|
+
this.$('chat-connection-status').textContent = page.status === 'stale'
|
|
131
|
+
? '聊天位置已变化 · 请重新往上翻' : '';
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error?.code === 'permission_required') {
|
|
134
|
+
this.history.clearProtected('permission_required');
|
|
135
|
+
this.controller.clearProtected('permission_required');
|
|
136
|
+
this.$('chat-connection-status').textContent = '聊天权限已撤销';
|
|
137
|
+
} else this.$('chat-connection-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示';
|
|
138
|
+
} finally {
|
|
139
|
+
const readPendingOlder = readKind === 'latest' && this.pendingOlderRead;
|
|
140
|
+
if (readKind === 'latest') this.pendingOlderRead = false;
|
|
141
|
+
this.activePageRead = null;
|
|
142
|
+
if (readPendingOlder) void this.readOlderHistory();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async readOlderHistory() {
|
|
147
|
+
const { hasMoreOlder, olderCursor, pageStatus } = this.history.snapshot();
|
|
148
|
+
if (this.controller.mode !== 'history' || pageStatus !== 'available' || !hasMoreOlder || !olderCursor) return;
|
|
149
|
+
await this.readHistory({ before: olderCursor, older: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async readBody(messageId, continuation) {
|
|
153
|
+
if (this.bodyReads.has(continuation) || this.history.bodyContinuation(messageId) !== continuation) return;
|
|
154
|
+
this.bodyReads.add(continuation);
|
|
155
|
+
try {
|
|
156
|
+
const result = await this.client.readConversationBody(continuation);
|
|
157
|
+
this.history.applyBody(continuation, result);
|
|
158
|
+
if (result.status === 'stale') {
|
|
159
|
+
this.controller.releaseContinuation(messageId, continuation);
|
|
160
|
+
this.$('chat-connection-status').textContent = '回复正文已变化 · 正在重新读取';
|
|
161
|
+
void this.readHistory();
|
|
162
|
+
}
|
|
163
|
+
if (result.status === 'withdrawn') this.controller.withdrawReply(messageId);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error?.code === 'permission_required') {
|
|
166
|
+
this.history.clearProtected('permission_required');
|
|
167
|
+
this.controller.clearProtected('permission_required');
|
|
168
|
+
this.$('chat-connection-status').textContent = '聊天权限已撤销';
|
|
169
|
+
} else {
|
|
170
|
+
this.controller.releaseContinuation(messageId, continuation);
|
|
171
|
+
this.$('chat-connection-status').textContent = '完整回复暂未读完 · 稍后重试';
|
|
172
|
+
}
|
|
173
|
+
} finally { this.bodyReads.delete(continuation); }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
start() {
|
|
177
|
+
void this.readHistory();
|
|
178
|
+
void this.draft.load();
|
|
179
|
+
this.historyMonitor = this.scheduleInterval(() => void this.readHistory(), 3000);
|
|
180
|
+
this.draftMonitor = this.scheduleInterval(() => void this.draft.load(), 3000);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
close() {
|
|
184
|
+
this.cancelInterval(this.historyMonitor);
|
|
185
|
+
this.cancelInterval(this.draftMonitor);
|
|
186
|
+
this.draft.close();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -2,8 +2,9 @@ import { explainError } from './errors.mjs';
|
|
|
2
2
|
|
|
3
3
|
/** Local controls only. Durable messages, identity and preferences belong to the Host. */
|
|
4
4
|
export class CompanionConversation {
|
|
5
|
-
constructor({ client, createPeer, render, transcript,
|
|
6
|
-
|
|
5
|
+
constructor({ client, createPeer, render, transcript, delivery = () => {}, stopScreen,
|
|
6
|
+
uuid = () => crypto.randomUUID() }) {
|
|
7
|
+
Object.assign(this, { client, createPeer, render, transcript, delivery, stopScreen, uuid });
|
|
7
8
|
this.active = false;
|
|
8
9
|
this.generation = 0;
|
|
9
10
|
this.phase = 'idle';
|
|
@@ -219,10 +220,15 @@ export class CompanionConversation {
|
|
|
219
220
|
try {
|
|
220
221
|
const receipt = await this.client.text(input.text, input.id);
|
|
221
222
|
if (receipt?.delivery !== 'accepted') {
|
|
223
|
+
this.reportDelivery(input, receipt ?? {
|
|
224
|
+
kind: 'delivery', delivery: 'unconfirmed', clientMessageId: input.id,
|
|
225
|
+
messageId: null, callId: null,
|
|
226
|
+
});
|
|
222
227
|
if (generation === this.generation) this.show(explainError({ code: 'unconfirmed' }));
|
|
223
228
|
return false;
|
|
224
229
|
}
|
|
225
230
|
if (this.pendingText === input) this.pendingText = undefined;
|
|
231
|
+
this.reportDelivery(input, receipt);
|
|
226
232
|
// Voice may have ended; an accepted message still retires this draft.
|
|
227
233
|
// Keep the newer voice status rather than announcing that we are listening.
|
|
228
234
|
if (generation !== this.generation) return true;
|
|
@@ -230,10 +236,18 @@ export class CompanionConversation {
|
|
|
230
236
|
this.show(this.active ? this.listening() : '已发送 · 回答会留在同一段聊天中');
|
|
231
237
|
return true;
|
|
232
238
|
} catch (error) {
|
|
239
|
+
if (error?.code === 'unconfirmed') this.reportDelivery(input, {
|
|
240
|
+
kind: 'delivery', delivery: 'unconfirmed', clientMessageId: input.id,
|
|
241
|
+
messageId: null, callId: null,
|
|
242
|
+
});
|
|
233
243
|
if (generation === this.generation) this.show(explainError(error));
|
|
234
244
|
return false;
|
|
235
245
|
} finally { this.sending = false; }
|
|
236
246
|
}
|
|
247
|
+
reportDelivery(input, receipt) {
|
|
248
|
+
try { this.delivery({ text: input.text, receipt }); }
|
|
249
|
+
catch { /* Presentation failures cannot change Host delivery truth. */ }
|
|
250
|
+
}
|
|
237
251
|
muteMic() {
|
|
238
252
|
if (this.audioMode === 'receive_only') return;
|
|
239
253
|
this.muted = !this.muted; this.peer?.muteMic(this.muted);
|
package/renderer/index.html
CHANGED
|
@@ -118,11 +118,28 @@
|
|
|
118
118
|
<div class="call-context-actions call-context-inline-actions"><button id="chat-mic" data-action="mute">静音</button><button id="chat-share" data-action="share"><span class="label">共享画面</span></button><button data-action="stop" class="destructive compact">挂断</button></div>
|
|
119
119
|
<div id="chat-share-context" class="call-share-line" hidden><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span><strong id="chat-share-target"></strong><small>仅共享此窗口</small></span></div>
|
|
120
120
|
</div>
|
|
121
|
-
<header><strong id="chat-name">和猫猫写几句</strong><button data-action="dismiss" aria-label="收起文字">收起</button></header>
|
|
121
|
+
<header id="chat-history-header"><strong id="chat-name">和猫猫写几句</strong><button data-action="dismiss" aria-label="收起文字">收起</button></header>
|
|
122
122
|
<div id="chat-transcript" role="log" aria-live="polite"><p class="empty">想说什么,就写在这里。</p></div>
|
|
123
|
-
<
|
|
124
|
-
<
|
|
125
|
-
|
|
123
|
+
<button id="reply-return-latest" class="reply-return-latest" type="button" hidden>回到最新 ↓</button>
|
|
124
|
+
<div id="reply-sent" class="reply-sent" tabindex="-1" hidden>
|
|
125
|
+
<svg viewBox="0 0 36 27" aria-hidden="true"><rect x="1" y="1" width="34" height="25" rx="4"/><path d="M2.5 3.5 18 15 33.5 3.5"/></svg>
|
|
126
|
+
<span><strong id="reply-sent-status">已发送</strong><small id="reply-letter-text"></small></span>
|
|
127
|
+
</div>
|
|
128
|
+
<article id="reply-card" class="reply-card" hidden tabindex="-1">
|
|
129
|
+
<div class="reply-head"><span id="reply-input-text" class="reply-input-text"></span>
|
|
130
|
+
<button id="reply-retain" class="reply-ring" type="button" aria-label="留住回复" title="留住回复">
|
|
131
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><circle class="reply-ring-track" cx="8" cy="8" r="6.5"/><circle class="reply-ring-progress" cx="8" cy="8" r="6.5"/></svg>
|
|
132
|
+
</button>
|
|
133
|
+
<button id="reply-close" class="reply-close" type="button" aria-label="关闭回复" title="关闭回复" hidden>×</button>
|
|
134
|
+
</div>
|
|
135
|
+
<div id="reply-body" class="reply-body"></div>
|
|
136
|
+
<small id="reply-body-status" class="reply-body-status" role="status"></small>
|
|
137
|
+
<button id="reply-history-open" class="reply-history-open" type="button">往上翻聊天记录</button>
|
|
138
|
+
</article>
|
|
139
|
+
<form id="compose"><textarea id="message" aria-label="给猫猫输入消息" placeholder="输入消息…" rows="1" maxlength="8000"></textarea><button type="submit" class="primary">发送</button></form>
|
|
140
|
+
<div class="chat-feedback"><small id="chat-status" role="status"></small><button id="reply-retry" type="button" hidden>↻ 重试</button></div>
|
|
141
|
+
<small id="chat-connection-status" role="status"></small>
|
|
142
|
+
<small id="chat-scope"><span id="connection-scope">语音与文字共用同一段聊天</span> <button id="history">在 Cat Café 中查看完整聊天 ↗</button></small>
|
|
126
143
|
</section>
|
|
127
144
|
<section id="transcript" class="floating call-transcript" data-expanded="false" aria-label="这次通话的字幕" hidden>
|
|
128
145
|
<div id="transcript-call-context" class="call-context transcript-call-context">
|
|
@@ -35,7 +35,13 @@ export function bindPetControls(client, { action, error, changed, moved = () =>
|
|
|
35
35
|
$('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
|
|
36
36
|
if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
|
|
37
37
|
if (previousPanel !== next) {
|
|
38
|
-
if (next === 'chat')
|
|
38
|
+
if (next === 'chat') {
|
|
39
|
+
const mode = $('chat').dataset.replyMode;
|
|
40
|
+
const target = mode === 'reply'
|
|
41
|
+
? ($('reply-close')?.hidden === false ? $('reply-close') : $('reply-retain'))
|
|
42
|
+
: mode === 'sent' ? $('reply-sent') : $('message');
|
|
43
|
+
target?.focus?.({ preventScroll: true });
|
|
44
|
+
}
|
|
39
45
|
if (next === 'transcript') $('call-message').focus({ preventScroll: true });
|
|
40
46
|
if (next === 'settings') $('settings-back').focus({ preventScroll: true });
|
|
41
47
|
if (next === 'decisions') $('decision-reload').focus({ preventScroll: true });
|
|
@@ -98,7 +104,7 @@ export function bindPetControls(client, { action, error, changed, moved = () =>
|
|
|
98
104
|
});
|
|
99
105
|
document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
|
|
100
106
|
const kind = button.dataset.action;
|
|
101
|
-
if (kind === 'write' || kind === 'history') { void show('chat'); return; }
|
|
107
|
+
if (kind === 'write' || kind === 'history') { action(kind); void show('chat'); return; }
|
|
102
108
|
if (kind === 'transcript') { void show('transcript'); return; }
|
|
103
109
|
if (kind === 'settings') { void show('settings'); return; }
|
|
104
110
|
if (kind === 'decisions') { void show('decisions'); return; }
|
|
@@ -0,0 +1,343 @@
|
|
|
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.selectedMessageId = null;
|
|
29
|
+
this.pauseReasons = new Set();
|
|
30
|
+
this.requestedContinuations = new Set();
|
|
31
|
+
this.dismissedReplies = new Set();
|
|
32
|
+
this.timerHandle = null;
|
|
33
|
+
this.timerStartedAt = null;
|
|
34
|
+
this.timerRemainingMs = 0;
|
|
35
|
+
this.blockedReason = null;
|
|
36
|
+
this.draftNotice = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
recordDelivery(receipt, text, context = {}) {
|
|
40
|
+
this.blockedReason = null;
|
|
41
|
+
if (receipt.delivery !== 'accepted') {
|
|
42
|
+
this.draft = {
|
|
43
|
+
text,
|
|
44
|
+
clientMessageId: receipt.clientMessageId,
|
|
45
|
+
...(context.submissionRevision === undefined ? {} : { submissionRevision: context.submissionRevision }),
|
|
46
|
+
...(context.scopeRef === undefined ? {} : { scopeRef: context.scopeRef }),
|
|
47
|
+
settlement: receipt.delivery === 'rejected' ? receipt.reason : 'unconfirmed',
|
|
48
|
+
};
|
|
49
|
+
if (this.mode !== 'history') this.mode = 'compose';
|
|
50
|
+
this.emit();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const letter = { text, clientMessageId: receipt.clientMessageId, inputMessageId: receipt.messageId };
|
|
54
|
+
const known = this.pending.get(receipt.messageId) ?? this.pairs.get(receipt.messageId)?.letter;
|
|
55
|
+
if (known?.clientMessageId === receipt.clientMessageId) {
|
|
56
|
+
if (this.draft?.clientMessageId === receipt.clientMessageId) this.draft = null;
|
|
57
|
+
this.emit();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.pending.set(receipt.messageId, letter);
|
|
61
|
+
if (context.restored === false || this.draft?.clientMessageId === receipt.clientMessageId) this.draft = null;
|
|
62
|
+
if (this.mode !== 'history') {
|
|
63
|
+
this.stopTimer();
|
|
64
|
+
this.reply = null;
|
|
65
|
+
this.letter = letter;
|
|
66
|
+
this.retained = false;
|
|
67
|
+
this.mode = 'sent';
|
|
68
|
+
}
|
|
69
|
+
this.emit();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
invalidateDraft(value) {
|
|
73
|
+
this.draft = {
|
|
74
|
+
text: value.text,
|
|
75
|
+
clientMessageId: value.clientMessageId,
|
|
76
|
+
submissionRevision: value.submissionRevision,
|
|
77
|
+
scopeRef: value.scopeRef,
|
|
78
|
+
settlement: value.reason ?? 'scope_changed',
|
|
79
|
+
};
|
|
80
|
+
if (this.mode !== 'history') this.mode = 'compose';
|
|
81
|
+
this.emit();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
setDraftNotice(value) {
|
|
85
|
+
if (this.draftNotice === value) return;
|
|
86
|
+
this.draftNotice = value;
|
|
87
|
+
this.emit();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
openCompose() {
|
|
91
|
+
if (this.reply) this.dismissedReplies.add(this.reply.id);
|
|
92
|
+
this.stopTimer();
|
|
93
|
+
this.reply = null;
|
|
94
|
+
this.letter = null;
|
|
95
|
+
this.mode = 'compose';
|
|
96
|
+
this.retained = false;
|
|
97
|
+
this.hasNewer = false;
|
|
98
|
+
this.selectedMessageId = null;
|
|
99
|
+
this.pauseReasons.clear();
|
|
100
|
+
this.emit();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
loadHistory(rows) {
|
|
104
|
+
this.history = rows.map(row => ({
|
|
105
|
+
id: row.id,
|
|
106
|
+
role: row.role,
|
|
107
|
+
text: row.text,
|
|
108
|
+
body: projectReplyBody(row),
|
|
109
|
+
inputMessageIds: [...(row.inputMessageIds ?? [])],
|
|
110
|
+
responsePhase: row.responsePhase ?? 'unknown',
|
|
111
|
+
deliveryReceipt: row.deliveryReceipt ? { ...row.deliveryReceipt } : undefined,
|
|
112
|
+
}));
|
|
113
|
+
let restoredLetter = null;
|
|
114
|
+
for (const row of this.history) {
|
|
115
|
+
const receipt = row.deliveryReceipt;
|
|
116
|
+
if (row.role !== 'user' || receipt?.delivery !== 'accepted' || receipt.messageId !== row.id) continue;
|
|
117
|
+
if (this.pending.has(row.id) || this.pairs.has(row.id)) continue;
|
|
118
|
+
restoredLetter = {
|
|
119
|
+
text: row.text,
|
|
120
|
+
clientMessageId: receipt.clientMessageId,
|
|
121
|
+
inputMessageId: row.id,
|
|
122
|
+
};
|
|
123
|
+
this.pending.set(row.id, restoredLetter);
|
|
124
|
+
}
|
|
125
|
+
let newestPair = null;
|
|
126
|
+
const settledNow = [];
|
|
127
|
+
for (const row of this.history) {
|
|
128
|
+
if (row.role !== 'assistant') continue;
|
|
129
|
+
if (row.responsePhase === 'completed') this.requestMissingBody(row);
|
|
130
|
+
for (const inputMessageId of row.inputMessageIds) {
|
|
131
|
+
const pending = this.pending.get(inputMessageId);
|
|
132
|
+
const existing = this.pairs.get(inputMessageId);
|
|
133
|
+
if (!pending && !existing) continue;
|
|
134
|
+
if (row.responsePhase !== 'completed') {
|
|
135
|
+
if (pending) {
|
|
136
|
+
const updated = { ...pending, responsePhase: row.responsePhase };
|
|
137
|
+
this.pending.set(inputMessageId, updated);
|
|
138
|
+
if (this.letter?.inputMessageId === inputMessageId) this.letter = updated;
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const letter = pending ?? existing.letter;
|
|
143
|
+
const pair = { inputMessageId, replyId: row.id, letter, reply: this.replyValue(row) };
|
|
144
|
+
if (!existing) {
|
|
145
|
+
this.pending.delete(inputMessageId);
|
|
146
|
+
this.settled.push({ inputMessageId, replyId: row.id });
|
|
147
|
+
settledNow.push(pair);
|
|
148
|
+
}
|
|
149
|
+
this.pairs.set(inputMessageId, pair);
|
|
150
|
+
newestPair = pair;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const presentationPair = selectReplyPresentation({ mode: this.mode, letter: this.letter,
|
|
155
|
+
reply: this.reply, settledPairs: settledNow, pairs: this.pairs,
|
|
156
|
+
dismissedReplies: this.dismissedReplies });
|
|
157
|
+
if (newestPair && this.mode === 'history') {
|
|
158
|
+
if (newestPair.replyId !== this.selectedMessageId) this.hasNewer = true;
|
|
159
|
+
} else if (presentationPair) {
|
|
160
|
+
const changedReply = this.reply?.id !== presentationPair.replyId;
|
|
161
|
+
const becameComplete = this.reply?.id === presentationPair.replyId
|
|
162
|
+
&& this.reply.complete === false && presentationPair.reply.complete === true;
|
|
163
|
+
this.letter = presentationPair.letter;
|
|
164
|
+
this.reply = presentationPair.reply;
|
|
165
|
+
this.selectedMessageId = presentationPair.replyId;
|
|
166
|
+
this.mode = 'reply';
|
|
167
|
+
if (changedReply) {
|
|
168
|
+
this.retained = false;
|
|
169
|
+
this.pauseReasons.clear();
|
|
170
|
+
this.stopTimer();
|
|
171
|
+
}
|
|
172
|
+
if ((changedReply || becameComplete) && presentationPair.reply.complete) {
|
|
173
|
+
this.timerRemainingMs = replyVisibleDuration(presentationPair.reply.text);
|
|
174
|
+
this.startTimer();
|
|
175
|
+
}
|
|
176
|
+
} else if (this.mode === 'sent') {
|
|
177
|
+
this.letter = [...this.pending.values()].at(-1) ?? null;
|
|
178
|
+
if (!this.letter) this.mode = 'idle';
|
|
179
|
+
} else if (restoredLetter && this.mode === 'idle') {
|
|
180
|
+
this.letter = restoredLetter;
|
|
181
|
+
this.mode = 'sent';
|
|
182
|
+
}
|
|
183
|
+
this.emit();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
replyValue(row) {
|
|
187
|
+
return { id: row.id, text: row.text, complete: row.body.complete,
|
|
188
|
+
responsePhase: row.responsePhase };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
requestMissingBody(row) {
|
|
192
|
+
const reference = row.body.continuation;
|
|
193
|
+
if (row.body.complete || !reference) {
|
|
194
|
+
for (const key of this.requestedContinuations) {
|
|
195
|
+
if (key.startsWith(`${row.id}\u0000`)) this.requestedContinuations.delete(key);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const key = `${row.id}\u0000${reference}`;
|
|
200
|
+
if (this.requestedContinuations.has(key)) return;
|
|
201
|
+
this.requestedContinuations.add(key);
|
|
202
|
+
this.requestContinuation({ messageId: row.id, continuation: reference });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
releaseContinuation(messageId, continuation) {
|
|
206
|
+
this.requestedContinuations.delete(`${messageId}\u0000${continuation}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
pause(reason) {
|
|
210
|
+
if (this.pauseReasons.has(reason)) return;
|
|
211
|
+
this.pauseReasons.add(reason);
|
|
212
|
+
if (this.timerHandle !== null) {
|
|
213
|
+
this.timerRemainingMs = Math.max(0, this.timerRemainingMs - (this.now() - this.timerStartedAt));
|
|
214
|
+
this.stopTimer(false);
|
|
215
|
+
}
|
|
216
|
+
this.emit();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
resume(reason) {
|
|
220
|
+
if (!this.pauseReasons.delete(reason)) return;
|
|
221
|
+
if (this.pauseReasons.size === 0) this.startTimer();
|
|
222
|
+
this.emit();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
pin() {
|
|
226
|
+
if (this.mode !== 'reply') return;
|
|
227
|
+
this.retained = true;
|
|
228
|
+
this.stopTimer();
|
|
229
|
+
this.emit();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
close() {
|
|
233
|
+
if (this.reply) this.dismissedReplies.add(this.reply.id);
|
|
234
|
+
this.stopTimer();
|
|
235
|
+
this.reply = null;
|
|
236
|
+
this.retained = false;
|
|
237
|
+
this.selectedMessageId = null;
|
|
238
|
+
this.letter = [...this.pending.values()].at(-1) ?? null;
|
|
239
|
+
this.mode = this.letter ? 'sent' : 'idle';
|
|
240
|
+
this.emit();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
withdrawReply(replyId) {
|
|
244
|
+
let restored = null;
|
|
245
|
+
for (const [inputMessageId, pair] of this.pairs) {
|
|
246
|
+
if (pair.replyId !== replyId) continue;
|
|
247
|
+
restored = { ...pair.letter, responsePhase: 'withdrawn' };
|
|
248
|
+
this.pairs.delete(inputMessageId);
|
|
249
|
+
this.pending.set(inputMessageId, restored);
|
|
250
|
+
}
|
|
251
|
+
this.settled = this.settled.filter(value => value.replyId !== replyId);
|
|
252
|
+
this.history = this.history.filter(value => value.id !== replyId);
|
|
253
|
+
this.dismissedReplies.delete(replyId);
|
|
254
|
+
for (const key of this.requestedContinuations) {
|
|
255
|
+
if (key.startsWith(`${replyId}\u0000`)) this.requestedContinuations.delete(key);
|
|
256
|
+
}
|
|
257
|
+
if (this.reply?.id === replyId) {
|
|
258
|
+
this.stopTimer();
|
|
259
|
+
this.reply = null;
|
|
260
|
+
this.retained = false;
|
|
261
|
+
this.selectedMessageId = null;
|
|
262
|
+
this.letter = restored ?? [...this.pending.values()].at(-1) ?? null;
|
|
263
|
+
this.mode = this.letter ? 'sent' : 'idle';
|
|
264
|
+
}
|
|
265
|
+
this.emit();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
clearProtected(reason) {
|
|
269
|
+
this.stopTimer();
|
|
270
|
+
this.pending.clear();
|
|
271
|
+
this.settled = [];
|
|
272
|
+
this.pairs.clear();
|
|
273
|
+
this.history = [];
|
|
274
|
+
this.letter = null;
|
|
275
|
+
this.reply = null;
|
|
276
|
+
this.retained = false;
|
|
277
|
+
this.hasNewer = false;
|
|
278
|
+
this.selectedMessageId = null;
|
|
279
|
+
this.requestedContinuations.clear();
|
|
280
|
+
this.dismissedReplies.clear();
|
|
281
|
+
this.blockedReason = reason;
|
|
282
|
+
this.mode = this.draft ? 'compose' : 'idle';
|
|
283
|
+
this.emit();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
openHistory(messageId = newestHistoryAnchor(this.selectedMessageId, this.settled, this.history)) {
|
|
287
|
+
this.stopTimer();
|
|
288
|
+
this.mode = 'history';
|
|
289
|
+
this.retained = true;
|
|
290
|
+
this.selectedMessageId = messageId;
|
|
291
|
+
this.hasNewer = false;
|
|
292
|
+
this.emit();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
returnLatest() {
|
|
296
|
+
this.selectedMessageId = this.settled.at(-1)?.replyId ?? this.history.at(-1)?.id ?? null;
|
|
297
|
+
this.hasNewer = false;
|
|
298
|
+
this.emit();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
startTimer() {
|
|
302
|
+
if (this.timerHandle !== null || this.timerRemainingMs <= 0 || this.retained
|
|
303
|
+
|| this.mode !== 'reply' || !this.reply?.complete || this.pauseReasons.size > 0) return;
|
|
304
|
+
this.timerStartedAt = this.now();
|
|
305
|
+
this.timerHandle = this.schedule(() => {
|
|
306
|
+
this.timerHandle = null;
|
|
307
|
+
this.timerStartedAt = null;
|
|
308
|
+
this.timerRemainingMs = 0;
|
|
309
|
+
this.close();
|
|
310
|
+
}, this.timerRemainingMs);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
stopTimer(reset = true) {
|
|
314
|
+
if (this.timerHandle !== null) this.cancel(this.timerHandle);
|
|
315
|
+
this.timerHandle = null;
|
|
316
|
+
this.timerStartedAt = null;
|
|
317
|
+
if (reset) this.timerRemainingMs = 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
snapshot() {
|
|
321
|
+
const elapsed = this.timerHandle === null ? 0 : this.now() - this.timerStartedAt;
|
|
322
|
+
return {
|
|
323
|
+
mode: this.mode,
|
|
324
|
+
draft: this.draft,
|
|
325
|
+
letter: this.letter,
|
|
326
|
+
reply: this.reply,
|
|
327
|
+
retained: this.retained,
|
|
328
|
+
selectedMessageId: this.selectedMessageId,
|
|
329
|
+
hasNewer: this.hasNewer,
|
|
330
|
+
blockedReason: this.blockedReason,
|
|
331
|
+
draftNotice: this.draftNotice,
|
|
332
|
+
pendingInputMessageIds: [...this.pending.keys()],
|
|
333
|
+
settled: this.settled.map(value => ({ ...value })),
|
|
334
|
+
history: this.history.map(row => ({ ...row, body: { ...row.body }, inputMessageIds: [...row.inputMessageIds] })),
|
|
335
|
+
timer: {
|
|
336
|
+
running: this.timerHandle !== null,
|
|
337
|
+
remainingMs: Math.max(0, this.timerRemainingMs - elapsed),
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
emit() { this.render(this.snapshot()); }
|
|
343
|
+
}
|
|
@@ -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
|
+
}
|