@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
@@ -0,0 +1,16 @@
1
+ /** Observe the Host window's current screen; never select or move a display. */
2
+ export function detectDockedEdge(win, petRect) {
3
+ const left = win.screen?.availLeft;
4
+ const width = win.screen?.availWidth;
5
+ if (![win.screenX, left, width, petRect.left, petRect.width].every(Number.isFinite) || width <= 0) return null;
6
+ const petLeft = win.screenX + petRect.left;
7
+ const edge = petLeft - left <= 40 ? 'left'
8
+ : left + width - petLeft - petRect.width <= 40 ? 'right' : null;
9
+ if (!edge) return null;
10
+ const top = win.screen?.availTop;
11
+ const height = win.screen?.availHeight;
12
+ const vertical = [win.screenY, top, height, petRect.top, petRect.height];
13
+ const atBottom = vertical.every(Number.isFinite) && height > 0
14
+ && top + height - (win.screenY + petRect.top + petRect.height) <= 40;
15
+ return { edge, zone: atBottom ? 'corner' : 'mid' };
16
+ }
@@ -0,0 +1,79 @@
1
+ const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
2
+ const validExpiry = (value, now) => Number.isFinite(value) && value > now;
3
+ const workKinds = new Set(['reasoning', 'tool', 'workspace', 'screen_reading']);
4
+
5
+ /** A bounded, expiring projection of Host-owned motion facts. */
6
+ export class MotionFacts {
7
+ constructor({ now, setTimer, clearTimer, changed }) {
8
+ this.now = now;
9
+ this.setTimer = setTimer;
10
+ this.clearTimer = clearTimer;
11
+ this.changed = changed;
12
+ this.snapshot = { work: null, delivery: null, travel: null };
13
+ this.seenResults = new Map();
14
+ this.suppressedTravel = new Map();
15
+ }
16
+
17
+ sync(value, suppressTravel = false) {
18
+ const now = this.now();
19
+ this.prune(now);
20
+ const input = value && typeof value === 'object' ? value : {};
21
+ const work = input.work;
22
+ const delivery = input.delivery;
23
+ const travel = input.travel;
24
+ this.snapshot = {
25
+ work: work && validId(work.taskId) && work.status === 'active' && workKinds.has(work.kind)
26
+ && validExpiry(work.expiresAt, now)
27
+ ? { taskId: work.taskId, kind: work.kind, expiresAt: work.expiresAt } : null,
28
+ delivery: delivery && validId(delivery.resultId) && delivery.status === 'applied'
29
+ && validExpiry(delivery.expiresAt, now)
30
+ ? { resultId: delivery.resultId, expiresAt: delivery.expiresAt } : null,
31
+ travel: travel && validId(travel.eventId) && travel.status === 'active' && Number.isFinite(travel.dx)
32
+ && Number.isFinite(travel.dy) && Math.hypot(travel.dx, travel.dy) >= 1
33
+ && validExpiry(travel.expiresAt, now) && !this.suppressedTravel.has(travel.eventId)
34
+ ? { eventId: travel.eventId, dx: travel.dx, dy: travel.dy, expiresAt: travel.expiresAt } : null,
35
+ };
36
+ if (suppressTravel) this.suppressTravel();
37
+ this.scheduleExpiry();
38
+ }
39
+
40
+ consumeDelivery() {
41
+ const delivery = this.snapshot.delivery;
42
+ if (!delivery || this.seenResults.has(delivery.resultId)) return null;
43
+ this.seenResults.set(delivery.resultId, this.now() + 3_600_000);
44
+ return delivery;
45
+ }
46
+
47
+ suppressTravel() {
48
+ if (!this.snapshot.travel) return;
49
+ this.suppressedTravel.set(this.snapshot.travel.eventId, this.now() + 3_600_000);
50
+ this.snapshot.travel = null;
51
+ this.scheduleExpiry();
52
+ }
53
+
54
+ close() {
55
+ if (this.timer !== undefined) this.clearTimer(this.timer);
56
+ this.timer = undefined;
57
+ }
58
+
59
+ scheduleExpiry() {
60
+ this.close();
61
+ const expiries = Object.values(this.snapshot).filter(Boolean).map(value => value.expiresAt);
62
+ if (!expiries.length) return;
63
+ this.timer = this.setTimer(() => {
64
+ this.timer = undefined;
65
+ const now = this.now();
66
+ for (const key of ['work', 'delivery', 'travel']) {
67
+ if (this.snapshot[key]?.expiresAt <= now) this.snapshot[key] = null;
68
+ }
69
+ this.scheduleExpiry();
70
+ this.changed();
71
+ }, Math.max(0, Math.min(...expiries) - this.now()));
72
+ }
73
+
74
+ prune(now) {
75
+ for (const collection of [this.seenResults, this.suppressedTravel]) {
76
+ for (const [id, expiresAt] of collection) if (expiresAt <= now) collection.delete(id);
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,24 @@
1
+ const priority = { reasoning: 1, tool: 2, workspace_fetch: 3, workspace_dispatch: 4, screen_read: 5 };
2
+ const semantic = {
3
+ reasoning: 'reasoning',
4
+ tool: 'tool',
5
+ workspace_fetch: 'workspace',
6
+ workspace_dispatch: 'workspace',
7
+ screen_read: 'screen_reading',
8
+ };
9
+
10
+ /** Normalize the Host's bounded call snapshot; never infer work from chat text. */
11
+ export function normalizeNativeWork(value, now = Date.now()) {
12
+ if (!value || typeof value !== 'object' || !Array.isArray(value.active) || !Array.isArray(value.recent)) return null;
13
+ const active = value.active
14
+ .filter(entry => entry && semantic[entry.kind] && Number.isFinite(entry.expiresAt) && entry.expiresAt > now)
15
+ .sort((left, right) => priority[right.kind] - priority[left.kind] || right.startedAt - left.startedAt)[0];
16
+ const handed = [...value.recent].reverse().find(event => event?.kind === 'result'
17
+ && event.phase === 'result_handed_to_voice' && typeof event.resultId === 'string'
18
+ && event.resultId.length > 0 && Number.isFinite(event.expiresAt) && event.expiresAt > now);
19
+ return {
20
+ work: active ? { taskId: active.taskId, kind: semantic[active.kind], status: 'active', expiresAt: active.expiresAt } : null,
21
+ delivery: handed ? { resultId: handed.resultId, status: 'applied', expiresAt: handed.expiresAt } : null,
22
+ travel: null,
23
+ };
24
+ }
@@ -0,0 +1,84 @@
1
+ /** Transient controls. The Host owns native placement, movement and hiding. */
2
+ export function bindPetControls(client, { action, error, changed, moved = () => {} }) {
3
+ const $ = id => document.getElementById(id);
4
+ const body = $('pet');
5
+ let panel = 'none', ambient = false, layoutRevision = 0, drag, suppressClick = false;
6
+ async function show(next) {
7
+ if (next === 'none' && ambient) next = 'bubble';
8
+ if (panel !== next && $(next)) $(next).style.maxHeight = '500px';
9
+ panel = next;
10
+ for (const id of ['actions', 'menu', 'chat', 'bubble', 'decisions']) $(id).hidden = id !== next;
11
+ body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(next)));
12
+ const node = $(next);
13
+ const revision = ++layoutRevision;
14
+ try {
15
+ const placed = await client.layout(next, Math.max(120, Math.ceil(node?.offsetWidth ?? 120)), Math.max(32, Math.ceil(node?.offsetHeight ?? 130)));
16
+ if (revision !== layoutRevision) return;
17
+ $('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
18
+ if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
19
+ if (next === 'chat') $('message').focus({ preventScroll: true });
20
+ if (next === 'decisions') $('decision-reload').focus({ preventScroll: true });
21
+ if (next === 'menu') $('menu').querySelector('button').focus({ preventScroll: true });
22
+ changed(next);
23
+ } catch (cause) { error(cause); }
24
+ }
25
+ body.onclick = event => {
26
+ if (suppressClick) { suppressClick = false; return; }
27
+ if (event.detail > 1) return;
28
+ void show(panel === 'actions' ? 'none' : 'actions');
29
+ };
30
+ body.oncontextmenu = event => { event.preventDefault(); void show('menu'); };
31
+ body.onkeydown = event => {
32
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) { event.preventDefault(); void show('menu'); }
33
+ };
34
+ body.onpointerdown = event => {
35
+ if (event.button !== 0) return;
36
+ const current = { id: event.pointerId, x: event.screenX, y: event.screenY, armed: false };
37
+ drag = current;
38
+ body.setPointerCapture(event.pointerId);
39
+ void client.drag('start').then(() => { if (drag === current) current.armed = true; }).catch(error);
40
+ };
41
+ body.onpointermove = event => {
42
+ if (!drag) return;
43
+ const dx = event.screenX - drag.x, dy = event.screenY - drag.y;
44
+ if (Math.hypot(dx, dy) < 6) return;
45
+ suppressClick = true;
46
+ if (drag.armed) moved(dx, dy);
47
+ drag.x = event.screenX; drag.y = event.screenY;
48
+ };
49
+ for (const name of ['pointerup', 'pointercancel']) body.addEventListener(name, () => {
50
+ drag = undefined; moved('stop'); void client.drag('end').catch(error);
51
+ });
52
+ document.addEventListener('pointerdown', event => {
53
+ if (!event.target.closest('#anchor, .floating')) void show('none');
54
+ });
55
+ document.addEventListener('keydown', event => {
56
+ if (event.key === 'Escape' && panel !== 'none') { event.preventDefault(); void show('none'); body.focus(); }
57
+ });
58
+ document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
59
+ const kind = button.dataset.action;
60
+ if (kind === 'write' || kind === 'history') { void show('chat'); return; }
61
+ if (kind === 'decisions') { void show('decisions'); return; }
62
+ if (kind === 'dismiss') { void show('none'); return; }
63
+ action(kind);
64
+ });
65
+ const sizes = new WeakMap();
66
+ const observer = new ResizeObserver(entries => {
67
+ for (const entry of entries) {
68
+ const size = `${entry.contentRect.width}:${entry.contentRect.height}`;
69
+ const previous = sizes.get(entry.target); sizes.set(entry.target, size);
70
+ if (previous && previous !== size && entry.target.id === panel && !entry.target.hidden) void show(panel);
71
+ }
72
+ });
73
+ for (const id of ['actions', 'menu', 'chat', 'bubble', 'decisions']) observer.observe($(id));
74
+ window.addEventListener('beforeunload', () => observer.disconnect());
75
+ return {
76
+ get panel() { return panel; }, show,
77
+ setAmbient(enabled) {
78
+ if (ambient === enabled) return Promise.resolve();
79
+ ambient = enabled;
80
+ return ['none', 'bubble'].includes(panel) ? show('none') : Promise.resolve();
81
+ },
82
+ dismiss() { if (drag) suppressClick = true; void show('none'); },
83
+ };
84
+ }
@@ -0,0 +1,350 @@
1
+ import { isLivingClip } from './living-body.mjs';
2
+ import { MotionFacts } from './motion-facts.mjs';
3
+
4
+ // F229 pet.json frame durations, plus the three F258 rows pinned in source-lock.json.
5
+ const atlas = {
6
+ idle: { row: 0, frames: [1200, 400, 400, 500, 500, 800] },
7
+ 'running-right': { row: 1, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
8
+ 'running-left': { row: 2, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
9
+ waving: { row: 3, frames: [140, 140, 140, 280] },
10
+ jumping: { row: 4, frames: [140, 140, 140, 140, 280] },
11
+ failed: { row: 5, frames: [140, 140, 140, 140, 140, 140, 140, 240] },
12
+ waiting: { row: 6, frames: [150, 150, 150, 150, 150, 260] },
13
+ running: { row: 7, frames: [120, 120, 120, 120, 120, 220] },
14
+ review: { row: 8, frames: [150, 150, 150, 150, 150, 280] },
15
+ };
16
+ const additional = {
17
+ sleeping: { file: 'xianxian-sleeping-row.png', frames: [700, 700, 700, 700, 700, 700] },
18
+ working: { file: 'xianxian-working-row.png', frames: [520, 560, 520, 640, 520, 560, 520, 640] },
19
+ staged_thought: { file: 'xianxian-staged-thought-row.png', frames: [1200, 500, 700, 900, 1200, 500] },
20
+ };
21
+ const visualAlias = { fetching: 'working', studying: 'staged_thought', delivering: 'review', held: 'idle' };
22
+ const workAction = { reasoning: 'staged_thought', tool: 'working', workspace: 'fetching', screen_reading: 'studying' };
23
+ const signals = { connected: 'waving', received: 'jumping', failed: 'failed' };
24
+ const staticSkins = { 'ragdoll-v1': 'ragdoll-v1.png', 'yarn-ball': 'yarn-ball.png' };
25
+ const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
26
+
27
+ /** Select one truthful body from Host facts and local, user-controlled gestures. */
28
+ export class PetMotion {
29
+ constructor(element, options = {}) {
30
+ this.element = element;
31
+ const setTimer = options.setTimer ?? globalThis.setTimeout;
32
+ const clearTimer = options.clearTimer ?? globalThis.clearTimeout;
33
+ this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
34
+ this.clearTimer = id => clearTimer.call(globalThis, id);
35
+ this.now = options.now ?? Date.now;
36
+ this.livingBody = options.livingBody;
37
+ this.reducedMotion = options.reducedMotion ?? globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
38
+ this.autoPlay = options.autoPlay === true;
39
+ this.autoPlayIdleMs = Math.max(90_000, options.autoPlayIdleMs ?? 90_000);
40
+ this.autoPlayCooldownMs = Math.max(180_000, options.autoPlayCooldownMs ?? 180_000);
41
+ this.lastPlayAt = Number.NEGATIVE_INFINITY;
42
+ this.skin = 'xianxian-codex';
43
+ this.phase = 'idle';
44
+ this.base = 'idle';
45
+ this.action = '';
46
+ this.frame = 0;
47
+ this.dragging = false;
48
+ this.oneShot = false;
49
+ this.pendingDecision = false;
50
+ this.dockedEdge = null;
51
+ this.dockZone = null;
52
+ this.resting = false;
53
+ this.transient = null;
54
+ this.facts = new MotionFacts({ now: this.now, setTimer: this.setTimer, clearTimer: this.clearTimer,
55
+ changed: () => this.refresh() });
56
+ this.seenEvents = new Map();
57
+ }
58
+
59
+ setContext({ skin, phase, nativeActivity, muted }) {
60
+ const selectedSkin = ['xianxian-codex', 'yanyan-codex', ...Object.keys(staticSkins)].includes(skin)
61
+ ? skin : 'xianxian-codex';
62
+ const wasSleeping = this.action === 'sleeping' || this.resting;
63
+ this.context = { skin: selectedSkin, phase, nativeActivity, muted };
64
+ const changedSkin = selectedSkin !== this.skin;
65
+ this.skin = selectedSkin;
66
+ this.phase = phase;
67
+ if (phase !== 'idle' || nativeActivity !== 'none') {
68
+ this.resting = false;
69
+ this.cancelIdleTimers();
70
+ }
71
+ if (nativeActivity !== 'none' && this.transient?.action !== 'failed') this.transient = null;
72
+ else if (wasSleeping && ['talking', 'connecting'].includes(phase) && !muted)
73
+ this.transient = { action: 'waking' };
74
+ this.refresh(changedSkin);
75
+ }
76
+
77
+ syncSnapshot(value) {
78
+ const now = this.now();
79
+ this.hasNativeWork = value !== null && value !== undefined;
80
+ for (const [id, expiresAt] of this.seenEvents) if (expiresAt <= now) this.seenEvents.delete(id);
81
+ this.facts.sync(value, this.dragging);
82
+ if (this.snapshot.work) {
83
+ this.resting = false;
84
+ this.cancelIdleTimers();
85
+ if (this.transient?.action !== 'failed') this.transient = null;
86
+ } else if (this.resting && (this.snapshot.delivery || this.snapshot.travel)) {
87
+ this.resting = false;
88
+ this.cancelRest();
89
+ this.transient = { action: 'waking' };
90
+ }
91
+ this.refresh();
92
+ }
93
+
94
+ setPendingDecision(value) {
95
+ const pending = value === true;
96
+ if (this.pendingDecision === pending) return;
97
+ this.pendingDecision = pending;
98
+ if (pending) {
99
+ this.resting = false;
100
+ this.transient = null;
101
+ this.cancelIdleTimers();
102
+ }
103
+ this.refresh();
104
+ }
105
+
106
+ setDockedEdge(value) {
107
+ const candidate = typeof value === 'string' ? { edge: value, zone: 'corner' } : value;
108
+ const edge = ['left', 'right'].includes(candidate?.edge) ? candidate.edge : null;
109
+ const zone = edge && ['corner', 'mid'].includes(candidate?.zone) ? candidate.zone : edge ? 'corner' : null;
110
+ if (this.dockedEdge === edge && this.dockZone === zone) return;
111
+ this.dockedEdge = edge;
112
+ this.dockZone = zone;
113
+ if (edge) this.cancelIdleTimers();
114
+ this.refresh(this.action === 'peek');
115
+ }
116
+
117
+ signal(kind, eventId) {
118
+ if (kind === 'answered') return;
119
+ if (kind === 'connected' && this.living()) return;
120
+ if (kind === 'play') {
121
+ if (!this.canPlay()) return;
122
+ this.lastPlayAt = this.now();
123
+ this.resting = false;
124
+ this.cancelIdleTimers();
125
+ this.transient = { action: 'play' };
126
+ this.refresh();
127
+ return;
128
+ }
129
+ let requested = signals[kind];
130
+ if (kind === 'received' && (this.action === 'sleeping' || this.resting)) requested = 'waking';
131
+ if (!requested || this.dragging || staticSkins[this.skin]) return;
132
+ const now = this.now();
133
+ if (validId(eventId)) {
134
+ if (this.seenEvents.has(eventId)) return;
135
+ this.seenEvents.set(eventId, now + 3_600_000);
136
+ }
137
+ this.resting = false;
138
+ this.cancelIdleTimers();
139
+ this.transient = { action: requested };
140
+ this.refresh();
141
+ }
142
+
143
+ move(dx, dy) {
144
+ if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 6) return;
145
+ if (this.dragging) return;
146
+ this.dragging = true;
147
+ this.element.classList?.add('dragging');
148
+ this.element.dataset.interaction = 'held-candidate';
149
+ this.facts.suppressTravel();
150
+ this.resting = false;
151
+ this.transient = null;
152
+ this.cancelIdleTimers();
153
+ this.refresh();
154
+ }
155
+
156
+ stopMove() {
157
+ if (!this.dragging) return;
158
+ this.dragging = false;
159
+ this.element.classList?.remove('dragging');
160
+ delete this.element.dataset.interaction;
161
+ this.refresh();
162
+ }
163
+
164
+ close() {
165
+ this.cancelFrame();
166
+ this.cancelIdleTimers();
167
+ this.facts.close();
168
+ this.livingBody?.close();
169
+ }
170
+ living() { return this.skin === 'xianxian-codex' && Boolean(this.livingBody); }
171
+ resolve(action) {
172
+ if (action === 'pending_decision' && !this.living()) return 'waiting';
173
+ if (action === 'peek' && !this.living()) return 'idle';
174
+ if (staticSkins[this.skin]) return 'idle';
175
+ const visual = visualAlias[action] ?? action;
176
+ if (this.skin !== 'xianxian-codex' && visual in additional)
177
+ return visual === 'sleeping' ? 'idle' : 'review';
178
+ return action;
179
+ }
180
+
181
+ refresh(force = false) {
182
+ const selection = this.select();
183
+ this.base = this.baseAction();
184
+ const action = this.resolve(selection.action);
185
+ const changed = action !== this.action;
186
+ if (changed || force || selection.oneShot !== this.oneShot) {
187
+ this.oneShot = selection.oneShot;
188
+ this.play(action, force);
189
+ }
190
+ if (action === 'idle') this.armRest();
191
+ else if (action !== 'sleeping') this.cancelRest();
192
+ }
193
+
194
+ select() {
195
+ if (this.dragging) return { action: 'held', oneShot: false };
196
+ if (this.transient?.action === 'failed') return { action: 'failed', oneShot: true };
197
+ if (this.pendingDecision) return { action: 'pending_decision', oneShot: false };
198
+ const work = this.activeWorkAction();
199
+ if (work) return { action: work, oneShot: false };
200
+ if (this.transient && this.transient.action !== 'play')
201
+ return { action: this.transient.action, oneShot: true };
202
+ const delivery = this.facts.consumeDelivery();
203
+ if (delivery) {
204
+ this.transient = { action: 'delivering', resultId: delivery.resultId };
205
+ return { action: 'delivering', oneShot: true };
206
+ }
207
+ if (!this.reducedMotion && this.snapshot.travel) {
208
+ const { dx, dy } = this.snapshot.travel;
209
+ return { action: Math.abs(dx) >= Math.abs(dy) ? dx < 0 ? 'running-left' : 'running-right' : 'running', oneShot: false };
210
+ }
211
+ if (['talking', 'connecting'].includes(this.phase) && !this.context?.muted)
212
+ return { action: 'waiting', oneShot: false };
213
+ if (this.transient?.action === 'play') return { action: 'play', oneShot: true };
214
+ if (this.dockedEdge) return { action: 'peek', oneShot: false };
215
+ if (this.resting) return { action: 'sleeping', oneShot: false };
216
+ return { action: 'idle', oneShot: false };
217
+ }
218
+
219
+ baseAction() {
220
+ if (this.pendingDecision) return 'pending_decision';
221
+ const work = this.activeWorkAction();
222
+ if (work) return work;
223
+ if (['talking', 'connecting'].includes(this.phase) && !this.context?.muted) return 'waiting';
224
+ if (this.dockedEdge) return 'peek';
225
+ return 'idle';
226
+ }
227
+
228
+ activeWorkAction() {
229
+ if (this.snapshot.work) return workAction[this.snapshot.work.kind];
230
+ if (!this.hasNativeWork && this.context?.nativeActivity === 'tool_running') return 'working';
231
+ if (!this.hasNativeWork && this.context?.nativeActivity === 'reasoning') return 'staged_thought';
232
+ return null;
233
+ }
234
+
235
+ play(action, force = false) {
236
+ if (this.action === action && !force) return;
237
+ this.cancelFrame();
238
+ this.action = action;
239
+ this.frame = 0;
240
+ this.paint();
241
+ this.scheduleFrame();
242
+ }
243
+
244
+ paint() {
245
+ const { style, dataset } = this.element;
246
+ dataset.skin = this.skin;
247
+ dataset.action = this.action;
248
+ dataset.dockEdge = this.dockedEdge ?? 'none';
249
+ dataset.dockZone = this.dockZone ?? 'none';
250
+ if (this.living()) {
251
+ style.backgroundImage = 'none';
252
+ this.livingBody.show(this.action, { reducedMotion: this.reducedMotion, onEnded: () => this.completeOneShot() });
253
+ return;
254
+ }
255
+ this.livingBody?.hide();
256
+ const staticFile = staticSkins[this.skin];
257
+ if (staticFile) {
258
+ style.backgroundImage = `url("./skins/${staticFile}")`;
259
+ style.backgroundSize = 'contain';
260
+ style.backgroundPosition = 'center';
261
+ return;
262
+ }
263
+ const visual = visualAlias[this.action] ?? this.action;
264
+ const extra = this.skin === 'xianxian-codex' ? additional[visual] : undefined;
265
+ style.backgroundImage = `url("./skins/${extra?.file ?? `${this.skin}.webp`}")`;
266
+ style.backgroundSize = extra ? '960px 130px' : '960px 1170px';
267
+ style.backgroundPosition = `${-120 * this.frame}px ${-130 * (atlas[visual]?.row ?? 0)}px`;
268
+ }
269
+
270
+ scheduleFrame() {
271
+ if (this.reducedMotion) {
272
+ if (this.oneShot) this.frameTimer = this.setTimer(() => { this.frameTimer = undefined; this.completeOneShot(); }, 1_000);
273
+ return;
274
+ }
275
+ if (staticSkins[this.skin]) return;
276
+ if (this.living()) {
277
+ if (isLivingClip(this.action) || !this.oneShot) return;
278
+ this.frameTimer = this.setTimer(() => { this.frameTimer = undefined; this.completeOneShot(); }, 700);
279
+ return;
280
+ }
281
+ const visual = visualAlias[this.action] ?? this.action;
282
+ const frames = additional[visual]?.frames ?? atlas[visual]?.frames;
283
+ if (!frames) return;
284
+ this.frameTimer = this.setTimer(() => {
285
+ this.frameTimer = undefined;
286
+ if (this.frame + 1 === frames.length && this.oneShot) {
287
+ this.completeOneShot();
288
+ return;
289
+ }
290
+ this.frame = (this.frame + 1) % frames.length;
291
+ this.paint();
292
+ this.scheduleFrame();
293
+ }, frames[this.frame]);
294
+ }
295
+
296
+ cancelFrame() {
297
+ if (this.frameTimer !== undefined) this.clearTimer(this.frameTimer);
298
+ this.frameTimer = undefined;
299
+ }
300
+
301
+ completeOneShot() {
302
+ if (!this.oneShot) return;
303
+ this.oneShot = false;
304
+ this.transient = null;
305
+ this.action = '';
306
+ this.refresh();
307
+ }
308
+
309
+ canPlay() {
310
+ return !this.reducedMotion && !this.dragging && !this.pendingDecision && this.phase === 'idle'
311
+ && !this.activeWorkAction() && !this.snapshot.delivery && !this.snapshot.travel && !this.dockedEdge
312
+ && !staticSkins[this.skin];
313
+ }
314
+
315
+ armRest() {
316
+ if (this.restTimer === undefined && !this.resting && !this.pendingDecision && !this.dockedEdge) {
317
+ this.restTimer = this.setTimer(() => {
318
+ this.restTimer = undefined;
319
+ if (this.action === 'idle' && !this.dragging && !this.transient) {
320
+ this.resting = true;
321
+ this.refresh();
322
+ }
323
+ }, 45_000);
324
+ }
325
+ if (this.autoPlay && !this.reducedMotion && this.autoTimer === undefined) {
326
+ const delay = Math.max(this.autoPlayIdleMs, this.lastPlayAt + this.autoPlayCooldownMs - this.now());
327
+ this.autoTimer = this.setTimer(() => {
328
+ this.autoTimer = undefined;
329
+ if (!this.canPlay()) return;
330
+ this.lastPlayAt = this.now();
331
+ this.resting = false;
332
+ this.transient = { action: 'play' };
333
+ this.refresh();
334
+ }, delay);
335
+ }
336
+ }
337
+
338
+ cancelRest() {
339
+ if (this.restTimer !== undefined) this.clearTimer(this.restTimer);
340
+ this.restTimer = undefined;
341
+ }
342
+
343
+ cancelIdleTimers() {
344
+ this.cancelRest();
345
+ if (this.autoTimer !== undefined) this.clearTimer(this.autoTimer);
346
+ this.autoTimer = undefined;
347
+ }
348
+
349
+ get snapshot() { return this.facts.snapshot; }
350
+ }
@@ -0,0 +1,39 @@
1
+ /** A compact projection of Host history plus the current unsaved voice caption. */
2
+ export class RecentBubble {
3
+ constructor(rows) {
4
+ this.rows = rows;
5
+ this.history = [];
6
+ this.live = new Map();
7
+ }
8
+ load(messages) {
9
+ this.history = messages.slice(-2);
10
+ this.render();
11
+ }
12
+ append(role, text) {
13
+ if (!['user', 'assistant'].includes(role) || typeof text !== 'string') return;
14
+ this.live.set(role, `${this.live.get(role) ?? ''}${text}`.slice(-240));
15
+ this.render();
16
+ }
17
+ finish(role) {
18
+ this.live.delete(role);
19
+ this.render();
20
+ }
21
+ reset() {
22
+ this.live.clear();
23
+ this.render();
24
+ }
25
+ hasContent() {
26
+ return this.history.length > 0 || this.live.size > 0;
27
+ }
28
+ render() {
29
+ const live = [...this.live].map(([role, text]) => ({ role, name: role === 'user' ? '你' : '猫猫', text }));
30
+ const entries = [...this.history.slice(-Math.max(0, 2 - live.length)), ...live].slice(-2);
31
+ for (const [index, row] of this.rows.entries()) {
32
+ const entry = entries[index];
33
+ row.hidden = !entry;
34
+ row.textContent = entry ? `${String(entry.name).slice(0, 28)}:${String(entry.text).slice(-120)}` : '';
35
+ row.dataset ??= {};
36
+ row.dataset.role = entry?.role ?? '';
37
+ }
38
+ }
39
+ }
Binary file
Binary file