@clowder-ai/companion 0.1.0-alpha.1 → 0.1.0-alpha.10

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 (48) hide show
  1. package/NOTICE +10 -2
  2. package/README.md +48 -0
  3. package/manifest.json +5 -5
  4. package/package.json +17 -6
  5. package/plugin.yaml +5 -5
  6. package/renderer/client.mjs +6 -0
  7. package/renderer/conversation.mjs +34 -17
  8. package/renderer/decision-view.mjs +30 -0
  9. package/renderer/index.html +84 -26
  10. package/renderer/living-body.mjs +172 -0
  11. package/renderer/living-edge.mjs +16 -0
  12. package/renderer/motion-facts.mjs +79 -0
  13. package/renderer/native-work-motion.mjs +24 -0
  14. package/renderer/pet-controls.mjs +84 -0
  15. package/renderer/pet-motion.mjs +350 -0
  16. package/renderer/recent-bubble.mjs +39 -0
  17. package/renderer/skins/ragdoll-v1.png +0 -0
  18. package/renderer/skins/xianxian-codex.webp +0 -0
  19. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  20. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  21. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  22. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  23. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  24. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  25. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  26. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  27. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  28. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  29. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  30. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  31. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  32. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  33. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  34. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  35. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  36. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  37. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  38. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  39. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  40. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  41. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  42. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  43. package/renderer/skins/xianxian-working-row.png +0 -0
  44. package/renderer/skins/yarn-ball.png +0 -0
  45. package/renderer/style.css +114 -308
  46. package/renderer/surface.mjs +189 -62
  47. package/renderer/transcript-view.mjs +42 -7
  48. package/source-lock.json +138 -4
@@ -4,102 +4,229 @@ 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 { RecentBubble } from './recent-bubble.mjs';
8
+ import { PetMotion } from './pet-motion.mjs';
9
+ import { LivingBody } from './living-body.mjs';
10
+ import { detectDockedEdge } from './living-edge.mjs';
11
+ import { bindPetControls } from './pet-controls.mjs';
12
+ import { decisionBadge, decisionRows } from './decision-view.mjs';
13
+ import { normalizeNativeWork } from './native-work-motion.mjs';
7
14
 
8
15
  const $ = id => document.getElementById(id);
9
- const status = text => { $('status').textContent = text; };
16
+ const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
17
+ const status = text => { $('status').textContent = text; $('chat-status').textContent = text; $('announcement').textContent = text; };
10
18
  const transcript = new TranscriptView($('transcript'));
11
- let expanded = false;
12
- let sharing = false;
13
- let pendingScreen = false;
19
+ const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
20
+ const motion = new PetMotion($('pet'), { livingBody: new LivingBody({ root: $('pet'), sit: $('living-sit'),
21
+ video: $('living-video'), transitionVideo: $('living-video-transition') }) });
22
+ let sharing = false, pendingScreen = false, loading = false, latestHistory, previousPhase = 'idle';
23
+ let observedHistory = false, lastAssistantId;
24
+ let resultPreviewUntil = 0;
25
+ let didMove = false;
26
+ let decisionLoading = false, decisionOffset = 0;
27
+ let threadTitle;
28
+ const showAmbient = active => (active || Date.now() < resultPreviewUntil) && bubble.hasContent();
14
29
 
