@clowder-ai/companion 0.1.0-alpha.3 → 0.1.0-alpha.31

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.
Files changed (70) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +54 -5
  3. package/manifest.json +3 -3
  4. package/package.json +5 -3
  5. package/plugin.yaml +3 -3
  6. package/renderer/app-assist-controller.mjs +125 -0
  7. package/renderer/call-controls-presentation.mjs +179 -0
  8. package/renderer/call-duration.mjs +59 -0
  9. package/renderer/call-transcript-view.mjs +240 -0
  10. package/renderer/chat-unread.mjs +44 -0
  11. package/renderer/client.mjs +75 -3
  12. package/renderer/compact-size.mjs +8 -0
  13. package/renderer/companion-identity.mjs +42 -0
  14. package/renderer/conversation-draft.mjs +304 -0
  15. package/renderer/conversation-history.mjs +176 -0
  16. package/renderer/conversation-surface.mjs +201 -0
  17. package/renderer/conversation.mjs +155 -21
  18. package/renderer/decision-panel.mjs +122 -0
  19. package/renderer/decision-view.mjs +124 -0
  20. package/renderer/errors.mjs +4 -4
  21. package/renderer/index.html +202 -25
  22. package/renderer/living-body.mjs +147 -0
  23. package/renderer/living-edge.mjs +16 -0
  24. package/renderer/living-geometry.mjs +96 -0
  25. package/renderer/meeting-state.mjs +55 -0
  26. package/renderer/motion-facts.mjs +108 -0
  27. package/renderer/native-window-travel.mjs +89 -0
  28. package/renderer/native-work-motion.mjs +39 -0
  29. package/renderer/peer.mjs +1 -1
  30. package/renderer/pet-controls.mjs +139 -27
  31. package/renderer/pet-motion-assets.mjs +22 -0
  32. package/renderer/pet-motion.mjs +343 -0
  33. package/renderer/recent-bubble.mjs +93 -0
  34. package/renderer/reply-letter-controller.mjs +361 -0
  35. package/renderer/reply-letter-state.mjs +38 -0
  36. package/renderer/reply-letter-view.mjs +116 -0
  37. package/renderer/settings-controller.mjs +171 -0
  38. package/renderer/settings-view.mjs +241 -0
  39. package/renderer/skins/xianxian-codex.webp +0 -0
  40. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  41. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  42. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  43. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  44. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  45. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  46. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  47. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  48. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  49. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  50. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  51. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  52. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  53. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  54. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  55. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  56. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  57. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  58. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  59. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  60. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  61. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  62. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  63. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  64. package/renderer/skins/xianxian-working-row.png +0 -0
  65. package/renderer/style.css +325 -27
  66. package/renderer/surface.mjs +308 -51
  67. package/renderer/transcript-view.mjs +117 -15
  68. package/renderer/visible-text-receipt.mjs +241 -0
  69. package/renderer/work-overview.mjs +95 -0
  70. package/source-lock.json +122 -2
@@ -2,47 +2,115 @@ import { explainError } from './errors.mjs';
2
2
 
3
3
  /** Local controls only. Durable messages, identity and preferences belong to the Host. */
