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

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 (33) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +10 -3
  3. package/manifest.json +2 -2
  4. package/package.json +1 -1
  5. package/plugin.yaml +2 -2
  6. package/renderer/index.html +22 -1
  7. package/renderer/living-body.mjs +84 -0
  8. package/renderer/living-edge.mjs +10 -0
  9. package/renderer/pet-motion.mjs +60 -9
  10. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  11. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  12. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  13. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  14. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  15. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  16. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  17. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  18. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  19. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  20. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  21. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  22. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  23. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  24. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  25. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  26. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  27. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  28. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  29. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  30. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  31. package/renderer/style.css +35 -3
  32. package/renderer/surface.mjs +32 -6
  33. package/source-lock.json +92 -1
package/NOTICE CHANGED
@@ -1,7 +1,12 @@
1
- Companion imagery and theme tokens originate in Clowder AI (MIT).
2
- See source-lock.json for the exact public source revision and content hashes.
1
+ The original companion atlases and theme tokens originate in Clowder AI (MIT).
2
+ See source-lock.json for their exact public source revision and content hashes.
3
3
  The legacy ragdoll-v1 and yarn-ball stills are AI-assisted derivatives of the
4
4
  listed public reference, restoring the complete cat silhouette and transparent
5
5
  background. Their reference and resulting hashes are recorded separately.
6
+ The living Xianxian layers and clips are F317 assets: CVO-generated source video,
7
+ keyframes by codex-astra, and deterministic extraction/alignment by opus55.
8
+ source-lock.json pins the 20 bundled distributable files, their geometry file,
9
+ and the source package manifest digest. Source video and private workspace paths
10
+ are not bundled.
6
11
  The conversation transport and controls continue the F317 companion implementation
7
12
  by the Clowder AI contributors. No private runtime data or pet metadata is included.
package/README.md CHANGED
@@ -17,15 +17,22 @@ No second transcript database, credentials or selectable Host identity live here
17
17
 
18
18
  ## Host integration
19
19
 
20
- This surface requires the additive desktop bridge **1.1.0**. The Host computes
20
+ This surface requires the additive desktop bridge **1.3.0**. The Host computes
21
21
  placement within the display work area, keeps the cat as its stable anchor, and
22
22
  owns bounded drag gestures and transparent-area hit testing. Older 1.0-only
23
23
  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
- The original animated skin bytes remain unchanged. Public static skin derivatives
28
- and the shared color tokens retain their exact provenance in `source-lock.json`.
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.
33
+
34
+ The Host keeps a small transparent margin left of the pet hit target so the
35
+ pounce's tail is visible without turning empty space into a click target.
29
36
 
30
37
  ## Verification
31
38
 
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.8",
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-s7/ld37FUql3GVoL0uRSVMJnL8IcY3S22GQEYGlvGJ0="
43
+ "integrity": "sha256-I7pGRaZJIHoBrsk0si+5MAm7VCAjc3AzQNjeo+ra3Is="
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.6",
3
+ "version": "0.1.0-alpha.8",
4
4
  "description": "The desktop voice body for your Clowder companion",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.8",
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-s7/ld37FUql3GVoL0uRSVMJnL8IcY3S22GQEYGlvGJ0="
43
+ "integrity": "sha256-I7pGRaZJIHoBrsk0si+5MAm7VCAjc3AzQNjeo+ra3Is="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
@@ -13,7 +13,27 @@
13
13
  </svg>
14
14
  <main id="desktop" aria-label="猫猫球">
15
15
  <div id="anchor">
