@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 +9 -0
- package/manifest.json +2 -2
- package/package.json +3 -3
- package/plugin.yaml +2 -2
- package/renderer/client.mjs +1 -1
- package/renderer/companion-identity.mjs +4 -3
- package/renderer/conversation.mjs +46 -12
- package/renderer/index.html +3 -1
- package/renderer/peer.mjs +1 -1
- package/renderer/style.css +2 -0
- package/renderer/surface.mjs +18 -6
- package/renderer/transcript-view.mjs +19 -1
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.
|
|
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-
|
|
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.
|
|
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.
|
|
30
|
-
"@clowder-ai/plugin-sdk": "0.1.0-beta.
|
|
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.
|
|
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-
|
|
43
|
+
"integrity": "sha256-vGmfNZUU2zOlDNsQUqPjrvcwmO6p+Qdi3aPwlI8Wcyc="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
46
|
"width": 300,
|
package/renderer/client.mjs
CHANGED
|
@@ -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
|
-
&&
|
|
8
|
-
&&
|
|
9
|
-
&&
|
|
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,
|
|
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() {
|
|
20
|
-
|
|
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('连接超时
|
|
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
|
-
|
|
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
|
|
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
|
|
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('语音连接已中断
|
|
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() {
|
|
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
|
}
|
package/renderer/index.html
CHANGED
|
@@ -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"><
|
|
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' }); }
|
package/renderer/style.css
CHANGED
|
@@ -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; }
|
package/renderer/surface.mjs
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
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 || /^(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
});
|