@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
@@ -4,110 +4,367 @@ 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
+ 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';
7
13
  import { bindPetControls } from './pet-controls.mjs';
8
-
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';
21
+ import { MeetingStatus, renderMeetingStatus } from './meeting-state.mjs';
22
+ import { VisibleTextPresentation } from './visible-text-receipt.mjs';
23
+ import { ConversationSurface } from './conversation-surface.mjs';
24
+ import { CallControlsPresentation } from './call-controls-presentation.mjs';
25
+ import { CallDuration } from './call-duration.mjs';
26
+ import { AppAssistController } from './app-assist-controller.mjs';
9
27
  const $ = id => document.getElementById(id);
10
28
  const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
11
- const status = text => { $('status').textContent = text; $('chat-status').textContent = text; $('announcement').textContent = text; };
12
- const transcript = new TranscriptView($('transcript'));
13
- let sharing = false, pendingScreen = false, loading = false, latestHistory, previousPhase = 'idle';
14
-
29
+ const status = text => {
30
+ $('status').textContent = text;
31
+ $('menu-status').textContent = text;
32
+ $('announcement').textContent = text;
33
+ };
34
+ const transcript = new TranscriptView($('chat-transcript'));
35
+ const callTranscript = new CallTranscriptView($('call-transcript-log'), {
36
+ onUnread: count => {
37
+ $('call-unread').hidden = count === 0;
38
+ $('call-unread').textContent = count ? `${count} 条新消息` : '';
39
+ },
40
+ });
41
+ const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
42
+ const callBubble = new RecentBubble([$('call-bubble-first'), $('call-bubble-second')], { showSpeaker: false });
43
+ const callDuration = new CallDuration($('call-duration'));
44
+ const meetingStatus = new MeetingStatus();
45
+ const foldedChatUnread = new FoldedChatUnread(count => {
46
+ $('call-chat-unread').hidden = count === 0;
47
+ $('call-chat-unread').textContent = count ? String(count) : '';
48
+ $('call-chat').setAttribute('aria-label', count ? `聊天,${count} 条未读` : '聊天');
49
+ });
50
+ const motion = new PetMotion($('pet'), { livingBody: new LivingBody({ root: $('pet'), sit: $('living-sit'),
51
+ video: $('living-video'), transitionVideo: $('living-video-transition') }) });
52
+ const nativeTravel = new NativeWindowTravel({
53
+ readPosition: () => ({ x: window.screenX, y: window.screenY }),
54
+ changed: value => motion.syncTravel(value),
55
+ });
56
+ let sharing = false, pendingScreen = false, transcriptLoading = false, previousPhase = 'idle';
57
+ let appAssistOffer = null;
58
+ let didMove = false, settingsView, settingsValues, decisionPanel, workView, callControlsPresentation;
59
+ let previousControlPanel = 'none';
60
+ // F229 stays quiet: a history ID change has no provenance for proactive text.
61
+ const ambientPanel = active => active ? 'actions' : appAssistOffer ? 'app-assist' : 'none';
62
+ const setTexts = (ids, text) => { for (const id of ids) $(id).textContent = text; };
63
+ const setHidden = (ids, hidden) => { for (const id of ids) $(id).hidden = hidden; };
15
64
  if (!window.clowderCompanion) {
16
65
  $('actions').hidden = false; $('status').hidden = false;
17
66
  status('请从 Clowder 的聊聊入口打开猫猫球');
18
67
  document.querySelectorAll('button').forEach(button => { button.disabled = true; });
19
68
  } else {
69
+ nativeTravel.start();
20
70
  const client = createCompanionClient(window.clowderCompanion);
71
+ let conversationSurface;
72
+ const visibleText = new VisibleTextPresentation({
73
+ document,
74
+ report: observation => client.observePresentation(observation),
75
+ transcript, callTranscript, bubble, callBubble,
76
+ });
21
77
  const controls = bindPetControls(client, {
22
- error: error => status(explainError(error)),
23
- changed: panel => { if (panel === 'chat') void readHistory(); },
78
+ activate: () => { const handled = callControlsPresentation?.activate() ?? false; if (handled) void controls.show('actions'); return handled; },
79
+ escape: panel => callControlsPresentation?.escape(panel) ?? false,
80
+ error: (error, context) => status(context?.panel === 'settings' && error?.code === 'invalid_request' ? '这个 Host 版本还不支持猫旁设置。原来的设置入口会继续保留。' : explainError(error)),
81
+ moved: (dx, dy) => {
82
+ if (dx !== 'stop') { didMove = true; nativeTravel.setManual(true); motion.move(dx, dy); return; }
83
+ nativeTravel.setManual(false);
84
+ motion.stopMove();
85
+ if (!didMove) return;
86
+ didMove = false;
87
+ void conversation.refresh();
88
+ setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
89
+ },
90
+ changed: panel => {
91
+ callControlsPresentation?.setPanel(panel);
92
+ visibleText.retry();
93
+ if (previousControlPanel === 'chat' && panel !== 'chat') conversationSurface.closeHistory();
94
+ if (panel === 'chat' && conversationSurface.controller.mode === 'idle') conversationSurface.openHistory();
95
+ if (panel === 'chat') foldedChatUnread.open();
96
+ else if (previousControlPanel === 'chat' && conversation?.active) foldedChatUnread.fold();
97
+ previousControlPanel = panel;
98
+ if (panel === 'chat') void conversationSurface.readHistory();
99
+ if (panel === 'transcript') void readCallTranscript();
100
+ if (panel === 'decisions') void decisionPanel?.read();
101
+ if (panel === 'settings') void settingsView?.open();
102
+ if (panel === 'menu') void workView?.load();
103
+ },
24
104
  action(kind) {
25
- if (kind === 'begin') { transcript.reset(); void conversation.begin(); void controls.show('none'); }
105
+ if (kind === 'write') {
106
+ conversationSurface.openWrite(conversation.active);
107
+ }
108
+ if (kind === 'history') conversationSurface.openHistory();
109
+ if (kind === 'begin') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin(); void controls.show('none'); }
110
+ if (kind === 'listen') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin('receive_only'); void controls.show('none'); }
26
111
  if (kind === 'stop') void conversation.end();
27
112
  if (kind === 'mute') conversation.muteMic();
28
113
  if (kind === 'speaker') conversation.muteSpeaker();
29
114
  if (kind === 'share') toggleScreen();
30
115
  if (kind === 'documents') void conversation.documents(conversation.identity?.documentsAllowed === false);
31
116
  if (kind === 'hide') void client.hide().catch(error => status(explainError(error)));
117
+ if (kind === 'size-toggle') {
118
+ if (!settingsValues) { status('大小设置仍在读取'); return; }
119
+ void settingsView.controller.requestUpdate('ballSize', compactSizeAction(settingsValues.ballSize).value);
120
+ }
121
+ if (kind === 'transcript-return') void controls.show('actions');
122
+ if (kind === 'transcript-expand') {
123
+ const expanded = $('transcript').dataset.expanded !== 'true';
124
+ $('transcript').dataset.expanded = String(expanded);
125
+ setTexts(['transcript-expand-label'], expanded ? '收起' : '展开');
126
+ void controls.show('transcript');
127
+ }
128
+ if (kind === 'play' && conversation.identity?.skin === 'xianxian-codex') {
129
+ void controls.show('none');
130
+ motion.signal('play');
131
+ }
132
+ },
133
+ });
134
+ callControlsPresentation = new CallControlsPresentation({
135
+ document, root: $('actions'), pet: $('pet'), input: $('lite-call-message'),
136
+ composers: [{ form: $('call-compose'), input: $('call-message') },
137
+ { form: $('lite-call-compose'), input: $('lite-call-message') }],
138
+ send: text => conversation.send(text),
139
+ accepted: () => { motion.signal('received'); void conversationSurface.readHistory(); },
140
+ changed: () => controls.remeasure(),
141
+ });
142
+ const appAssist = new AppAssistController({
143
+ client,
144
+ changed: value => {
145
+ appAssistOffer = value.offer;
146
+ $('app-assist-name').textContent = appAssistOffer?.appName ?? '';
147
+ void controls.setAmbient(ambientPanel(previousPhase !== 'idle'));
148
+ },
149
+ accepted: () => {
150
+ status('提示已收起。请先点“语音通话”,连上后再点“共享画面”。');
151
+ $('menu-status').hidden = false;
152
+ void controls.show('menu');
32
153
  },
33
154
  });
