@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 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.3.0**. The Host computes
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. Older 1.0-only
32
- Hosts reject this manifest before installation. Media admission and capture
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.21",
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.3.0",
40
+ "bridgeVersion": "1.4.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-rfOwPKO1TWQFzz7oUnWghd+p9HnVRS6e0SmUX679ezY="
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.21",
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/panel-layout.browser.mjs",
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.25",
31
- "@clowder-ai/plugin-sdk": "0.1.0-beta.21",
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.21",
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.3.0",
40
+ "bridgeVersion": "1.4.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-rfOwPKO1TWQFzz7oUnWghd+p9HnVRS6e0SmUX679ezY="
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 true;
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();
@@ -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'),
@@ -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
  }
@@ -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 { width: 8px; height: 8px; flex: none; border-radius: 999px; background: var(--desktop-success); }
98
- [data-connection-state="recovering"] .status-dot { background: var(--desktop-warning); }
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"); }
@@ -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
- bubble.append(event.role, event.text, speaker); callBubble.append(event.role, event.text, speaker);
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
- callTranscript.setCall(value.callId);
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
- for (const id of ['actions', 'chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context']) $(id).dataset.connectionState = value.recovering ? 'recovering' : active ? 'connected' : 'idle';
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
- callTranscript.load(reply);
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
+ }