@clowder-ai/companion 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (61) hide show
  1. package/NOTICE +10 -2
  2. package/README.md +59 -0
  3. package/manifest.json +5 -5
  4. package/package.json +19 -6
  5. package/plugin.yaml +5 -5
  6. package/renderer/call-transcript-view.mjs +238 -0
  7. package/renderer/chat-unread.mjs +44 -0
  8. package/renderer/client.mjs +15 -1
  9. package/renderer/compact-size.mjs +8 -0
  10. package/renderer/companion-identity.mjs +42 -0
  11. package/renderer/conversation.mjs +144 -24
  12. package/renderer/decision-panel.mjs +122 -0
  13. package/renderer/decision-view.mjs +124 -0
  14. package/renderer/errors.mjs +4 -4
  15. package/renderer/index.html +188 -26
  16. package/renderer/living-body.mjs +147 -0
  17. package/renderer/living-edge.mjs +16 -0
  18. package/renderer/living-geometry.mjs +96 -0
  19. package/renderer/motion-facts.mjs +108 -0
  20. package/renderer/native-window-travel.mjs +89 -0
  21. package/renderer/native-work-motion.mjs +39 -0
  22. package/renderer/peer.mjs +1 -1
  23. package/renderer/pet-controls.mjs +130 -0
  24. package/renderer/pet-motion-assets.mjs +22 -0
  25. package/renderer/pet-motion.mjs +343 -0
  26. package/renderer/recent-bubble.mjs +74 -0
  27. package/renderer/settings-controller.mjs +171 -0
  28. package/renderer/settings-view.mjs +234 -0
  29. package/renderer/skins/ragdoll-v1.png +0 -0
  30. package/renderer/skins/xianxian-codex.webp +0 -0
  31. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  32. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  33. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  34. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  35. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  36. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  37. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  38. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  39. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  40. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  41. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  42. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  43. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  44. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  45. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  46. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  47. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  48. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  49. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  50. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  51. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  52. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  53. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  54. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  55. package/renderer/skins/xianxian-working-row.png +0 -0
  56. package/renderer/skins/yarn-ball.png +0 -0
  57. package/renderer/style.css +260 -308
  58. package/renderer/surface.mjs +306 -69
  59. package/renderer/transcript-view.mjs +60 -7
  60. package/renderer/work-overview.mjs +95 -0
  61. package/source-lock.json +138 -4
@@ -4,102 +4,339 @@ import { explainError } from './errors.mjs';
4
4
  import { VoicePeer } from './peer.mjs';
5
5
  import { ScreenShare } from './screen-share.mjs';
6
6
  import { TranscriptView } from './transcript-view.mjs';
7
-
7
+ import { CallTranscriptView } from './call-transcript-view.mjs';
8
+ import { SettingsView } from './settings-view.mjs';
9
+ import { RecentBubble } from './recent-bubble.mjs';
10
+ import { PetMotion } from './pet-motion.mjs';
11
+ import { LivingBody } from './living-body.mjs';
12
+ import { detectDockedEdge } from './living-edge.mjs';
13
+ import { bindPetControls } from './pet-controls.mjs';
14
+ import { DecisionPanel } from './decision-panel.mjs';
15
+ import { nativeWorkLabel, normalizeNativeWork } from './native-work-motion.mjs';
16
+ import { NativeWindowTravel } from './native-window-travel.mjs';
17
+ import { currentCompanionIdentity } from './companion-identity.mjs';
18
+ import { FoldedChatUnread } from './chat-unread.mjs';
19
+ import { COMPACT_BALL_SIZE, compactSizeAction } from './compact-size.mjs';
20
+ import { WorkOverviewView } from './work-overview.mjs';
8
21
  const $ = id => document.getElementById(id);