34
155
  const screen = new ScreenShare({
35
- screenRequest: () => client.screenPick(),
36
- screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
156
+ screenRequest: () => client.screenPick(), screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
37
157
  screenFrame: (selectionId, frame) => client.screenFrame(selectionId, {
38
158
  ...frame, frameId: crypto.randomUUID(), sourceLabel: '所选画面', observedAt: Date.now(),
39
159
  }), screenStop: () => client.screenClose(),
40
160
  }, value => {
161
+ const wasActive = sharing || pendingScreen;
41
162
  sharing = value.sharing; pendingScreen = value.pending === true;
42
- for (const id of ['share', 'menu-share']) label(id, sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享画面');
163
+ callControlsPresentation.setMediaLocked(sharing || pendingScreen);
164
+ for (const id of ['share', 'menu-share', 'menu-call-share', 'chat-share', 'settings-share']) label(id, sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享画面');
165
+ for (const prefix of ['call', 'menu', 'chat', 'transcript', 'settings']) {
166
+ $(`${prefix}-share-context`).hidden = !sharing;
167
+ $(`${prefix}-share-target`).textContent = sharing ? `共享中:${value.label}` : '';
168
+ }
43
169
  $('share-badge').hidden = !sharing && !pendingScreen;
44
170
  $('share-badge').title = sharing ? `正在共享:${value.label},点此停止` : '正在选屏,点此取消';
171
+ $('share-badge').setAttribute('aria-label', sharing ? `停止共享:${value.label}` : '取消选屏');
172
+ if (value.message && value.message !== '未共享屏幕' && (wasActive || pendingScreen)) status(value.message);
45
173
  });
46
174
  function toggleScreen() {
175
+ if (conversation.phase !== 'talking') {
176
+ status('先开始语音通话,连上后可选择窗口或屏幕');
177
+ $('status').hidden = false;
178
+ void controls.show('actions');
179
+ return;
180
+ }
47
181
  void (sharing || pendingScreen ? screen.stop('屏幕共享已停止') : screen.start()).catch(error => status(explainError(error)));
48
182
  }
49
183
  const conversation = new CompanionConversation({
50
184
  client, createPeer: callback => new VoicePeer(callback, client), stopScreen: () => screen.stop(),
51
- transcript: event => event.type === 'turn-done' ? transcript.finish(event) : transcript.append(event.role, event.text, !event.typed),
185
+ delivery: ({ text, receipt }) => conversationSurface.controller.recordDelivery(receipt, text),
186
+ transcript: event => {
187
+ if (event.type === 'turn-done') {
188
+ transcript.finish(event); callTranscript.finish(event); bubble.finish(event.role); callBubble.finish(event.role);
189
+ void conversationSurface.readHistory(); void readCallTranscript();
190
+ } else if (event.type === 'typed') {
191
+ callTranscript.appendTyped(event);
192
+ void conversationSurface.readHistory(); void readCallTranscript();
193
+ } else {
194
+ const speaker = event.role === 'assistant' ? conversation.identity?.displayName ?? '猫猫' : '语音';
195
+ visibleText.appendLive(event, speaker);
196
+ void controls.setAmbient('actions');
197
+ }
198
+ },
52
199
  render(value) {
53
200
  const showFailure = value.failed && previousPhase !== 'idle' && value.phase === 'idle';
201
+ const justConnected = previousPhase !== 'talking' && value.phase === 'talking';
202
+ if (previousPhase !== 'idle' && value.phase === 'idle') {
203
+ transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversationSurface.readHistory();
204
+ foldedChatUnread.reset();
205
+ if (controls.panel === 'transcript') void controls.show('menu');
206
+ }
54
207
  previousPhase = value.phase;
55
208
  const active = value.phase !== 'idle';
209
+ const receiveOnly = value.audioMode === 'receive_only';
210
+ const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
211
+ const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
212
+ visibleText.setCall(value.callId);
213
+ callDuration.set(value.connectedAt);
214
+ callControlsPresentation.setActive(active);
215
+ if (value.identity?.appAssist) appAssist.consume(value.identity.appAssist);
216
+ void controls.setAmbient(ambientPanel(active));
56
217
  $('begin').hidden = active; $('begin').disabled = !value.identity;
57
- label('begin', value.failed ? '重试语音' : '语音聊');
58
- $('mic').hidden = value.phase !== 'talking'; $('speaker').hidden = !active;
218
+ $('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
219
+ label('begin', value.failed && !retryReceiveOnly ? '重试语音通话' : '语音通话');
220
+ label('listen', retryReceiveOnly ? '重试只听' : '只听');
221
+ $('begin').className = retryReceiveOnly ? '' : 'primary';
222
+ $('listen').className = retryReceiveOnly ? 'primary' : '';
223
+ $('mic').hidden = value.phase !== 'talking' || receiveOnly; $('speaker').hidden = !active;
224
+ $('chat-mic').hidden = value.phase !== 'talking' || receiveOnly;
225
+ $('menu-mic').hidden = value.phase !== 'talking' || receiveOnly;
226
+ $('transcript-mic').hidden = value.phase !== 'talking' || receiveOnly;
227
+ $('settings-mic').hidden = value.phase !== 'talking' || receiveOnly;
59
228
  $('active-actions').hidden = !active;
60
- for (const id of ['share', 'menu-share']) $(id).disabled = value.phase !== 'talking';
229
+ $('share').disabled = value.phase !== 'talking';
230
+ $('menu-call-share').disabled = value.phase !== 'talking';
61
231
  $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
232
+ callControlsPresentation.setComposerState(value.identity, value.phase === 'connecting');
62
233
  status(value.message ?? '');
63
- $('status').hidden = !value.message || /^(点|正在听|语音已结束|已发送)/.test(value.message);
64
- $('call-badge').hidden = !active;
65
- $('call-badge').dataset.state = value.muted ? 'muted' : value.phase;
66
- const callLabel = value.phase === 'connecting' ? '正在连接,点此取消' : value.muted ? '麦克风已静音,点此结束' : '语音进行中,点此结束';
67
- $('call-badge').title = callLabel; $('call-badge').setAttribute('aria-label', callLabel);
234
+ $('status').hidden = !value.message || /^(点击|正在听|麦克风已静音|只听模式|只听已结束|语音通话已结束|已发送)/.test(value.message);
235
+ $('menu-status').hidden = !value.message;
236
+ const callState = value.recovering ? '连接不稳定' : value.phase === 'connecting' ? '正在连接'
237
+ : receiveOnly ? '只听中' : active ? '通话中' : '';
238
+ const microphoneState = receiveOnly ? '麦克风未启用'
239
+ : value.phase === 'connecting' ? '正在请求麦克风'
240
+ : value.recovering ? `正在等待原连接恢复 · ${value.muted ? '麦克风已静音' : '麦克风已开启'}`
241
+ : value.muted ? '麦克风已静音' : active ? '麦克风已开启' : '';
242
+ const connectionState = value.recovering ? 'recovering' : value.phase === 'talking' ? 'connected' : value.phase === 'connecting' ? 'connecting' : 'idle';
243
+ for (const id of ['actions', 'chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context']) $(id).dataset.connectionState = connectionState;
244
+ setTexts(['call-state', 'chat-call-state', 'transcript-call-state', 'menu-call-state', 'settings-call-state'], callState);
245
+ setTexts(['call-mic-state', 'chat-call-mic-state', 'transcript-call-mic-state', 'menu-call-mic-state', 'settings-call-mic-state'], microphoneState);
246
+ setHidden(['chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context'], !active);
68
247
  label('mic', value.muted ? '取消静音' : '静音'); $('mic').setAttribute('aria-pressed', String(value.muted));
69
- label('speaker', value.silent ? '开启播音' : '关闭播音');
248
+ $('mic').setAttribute('aria-label', value.muted ? '取消静音' : '静音');
249
+ $('mic-icon').setAttribute('href', value.muted ? '#i-mic-off' : '#i-mic');
250
+ $('chat-mic').textContent = value.muted ? '取消静音' : '静音';
251
+ $('menu-mic').setAttribute('aria-label', value.muted ? '取消静音' : '静音');
252
+ $('menu-mic').title = value.muted ? '取消静音' : '静音';
253
+ $('menu-mic').setAttribute('aria-pressed', String(value.muted));
254
+ $('menu-mic-icon').setAttribute('href', value.muted ? '#i-mic-off' : '#i-mic');
255
+ $('settings-mic').textContent = value.muted ? '取消静音' : '静音';
256
+ $('transcript-mic').setAttribute('aria-label', value.muted ? '取消静音' : '静音');
257
+ $('transcript-mic').setAttribute('aria-pressed', String(value.muted));
258
+ $('transcript-mic-icon').setAttribute('href', value.muted ? '#i-mic-off' : '#i-mic');
259
+ label('speaker', '播音');
260
+ $('speaker').setAttribute('aria-pressed', String(!value.silent));
261
+ $('speaker').title = value.silent ? '开启播音' : '关闭播音';
262
+ $('name-card-actions').hidden = active;
263
+ $('menu-hide-label').textContent = active ? '结束通话并隐藏' : '隐藏';
70
264
  const identity = value.identity;
265
+ const companionIdentity = currentCompanionIdentity(identity);
266
+ renderMeetingStatus(document, meetingStatus.update(active ? identity?.meeting : null));
267
+ const nativeSnapshot = normalizeNativeWork(identity?.nativeWork);
268
+ const workLabel = nativeWorkLabel(nativeSnapshot, identity?.nativeActivity), workState = workLabel ? `${identity.displayName}${workLabel}` : '';
269
+ setTexts(['call-work', 'chat-call-work', 'transcript-call-work', 'menu-call-work', 'settings-call-work'], workState);
270
+ setHidden(['call-work', 'chat-call-work', 'transcript-call-work', 'menu-call-work', 'settings-call-work'], !workState);
271
+ motion.setContext({ skin: identity?.skin, phase: value.phase,
272
+ nativeActivity: identity?.nativeActivity ?? 'none', muted: value.muted || receiveOnly });
273
+ motion.setBehaviorEnabled(identity?.behaviorEnabled === true);
274
+ motion.syncNativeSnapshot(nativeSnapshot);
275
+ if (justConnected) motion.signal('connected');
276
+ if (showFailure) motion.signal('failed');
71
277
  if (identity) {
72
- $('chat-name').textContent = identity.displayName; $('menu-name').textContent = identity.displayName;
73
- $('pet').setAttribute('aria-label', `${identity.displayName}:点击交流,右键更多,拖动移动`);
74
- $('pet').dataset.skin = identity.skin;
278
+ $('menu-play').hidden = identity.skin !== 'xianxian-codex';
279
+ const companionLabel = companionIdentity
280
+ ? `${companionIdentity.title} · ${companionIdentity.partnerLabel}` : identity.displayName;
281
+ $('chat-name').textContent = conversationSurface?.threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
282
+ $('message').placeholder = `发消息给${identity.displayName}…`;
283
+ $('pet').setAttribute('aria-label', active ? `${companionLabel}:指向或点击显示通话控制,右键打开名片,拖动移动`
284
+ : `${companionLabel}:点击或右键打开名片,拖动移动`);
75
285
  $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
76
286
  $('documents').setAttribute('aria-pressed', String(identity.documentsAllowed));
77
- $('connection-scope').textContent = identity.duty.catId === identity.carrier.catId ? '交流保存在同一段聊天中' : `${identity.duty.displayName} · 实时语音由${identity.carrier.displayName}承载`;
287
+ if (companionIdentity) {
288
+ $('connection-scope').textContent = `${companionIdentity.partnerLabel} · ${companionIdentity.liveLabel} · ${companionIdentity.deepLabel}`;
289
+ } else {
290
+ const live = identity.liveTransport?.kind === 'gpt_live_v3'
291
+ ? 'GPT Live · 实时型号未证实' : '实时载体未确认';
292
+ $('connection-scope').textContent = identity.duty.catId === identity.carrier.catId
293
+ ? `${identity.duty.displayName} · ${live}`
294
+ : `${identity.duty.displayName} · ${live} · 绑定载体 ${identity.carrier.displayName}`;
295
+ }
78
296
  }
79
- if (showFailure) void controls.show('actions');
297
+ if (showFailure) void controls.show('menu');
80
298
  },
81
299
  });
82
- async function readHistory() {
83
- if (loading || conversation.active || controls.panel !== 'chat') return;
84
- loading = true;
300
+ $('app-assist-dismiss').onclick = () => { void appAssist.settle('dismiss'); };
301
+ $('app-assist-start').onclick = () => { void appAssist.settle('accept'); };
302
+ conversationSurface = new ConversationSurface({
303
+ document, client, controls, bubble, transcript,
304
+ onAccepted: () => motion.signal('received'),
305
+ retryLegacy: text => conversation.send(text),
306
+ status,
307
+ scheduleInterval: setInterval,
308
+ cancelInterval: clearInterval,
309
+ });
310
+ settingsView = new SettingsView({
311
+ document,
312
+ client,
313
+ close: () => void controls.show('menu'),
314
+ callActive: () => conversation.active,
315
+ expectHostStop: () => conversation.expectHostStop(),
316
+ settleExpectedHostStop: callStopped => conversation.settleExpectedHostStop(callStopped),
317
+ layoutChanged: () => { if (controls.panel === 'settings') controls.remeasure?.(); },
318
+ onValues(values) {
319
+ const sizeChanged = settingsValues !== undefined && settingsValues.ballSize !== values.ballSize;
320
+ settingsValues = values;
321
+ document.documentElement?.style?.setProperty?.('--pet-scale', String(values.ballSize / 72));
322
+ $('anchor').dataset.ballSize = String(values.ballSize);
323
+ $('anchor').dataset.compact = String(values.ballSize <= COMPACT_BALL_SIZE);
324
+ label('menu-size-toggle', compactSizeAction(values.ballSize).label);
325
+ motion.setBehaviorEnabled(values.behaviorEnabled);
326
+ $('documents').hidden = true;
327
+ decisionPanel?.refreshBadge();
328
+ if (sizeChanged) void controls.show(controls.panel);
329
+ },
330
+ });
331
+ workView = new WorkOverviewView({
332
+ document,
333
+ client,
334
+ changed: () => { if (controls.panel === 'menu') controls.remeasure?.(); },
335
+ });
336
+ decisionPanel = new DecisionPanel({
337
+ document,
338
+ client,
339
+ controls,
340
+ motion,
341
+ proactivePolicy: () => settingsValues?.proactivePolicy,
342
+ });
343
+ async function readCallTranscript() {
344
+ if (transcriptLoading || !conversation.active || !conversation.callId) return;
345
+ transcriptLoading = true;
346
+ const callId = conversation.callId;
85
347
  try {
86
- const history = await client.readConversation();
87
- if (conversation.active) return;
88
- $('history').textContent = history.hasMore ? '更早聊天 ↗' : '完整聊天 ↗';
89
- const serialized = JSON.stringify(history.messages);
90
- if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
91
- } catch (error) { status(explainError(error)); }
92
- finally { loading = false; }
348
+ const reply = await client.readTranscript();
349
+ if (!conversation.active || conversation.callId !== callId) return;
350
+ if (!visibleText.loadTranscript(callId, reply, conversation.identity?.displayName)) return;
351
+ foldedChatUnread.receiveMany(reply.rows);
352
+ $('call-transcript-status').textContent = reply.hasMore ? '这里只显示这次通话最近的内容' : '';
353
+ } catch {
354
+ if (conversation.active && conversation.callId === callId) $('call-transcript-status').textContent = '字幕暂未更新 · 实时内容仍会继续显示';
355
+ } finally { transcriptLoading = false; }
93
356
  }
94
- $('call-badge').onclick = () => void conversation.end();
95
357
  $('share-badge').onclick = toggleScreen;
96
- $('history').onclick = () => void client.openConversation().then(result => {
97
- if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看');
98
- }).catch(error => status(explainError(error)));
99
- $('compose').onsubmit = async event => {
100
- event.preventDefault(); const text = $('message').value;
101
- if (await conversation.send(text)) {
102
- if ($('message').value === text) $('message').value = '';
103
- latestHistory = undefined; void readHistory();
104
- }
105
- };
358
+ $('call-unread').onclick = () => callTranscript.scrollToBottom();
106
359
  const unsubscribe = client.subscribe(event => {
107
- if (event.kind === 'media-stopped') void conversation.releaseLocal('语音已停止');
360
+ if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
108
361
  if (event.kind === 'view-dismiss') controls.dismiss();
109
362
  });
110
- const monitor = setInterval(() => { void conversation.refresh(); void readHistory(); }, 3000);
111
- void conversation.refresh(); void controls.show('none');
112
- window.addEventListener('beforeunload', () => { clearInterval(monitor); unsubscribe(); void conversation.end(); });
363
+ const statusMonitor = setInterval(() => void conversation.refresh(), 1000), transcriptMonitor = setInterval(() => void readCallTranscript(), 1000);
364
+ const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000), workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
365
+ void conversation.refresh(); conversationSurface.start(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
366
+ window.addEventListener('beforeunload', () => {
367
+ clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor); conversationSurface.close(); callControlsPresentation.close(); callDuration.close();
368
+ unsubscribe(); appAssist.close(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
369
+ });
113
370
  }
@@ -1,8 +1,13 @@
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();
9
+ this.savedHistoryViewport = null;
10
+ this.pendingHistoryEntry = null;
6
11
  }
7
12
  append(role, text, chunk = false) {
8
13
  const pinned = this.nearBottom();
@@ -11,41 +16,138 @@ export class TranscriptView {
11
16
  if (!row) {
12
17
  row = this.container.ownerDocument.createElement('p');
13
18
  row.className = role;
19
+ if (chunk) { row.dataset.live = 'true'; row.dataset.author = `实时语音 · ${role === 'user' ? '你' : '猫猫'}`; }
14
20
  this.container.append(row);
15
21
  if (chunk) this.activeRows.set(role, row);
16
22
  }
17
- row.textContent += text;
23
+ row.textContent = (row.textContent + text).slice(-16000);
18
24
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
25
+ return row;
19
26
  }
20
27
  finish({ role, transcript, turnId }) {
21
28
  if (!['user', 'assistant'].includes(role) || (turnId && this.completed.has(turnId))) return;
22
29
  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;
30
+ // A provider caption is ephemeral. Only Host history can create a saved row.
31
+ this.activeRows.get(role)?.remove();
29
32
  this.activeRows.delete(role);
30
33
  if (turnId) this.completed.add(turnId);
34
+ if (this.completed.size > 256) this.completed.delete(this.completed.values().next().value);
31
35
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
32
36
  }
33
37
  nearBottom() {
34
- return this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
38
+ return !this.container.hidden
39
+ && this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
40
+ }
41
+ visibleHistoryAnchor() {
42
+ const viewport = this.container.getBoundingClientRect?.();
43
+ if (!viewport) return null;
44
+ for (const [id, row] of this.historyRows) {
45
+ const rect = row.getBoundingClientRect?.();
46
+ if (rect && rect.bottom > viewport.top && rect.top < viewport.bottom)
47
+ return { id, offset: rect.top - viewport.top };
48
+ }
49
+ return null;
50
+ }
51
+ restoreHistoryAnchor(anchor, readingPosition, useFirstAvailable = false) {
52
+ const row = this.historyRows.get(anchor?.id)
53
+ ?? (useFirstAvailable ? this.historyRows.values().next().value : null);
54
+ const rect = row?.getBoundingClientRect?.();
55
+ const viewport = this.container.getBoundingClientRect?.();
56
+ if (!rect || !viewport || viewport.bottom <= viewport.top) {
57
+ this.container.scrollTop = readingPosition;
58
+ return false;
59
+ }
60
+ this.container.scrollTop += rect.top - viewport.top - anchor.offset;
61
+ return true;
62
+ }
63
+ revealHistoryRow(id) {
64
+ const row = this.historyRows.get(id);
65
+ const rect = row?.getBoundingClientRect?.();
66
+ const viewport = this.container.getBoundingClientRect?.();
67
+ if (!rect || !viewport || viewport.bottom <= viewport.top) return false;
68
+ if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) return true;
69
+ const viewportHeight = viewport.bottom - viewport.top;
70
+ const rowHeight = Math.max(0, rect.bottom - rect.top);
71
+ const offset = Math.max(0, (viewportHeight - rowHeight) / 2);
72
+ this.container.scrollTop += rect.top - viewport.top - offset;
73
+ return true;
74
+ }
75
+ placeHistoryEntry(afterLoad) {
76
+ if (!this.pendingHistoryEntry || this.container.hidden) return false;
77
+ const viewport = this.pendingHistoryEntry;
78
+ if (viewport.anchor && this.restoreHistoryAnchor(viewport.anchor, viewport.scrollTop)) {
79
+ this.pendingHistoryEntry = null;
80
+ return true;
81
+ }
82
+ if (viewport.selectedMessageId && this.revealHistoryRow(viewport.selectedMessageId)) {
83
+ this.pendingHistoryEntry = null;
84
+ return true;
85
+ }
86
+ const bounds = this.container.getBoundingClientRect?.();
87
+ if (!afterLoad || !bounds || bounds.bottom <= bounds.top) return false;
88
+ this.container.scrollTop = this.container.scrollHeight;
89
+ this.pendingHistoryEntry = null;
90
+ return true;
91
+ }
92
+ leaveHistory(selectedMessageId) {
93
+ this.savedHistoryViewport = {
94
+ selectedMessageId,
95
+ anchor: this.visibleHistoryAnchor(),
96
+ scrollTop: this.container.scrollTop,
97
+ };
98
+ this.pendingHistoryEntry = null;
99
+ }
100
+ enterHistory(selectedMessageId) {
101
+ const saved = this.savedHistoryViewport?.selectedMessageId === selectedMessageId
102
+ ? this.savedHistoryViewport : null;
103
+ this.pendingHistoryEntry = {
104
+ selectedMessageId,
105
+ anchor: saved?.anchor ?? null,
106
+ scrollTop: saved?.scrollTop ?? this.container.scrollTop,
107
+ };
108
+ this.placeHistoryEntry(false);
35
109
  }
36
110
  reset() {
111
+ for (const row of this.activeRows.values()) row.remove();
37
112
  this.activeRows.clear();
38
113
  this.completed.clear();
39
114
  }
40
115
  load(messages) {
41
- const pinned = this.nearBottom();
42
- this.reset();
43
- this.container.replaceChildren(...messages.map(message => {
44
- const row = this.container.ownerDocument.createElement('p');
116
+ const hidden = this.container.hidden;
117
+ const pinned = !hidden && this.nearBottom();
118
+ const readingPosition = this.container.scrollTop;
119
+ const anchor = hidden || pinned ? null : this.visibleHistoryAnchor();
120
+ const previous = this.historyRows;
121
+ this.historyRows = new Map();
122
+ const rows = messages.map(message => {
123
+ const row = previous.get(message.id) ?? this.container.ownerDocument.createElement('p');
45
124
  row.className = message.role; row.dataset.messageId = message.id;
46
- row.textContent = message.text; row.title = message.name;
125
+ if (row.textContent !== message.text) row.textContent = message.text;
126
+ const identity = historicalCompanionIdentity(message.companionIdentity, message.name);
127
+ row.dataset.author = identity.authorLabel;
128
+ if (identity.companionKnown) {
129
+ row.dataset.companion = identity.partnerLabel;
130
+ row.dataset.companionLive = identity.liveLabel;
131
+ row.dataset.companionDeep = identity.deepLabel;
132
+ row.dataset.companionAvatar = identity.avatarCatId;
133
+ row.dataset.companionSkin = identity.skin;
134
+ row.title = [identity.authorLabel, identity.partnerLabel, identity.liveLabel, identity.deepLabel].join('\n');
135
+ row.setAttribute('aria-label', [identity.authorLabel, message.text,
136
+ identity.partnerLabel, identity.liveLabel, identity.deepLabel].join('。'));
137
+ } else {
138
+ for (const key of ['companion', 'companionLive', 'companionDeep', 'companionAvatar', 'companionSkin'])
139
+ delete row.dataset[key];
140
+ row.title = identity.authorLabel;
141
+ row.removeAttribute?.('aria-label');
142
+ }
143
+ this.historyRows.set(message.id, row);
47
144
  return row;
48
- }));
49
- if (pinned) this.container.scrollTop = this.container.scrollHeight;
145
+ });
146
+ this.container.replaceChildren(...rows, ...this.activeRows.values());
147
+ if (hidden) this.container.scrollTop = readingPosition;
148
+ else if (this.placeHistoryEntry(true)) return;
149
+ else if (pinned) this.container.scrollTop = this.container.scrollHeight;
150
+ else if (anchor) this.restoreHistoryAnchor(anchor, readingPosition, true);
151
+ else this.container.scrollTop = readingPosition;
50
152
  }
51
153
  }