@clowder-ai/companion 0.1.0-alpha.12 → 0.1.0-alpha.13

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
@@ -10,10 +10,19 @@ visible and ends the call in one click. Screen sharing has its own explicit
10
10
  picker and termination badge. There is no microphone action on a double-click,
11
11
  page load, body tap or drag.
12
12
 
13
+ When a compatible Host advertises receive-only audio, **只听** opens the same
14
+ conversation with playback but never requests or toggles a microphone. Older
15
+ Hosts omit that capability, so the control stays hidden and ordinary voice keeps
16
+ the exact legacy connect request. Text remains available independently.
17
+
13
18
  Typing works with voice off. The Host uses the existing owner conversation,
14
19
  configured duty cat, ordinary message routing and idempotency. The small history
15
20
  view reads a bounded recent subset; **完整聊天** opens the canonical conversation.
16
21
  No second transcript database, credentials or selectable Host identity live here.
22
+ When the Host supplies a valid per-message identity snapshot, each history row
23
+ keeps its real author while separately showing the partner, Live carrier and
24
+ deep cat saved with that message. Legacy rows omit this context instead of
25
+ borrowing today's selected companion.
17
26
 
18
27
  ## Host integration
19
28
 
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.13",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -40,7 +40,7 @@
40
40
  "bridgeVersion": "1.3.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-x3ponGs/dhxPH5D1rT9JrLqJpFA6rxrgMjSImhksoHQ="
43
+ "integrity": "sha256-vGmfNZUU2zOlDNsQUqPjrvcwmO6p+Qdi3aPwlI8Wcyc="
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.12",
3
+ "version": "0.1.0-alpha.13",
4
4
  "description": "The desktop voice body for your Clowder companion",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  "prepack": "pnpm build"
27
27
  },