16
- <button id="pet" aria-label="猫猫:点击交流,右键更多,拖动移动" aria-expanded="false" aria-controls="actions menu chat"></button>
16
+ <button id="pet" aria-label="猫猫:点击交流,右键更多,拖动移动" aria-expanded="false" aria-controls="actions menu chat">
17
+ <svg id="living-sit" viewBox="0 0 600 600" aria-hidden="true" hidden>
18
+ <defs>
19
+ <mask id="living-eye-left" maskUnits="userSpaceOnUse" x="0" y="0" width="600" height="600"><image href="./skins/xianxian-living/layers/whiteL.webp" width="600" height="600"/></mask>
20
+ <mask id="living-eye-right" maskUnits="userSpaceOnUse" x="0" y="0" width="600" height="600"><image href="./skins/xianxian-living/layers/whiteR.webp" width="600" height="600"/></mask>
21
+ </defs>
22
+ <g data-layer="tail"><image href="./skins/xianxian-living/layers/tail.webp" width="600" height="600"/></g>
23
+ <image href="./skins/xianxian-living/layers/body.webp" width="600" height="600"/>
24
+ <g data-layer="head">
25
+ <g data-layer="ear-left"><image href="./skins/xianxian-living/layers/earL.webp" width="600" height="600"/></g>
26
+ <g data-layer="ear-right"><image href="./skins/xianxian-living/layers/earR.webp" width="600" height="600"/></g>
27
+ <image href="./skins/xianxian-living/layers/head.webp" width="600" height="600"/>
28
+ <g mask="url(#living-eye-left)"><image data-layer="iris-left" href="./skins/xianxian-living/layers/irisL.webp" width="600" height="600"/></g>
29
+ <g mask="url(#living-eye-right)"><image data-layer="iris-right" href="./skins/xianxian-living/layers/irisR.webp" width="600" height="600"/></g>
30
+ <image data-layer="closed" href="./skins/xianxian-living/layers/closed.webp" width="600" height="600"/>
31
+ <image data-layer="mouth" href="./skins/xianxian-living/layers/mouth.webp" width="600" height="600"/>
32
+ <image data-layer="scared" href="./skins/xianxian-living/layers/scared.webp" width="600" height="600"/>
33
+ </g>
34
+ </svg>
35
+ <video id="living-video" muted playsinline preload="metadata" aria-hidden="true" hidden></video>
36
+ </button>
17
37
  <div id="indicators">
18
38
  <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>
19
39
  <button id="share-badge" hidden aria-label="停止共享" title="停止共享"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg></button>
@@ -40,6 +60,7 @@
40
60
  <button data-action="history"><svg class="icon" aria-hidden="true"><use href="#i-history"/></svg><span>聊天记录</span></button>
41
61
  <button data-action="decisions"><span aria-hidden="true">◇</span><span>待你看</span><span id="menu-pending" class="menu-status">—</span></button>
42
62
  <button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>打字</span></button>
63
+ <button id="menu-play" data-action="play" hidden><span aria-hidden="true">✦</span><span>逗宪宪</span></button>
43
64
  <div class="menu-divider" role="separator"></div>
44
65
  <button id="speaker" data-action="speaker" hidden><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">关闭播音</span></button>
45
66
  <button id="menu-share" data-action="share" title="语音连接后可共享画面"><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span class="label">共享画面</span></button>
@@ -0,0 +1,84 @@
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.
3
+ const scale = 120 / 290;
4
+ const groundY = 10 + 262.5 * scale;
5
+ const clips = {
6
+ 'running-left': { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
7
+ 'running-right': { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
8
+ running: { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
9
+ 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 },
12
+ peek: { name: 'peek', size: [153.5, 234.3], ground: 234.3, centre: 76.75, loop: true },
13
+ review: { name: 'carry', size: [344.3, 290.9], ground: 263, centre: 198.3 },
14
+ pending_decision: { name: 'wait', size: [197.9, 252.8], ground: 249.6, centre: 98.4, loop: true },
15
+ working: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
16
+ staged_thought: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
17
+ };
18
+ export const isLivingClip = action => Object.hasOwn(clips, action);
19
+ 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.
22
+ node.toggleAttribute?.('hidden', hidden);
23
+ node.hidden = hidden;
24
+ };
25
+
26
+ /** One visible body: a layered sit or one muted clip, never both. */
27
+ export class LivingBody {
28
+ constructor({ root, sit, video }) {
29
+ this.root = root;
30
+ this.sit = sit;
31
+ this.video = video;
32
+ this.action = '';
33
+ this.revision = 0;
34
+ }
35
+ show(action, { reducedMotion = false, onEnded = () => {} } = {}) {
36
+ const dockEdge = this.root.dataset.dockEdge;
37
+ if (this.action === action && this.reducedMotion === reducedMotion && this.dockEdge === dockEdge) return;
38
+ this.action = action;
39
+ this.reducedMotion = reducedMotion;
40
+ this.dockEdge = dockEdge;
41
+ 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();
48
+ if (!clip) {
49
+ setHidden(this.video, true);
50
+ setHidden(this.sit, false);
51
+ return;
52
+ }
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(); };
64
+ setHidden(this.sit, true);
65
+ setHidden(this.video, false);
66
+ void this.video.play().catch(() => {
67
+ if (revision !== this.revision) return;
68
+ setHidden(this.video, true);
69
+ setHidden(this.sit, false);
70
+ onEnded();
71
+ });
72
+ }
73
+ close() {
74
+ this.revision++;
75
+ this.video.onended = null;
76
+ this.video.pause();
77
+ }
78
+ hide() {
79
+ this.close();
80
+ setHidden(this.video, true);
81
+ setHidden(this.sit, true);
82
+ this.action = '';
83
+ }
84
+ }
@@ -0,0 +1,10 @@
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
+ if (petLeft - left <= 40) return 'left';
8
+ if (left + width - petLeft - petRect.width <= 40) return 'right';
9
+ return null;
10
+ }
@@ -1,3 +1,5 @@
1
+ import { isLivingClip } from './living-body.mjs';
2
+
1
3
  // F229 pet.json frame durations, plus the three F258 rows pinned in source-lock.json.