15
30
  if (!window.clowderCompanion) {
16
- status('请从 Clowder 的插件页面打开猫猫球');
17
- for (const id of ['begin', 'documents', 'history']) $(id).disabled = true;
31
+ $('actions').hidden = false; $('status').hidden = false;
32
+ status('请从 Clowder 的聊聊入口打开猫猫球');
33
+ document.querySelectorAll('button').forEach(button => { button.disabled = true; });
18
34
  } else {
19
35
  const client = createCompanionClient(window.clowderCompanion);
36
+ const controls = bindPetControls(client, {
37
+ error: error => status(explainError(error)),
38
+ moved: (dx, dy) => {
39
+ if (dx !== 'stop') { didMove = true; motion.move(dx, dy); return; }
40
+ motion.stopMove();
41
+ if (!didMove) return;
42
+ didMove = false;
43
+ void conversation.refresh();
44
+ setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
45
+ },
46
+ changed: panel => { if (panel === 'chat') void readHistory(); if (panel === 'decisions') void readDecisions(); },
47
+ action(kind) {
48
+ if (kind === 'begin') { transcript.reset(); bubble.reset(); void conversation.begin(); void controls.show('none'); }
49
+ if (kind === 'stop') void conversation.end();
50
+ if (kind === 'mute') conversation.muteMic();
51
+ if (kind === 'speaker') conversation.muteSpeaker();
52
+ if (kind === 'share') toggleScreen();
53
+ if (kind === 'documents') void conversation.documents(conversation.identity?.documentsAllowed === false);
54
+ if (kind === 'hide') void client.hide().catch(error => status(explainError(error)));
55
+ if (kind === 'play' && conversation.identity?.skin === 'xianxian-codex') {
56
+ void controls.show('none');
57
+ motion.signal('play');
58
+ }
59
+ },
60
+ });
20
61
  const screen = new ScreenShare({
21
62
  screenRequest: () => client.screenPick(),
22
- screenStart: (selectionId, label) => client.screenOpen(selectionId, label),
63
+ screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
23
64
  screenFrame: (selectionId, frame) => client.screenFrame(selectionId, {
24
65
  ...frame, frameId: crypto.randomUUID(), sourceLabel: '所选画面', observedAt: Date.now(),
25
- }),
26
- screenStop: () => client.screenClose(),
66
+ }), screenStop: () => client.screenClose(),
27
67
  }, 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';
68
+ sharing = value.sharing; pendingScreen = value.pending === true;
69
+ for (const id of ['share', 'menu-share']) label(id, sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享画面');
70
+ $('share-badge').hidden = !sharing && !pendingScreen;
71
+ $('share-badge').title = sharing ? `正在共享:${value.label},点此停止` : '正在选屏,点此取消';
35
72
  });
