@clowder-ai/companion 0.1.0-alpha.12 → 0.1.0-alpha.14
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 +5 -3
- package/plugin.yaml +2 -2
- package/renderer/call-transcript-view.mjs +238 -0
- package/renderer/client.mjs +6 -1
- package/renderer/companion-identity.mjs +4 -3
- package/renderer/conversation.mjs +127 -23
- package/renderer/decision-view.mjs +6 -8
- package/renderer/errors.mjs +4 -4
- package/renderer/index.html +105 -22
- package/renderer/native-work-motion.mjs +15 -0
- package/renderer/peer.mjs +1 -1
- package/renderer/pet-controls.mjs +71 -25
- package/renderer/pet-motion.mjs +17 -23
- package/renderer/recent-bubble.mjs +16 -3
- package/renderer/settings-controller.mjs +171 -0
- package/renderer/settings-view.mjs +234 -0
- package/renderer/style.css +136 -20
- package/renderer/surface.mjs +161 -54
- 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.14",
|
|
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-UH8VFfr3q9a5gI2pPPXinKzCTjaGI8ZdvrJngy6i+M0="
|
|
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.14",
|
|
4
4
|
"description": "The desktop voice body for your Clowder companion",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,13 +21,15 @@
|
|
|
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
25
|
"typecheck": "node scripts/check.mjs",
|
|
25
26
|
"lint": "node scripts/check.mjs",
|
|
26
27
|
"prepack": "pnpm build"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
|
-
"@clowder-ai/plugin-contract": "0.1.0-beta.
|
|
30
|
-
"@clowder-ai/plugin-sdk": "0.1.0-beta.
|
|
30
|
+
"@clowder-ai/plugin-contract": "0.1.0-beta.23",
|
|
31
|
+
"@clowder-ai/plugin-sdk": "0.1.0-beta.19",
|
|
32
|
+
"playwright": "1.61.1"
|
|
31
33
|
},
|
|
32
34
|
"repository": {
|
|
33
35
|
"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.14",
|
|
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-UH8VFfr3q9a5gI2pPPXinKzCTjaGI8ZdvrJngy6i+M0="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
46
|
"width": 300,
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
const MAX_LIVE_ROWS = 8;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Projection of the Host-bound transcript for one trusted call.
|
|
5
|
+
*
|
|
6
|
+
* Durable rows are keyed only by Host messageId. Ephemeral audio is keyed by
|
|
7
|
+
* provider item/turn identity and is discarded when the Host projects the
|
|
8
|
+
* matching durable source. Text equality is deliberately never used.
|
|
9
|
+
*/
|
|
10
|
+
export class CallTranscriptView {
|
|
11
|
+
constructor(container, { onUnread = () => {} } = {}) {
|
|
12
|
+
this.container = container;
|
|
13
|
+
this.onUnread = onUnread;
|
|
14
|
+
this.durableRows = new Map();
|
|
15
|
+
this.liveRows = new Map();
|
|
16
|
+
this.liveSequence = 0;
|
|
17
|
+
this.optimisticIds = new Set();
|
|
18
|
+
this.unread = 0;
|
|
19
|
+
this.initialized = false;
|
|
20
|
+
container.addEventListener?.('scroll', () => {
|
|
21
|
+
if (this.nearBottom()) this.setUnread(0);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setCall(callId) {
|
|
26
|
+
if (this.callId === callId) return;
|
|
27
|
+
this.callId = callId ?? null;
|
|
28
|
+
this.scope = undefined;
|
|
29
|
+
this.durableRows.clear();
|
|
30
|
+
this.liveRows.clear();
|
|
31
|
+
this.liveSequence = 0;
|
|
32
|
+
this.optimisticIds.clear();
|
|
33
|
+
this.initialized = false;
|
|
34
|
+
this.container.replaceChildren();
|
|
35
|
+
this.container.dataset.callId = this.callId ?? '';
|
|
36
|
+
this.container.dataset.hasMore = 'false';
|
|
37
|
+
this.setUnread(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
reset() { this.setCall(null); }
|
|
41
|
+
|
|
42
|
+
nearBottom() {
|
|
43
|
+
return this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
scrollToBottom() {
|
|
47
|
+
this.container.scrollTop = this.container.scrollHeight;
|
|
48
|
+
this.setUnread(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setUnread(count) {
|
|
52
|
+
if (this.unread === count) return;
|
|
53
|
+
this.unread = count;
|
|
54
|
+
this.onUnread(count);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
visibleAnchor() {
|
|
58
|
+
const viewport = this.container.getBoundingClientRect?.();
|
|
59
|
+
if (!viewport) return null;
|
|
60
|
+
for (const [id, row] of this.durableRows) {
|
|
61
|
+
const rect = row.getBoundingClientRect?.();
|
|
62
|
+
if (rect && rect.bottom > viewport.top && rect.top < viewport.bottom) {
|
|
63
|
+
return { id, offset: rect.top - viewport.top };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
createRow() {
|
|
70
|
+
return this.container.ownerDocument.createElement('p');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
updateDurable(row, value) {
|
|
74
|
+
row.className = `call-transcript-row ${value.source.kind} ${value.role}`;
|
|
75
|
+
row.textContent = value.text;
|
|
76
|
+
row.dataset.messageId = value.messageId;
|
|
77
|
+
row.dataset.source = value.source.kind;
|
|
78
|
+
row.dataset.role = value.role;
|
|
79
|
+
delete row.dataset.live;
|
|
80
|
+
delete row.dataset.itemId;
|
|
81
|
+
delete row.dataset.turnId;
|
|
82
|
+
delete row.dataset.optimistic;
|
|
83
|
+
if (value.source.kind === 'voice') {
|
|
84
|
+
row.dataset.itemId = value.source.nativeItemId;
|
|
85
|
+
if (value.source.nativeTurnId) row.dataset.turnId = value.source.nativeTurnId;
|
|
86
|
+
delete row.dataset.clientMessageId;
|
|
87
|
+
} else {
|
|
88
|
+
row.dataset.clientMessageId = value.source.clientMessageId;
|
|
89
|
+
}
|
|
90
|
+
row.setAttribute?.('aria-label', value.source.kind === 'typed'
|
|
91
|
+
? `你打字说:${value.text}`
|
|
92
|
+
: value.role === 'assistant' ? `猫猫:${value.text}` : `语音转写:${value.text}`);
|
|
93
|
+
return row;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
removeReconciledLive(source) {
|
|
97
|
+
if (source.kind !== 'voice') return false;
|
|
98
|
+
let removed = false;
|
|
99
|
+
for (const [key, row] of this.liveRows) {
|
|
100
|
+
if ((row.dataset.itemId && row.dataset.itemId === source.nativeItemId)
|
|
101
|
+
|| (source.nativeTurnId && row.dataset.turnId === source.nativeTurnId)) {
|
|
102
|
+
this.liveRows.delete(key);
|
|
103
|
+
removed = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return removed;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
render() {
|
|
110
|
+
this.container.replaceChildren(...this.durableRows.values(), ...this.liveRows.values());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
load(reply) {
|
|
114
|
+
if (!this.callId || reply?.kind !== 'transcript' || reply.scope?.callId !== this.callId) return false;
|
|
115
|
+
const pinned = this.nearBottom();
|
|
116
|
+
const readingPosition = this.container.scrollTop;
|
|
117
|
+
const anchor = pinned ? null : this.visibleAnchor();
|
|
118
|
+
const previous = this.durableRows;
|
|
119
|
+
const previousIds = new Set(previous.keys());
|
|
120
|
+
const next = new Map();
|
|
121
|
+
let added = 0;
|
|
122
|
+
|
|
123
|
+
for (const value of reply.rows) {
|
|
124
|
+
const reconciledLive = this.removeReconciledLive(value.source);
|
|
125
|
+
const row = previous.get(value.messageId) ?? this.createRow();
|
|
126
|
+
this.updateDurable(row, value);
|
|
127
|
+
this.optimisticIds.delete(value.messageId);
|
|
128
|
+
next.set(value.messageId, row);
|
|
129
|
+
if (this.initialized && !previousIds.has(value.messageId) && !reconciledLive) added += 1;
|
|
130
|
+
}
|
|
131
|
+
// An accepted delivery already has a durable Host messageId. A transcript
|
|
132
|
+
// read can race the MessageStore projection, so keep that exact row until
|
|
133
|
+
// the same id appears; never fall back to matching its text.
|
|
134
|
+
for (const id of this.optimisticIds) {
|
|
135
|
+
const row = previous.get(id);
|
|
136
|
+
if (row && !next.has(id)) next.set(id, row);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.scope = reply.scope;
|
|
140
|
+
this.durableRows = next;
|
|
141
|
+
this.container.dataset.callId = reply.scope.callId;
|
|
142
|
+
this.container.dataset.hasMore = String(reply.hasMore);
|
|
143
|
+
this.render();
|
|
144
|
+
|
|
145
|
+
if (pinned) {
|
|
146
|
+
this.container.scrollTop = this.container.scrollHeight;
|
|
147
|
+
this.setUnread(0);
|
|
148
|
+
} else if (anchor) {
|
|
149
|
+
const row = this.durableRows.get(anchor.id) ?? this.durableRows.values().next().value;
|
|
150
|
+
const rect = row?.getBoundingClientRect?.();
|
|
151
|
+
const viewport = this.container.getBoundingClientRect?.();
|
|
152
|
+
this.container.scrollTop = rect && viewport
|
|
153
|
+
? this.container.scrollTop + rect.top - viewport.top - anchor.offset
|
|
154
|
+
: readingPosition;
|
|
155
|
+
if (added) this.setUnread(this.unread + added);
|
|
156
|
+
} else {
|
|
157
|
+
this.container.scrollTop = readingPosition;
|
|
158
|
+
if (added) this.setUnread(this.unread + added);
|
|
159
|
+
}
|
|
160
|
+
this.initialized = true;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
liveKey(event) {
|
|
165
|
+
if (event.itemId) return `item:${event.itemId}`;
|
|
166
|
+
if (event.turnId) return `turn:${event.turnId}`;
|
|
167
|
+
return `ephemeral:${++this.liveSequence}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
appendLive(event) {
|
|
171
|
+
if (!this.callId || event?.type !== 'transcript' || event.callId !== this.callId) return false;
|
|
172
|
+
const pinned = this.nearBottom();
|
|
173
|
+
const key = this.liveKey(event);
|
|
174
|
+
let row = this.liveRows.get(key);
|
|
175
|
+
const isNew = !row;
|
|
176
|
+
if (!row) {
|
|
177
|
+
row = this.createRow();
|
|
178
|
+
row.className = `call-transcript-row voice ${event.role}`;
|
|
179
|
+
row.dataset.live = 'true';
|
|
180
|
+
row.dataset.source = 'voice';
|
|
181
|
+
row.dataset.role = event.role;
|
|
182
|
+
if (event.itemId) row.dataset.itemId = event.itemId;
|
|
183
|
+
if (event.turnId) row.dataset.turnId = event.turnId;
|
|
184
|
+
this.liveRows.set(key, row);
|
|
185
|
+
while (this.liveRows.size > MAX_LIVE_ROWS) this.liveRows.delete(this.liveRows.keys().next().value);
|
|
186
|
+
}
|
|
187
|
+
row.textContent = `${row.textContent}${event.text}`.slice(-16000);
|
|
188
|
+
row.setAttribute?.('aria-label', `${event.role === 'assistant' ? '猫猫' : '语音转写'},实时:${row.textContent}`);
|
|
189
|
+
this.render();
|
|
190
|
+
if (pinned) {
|
|
191
|
+
this.container.scrollTop = this.container.scrollHeight;
|
|
192
|
+
this.setUnread(0);
|
|
193
|
+
} else if (isNew) this.setUnread(this.unread + 1);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
appendTyped({ receipt, text }) {
|
|
198
|
+
if (!this.callId || receipt?.delivery !== 'accepted' || receipt.callId !== this.callId) return false;
|
|
199
|
+
const pinned = this.nearBottom();
|
|
200
|
+
let row = this.durableRows.get(receipt.messageId);
|
|
201
|
+
const isNew = !row;
|
|
202
|
+
if (!row) {
|
|
203
|
+
row = this.createRow();
|
|
204
|
+
this.durableRows.set(receipt.messageId, row);
|
|
205
|
+
}
|
|
206
|
+
this.updateDurable(row, {
|
|
207
|
+
messageId: receipt.messageId,
|
|
208
|
+
role: 'user',
|
|
209
|
+
text,
|
|
210
|
+
source: { kind: 'typed', clientMessageId: receipt.clientMessageId, callId: receipt.callId },
|
|
211
|
+
});
|
|
212
|
+
row.dataset.optimistic = 'true';
|
|
213
|
+
this.optimisticIds.add(receipt.messageId);
|
|
214
|
+
while (this.optimisticIds.size > MAX_LIVE_ROWS) {
|
|
215
|
+
const oldest = this.optimisticIds.values().next().value;
|
|
216
|
+
this.optimisticIds.delete(oldest);
|
|
217
|
+
if (this.durableRows.get(oldest)?.dataset.optimistic === 'true') this.durableRows.delete(oldest);
|
|
218
|
+
}
|
|
219
|
+
this.render();
|
|
220
|
+
if (pinned) {
|
|
221
|
+
this.container.scrollTop = this.container.scrollHeight;
|
|
222
|
+
this.setUnread(0);
|
|
223
|
+
} else if (isNew) this.setUnread(this.unread + 1);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
finish(event) {
|
|
228
|
+
if (!this.callId || event?.type !== 'turn-done' || event.callId !== this.callId) return false;
|
|
229
|
+
for (const row of this.liveRows.values()) {
|
|
230
|
+
const matchesTurn = event.turnId && row.dataset.turnId === event.turnId;
|
|
231
|
+
const matchesUnkeyedRole = !event.turnId && event.role && !row.dataset.itemId
|
|
232
|
+
&& !row.dataset.turnId && row.dataset.role === event.role;
|
|
233
|
+
if (matchesTurn || matchesUnkeyedRole) row.dataset.complete = 'true';
|
|
234
|
+
}
|
|
235
|
+
this.render();
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
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'),
|
|
@@ -42,7 +42,12 @@ export function createCompanionClient(bridge) {
|
|
|
42
42
|
layout: (panel, width, height) => invoke({ kind: 'view.layout', panel, width, height }, 'layout'),
|
|
43
43
|
drag: (phase) => invoke({ kind: 'view.drag', phase }, 'ok'),
|
|
44
44
|
hide: () => invoke({ kind: 'view.hide' }, 'ok'),
|
|
45
|
+
resetPosition: () => invoke({ kind: 'view.reset' }, 'ok'),
|
|
46
|
+
readSettings: () => invoke({ kind: 'settings.read' }, 'settings'),
|
|
47
|
+
updateSetting: (field, value) => invoke({ kind: 'settings.update', field, value }, 'settings-update'),
|
|
48
|
+
disableCompanion: () => invoke({ kind: 'companion.disable' }, 'companion-lifecycle'),
|
|
45
49
|
readConversation: () => invoke({ kind: 'conversation.read' }, 'conversation'),
|
|
50
|
+
readTranscript: () => invoke({ kind: 'transcript.read' }, 'transcript'),
|
|
46
51
|
subscribe: (listener) => bridge.subscribe(listener),
|
|
47
52
|
};
|
|
48
53
|
}
|
|
@@ -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,42 +7,107 @@ 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;
|
|
15
|
+
this.recovering = false;
|
|
16
|
+
this.retiredCallId = null;
|
|
13
17
|
}
|
|
14
18
|
show(message) {
|
|
15
19
|
this.message = message;
|
|
16
|
-
this.render({ phase: this.phase, identity: this.identity,
|
|
20
|
+
this.render({ phase: this.phase, identity: this.identity, audioMode: this.audioMode, retryMode: this.retryMode,
|
|
21
|
+
muted: this.muted, silent: this.silent, failed: this.failed, recovering: this.recovering,
|
|
22
|
+
callId: this.callId, message });
|
|
17
23
|
}
|
|
18
24
|
current(generation) { return this.active && generation === this.generation; }
|
|
19
|
-
listening() {
|
|
20
|
-
|
|
25
|
+
listening() {
|
|
26
|
+
if (this.audioMode === 'receive_only') return '只听模式 · 麦克风未启用';
|
|
27
|
+
return this.muted ? '麦克风已静音' : '正在听,直接说话就好';
|
|
28
|
+
}
|
|
29
|
+
retrying(prefix) {
|
|
30
|
+
return this.audioMode === 'receive_only'
|
|
31
|
+
? `${prefix} · 麦克风未启用,点击只听重试`
|
|
32
|
+
: `${prefix} · 点击语音通话重试`;
|
|
33
|
+
}
|
|
34
|
+
stoppedMessage() {
|
|
35
|
+
return this.audioMode === 'receive_only'
|
|
36
|
+
? '只听已结束 · 麦克风未启用'
|
|
37
|
+
: '语音通话已结束 · 麦克风已关闭';
|
|
38
|
+
}
|
|
39
|
+
expectHostStop() {
|
|
40
|
+
if (!this.active) return false;
|
|
41
|
+
this.expectedHostStop = { generation: this.generation, pending: true };
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
async settleExpectedHostStop(callStopped) {
|
|
45
|
+
const expected = this.expectedHostStop;
|
|
46
|
+
if (!expected || expected.generation !== this.generation) return;
|
|
47
|
+
if (callStopped === false) {
|
|
48
|
+
this.expectedHostStop = undefined;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (callStopped === null) {
|
|
52
|
+
expected.pending = false;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.expectedHostStop = undefined;
|
|
56
|
+
if (!this.active) return;
|
|
57
|
+
this.failed = false;
|
|
58
|
+
await this.releaseLocal(this.stoppedMessage());
|
|
59
|
+
}
|
|
60
|
+
async begin(mode = 'duplex') {
|
|
21
61
|
if (this.active || this.stopping || this.changingDocuments) return;
|
|
62
|
+
if (mode === 'receive_only' && !this.identity?.audio?.supportedModes?.includes(mode)) return;
|
|
63
|
+
if (!['duplex', 'receive_only'].includes(mode)) return;
|
|
22
64
|
this.active = true;
|
|
65
|
+
this.audioMode = mode;
|
|
66
|
+
this.retryMode = null;
|
|
23
67
|
this.failed = false;
|
|
68
|
+
this.recovering = false;
|
|
24
69
|
this.phase = 'connecting';
|
|
70
|
+
this.callId = null;
|
|
71
|
+
this.expectedHostStop = undefined;
|
|
25
72
|
const generation = ++this.generation;
|
|
26
73
|
// Must reach the isolated preload while the click still has user activation.
|
|
27
74
|
const preparation = this.client.prepare();
|
|
28
75
|
this.show('正在连接…');
|
|
29
|
-
this.deadline = setTimeout(() => { if (this.current(generation)) void this.end('连接超时
|
|
76
|
+
this.deadline = setTimeout(() => { if (this.current(generation)) void this.end(this.retrying('连接超时'), true); }, 60_000);
|
|
30
77
|
try {
|
|
31
78
|
const peer = this.createPeer(event => {
|
|
32
79
|
if (!this.current(generation)) return;
|
|
33
|
-
if (event.type === '
|
|
34
|
-
if (event.type === '
|
|
35
|
-
|
|
80
|
+
if (event.type === 'error' && !this.callId && event.callId === this.retiredCallId) return;
|
|
81
|
+
if (event.type === 'connected') {
|
|
82
|
+
if (this.callId && this.callId !== event.callId) return;
|
|
83
|
+
this.callId = event.callId;
|
|
84
|
+
this.recovering = false;
|
|
85
|
+
clearTimeout(this.deadline); this.phase = 'talking'; this.show(this.listening());
|
|
86
|
+
}
|
|
87
|
+
if (event.type === 'error' && (!event.callId || !this.callId || event.callId === this.callId)) {
|
|
88
|
+
void this.end(explainError(event), true);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!this.callId || event.callId !== this.callId) return;
|
|
92
|
+
if (event.type === 'recovering') {
|
|
93
|
+
this.recovering = true;
|
|
94
|
+
this.show('恢复后通话继续,无需重新连接');
|
|
95
|
+
}
|
|
96
|
+
if (event.type === 'recovered') {
|
|
97
|
+
this.recovering = false;
|
|
98
|
+
this.show(this.listening());
|
|
99
|
+
}
|
|
36
100
|
if (event.type === 'transcript' || event.type === 'turn-done') this.transcript(event);
|
|
37
|
-
if (event.type === 'error') void this.end(explainError(event), true);
|
|
38
101
|
});
|
|
39
102
|
this.peer = peer;
|
|
40
|
-
|
|
103
|
+
if (mode === 'duplex') peer.muteMic(this.muted);
|
|
104
|
+
peer.muteSpeaker(this.silent);
|
|
41
105
|
// Submit capture intent while this same click is active. The Host waits
|
|
42
106
|
// for its matching preparation before creating the media document.
|
|
43
|
-
const [identity] = await Promise.all([preparation, peer.connect()]);
|
|
107
|
+
const [identity] = await Promise.all([preparation, peer.connect(mode === 'receive_only' ? mode : undefined)]);
|
|
44
108
|
if (!this.current(generation)) { await peer.close(); return; }
|
|
45
109
|
if (identity.phase !== 'ready') throw { code: 'unavailable' };
|
|
110
|
+
if (mode === 'receive_only' && !identity.audio?.supportedModes?.includes(mode)) throw { code: 'unavailable' };
|
|
46
111
|
this.identity = identity;
|
|
47
112
|
this.show(this.message);
|
|
48
113
|
} catch (error) {
|
|
@@ -51,35 +116,57 @@ export class CompanionConversation {
|
|
|
51
116
|
}
|
|
52
117
|
async releaseLocal(message) {
|
|
53
118
|
this.active = false;
|
|
119
|
+
this.expectedHostStop = undefined;
|
|
120
|
+
if (this.callId) this.retiredCallId = this.callId;
|
|
54
121
|
++this.generation;
|
|
55
122
|
this.pendingText = undefined;
|
|
56
123
|
clearTimeout(this.deadline);
|
|
57
124
|
const peer = this.peer;
|
|
58
125
|
this.peer = undefined;
|
|
126
|
+
const releasedMode = this.audioMode;
|
|
127
|
+
this.audioMode = null;
|
|
128
|
+
this.retryMode = this.failed ? releasedMode : null;
|
|
59
129
|
this.phase = 'idle';
|
|
130
|
+
this.recovering = false;
|
|
131
|
+
this.callId = null;
|
|
60
132
|
this.show(message);
|
|
61
133
|
await Promise.allSettled([peer?.close(), this.stopScreen()]);
|
|
62
134
|
}
|
|
63
|
-
async end(message
|
|
135
|
+
async end(message, failed = false) {
|
|
64
136
|
if (this.stopping) return this.stopping;
|
|
137
|
+
const receiveOnly = this.audioMode === 'receive_only';
|
|
138
|
+
message ??= this.stoppedMessage();
|
|
65
139
|
this.failed = failed;
|
|
66
140
|
const local = this.releaseLocal(message);
|
|
67
141
|
const operation = Promise.all([local, this.client.stop()]);
|
|
68
142
|
this.stopping = operation;
|
|
69
143
|
try { await operation; }
|
|
70
|
-
catch { this.show('麦克风已关闭 · 连接收尾尚未确认'); }
|
|
144
|
+
catch { this.show(receiveOnly ? '麦克风未启用 · 连接收尾尚未确认' : '麦克风已关闭 · 连接收尾尚未确认'); }
|
|
71
145
|
finally { if (this.stopping === operation) this.stopping = undefined; }
|
|
72
146
|
}
|
|
73
147
|
async hostStopped(reason) {
|
|
74
148
|
// Stop echoes must not erase a more useful local failure or manual ending.
|
|
75
149
|
if (!this.active) return;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
150
|
+
if (reason === 'revoked' && this.expectedHostStop?.generation === this.generation) {
|
|
151
|
+
this.expectedHostStop = undefined;
|
|
152
|
+
this.failed = false;
|
|
153
|
+
await this.releaseLocal(this.stoppedMessage());
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const receiveOnly = this.audioMode === 'receive_only';
|
|
157
|
+
const messages = receiveOnly ? {
|
|
158
|
+
locked: '屏幕已锁定 · 只听已停止,解锁后可点击只听继续',
|
|
159
|
+
suspended: '电脑已休眠 · 只听已停止,可点击只听继续',
|
|
160
|
+
hidden: '猫猫已收起 · 只听已停止',
|
|
161
|
+
} : {
|
|
162
|
+
locked: '屏幕已锁定 · 语音通话已停止,解锁后可点击语音通话继续',
|
|
163
|
+
suspended: '电脑已休眠 · 语音通话已停止,可点击语音通话继续',
|
|
164
|
+
hidden: '猫猫已收起 · 语音通话已停止',
|
|
80
165
|
};
|
|
81
166
|
this.failed = !Object.hasOwn(messages, reason);
|
|
82
|
-
await this.releaseLocal(messages[reason] ??
|
|
167
|
+
await this.releaseLocal(messages[reason] ?? (receiveOnly
|
|
168
|
+
? '只听连接已中断 · 麦克风未启用,点击只听重试'
|
|
169
|
+
: '语音通话已中断 · 麦克风已关闭,点击语音通话重试'));
|
|
83
170
|
}
|
|
84
171
|
async refresh() {
|
|
85
172
|
if (this.refreshing) return;
|
|
@@ -89,9 +176,18 @@ export class CompanionConversation {
|
|
|
89
176
|
const identity = await this.client.state();
|
|
90
177
|
if (generation !== this.generation) return;
|
|
91
178
|
this.identity = identity;
|
|
179
|
+
if (this.active && identity.audio?.activeMode) this.audioMode = identity.audio.activeMode;
|
|
92
180
|
if (this.active && ['closed', 'failed', 'idle'].includes(identity.phase)) {
|
|
93
|
-
|
|
94
|
-
|
|
181
|
+
if (this.expectedHostStop?.generation === this.generation) {
|
|
182
|
+
this.expectedHostStop = undefined;
|
|
183
|
+
this.failed = false;
|
|
184
|
+
await this.releaseLocal(this.stoppedMessage());
|
|
185
|
+
} else await this.end(this.retrying('语音连接已中断'), true);
|
|
186
|
+
} else {
|
|
187
|
+
if (this.expectedHostStop?.generation === this.generation && !this.expectedHostStop.pending)
|
|
188
|
+
this.expectedHostStop = undefined;
|
|
189
|
+
this.show(this.message ?? '点击语音通话,直接对我说话');
|
|
190
|
+
}
|
|
95
191
|
} catch (error) {
|
|
96
192
|
if (generation === this.generation) {
|
|
97
193
|
if (this.active) await this.end(explainError(error), true);
|
|
@@ -109,7 +205,7 @@ export class CompanionConversation {
|
|
|
109
205
|
const identity = await changing;
|
|
110
206
|
await local;
|
|
111
207
|
this.identity = identity;
|
|
112
|
-
this.show(allowed ? '资料查询已开启 ·
|
|
208
|
+
this.show(allowed ? '资料查询已开启 · 点击语音通话继续' : '资料查询已暂停 · 点击语音通话继续');
|
|
113
209
|
} catch (error) { this.show(explainError(error)); }
|
|
114
210
|
finally { this.changingDocuments = false; }
|
|
115
211
|
}
|
|
@@ -121,12 +217,16 @@ export class CompanionConversation {
|
|
|
121
217
|
const generation = this.generation;
|
|
122
218
|
this.sending = true;
|
|
123
219
|
try {
|
|
124
|
-
await this.client.text(input.text, input.id);
|
|
220
|
+
const receipt = await this.client.text(input.text, input.id);
|
|
221
|
+
if (receipt?.delivery !== 'accepted') {
|
|
222
|
+
if (generation === this.generation) this.show(explainError({ code: 'unconfirmed' }));
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
125
225
|
if (this.pendingText === input) this.pendingText = undefined;
|
|
126
226
|
// Voice may have ended; an accepted message still retires this draft.
|
|
127
227
|
// Keep the newer voice status rather than announcing that we are listening.
|
|
128
228
|
if (generation !== this.generation) return true;
|
|
129
|
-
this.transcript({ type: '
|
|
229
|
+
this.transcript({ type: 'typed', role: 'user', text, receipt });
|
|
130
230
|
this.show(this.active ? this.listening() : '已发送 · 回答会留在同一段聊天中');
|
|
131
231
|
return true;
|
|
132
232
|
} catch (error) {
|
|
@@ -134,6 +234,10 @@ export class CompanionConversation {
|
|
|
134
234
|
return false;
|
|
135
235
|
} finally { this.sending = false; }
|
|
136
236
|
}
|
|
137
|
-
muteMic() {
|
|
237
|
+
muteMic() {
|
|
238
|
+
if (this.audioMode === 'receive_only') return;
|
|
239
|
+
this.muted = !this.muted; this.peer?.muteMic(this.muted);
|
|
240
|
+
this.show(this.phase === 'talking' ? this.listening() : this.message);
|
|
241
|
+
}
|
|
138
242
|
muteSpeaker() { this.silent = !this.silent; this.peer?.muteSpeaker(this.silent); this.show(this.message); }
|
|
139
243
|
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/** F246 remains the decision truth; linked F310 receipts are enrichment, not another item. */
|
|
2
2
|
export function decisionBadge(page) {
|
|
3
3
|
if (!page || page.status !== 'available')
|
|
4
|
-
return { visible: true, label: '?', title: '
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
title: `${page.approvalCount} 项审批事项,${page.otherNeedsMeCount} 项其他待处理`,
|
|
10
|
-
};
|
|
4
|
+
return { visible: true, label: '?', title: '待办暂不可读', state: 'unavailable' };
|
|
5
|
+
const hasKnownRows = (page.approvals?.length ?? 0) > 0 || (page.otherNeedsMe?.length ?? 0) > 0
|
|
6
|
+
|| page.approvalCount > 0 || page.needsMeCount > 0 || page.otherNeedsMeCount > 0;
|
|
7
|
+
if (hasKnownRows) return { visible: true, label: '有待办', title: '有待办', state: 'available' };
|
|
8
|
+
return { visible: false, label: '0', title: '现在没有待办', state: 'empty' };
|
|
11
9
|
}
|
|
12
10
|
|
|
13
11
|
export function decisionRows(page) {
|
|
@@ -19,7 +17,7 @@ export function decisionRows(page) {
|
|
|
19
17
|
return [
|
|
20
18
|
...page.approvals.map(item => ({
|
|
21
19
|
title: item.summary || '待决策事项',
|
|
22
|
-
meta:
|
|
20
|
+
meta: `审批 · ${state[item.resolution] ?? '状态待核'} · ${item.materializationState === 'outcome_unknown' ? '结果未知' : item.materializationState === 'failed' ? '执行失败' : item.linkedNeedsMe ? '已关联待办' : '原处卡片'}`,
|
|
23
21
|
proposalId: item.proposalId,
|
|
24
22
|
previewable: item.sourceFeatureId === 'F221' &&
|
|
25
23
|
/^[A-Za-z0-9_-]{1,200}$/.test(item.proposalId) &&
|
package/renderer/errors.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const messages = {
|
|
2
|
-
permission_required: '
|
|
3
|
-
session_required: '连接已结束 ·
|
|
4
|
-
busy: '
|
|
5
|
-
selection_changed: '猫猫球的选择已更新 ·
|
|
2
|
+
permission_required: '请点击语音通话,或在系统提示中允许本次使用',
|
|
3
|
+
session_required: '连接已结束 · 点击语音通话继续',
|
|
4
|
+
busy: '会话暂时被占用,请稍后重试',
|
|
5
|
+
selection_changed: '猫猫球的选择已更新 · 点击语音通话继续',
|
|
6
6
|
carrier_unavailable: '实时语音暂不可用 · 可以打开聊天继续交流',
|
|
7
7
|
cancelled: '操作已取消',
|
|
8
8
|
unconfirmed: '发送尚未确认 · 文字已保留,可检查聊天记录后重试',
|