@clowder-ai/companion 0.1.0-alpha.8 → 0.1.0-alpha.9

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.
package/README.md CHANGED
@@ -24,12 +24,16 @@ Hosts reject this manifest before installation. Media admission and capture
24
24
  remain in the trusted Host; this package receives neither streams nor provider
25
25
  SDP. Hiding releases media; the existing Clowder **聊聊** entry restores the cat.
26
26
 
27
- Xianxian uses one layered sitting body or one VP9 clip at a time. Actual Host
28
- activity, durable answers and owner-scoped pending decisions select thought,
29
- delivery and waiting actions. Dragging selects the running clip; an explicit
30
- play gesture selects pounce. Reduced motion keeps the body static. Other cats
31
- retain their own skins. The v2 living assets and their hashes are pinned in
32
- `source-lock.json`; the installable renderer carries all 20 assets itself.
27
+ Xianxian uses one layered sitting body or one VP9 clip at a time. Expiring Host
28
+ facts with task, result and event identities select tool work, workspace fetch,
29
+ screen reading, applied-result delivery and autonomous target movement.
30
+ Dragging instead freezes a visibly held candidate and cancels the old target;
31
+ it never renames the running clip. Pounce requires an explicit play gesture by
32
+ default; optional automatic play is bounded by idle and cooldown timers.
33
+ Reduced motion keeps semantic poses static and suppresses roaming and pounce.
34
+ Other cats retain their own skins. The v3 living assets and their hashes are
35
+ pinned in `source-lock.json`; the installable renderer carries all 21 assets
36
+ itself, including the mid-edge `peek_fade` variant.
33
37
 
34
38
  The Host keeps a small transparent margin left of the pet hit target so the
35
39
  pounce's tail is visible without turning empty space into a click target.
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.1.0-alpha.9",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -40,7 +40,7 @@
40
40
  "bridgeVersion": "1.3.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-I7pGRaZJIHoBrsk0si+5MAm7VCAjc3AzQNjeo+ra3Is="
43
+ "integrity": "sha256-x3ponGs/dhxPH5D1rT9JrLqJpFA6rxrgMjSImhksoHQ="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clowder-ai/companion",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.1.0-alpha.9",
4
4
  "description": "The desktop voice body for your Clowder companion",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  "prepack": "pnpm build"
27
27
  },