73
+ function toggleScreen() {
74
+ if (conversation.phase !== 'talking') {
75
+ status('先点语音聊,连上后可选择窗口或屏幕');
76
+ $('status').hidden = false;
77
+ void controls.show('actions');
78
+ return;
79
+ }
80
+ void (sharing || pendingScreen ? screen.stop('屏幕共享已停止') : screen.start()).catch(error => status(explainError(error)));
81
+ }
36
82
  const conversation = new CompanionConversation({
37
83
  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),
84
+ transcript: event => {
85
+ if (event.type === 'turn-done') { transcript.finish(event); bubble.finish(event.role); void readHistory(); }
86
+ else if (event.typed) void readHistory();
87
+ else { transcript.append(event.role, event.text, true); bubble.append(event.role, event.text); void controls.setAmbient(true); }
88
+ },
39
89
  render(value) {
90
+ const showFailure = value.failed && previousPhase !== 'idle' && value.phase === 'idle';
91
+ const justConnected = previousPhase !== 'talking' && value.phase === 'talking';
92
+ if (previousPhase !== 'idle' && value.phase === 'idle') { transcript.reset(); bubble.reset(); void readHistory(); }
93
+ previousPhase = value.phase;
40
94
  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);
95
+ void controls.setAmbient(showAmbient(active));
96
+ $('begin').hidden = active; $('begin').disabled = !value.identity;
97
+ label('begin', value.failed ? '重试语音' : '语音聊');
98
+ $('mic').hidden = value.phase !== 'talking'; $('speaker').hidden = !active;
99
+ $('active-actions').hidden = !active;
100
+ $('share').disabled = value.phase !== 'talking';
101
+ $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
102
+ status(value.message ?? '');
103
+ $('status').hidden = !value.message || /^(点|正在听|语音已结束|已发送)/.test(value.message);
104
+ $('call-badge').hidden = !active;
105
+ $('call-badge').dataset.state = value.muted ? 'muted' : value.phase;
106
+ const callLabel = value.phase === 'connecting' ? '正在连接,点此取消' : value.muted ? '麦克风已静音,点此结束' : '语音进行中,点此结束';
107
+ $('call-badge').title = callLabel; $('call-badge').setAttribute('aria-label', callLabel);
108
+ label('mic', value.muted ? '取消静音' : '静音'); $('mic').setAttribute('aria-pressed', String(value.muted));
109
+ label('speaker', value.silent ? '开启播音' : '关闭播音');
47
110
  const identity = value.identity;
111
+ motion.setContext({ skin: identity?.skin, phase: value.phase, nativeActivity: identity?.nativeActivity ?? 'none', muted: value.muted });
112
+ motion.syncSnapshot(normalizeNativeWork(identity?.nativeWork));
113
+ if (justConnected) motion.signal('connected');
114
+ if (showFailure) motion.signal('failed');
48
115
  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 ? '开启' : '暂停'}`;
116
+ $('menu-play').hidden = identity.skin !== 'xianxian-codex';
117
+ $('chat-name').textContent = threadTitle ?? identity.displayName; $('menu-name').textContent = identity.displayName;
118
+ $('pet').setAttribute('aria-label', `${identity.displayName}:点击交流,右键更多,拖动移动`);
119
+ $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
57
120
  $('documents').setAttribute('aria-pressed', String(identity.documentsAllowed));
121
+ const live = identity.liveTransport?.kind === 'gpt_live_v3'
122
+ ? 'GPT Live · 实时型号未证实' : '实时载体未确认';
58
123
  $('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;
124
+ ? `${identity.duty.displayName} · ${live}`
125
+ : `${identity.duty.displayName} · ${live} · 绑定载体 ${identity.carrier.displayName}`;
69
126
  }
127
+ if (showFailure) void controls.show('actions');
70
128
  },
71
129
  });
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)));
130
+ async function readHistory() {
131
+ if (loading) return;
132
+ loading = true;
133
+ try {
134
+ const history = await client.readConversation();
135
+ const assistantId = history.messages.findLast(message => message.role === 'assistant')?.id;
136
+ if (observedHistory && assistantId && assistantId !== lastAssistantId) {
137
+ resultPreviewUntil = Date.now() + 12_000;
138
+ }
139
+ observedHistory = true;
140
+ lastAssistantId = assistantId;
141
+ $('history').textContent = history.hasMore ? '更早聊天 ↗' : '完整聊天 ↗';
142
+ threadTitle = history.threadTitle;
143
+ $('chat-name').textContent = threadTitle;
144
+ bubble.load(history.messages);
145
+ void controls.setAmbient(showAmbient(conversation.phase !== 'idle'));
146
+ const serialized = JSON.stringify(history.messages);
147
+ if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
148
+ } catch { $('chat-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示'; }
149
+ finally { loading = false; }
150
+ }
151
+ function showDecisionBadge(page) {
152
+ const badge = decisionBadge(page);
153
+ $('pending-badge').hidden = !badge.visible;
154
+ $('pending-count').textContent = badge.label;
155
+ $('pending-badge').title = badge.title;
156
+ $('pending-badge').setAttribute('aria-label', badge.title);
157
+ $('menu-pending').textContent = badge.label;
158
+ }
159
+ async function readDecisions(more = false) {
160
+ if (decisionLoading) return;
161
+ decisionLoading = true;
162
+ if (!more && controls.panel === 'decisions') $('decision-status').textContent = '正在读取待决事项…';
163
+ try {
164
+ const page = await client.readDecisions(more ? decisionOffset : 0, 10);
165
+ if (page.status !== 'available') throw new Error('Decision source unavailable');
166
+ showDecisionBadge(page);
167
+ motion.setPendingDecision(page.approvalCount + page.otherNeedsMeCount > 0);
168
+ if (controls.panel !== 'decisions') return;
169
+ const list = $('decision-list');
170
+ if (!more) { list.replaceChildren(); list.scrollTop = 0; }
171
+ for (const row of decisionRows(page)) {
172
+ const item = document.createElement('li');
173
+ const title = document.createElement('strong');
174
+ const meta = document.createElement('span');
175
+ title.textContent = row.title; meta.textContent = row.meta;
176
+ item.append(title, meta); list.append(item);
177
+ if (row.previewable) {
178
+ const button = document.createElement('button');
179
+ button.type = 'button'; button.textContent = '查看确认演练';
180
+ button.onclick = async () => {
181
+ try {
182
+ const result = await client.inspectF221(row.proposalId);
183
+ $('decision-status').textContent = result.status === 'trial_confirmed'
184
+ ? '确认演练已完成;没有写回提案'
185
+ : result.status === 'stale' ? '提案已变化,请重新查看原处卡片'
186
+ : result.status === 'dismissed' ? '已取消演练;提案没有变化'
187
+ : '确认演练暂不可用;请在原处处理';
188
+ } catch { $('decision-status').textContent = '确认演练暂不可用;请在原处处理'; }
189
+ };
190
+ item.append(button);
191
+ }
192
+ }
193
+ decisionOffset = page.page.offset + page.page.limit;
194
+ $('decision-status').textContent = page.approvalCount + page.otherNeedsMeCount === 0
195
+ ? '现在没有待你处理的事项'
196
+ : `${page.approvalCount} 项审批事项,另有 ${page.otherNeedsMeCount} 项待处理`;
197
+ $('decision-more').hidden = !page.page.hasMoreApprovals && !page.page.hasMoreNeedsMe;
198
+ } catch {
199
+ showDecisionBadge(undefined);
200
+ motion.setPendingDecision(null);
201
+ if (controls.panel === 'decisions') $('decision-status').textContent = '待决事项暂不可读 · 请稍后刷新';
202
+ } finally { decisionLoading = false; }
77
203
  }
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('屏幕共享已停止'));
204
+ $('call-badge').onclick = () => void conversation.end();
205
+ $('share-badge').onclick = toggleScreen;
206
+ $('pending-badge').onclick = () => void controls.show('decisions');
207
+ $('decision-reload').onclick = () => void readDecisions();
208
+ $('decision-more').onclick = () => void readDecisions(true);
85
209
  $('history').onclick = () => void client.openConversation().then(result => {
86
- if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看猫猫球聊天');
210
+ if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看');
87
211
  }).catch(error => status(explainError(error)));
88
- $('documents').onclick = () => void conversation.documents(conversation.identity?.documentsAllowed === false);
89
212
  $('compose').onsubmit = async event => {
90
- event.preventDefault();
91
- const text = $('message').value;
213
+ event.preventDefault(); const text = $('message').value;
92
214
  if (await conversation.send(text)) {
215
+ motion.signal('received');
93
216
  if ($('message').value === text) $('message').value = '';
217
+ latestHistory = undefined; void readHistory();
94
218
  }
95
219
  };
96
220
  const unsubscribe = client.subscribe(event => {
97
- if (event.kind === 'media-stopped') void conversation.releaseLocal('语音已停止 · 点击开始聊天继续');
221
+ if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
222
+ if (event.kind === 'view-dismiss') controls.dismiss();
98
223
  });
99
- const monitor = setInterval(() => void conversation.refresh(), 3000);
100
- void conversation.refresh();
224
+ const statusMonitor = setInterval(() => void conversation.refresh(), 1000);
225
+ const historyMonitor = setInterval(() => void readHistory(), 3000);
226
+ const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void readDecisions(); }, 15000);
227
+ void conversation.refresh(); void readHistory(); void readDecisions(); void controls.show('none');
101
228
  window.addEventListener('beforeunload', () => {
102
- clearInterval(monitor); unsubscribe();
103
- void conversation.end();
229
+ clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(decisionMonitor);
230
+ unsubscribe(); motion.close(); void conversation.end();
104
231
  });
105
232
  }
@@ -2,6 +2,7 @@ export class TranscriptView {
2
2
  constructor(container) {
3
3
  this.container = container;
4
4
  this.activeRows = new Map();
5
+ this.historyRows = new Map();
5
6
  this.completed = new Set();
6
7
  }
7
8
  append(role, text, chunk = false) {
@@ -11,30 +12,64 @@ export class TranscriptView {
11
12
  if (!row) {
12
13
  row = this.container.ownerDocument.createElement('p');
13
14
  row.className = role;
15
+ if (chunk) { row.dataset.live = 'true'; row.dataset.author = `实时语音 · ${role === 'user' ? '你' : '猫猫'}`; }
14
16
  this.container.append(row);
15
17
  if (chunk) this.activeRows.set(role, row);
16
18
  }
17
- row.textContent += text;
19
+ row.textContent = (row.textContent + text).slice(-16000);
18
20
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
19
21
  }
20
22
  finish({ role, transcript, turnId }) {
21
23
  if (!['user', 'assistant'].includes(role) || (turnId && this.completed.has(turnId))) return;
22
24
  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;
25
+ // A provider caption is ephemeral. Only Host history can create a saved row.
26
+ this.activeRows.get(role)?.remove();
29
27
  this.activeRows.delete(role);
30
28
  if (turnId) this.completed.add(turnId);
29
+ if (this.completed.size > 256) this.completed.delete(this.completed.values().next().value);
31
30
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
32
31
  }
33
32
  nearBottom() {
34
33
  return this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
35
34
  }
35
+ visibleHistoryAnchor() {
36
+ const viewport = this.container.getBoundingClientRect?.();
37
+ if (!viewport) return null;
38
+ for (const [id, row] of this.historyRows) {
39
+ const rect = row.getBoundingClientRect?.();
40
+ if (rect && rect.bottom > viewport.top && rect.top < viewport.bottom)
41
+ return { id, offset: rect.top - viewport.top };
42
+ }
43
+ return null;
44
+ }
36
45
  reset() {
46
+ for (const row of this.activeRows.values()) row.remove();
37
47
  this.activeRows.clear();
38
48
  this.completed.clear();
39
49
  }
50
+ load(messages) {
51
+ const pinned = this.nearBottom();
52
+ const readingPosition = this.container.scrollTop;
53
+ const anchor = pinned ? null : this.visibleHistoryAnchor();
54
+ const previous = this.historyRows;
55
+ this.historyRows = new Map();
56
+ const rows = messages.map(message => {
57
+ const row = previous.get(message.id) ?? this.container.ownerDocument.createElement('p');
58
+ row.className = message.role; row.dataset.messageId = message.id;
59
+ if (row.textContent !== message.text) row.textContent = message.text;
60
+ row.title = message.name; row.dataset.author = message.name;
61
+ this.historyRows.set(message.id, row);
62
+ return row;
63
+ });
64
+ this.container.replaceChildren(...rows, ...this.activeRows.values());
65
+ if (pinned) this.container.scrollTop = this.container.scrollHeight;
66
+ else if (anchor) {
67
+ const row = this.historyRows.get(anchor.id) ?? rows[0];
68
+ const rect = row?.getBoundingClientRect?.();
69
+ const viewport = this.container.getBoundingClientRect?.();
70
+ this.container.scrollTop = rect && viewport
71
+ ? this.container.scrollTop + rect.top - viewport.top - anchor.offset
72
+ : readingPosition;
73
+ } else this.container.scrollTop = readingPosition;
74
+ }
40
75
  }
package/source-lock.json CHANGED
@@ -15,17 +15,151 @@
15
15
  {
16
16
  "source": "packages/web/public/concierge/skins/xianxian-codex/spritesheet.webp",
17
17
  "destination": "assets/skins/xianxian-codex.webp",
18
- "sha256": "5f653974735a4104dfdaf1845128494a5cb5164cb2f45fecb2072a167c80e8a5"
18
+ "sha256": "28e6a4211acac553c982627b402865cf7c079038fc4ad7954522ce37b1db90cb",
19
+ "derivation": {
20
+ "method": "reviewed-atlas-refresh",
21
+ "date": "2026-09-23",
22
+ "referenceSha256": "5f653974735a4104dfdaf1845128494a5cb5164cb2f45fecb2072a167c80e8a5",
23
+ "purpose": "Synchronize the family-owned repaired atlas: preserve opaque white fur and use a fixed crop and scale for the six idle frames.",
24
+ "author": "codex-astra"
25
+ }
19
26
  },
20
27
  {
21
28
  "source": "packages/web/public/concierge/skins/ragdoll-v1/idle.png",
22
29
  "destination": "assets/skins/ragdoll-v1.png",
23
- "sha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025"
30
+ "sha256": "24e5c4dea8d6ee35bf69a5612417088ff15f08c40404cbb18bfb99caad25564e",
31
+ "derivation": {
32
+ "method": "native-imagegen",
33
+ "date": "2026-09-20",
34
+ "referenceSha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025",
35
+ "purpose": "Restore the complete original grey-point cat silhouette on transparent alpha; remove the cropped room background.",
36
+ "author": "codex-astra"
37
+ }
24
38
  },
25
39
  {
26
40
  "source": "packages/web/public/concierge/sprites/ragdoll/idle.png",
27
41
  "destination": "assets/skins/yarn-ball.png",
28
- "sha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025"
42
+ "sha256": "24e5c4dea8d6ee35bf69a5612417088ff15f08c40404cbb18bfb99caad25564e",
43
+ "derivation": {
44
+ "method": "native-imagegen",
45
+ "date": "2026-09-20",
46
+ "referenceSha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025",
47
+ "purpose": "Restore the complete original grey-point cat silhouette on transparent alpha; remove the cropped room background.",
48
+ "author": "codex-astra"
49
+ }
50
+ },
51
+ {
52
+ "source": "packages/web/public/visible-cafe/skins/xianxian/sleeping-row.png",
53
+ "sourceCommit": "569196d723bcfaa3e4c162312a2081e479ed5579",
54
+ "destination": "assets/skins/xianxian-sleeping-row.png",
55
+ "sha256": "1f15086d9836b6a1174349bc7814c3d4871a6019cd4aa896456fb2776fe6aa05"
56
+ },
57
+ {
58
+ "source": "packages/web/public/visible-cafe/skins/xianxian/working-row.png",
59
+ "sourceCommit": "569196d723bcfaa3e4c162312a2081e479ed5579",
60
+ "destination": "assets/skins/xianxian-working-row.png",
61
+ "sha256": "28862148bb7c0c42391f0c28399ac1314ecc6f4f61cab802933ed3c81ee593d6"
62
+ },
63
+ {
64
+ "source": "packages/web/public/visible-cafe/skins/xianxian/staged-thought-row.png",
65
+ "sourceCommit": "569196d723bcfaa3e4c162312a2081e479ed5579",
66
+ "destination": "assets/skins/xianxian-staged-thought-row.png",
67
+ "sha256": "e9286ce2c234911d9b45835849e1a2a853598d0d0260ecf7d24430a95847725f"
29
68
  }
30
- ]
69
+ ],
70
+ "livingSkin": {
71
+ "package": "xianxian-living package-v3",
72
+ "manifestSha256": "a7434caa0cd559bfb8747a9d9391fb5219d4c4a68639b5b669008dd7c7b67aaf",
73
+ "creator": "opus55; keyframes by codex-astra; source videos by CVO",
74
+ "files": [
75
+ {
76
+ "destination": "assets/skins/xianxian-living/layers/body.webp",
77
+ "sha256": "39f8fae11b49d6fc8c7d0993a2ed867b6d863f1da580a711ff11d91aa8c6c233"
78
+ },
79
+ {
80
+ "destination": "assets/skins/xianxian-living/layers/head.webp",
81
+ "sha256": "18a7d34716448ceb70afd7b0e633ef6ab71c6717a96b73b8e39e7ee027d550cb"
82
+ },
83
+ {
84
+ "destination": "assets/skins/xianxian-living/layers/tail.webp",
85
+ "sha256": "d23c5c964e9084c6f44d4a6b8b42cbaff6678348bec87a8434a18967816cf4ff"
86
+ },
87
+ {
88
+ "destination": "assets/skins/xianxian-living/layers/earL.webp",
89
+ "sha256": "0d40004c7bbc592b76a4452c42740b6059697a1b313c9f9919bd16ef5a64b1d1"
90
+ },
91
+ {
92
+ "destination": "assets/skins/xianxian-living/layers/earR.webp",
93
+ "sha256": "1c2141ac3aa4d93ab4f4dfb14c5ec8c566db2e53444a730b008e00f65765b700"
94
+ },
95
+ {
96
+ "destination": "assets/skins/xianxian-living/layers/irisL.webp",
97
+ "sha256": "006b247e4fa8924b8f20198fc34d38a231dda0577ee45581a3d6d1fa74886533"
98
+ },
99
+ {
100
+ "destination": "assets/skins/xianxian-living/layers/irisR.webp",
101
+ "sha256": "929bd6c888860ac5ebe80f230a5793bc8c93b36eabdc9d18b3ef1fd7cb1ff095"
102
+ },
103
+ {
104
+ "destination": "assets/skins/xianxian-living/layers/whiteL.webp",
105
+ "sha256": "b4b2a539950376575ed9d61052b1a43f06812af6158244c86351d29ab935fde7"
106
+ },
107
+ {
108
+ "destination": "assets/skins/xianxian-living/layers/whiteR.webp",
109
+ "sha256": "9203fc92f5430d72deb56b1587a8cc79e9d7952fc6e5027adfc8507ed661a441"
110
+ },
111
+ {
112
+ "destination": "assets/skins/xianxian-living/layers/closed.webp",
113
+ "sha256": "adb354110fb952a3bb6d5f5fc11968b74c1cc2cf8d2e6dafc4b2b4ad7ca1d92b"
114
+ },
115
+ {
116
+ "destination": "assets/skins/xianxian-living/layers/mouth.webp",
117
+ "sha256": "f968f9afba93e6bf64a51e7c26f0ddfc2ed10fd18d65c38cf636b4e73eeb9b53"
118
+ },
119
+ {
120
+ "destination": "assets/skins/xianxian-living/layers/scared.webp",
121
+ "sha256": "1cb165fa0a116aea063b78aef64ac3d512d2ec71f0b3f169f57914aaa5a9f48a"
122
+ },
123
+ {
124
+ "destination": "assets/skins/xianxian-living/clips/trot.webm",
125
+ "sha256": "02660b4a57dfaca0e4b3d4a4d1e791715af8d01a831c56bd3ff9e30e4fc56900"
126
+ },
127
+ {
128
+ "destination": "assets/skins/xianxian-living/clips/pounce.webm",
129
+ "sha256": "65a380d993f53debb6ff761e586e9fdacce4cb2156c6bb461b4f3875926d5a00"
130
+ },
131
+ {
132
+ "destination": "assets/skins/xianxian-living/clips/sleep.webm",
133
+ "sha256": "f53ae41cc7722128e68f4b5cf3df980be216a79f8cc4b8dff36fa0dc029dbca9"
134
+ },
135
+ {
136
+ "destination": "assets/skins/xianxian-living/clips/wake.webm",
137
+ "sha256": "8821cb764d7ca085f4b30dec2f893f2515d383cc1f2053215e91b18b86753cd7"
138
+ },
139
+ {
140
+ "destination": "assets/skins/xianxian-living/clips/peek.webm",
141
+ "sha256": "adebecf89b724eec9852d097364e10bc328d006ce3bb3bd3f4b257c095d57a92"
142
+ },
143
+ {
144
+ "destination": "assets/skins/xianxian-living/clips/peek_fade.webm",
145
+ "sha256": "7880e695fa8afe783c4f1fcb4fc1c31c12d64d36f3d124dc4ef929ad09f7ffef"
146
+ },
147
+ {
148
+ "destination": "assets/skins/xianxian-living/clips/carry.webm",
149
+ "sha256": "4fa582ab6a18b538cfe815ab19e73da30c0da061ec661bb12e4060f8b3d2e600"
150
+ },
151
+ {
152
+ "destination": "assets/skins/xianxian-living/clips/wait.webm",
153
+ "sha256": "5e2a41e967eed178b8e540231e0b1733bbc388f955204a7b0b76b2aa7cfc4e45"
154
+ },
155
+ {
156
+ "destination": "assets/skins/xianxian-living/clips/think.webm",
157
+ "sha256": "4ba9fa0f38076a4abd0d664dc75287b76def7b340dc763b6aab4ea15bbe8abc1"
158
+ },
159
+ {
160
+ "destination": "assets/skins/xianxian-living/layers/meta.json",
161
+ "sha256": "92fe5571b2f4b78c1138e199aaa29bb523b6f6ad7ab346fbd6407c8b557e220f"
162
+ }
163
+ ]
164
+ }
31
165
  }