28
28
  "devDependencies": {
29
- "@clowder-ai/plugin-contract": "0.1.0-beta.20",
30
- "@clowder-ai/plugin-sdk": "0.1.0-beta.16"
29
+ "@clowder-ai/plugin-contract": "0.1.0-beta.21",
30
+ "@clowder-ai/plugin-sdk": "0.1.0-beta.17"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.13",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -40,7 +40,7 @@
40
40
  "bridgeVersion": "1.3.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-x3ponGs/dhxPH5D1rT9JrLqJpFA6rxrgMjSImhksoHQ="
43
+ "integrity": "sha256-vGmfNZUU2zOlDNsQUqPjrvcwmO6p+Qdi3aPwlI8Wcyc="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
@@ -19,7 +19,7 @@ export function createCompanionClient(bridge) {
19
19
  return {
20
20
  state: () => invoke({ kind: 'state' }, 'state'),
21
21
  prepare: () => invoke({ kind: 'prepare' }, 'state'),
22
- connectAudio: () => invoke({ kind: 'audio.connect' }, 'ok'),
22
+ connectAudio: (mode) => invoke(mode === undefined ? { kind: 'audio.connect' } : { kind: 'audio.connect', mode }, 'ok'),
23
23
  closeAudio: () => invoke({ kind: 'audio.close' }, 'ok'),
24
24
  muteMicrophone: (muted) => invoke({ kind: 'audio.microphone', muted }, 'ok'),
25
25
  muteSpeaker: (muted) => invoke({ kind: 'audio.speaker', muted }, 'ok'),
@@ -1,12 +1,13 @@
1
1
  const named = value => typeof value === 'string' && value.trim().length > 0;
2
2
  const actor = value => value && named(value.catId) && named(value.displayName);
3
3
  const model = value => value === null || (named(value) && value.length <= 160);
4
+ const snapshotActor = value => actor(value) && value.catId.length <= 160 && value.displayName.length <= 160;
4
5
 
5
6
  function validSnapshot(value) {
6
7
  return value?.v === 1 && value.name === '猫猫球'
7
- && actor(value.partner) && named(value.partner.skin)
8
- && actor(value.live) && value.live.transport === 'gpt_live_v3' && model(value.live.verifiedModel)
9
- && actor(value.deep) && value.deep.catId === value.partner.catId && model(value.deep.verifiedModel);
8
+ && snapshotActor(value.partner) && named(value.partner.skin) && value.partner.skin.length <= 160
9
+ && snapshotActor(value.live) && value.live.transport === 'gpt_live_v3' && model(value.live.verifiedModel)
10
+ && snapshotActor(value.deep) && value.deep.catId === value.partner.catId && model(value.deep.verifiedModel);
10
11
  }
11
12
 
12
13
  function display(snapshot, partnerLabel) {
@@ -7,26 +7,41 @@ export class CompanionConversation {
7
7
  this.active = false;
8
8
  this.generation = 0;
9
9
  this.phase = 'idle';
10
+ this.audioMode = null;
11
+ this.retryMode = null;
10
12
  this.muted = false;
11
13
  this.silent = false;
12
14
  this.failed = false;
13
15
  }
14
16
  show(message) {
15
17
  this.message = message;
16
- this.render({ phase: this.phase, identity: this.identity, muted: this.muted, silent: this.silent, failed: this.failed, message });
18
+ this.render({ phase: this.phase, identity: this.identity, audioMode: this.audioMode, retryMode: this.retryMode,
19
+ muted: this.muted, silent: this.silent, failed: this.failed, message });
17
20
  }
18
21
  current(generation) { return this.active && generation === this.generation; }
19
- listening() { return this.muted ? '麦克风已静音' : '正在听,直接说话就好'; }
20
- async begin() {
22
+ listening() {
23
+ if (this.audioMode === 'receive_only') return '只听模式 · 麦克风未启用';
24
+ return this.muted ? '麦克风已静音' : '正在听,直接说话就好';
25
+ }
26
+ retrying(prefix) {
27
+ return this.audioMode === 'receive_only'
28
+ ? `${prefix} · 麦克风未启用,点击只听重试`
29
+ : `${prefix} · 点击语音聊重试`;
30
+ }
31
+ async begin(mode = 'duplex') {
21
32
  if (this.active || this.stopping || this.changingDocuments) return;
33
+ if (mode === 'receive_only' && !this.identity?.audio?.supportedModes?.includes(mode)) return;
34
+ if (!['duplex', 'receive_only'].includes(mode)) return;
22
35
  this.active = true;
36
+ this.audioMode = mode;
37
+ this.retryMode = null;
23
38
  this.failed = false;
24
39
  this.phase = 'connecting';
25
40
  const generation = ++this.generation;
26
41
  // Must reach the isolated preload while the click still has user activation.
27
42
  const preparation = this.client.prepare();
28
43
  this.show('正在连接…');
29
- this.deadline = setTimeout(() => { if (this.current(generation)) void this.end('连接超时 · 点击语音聊重试', true); }, 60_000);
44
+ this.deadline = setTimeout(() => { if (this.current(generation)) void this.end(this.retrying('连接超时'), true); }, 60_000);
30
45
  try {
31
46
  const peer = this.createPeer(event => {
32
47
  if (!this.current(generation)) return;
@@ -37,12 +52,14 @@ export class CompanionConversation {
37
52
  if (event.type === 'error') void this.end(explainError(event), true);
38
53
  });
39
54
  this.peer = peer;
40
- peer.muteMic(this.muted); peer.muteSpeaker(this.silent);
55
+ if (mode === 'duplex') peer.muteMic(this.muted);
56
+ peer.muteSpeaker(this.silent);
41
57
  // Submit capture intent while this same click is active. The Host waits
42
58
  // for its matching preparation before creating the media document.
43
- const [identity] = await Promise.all([preparation, peer.connect()]);
59
+ const [identity] = await Promise.all([preparation, peer.connect(mode === 'receive_only' ? mode : undefined)]);
44
60
  if (!this.current(generation)) { await peer.close(); return; }
45
61
  if (identity.phase !== 'ready') throw { code: 'unavailable' };
62
+ if (mode === 'receive_only' && !identity.audio?.supportedModes?.includes(mode)) throw { code: 'unavailable' };
46
63
  this.identity = identity;
47
64
  this.show(this.message);
48
65
  } catch (error) {
@@ -56,30 +73,42 @@ export class CompanionConversation {
56
73
  clearTimeout(this.deadline);
57
74
  const peer = this.peer;
58
75
  this.peer = undefined;
76
+ const releasedMode = this.audioMode;
77
+ this.audioMode = null;
78
+ this.retryMode = this.failed ? releasedMode : null;
59
79
  this.phase = 'idle';
60
80
  this.show(message);
61
81
  await Promise.allSettled([peer?.close(), this.stopScreen()]);
62
82
  }
63
- async end(message = '语音已结束 · 麦克风已关闭', failed = false) {
83
+ async end(message, failed = false) {
64
84
  if (this.stopping) return this.stopping;
85
+ const receiveOnly = this.audioMode === 'receive_only';
86
+ message ??= this.audioMode === 'receive_only' ? '只听已结束 · 麦克风未启用' : '语音已结束 · 麦克风已关闭';
65
87
  this.failed = failed;
66
88
  const local = this.releaseLocal(message);
67
89
  const operation = Promise.all([local, this.client.stop()]);
68
90
  this.stopping = operation;
69
91
  try { await operation; }
70
- catch { this.show('麦克风已关闭 · 连接收尾尚未确认'); }
92
+ catch { this.show(receiveOnly ? '麦克风未启用 · 连接收尾尚未确认' : '麦克风已关闭 · 连接收尾尚未确认'); }
71
93
  finally { if (this.stopping === operation) this.stopping = undefined; }
72
94
  }
73
95
  async hostStopped(reason) {
74
96
  // Stop echoes must not erase a more useful local failure or manual ending.
75
97
  if (!this.active) return;
76
- const messages = {
98
+ const receiveOnly = this.audioMode === 'receive_only';
99
+ const messages = receiveOnly ? {
100
+ locked: '屏幕已锁定 · 只听已停止,解锁后可点击只听继续',
101
+ suspended: '电脑已休眠 · 只听已停止,可点击只听继续',
102
+ hidden: '猫猫已收起 · 只听已停止',
103
+ } : {
77
104
  locked: '屏幕已锁定 · 语音已停止,解锁后可点击语音聊继续',
78
105
  suspended: '电脑已休眠 · 语音已停止,可点击语音聊继续',
79
106
  hidden: '猫猫已收起 · 语音已停止',
80
107
  };
81
108
  this.failed = !Object.hasOwn(messages, reason);
82
- await this.releaseLocal(messages[reason] ?? '语音连接已中断 · 麦克风已关闭,点击语音聊重试');
109
+ await this.releaseLocal(messages[reason] ?? (receiveOnly
110
+ ? '只听连接已中断 · 麦克风未启用,点击只听重试'
111
+ : '语音连接已中断 · 麦克风已关闭,点击语音聊重试'));
83
112
  }
84
113
  async refresh() {
85
114
  if (this.refreshing) return;
@@ -89,8 +118,9 @@ export class CompanionConversation {
89
118
  const identity = await this.client.state();
90
119
  if (generation !== this.generation) return;
91
120
  this.identity = identity;
121
+ if (this.active && identity.audio?.activeMode) this.audioMode = identity.audio.activeMode;
92
122
  if (this.active && ['closed', 'failed', 'idle'].includes(identity.phase)) {
93
- await this.end('语音连接已中断 · 点击语音聊重试', true);
123
+ await this.end(this.retrying('语音连接已中断'), true);
94
124
  } else this.show(this.message ?? '点开始聊天,直接对我说话');
95
125
  } catch (error) {
96
126
  if (generation === this.generation) {
@@ -134,6 +164,10 @@ export class CompanionConversation {
134
164
  return false;
135
165
  } finally { this.sending = false; }
136
166
  }
137
- muteMic() { this.muted = !this.muted; this.peer?.muteMic(this.muted); this.show(this.phase === 'talking' ? this.listening() : this.message); }
167
+ muteMic() {
168
+ if (this.audioMode === 'receive_only') return;
169
+ this.muted = !this.muted; this.peer?.muteMic(this.muted);
170
+ this.show(this.phase === 'talking' ? this.listening() : this.message);
171
+ }
138
172
  muteSpeaker() { this.silent = !this.silent; this.peer?.muteSpeaker(this.silent); this.show(this.message); }
139
173
  }
@@ -4,6 +4,7 @@
4
4
  <body>
5
5
  <svg class="icon-definitions" aria-hidden="true">
6
6
  <symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3M8 21h8"/></symbol>
7
+ <symbol id="i-listen" viewBox="0 0 24 24"><path d="M4 13v-1a8 8 0 0 1 16 0v1M4 13h3v7H6a2 2 0 0 1-2-2v-5ZM20 13h-3v7h1a2 2 0 0 0 2-2v-5Z"/></symbol>
7
8
  <symbol id="i-keyboard" viewBox="0 0 24 24"><rect x="2.5" y="5" width="19" height="14" rx="3"/><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M7 15h10"/></symbol>
8
9
  <symbol id="i-history" viewBox="0 0 24 24"><path d="M3 11a9 9 0 1 1 2.6 7.4M3 4v7h7M12 7v5l3 2"/></symbol>
9
10
  <symbol id="i-screen" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="13" rx="2.5"/><path d="M8 21h8M12 17v4"/></symbol>
@@ -36,7 +37,7 @@
36
37
  <video id="living-video-transition" muted playsinline preload="metadata" aria-hidden="true" hidden></video>
37
38
  </button>
38
39
  <div id="indicators">
39
- <button id="call-badge" hidden aria-label="结束语音" title="结束语音"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4M8 22h8"/></svg></button>
40
+ <button id="call-badge" hidden aria-label="结束语音" title="结束语音"><svg viewBox="0 0 24 24" aria-hidden="true"><use id="call-badge-icon" href="#i-mic"/></svg></button>
40
41
  <button id="share-badge" hidden aria-label="停止共享" title="停止共享"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg></button>
41
42
  <button id="pending-badge" hidden aria-label="查看待决事项" title="查看待决事项"><span id="pending-count">?</span></button>
42
43
  </div>
@@ -48,6 +49,7 @@
48
49
  <p id="status" role="status" hidden></p>
49
50
  <div class="action-row">
50
51
  <button id="begin" class="primary" data-action="begin"><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">语音聊</span></button>
52
+ <button id="listen" data-action="listen" hidden><svg class="icon" aria-hidden="true"><use href="#i-listen"/></svg><span class="label">只听</span></button>
51
53
  <button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>打字</span></button>
52
54
  <button id="mic" data-action="mute" hidden><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">静音</span></button>
53
55
  </div>
package/renderer/peer.mjs CHANGED
@@ -10,7 +10,7 @@ export class VoicePeer {
10
10
  }
11
11
  });
12
12
  }
13
- async connect() { if (!this.closed) await this.client.connectAudio(); }
13
+ async connect(mode) { if (!this.closed) await this.client.connectAudio(mode); }
14
14
  muteMic(muted) { void this.client.muteMicrophone(muted).catch(() => this.failed()); }
15
15
  muteSpeaker(muted) { void this.client.muteSpeaker(muted).catch(() => this.failed()); }
16
16
  failed() { if (!this.closed) this.onEvent({ type: 'error', code: 'unavailable' }); }
@@ -50,6 +50,7 @@ button:disabled { opacity: .45; cursor: default; }
50
50
  #indicators button:hover { background: #ab4842; }
51
51
  #indicators button[data-state="connecting"] { background: #a77b36; animation: pulse 1.8s infinite; }
52
52
  #indicators button[data-state="muted"] { background: #5f6670; }
53
+ #indicators button[data-state="receive-only"] { background: #476dac; }
53
54
  #indicators svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
54
55
  #share-badge { background: #476dac !important; }
55
56
  #pending-badge { background: #9b6841 !important; font-size: 11px; font-weight: 700; }
@@ -100,6 +101,7 @@ button:disabled { opacity: .45; cursor: default; }
100
101
  #pet[data-skin="yarn-ball"] { background-image: url("./skins/yarn-ball.png"); animation: none; background-size: contain; background-repeat: no-repeat; background-position: center; }
101
102
  #transcript { height: 230px; font-size: 13px; }
102
103
  #transcript p[data-author]::before { content: attr(data-author); display: block; color: var(--cafe-text-muted); font-size: 10px; margin-bottom: 2px; }
104
+ #transcript p[data-companion]::after { content: attr(data-companion) " · " attr(data-companion-live) " · " attr(data-companion-deep); display: block; color: var(--cafe-text-muted); font-size: 9px; line-height: 1.35; margin-top: 4px; }
103
105
  #transcript p[data-live="true"] { border-left: 2px solid var(--cafe-accent); }
104
106
  #history { padding: 0; font-size: 10px; color: var(--cafe-text-secondary); }
105
107
  #decisions { width: 320px; min-height: 180px; padding: 8px 12px 12px; display: flex; flex-direction: column; overflow: hidden; }
@@ -54,6 +54,7 @@ if (!window.clowderCompanion) {
54
54
  changed: panel => { if (panel === 'chat') void readHistory(); if (panel === 'decisions') void readDecisions(); },
55
55
  action(kind) {
56
56
  if (kind === 'begin') { transcript.reset(); bubble.reset(); void conversation.begin(); void controls.show('none'); }
57
+ if (kind === 'listen') { transcript.reset(); bubble.reset(); void conversation.begin('receive_only'); void controls.show('none'); }
57
58
  if (kind === 'stop') void conversation.end();
58
59
  if (kind === 'mute') conversation.muteMic();
59
60
  if (kind === 'speaker') conversation.muteSpeaker();
@@ -100,24 +101,35 @@ if (!window.clowderCompanion) {
100
101
  if (previousPhase !== 'idle' && value.phase === 'idle') { transcript.reset(); bubble.reset(); void readHistory(); }
101
102
  previousPhase = value.phase;
102
103
  const active = value.phase !== 'idle';
104
+ const receiveOnly = value.audioMode === 'receive_only';
105
+ const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
106
+ const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
103
107
  void controls.setAmbient(showAmbient(active));
104
108
  $('begin').hidden = active; $('begin').disabled = !value.identity;
105
- label('begin', value.failed ? '重试语音' : '语音聊');
106
- $('mic').hidden = value.phase !== 'talking'; $('speaker').hidden = !active;
109
+ $('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
110
+ label('begin', value.failed && !retryReceiveOnly ? '重试语音' : '语音聊');
111
+ label('listen', retryReceiveOnly ? '重试只听' : '只听');
112
+ $('begin').className = retryReceiveOnly ? '' : 'primary';
113
+ $('listen').className = retryReceiveOnly ? 'primary' : '';
114
+ $('mic').hidden = value.phase !== 'talking' || receiveOnly; $('speaker').hidden = !active;
107
115
  $('active-actions').hidden = !active;
108
116
  $('share').disabled = value.phase !== 'talking';
109
117
  $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
110
118
  status(value.message ?? '');
111
- $('status').hidden = !value.message || /^(点|正在听|语音已结束|已发送)/.test(value.message);
119
+ $('status').hidden = !value.message || /^(点|正在听|只听模式|只听已结束|语音已结束|已发送)/.test(value.message);
112
120
  $('call-badge').hidden = !active;
113
- $('call-badge').dataset.state = value.muted ? 'muted' : value.phase;
114
- const callLabel = value.phase === 'connecting' ? '正在连接,点此取消' : value.muted ? '麦克风已静音,点此结束' : '语音进行中,点此结束';
121
+ $('call-badge').dataset.state = receiveOnly ? 'receive-only' : value.muted ? 'muted' : value.phase;
122
+ $('call-badge-icon').setAttribute('href', receiveOnly ? '#i-listen' : '#i-mic');
123
+ const callLabel = value.phase === 'connecting' ? (receiveOnly ? '正在连接只听,麦克风未启用,点此取消' : '正在连接,点此取消')
124
+ : receiveOnly ? '只听进行中,麦克风未启用,点此结束'
125
+ : value.muted ? '麦克风已静音,点此结束' : '语音进行中,点此结束';
115
126
  $('call-badge').title = callLabel; $('call-badge').setAttribute('aria-label', callLabel);
116
127
  label('mic', value.muted ? '取消静音' : '静音'); $('mic').setAttribute('aria-pressed', String(value.muted));
117
128
  label('speaker', value.silent ? '开启播音' : '关闭播音');
118
129
  const identity = value.identity;
119
130
  const companionIdentity = currentCompanionIdentity(identity);
120
- motion.setContext({ skin: identity?.skin, phase: value.phase, nativeActivity: identity?.nativeActivity ?? 'none', muted: value.muted });
131
+ motion.setContext({ skin: identity?.skin, phase: value.phase,
132
+ nativeActivity: identity?.nativeActivity ?? 'none', muted: value.muted || receiveOnly });
121
133
  motion.syncNativeSnapshot(normalizeNativeWork(identity?.nativeWork));
122
134
  if (justConnected) motion.signal('connected');
123
135
  if (showFailure) motion.signal('failed');
@@ -1,3 +1,5 @@
1
+ import { historicalCompanionIdentity } from './companion-identity.mjs';
2
+
1
3
  export class TranscriptView {
2
4
  constructor(container) {
3
5
  this.container = container;
@@ -57,7 +59,23 @@ export class TranscriptView {
57
59
  const row = previous.get(message.id) ?? this.container.ownerDocument.createElement('p');
58
60
  row.className = message.role; row.dataset.messageId = message.id;
59
61
  if (row.textContent !== message.text) row.textContent = message.text;
60
- row.title = message.name; row.dataset.author = message.name;
62
+ const identity = historicalCompanionIdentity(message.companionIdentity, message.name);
63
+ row.dataset.author = identity.authorLabel;
64
+ if (identity.companionKnown) {
65
+ row.dataset.companion = identity.partnerLabel;
66
+ row.dataset.companionLive = identity.liveLabel;
67
+ row.dataset.companionDeep = identity.deepLabel;
68
+ row.dataset.companionAvatar = identity.avatarCatId;
69
+ row.dataset.companionSkin = identity.skin;
70
+ row.title = [identity.authorLabel, identity.partnerLabel, identity.liveLabel, identity.deepLabel].join('\n');
71
+ row.setAttribute('aria-label', [identity.authorLabel, message.text,
72
+ identity.partnerLabel, identity.liveLabel, identity.deepLabel].join('。'));
73
+ } else {
74
+ for (const key of ['companion', 'companionLive', 'companionDeep', 'companionAvatar', 'companionSkin'])
75
+ delete row.dataset[key];
76
+ row.title = identity.authorLabel;
77
+ row.removeAttribute?.('aria-label');
78
+ }
61
79
  this.historyRows.set(message.id, row);
62
80
  return row;
63
81
  });