@clowder-ai/companion 0.1.0-alpha.13 → 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/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.13",
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-vGmfNZUU2zOlDNsQUqPjrvcwmO6p+Qdi3aPwlI8Wcyc="
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.13",
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.21",
30
- "@clowder-ai/plugin-sdk": "0.1.0-beta.17"
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.13",
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-vGmfNZUU2zOlDNsQUqPjrvcwmO6p+Qdi3aPwlI8Wcyc="
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
+ }
@@ -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
  }
@@ -12,11 +12,14 @@ export class CompanionConversation {
12
12
  this.muted = false;
13
13
  this.silent = false;
14
14
  this.failed = false;
15
+ this.recovering = false;
16
+ this.retiredCallId = null;
15
17
  }
16
18
  show(message) {
17
19
  this.message = message;
18
20
  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 });
21
+ muted: this.muted, silent: this.silent, failed: this.failed, recovering: this.recovering,
22
+ callId: this.callId, message });
20
23
  }
21
24
  current(generation) { return this.active && generation === this.generation; }
22
25
  listening() {
@@ -26,7 +29,33 @@ export class CompanionConversation {
26
29
  retrying(prefix) {
27
30
  return this.audioMode === 'receive_only'
28
31
  ? `${prefix} · 麦克风未启用,点击只听重试`
29
- : `${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());
30
59
  }
31
60
  async begin(mode = 'duplex') {
32
61
  if (this.active || this.stopping || this.changingDocuments) return;
@@ -36,7 +65,10 @@ export class CompanionConversation {
36
65
  this.audioMode = mode;
37
66
  this.retryMode = null;
38
67
  this.failed = false;
68
+ this.recovering = false;
39
69
  this.phase = 'connecting';
70
+ this.callId = null;
71
+ this.expectedHostStop = undefined;
40
72
  const generation = ++this.generation;
41
73
  // Must reach the isolated preload while the click still has user activation.
42
74
  const preparation = this.client.prepare();
@@ -45,11 +77,27 @@ export class CompanionConversation {
45
77
  try {
46
78
  const peer = this.createPeer(event => {
47
79
  if (!this.current(generation)) return;
48
- if (event.type === 'connected') { clearTimeout(this.deadline); this.phase = 'talking'; this.show(this.listening()); }
49
- if (event.type === 'recovering') this.show('连接暂时中断 · 正在等待恢复');
50
- if (event.type === 'recovered') this.show(this.listening());
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
+ }
51
100
  if (event.type === 'transcript' || event.type === 'turn-done') this.transcript(event);
52
- if (event.type === 'error') void this.end(explainError(event), true);
53
101
  });
54
102
  this.peer = peer;
55
103
  if (mode === 'duplex') peer.muteMic(this.muted);
@@ -68,6 +116,8 @@ export class CompanionConversation {
68
116
  }
69
117
  async releaseLocal(message) {
70
118
  this.active = false;
119
+ this.expectedHostStop = undefined;
120
+ if (this.callId) this.retiredCallId = this.callId;
71
121
  ++this.generation;
72
122
  this.pendingText = undefined;
73
123
  clearTimeout(this.deadline);
@@ -77,13 +127,15 @@ export class CompanionConversation {
77
127
  this.audioMode = null;
78
128
  this.retryMode = this.failed ? releasedMode : null;
79
129
  this.phase = 'idle';
130
+ this.recovering = false;
131
+ this.callId = null;
80
132
  this.show(message);
81
133
  await Promise.allSettled([peer?.close(), this.stopScreen()]);
82
134
  }
83
135
  async end(message, failed = false) {
84
136
  if (this.stopping) return this.stopping;
85
137
  const receiveOnly = this.audioMode === 'receive_only';
86
- message ??= this.audioMode === 'receive_only' ? '只听已结束 · 麦克风未启用' : '语音已结束 · 麦克风已关闭';
138
+ message ??= this.stoppedMessage();
87
139
  this.failed = failed;
88
140
  const local = this.releaseLocal(message);
89
141
  const operation = Promise.all([local, this.client.stop()]);
@@ -95,20 +147,26 @@ export class CompanionConversation {
95
147
  async hostStopped(reason) {
96
148
  // Stop echoes must not erase a more useful local failure or manual ending.
97
149
  if (!this.active) return;
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
+ }
98
156
  const receiveOnly = this.audioMode === 'receive_only';
99
157
  const messages = receiveOnly ? {
100
158
  locked: '屏幕已锁定 · 只听已停止,解锁后可点击只听继续',
101
159
  suspended: '电脑已休眠 · 只听已停止,可点击只听继续',
102
160
  hidden: '猫猫已收起 · 只听已停止',
103
161
  } : {
104
- locked: '屏幕已锁定 · 语音已停止,解锁后可点击语音聊继续',
105
- suspended: '电脑已休眠 · 语音已停止,可点击语音聊继续',
106
- hidden: '猫猫已收起 · 语音已停止',
162
+ locked: '屏幕已锁定 · 语音通话已停止,解锁后可点击语音通话继续',
163
+ suspended: '电脑已休眠 · 语音通话已停止,可点击语音通话继续',
164
+ hidden: '猫猫已收起 · 语音通话已停止',
107
165
  };
108
166
  this.failed = !Object.hasOwn(messages, reason);
109
167
  await this.releaseLocal(messages[reason] ?? (receiveOnly
110
168
  ? '只听连接已中断 · 麦克风未启用,点击只听重试'
111
- : '语音连接已中断 · 麦克风已关闭,点击语音聊重试'));
169
+ : '语音通话已中断 · 麦克风已关闭,点击语音通话重试'));
112
170
  }
113
171
  async refresh() {
114
172
  if (this.refreshing) return;
@@ -120,8 +178,16 @@ export class CompanionConversation {
120
178
  this.identity = identity;
121
179
  if (this.active && identity.audio?.activeMode) this.audioMode = identity.audio.activeMode;
122
180
  if (this.active && ['closed', 'failed', 'idle'].includes(identity.phase)) {
123
- await this.end(this.retrying('语音连接已中断'), true);
124
- } else this.show(this.message ?? '点开始聊天,直接对我说话');
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
+ }
125
191
  } catch (error) {
126
192
  if (generation === this.generation) {
127
193
  if (this.active) await this.end(explainError(error), true);
@@ -139,7 +205,7 @@ export class CompanionConversation {
139
205
  const identity = await changing;
140
206
  await local;
141
207
  this.identity = identity;
142
- this.show(allowed ? '资料查询已开启 · 点击开始聊天继续' : '资料查询已暂停 · 点击开始聊天继续');
208
+ this.show(allowed ? '资料查询已开启 · 点击语音通话继续' : '资料查询已暂停 · 点击语音通话继续');
143
209
  } catch (error) { this.show(explainError(error)); }
144
210
  finally { this.changingDocuments = false; }
145
211
  }
@@ -151,12 +217,16 @@ export class CompanionConversation {
151
217
  const generation = this.generation;
152
218
  this.sending = true;
153
219
  try {
154
- 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
+ }
155
225
  if (this.pendingText === input) this.pendingText = undefined;
156
226
  // Voice may have ended; an accepted message still retires this draft.
157
227
  // Keep the newer voice status rather than announcing that we are listening.
158
228
  if (generation !== this.generation) return true;
159
- this.transcript({ type: 'transcript', role: 'user', text, typed: true });
229
+ this.transcript({ type: 'typed', role: 'user', text, receipt });
160
230
  this.show(this.active ? this.listening() : '已发送 · 回答会留在同一段聊天中');
161
231
  return true;
162
232
  } catch (error) {
@@ -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 total = page.approvalCount + page.otherNeedsMeCount;
6
- return {
7
- visible: total > 0,
8
- label: total > 99 ? '99+' : String(total),
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: `${item.sourceFeatureId} · ${state[item.resolution] ?? '状态待核'} · ${item.materializationState === 'outcome_unknown' ? '结果未知' : item.materializationState === 'failed' ? '执行失败' : item.linkedNeedsMe ? '已关联任务' : '原处卡片'}`,
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) &&
@@ -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: '发送尚未确认 · 文字已保留,可检查聊天记录后重试',