9
- const status = text => { $('status').textContent = text; };
10
- const transcript = new TranscriptView($('transcript'));
11
- let expanded = false;
12
- let sharing = false;
13
- let pendingScreen = false;
14
-
22
+ const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
23
+ const status = text => {
24
+ $('status').textContent = text;
25
+ $('menu-status').textContent = text;
26
+ $('chat-status').textContent = text;
27
+ $('announcement').textContent = text;
28
+ };
29
+ const transcript = new TranscriptView($('chat-transcript'));
30
+ const callTranscript = new CallTranscriptView($('call-transcript-log'), {
31
+ onUnread: count => {
32
+ $('call-unread').hidden = count === 0;
33
+ $('call-unread').textContent = count ? `${count} 条新消息` : '';
34
+ },
35
+ });
36
+ const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
37
+ const callBubble = new RecentBubble([$('call-bubble-first'), $('call-bubble-second')]);
38
+ const foldedChatUnread = new FoldedChatUnread(count => {
39
+ $('call-chat-unread').hidden = count === 0;
40
+ $('call-chat-unread').textContent = count ? String(count) : '';
41
+ $('call-chat').setAttribute('aria-label', count ? `聊天,${count} 条未读` : '聊天');
42
+ });
43
+ const motion = new PetMotion($('pet'), { livingBody: new LivingBody({ root: $('pet'), sit: $('living-sit'),
44
+ video: $('living-video'), transitionVideo: $('living-video-transition') }) });
45
+ const nativeTravel = new NativeWindowTravel({
46
+ readPosition: () => ({ x: window.screenX, y: window.screenY }),
47
+ changed: value => motion.syncTravel(value),
48
+ });
49
+ let sharing = false, pendingScreen = false, loading = false, transcriptLoading = false, latestHistory, previousPhase = 'idle';
50
+ let didMove = false, threadTitle, settingsView, settingsValues, decisionPanel, workView;
51
+ let previousControlPanel = 'none';
52
+ // F229 stays quiet: a history ID change has no provenance for proactive text.
53
+ const ambientPanel = active => active ? 'actions' : 'none';
54
+ const setTexts = (ids, text) => { for (const id of ids) $(id).textContent = text; };
55
+ const setHidden = (ids, hidden) => { for (const id of ids) $(id).hidden = hidden; };
15
56
  if (!window.clowderCompanion) {
16
- status('请从 Clowder 的插件页面打开猫猫球');
17
- for (const id of ['begin', 'documents', 'history']) $(id).disabled = true;
57
+ $('actions').hidden = false; $('status').hidden = false;
58
+ status('请从 Clowder 的聊聊入口打开猫猫球');
59
+ document.querySelectorAll('button').forEach(button => { button.disabled = true; });
18
60
  } else {
61
+ nativeTravel.start();
19
62
  const client = createCompanionClient(window.clowderCompanion);
63
+ const controls = bindPetControls(client, {
64
+ error: (error, context) => status(context?.panel === 'settings' && error?.code === 'invalid_request' ? '这个 Host 版本还不支持猫旁设置。原来的设置入口会继续保留。' : explainError(error)),
65
+ moved: (dx, dy) => {
66
+ if (dx !== 'stop') { didMove = true; nativeTravel.setManual(true); motion.move(dx, dy); return; }
67
+ nativeTravel.setManual(false);
68
+ motion.stopMove();
69
+ if (!didMove) return;
70
+ didMove = false;
71
+ void conversation.refresh();
72
+ setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
73
+ },
74
+ changed: panel => {
75
+ if (panel === 'chat') foldedChatUnread.open();
76
+ else if (previousControlPanel === 'chat' && conversation?.active) foldedChatUnread.fold();
77
+ previousControlPanel = panel;
78
+ if (panel === 'chat') void readHistory();
79
+ if (panel === 'transcript') void readCallTranscript();
80
+ if (panel === 'decisions') void decisionPanel?.read();
81
+ if (panel === 'settings') void settingsView?.open();
82
+ if (panel === 'menu') void workView?.load();
83
+ },
84
+ action(kind) {
85
+ if (kind === 'begin') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin(); void controls.show('none'); }
86
+ if (kind === 'listen') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin('receive_only'); void controls.show('none'); }
87
+ if (kind === 'stop') void conversation.end();
88
+ if (kind === 'mute') conversation.muteMic();
89
+ if (kind === 'speaker') conversation.muteSpeaker();
90
+ if (kind === 'share') toggleScreen();
91
+ if (kind === 'documents') void conversation.documents(conversation.identity?.documentsAllowed === false);
92
+ if (kind === 'hide') void client.hide().catch(error => status(explainError(error)));
93
+ if (kind === 'size-toggle') {
94
+ if (!settingsValues) { status('大小设置仍在读取'); return; }
95
+ void settingsView.controller.requestUpdate('ballSize', compactSizeAction(settingsValues.ballSize).value);
96
+ }
97
+ if (kind === 'transcript-return') void controls.show('actions');
98
+ if (kind === 'transcript-expand') {
99
+ const expanded = $('transcript').dataset.expanded !== 'true';
100
+ $('transcript').dataset.expanded = String(expanded);
101
+ setTexts(['transcript-expand-label'], expanded ? '收起' : '展开');
102
+ void controls.show('transcript');
103
+ }
104
+ if (kind === 'play' && conversation.identity?.skin === 'xianxian-codex') {
105
+ void controls.show('none');
106
+ motion.signal('play');
107
+ }
108
+ },
109
+ });
20
110
  const screen = new ScreenShare({
21
111
  screenRequest: () => client.screenPick(),
22
- screenStart: (selectionId, label) => client.screenOpen(selectionId, label),
112
+ screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
23
113
  screenFrame: (selectionId, frame) => client.screenFrame(selectionId, {
24
114
  ...frame, frameId: crypto.randomUUID(), sourceLabel: '所选画面', observedAt: Date.now(),
25
- }),
26
- screenStop: () => client.screenClose(),
115
+ }), screenStop: () => client.screenClose(),
27
116
  }, value => {
28
- sharing = value.sharing;
29
- pendingScreen = value.pending === true;
30
- $('share').textContent = sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享屏幕';
31
- $('share').setAttribute('aria-pressed', String(sharing));
32
- $('screen-status').hidden = !sharing && !pendingScreen && value.message === '未共享屏幕';
33
- $('screen-status').textContent = sharing ? `正在共享:${value.label}` : value.message;
34
- $('screen-status').dataset.state = sharing ? 'sharing' : pendingScreen ? 'pending' : 'stopped';
117
+ const wasActive = sharing || pendingScreen;
118
+ sharing = value.sharing; pendingScreen = value.pending === true;
119
+ for (const id of ['share', 'menu-share', 'menu-call-share', 'chat-share', 'settings-share']) label(id, sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享画面');
120
+ for (const prefix of ['call', 'menu', 'chat', 'transcript', 'settings']) {
121
+ $(`${prefix}-share-context`).hidden = !sharing;
122
+ $(`${prefix}-share-target`).textContent = sharing ? `共享中:${value.label}` : '';
123
+ }
124
+ $('share-badge').hidden = !sharing && !pendingScreen;
125
+ $('share-badge').title = sharing ? `正在共享:${value.label},点此停止` : '正在选屏,点此取消';
126
+ $('share-badge').setAttribute('aria-label', sharing ? `停止共享:${value.label}` : '取消选屏');
127
+ if (value.message && value.message !== '未共享屏幕' && (wasActive || pendingScreen)) status(value.message);
35
128
  });