2
4
  const atlas = {
3
5
  idle: { row: 0, frames: [1200, 400, 400, 500, 500, 800] },
@@ -15,7 +17,7 @@ const additional = {
15
17
  working: { file: 'xianxian-working-row.png', frames: [520, 560, 520, 640, 520, 560, 520, 640] },
16
18
  staged_thought: { file: 'xianxian-staged-thought-row.png', frames: [1200, 500, 700, 900, 1200, 500] },
17
19
  };
18
- const signals = { connected: 'waving', received: 'jumping', answered: 'review', failed: 'failed' };
20
+ const signals = { connected: 'waving', received: 'jumping', answered: 'review', failed: 'failed', play: 'play' };
19
21
  const staticSkins = { 'ragdoll-v1': 'ragdoll-v1.png', 'yarn-ball': 'yarn-ball.png' };
20
22
 
21
23
  /** All animation choices come from Host state or a completed local gesture. */
@@ -26,6 +28,7 @@ export class PetMotion {
26
28
  const clearTimer = options.clearTimer ?? globalThis.clearTimeout;
27
29
  this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
28
30
  this.clearTimer = id => clearTimer.call(globalThis, id);
31
+ this.livingBody = options.livingBody;
29
32
  this.reducedMotion = options.reducedMotion ?? globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
30
33
  this.skin = 'xianxian-codex';
31
34
  this.phase = 'idle';
@@ -34,25 +37,51 @@ export class PetMotion {
34
37
  this.frame = 0;
35
38
  this.moving = false;
36
39
  this.oneShot = false;
40
+ this.pendingDecision = false;
41
+ this.dockedEdge = null;
37
42
  }
38
43
  setContext({ skin, phase, nativeActivity, muted }) {
44
+ this.context = { skin, phase, nativeActivity, muted };
39
45
  const selectedSkin = ['xianxian-codex', 'yanyan-codex', ...Object.keys(staticSkins)].includes(skin) ? skin : 'xianxian-codex';
40
46
  const changedSkin = selectedSkin !== this.skin;
41
47
  const changedPhase = phase !== this.phase;
42
48
  this.skin = selectedSkin;
43
49
  this.phase = phase;
44
- const requested = nativeActivity === 'tool_running' ? 'working' : nativeActivity === 'reasoning' ? 'staged_thought'
45
- : ['talking', 'connecting'].includes(phase) && !muted ? 'waiting' : 'idle';
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';
46
52
  const nextBase = this.resolve(requested);
47
53
  const changedBase = nextBase !== this.base;
48
54
  this.base = nextBase;
49
55
  if (phase !== 'idle') this.cancelRest();
50
56
  if (phase === 'idle' && changedPhase) { this.moving = false; this.oneShot = false; }
51
- if (!this.moving && !this.oneShot && (changedSkin || changedBase || changedPhase || !this.action)) this.play(this.base);
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);
52
63
  if (phase === 'idle' && this.action === 'idle') this.armRest();
53
64
  }
65
+ 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);
72
+ }
73
+ }
74
+ setDockedEdge(value) {
75
+ const edge = ['left', 'right'].includes(value) ? value : null;
76
+ if (this.dockedEdge === edge) return;
77
+ const previous = this.dockedEdge;
78
+ this.dockedEdge = edge;
79
+ if (this.context) this.setContext(this.context);
80
+ if (previous && edge && this.action === 'peek') this.play('peek', true);
81
+ }
54
82
  signal(kind) {
55
- const requested = signals[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;
56
85
  if (!requested || this.moving || this.reducedMotion || staticSkins[this.skin]) return;
57
86
  this.cancelRest();
58
87
  this.oneShot = true;
@@ -71,15 +100,18 @@ export class PetMotion {
71
100
  this.play(this.base);
72
101
  if (this.phase === 'idle') this.armRest();
73
102
  }
74
- close() { this.cancelFrame(); this.cancelRest(); }
103
+ close() { this.cancelFrame(); this.cancelRest(); this.livingBody?.close(); }
104
+ living() { return this.skin === 'xianxian-codex' && Boolean(this.livingBody); }
75
105
  resolve(action) {
106
+ if (action === 'pending_decision' && !this.living()) return 'waiting';
107
+ if (action === 'peek' && !this.living()) return 'idle';
76
108
  if (staticSkins[this.skin]) return 'idle';
77
109
  if (this.skin !== 'xianxian-codex' && action in additional)
78
110
  return action === 'sleeping' ? 'idle' : 'review';
79
111
  return action;
80
112
  }
81
- play(action) {
82
- if (this.action === action) return;
113
+ play(action, force = false) {
114
+ if (this.action === action && !force) return;
83
115
  this.cancelFrame();
84
116
  this.action = action;
85
117
  this.frame = 0;
@@ -90,6 +122,13 @@ export class PetMotion {
90
122
  const { style, dataset } = this.element;
91
123
  dataset.skin = this.skin;
92
124
  dataset.action = this.action;
125
+ dataset.dockEdge = this.dockedEdge ?? 'none';
126
+ if (this.living()) {
127
+ style.backgroundImage = 'none';
128
+ this.livingBody.show(this.action, { reducedMotion: this.reducedMotion, onEnded: () => this.completeOneShot() });
129
+ return;
130
+ }
131
+ this.livingBody?.hide();
93
132
  const staticFile = staticSkins[this.skin];
94
133
  if (staticFile) {
95
134
  style.backgroundImage = `url("./skins/${staticFile}")`;
@@ -104,6 +143,11 @@ export class PetMotion {
104
143
  }
105
144
  scheduleFrame() {
106
145
  if (this.reducedMotion || staticSkins[this.skin]) return;
146
+ if (this.living()) {
147
+ if (isLivingClip(this.action) || !this.oneShot) return;
148
+ this.frameTimer = this.setTimer(() => { this.frameTimer = undefined; this.completeOneShot(); }, 700);
149
+ return;
150
+ }
107
151
  const frames = additional[this.action]?.frames ?? atlas[this.action]?.frames;
108
152
  if (!frames) return;
109
153
  this.frameTimer = this.setTimer(() => {
@@ -121,8 +165,15 @@ export class PetMotion {
121
165
  }, frames[this.frame]);
122
166
  }
123
167
  cancelFrame() { if (this.frameTimer !== undefined) this.clearTimer(this.frameTimer); this.frameTimer = undefined; }
168
+ completeOneShot() {
169
+ if (!this.oneShot) return;
170
+ this.oneShot = false;
171
+ this.action = '';
172
+ this.play(this.base);
173
+ if (this.phase === 'idle') this.armRest();
174
+ }
124
175
  armRest() {
125
- if (this.restTimer !== undefined || this.action === 'sleeping') return;
176
+ if (this.restTimer !== undefined || this.action === 'sleeping' || this.pendingDecision || this.dockedEdge) return;
126
177
  this.restTimer = this.setTimer(() => {
127
178
  this.restTimer = undefined;
128
179
  if (this.phase === 'idle' && !this.moving && !this.oneShot) this.play(this.resolve('sleeping'));
@@ -0,0 +1,88 @@
1
+ {
2
+ "canvas": 600,
3
+ "tailPivot": [
4
+ 168.1,
5
+ 456.1
6
+ ],
7
+ "earPivotL": [
8
+ 218.9,
9
+ 101.4
10
+ ],
11
+ "earPivotR": [
12
+ 391.0,
13
+ 100.2
14
+ ],
15
+ "neckPivot": [
16
+ 300.0,
17
+ 280.4
18
+ ],
19
+ "irisL": [
20
+ 231.1,
21
+ 156.5,
22
+ 285.6,
23
+ 213.4
24
+ ],
25
+ "irisR": [
26
+ 339.7,
27
+ 154.5,
28
+ 388.0,
29
+ 211.0
30
+ ],
31
+ "whiteL": [
32
+ 230.6,
33
+ 156.5,
34
+ 294.3,
35
+ 221.1
36
+ ],
37
+ "whiteR": [
38
+ 331.1,
39
+ 154.5,
40
+ 391.4,
41
+ 219.6
42
+ ],
43
+ "tail": [
44
+ 47.8,
45
+ 313.4,
46
+ 178.9,
47
+ 507.2
48
+ ],
49
+ "earL": [
50
+ 169.4,
51
+ 26.8,
52
+ 258.4,
53
+ 140.7
54
+ ],
55
+ "earR": [
56
+ 345.9,
57
+ 20.1,
58
+ 437.8,
59
+ 140.7
60
+ ],
61
+ "mouth": [
62
+ 286.6,
63
+ 221.5,
64
+ 343.1,
65
+ 256.9
66
+ ],
67
+ "areas_native": {
68
+ "tail": 78634,
69
+ "earL": 23228,
70
+ "earR": 23149,
71
+ "fearL": 9568,
72
+ "fearR": 9138,
73
+ "irisL": 7903,
74
+ "irisR": 7624,
75
+ "whiteL": 10967,
76
+ "whiteR": 10974,
77
+ "mouth": 4342
78
+ },
79
+ "collarTop_native": 580,
80
+ "sitFloorNative": 1135,
81
+ "runCropNative": [
82
+ 27,
83
+ 181,
84
+ 1220,
85
+ 1022
86
+ ],
87
+ "runFloorNative": 1016
88
+ }
@@ -12,6 +12,34 @@ button:disabled { opacity: .45; cursor: default; }
12
12
  #anchor { position: absolute; left: 8px; top: 8px; width: 120px; height: 130px; }
13
13
  #pet { width: 120px; height: 130px; display: block; border: 0; padding: 0; background: url("./skins/xianxian-codex.webp") 0 0 / 960px 1170px; filter: drop-shadow(0 6px 4px #0002); touch-action: none; cursor: grab; }
14
14
  #pet.dragging { cursor: grabbing; }
15
+ #pet[data-skin="xianxian-codex"] { position: relative; background-image: none; overflow: visible; }
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); }
19
+ #living-sit [data-layer="tail"] { transform-box: view-box; transform-origin: 168px 456px; animation: living-tail 3.4s ease-in-out infinite; }
20
+ #living-sit [data-layer="head"] { transform-box: view-box; transform-origin: 300px 280px; animation: living-head 5.5s ease-in-out infinite; }
21
+ #living-sit [data-layer="ear-left"], #living-sit [data-layer="ear-right"] { transform-box: view-box; }
22
+ #living-sit [data-layer="ear-left"] { transform-origin: 219px 101px; }
23
+ #living-sit [data-layer="ear-right"] { transform-origin: 391px 100px; }
24
+ #pet[data-pose="listening"] #living-sit [data-layer="ear-left"] { transform: rotate(-8deg); }
25
+ #pet[data-pose="listening"] #living-sit [data-layer="ear-right"] { transform: rotate(8deg); }
26
+ #living-sit [data-layer="closed"] { opacity: 0; animation: living-blink 5.8s linear infinite; }
27
+ #pet[data-action="idle"] #living-sit [data-layer="tail"] { animation: living-idle-tail 4.8s ease-in-out infinite; }
28
+ #pet[data-action="idle"] #living-sit [data-layer="head"] { animation: living-idle-head 6.8s ease-in-out infinite; }
29
+ #pet[data-action="idle"] #living-sit [data-layer="ear-left"] { animation: living-idle-ear-left 8.1s ease-in-out infinite; }
30
+ #pet[data-action="idle"] #living-sit [data-layer="ear-right"] { animation: living-idle-ear-right 7.3s ease-in-out infinite; }
31
+ #pet[data-action="idle"] #living-sit [data-layer="closed"] { animation: living-idle-blink 5.8s steps(1, end) infinite; }
32
+ #living-sit [data-layer="mouth"], #living-sit [data-layer="scared"] { opacity: 0; }
33
+ #pet[data-pose="scared"] #living-sit [data-layer="scared"] { opacity: 1; }
34
+ #pet[data-pose="sleeping"] #living-sit [data-layer="closed"] { opacity: 1; animation: none; }
35
+ @keyframes living-tail { 50% { transform: rotate(5deg); } }
36
+ @keyframes living-head { 50% { transform: rotate(2deg); } }
37
+ @keyframes living-blink { 0%, 96%, 100% { opacity: 0; } 97%, 98% { opacity: 1; } }
38
+ @keyframes living-idle-tail { 0%, 12%, 100% { transform: rotate(0); } 34% { transform: rotate(10deg); } 62% { transform: rotate(-8deg); } 80% { transform: rotate(3deg); } }
39
+ @keyframes living-idle-head { 0%, 12%, 100% { transform: rotate(0); } 35% { transform: rotate(5deg); } 69% { transform: rotate(-3deg); } }
40
+ @keyframes living-idle-ear-left { 0%, 68%, 76%, 100% { transform: rotate(0); } 71% { transform: rotate(-9deg); } 73% { transform: rotate(3deg); } }
41
+ @keyframes living-idle-ear-right { 0%, 30%, 39%, 100% { transform: rotate(0); } 33% { transform: rotate(8deg); } 36% { transform: rotate(-3deg); } }
42
+ @keyframes living-idle-blink { 0%, 24%, 30%, 74%, 80%, 100% { opacity: 0; } 25%, 29%, 75%, 79% { opacity: 1; } }
15
43
  #indicators { position: absolute; bottom: 0; right: 0; display: flex; gap: 4px; }
16
44
  #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; }
17
45
  #indicators button:hover { background: #ab4842; }
@@ -50,18 +78,22 @@ button:disabled { opacity: .45; cursor: default; }
50
78
  #chat header button { font-size: 12px; color: var(--cafe-text-secondary); }
51
79
  #transcript { height: 145px; overflow: auto; padding: 14px; line-height: 1.7; }
52
80
  #transcript p { margin: 0 0 10px; overflow-wrap: anywhere; white-space: pre-wrap; }
53
- #transcript p:not(.empty) { background: var(--accent-50); padding: 7px 10px; border-radius: 9px; }
81
+ #transcript p:not(.empty) { width: fit-content; max-width: 92%; padding: 7px 10px; border-radius: 9px; }
82
+ #transcript p.user { margin-left: auto; background: var(--accent-50); }
83
+ #transcript p.assistant { margin-right: auto; background: var(--cafe-surface); border: 1px solid var(--cafe-border-subtle); }
54
84
  .empty { color: var(--cafe-text-muted); font-size: 13px; }
55
85
  #compose { display: flex; align-items: flex-end; padding: 10px; gap: 8px; border-top: 1px solid var(--cafe-border-subtle); }
56
86
  #message { resize: none; border: 0; min-width: 0; flex: 1; background: transparent; padding: 5px; color: inherit; line-height: 1.5; }
57
87
  #chat small { color: var(--cafe-text-muted); font-size: 10px; padding: 0 14px 10px; }
58
88
  .sr-only { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; }
59
- @media (prefers-reduced-motion: reduce) { #pet, #indicators button { animation: none; } }
89
+ @media (prefers-reduced-motion: reduce) {
90
+ #pet, #indicators button, #living-sit [data-layer], #pet[data-action="idle"] #living-sit [data-layer] { animation: none; }
91
+ #living-sit [data-layer="closed"] { opacity: 0; }
92
+ }
60
93
  #pet[data-skin="yanyan-codex"] { background-image: url("./skins/yanyan-codex.webp"); }
61
94
  #pet[data-skin="ragdoll-v1"] { background-image: url("./skins/ragdoll-v1.png"); animation: none; background-size: contain; background-repeat: no-repeat; background-position: center; }
62
95
  #pet[data-skin="yarn-ball"] { background-image: url("./skins/yarn-ball.png"); animation: none; background-size: contain; background-repeat: no-repeat; background-position: center; }
63
96
  #transcript { height: 230px; font-size: 13px; }
64
- #transcript .assistant { background: transparent; }
65
97
  #transcript p[data-author]::before { content: attr(data-author); display: block; color: var(--cafe-text-muted); font-size: 10px; margin-bottom: 2px; }
66
98
  #transcript p[data-live="true"] { border-left: 2px solid var(--cafe-accent); }
67
99
  #history { padding: 0; font-size: 10px; color: var(--cafe-text-secondary); }
@@ -6,6 +6,8 @@ import { ScreenShare } from './screen-share.mjs';
6
6
  import { TranscriptView } from './transcript-view.mjs';
7
7
  import { RecentBubble } from './recent-bubble.mjs';
8
8
  import { PetMotion } from './pet-motion.mjs';
9
+ import { LivingBody } from './living-body.mjs';
10
+ import { detectDockedEdge } from './living-edge.mjs';
9
11
  import { bindPetControls } from './pet-controls.mjs';
10
12
  import { decisionBadge, decisionRows } from './decision-view.mjs';
11
13
 
@@ -14,10 +16,14 @@ const label = (id, text) => { $(id).querySelector('.label').textContent = text;
14
16
  const status = text => { $('status').textContent = text; $('chat-status').textContent = text; $('announcement').textContent = text; };
15
17
  const transcript = new TranscriptView($('transcript'));
16
18
  const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
17
- const motion = new PetMotion($('pet'));
19
+ const motion = new PetMotion($('pet'), { livingBody: new LivingBody({ root: $('pet'), sit: $('living-sit'), video: $('living-video') }) });
18
20
  let sharing = false, pendingScreen = false, loading = false, latestHistory, previousPhase = 'idle';
21
+ let observedHistory = false, lastAssistantId;
22
+ let resultPreviewUntil = 0;
23
+ let didMove = false;
19
24
  let decisionLoading = false, decisionOffset = 0;
20
25
  let threadTitle;
26
+ const showAmbient = active => (active || Date.now() < resultPreviewUntil) && bubble.hasContent();
21
27
 
22
28
  if (!window.clowderCompanion) {
23
29
  $('actions').hidden = false; $('status').hidden = false;
@@ -27,7 +33,13 @@ if (!window.clowderCompanion) {
27
33
  const client = createCompanionClient(window.clowderCompanion);
28
34
  const controls = bindPetControls(client, {
29
35
  error: error => status(explainError(error)),
30
- moved: (dx, dy) => dx === 'stop' ? motion.stopMove() : motion.move(dx, dy),
36
+ moved: (dx, dy) => {
37
+ if (dx !== 'stop') { didMove = true; motion.move(dx, dy); return; }
38
+ motion.stopMove();
39
+ if (!didMove) return;
40
+ didMove = false;
41
+ setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
42
+ },
31
43
  changed: panel => { if (panel === 'chat') void readHistory(); if (panel === 'decisions') void readDecisions(); },
32
44
  action(kind) {
33
45
  if (kind === 'begin') { transcript.reset(); bubble.reset(); void conversation.begin(); void controls.show('none'); }
@@ -37,6 +49,10 @@ if (!window.clowderCompanion) {
37
49
  if (kind === 'share') toggleScreen();
38
50
  if (kind === 'documents') void conversation.documents(conversation.identity?.documentsAllowed === false);
39
51
  if (kind === 'hide') void client.hide().catch(error => status(explainError(error)));
52
+ if (kind === 'play' && conversation.identity?.skin === 'xianxian-codex') {
53
+ void controls.show('none');
54
+ motion.signal('play');
55
+ }
40
56
  },
41
57
  });
42
58
  const screen = new ScreenShare({
@@ -73,7 +89,7 @@ if (!window.clowderCompanion) {
73
89
  if (previousPhase !== 'idle' && value.phase === 'idle') { transcript.reset(); bubble.reset(); void readHistory(); }
74
90
  previousPhase = value.phase;
75
91
  const active = value.phase !== 'idle';
76
- void controls.setAmbient(active && bubble.hasContent());
92
+ void controls.setAmbient(showAmbient(active));
77
93
  $('begin').hidden = active; $('begin').disabled = !value.identity;
78
94
  label('begin', value.failed ? '重试语音' : '语音聊');
79
95
  $('mic').hidden = value.phase !== 'talking'; $('speaker').hidden = !active;
@@ -93,6 +109,7 @@ if (!window.clowderCompanion) {
93
109
  if (justConnected) motion.signal('connected');
94
110
  if (showFailure) motion.signal('failed');
95
111
  if (identity) {
112
+ $('menu-play').hidden = identity.skin !== 'xianxian-codex';
96
113
  $('chat-name').textContent = threadTitle ?? identity.displayName; $('menu-name').textContent = identity.displayName;
97
114
  $('pet').setAttribute('aria-label', `${identity.displayName}:点击交流,右键更多,拖动移动`);
98
115
  $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
@@ -103,15 +120,22 @@ if (!window.clowderCompanion) {
103
120
  },
104
121
  });
105
122
  async function readHistory() {
106
- if (loading || (controls.panel !== 'chat' && conversation.phase === 'idle')) return;
123
+ if (loading) return;
107
124
  loading = true;
108
125
  try {
109
126
  const history = await client.readConversation();
127
+ const assistantId = history.messages.findLast(message => message.role === 'assistant')?.id;
128
+ if (observedHistory && assistantId && assistantId !== lastAssistantId) {
129
+ motion.signal('answered');
130
+ resultPreviewUntil = Date.now() + 12_000;
131
+ }
132
+ observedHistory = true;
133
+ lastAssistantId = assistantId;
110
134
  $('history').textContent = history.hasMore ? '更早聊天 ↗' : '完整聊天 ↗';
111
135
  threadTitle = history.threadTitle;
112
136
  $('chat-name').textContent = threadTitle;
113
137
  bubble.load(history.messages);
114
- void controls.setAmbient(conversation.phase !== 'idle' && bubble.hasContent());
138
+ void controls.setAmbient(showAmbient(conversation.phase !== 'idle'));
115
139
  const serialized = JSON.stringify(history.messages);
116
140
  if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
117
141
  } catch { $('chat-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示'; }
@@ -133,6 +157,7 @@ if (!window.clowderCompanion) {
133
157
  const page = await client.readDecisions(more ? decisionOffset : 0, 10);
134
158
  if (page.status !== 'available') throw new Error('Decision source unavailable');
135
159
  showDecisionBadge(page);
160
+ motion.setPendingDecision(page.approvalCount + page.otherNeedsMeCount > 0);
136
161
  if (controls.panel !== 'decisions') return;
137
162
  const list = $('decision-list');
138
163
  if (!more) { list.replaceChildren(); list.scrollTop = 0; }
@@ -165,6 +190,7 @@ if (!window.clowderCompanion) {
165
190
  $('decision-more').hidden = !page.page.hasMoreApprovals && !page.page.hasMoreNeedsMe;
166
191
  } catch {
167
192
  showDecisionBadge(undefined);
193
+ motion.setPendingDecision(null);
168
194
  if (controls.panel === 'decisions') $('decision-status').textContent = '待决事项暂不可读 · 请稍后刷新';
169
195
  } finally { decisionLoading = false; }
170
196
  }
@@ -191,7 +217,7 @@ if (!window.clowderCompanion) {
191
217
  const statusMonitor = setInterval(() => void conversation.refresh(), 1000);
192
218
  const historyMonitor = setInterval(() => void readHistory(), 3000);
193
219
  const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void readDecisions(); }, 15000);
194
- void conversation.refresh(); void readDecisions(); void controls.show('none');
220
+ void conversation.refresh(); void readHistory(); void readDecisions(); void controls.show('none');
195
221
  window.addEventListener('beforeunload', () => {
196
222
  clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(decisionMonitor);
197
223
  unsubscribe(); motion.close(); void conversation.end();
package/source-lock.json CHANGED
@@ -66,5 +66,96 @@
66
66
  "destination": "assets/skins/xianxian-staged-thought-row.png",
67
67
  "sha256": "e9286ce2c234911d9b45835849e1a2a853598d0d0260ecf7d24430a95847725f"
68
68
  }
69
- ]
69
+ ],
70
+ "livingSkin": {
71
+ "package": "xianxian-living package-v2",
72
+ "manifestSha256": "08491ed557a82bc1860bbaeecd094f4a7e637479e78af8b411786f19a5164da0",
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": "e39554e7aca7b1c51b388b73f7dbc08e3fa7ddafef395cb28c1099d41a9b41d8"
134
+ },
135
+ {
136
+ "destination": "assets/skins/xianxian-living/clips/wake.webm",
137
+ "sha256": "73b455088e53edf2849e888e0ecb99953f7e5dba373592fb6232f9736b6da3a0"
138
+ },
139
+ {
140
+ "destination": "assets/skins/xianxian-living/clips/peek.webm",
141
+ "sha256": "adebecf89b724eec9852d097364e10bc328d006ce3bb3bd3f4b257c095d57a92"
142
+ },
143
+ {
144
+ "destination": "assets/skins/xianxian-living/clips/carry.webm",
145
+ "sha256": "4fa582ab6a18b538cfe815ab19e73da30c0da061ec661bb12e4060f8b3d2e600"
146
+ },
147
+ {
148
+ "destination": "assets/skins/xianxian-living/clips/wait.webm",
149
+ "sha256": "5e2a41e967eed178b8e540231e0b1733bbc388f955204a7b0b76b2aa7cfc4e45"
150
+ },
151
+ {
152
+ "destination": "assets/skins/xianxian-living/clips/think.webm",
153
+ "sha256": "4ba9fa0f38076a4abd0d664dc75287b76def7b340dc763b6aab4ea15bbe8abc1"
154
+ },
155
+ {
156
+ "destination": "assets/skins/xianxian-living/layers/meta.json",
157
+ "sha256": "92fe5571b2f4b78c1138e199aaa29bb523b6f6ad7ab346fbd6407c8b557e220f"
158
+ }
159
+ ]
160
+ }
70
161
  }