28
28
  "devDependencies": {
29
- "@clowder-ai/plugin-contract": "0.1.0-beta.18",
30
- "@clowder-ai/plugin-sdk": "0.1.0-beta.14"
29
+ "@clowder-ai/plugin-contract": "0.1.0-beta.19",
30
+ "@clowder-ai/plugin-sdk": "0.1.0-beta.15"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.1.0-alpha.9",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -40,7 +40,7 @@
40
40
  "bridgeVersion": "1.3.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-I7pGRaZJIHoBrsk0si+5MAm7VCAjc3AzQNjeo+ra3Is="
43
+ "integrity": "sha256-x3ponGs/dhxPH5D1rT9JrLqJpFA6rxrgMjSImhksoHQ="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
@@ -33,6 +33,7 @@
33
33
  </g>
34
34
  </svg>
35
35
  <video id="living-video" muted playsinline preload="metadata" aria-hidden="true" hidden></video>
36
+ <video id="living-video-transition" muted playsinline preload="metadata" aria-hidden="true" hidden></video>
36
37
  </button>
37
38
  <div id="indicators">
38
39
  <button id="call-badge" hidden aria-label="结束语音" title="结束语音"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4M8 22h8"/></svg></button>
@@ -1,5 +1,4 @@
1
- // F317 living Xianxian package v1. All clips share the layered sit's ground line.
2
- // Values are the package-v1 CSS anchors scaled from its 290px stage to the Host pet.
1
+ // F317 living Xianxian package v3. Values are CSS anchors from its reviewed manifest.
3
2
  const scale = 120 / 290;
4
3
  const groundY = 10 + 262.5 * scale;
5
4
  const clips = {
@@ -7,77 +6,166 @@ const clips = {
7
6
  'running-right': { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
8
7
  running: { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
9
8
  play: { name: 'pounce', size: [304.8, 312.6], ground: 303.9, centre: 201.9, rate: 1.5 },
10
- sleeping: { name: 'sleep', size: [468.5, 262.1], ground: 256.4, centre: 238.9, loop: true },
11
- waking: { name: 'wake', size: [468.5, 262.1], ground: 256.4, centre: 238.9 },
9
+ sleeping: { name: 'sleep', size: [271.2, 168], ground: 161, centre: 133.2, loop: true },
10
+ waking: { name: 'wake', size: [400.2, 282.7], ground: 244.2, centre: 200.5,
11
+ endGround: 277.4, endCentre: 197.7, durationMs: 7_250, crossfadeMs: 250 },
12
12
  peek: { name: 'peek', size: [153.5, 234.3], ground: 234.3, centre: 76.75, loop: true },
13
+ delivering: { name: 'carry', size: [344.3, 290.9], ground: 263, centre: 198.3 },
13
14
  review: { name: 'carry', size: [344.3, 290.9], ground: 263, centre: 198.3 },
14
15
  pending_decision: { name: 'wait', size: [197.9, 252.8], ground: 249.6, centre: 98.4, loop: true },
15
16
  working: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
17
+ fetching: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
18
+ studying: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
16
19
  staged_thought: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
17
20
  };
18
21
  export const isLivingClip = action => Object.hasOwn(clips, action);
19
22
  const setHidden = (node, hidden) => {
20
- // SVGElement.hidden is not consistently reflected to the HTML hidden
21
- // attribute; the renderer's [hidden] rule reads the attribute itself.
23
+ if (!node) return;
22
24
  node.toggleAttribute?.('hidden', hidden);
23
25
  node.hidden = hidden;
24
26
  };
25
27
 
26
- /** One visible body: a layered sit or one muted clip, never both. */
28
+ /** One visible body: a layered sit or one muted clip, with a bounded sleep/wake hand-off. */
27
29
  export class LivingBody {
28
- constructor({ root, sit, video }) {
30
+ constructor({ root, sit, video, transitionVideo, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout }) {
29
31
  this.root = root;
30
32
  this.sit = sit;
31
33
  this.video = video;
34
+ this.standby = transitionVideo;
35
+ this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
36
+ this.clearTimer = id => clearTimer.call(globalThis, id);
32
37
  this.action = '';
33
38
  this.revision = 0;
34
39
  }
40
+
35
41
  show(action, { reducedMotion = false, onEnded = () => {} } = {}) {
36
42
  const dockEdge = this.root.dataset.dockEdge;
37
- if (this.action === action && this.reducedMotion === reducedMotion && this.dockEdge === dockEdge) return;
43
+ const dockZone = this.root.dataset.dockZone;
44
+ if (this.action === action && this.reducedMotion === reducedMotion && this.dockEdge === dockEdge && this.dockZone === dockZone) return;
45
+ const previous = this.action;
38
46
  this.action = action;
39
47
  this.reducedMotion = reducedMotion;
40
48
  this.dockEdge = dockEdge;
49
+ this.dockZone = dockZone;
41
50
  const revision = ++this.revision;
42
- const clip = !reducedMotion && clips[action];
43
- this.root.dataset.pose = action === 'failed' ? 'scared' : action === 'waiting' ? 'listening' :
44
- action === 'sleeping' ? 'sleeping' : action === 'pending_decision' ? 'pending' : 'sit';
45
- this.root.dataset.direction = action === 'running-left' || (action === 'peek' && this.root.dataset.dockEdge === 'left') ? 'left' : 'right';
46
- this.video.onended = null;
47
- this.video.pause();
51
+ this.cancelFade();
52
+ this.root.dataset.pose = this.pose(action);
53
+ this.root.dataset.direction = action === 'running-left' || (action === 'peek' && dockEdge === 'left') ? 'left' : 'right';
54
+ const base = clips[action];
55
+ const clip = !reducedMotion && base
56
+ ? { ...base, name: action === 'peek' && dockZone !== 'corner' ? 'peek_fade' : base.name }
57
+ : null;
48
58
  if (!clip) {
49
- setHidden(this.video, true);
59
+ this.hideVideos();
50
60
  setHidden(this.sit, false);
51
61
  return;
52
62
  }
53
- this.video.src = `./skins/xianxian-living/clips/${clip.name}.webm`;
54
- this.video.loop = clip.loop === true;
55
- this.video.playbackRate = clip.rate ?? 1;
56
- this.video.style.width = `${clip.size[0] * scale}px`;
57
- this.video.style.height = `${clip.size[1] * scale}px`;
58
- this.video.style.left = action === 'peek'
59
- ? `${this.root.dataset.dockEdge === 'left' ? -32 : 128 - clip.size[0] * scale}px`
60
- : `${60 - clip.centre * scale}px`;
61
- this.video.style.top = `${action === 'peek' ? 138 - clip.size[1] * scale : groundY - clip.ground * scale}px`;
62
- try { this.video.currentTime = 0; } catch { /* New source is not yet seekable. */ }
63
- this.video.onended = () => { if (revision === this.revision) onEnded(); };
63
+ const crossfade = previous === 'sleeping' && action === 'waking' && this.standby;
64
+ if (crossfade) this.crossfade(clip, revision, onEnded);
65
+ else this.showClip(this.video, clip, revision, onEnded);
66
+ }
67
+
68
+ pose(action) {
69
+ if (action === 'failed') return 'scared';
70
+ if (action === 'waiting') return 'listening';
71
+ if (['sleeping', 'pending_decision', 'held', 'working', 'fetching', 'studying'].includes(action))
72
+ return action === 'pending_decision' ? 'pending' : action;
73
+ return 'sit';
74
+ }
75
+
76
+ showClip(video, clip, revision, onEnded) {
77
+ this.retireVideo(this.standby);
78
+ this.configure(video, clip, revision, onEnded);
79
+ video.style.opacity = '1';
80
+ setHidden(this.sit, true);
81
+ setHidden(video, false);
82
+ this.play(video, revision, onEnded);
83
+ }
84
+
85
+ crossfade(clip, revision, onEnded) {
86
+ const outgoing = this.video;
87
+ const incoming = this.standby;
88
+ this.configure(incoming, clip, revision, onEnded);
89
+ incoming.style.transition = `opacity ${clip.crossfadeMs}ms linear`;
90
+ outgoing.style.transition = `opacity ${clip.crossfadeMs}ms linear`;
91
+ incoming.style.opacity = '0';
92
+ outgoing.style.opacity = '1';
64
93
  setHidden(this.sit, true);
65
- setHidden(this.video, false);
66
- void this.video.play().catch(() => {
94
+ setHidden(outgoing, false);
95
+ setHidden(incoming, false);
96
+ this.play(incoming, revision, onEnded);
97
+ incoming.style.opacity = '1';
98
+ outgoing.style.opacity = '0';
99
+ this.video = incoming;
100
+ this.standby = outgoing;
101
+ this.fadeTimer = this.setTimer(() => {
102
+ this.fadeTimer = undefined;
67
103
  if (revision !== this.revision) return;
68
- setHidden(this.video, true);
104
+ this.retireVideo(outgoing);
105
+ }, clip.crossfadeMs);
106
+ }
107
+
108
+ configure(video, clip, revision, onEnded) {
109
+ video.onended = null;
110
+ video.pause();
111
+ video.src = `./skins/xianxian-living/clips/${clip.name}.webm`;
112
+ video.loop = clip.loop === true;
113
+ video.playbackRate = clip.rate ?? 1;
114
+ video.style.width = `${clip.size[0] * scale}px`;
115
+ video.style.height = `${clip.size[1] * scale}px`;
116
+ const left = this.action === 'peek'
117
+ ? (this.root.dataset.dockEdge === 'left' ? -32 : 128 - clip.size[0] * scale)
118
+ : 60 - clip.centre * scale;
119
+ const top = this.action === 'peek' ? 138 - clip.size[1] * scale : groundY - clip.ground * scale;
120
+ video.style.left = `${left}px`;
121
+ video.style.top = `${top}px`;
122
+ video.style.animation = clip.endGround
123
+ ? `living-wake-anchor ${clip.durationMs}ms linear forwards` : 'none';
124
+ if (clip.endGround) {
125
+ video.style.setProperty?.('--living-left-start', `${left}px`);
126
+ video.style.setProperty?.('--living-top-start', `${top}px`);
127
+ video.style.setProperty?.('--living-left-end', `${60 - clip.endCentre * scale}px`);
128
+ video.style.setProperty?.('--living-top-end', `${groundY - clip.endGround * scale}px`);
129
+ }
130
+ try { video.currentTime = 0; } catch { /* New source is not yet seekable. */ }
131
+ video.onended = () => { if (revision === this.revision) onEnded(); };
132
+ }
133
+
134
+ play(video, revision, onEnded) {
135
+ void video.play().catch(() => {
136
+ if (revision !== this.revision) return;
137
+ this.hideVideos();
69
138
  setHidden(this.sit, false);
70
139
  onEnded();
71
140
  });
72
141
  }
142
+
143
+ hideVideos() {
144
+ for (const video of [this.video, this.standby]) {
145
+ this.retireVideo(video);
146
+ }
147
+ }
148
+
149
+ retireVideo(video) {
150
+ if (!video) return;
151
+ video.onended = null;
152
+ video.pause();
153
+ setHidden(video, true);
154
+ }
155
+
156
+ cancelFade() {
157
+ if (this.fadeTimer !== undefined) this.clearTimer(this.fadeTimer);
158
+ this.fadeTimer = undefined;
159
+ }
160
+
73
161
  close() {
74
162
  this.revision++;
75
- this.video.onended = null;
76
- this.video.pause();
163
+ this.cancelFade();
164
+ this.hideVideos();
77
165
  }
166
+
78
167
  hide() {
79
168
  this.close();
80
- setHidden(this.video, true);
81
169
  setHidden(this.sit, true);
82
170
  this.action = '';
83
171
  }
@@ -4,7 +4,13 @@ export function detectDockedEdge(win, petRect) {
4
4
  const width = win.screen?.availWidth;
5
5
  if (![win.screenX, left, width, petRect.left, petRect.width].every(Number.isFinite) || width <= 0) return null;
6
6
  const petLeft = win.screenX + petRect.left;
7
- if (petLeft - left <= 40) return 'left';
8
- if (left + width - petLeft - petRect.width <= 40) return 'right';
9
- return null;
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' };
10
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
+ }
@@ -1,4 +1,5 @@
1
1
  import { isLivingClip } from './living-body.mjs';
2
+ import { MotionFacts } from './motion-facts.mjs';
2
3
 
3
4
  // F229 pet.json frame durations, plus the three F258 rows pinned in source-lock.json.
4
5
  const atlas = {
@@ -17,10 +18,13 @@ const additional = {
17
18
  working: { file: 'xianxian-working-row.png', frames: [520, 560, 520, 640, 520, 560, 520, 640] },
18
19
  staged_thought: { file: 'xianxian-staged-thought-row.png', frames: [1200, 500, 700, 900, 1200, 500] },
19
20
  };
20
- const signals = { connected: 'waving', received: 'jumping', answered: 'review', failed: 'failed', play: 'play' };
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' };
21
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;
22
26
 
23
- /** All animation choices come from Host state or a completed local gesture. */
27
+ /** Select one truthful body from Host facts and local, user-controlled gestures. */
24
28
  export class PetMotion {
25
29
  constructor(element, options = {}) {
26
30
  this.element = element;
@@ -28,88 +32,206 @@ export class PetMotion {
28
32
  const clearTimer = options.clearTimer ?? globalThis.clearTimeout;
29
33
  this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
30
34
  this.clearTimer = id => clearTimer.call(globalThis, id);
35
+ this.now = options.now ?? Date.now;
31
36
  this.livingBody = options.livingBody;
32
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;
33
42
  this.skin = 'xianxian-codex';
34
43
  this.phase = 'idle';
35
44
  this.base = 'idle';
36
45
  this.action = '';
37
46
  this.frame = 0;
38
- this.moving = false;
47
+ this.dragging = false;
39
48
  this.oneShot = false;
40
49
  this.pendingDecision = false;
41
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();
42
57
  }
58
+
43
59
  setContext({ skin, phase, nativeActivity, muted }) {
44
- this.context = { skin, phase, nativeActivity, muted };
45
- const selectedSkin = ['xianxian-codex', 'yanyan-codex', ...Object.keys(staticSkins)].includes(skin) ? skin : 'xianxian-codex';
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 };
46
64
  const changedSkin = selectedSkin !== this.skin;
47
- const changedPhase = phase !== this.phase;
48
65
  this.skin = selectedSkin;
49
66
  this.phase = phase;
50
- const requested = this.pendingDecision ? 'pending_decision' : nativeActivity === 'tool_running' ? 'working' : nativeActivity === 'reasoning' ? 'staged_thought'
51
- : ['talking', 'connecting'].includes(phase) && !muted ? 'waiting' : this.dockedEdge ? 'peek' : 'idle';
52
- const nextBase = this.resolve(requested);
53
- const changedBase = nextBase !== this.base;
54
- this.base = nextBase;
55
- if (phase !== 'idle') this.cancelRest();
56
- if (phase === 'idle' && changedPhase) { this.moving = false; this.oneShot = false; }
57
- const waking = this.living() && !this.reducedMotion && this.action === 'sleeping' && this.base === 'waiting';
58
- if (waking) { this.oneShot = true; this.play('waking'); }
59
- else if (this.oneShot && this.action === 'waking' && ['working', 'staged_thought', 'pending_decision'].includes(this.base)) {
60
- this.oneShot = false;
61
- this.play(this.base);
62
- } else if (!this.moving && !this.oneShot && (changedSkin || changedBase || changedPhase || !this.action)) this.play(this.base, changedSkin);
63
- if (phase === 'idle' && this.action === 'idle') this.armRest();
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);
64
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
+
65
94
  setPendingDecision(value) {
66
- if (this.pendingDecision === value) return;
67
- this.pendingDecision = value;
68
- if (this.context) this.setContext(this.context);
69
- if (value === true && this.oneShot) {
70
- this.oneShot = false;
71
- this.play(this.base);
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();
72
102
  }
103
+ this.refresh();
73
104
  }
105
+
74
106
  setDockedEdge(value) {
75
- const edge = ['left', 'right'].includes(value) ? value : null;
76
- if (this.dockedEdge === edge) return;
77
- const previous = this.dockedEdge;
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;
78
111
  this.dockedEdge = edge;
79
- if (this.context) this.setContext(this.context);
80
- if (previous && edge && this.action === 'peek') this.play('peek', true);
112
+ this.dockZone = zone;
113
+ if (edge) this.cancelIdleTimers();
114
+ this.refresh(this.action === 'peek');
81
115
  }
82
- signal(kind) {
83
- const requested = kind === 'received' && this.living() && this.action === 'sleeping' ? 'waking' : signals[kind];
84
- if (kind === 'play' && (this.pendingDecision || this.phase !== 'idle' || this.context?.nativeActivity !== 'none')) return;
85
- if (!requested || this.moving || this.reducedMotion || staticSkins[this.skin]) return;
86
- this.cancelRest();
87
- this.oneShot = true;
88
- this.play(this.resolve(requested));
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();
89
141
  }
142
+
90
143
  move(dx, dy) {
91
144
  if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 6) return;
92
- this.moving = true;
93
- this.oneShot = false;
94
- this.cancelRest();
95
- this.play(this.resolve(Math.abs(dx) >= Math.abs(dy) ? dx < 0 ? 'running-left' : 'running-right' : 'running'));
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();
96
154
  }
155
+
97
156
  stopMove() {
98
- if (!this.moving) return;
99
- this.moving = false;
100
- this.play(this.base);
101
- if (this.phase === 'idle') this.armRest();
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();
102
169
  }
103
- close() { this.cancelFrame(); this.cancelRest(); this.livingBody?.close(); }
104
170
  living() { return this.skin === 'xianxian-codex' && Boolean(this.livingBody); }
105
171
  resolve(action) {
106
172
  if (action === 'pending_decision' && !this.living()) return 'waiting';
107
173
  if (action === 'peek' && !this.living()) return 'idle';
108
174
  if (staticSkins[this.skin]) return 'idle';
109
- if (this.skin !== 'xianxian-codex' && action in additional)
110
- return action === 'sleeping' ? 'idle' : 'review';
175
+ const visual = visualAlias[action] ?? action;
176
+ if (this.skin !== 'xianxian-codex' && visual in additional)
177
+ return visual === 'sleeping' ? 'idle' : 'review';
111
178
  return action;
112
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
+
113
235
  play(action, force = false) {
114
236
  if (this.action === action && !force) return;
115
237
  this.cancelFrame();
@@ -118,11 +240,13 @@ export class PetMotion {
118
240
  this.paint();
119
241
  this.scheduleFrame();
120
242
  }
243
+
121
244
  paint() {
122
245
  const { style, dataset } = this.element;
123
246
  dataset.skin = this.skin;
124
247
  dataset.action = this.action;
125
248
  dataset.dockEdge = this.dockedEdge ?? 'none';
249
+ dataset.dockZone = this.dockZone ?? 'none';
126
250
  if (this.living()) {
127
251
  style.backgroundImage = 'none';
128
252
  this.livingBody.show(this.action, { reducedMotion: this.reducedMotion, onEnded: () => this.completeOneShot() });
@@ -136,27 +260,31 @@ export class PetMotion {
136
260
  style.backgroundPosition = 'center';
137
261
  return;
138
262
  }
139
- const extra = this.skin === 'xianxian-codex' ? additional[this.action] : undefined;
263
+ const visual = visualAlias[this.action] ?? this.action;
264
+ const extra = this.skin === 'xianxian-codex' ? additional[visual] : undefined;
140
265
  style.backgroundImage = `url("./skins/${extra?.file ?? `${this.skin}.webp`}")`;
141
266
  style.backgroundSize = extra ? '960px 130px' : '960px 1170px';
142
- style.backgroundPosition = `${-120 * this.frame}px ${-130 * (atlas[this.action]?.row ?? 0)}px`;
267
+ style.backgroundPosition = `${-120 * this.frame}px ${-130 * (atlas[visual]?.row ?? 0)}px`;
143
268
  }
269
+
144
270
  scheduleFrame() {
145
- if (this.reducedMotion || staticSkins[this.skin]) return;
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;
146
276
  if (this.living()) {
147
277
  if (isLivingClip(this.action) || !this.oneShot) return;
148
278
  this.frameTimer = this.setTimer(() => { this.frameTimer = undefined; this.completeOneShot(); }, 700);
149
279
  return;
150
280
  }
151
- const frames = additional[this.action]?.frames ?? atlas[this.action]?.frames;
281
+ const visual = visualAlias[this.action] ?? this.action;
282
+ const frames = additional[visual]?.frames ?? atlas[visual]?.frames;
152
283
  if (!frames) return;
153
284
  this.frameTimer = this.setTimer(() => {
154
285
  this.frameTimer = undefined;
155
286
  if (this.frame + 1 === frames.length && this.oneShot) {
156
- this.oneShot = false;
157
- this.action = '';
158
- this.play(this.base);
159
- if (this.phase === 'idle') this.armRest();
287
+ this.completeOneShot();
160
288
  return;
161
289
  }
162
290
  this.frame = (this.frame + 1) % frames.length;
@@ -164,20 +292,59 @@ export class PetMotion {
164
292
  this.scheduleFrame();
165
293
  }, frames[this.frame]);
166
294
  }
167
- cancelFrame() { if (this.frameTimer !== undefined) this.clearTimer(this.frameTimer); this.frameTimer = undefined; }
295
+
296
+ cancelFrame() {
297
+ if (this.frameTimer !== undefined) this.clearTimer(this.frameTimer);
298
+ this.frameTimer = undefined;
299
+ }
300
+
168
301
  completeOneShot() {
169
302
  if (!this.oneShot) return;
170
303
  this.oneShot = false;
304
+ this.transient = null;
171
305
  this.action = '';
172
- this.play(this.base);
173
- if (this.phase === 'idle') this.armRest();
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];
174
313
  }
314
+
175
315
  armRest() {
176
- if (this.restTimer !== undefined || this.action === 'sleeping' || this.pendingDecision || this.dockedEdge) return;
177
- this.restTimer = this.setTimer(() => {
178
- this.restTimer = undefined;
179
- if (this.phase === 'idle' && !this.moving && !this.oneShot) this.play(this.resolve('sleeping'));
180
- }, 45_000);
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
+ }
181
336
  }
182
- cancelRest() { if (this.restTimer !== undefined) this.clearTimer(this.restTimer); this.restTimer = undefined; }
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; }
183
350
  }
@@ -14,8 +14,9 @@ button:disabled { opacity: .45; cursor: default; }
14
14
  #pet.dragging { cursor: grabbing; }
15
15
  #pet[data-skin="xianxian-codex"] { position: relative; background-image: none; overflow: visible; }
16
16
  #living-sit { position: absolute; left: 0; bottom: 10px; width: 120px; height: 120px; overflow: visible; pointer-events: none; }
17
- #living-video { position: absolute; object-fit: fill; pointer-events: none; }
18
- #pet[data-direction="left"] #living-video { transform: scaleX(-1); }
17
+ #living-video, #living-video-transition { position: absolute; object-fit: fill; pointer-events: none; }
18
+ #pet[data-direction="left"] #living-video, #pet[data-direction="left"] #living-video-transition { transform: scaleX(-1); }
19
+ #pet[data-action="held"] #living-sit { transform: translateY(-4px) scale(.98); transform-origin: 50% 80%; }
19
20
  #living-sit [data-layer="tail"] { transform-box: view-box; transform-origin: 168px 456px; animation: living-tail 3.4s ease-in-out infinite; }
20
21
  #living-sit [data-layer="head"] { transform-box: view-box; transform-origin: 300px 280px; animation: living-head 5.5s ease-in-out infinite; }
21
22
  #living-sit [data-layer="ear-left"], #living-sit [data-layer="ear-right"] { transform-box: view-box; }
@@ -40,6 +41,10 @@ button:disabled { opacity: .45; cursor: default; }
40
41
  @keyframes living-idle-ear-left { 0%, 68%, 76%, 100% { transform: rotate(0); } 71% { transform: rotate(-9deg); } 73% { transform: rotate(3deg); } }
41
42
  @keyframes living-idle-ear-right { 0%, 30%, 39%, 100% { transform: rotate(0); } 33% { transform: rotate(8deg); } 36% { transform: rotate(-3deg); } }
42
43
  @keyframes living-idle-blink { 0%, 24%, 30%, 74%, 80%, 100% { opacity: 0; } 25%, 29%, 75%, 79% { opacity: 1; } }
44
+ @keyframes living-wake-anchor {
45
+ from { left: var(--living-left-start); top: var(--living-top-start); }
46
+ to { left: var(--living-left-end); top: var(--living-top-end); }
47
+ }
43
48
  #indicators { position: absolute; bottom: 0; right: 0; display: flex; gap: 4px; }
44
49
  #indicators button { width: 32px; height: 32px; border: 1px solid #fff9; border-radius: 50%; display: grid; place-items: center; background: #236b59; color: white; box-shadow: 0 2px 5px #0003; }
45
50
  #indicators button:hover { background: #ab4842; }
@@ -10,13 +10,15 @@ import { LivingBody } from './living-body.mjs';
10
10
  import { detectDockedEdge } from './living-edge.mjs';
11
11
  import { bindPetControls } from './pet-controls.mjs';
12
12
  import { decisionBadge, decisionRows } from './decision-view.mjs';
13
+ import { normalizeNativeWork } from './native-work-motion.mjs';
13
14
 
14
15
  const $ = id => document.getElementById(id);
15
16
  const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
16
17
  const status = text => { $('status').textContent = text; $('chat-status').textContent = text; $('announcement').textContent = text; };
17
18
  const transcript = new TranscriptView($('transcript'));
18
19
  const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
19
- const motion = new PetMotion($('pet'), { livingBody: new LivingBody({ root: $('pet'), sit: $('living-sit'), video: $('living-video') }) });
20
+ const motion = new PetMotion($('pet'), { livingBody: new LivingBody({ root: $('pet'), sit: $('living-sit'),
21
+ video: $('living-video'), transitionVideo: $('living-video-transition') }) });
20
22
  let sharing = false, pendingScreen = false, loading = false, latestHistory, previousPhase = 'idle';
21
23
  let observedHistory = false, lastAssistantId;
22
24
  let resultPreviewUntil = 0;
@@ -38,6 +40,7 @@ if (!window.clowderCompanion) {
38
40
  motion.stopMove();
39
41
  if (!didMove) return;
40
42
  didMove = false;
43
+ void conversation.refresh();
41
44
  setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
42
45
  },
43
46
  changed: panel => { if (panel === 'chat') void readHistory(); if (panel === 'decisions') void readDecisions(); },
@@ -79,7 +82,7 @@ if (!window.clowderCompanion) {
79
82
  const conversation = new CompanionConversation({
80
83
  client, createPeer: callback => new VoicePeer(callback, client), stopScreen: () => screen.stop(),
81
84
  transcript: event => {
82
- if (event.type === 'turn-done') { transcript.finish(event); bubble.finish(event.role); if (event.role === 'assistant') motion.signal('answered'); void readHistory(); }
85
+ if (event.type === 'turn-done') { transcript.finish(event); bubble.finish(event.role); void readHistory(); }
83
86
  else if (event.typed) void readHistory();
84
87
  else { transcript.append(event.role, event.text, true); bubble.append(event.role, event.text); void controls.setAmbient(true); }
85
88
  },
@@ -106,6 +109,7 @@ if (!window.clowderCompanion) {
106
109
  label('speaker', value.silent ? '开启播音' : '关闭播音');
107
110
  const identity = value.identity;
108
111
  motion.setContext({ skin: identity?.skin, phase: value.phase, nativeActivity: identity?.nativeActivity ?? 'none', muted: value.muted });
112
+ motion.syncSnapshot(normalizeNativeWork(identity?.nativeWork));
109
113
  if (justConnected) motion.signal('connected');
110
114
  if (showFailure) motion.signal('failed');
111
115
  if (identity) {
@@ -114,7 +118,11 @@ if (!window.clowderCompanion) {
114
118
  $('pet').setAttribute('aria-label', `${identity.displayName}:点击交流,右键更多,拖动移动`);
115
119
  $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
116
120
  $('documents').setAttribute('aria-pressed', String(identity.documentsAllowed));
117
- $('connection-scope').textContent = identity.duty.catId === identity.carrier.catId ? '交流保存在同一段聊天中' : `${identity.duty.displayName} · 实时语音由${identity.carrier.displayName}承载`;
121
+ const live = identity.liveTransport?.kind === 'gpt_live_v3'
122
+ ? 'GPT Live · 实时型号未证实' : '实时载体未确认';
123
+ $('connection-scope').textContent = identity.duty.catId === identity.carrier.catId
124
+ ? `${identity.duty.displayName} · ${live}`
125
+ : `${identity.duty.displayName} · ${live} · 绑定载体 ${identity.carrier.displayName}`;
118
126
  }
119
127
  if (showFailure) void controls.show('actions');
120
128
  },
@@ -126,7 +134,6 @@ if (!window.clowderCompanion) {
126
134
  const history = await client.readConversation();
127
135
  const assistantId = history.messages.findLast(message => message.role === 'assistant')?.id;
128
136
  if (observedHistory && assistantId && assistantId !== lastAssistantId) {
129
- motion.signal('answered');
130
137
  resultPreviewUntil = Date.now() + 12_000;
131
138
  }
132
139
  observedHistory = true;
package/source-lock.json CHANGED
@@ -68,8 +68,8 @@
68
68
  }
69
69
  ],
70
70
  "livingSkin": {
71
- "package": "xianxian-living package-v2",
72
- "manifestSha256": "08491ed557a82bc1860bbaeecd094f4a7e637479e78af8b411786f19a5164da0",
71
+ "package": "xianxian-living package-v3",
72
+ "manifestSha256": "a7434caa0cd559bfb8747a9d9391fb5219d4c4a68639b5b669008dd7c7b67aaf",
73
73
  "creator": "opus55; keyframes by codex-astra; source videos by CVO",
74
74
  "files": [
75
75
  {
@@ -130,16 +130,20 @@
130
130
  },
131
131
  {
132
132
  "destination": "assets/skins/xianxian-living/clips/sleep.webm",
133
- "sha256": "e39554e7aca7b1c51b388b73f7dbc08e3fa7ddafef395cb28c1099d41a9b41d8"
133
+ "sha256": "f53ae41cc7722128e68f4b5cf3df980be216a79f8cc4b8dff36fa0dc029dbca9"
134
134
  },
135
135
  {
136
136
  "destination": "assets/skins/xianxian-living/clips/wake.webm",
137
- "sha256": "73b455088e53edf2849e888e0ecb99953f7e5dba373592fb6232f9736b6da3a0"
137
+ "sha256": "8821cb764d7ca085f4b30dec2f893f2515d383cc1f2053215e91b18b86753cd7"
138
138
  },
139
139
  {
140
140
  "destination": "assets/skins/xianxian-living/clips/peek.webm",
141
141
  "sha256": "adebecf89b724eec9852d097364e10bc328d006ce3bb3bd3f4b257c095d57a92"
142
142
  },
143
+ {
144
+ "destination": "assets/skins/xianxian-living/clips/peek_fade.webm",
145
+ "sha256": "7880e695fa8afe783c4f1fcb4fc1c31c12d64d36f3d124dc4ef929ad09f7ffef"
146
+ },
143
147
  {
144
148
  "destination": "assets/skins/xianxian-living/clips/carry.webm",
145
149
  "sha256": "4fa582ab6a18b538cfe815ab19e73da30c0da061ec661bb12e4060f8b3d2e600"