@clowder-ai/companion 0.1.0-alpha.21 → 0.1.0-alpha.24
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 +12 -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 +7 -0
- package/renderer/index.html +7 -2
- package/renderer/meeting-state.mjs +55 -0
- package/renderer/recent-bubble.mjs +19 -2
- package/renderer/style.css +40 -3
- package/renderer/surface.mjs +16 -8
- package/renderer/transcript-view.mjs +1 -0
- package/renderer/visible-text-receipt.mjs +241 -0
package/README.md
CHANGED
|
@@ -26,12 +26,21 @@ borrowing today's selected companion.
|
|
|
26
26
|
|
|
27
27
|
## Host integration
|
|
28
28
|
|
|
29
|
-
This surface requires the additive desktop bridge **1.
|
|
29
|
+
This surface requires the additive desktop bridge **1.4.0**. The Host computes
|
|
30
30
|
placement within the display work area, keeps the cat as its stable anchor, and
|
|
31
|
-
owns bounded drag gestures and transparent-area hit testing.
|
|
32
|
-
|
|
31
|
+
owns bounded drag gestures and transparent-area hit testing. Hosts below 1.4
|
|
32
|
+
reject this manifest before installation. Media admission and capture
|
|
33
33
|
remain in the trusted Host; this package receives neither streams nor provider
|
|
34
34
|
SDP. Hiding releases media; the existing Clowder **聊聊** entry restores the cat.
|
|
35
|
+
Bridge 1.4 adds one untrusted first-visible-subtitle observation containing only
|
|
36
|
+
an epoch-millisecond time, the current call fence and a Host-correlatable source
|
|
37
|
+
reference. It does not claim compositor paint, an unoccluded window or reading.
|
|
38
|
+
Message and native-item references use the same bounded, single-line opaque ID
|
|
39
|
+
contract as the transcript surfaces that supply them. A Host
|
|
40
|
+
`invalid_request` rejects and remembers that candidate for the current call so
|
|
41
|
+
another current reference may be observed without retrying the rejected one.
|
|
42
|
+
Other failures retry after 1 and 2 seconds, then leave the call's
|
|
43
|
+
first-visible result unknown; later text never backfills a manufactured success.
|
|
35
44
|
|
|
36
45
|
Xianxian uses one layered sitting body or one VP9 clip at a time. Expiring Host
|
|
37
46
|
facts with task, result and event identities select tool work, workspace fetch,
|
package/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pluginId": "official.companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.24",
|
|
4
4
|
"contractVersion": "0.1.0",
|
|
5
5
|
"name": "猫猫球",
|
|
6
6
|
"icon": {
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"type": "desktop-window",
|
|
38
38
|
"id": "companion",
|
|
39
39
|
"role": "companion",
|
|
40
|
-
"bridgeVersion": "1.
|
|
40
|
+
"bridgeVersion": "1.4.0",
|
|
41
41
|
"surface": {
|
|
42
42
|
"entrypoint": "renderer/index.html",
|
|
43
|
-
"integrity": "sha256-
|
|
43
|
+
"integrity": "sha256-EFERDKvXnOw6Mbk1Kq7ZyX7w4FdswfX5W/B0w/KTLE8="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
46
|
"width": 300,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clowder-ai/companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.24",
|
|
4
4
|
"description": "The desktop voice body for your Clowder companion",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,14 +21,14 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "node scripts/build.mjs",
|
|
23
23
|
"test": "node --test test/*.test.mjs",
|
|
24
|
-
"test:browser": "pnpm build && node --test test
|
|
24
|
+
"test:browser": "pnpm build && node --test test/*.browser.mjs",
|
|
25
25
|
"typecheck": "node scripts/check.mjs",
|
|
26
26
|
"lint": "node scripts/check.mjs",
|
|
27
27
|
"prepack": "pnpm build"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@clowder-ai/plugin-contract": "0.1.0-beta.
|
|
31
|
-
"@clowder-ai/plugin-sdk": "0.1.0-beta.
|
|
30
|
+
"@clowder-ai/plugin-contract": "0.1.0-beta.27",
|
|
31
|
+
"@clowder-ai/plugin-sdk": "0.1.0-beta.23",
|
|
32
32
|
"playwright": "1.61.1"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
package/plugin.yaml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pluginId": "official.companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.24",
|
|
4
4
|
"contractVersion": "0.1.0",
|
|
5
5
|
"name": "猫猫球",
|
|
6
6
|
"icon": {
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"type": "desktop-window",
|
|
38
38
|
"id": "companion",
|
|
39
39
|
"role": "companion",
|
|
40
|
-
"bridgeVersion": "1.
|
|
40
|
+
"bridgeVersion": "1.4.0",
|
|
41
41
|
"surface": {
|
|
42
42
|
"entrypoint": "renderer/index.html",
|
|
43
|
-
"integrity": "sha256-
|
|
43
|
+
"integrity": "sha256-EFERDKvXnOw6Mbk1Kq7ZyX7w4FdswfX5W/B0w/KTLE8="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
46
|
"width": 300,
|
|
@@ -191,9 +191,11 @@ export class CallTranscriptView {
|
|
|
191
191
|
this.container.scrollTop = this.container.scrollHeight;
|
|
192
192
|
this.setUnread(0);
|
|
193
193
|
} else if (isNew) this.setUnread(this.unread + 1);
|
|
194
|
-
return
|
|
194
|
+
return row;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
rowForMessage(messageId) { return this.durableRows.get(messageId); }
|
|
198
|
+
|
|
197
199
|
appendTyped({ receipt, text }) {
|
|
198
200
|
if (!this.callId || receipt?.delivery !== 'accepted' || receipt.callId !== this.callId) return false;
|
|
199
201
|
const pinned = this.nearBottom();
|
package/renderer/client.mjs
CHANGED
|
@@ -36,6 +36,13 @@ export function createCompanionClient(bridge) {
|
|
|
36
36
|
screenFrame: (selectionId, frame) => invoke({ kind: 'screen.frame', selectionId, frame }, 'ok'),
|
|
37
37
|
screenClose: () => invoke({ kind: 'screen.close' }, 'ok'),
|
|
38
38
|
openConversation: () => invoke({ kind: 'conversation.open' }, 'navigation'),
|
|
39
|
+
observePresentation: (observation) => invoke({
|
|
40
|
+
kind: 'presentation.observe',
|
|
41
|
+
event: observation.event,
|
|
42
|
+
observedAt: observation.observedAt,
|
|
43
|
+
callId: observation.callId,
|
|
44
|
+
reference: observation.reference,
|
|
45
|
+
}, 'ok'),
|
|
39
46
|
readWork: () => invoke({ kind: 'work.read' }, 'work'),
|
|
40
47
|
openWork: (entryRef) => invoke({ kind: 'work.open', entryRef }, 'navigation'),
|
|
41
48
|
readDecisions: (offset = 0, limit = 20) => invoke({ kind: 'decisions.read', offset, limit }, 'decisions'),
|
package/renderer/index.html
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
</section>
|
|
50
50
|
<section id="actions" class="floating call-bar" aria-label="当前通话" hidden>
|
|
51
51
|
<div class="call-status-line"><span class="status-dot" aria-hidden="true"></span><strong id="call-state">通话中</strong><span id="call-mic-state">麦克风已开启</span></div>
|
|
52
|
+
<p id="call-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="call-meeting-label" class="meeting-label"></span><span id="call-meeting-source" class="meeting-source" hidden></span></p>
|
|
52
53
|
<p id="call-work" class="call-work" hidden></p>
|
|
53
54
|
<div id="call-share-context" class="call-share-context" hidden><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span><strong id="call-share-target"></strong><small>仅共享此窗口</small></span></div>
|
|
54
55
|
<div class="call-recent" aria-label="最新消息">
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
<section id="menu" class="floating name-card" aria-label="猫猫名片" hidden>
|
|
68
69
|
<div id="menu-call-context" class="call-context" hidden>
|
|
69
70
|
<span class="status-dot" aria-hidden="true"></span><strong id="menu-call-state"></strong><span id="menu-call-mic-state"></span>
|
|
71
|
+
<small id="menu-call-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="menu-call-meeting-label" class="meeting-label"></span><span id="menu-call-meeting-source" class="meeting-source" hidden></span></small>
|
|
70
72
|
<small id="menu-call-work" hidden></small>
|
|
71
73
|
<div id="menu-call-actions" class="call-context-actions menu-call-actions">
|
|
72
74
|
<button id="menu-mic" data-action="mute" aria-label="静音"><svg class="icon" aria-hidden="true"><use id="menu-mic-icon" href="#i-mic"/></svg></button>
|
|
@@ -111,8 +113,9 @@
|
|
|
111
113
|
<section id="chat" class="floating" aria-label="和猫猫写几句" hidden>
|
|
112
114
|
<div id="chat-call-context" class="call-context" hidden>
|
|
113
115
|
<span class="status-dot" aria-hidden="true"></span><strong id="chat-call-state"></strong><span id="chat-call-mic-state"></span>
|
|
116
|
+
<small id="chat-call-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="chat-call-meeting-label" class="meeting-label"></span><span id="chat-call-meeting-source" class="meeting-source" hidden></span></small>
|
|
114
117
|
<small id="chat-call-work" hidden></small>
|
|
115
|
-
<div class="call-context-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>
|
|
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>
|
|
116
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>
|
|
117
120
|
</div>
|
|
118
121
|
<header><strong id="chat-name">和猫猫写几句</strong><button data-action="dismiss" aria-label="收起文字">收起</button></header>
|
|
@@ -124,6 +127,7 @@
|
|
|
124
127
|
<section id="transcript" class="floating call-transcript" data-expanded="false" aria-label="这次通话的字幕" hidden>
|
|
125
128
|
<div id="transcript-call-context" class="call-context transcript-call-context">
|
|
126
129
|
<span class="status-dot" aria-hidden="true"></span><strong id="transcript-call-state"></strong><span id="transcript-call-mic-state"></span>
|
|
130
|
+
<small id="transcript-call-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="transcript-call-meeting-label" class="meeting-label"></span><span id="transcript-call-meeting-source" class="meeting-source" hidden></span></small>
|
|
127
131
|
<small id="transcript-call-work" hidden></small>
|
|
128
132
|
<div class="call-context-actions">
|
|
129
133
|
<button id="transcript-return" class="transcript-return" type="button" data-action="transcript-return"><span aria-hidden="true">‹</span><span>返回通话</span></button>
|
|
@@ -142,8 +146,9 @@
|
|
|
142
146
|
<section id="settings" class="floating settings-panel" aria-label="猫猫球设置" hidden>
|
|
143
147
|
<div id="settings-call-context" class="call-context" hidden>
|
|
144
148
|
<span class="status-dot" aria-hidden="true"></span><strong id="settings-call-state"></strong><span id="settings-call-mic-state"></span>
|
|
149
|
+
<small id="settings-call-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="settings-call-meeting-label" class="meeting-label"></span><span id="settings-call-meeting-source" class="meeting-source" hidden></span></small>
|
|
145
150
|
<small id="settings-call-work" hidden></small>
|
|
146
|
-
<div class="call-context-actions"><button id="settings-mic" data-action="mute">静音</button><button id="settings-share" data-action="share"><span class="label">共享画面</span></button><button data-action="stop" class="destructive compact">挂断</button></div>
|
|
151
|
+
<div class="call-context-actions call-context-inline-actions"><button id="settings-mic" data-action="mute">静音</button><button id="settings-share" data-action="share"><span class="label">共享画面</span></button><button data-action="stop" class="destructive compact">挂断</button></div>
|
|
147
152
|
<div id="settings-share-context" class="call-share-line" hidden><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span><strong id="settings-share-target"></strong><small>仅共享此窗口</small></span></div>
|
|
148
153
|
</div>
|
|
149
154
|
<header class="settings-head"><button id="settings-back" type="button" aria-label="返回名片">‹</button><strong id="settings-title">设置</strong></header>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function meetingPresentation(meeting) {
|
|
2
|
+
if (!meeting || meeting.kind === 'idle') return null;
|
|
3
|
+
if (meeting.kind === 'running') {
|
|
4
|
+
if (meeting.paused) return { label: '会议记录已暂停', sourceLabel: meeting.sourceLabel, tone: 'quiet' };
|
|
5
|
+
return meeting.sharing
|
|
6
|
+
? { label: '听会中', sourceLabel: meeting.sourceLabel, tone: 'active' }
|
|
7
|
+
: { label: '会议记录中 · 未接入通话', sourceLabel: meeting.sourceLabel, tone: 'active' };
|
|
8
|
+
}
|
|
9
|
+
if (meeting.kind === 'stopped') return { label: '会议记录已停止', sourceLabel: null, tone: 'quiet' };
|
|
10
|
+
if (meeting.kind === 'unconfirmed') return { label: '会议记录状态未确认', sourceLabel: null, tone: 'warning' };
|
|
11
|
+
if (meeting.kind === 'needs_source') return { label: '需要确认会议所在的 App', sourceLabel: null, tone: 'warning' };
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const meetingPrefixes = ['call', 'menu-call', 'chat-call', 'transcript-call', 'settings-call'];
|
|
16
|
+
export function renderMeetingStatus(document, presentation) {
|
|
17
|
+
for (const prefix of meetingPrefixes) {
|
|
18
|
+
const row = document.getElementById(`${prefix}-meeting`);
|
|
19
|
+
const source = document.getElementById(`${prefix}-meeting-source`);
|
|
20
|
+
row.hidden = presentation === null;
|
|
21
|
+
row.dataset.tone = presentation?.tone ?? '';
|
|
22
|
+
document.getElementById(`${prefix}-meeting-label`).textContent = presentation?.label ?? '';
|
|
23
|
+
source.textContent = presentation?.sourceLabel ? ` · ${presentation.sourceLabel}` : '';
|
|
24
|
+
source.hidden = !presentation?.sourceLabel;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Keeps the stopped acknowledgement transient without hiding uncertain or active capture truth. */
|
|
29
|
+
export class MeetingStatus {
|
|
30
|
+
constructor({ now = () => Date.now() } = {}) {
|
|
31
|
+
this.now = now;
|
|
32
|
+
this.previousKind = null;
|
|
33
|
+
this.stoppedUntil = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
update(meeting) {
|
|
37
|
+
const kind = meeting?.kind ?? 'idle';
|
|
38
|
+
if (kind === 'idle') {
|
|
39
|
+
this.previousKind = 'idle';
|
|
40
|
+
this.stoppedUntil = 0;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (kind === 'stopped') {
|
|
44
|
+
if (this.previousKind !== 'stopped') {
|
|
45
|
+
this.stoppedUntil = this.previousKind !== null && this.previousKind !== 'idle'
|
|
46
|
+
? this.now() + 10_000 : 0;
|
|
47
|
+
}
|
|
48
|
+
this.previousKind = 'stopped';
|
|
49
|
+
return this.now() < this.stoppedUntil ? meetingPresentation(meeting) : null;
|
|
50
|
+
}
|
|
51
|
+
this.previousKind = kind;
|
|
52
|
+
this.stoppedUntil = 0;
|
|
53
|
+
return meetingPresentation(meeting);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/** A compact projection of Host history plus the current unsaved voice caption. */
|
|
2
|
+
function referenceKey(reference) {
|
|
3
|
+
if (reference?.kind === 'message') return `message:${reference.messageId}`;
|
|
4
|
+
if (reference?.kind === 'native-item') return `native-item:${reference.nativeItemId}`;
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
|
|
2
8
|
export class RecentBubble {
|
|
3
9
|
constructor(rows) {
|
|
4
10
|
this.rows = rows;
|
|
5
11
|
this.history = [];
|
|
6
12
|
this.live = new Map();
|
|
13
|
+
this.rowReferences = new WeakMap();
|
|
7
14
|
}
|
|
8
15
|
load(messages) {
|
|
9
16
|
this.history = messages.slice(-2);
|
|
@@ -14,13 +21,16 @@ export class RecentBubble {
|
|
|
14
21
|
role: row.role,
|
|
15
22
|
text: row.text,
|
|
16
23
|
name: row.source?.kind === 'typed' ? '你' : row.role === 'assistant' ? companionName : '语音',
|
|
24
|
+
reference: { kind: 'message', messageId: row.messageId },
|
|
17
25
|
})));
|
|
18
26
|
}
|
|
19
|
-
append(role, text, name = role === 'user' ? '你' : '猫猫') {
|
|
27
|
+
append(role, text, name = role === 'user' ? '你' : '猫猫', reference) {
|
|
20
28
|
if (!['user', 'assistant'].includes(role) || typeof text !== 'string') return;
|
|
21
29
|
const previous = this.live.get(role);
|
|
22
|
-
this.live.set(role, { name, text: `${previous?.text ?? ''}${text}`.slice(-240)
|
|
30
|
+
this.live.set(role, { name, text: `${previous?.text ?? ''}${text}`.slice(-240),
|
|
31
|
+
reference: reference ?? previous?.reference });
|
|
23
32
|
this.render();
|
|
33
|
+
return reference ? this.rowsForReference(reference)[0] : this.rows.find(row => row.dataset?.role === role);
|
|
24
34
|
}
|
|
25
35
|
finish(role) {
|
|
26
36
|
this.live.delete(role);
|
|
@@ -38,6 +48,10 @@ export class RecentBubble {
|
|
|
38
48
|
hasContent() {
|
|
39
49
|
return this.history.length > 0 || this.live.size > 0;
|
|
40
50
|
}
|
|
51
|
+
rowsForReference(reference) {
|
|
52
|
+
const key = referenceKey(reference);
|
|
53
|
+
return key ? this.rows.filter(row => this.rowReferences.get(row) === key) : [];
|
|
54
|
+
}
|
|
41
55
|
render() {
|
|
42
56
|
const live = [...this.live].map(([role, value]) => ({ role, ...value }));
|
|
43
57
|
const entries = [...this.history.slice(-Math.max(0, 2 - live.length)), ...live].slice(-2);
|
|
@@ -69,6 +83,9 @@ export class RecentBubble {
|
|
|
69
83
|
}
|
|
70
84
|
row.dataset ??= {};
|
|
71
85
|
row.dataset.role = entry?.role ?? '';
|
|
86
|
+
const key = referenceKey(entry?.reference);
|
|
87
|
+
if (key) this.rowReferences.set(row, key);
|
|
88
|
+
else this.rowReferences.delete(row);
|
|
72
89
|
}
|
|
73
90
|
}
|
|
74
91
|
}
|
package/renderer/style.css
CHANGED
|
@@ -94,8 +94,44 @@ button:disabled { opacity: .45; cursor: default; }
|
|
|
94
94
|
.call-status-line { min-height: 24px; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
|
|
95
95
|
.call-status-line strong { font-size: 13px; font-weight: 600; }
|
|
96
96
|
.call-status-line span:not(.status-dot) { color: var(--desktop-muted); font-size: 12px; }
|
|
97
|
-
.status-dot {
|
|
98
|
-
|
|
97
|
+
.status-dot {
|
|
98
|
+
--call-status-color: var(--desktop-success);
|
|
99
|
+
position: relative;
|
|
100
|
+
isolation: isolate;
|
|
101
|
+
z-index: 0;
|
|
102
|
+
width: 8px;
|
|
103
|
+
height: 8px;
|
|
104
|
+
flex: none;
|
|
105
|
+
border-radius: 999px;
|
|
106
|
+
background: var(--call-status-color);
|
|
107
|
+
}
|
|
108
|
+
.status-dot::after {
|
|
109
|
+
content: "";
|
|
110
|
+
position: absolute;
|
|
111
|
+
inset: 0;
|
|
112
|
+
z-index: -1;
|
|
113
|
+
border-radius: inherit;
|
|
114
|
+
background: var(--call-status-color);
|
|
115
|
+
opacity: 0;
|
|
116
|
+
pointer-events: none;
|
|
117
|
+
transform: scale(1);
|
|
118
|
+
}
|
|
119
|
+
[data-connection-state="connected"] .status-dot::after,
|
|
120
|
+
[data-connection-state="recovering"] .status-dot::after { animation: call-status-breathe 2s ease-out infinite; }
|
|
121
|
+
[data-connection-state="recovering"] .status-dot { --call-status-color: var(--desktop-warning); }
|
|
122
|
+
[data-connection-state="connecting"] .status-dot,
|
|
123
|
+
[data-connection-state="idle"] .status-dot { --call-status-color: var(--desktop-muted); }
|
|
124
|
+
@keyframes call-status-breathe {
|
|
125
|
+
from { opacity: .38; transform: scale(1); }
|
|
126
|
+
to { opacity: 0; transform: scale(3); }
|
|
127
|
+
}
|
|
128
|
+
.call-meeting { min-width: 0; margin: 3px 0 0 15px; display: flex; align-items: center; gap: 5px; color: var(--desktop-muted); font-size: 12px; line-height: 1.35; }
|
|
129
|
+
.call-context > .call-meeting { grid-column: 1 / -1; margin: 0; }
|
|
130
|
+
.meeting-status-dot { width: 7px; height: 7px; flex: none; border-radius: 999px; background: var(--desktop-success); }
|
|
131
|
+
.call-meeting[data-tone="quiet"] .meeting-status-dot { background: var(--desktop-muted); }
|
|
132
|
+
.call-meeting[data-tone="warning"] .meeting-status-dot { background: var(--desktop-warning); }
|
|
133
|
+
.meeting-label { flex: none; white-space: nowrap; }
|
|
134
|
+
.meeting-source { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
99
135
|
.call-work { margin: 3px 0 0 15px; color: var(--desktop-muted); font-size: 12px; }
|
|
100
136
|
.call-share-context { display: flex; align-items: center; gap: 7px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--desktop-line); }
|
|
101
137
|
.call-share-context > span, .call-share-line > span { display: grid; gap: 1px; min-width: 0; }
|
|
@@ -132,6 +168,7 @@ button:disabled { opacity: .45; cursor: default; }
|
|
|
132
168
|
.call-context > small { grid-column: 2 / 4; }
|
|
133
169
|
.call-share-line { grid-column: 1 / -1; display: flex; align-items: center; gap: 7px; padding-top: 6px; border-top: 1px solid var(--desktop-line); min-width: 0; }
|
|
134
170
|
.call-context-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
|
|
171
|
+
.call-context-inline-actions { grid-column: 4; grid-row: 1; }
|
|
135
172
|
.call-context-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 9px; white-space: nowrap; }
|
|
136
173
|
#menu .call-context { margin: -8px -8px 5px; }
|
|
137
174
|
.menu-footer { flex: none; display: flex; align-items: center; gap: 2px; margin: 4px -8px -8px; border-top: 1px solid var(--desktop-line); padding: 4px; }
|
|
@@ -238,7 +275,7 @@ button:disabled { opacity: .45; cursor: default; }
|
|
|
238
275
|
.settings-empty { padding: 16px; }
|
|
239
276
|
.sr-only { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; }
|
|
240
277
|
@media (prefers-reduced-motion: reduce) {
|
|
241
|
-
#pet, #indicators button, #living-sit [data-layer], #pet[data-action="idle"] #living-sit [data-layer] { animation: none; }
|
|
278
|
+
#pet, #indicators button, [data-connection-state] .status-dot::after, #living-sit [data-layer], #pet[data-action="idle"] #living-sit [data-layer] { animation: none; }
|
|
242
279
|
#living-sit [data-layer="closed"] { opacity: 0; }
|
|
243
280
|
}
|
|
244
281
|
#pet[data-skin="yanyan-codex"] { background-image: url("./skins/yanyan-codex.webp"); }
|
package/renderer/surface.mjs
CHANGED
|
@@ -18,6 +18,8 @@ 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';
|
|
21
23
|
const $ = id => document.getElementById(id);
|
|
22
24
|
const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
|
|
23
25
|
const status = text => {
|
|
@@ -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) : '';
|
|
@@ -60,6 +63,11 @@ if (!window.clowderCompanion) {
|
|
|
60
63
|
} else {
|
|
61
64
|
nativeTravel.start();
|
|
62
65
|
const client = createCompanionClient(window.clowderCompanion);
|
|
66
|
+
const visibleText = new VisibleTextPresentation({
|
|
67
|
+
document,
|
|
68
|
+
report: observation => client.observePresentation(observation),
|
|
69
|
+
transcript, callTranscript, bubble, callBubble,
|
|
70
|
+
});
|
|
63
71
|
const controls = bindPetControls(client, {
|
|
64
72
|
error: (error, context) => status(context?.panel === 'settings' && error?.code === 'invalid_request' ? '这个 Host 版本还不支持猫旁设置。原来的设置入口会继续保留。' : explainError(error)),
|
|
65
73
|
moved: (dx, dy) => {
|
|
@@ -72,6 +80,7 @@ if (!window.clowderCompanion) {
|
|
|
72
80
|
setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
|
|
73
81
|
},
|
|
74
82
|
changed: panel => {
|
|
83
|
+
visibleText.retry();
|
|
75
84
|
if (panel === 'chat') foldedChatUnread.open();
|
|
76
85
|
else if (previousControlPanel === 'chat' && conversation?.active) foldedChatUnread.fold();
|
|
77
86
|
previousControlPanel = panel;
|
|
@@ -145,10 +154,8 @@ if (!window.clowderCompanion) {
|
|
|
145
154
|
callTranscript.appendTyped(event);
|
|
146
155
|
void readHistory(); void readCallTranscript();
|
|
147
156
|
} else {
|
|
148
|
-
transcript.append(event.role, event.text, true);
|
|
149
|
-
callTranscript.appendLive(event);
|
|
150
157
|
const speaker = event.role === 'assistant' ? conversation.identity?.displayName ?? '猫猫' : '语音';
|
|
151
|
-
|
|
158
|
+
visibleText.appendLive(event, speaker);
|
|
152
159
|
void controls.setAmbient('actions');
|
|
153
160
|
}
|
|
154
161
|
},
|
|
@@ -165,7 +172,7 @@ if (!window.clowderCompanion) {
|
|
|
165
172
|
const receiveOnly = value.audioMode === 'receive_only';
|
|
166
173
|
const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
|
|
167
174
|
const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
|
|
168
|
-
|
|
175
|
+
visibleText.setCall(value.callId);
|
|
169
176
|
void controls.setAmbient(ambientPanel(active));
|
|
170
177
|
$('begin').hidden = active; $('begin').disabled = !value.identity;
|
|
171
178
|
$('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
|
|
@@ -191,7 +198,8 @@ if (!window.clowderCompanion) {
|
|
|
191
198
|
: value.phase === 'connecting' ? '正在请求麦克风'
|
|
192
199
|
: value.recovering ? `正在等待原连接恢复 · ${value.muted ? '麦克风已静音' : '麦克风已开启'}`
|
|
193
200
|
: value.muted ? '麦克风已静音' : active ? '麦克风已开启' : '';
|
|
194
|
-
|
|
201
|
+
const connectionState = value.recovering ? 'recovering' : value.phase === 'talking' ? 'connected' : value.phase === 'connecting' ? 'connecting' : 'idle';
|
|
202
|
+
for (const id of ['actions', 'chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context']) $(id).dataset.connectionState = connectionState;
|
|
195
203
|
setTexts(['call-state', 'chat-call-state', 'transcript-call-state', 'menu-call-state', 'settings-call-state'], callState);
|
|
196
204
|
setTexts(['call-mic-state', 'chat-call-mic-state', 'transcript-call-mic-state', 'menu-call-mic-state', 'settings-call-mic-state'], microphoneState);
|
|
197
205
|
setHidden(['chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context'], !active);
|
|
@@ -214,6 +222,7 @@ if (!window.clowderCompanion) {
|
|
|
214
222
|
$('menu-hide-label').textContent = active ? '结束通话并隐藏' : '隐藏';
|
|
215
223
|
const identity = value.identity;
|
|
216
224
|
const companionIdentity = currentCompanionIdentity(identity);
|
|
225
|
+
renderMeetingStatus(document, meetingStatus.update(active ? identity?.meeting : null));
|
|
217
226
|
const nativeSnapshot = normalizeNativeWork(identity?.nativeWork);
|
|
218
227
|
const workLabel = nativeWorkLabel(nativeSnapshot, identity?.nativeActivity);
|
|
219
228
|
const workState = workLabel ? `${identity.displayName}${workLabel}` : '';
|
|
@@ -302,9 +311,8 @@ if (!window.clowderCompanion) {
|
|
|
302
311
|
try {
|
|
303
312
|
const reply = await client.readTranscript();
|
|
304
313
|
if (!conversation.active || conversation.callId !== callId) return;
|
|
305
|
-
|
|
314
|
+
if (!visibleText.loadTranscript(callId, reply, conversation.identity?.displayName)) return;
|
|
306
315
|
foldedChatUnread.receiveMany(reply.rows);
|
|
307
|
-
callBubble.loadTranscript(reply.rows, conversation.identity?.displayName);
|
|
308
316
|
$('call-transcript-status').textContent = reply.hasMore ? '这里只显示这次通话最近的内容' : '';
|
|
309
317
|
} catch {
|
|
310
318
|
if (conversation.active && conversation.callId === callId) $('call-transcript-status').textContent = '字幕暂未更新 · 实时内容仍会继续显示';
|
|
@@ -337,6 +345,6 @@ if (!window.clowderCompanion) {
|
|
|
337
345
|
void conversation.refresh(); void readHistory(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
|
|
338
346
|
window.addEventListener('beforeunload', () => {
|
|
339
347
|
clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor);
|
|
340
|
-
unsubscribe(); nativeTravel.close(); motion.close(); void conversation.end();
|
|
348
|
+
unsubscribe(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
|
|
341
349
|
});
|
|
342
350
|
}
|
|
@@ -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
|
+
}
|