129
+ function toggleScreen() {
130
+ if (conversation.phase !== 'talking') {
131
+ status('先开始语音通话,连上后可选择窗口或屏幕');
132
+ $('status').hidden = false;
133
+ void controls.show('actions');
134
+ return;
135
+ }
136
+ void (sharing || pendingScreen ? screen.stop('屏幕共享已停止') : screen.start()).catch(error => status(explainError(error)));
137
+ }
36
138
  const conversation = new CompanionConversation({
37
139
  client, createPeer: callback => new VoicePeer(callback, client), stopScreen: () => screen.stop(),
38
- transcript: event => event.type === 'turn-done' ? transcript.finish(event) : transcript.append(event.role, event.text, !event.typed),
140
+ transcript: event => {
141
+ if (event.type === 'turn-done') {
142
+ transcript.finish(event); callTranscript.finish(event); bubble.finish(event.role); callBubble.finish(event.role);
143
+ void readHistory(); void readCallTranscript();
144
+ } else if (event.type === 'typed') {
145
+ callTranscript.appendTyped(event);
146
+ void readHistory(); void readCallTranscript();
147
+ } else {
148
+ transcript.append(event.role, event.text, true);
149
+ callTranscript.appendLive(event);
150
+ const speaker = event.role === 'assistant' ? conversation.identity?.displayName ?? '猫猫' : '语音';
151
+ bubble.append(event.role, event.text, speaker); callBubble.append(event.role, event.text, speaker);
152
+ void controls.setAmbient('actions');
153
+ }
154
+ },
39
155
  render(value) {
156
+ const showFailure = value.failed && previousPhase !== 'idle' && value.phase === 'idle';
157
+ const justConnected = previousPhase !== 'talking' && value.phase === 'talking';
158
+ if (previousPhase !== 'idle' && value.phase === 'idle') {
159
+ transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void readHistory();
160
+ foldedChatUnread.reset();
161
+ if (controls.panel === 'transcript') void controls.show('menu');
162
+ }
163
+ previousPhase = value.phase;
40
164
  const active = value.phase !== 'idle';
41
- document.body.className = value.phase === 'talking' ? 'connected' : active ? 'connecting' : '';
42
- $('idle').hidden = active; $('controls').hidden = !active;
43
- $('share').hidden = value.phase !== 'talking';
44
- $('compose').querySelector('button').disabled = value.phase !== 'talking';
45
- $('begin').disabled = !value.identity;
46
- status(value.message);
165
+ const receiveOnly = value.audioMode === 'receive_only';
166
+ const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
167
+ const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
168
+ callTranscript.setCall(value.callId);
169
+ void controls.setAmbient(ambientPanel(active));
170
+ $('begin').hidden = active; $('begin').disabled = !value.identity;
171
+ $('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
172
+ label('begin', value.failed && !retryReceiveOnly ? '重试语音通话' : '语音通话');
173
+ label('listen', retryReceiveOnly ? '重试只听' : '只听');
174
+ $('begin').className = retryReceiveOnly ? '' : 'primary';
175
+ $('listen').className = retryReceiveOnly ? 'primary' : '';
176
+ $('mic').hidden = value.phase !== 'talking' || receiveOnly; $('speaker').hidden = !active;
177
+ $('chat-mic').hidden = value.phase !== 'talking' || receiveOnly;
178
+ $('menu-mic').hidden = value.phase !== 'talking' || receiveOnly;
179
+ $('transcript-mic').hidden = value.phase !== 'talking' || receiveOnly;
180
+ $('settings-mic').hidden = value.phase !== 'talking' || receiveOnly;
181
+ $('active-actions').hidden = !active;
182
+ $('share').disabled = value.phase !== 'talking';
183
+ $('menu-call-share').disabled = value.phase !== 'talking';
184
+ $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
185
+ status(value.message ?? '');
186
+ $('status').hidden = !value.message || /^(点击|正在听|只听模式|只听已结束|语音通话已结束|已发送)/.test(value.message);
187
+ $('menu-status').hidden = !value.message;
188
+ const callState = value.recovering ? '连接不稳定' : value.phase === 'connecting' ? '正在连接'
189
+ : receiveOnly ? '只听中' : active ? '通话中' : '';
190
+ const microphoneState = receiveOnly ? '麦克风未启用'
191
+ : value.phase === 'connecting' ? '正在请求麦克风'
192
+ : value.recovering ? `正在等待原连接恢复 · ${value.muted ? '麦克风已静音' : '麦克风已开启'}`
193
+ : value.muted ? '麦克风已静音' : active ? '麦克风已开启' : '';
194
+ for (const id of ['actions', 'chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context']) $(id).dataset.connectionState = value.recovering ? 'recovering' : active ? 'connected' : 'idle';
195
+ setTexts(['call-state', 'chat-call-state', 'transcript-call-state', 'menu-call-state', 'settings-call-state'], callState);
196
+ setTexts(['call-mic-state', 'chat-call-mic-state', 'transcript-call-mic-state', 'menu-call-mic-state', 'settings-call-mic-state'], microphoneState);
197
+ setHidden(['chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context'], !active);
198
+ label('mic', value.muted ? '取消静音' : '静音'); $('mic').setAttribute('aria-pressed', String(value.muted));
199
+ $('mic').setAttribute('aria-label', value.muted ? '取消静音' : '静音');
200
+ $('mic-icon').setAttribute('href', value.muted ? '#i-mic-off' : '#i-mic');
201
+ $('chat-mic').textContent = value.muted ? '取消静音' : '静音';
202
+ $('menu-mic').setAttribute('aria-label', value.muted ? '取消静音' : '静音');
203
+ $('menu-mic').title = value.muted ? '取消静音' : '静音';
204
+ $('menu-mic').setAttribute('aria-pressed', String(value.muted));
205
+ $('menu-mic-icon').setAttribute('href', value.muted ? '#i-mic-off' : '#i-mic');
206
+ $('settings-mic').textContent = value.muted ? '取消静音' : '静音';
207
+ $('transcript-mic').setAttribute('aria-label', value.muted ? '取消静音' : '静音');
208
+ $('transcript-mic').setAttribute('aria-pressed', String(value.muted));
209
+ $('transcript-mic-icon').setAttribute('href', value.muted ? '#i-mic-off' : '#i-mic');
210
+ label('speaker', '播音');
211
+ $('speaker').setAttribute('aria-pressed', String(!value.silent));
212
+ $('speaker').title = value.silent ? '开启播音' : '关闭播音';
213
+ $('name-card-actions').hidden = active;
214
+ $('menu-hide-label').textContent = active ? '结束通话并隐藏' : '隐藏';
47
215
  const identity = value.identity;
216
+ const companionIdentity = currentCompanionIdentity(identity);
217
+ const nativeSnapshot = normalizeNativeWork(identity?.nativeWork);
218
+ const workLabel = nativeWorkLabel(nativeSnapshot, identity?.nativeActivity);
219
+ const workState = workLabel ? `${identity.displayName}${workLabel}` : '';
220
+ setTexts(['call-work', 'chat-call-work', 'transcript-call-work', 'menu-call-work', 'settings-call-work'], workState);
221
+ setHidden(['call-work', 'chat-call-work', 'transcript-call-work', 'menu-call-work', 'settings-call-work'], !workState);
222
+ motion.setContext({ skin: identity?.skin, phase: value.phase,
223
+ nativeActivity: identity?.nativeActivity ?? 'none', muted: value.muted || receiveOnly });
224
+ motion.setBehaviorEnabled(identity?.behaviorEnabled === true);
225
+ motion.syncNativeSnapshot(nativeSnapshot);
226
+ if (justConnected) motion.signal('connected');
227
+ if (showFailure) motion.signal('failed');
48
228
  if (identity) {
49
- $('identity').textContent = identity.displayName;
50
- $('chat-name').textContent = identity.displayName;
51
- $('pet').setAttribute('aria-label', `${identity.displayName},拖动可以移动`);
52
- $('pet').dataset.skin = identity.skin;
53
- $('dock').setAttribute('aria-label', `和${identity.displayName}交流`);
54
- $('message').setAttribute('aria-label', `给${identity.displayName}写一句`);
55
- document.documentElement.style.setProperty('--speaker-label', JSON.stringify(identity.carrier.displayName));
56
- $('documents').textContent = `资料查询 · ${identity.documentsAllowed ? '开启' : '暂停'}`;
229
+ $('menu-play').hidden = identity.skin !== 'xianxian-codex';
230
+ const companionLabel = companionIdentity
231
+ ? `${companionIdentity.title} · ${companionIdentity.partnerLabel}` : identity.displayName;
232
+ $('chat-name').textContent = threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
233
+ $('call-message').placeholder = `发消息给${identity.displayName}…`;
234
+ $('pet').setAttribute('aria-label', `${companionLabel}:点击或右键打开名片,拖动移动`);
235
+ $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
57
236
  $('documents').setAttribute('aria-pressed', String(identity.documentsAllowed));
58
- $('connection-scope').textContent = identity.duty.catId === identity.carrier.catId
59
- ? (active && identity.documentsAllowed ? `资料工具 · ${identity.toolsReady ? '已连接' : '连接中'}` : '交流保存在同一段聊天中')
60
- : `${identity.duty.displayName} · 实时语音由${identity.carrier.displayName}承载`;
61
- }
62
- for (const [id, muted, target, off, on] of [
63
- ['mic', value.muted, '麦克风', '取消静音', '静音'],
64
- ['speaker', value.silent, '播音', '开声音', '关声音'],
65
- ]) {
66
- $(id).setAttribute('aria-pressed', String(muted));
67
- $(id).setAttribute('aria-label', `${muted ? '开启' : '关闭'}${target}`);
68
- $(id).querySelector('span').textContent = muted ? off : on;
237
+ if (companionIdentity) {
238
+ $('connection-scope').textContent = `${companionIdentity.partnerLabel} · ${companionIdentity.liveLabel} · ${companionIdentity.deepLabel}`;
239
+ } else {
240
+ const live = identity.liveTransport?.kind === 'gpt_live_v3'
241
+ ? 'GPT Live · 实时型号未证实' : '实时载体未确认';
242
+ $('connection-scope').textContent = identity.duty.catId === identity.carrier.catId
243
+ ? `${identity.duty.displayName} · ${live}`
244
+ : `${identity.duty.displayName} · ${live} · 绑定载体 ${identity.carrier.displayName}`;
245
+ }
69
246
  }
247
+ if (showFailure) void controls.show('menu');
248
+ },
249
+ });
250
+ settingsView = new SettingsView({
251
+ document,
252
+ client,
253
+ close: () => void controls.show('menu'),
254
+ callActive: () => conversation.active,
255
+ expectHostStop: () => conversation.expectHostStop(),
256
+ settleExpectedHostStop: callStopped => conversation.settleExpectedHostStop(callStopped),
257
+ layoutChanged: () => { if (controls.panel === 'settings') controls.remeasure?.(); },
258
+ onValues(values) {
259
+ const sizeChanged = settingsValues !== undefined && settingsValues.ballSize !== values.ballSize;
260
+ settingsValues = values;
261
+ document.documentElement?.style?.setProperty?.('--pet-scale', String(values.ballSize / 72));
262
+ $('anchor').dataset.ballSize = String(values.ballSize);
263
+ $('anchor').dataset.compact = String(values.ballSize <= COMPACT_BALL_SIZE);
264
+ label('menu-size-toggle', compactSizeAction(values.ballSize).label);
265
+ motion.setBehaviorEnabled(values.behaviorEnabled);
266
+ $('documents').hidden = true;
267
+ decisionPanel?.refreshBadge();
268
+ if (sizeChanged) void controls.show(controls.panel);
70
269
  },
71
270
  });
72
- function details(value) {
73
- expanded = value;
74
- $('details').hidden = !value;
75
- $('write').setAttribute('aria-expanded', String(value));
76
- void client.resize(value).catch(error => status(explainError(error)));
271
+ workView = new WorkOverviewView({
272
+ document,
273
+ client,
274
+ changed: () => { if (controls.panel === 'menu') controls.remeasure?.(); },
275
+ });
276
+ decisionPanel = new DecisionPanel({
277
+ document,
278
+ client,
279
+ controls,
280
+ motion,
281
+ proactivePolicy: () => settingsValues?.proactivePolicy,
282
+ });
283
+ async function readHistory() {
284
+ if (loading) return;
285
+ loading = true;
286
+ try {
287
+ const history = await client.readConversation();
288
+ $('history').textContent = history.hasMore ? '在 Cat Café 中查看更早聊天 ↗' : '在 Cat Café 中查看完整聊天 ↗';
289
+ threadTitle = history.threadTitle;
290
+ $('chat-name').textContent = threadTitle;
291
+ bubble.load(history.messages);
292
+ void controls.setAmbient(ambientPanel(conversation.phase !== 'idle'));
293
+ const serialized = JSON.stringify(history.messages);
294
+ if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
295
+ } catch { $('chat-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示'; }
296
+ finally { loading = false; }
77
297
  }
78
- $('begin').onclick = () => { transcript.reset(); void conversation.begin(); };
79
- $('end').onclick = () => void conversation.end();
80
- $('mic').onclick = () => conversation.muteMic();
81
- $('speaker').onclick = () => conversation.muteSpeaker();
82
- $('write').onclick = () => details(!expanded);
83
- $('collapse').onclick = () => details(false);
84
- $('share').onclick = () => void (sharing || pendingScreen ? screen.stop('屏幕共享已停止') : screen.start()).catch(() => status('屏幕共享已停止'));
298
+ async function readCallTranscript() {
299
+ if (transcriptLoading || !conversation.active || !conversation.callId) return;
300
+ transcriptLoading = true;
301
+ const callId = conversation.callId;
302
+ try {
303
+ const reply = await client.readTranscript();
304
+ if (!conversation.active || conversation.callId !== callId) return;
305
+ callTranscript.load(reply);
306
+ foldedChatUnread.receiveMany(reply.rows);
307
+ callBubble.loadTranscript(reply.rows, conversation.identity?.displayName);
308
+ $('call-transcript-status').textContent = reply.hasMore ? '这里只显示这次通话最近的内容' : '';
309
+ } catch {
310
+ if (conversation.active && conversation.callId === callId) $('call-transcript-status').textContent = '字幕暂未更新 · 实时内容仍会继续显示';
311
+ } finally { transcriptLoading = false; }
312
+ }
313
+ $('share-badge').onclick = toggleScreen;
85
314
  $('history').onclick = () => void client.openConversation().then(result => {
86
- if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看猫猫球聊天');
315
+ if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看');
87
316
  }).catch(error => status(explainError(error)));
88
- $('documents').onclick = () => void conversation.documents(conversation.identity?.documentsAllowed === false);
89
- $('compose').onsubmit = async event => {
90
- event.preventDefault();
91
- const text = $('message').value;
317
+ async function submitMessage(event, inputId) {
318
+ event.preventDefault(); const input = $(inputId); const text = input.value;
92
319
  if (await conversation.send(text)) {
93
- if ($('message').value === text) $('message').value = '';
320
+ motion.signal('received');
321
+ if (input.value === text) input.value = '';
322
+ latestHistory = undefined; void readHistory();
94
323
  }
95
- };
324
+ }
325
+ $('compose').onsubmit = event => submitMessage(event, 'message');
326
+ $('call-compose').onsubmit = event => submitMessage(event, 'call-message');
327
+ $('call-unread').onclick = () => callTranscript.scrollToBottom();
96
328
  const unsubscribe = client.subscribe(event => {
97
- if (event.kind === 'media-stopped') void conversation.releaseLocal('语音已停止 · 点击开始聊天继续');
329
+ if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
330
+ if (event.kind === 'view-dismiss') controls.dismiss();
98
331
  });
99
- const monitor = setInterval(() => void conversation.refresh(), 3000);
100
- void conversation.refresh();
332
+ const statusMonitor = setInterval(() => void conversation.refresh(), 1000);
333
+ const historyMonitor = setInterval(() => void readHistory(), 3000);
334
+ const transcriptMonitor = setInterval(() => void readCallTranscript(), 1000);
335
+ const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000);
336
+ const workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
337
+ void conversation.refresh(); void readHistory(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
101
338
  window.addEventListener('beforeunload', () => {
102
- clearInterval(monitor); unsubscribe();
103
- void conversation.end();
339
+ clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor);
340
+ unsubscribe(); nativeTravel.close(); motion.close(); void conversation.end();
104
341
  });
105
342
  }
@@ -1,7 +1,10 @@
1
+ import { historicalCompanionIdentity } from './companion-identity.mjs';
2
+
1
3
  export class TranscriptView {
2
4
  constructor(container) {
3
5
  this.container = container;
4
6
  this.activeRows = new Map();
7
+ this.historyRows = new Map();
5
8
  this.completed = new Set();
6
9
  }
7
10
  append(role, text, chunk = false) {
@@ -11,30 +14,80 @@ export class TranscriptView {
11
14
  if (!row) {
12
15
  row = this.container.ownerDocument.createElement('p');
13
16
  row.className = role;
17
+ if (chunk) { row.dataset.live = 'true'; row.dataset.author = `实时语音 · ${role === 'user' ? '你' : '猫猫'}`; }
14
18
  this.container.append(row);
15
19
  if (chunk) this.activeRows.set(role, row);
16
20
  }
17
- row.textContent += text;
21
+ row.textContent = (row.textContent + text).slice(-16000);
18
22
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
19
23
  }
20
24
  finish({ role, transcript, turnId }) {
21
25
  if (!['user', 'assistant'].includes(role) || (turnId && this.completed.has(turnId))) return;
22
26
  const pinned = this.nearBottom();
23
- let row = this.activeRows.get(role);
24
- if (!row && transcript) {
25
- this.append(role, '', true);
26
- row = this.activeRows.get(role);
27
- }
28
- if (row && typeof transcript === 'string') row.textContent = transcript;
27
+ // A provider caption is ephemeral. Only Host history can create a saved row.
28
+ this.activeRows.get(role)?.remove();
29
29
  this.activeRows.delete(role);
30
30
  if (turnId) this.completed.add(turnId);
31
+ if (this.completed.size > 256) this.completed.delete(this.completed.values().next().value);
31
32
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
32
33
  }
33
34
  nearBottom() {
34
35
  return this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
35
36
  }
37
+ visibleHistoryAnchor() {
38
+ const viewport = this.container.getBoundingClientRect?.();
39
+ if (!viewport) return null;
40
+ for (const [id, row] of this.historyRows) {
41
+ const rect = row.getBoundingClientRect?.();
42
+ if (rect && rect.bottom > viewport.top && rect.top < viewport.bottom)
43
+ return { id, offset: rect.top - viewport.top };
44
+ }
45
+ return null;
46
+ }
36
47
  reset() {
48
+ for (const row of this.activeRows.values()) row.remove();
37
49
  this.activeRows.clear();
38
50
  this.completed.clear();
39
51
  }
52
+ load(messages) {
53
+ const pinned = this.nearBottom();
54
+ const readingPosition = this.container.scrollTop;
55
+ const anchor = pinned ? null : this.visibleHistoryAnchor();
56
+ const previous = this.historyRows;
57
+ this.historyRows = new Map();
58
+ const rows = messages.map(message => {
59
+ const row = previous.get(message.id) ?? this.container.ownerDocument.createElement('p');
60
+ row.className = message.role; row.dataset.messageId = message.id;
61
+ if (row.textContent !== message.text) row.textContent = message.text;
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
+ }
79
+ this.historyRows.set(message.id, row);
80
+ return row;
81
+ });
82
+ this.container.replaceChildren(...rows, ...this.activeRows.values());
83
+ if (pinned) this.container.scrollTop = this.container.scrollHeight;
84
+ else if (anchor) {
85
+ const row = this.historyRows.get(anchor.id) ?? rows[0];
86
+ const rect = row?.getBoundingClientRect?.();
87
+ const viewport = this.container.getBoundingClientRect?.();
88
+ this.container.scrollTop = rect && viewport
89
+ ? this.container.scrollTop + rect.top - viewport.top - anchor.offset
90
+ : readingPosition;
91
+ } else this.container.scrollTop = readingPosition;
92
+ }
40
93
  }
@@ -0,0 +1,95 @@
1
+ const activityLabel = activity => activity === 'waiting' ? '等待中' : '正在工作';
2
+
3
+ function deliveryMeta(artifact) {
4
+ if (!artifact) return '已交付';
5
+ const version = artifact.version ? `作品 ${artifact.version}` : '作品 · 版本未确认';
6
+ if (artifact.versionState === 'current') return `${version} · 当前版本`;
7
+ if (artifact.versionState === 'superseded') return `${version} · 已有更新版本`;
8
+ return `${version} · 版本关系未确认`;
9
+ }
10
+
11
+ export function workOverviewPresentation(reply) {
12
+ const availableRows = reply?.status !== 'unavailable';
13
+ const active = availableRows ? (reply.active ?? []).map(entry => ({
14
+ entryRef: entry.entryRef,
15
+ title: entry.title,
16
+ meta: entry.actor ? `${entry.actor.displayName} · ${activityLabel(entry.activity)}` : activityLabel(entry.activity),
17
+ })) : [];
18
+ const deliveries = availableRows ? (reply.recentDeliveries ?? []).map(entry => ({
19
+ entryRef: entry.entryRef,
20
+ title: entry.title,
21
+ meta: deliveryMeta(entry.artifact),
22
+ })) : [];
23
+ return {
24
+ visible: active.length > 0 || deliveries.length > 0 || ['partial', 'unavailable'].includes(reply?.status),
25
+ scopeLabel: availableRows && reply?.scope?.label ? `仅当前项目 · ${reply.scope.label}` : '',
26
+ notice: reply?.status === 'partial' ? '仅部分读取' : reply?.status === 'unavailable' ? '暂不可用' : '',
27
+ active,
28
+ deliveries,
29
+ };
30
+ }
31
+
32
+ const navigationText = delivery => delivery === 'applied' ? '已打开原处'
33
+ : delivery === 'queued' || delivery === 'requested' ? '已请求打开原处'
34
+ : delivery === 'blocked' ? '原处暂时无法打开' : '打开原处的结果未确认';
35
+
36
+ /** Renders only bounded Host-issued rows; every navigation target remains opaque. */
37
+ export class WorkOverviewView {
38
+ constructor({ document, client, changed = () => {} }) {
39
+ this.document = document;
40
+ this.client = client;
41
+ this.changed = changed;
42
+ this.$ = id => document.getElementById(id);
43
+ this.loading = false;
44
+ }
45
+
46
+ renderRows(listId, rows) {
47
+ const list = this.$(listId);
48
+ list.replaceChildren();
49
+ for (const row of rows) {
50
+ const button = this.document.createElement('button');
51
+ button.type = 'button';
52
+ button.className = 'work-overview-row';
53
+ const title = this.document.createElement('strong');
54
+ const meta = this.document.createElement('span');
55
+ title.textContent = row.title;
56
+ meta.textContent = row.meta;
57
+ button.append(title, meta);
58
+ button.onclick = () => void this.open(row.entryRef);
59
+ list.append(button);
60
+ }
61
+ }
62
+
63
+ render(reply) {
64
+ const view = workOverviewPresentation(reply);
65
+ this.$('work-overview').hidden = !view.visible;
66
+ this.$('work-scope').textContent = view.scopeLabel;
67
+ this.$('work-notice').textContent = view.notice;
68
+ this.$('work-active-section').hidden = view.active.length === 0;
69
+ this.$('work-delivery-section').hidden = view.deliveries.length === 0;
70
+ this.renderRows('work-active-list', view.active);
71
+ this.renderRows('work-delivery-list', view.deliveries);
72
+ this.changed();
73
+ }
74
+
75
+ async open(entryRef) {
76
+ try {
77
+ const reply = await this.client.openWork(entryRef);
78
+ this.$('work-notice').textContent = navigationText(reply.delivery);
79
+ } catch {
80
+ this.$('work-notice').textContent = '原处暂时无法打开';
81
+ }
82
+ }
83
+
84
+ async load() {
85
+ if (this.loading) return;
86
+ this.loading = true;
87
+ try {
88
+ this.render(await this.client.readWork());
89
+ } catch {
90
+ this.render({ status: 'unavailable', active: [], recentDeliveries: [] });
91
+ } finally {
92
+ this.loading = false;
93
+ }
94
+ }
95
+ }