4
4
  export class CompanionConversation {
5
- constructor({ client, createPeer, render, transcript, stopScreen, uuid = () => crypto.randomUUID() }) {
6
- Object.assign(this, { client, createPeer, render, transcript, stopScreen, uuid });
5
+ constructor({ client, createPeer, render, transcript, delivery = () => {}, stopScreen,
6
+ uuid = () => crypto.randomUUID(), now = Date.now }) {
7
+ Object.assign(this, { client, createPeer, render, transcript, delivery, stopScreen, uuid, now });
7
8
  this.active = false;
8
9
  this.generation = 0;
9
10
  this.phase = 'idle';
11
+ this.audioMode = null;
12
+ this.retryMode = null;
10
13
  this.muted = false;
11
14
  this.silent = false;
12
15
  this.failed = false;
16
+ this.recovering = false;
17
+ this.retiredCallId = null;
13
18
  }
14
19
  show(message) {
15
20
  this.message = message;
16
- this.render({ phase: this.phase, identity: this.identity, muted: this.muted, silent: this.silent, failed: this.failed, message });
21
+ this.render({ phase: this.phase, identity: this.identity, audioMode: this.audioMode, retryMode: this.retryMode,
22
+ muted: this.muted, silent: this.silent, failed: this.failed, recovering: this.recovering,
23
+ callId: this.callId, connectedAt: this.connectedAt ?? null, message });
17
24
  }
18
25
  current(generation) { return this.active && generation === this.generation; }
19
- listening() { return this.muted ? '麦克风已静音' : '正在听,直接说话就好'; }
20
- async begin() {
26
+ listening() {
27
+ if (this.audioMode === 'receive_only') return '只听模式 · 麦克风未启用';
28
+ return this.muted ? '麦克风已静音' : '正在听,直接说话就好';
29
+ }
30
+ retrying(prefix) {
31
+ return this.audioMode === 'receive_only'
32
+ ? `${prefix} · 麦克风未启用,点击只听重试`
33
+ : `${prefix} · 点击语音通话重试`;
34
+ }
35
+ stoppedMessage() {
36
+ return this.audioMode === 'receive_only'
37
+ ? '只听已结束 · 麦克风未启用'
38
+ : '语音通话已结束 · 麦克风已关闭';
39
+ }
40
+ expectHostStop() {
41
+ if (!this.active) return false;
42
+ this.expectedHostStop = { generation: this.generation, pending: true };
43
+ return true;
44
+ }
45
+ async settleExpectedHostStop(callStopped) {
46
+ const expected = this.expectedHostStop;
47
+ if (!expected || expected.generation !== this.generation) return;
48
+ if (callStopped === false) {
49
+ this.expectedHostStop = undefined;
50
+ return;
51
+ }
52
+ if (callStopped === null) {
53
+ expected.pending = false;
54
+ return;
55
+ }
56
+ this.expectedHostStop = undefined;
57
+ if (!this.active) return;
58
+ this.failed = false;
59
+ await this.releaseLocal(this.stoppedMessage());
60
+ }
61
+ async begin(mode = 'duplex') {
21
62
  if (this.active || this.stopping || this.changingDocuments) return;
63
+ if (mode === 'receive_only' && !this.identity?.audio?.supportedModes?.includes(mode)) return;
64
+ if (!['duplex', 'receive_only'].includes(mode)) return;
22
65
  this.active = true;
66
+ this.audioMode = mode;
67
+ this.retryMode = null;
23
68
  this.failed = false;
69
+ this.recovering = false;
24
70
  this.phase = 'connecting';
71
+ this.callId = null;
72
+ this.connectedAt = null;
73
+ this.expectedHostStop = undefined;
25
74
  const generation = ++this.generation;
26
75
  // Must reach the isolated preload while the click still has user activation.
27
76
  const preparation = this.client.prepare();
28
77
  this.show('正在连接…');
29
- this.deadline = setTimeout(() => { if (this.current(generation)) void this.end('连接超时 · 点击语音聊重试', true); }, 60_000);
78
+ this.deadline = setTimeout(() => { if (this.current(generation)) void this.end(this.retrying('连接超时'), true); }, 60_000);
30
79
  try {
31
80
  const peer = this.createPeer(event => {
32
81
  if (!this.current(generation)) return;
33
- if (event.type === 'connected') { clearTimeout(this.deadline); this.phase = 'talking'; this.show(this.listening()); }
34
- if (event.type === 'recovering') this.show('连接暂时中断 · 正在等待恢复');
35
- if (event.type === 'recovered') this.show(this.listening());
82
+ if (event.type === 'error' && !this.callId && event.callId === this.retiredCallId) return;
83
+ if (event.type === 'connected') {
84
+ if (this.callId && this.callId !== event.callId) return;
85
+ this.callId = event.callId;
86
+ this.connectedAt ??= this.now();
87
+ this.recovering = false;
88
+ clearTimeout(this.deadline); this.phase = 'talking'; this.show(this.listening());
89
+ }
90
+ if (event.type === 'error' && (!event.callId || !this.callId || event.callId === this.callId)) {
91
+ void this.end(explainError(event), true);
92
+ return;
93
+ }
94
+ if (!this.callId || event.callId !== this.callId) return;
95
+ if (event.type === 'recovering') {
96
+ this.recovering = true;
97
+ this.show('恢复后通话继续,无需重新连接');
98
+ }
99
+ if (event.type === 'recovered') {
100
+ this.recovering = false;
101
+ this.show(this.listening());
102
+ }
36
103
  if (event.type === 'transcript' || event.type === 'turn-done') this.transcript(event);
37
- if (event.type === 'error') void this.end(explainError(event), true);
38
104
  });
39
105
  this.peer = peer;
40
- peer.muteMic(this.muted); peer.muteSpeaker(this.silent);
106
+ if (mode === 'duplex') peer.muteMic(this.muted);
107
+ peer.muteSpeaker(this.silent);
41
108
  // Submit capture intent while this same click is active. The Host waits
42
109
  // for its matching preparation before creating the media document.
43
- const [identity] = await Promise.all([preparation, peer.connect()]);
110
+ const [identity] = await Promise.all([preparation, peer.connect(mode === 'receive_only' ? mode : undefined)]);
44
111
  if (!this.current(generation)) { await peer.close(); return; }
45
112
  if (identity.phase !== 'ready') throw { code: 'unavailable' };
113
+ if (mode === 'receive_only' && !identity.audio?.supportedModes?.includes(mode)) throw { code: 'unavailable' };
46
114
  this.identity = identity;
47
115
  this.show(this.message);
48
116
  } catch (error) {
@@ -51,25 +119,59 @@ export class CompanionConversation {
51
119
  }
52
120
  async releaseLocal(message) {
53
121
  this.active = false;
122
+ this.expectedHostStop = undefined;
123
+ if (this.callId) this.retiredCallId = this.callId;
54
124
  ++this.generation;
55
125
  this.pendingText = undefined;
56
126
  clearTimeout(this.deadline);
57
127
  const peer = this.peer;
58
128
  this.peer = undefined;
129
+ const releasedMode = this.audioMode;
130
+ this.audioMode = null;
131
+ this.retryMode = this.failed ? releasedMode : null;
59
132
  this.phase = 'idle';
133
+ this.recovering = false;
134
+ this.callId = null;
135
+ this.connectedAt = null;
60
136
  this.show(message);
61
137
  await Promise.allSettled([peer?.close(), this.stopScreen()]);
62
138
  }
63
- async end(message = '语音已结束 · 麦克风已关闭', failed = false) {
139
+ async end(message, failed = false) {
64
140
  if (this.stopping) return this.stopping;
141
+ const receiveOnly = this.audioMode === 'receive_only';
142
+ message ??= this.stoppedMessage();
65
143
  this.failed = failed;
66
144
  const local = this.releaseLocal(message);
67
145
  const operation = Promise.all([local, this.client.stop()]);
68
146
  this.stopping = operation;
69
147
  try { await operation; }
70
- catch { this.show('麦克风已关闭 · 连接收尾尚未确认'); }
148
+ catch { this.show(receiveOnly ? '麦克风未启用 · 连接收尾尚未确认' : '麦克风已关闭 · 连接收尾尚未确认'); }
71
149
  finally { if (this.stopping === operation) this.stopping = undefined; }
72
150
  }
151
+ async hostStopped(reason) {
152
+ // Stop echoes must not erase a more useful local failure or manual ending.
153
+ if (!this.active) return;
154
+ if (reason === 'revoked' && this.expectedHostStop?.generation === this.generation) {
155
+ this.expectedHostStop = undefined;
156
+ this.failed = false;
157
+ await this.releaseLocal(this.stoppedMessage());
158
+ return;
159
+ }
160
+ const receiveOnly = this.audioMode === 'receive_only';
161
+ const messages = receiveOnly ? {
162
+ locked: '屏幕已锁定 · 只听已停止,解锁后可点击只听继续',
163
+ suspended: '电脑已休眠 · 只听已停止,可点击只听继续',
164
+ hidden: '猫猫已收起 · 只听已停止',
165
+ } : {
166
+ locked: '屏幕已锁定 · 语音通话已停止,解锁后可点击语音通话继续',
167
+ suspended: '电脑已休眠 · 语音通话已停止,可点击语音通话继续',
168
+ hidden: '猫猫已收起 · 语音通话已停止',
169
+ };
170
+ this.failed = !Object.hasOwn(messages, reason);
171
+ await this.releaseLocal(messages[reason] ?? (receiveOnly
172
+ ? '只听连接已中断 · 麦克风未启用,点击只听重试'
173
+ : '语音通话已中断 · 麦克风已关闭,点击语音通话重试'));
174
+ }
73
175
  async refresh() {
74
176
  if (this.refreshing) return;
75
177
  this.refreshing = true;
@@ -78,9 +180,18 @@ export class CompanionConversation {
78
180
  const identity = await this.client.state();
79
181
  if (generation !== this.generation) return;
80
182
  this.identity = identity;
183
+ if (this.active && identity.audio?.activeMode) this.audioMode = identity.audio.activeMode;
81
184
  if (this.active && ['closed', 'failed', 'idle'].includes(identity.phase)) {
82
- await this.end('连接已结束 · 点击开始聊天继续');
83
- } else this.show(this.message ?? '点开始聊天,直接对我说话');
185
+ if (this.expectedHostStop?.generation === this.generation) {
186
+ this.expectedHostStop = undefined;
187
+ this.failed = false;
188
+ await this.releaseLocal(this.stoppedMessage());
189
+ } else await this.end(this.retrying('语音连接已中断'), true);
190
+ } else {
191
+ if (this.expectedHostStop?.generation === this.generation && !this.expectedHostStop.pending)
192
+ this.expectedHostStop = undefined;
193
+ this.show(this.message ?? '点击语音通话,直接对我说话');
194
+ }
84
195
  } catch (error) {
85
196
  if (generation === this.generation) {
86
197
  if (this.active) await this.end(explainError(error), true);
@@ -98,7 +209,7 @@ export class CompanionConversation {
98
209
  const identity = await changing;
99
210
  await local;
100
211
  this.identity = identity;
101
- this.show(allowed ? '资料查询已开启 · 点击开始聊天继续' : '资料查询已暂停 · 点击开始聊天继续');
212
+ this.show(allowed ? '资料查询已开启 · 点击语音通话继续' : '资料查询已暂停 · 点击语音通话继续');
102
213
  } catch (error) { this.show(explainError(error)); }
103
214
  finally { this.changingDocuments = false; }
104
215
  }
@@ -110,17 +221,40 @@ export class CompanionConversation {
110
221
  const generation = this.generation;
111
222
  this.sending = true;
112
223
  try {
113
- await this.client.text(input.text, input.id);
224
+ const receipt = await this.client.text(input.text, input.id);
225
+ if (receipt?.delivery !== 'accepted') {
226
+ this.reportDelivery(input, receipt ?? {
227
+ kind: 'delivery', delivery: 'unconfirmed', clientMessageId: input.id,
228
+ messageId: null, callId: null,
229
+ });
230
+ if (generation === this.generation) this.show(explainError({ code: 'unconfirmed' }));
231
+ return false;
232
+ }
114
233
  if (this.pendingText === input) this.pendingText = undefined;
115
- if (generation !== this.generation) return false;
116
- this.transcript({ type: 'transcript', role: 'user', text, typed: true });
234
+ this.reportDelivery(input, receipt);
235
+ // Voice may have ended; an accepted message still retires this draft.
236
+ // Keep the newer voice status rather than announcing that we are listening.
237
+ if (generation !== this.generation) return true;
238
+ this.transcript({ type: 'typed', role: 'user', text, receipt });
117
239
  this.show(this.active ? this.listening() : '已发送 · 回答会留在同一段聊天中');
118
240
  return true;
119
241
  } catch (error) {
242
+ if (error?.code === 'unconfirmed') this.reportDelivery(input, {
243
+ kind: 'delivery', delivery: 'unconfirmed', clientMessageId: input.id,
244
+ messageId: null, callId: null,
245
+ });
120
246
  if (generation === this.generation) this.show(explainError(error));
121
247
  return false;
122
248
  } finally { this.sending = false; }
123
249
  }
124
- muteMic() { this.muted = !this.muted; this.peer?.muteMic(this.muted); this.show(this.phase === 'talking' ? this.listening() : this.message); }
250
+ reportDelivery(input, receipt) {
251
+ try { this.delivery({ text: input.text, receipt }); }
252
+ catch { /* Presentation failures cannot change Host delivery truth. */ }
253
+ }
254
+ muteMic() {
255
+ if (this.audioMode === 'receive_only') return;
256
+ this.muted = !this.muted; this.peer?.muteMic(this.muted);
257
+ this.show(this.phase === 'talking' ? this.listening() : this.message);
258
+ }
125
259
  muteSpeaker() { this.silent = !this.silent; this.peer?.muteSpeaker(this.silent); this.show(this.message); }
126
260
  }
@@ -0,0 +1,122 @@
1
+ import { decisionBadge, decisionPresentation, decisionRows } from './decision-view.mjs';
2
+
3
+ const readBasis = page => page?.version === 1
4
+ ? [page.status, page.totalCount ?? 'unknown', ...['approvals', 'needsMe']
5
+ .flatMap(key => [page.sources?.[key]?.status, page.sources?.[key]?.coverage])].join('|')
6
+ : page ? 'legacy' : null;
7
+
8
+ export const decisionNavigationText = delivery => delivery === 'applied' ? '已打开对应事项'
9
+ : delivery === 'queued' || delivery === 'requested' ? '已请求打开对应事项'
10
+ : delivery === 'blocked' ? '暂时无法打开对应事项 · 请稍后重试'
11
+ : '打开对应事项尚未确认 · 请从 Cat Café 查看';
12
+
13
+ export class DecisionPanel {
14
+ constructor({ document, client, controls, motion, proactivePolicy = () => undefined }) {
15
+ this.document = document;
16
+ this.client = client;
17
+ this.controls = controls;
18
+ this.motion = motion;
19
+ this.proactivePolicy = proactivePolicy;
20
+ this.$ = id => document.getElementById(id);
21
+ this.loading = false;
22
+ this.offset = 0;
23
+ this.latestPage = undefined;
24
+ this.$('pending-badge').onclick = () => void controls.show('decisions');
25
+ this.$('decision-reload').onclick = () => void this.read();
26
+ this.$('decision-more').onclick = () => void this.read(true);
27
+ }
28
+
29
+ showBadge(page) {
30
+ this.latestPage = page;
31
+ const badge = decisionBadge(page);
32
+ this.$('pending-badge').hidden = !badge.visible || this.proactivePolicy() !== 'quiet-badge';
33
+ this.$('pending-count').textContent = badge.label;
34
+ this.$('pending-badge').title = badge.title;
35
+ this.$('pending-badge').setAttribute('aria-label', badge.title);
36
+ this.$('menu-pending').textContent = badge.label;
37
+ this.$('menu-pending').dataset.state = badge.state;
38
+ }
39
+
40
+ refreshBadge() {
41
+ if (this.latestPage) this.showBadge(this.latestPage);
42
+ }
43
+
44
+ appendRow(list, row, knownVariantRefs) {
45
+ if (row.variantRef && knownVariantRefs.has(row.variantRef)) return;
46
+ const item = this.document.createElement('li');
47
+ if (row.variantRef) { item.dataset.variantRef = row.variantRef; knownVariantRefs.add(row.variantRef); }
48
+ const title = this.document.createElement('strong');
49
+ const meta = this.document.createElement('span');
50
+ title.textContent = row.title; meta.textContent = row.meta;
51
+ item.append(title, meta); list.append(item);
52
+ if (row.navigation) this.appendNavigation(item, row.navigation);
53
+ else if (row.previewable) this.appendTrial(item, row.proposalId);
54
+ }
55
+
56
+ appendNavigation(item, navigation) {
57
+ const button = this.document.createElement('button');
58
+ button.type = 'button'; button.textContent = navigation.label;
59
+ button.onclick = async () => {
60
+ try {
61
+ const result = await this.client.openDecision(navigation.variantRef, navigation.target);
62
+ this.$('decision-status').textContent = decisionNavigationText(result.delivery);
63
+ } catch { this.$('decision-status').textContent = '暂时无法打开对应事项 · 请稍后重试'; }
64
+ };
65
+ item.append(button);
66
+ }
67
+
68
+ appendTrial(item, proposalId) {
69
+ const button = this.document.createElement('button');
70
+ button.type = 'button'; button.textContent = '查看确认演练';
71
+ button.onclick = async () => {
72
+ try {
73
+ const result = await this.client.inspectF221(proposalId);
74
+ this.$('decision-status').textContent = result.status === 'trial_confirmed'
75
+ ? '确认演练已完成;没有写回提案'
76
+ : result.status === 'stale' ? '提案已变化,请重新查看原处卡片'
77
+ : result.status === 'dismissed' ? '已取消演练;提案没有变化' : '确认演练暂不可用;请在原处处理';
78
+ } catch { this.$('decision-status').textContent = '确认演练暂不可用;请在原处处理'; }
79
+ };
80
+ item.append(button);
81
+ }
82
+
83
+ async read(more = false) {
84
+ if (this.loading) return;
85
+ this.loading = true;
86
+ if (!more && this.controls.panel === 'decisions') this.$('decision-status').textContent = '正在读取待办…';
87
+ try {
88
+ const previous = readBasis(this.latestPage);
89
+ const page = await this.client.readDecisions(more ? this.offset : 0, 10);
90
+ const presentation = decisionPresentation(page);
91
+ const changedRead = more && presentation.renderRows && previous !== null && readBasis(page) !== previous;
92
+ this.showBadge(page);
93
+ this.motion.setPendingDecision(presentation.pending);
94
+ if (this.controls.panel !== 'decisions') return;
95
+ const list = this.$('decision-list');
96
+ if (!more || changedRead) { list.replaceChildren(); list.scrollTop = 0; }
97
+ const retainedRows = more && !presentation.renderRows && list.children.length > 0;
98
+ const knownVariantRefs = new Set(Array.from(list.children).map(item => item.dataset.variantRef).filter(Boolean));
99
+ for (const row of presentation.renderRows && !changedRead ? decisionRows(page) : []) {
100
+ this.appendRow(list, row, knownVariantRefs);
101
+ }
102
+ this.offset = changedRead ? 0 : page.page.offset + page.page.limit;
103
+ this.$('decision-reload').textContent = changedRead ? '刷新' : presentation.reloadLabel;
104
+ this.$('decision-status').textContent = changedRead && page.totalCount !== 0
105
+ ? '待办已变化 · 请刷新查看最新列表'
106
+ : retainedRows
107
+ ? `${presentation.badge.state === 'authentication' ? '待办需要登录' : '待办暂不可读'} · 以下为上次读到的内容`
108
+ : presentation.message;
109
+ this.$('decision-more').hidden = changedRead || !presentation.hasMore;
110
+ } catch {
111
+ this.showBadge(undefined);
112
+ this.motion.setPendingDecision(null);
113
+ if (this.controls.panel === 'decisions') {
114
+ const retained = this.$('decision-list').children.length > 0;
115
+ this.$('decision-status').textContent = retained
116
+ ? '待办暂不可读 · 以下为上次读到的内容' : '待办暂不可读 · 请稍后刷新';
117
+ this.$('decision-reload').textContent = '重试';
118
+ this.$('decision-more').hidden = true;
119
+ }
120
+ } finally { this.loading = false; }
121
+ }
122
+ }
@@ -0,0 +1,124 @@
1
+ function unified(page) {
2
+ return page?.version === 1 && Array.isArray(page.items);
3
+ }
4
+
5
+ function knownRows(page) {
6
+ if (unified(page)) return page.items.length;
7
+ return (page?.approvals?.length ?? 0) + (page?.otherNeedsMe?.length ?? 0);
8
+ }
9
+
10
+ function authenticationRequired(page) {
11
+ if (!unified(page)) return false;
12
+ const sources = Object.values(page.sources ?? {});
13
+ return sources.length > 0 && sources.every(source => source.status === 'unauthenticated');
14
+ }
15
+
16
+ /** Exact totals exist only on a complete, consistent unified read. Legacy pages never invent a sum. */
17
+ export function decisionBadge(page) {
18
+ if (!page) return { visible: true, label: '?', title: '待办暂不可读', state: 'unavailable' };
19
+ if (unified(page)) {
20
+ if (page.status === 'partial') {
21
+ return { visible: true, label: page.items.length > 0 ? '•' : '!', title: '仅部分读取', state: 'partial' };
22
+ }
23
+ if (page.status === 'unavailable') {
24
+ return authenticationRequired(page)
25
+ ? { visible: true, label: '!', title: '需要登录', state: 'authentication' }
26
+ : { visible: true, label: '!', title: '待办暂不可读', state: 'unavailable' };
27
+ }
28
+ if (Number.isInteger(page.totalCount)) {
29
+ return page.totalCount === 0
30
+ ? { visible: false, label: '0', title: '暂无待办', state: 'empty' }
31
+ : { visible: true, label: String(page.totalCount), title: `待办 · ${page.totalCount} 件`, state: 'available' };
32
+ }
33
+ return { visible: true, label: page.items.length > 0 ? '•' : '!', title: '数量未确认', state: 'uncertain' };
34
+ }
35
+ if (page.status !== 'available')
36
+ return { visible: true, label: '?', title: '待办暂不可读', state: 'unavailable' };
37
+ const hasKnownRows = knownRows(page) > 0
38
+ || page.approvalCount > 0 || page.needsMeCount > 0 || page.otherNeedsMeCount > 0;
39
+ if (hasKnownRows) return { visible: true, label: '有待办', title: '有待办', state: 'available' };
40
+ return { visible: false, label: '0', title: '现在没有待办', state: 'empty' };
41
+ }
42
+
43
+ function approvalMeta(approval) {
44
+ const state = {
45
+ open: '待你决定', accepted: '已接纳 · 待核结果', rejected: '已拒绝',
46
+ closed_without_decision: '已关闭',
47
+ };
48
+ const materialization = approval.materializationState === 'outcome_unknown' ? '结果未知'
49
+ : approval.materializationState === 'failed' ? '执行失败'
50
+ : approval.linkedNeedsMe ? '已关联待办' : '原处卡片';
51
+ return `审批 · ${state[approval.resolution] ?? '状态待核'} · ${materialization}`;
52
+ }
53
+
54
+ function navigation(item) {
55
+ const priorities = item.kind === 'approval'
56
+ ? [['approval_card', '打开审批'], ['action', '打开处理入口'], ['origin', '打开原处']]
57
+ : [['action', '打开处理入口'], ['origin', '打开原处'], ['approval_card', '打开审批']];
58
+ const selected = priorities.find(([target]) => item.navigation.targets.includes(target));
59
+ return selected ? { variantRef: item.variantRef, target: selected[0], label: selected[1] } : undefined;
60
+ }
61
+
62
+ export function decisionRows(page) {
63
+ if (unified(page)) {
64
+ if (page.status === 'unavailable') return [];
65
+ return page.items.map(item => ({
66
+ title: item.summary || '待处理事项',
67
+ meta: item.kind === 'approval' ? approvalMeta(item.approval)
68
+ : item.kind === 'judgment' ? '等你判断 · 请在原处处理' : '需要修复 · 请在原处处理',
69
+ variantRef: item.variantRef,
70
+ navigation: navigation(item),
71
+ previewable: false,
72
+ }));
73
+ }
74
+ if (!page || page.status !== 'available') return [];
75
+ return [
76
+ ...(page.approvals ?? []).map(item => ({
77
+ title: item.summary || '待决策事项',
78
+ meta: approvalMeta(item),
79
+ proposalId: item.proposalId,
80
+ previewable: item.sourceFeatureId === 'F221'
81
+ && /^[A-Za-z0-9_-]{1,200}$/.test(item.proposalId)
82
+ && item.resolution === 'open' && item.materializationState === 'not_started',
83
+ })),
84
+ ...(page.otherNeedsMe ?? []).map(item => ({ title: item.summary || '待处理事项', meta: '其他待处理 · 请向猫猫询问详情' })),
85
+ ];
86
+ }
87
+
88
+ export function decisionPresentation(page) {
89
+ const badge = decisionBadge(page);
90
+ if (!page) return { badge, message: '待办暂不可读 · 请稍后刷新', reloadLabel: '重试', pending: null, hasMore: false, renderRows: false };
91
+ if (unified(page)) {
92
+ const pending = Number.isInteger(page.totalCount) ? page.totalCount > 0 : page.items.length > 0 ? true : null;
93
+ if (page.status === 'partial') return {
94
+ badge, message: '仅部分读取 · 其余待办未能读取', reloadLabel: '重试', pending,
95
+ hasMore: page.page.hasMore, renderRows: true,
96
+ };
97
+ if (page.status === 'unavailable') return {
98
+ badge, message: authenticationRequired(page) ? '待办需要登录 · 请登录后重试' : '待办暂不可读 · 请稍后重试',
99
+ reloadLabel: '重试', pending: null, hasMore: false, renderRows: false,
100
+ };
101
+ if (page.totalCount === 0) return {
102
+ badge, message: '暂无待办', reloadLabel: '刷新', pending: false,
103
+ hasMore: page.page.hasMore, renderRows: true,
104
+ };
105
+ return {
106
+ badge,
107
+ message: Number.isInteger(page.totalCount)
108
+ ? `已读取 ${page.totalCount} 件待办`
109
+ : '已显示读取到的待办 · 数量未确认',
110
+ reloadLabel: '刷新', pending,
111
+ hasMore: page.page.hasMore, renderRows: true,
112
+ };
113
+ }
114
+ if (page.status !== 'available') return {
115
+ badge, message: '待办暂不可读 · 请稍后刷新', reloadLabel: '重试', pending: null, hasMore: false, renderRows: false,
116
+ };
117
+ return {
118
+ badge,
119
+ message: badge.state === 'empty' ? '现在没有待你处理的事项' : '已显示读取到的待办 · 暂无总数',
120
+ reloadLabel: '刷新', pending: badge.state === 'empty' ? false : true,
121
+ hasMore: page.page.hasMoreApprovals || page.page.hasMoreNeedsMe,
122
+ renderRows: true,
123
+ };
124
+ }
@@ -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: '发送尚未确认 · 文字已保留,可检查聊天记录后重试',