@clowder-ai/companion 0.1.0-alpha.21 → 0.1.0-alpha.25
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 +25 -3
- package/manifest.json +3 -3
- package/package.json +4 -4
- package/plugin.yaml +3 -3
- package/renderer/call-transcript-view.mjs +3 -1
- package/renderer/client.mjs +60 -1
- package/renderer/conversation-draft.mjs +304 -0
- package/renderer/conversation-history.mjs +176 -0
- package/renderer/conversation-surface.mjs +178 -0
- package/renderer/conversation.mjs +16 -2
- package/renderer/index.html +28 -6
- package/renderer/meeting-state.mjs +55 -0
- package/renderer/pet-controls.mjs +8 -2
- package/renderer/recent-bubble.mjs +19 -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 +75 -8
- package/renderer/surface.mjs +49 -42
- package/renderer/transcript-view.mjs +1 -0
- package/renderer/visible-text-receipt.mjs +241 -0
package/renderer/surface.mjs
CHANGED
|
@@ -18,12 +18,14 @@ import { currentCompanionIdentity } from './companion-identity.mjs';
|
|
|
18
18
|
import { FoldedChatUnread } from './chat-unread.mjs';
|
|
19
19
|
import { COMPACT_BALL_SIZE, compactSizeAction } from './compact-size.mjs';
|
|
20
20
|
import { WorkOverviewView } from './work-overview.mjs';
|
|
21
|
+
import { MeetingStatus, renderMeetingStatus } from './meeting-state.mjs';
|
|
22
|
+
import { VisibleTextPresentation } from './visible-text-receipt.mjs';
|
|
23
|
+
import { ConversationSurface } from './conversation-surface.mjs';
|
|
21
24
|
const $ = id => document.getElementById(id);
|
|
22
25
|
const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
|
|
23
26
|
const status = text => {
|
|
24
27
|
$('status').textContent = text;
|
|
25
28
|
$('menu-status').textContent = text;
|
|
26
|
-
$('chat-status').textContent = text;
|
|
27
29
|
$('announcement').textContent = text;
|
|
28
30
|
};
|
|
29
31
|
const transcript = new TranscriptView($('chat-transcript'));
|
|
@@ -35,6 +37,7 @@ const callTranscript = new CallTranscriptView($('call-transcript-log'), {
|
|
|
35
37
|
});
|
|
36
38
|
const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
|
|
37
39
|
const callBubble = new RecentBubble([$('call-bubble-first'), $('call-bubble-second')]);
|
|
40
|
+
const meetingStatus = new MeetingStatus();
|
|
38
41
|
const foldedChatUnread = new FoldedChatUnread(count => {
|
|
39
42
|
$('call-chat-unread').hidden = count === 0;
|
|
40
43
|
$('call-chat-unread').textContent = count ? String(count) : '';
|
|
@@ -46,8 +49,8 @@ const nativeTravel = new NativeWindowTravel({
|
|
|
46
49
|
readPosition: () => ({ x: window.screenX, y: window.screenY }),
|
|
47
50
|
changed: value => motion.syncTravel(value),
|
|
48
51
|
});
|
|
49
|
-
let sharing = false, pendingScreen = false,
|
|
50
|
-
let didMove = false,
|
|
52
|
+
let sharing = false, pendingScreen = false, transcriptLoading = false, previousPhase = 'idle';
|
|
53
|
+
let didMove = false, settingsView, settingsValues, decisionPanel, workView;
|
|
51
54
|
let previousControlPanel = 'none';
|
|
52
55
|
// F229 stays quiet: a history ID change has no provenance for proactive text.
|
|
53
56
|
const ambientPanel = active => active ? 'actions' : 'none';
|
|
@@ -60,6 +63,12 @@ if (!window.clowderCompanion) {
|
|
|
60
63
|
} else {
|
|
61
64
|
nativeTravel.start();
|
|
62
65
|
const client = createCompanionClient(window.clowderCompanion);
|
|
66
|
+
let conversationSurface;
|
|
67
|
+
const visibleText = new VisibleTextPresentation({
|
|
68
|
+
document,
|
|
69
|
+
report: observation => client.observePresentation(observation),
|
|
70
|
+
transcript, callTranscript, bubble, callBubble,
|
|
71
|
+
});
|
|
63
72
|
const controls = bindPetControls(client, {
|
|
64
73
|
error: (error, context) => status(context?.panel === 'settings' && error?.code === 'invalid_request' ? '这个 Host 版本还不支持猫旁设置。原来的设置入口会继续保留。' : explainError(error)),
|
|
65
74
|
moved: (dx, dy) => {
|
|
@@ -72,16 +81,23 @@ if (!window.clowderCompanion) {
|
|
|
72
81
|
setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
|
|
73
82
|
},
|
|
74
83
|
changed: panel => {
|
|
84
|
+
visibleText.retry();
|
|
85
|
+
if (previousControlPanel === 'chat' && panel !== 'chat') conversationSurface.closeHistory();
|
|
86
|
+
if (panel === 'chat' && conversationSurface.controller.mode === 'idle') conversationSurface.openHistory();
|
|
75
87
|
if (panel === 'chat') foldedChatUnread.open();
|
|
76
88
|
else if (previousControlPanel === 'chat' && conversation?.active) foldedChatUnread.fold();
|
|
77
89
|
previousControlPanel = panel;
|
|
78
|
-
if (panel === 'chat') void readHistory();
|
|
90
|
+
if (panel === 'chat') void conversationSurface.readHistory();
|
|
79
91
|
if (panel === 'transcript') void readCallTranscript();
|
|
80
92
|
if (panel === 'decisions') void decisionPanel?.read();
|
|
81
93
|
if (panel === 'settings') void settingsView?.open();
|
|
82
94
|
if (panel === 'menu') void workView?.load();
|
|
83
95
|
},
|
|
84
96
|
action(kind) {
|
|
97
|
+
if (kind === 'write') {
|
|
98
|
+
conversationSurface.openWrite(conversation.active);
|
|
99
|
+
}
|
|
100
|
+
if (kind === 'history') conversationSurface.openHistory();
|
|
85
101
|
if (kind === 'begin') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin(); void controls.show('none'); }
|
|
86
102
|
if (kind === 'listen') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin('receive_only'); void controls.show('none'); }
|
|
87
103
|
if (kind === 'stop') void conversation.end();
|
|
@@ -137,18 +153,17 @@ if (!window.clowderCompanion) {
|
|
|
137
153
|
}
|
|
138
154
|
const conversation = new CompanionConversation({
|
|
139
155
|
client, createPeer: callback => new VoicePeer(callback, client), stopScreen: () => screen.stop(),
|
|
156
|
+
delivery: ({ text, receipt }) => conversationSurface.controller.recordDelivery(receipt, text),
|
|
140
157
|
transcript: event => {
|
|
141
158
|
if (event.type === 'turn-done') {
|
|
142
159
|
transcript.finish(event); callTranscript.finish(event); bubble.finish(event.role); callBubble.finish(event.role);
|
|
143
|
-
void readHistory(); void readCallTranscript();
|
|
160
|
+
void conversationSurface.readHistory(); void readCallTranscript();
|
|
144
161
|
} else if (event.type === 'typed') {
|
|
145
162
|
callTranscript.appendTyped(event);
|
|
146
|
-
void readHistory(); void readCallTranscript();
|
|
163
|
+
void conversationSurface.readHistory(); void readCallTranscript();
|
|
147
164
|
} else {
|
|
148
|
-
transcript.append(event.role, event.text, true);
|
|
149
|
-
callTranscript.appendLive(event);
|
|
150
165
|
const speaker = event.role === 'assistant' ? conversation.identity?.displayName ?? '猫猫' : '语音';
|
|
151
|
-
|
|
166
|
+
visibleText.appendLive(event, speaker);
|
|
152
167
|
void controls.setAmbient('actions');
|
|
153
168
|
}
|
|
154
169
|
},
|
|
@@ -156,7 +171,7 @@ if (!window.clowderCompanion) {
|
|
|
156
171
|
const showFailure = value.failed && previousPhase !== 'idle' && value.phase === 'idle';
|
|
157
172
|
const justConnected = previousPhase !== 'talking' && value.phase === 'talking';
|
|
158
173
|
if (previousPhase !== 'idle' && value.phase === 'idle') {
|
|
159
|
-
transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void readHistory();
|
|
174
|
+
transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversationSurface.readHistory();
|
|
160
175
|
foldedChatUnread.reset();
|
|
161
176
|
if (controls.panel === 'transcript') void controls.show('menu');
|
|
162
177
|
}
|
|
@@ -165,7 +180,7 @@ if (!window.clowderCompanion) {
|
|
|
165
180
|
const receiveOnly = value.audioMode === 'receive_only';
|
|
166
181
|
const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
|
|
167
182
|
const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
|
|
168
|
-
|
|
183
|
+
visibleText.setCall(value.callId);
|
|
169
184
|
void controls.setAmbient(ambientPanel(active));
|
|
170
185
|
$('begin').hidden = active; $('begin').disabled = !value.identity;
|
|
171
186
|
$('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
|
|
@@ -191,7 +206,8 @@ if (!window.clowderCompanion) {
|
|
|
191
206
|
: value.phase === 'connecting' ? '正在请求麦克风'
|
|
192
207
|
: value.recovering ? `正在等待原连接恢复 · ${value.muted ? '麦克风已静音' : '麦克风已开启'}`
|
|
193
208
|
: value.muted ? '麦克风已静音' : active ? '麦克风已开启' : '';
|
|
194
|
-
|
|
209
|
+
const connectionState = value.recovering ? 'recovering' : value.phase === 'talking' ? 'connected' : value.phase === 'connecting' ? 'connecting' : 'idle';
|
|
210
|
+
for (const id of ['actions', 'chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context']) $(id).dataset.connectionState = connectionState;
|
|
195
211
|
setTexts(['call-state', 'chat-call-state', 'transcript-call-state', 'menu-call-state', 'settings-call-state'], callState);
|
|
196
212
|
setTexts(['call-mic-state', 'chat-call-mic-state', 'transcript-call-mic-state', 'menu-call-mic-state', 'settings-call-mic-state'], microphoneState);
|
|
197
213
|
setHidden(['chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context'], !active);
|
|
@@ -214,6 +230,7 @@ if (!window.clowderCompanion) {
|
|
|
214
230
|
$('menu-hide-label').textContent = active ? '结束通话并隐藏' : '隐藏';
|
|
215
231
|
const identity = value.identity;
|
|
216
232
|
const companionIdentity = currentCompanionIdentity(identity);
|
|
233
|
+
renderMeetingStatus(document, meetingStatus.update(active ? identity?.meeting : null));
|
|
217
234
|
const nativeSnapshot = normalizeNativeWork(identity?.nativeWork);
|
|
218
235
|
const workLabel = nativeWorkLabel(nativeSnapshot, identity?.nativeActivity);
|
|
219
236
|
const workState = workLabel ? `${identity.displayName}${workLabel}` : '';
|
|
@@ -229,7 +246,8 @@ if (!window.clowderCompanion) {
|
|
|
229
246
|
$('menu-play').hidden = identity.skin !== 'xianxian-codex';
|
|
230
247
|
const companionLabel = companionIdentity
|
|
231
248
|
? `${companionIdentity.title} · ${companionIdentity.partnerLabel}` : identity.displayName;
|
|
232
|
-
$('chat-name').textContent = threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
|
|
249
|
+
$('chat-name').textContent = conversationSurface?.threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
|
|
250
|
+
$('message').placeholder = `发消息给${identity.displayName}…`;
|
|
233
251
|
$('call-message').placeholder = `发消息给${identity.displayName}…`;
|
|
234
252
|
$('pet').setAttribute('aria-label', `${companionLabel}:点击或右键打开名片,拖动移动`);
|
|
235
253
|
$('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
|
|
@@ -247,6 +265,15 @@ if (!window.clowderCompanion) {
|
|
|
247
265
|
if (showFailure) void controls.show('menu');
|
|
248
266
|
},
|
|
249
267
|
});
|
|
268
|
+
conversationSurface = new ConversationSurface({
|
|
269
|
+
document, client, controls, bubble, transcript,
|
|
270
|
+
getCallPhase: () => conversation.phase,
|
|
271
|
+
onAccepted: () => motion.signal('received'),
|
|
272
|
+
retryLegacy: text => conversation.send(text),
|
|
273
|
+
status,
|
|
274
|
+
scheduleInterval: setInterval,
|
|
275
|
+
cancelInterval: clearInterval,
|
|
276
|
+
});
|
|
250
277
|
settingsView = new SettingsView({
|
|
251
278
|
document,
|
|
252
279
|
client,
|
|
@@ -280,21 +307,6 @@ if (!window.clowderCompanion) {
|
|
|
280
307
|
motion,
|
|
281
308
|
proactivePolicy: () => settingsValues?.proactivePolicy,
|
|
282
309
|
});
|
|
283
|
-
async function readHistory() {
|
|
284
|
-
if (loading) return;
|
|
285
|
-
loading = true;
|
|
286
|
-
try {
|
|
287
|
-
const history = await client.readConversation();
|
|
288
|
-
$('history').textContent = history.hasMore ? '在 Cat Café 中查看更早聊天 ↗' : '在 Cat Café 中查看完整聊天 ↗';
|
|
289
|
-
threadTitle = history.threadTitle;
|
|
290
|
-
$('chat-name').textContent = threadTitle;
|
|
291
|
-
bubble.load(history.messages);
|
|
292
|
-
void controls.setAmbient(ambientPanel(conversation.phase !== 'idle'));
|
|
293
|
-
const serialized = JSON.stringify(history.messages);
|
|
294
|
-
if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
|
|
295
|
-
} catch { $('chat-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示'; }
|
|
296
|
-
finally { loading = false; }
|
|
297
|
-
}
|
|
298
310
|
async function readCallTranscript() {
|
|
299
311
|
if (transcriptLoading || !conversation.active || !conversation.callId) return;
|
|
300
312
|
transcriptLoading = true;
|
|
@@ -302,41 +314,36 @@ if (!window.clowderCompanion) {
|
|
|
302
314
|
try {
|
|
303
315
|
const reply = await client.readTranscript();
|
|
304
316
|
if (!conversation.active || conversation.callId !== callId) return;
|
|
305
|
-
|
|
317
|
+
if (!visibleText.loadTranscript(callId, reply, conversation.identity?.displayName)) return;
|
|
306
318
|
foldedChatUnread.receiveMany(reply.rows);
|
|
307
|
-
callBubble.loadTranscript(reply.rows, conversation.identity?.displayName);
|
|
308
319
|
$('call-transcript-status').textContent = reply.hasMore ? '这里只显示这次通话最近的内容' : '';
|
|
309
320
|
} catch {
|
|
310
321
|
if (conversation.active && conversation.callId === callId) $('call-transcript-status').textContent = '字幕暂未更新 · 实时内容仍会继续显示';
|
|
311
322
|
} finally { transcriptLoading = false; }
|
|
312
323
|
}
|
|
313
324
|
$('share-badge').onclick = toggleScreen;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}).catch(error => status(explainError(error)));
|
|
317
|
-
async function submitMessage(event, inputId) {
|
|
318
|
-
event.preventDefault(); const input = $(inputId); const text = input.value;
|
|
325
|
+
async function submitCallMessage(event) {
|
|
326
|
+
event.preventDefault(); const input = $('call-message'); const text = input.value;
|
|
319
327
|
if (await conversation.send(text)) {
|
|
320
328
|
motion.signal('received');
|
|
321
329
|
if (input.value === text) input.value = '';
|
|
322
|
-
|
|
330
|
+
void conversationSurface.readHistory();
|
|
323
331
|
}
|
|
324
332
|
}
|
|
325
|
-
$('compose').onsubmit =
|
|
326
|
-
$('call-compose').onsubmit = event => submitMessage(event, 'call-message');
|
|
333
|
+
$('call-compose').onsubmit = submitCallMessage;
|
|
327
334
|
$('call-unread').onclick = () => callTranscript.scrollToBottom();
|
|
328
335
|
const unsubscribe = client.subscribe(event => {
|
|
329
336
|
if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
|
|
330
337
|
if (event.kind === 'view-dismiss') controls.dismiss();
|
|
331
338
|
});
|
|
332
339
|
const statusMonitor = setInterval(() => void conversation.refresh(), 1000);
|
|
333
|
-
const historyMonitor = setInterval(() => void readHistory(), 3000);
|
|
334
340
|
const transcriptMonitor = setInterval(() => void readCallTranscript(), 1000);
|
|
335
341
|
const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000);
|
|
336
342
|
const workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
|
|
337
|
-
void conversation.refresh();
|
|
343
|
+
void conversation.refresh(); conversationSurface.start(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
|
|
338
344
|
window.addEventListener('beforeunload', () => {
|
|
339
|
-
clearInterval(statusMonitor); clearInterval(
|
|
340
|
-
|
|
345
|
+
clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor);
|
|
346
|
+
conversationSurface.close();
|
|
347
|
+
unsubscribe(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
|
|
341
348
|
});
|
|
342
349
|
}
|
|
@@ -20,6 +20,7 @@ export class TranscriptView {
|
|
|
20
20
|
}
|
|
21
21
|
row.textContent = (row.textContent + text).slice(-16000);
|
|
22
22
|
if (pinned) this.container.scrollTop = this.container.scrollHeight;
|
|
23
|
+
return row;
|
|
23
24
|
}
|
|
24
25
|
finish({ role, transcript, turnId }) {
|
|
25
26
|
if (!['user', 'assistant'].includes(role) || (turnId && this.completed.has(turnId))) return;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
const MAX_PENDING = 64;
|
|
2
|
+
const MAX_REPORT_ATTEMPTS = 3;
|
|
3
|
+
const REPORT_RETRY_DELAYS_MS = [1_000, 2_000];
|
|
4
|
+
const CLIPPING_OVERFLOW = new Set(['auto', 'clip', 'hidden', 'scroll']);
|
|
5
|
+
|
|
6
|
+
function referenceKey(reference) {
|
|
7
|
+
if (reference?.kind === 'message' && typeof reference.messageId === 'string')
|
|
8
|
+
return `message:${reference.messageId}`;
|
|
9
|
+
if (reference?.kind === 'native-item' && typeof reference.nativeItemId === 'string')
|
|
10
|
+
return `native-item:${reference.nativeItemId}`;
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function intersect(bounds, rect) {
|
|
15
|
+
return {
|
|
16
|
+
left: Math.max(bounds.left, rect.left),
|
|
17
|
+
top: Math.max(bounds.top, rect.top),
|
|
18
|
+
right: Math.min(bounds.right, rect.right),
|
|
19
|
+
bottom: Math.min(bounds.bottom, rect.bottom),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nodeIsVisible(document, node) {
|
|
24
|
+
if (document.visibilityState !== 'visible' || node?.isConnected !== true) return false;
|
|
25
|
+
const view = document.defaultView;
|
|
26
|
+
if (!view) return false;
|
|
27
|
+
const rects = node.getClientRects?.();
|
|
28
|
+
if (!rects || rects.length === 0) return false;
|
|
29
|
+
const viewport = {
|
|
30
|
+
left: 0,
|
|
31
|
+
top: 0,
|
|
32
|
+
right: document.documentElement?.clientWidth ?? view.innerWidth,
|
|
33
|
+
bottom: document.documentElement?.clientHeight ?? view.innerHeight,
|
|
34
|
+
};
|
|
35
|
+
let visible = intersect(viewport, node.getBoundingClientRect());
|
|
36
|
+
for (let current = node; current; current = current.parentElement) {
|
|
37
|
+
if (current.hidden || current.getAttribute?.('aria-hidden') === 'true') return false;
|
|
38
|
+
const style = view.getComputedStyle?.(current);
|
|
39
|
+
if (style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse'
|
|
40
|
+
|| style?.opacity === '0') return false;
|
|
41
|
+
const clipsX = CLIPPING_OVERFLOW.has(style?.overflowX ?? style?.overflow);
|
|
42
|
+
const clipsY = CLIPPING_OVERFLOW.has(style?.overflowY ?? style?.overflow);
|
|
43
|
+
if ((clipsX || clipsY) && current !== node) {
|
|
44
|
+
const bounds = current.getBoundingClientRect?.();
|
|
45
|
+
if (bounds) visible = intersect(visible, {
|
|
46
|
+
left: clipsX ? bounds.left : visible.left,
|
|
47
|
+
right: clipsX ? bounds.right : visible.right,
|
|
48
|
+
top: clipsY ? bounds.top : visible.top,
|
|
49
|
+
bottom: clipsY ? bounds.bottom : visible.bottom,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return visible.right > visible.left && visible.bottom > visible.top;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function currentNodes(candidate) {
|
|
57
|
+
try {
|
|
58
|
+
const nodes = typeof candidate.nodes === 'function' ? candidate.nodes() : candidate.nodes;
|
|
59
|
+
return Array.isArray(nodes) ? nodes.filter(Boolean) : [];
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function errorCode(value) {
|
|
66
|
+
return typeof value?.code === 'string' ? value.code : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reports the first assistant text that reaches a visible renderer surface.
|
|
71
|
+
* A post-mutation animation frame plus DOM/document/viewport checks is not a
|
|
72
|
+
* claim that the compositor painted, the window was unoccluded, or a person read it.
|
|
73
|
+
*/
|
|
74
|
+
export class FirstVisibleTextReceipt {
|
|
75
|
+
constructor({ document, report, now = Date.now, frame } = {}) {
|
|
76
|
+
this.document = document;
|
|
77
|
+
this.report = report;
|
|
78
|
+
this.now = now;
|
|
79
|
+
this.frame = frame ?? document?.defaultView?.requestAnimationFrame?.bind(document.defaultView);
|
|
80
|
+
this.pending = new Map();
|
|
81
|
+
this.rejected = new Set();
|
|
82
|
+
this.onVisibilityChange = () => this.retry();
|
|
83
|
+
this.onGeometryChange = () => this.retry();
|
|
84
|
+
document?.addEventListener?.('visibilitychange', this.onVisibilityChange);
|
|
85
|
+
document?.addEventListener?.('scroll', this.onGeometryChange, true);
|
|
86
|
+
document?.defaultView?.addEventListener?.('resize', this.onGeometryChange);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setCall(callId) {
|
|
90
|
+
callId ??= null;
|
|
91
|
+
if (callId === this.callId) return;
|
|
92
|
+
this.callId = callId;
|
|
93
|
+
this.pending.clear();
|
|
94
|
+
this.rejected.clear();
|
|
95
|
+
this.reported = false;
|
|
96
|
+
this.exhausted = false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
observe({ callId, reference, nodes } = {}) {
|
|
100
|
+
const key = referenceKey(reference);
|
|
101
|
+
if (!key || !callId || callId !== this.callId || this.reported || this.exhausted
|
|
102
|
+
|| this.rejected.has(key)
|
|
103
|
+
|| (!Array.isArray(nodes) && typeof nodes !== 'function')) return false;
|
|
104
|
+
const existing = this.pending.get(key);
|
|
105
|
+
const candidate = existing ?? { callId, reference, nodes, attempts: 0, retryAt: 0 };
|
|
106
|
+
candidate.nodes = nodes;
|
|
107
|
+
if (!existing && currentNodes(candidate).length === 0) return false;
|
|
108
|
+
this.pending.set(key, candidate);
|
|
109
|
+
while (this.pending.size > MAX_PENDING) this.pending.delete(this.pending.keys().next().value);
|
|
110
|
+
this.retry();
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
retry() {
|
|
115
|
+
if (this.scheduled || this.inFlight || this.reported || this.exhausted || !this.callId || this.pending.size === 0
|
|
116
|
+
|| typeof this.frame !== 'function') return;
|
|
117
|
+
this.scheduled = true;
|
|
118
|
+
this.frame(() => {
|
|
119
|
+
this.scheduled = false;
|
|
120
|
+
void this.flush();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async flush() {
|
|
125
|
+
if (this.inFlight || this.reported || this.exhausted || !this.callId) return;
|
|
126
|
+
const entry = [...this.pending].find(([, candidate]) => currentNodes(candidate).some(node =>
|
|
127
|
+
nodeIsVisible(this.document, node)));
|
|
128
|
+
if (!entry) return;
|
|
129
|
+
const [key, candidate] = entry;
|
|
130
|
+
if (candidate.retryAt > this.now()) return;
|
|
131
|
+
const callId = this.callId;
|
|
132
|
+
const attempt = { callId, key };
|
|
133
|
+
this.inFlight = attempt;
|
|
134
|
+
candidate.attempts += 1;
|
|
135
|
+
let continueWithNext = false;
|
|
136
|
+
try {
|
|
137
|
+
const reply = await this.report({
|
|
138
|
+
event: 'first_text_visible', observedAt: this.now(), callId, reference: candidate.reference,
|
|
139
|
+
});
|
|
140
|
+
if (reply?.kind === 'ok' && this.callId === callId && this.pending.get(key) === candidate) {
|
|
141
|
+
this.reported = true;
|
|
142
|
+
this.pending.clear();
|
|
143
|
+
} else if (reply?.kind !== 'ok') {
|
|
144
|
+
continueWithNext = this.rejectCandidate(callId, key, candidate, reply);
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
continueWithNext = this.rejectCandidate(callId, key, candidate, error);
|
|
148
|
+
} finally {
|
|
149
|
+
if (this.inFlight === attempt) {
|
|
150
|
+
this.inFlight = undefined;
|
|
151
|
+
if (this.callId !== callId || continueWithNext) this.retry();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
rejectCandidate(callId, key, candidate, failure) {
|
|
157
|
+
if (this.callId !== callId || this.pending.get(key) !== candidate) return false;
|
|
158
|
+
if (errorCode(failure) === 'invalid_request') {
|
|
159
|
+
this.pending.delete(key);
|
|
160
|
+
if (this.rejected.size >= MAX_PENDING) {
|
|
161
|
+
this.exhausted = true;
|
|
162
|
+
this.pending.clear();
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
this.rejected.add(key);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (candidate.attempts >= MAX_REPORT_ATTEMPTS) {
|
|
169
|
+
// The first visible candidate was plausible but the Host never confirmed it.
|
|
170
|
+
// Preserve that result as unknown instead of reporting later text as the first.
|
|
171
|
+
this.exhausted = true;
|
|
172
|
+
this.pending.clear();
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
candidate.retryAt = this.now() + REPORT_RETRY_DELAYS_MS[candidate.attempts - 1];
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
close() {
|
|
180
|
+
this.document?.removeEventListener?.('visibilitychange', this.onVisibilityChange);
|
|
181
|
+
this.document?.removeEventListener?.('scroll', this.onGeometryChange, true);
|
|
182
|
+
this.document?.defaultView?.removeEventListener?.('resize', this.onGeometryChange);
|
|
183
|
+
this.pending.clear();
|
|
184
|
+
this.rejected.clear();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Owns the official companion's four subtitle projections and their receipt nodes. */
|
|
189
|
+
export class VisibleTextPresentation {
|
|
190
|
+
constructor({ document, report, transcript, callTranscript, bubble, callBubble }) {
|
|
191
|
+
this.receipt = new FirstVisibleTextReceipt({ document, report });
|
|
192
|
+
Object.assign(this, { transcript, callTranscript, bubble, callBubble });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
setCall(callId) {
|
|
196
|
+
this.callTranscript.setCall(callId);
|
|
197
|
+
this.receipt.setCall(callId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
appendLive(event, speaker) {
|
|
201
|
+
const reference = event.itemId
|
|
202
|
+
? { kind: 'native-item', nativeItemId: event.itemId }
|
|
203
|
+
: undefined;
|
|
204
|
+
const stableNodes = [
|
|
205
|
+
this.transcript.append(event.role, event.text, true),
|
|
206
|
+
this.callTranscript.appendLive(event),
|
|
207
|
+
];
|
|
208
|
+
this.bubble.append(event.role, event.text, speaker, reference);
|
|
209
|
+
this.callBubble.append(event.role, event.text, speaker, reference);
|
|
210
|
+
if (event.role === 'assistant' && reference && typeof event.text === 'string'
|
|
211
|
+
&& event.text.trim().length > 0) this.receipt.observe({
|
|
212
|
+
callId: event.callId,
|
|
213
|
+
reference,
|
|
214
|
+
nodes: () => [
|
|
215
|
+
...stableNodes,
|
|
216
|
+
...this.bubble.rowsForReference(reference),
|
|
217
|
+
...this.callBubble.rowsForReference(reference),
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
loadTranscript(callId, reply, companionName) {
|
|
223
|
+
if (!this.callTranscript.load(reply)) return false;
|
|
224
|
+
this.callBubble.loadTranscript(reply.rows, companionName);
|
|
225
|
+
for (const row of reply.rows) {
|
|
226
|
+
if (row.role !== 'assistant' || row.text.trim().length === 0) continue;
|
|
227
|
+
const reference = { kind: 'message', messageId: row.messageId };
|
|
228
|
+
const transcriptRow = this.callTranscript.rowForMessage(row.messageId);
|
|
229
|
+
this.receipt.observe({
|
|
230
|
+
callId,
|
|
231
|
+
reference,
|
|
232
|
+
nodes: () => [transcriptRow, ...this.callBubble.rowsForReference(reference)],
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
this.receipt.retry();
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
retry() { this.receipt.retry(); }
|
|
240
|
+
close() { this.receipt.close(); }
|
|
241
|
+
}
|