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

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
@@ -26,11 +26,13 @@ SDP. Hiding releases media; the existing Clowder **聊聊** entry restores the c
26
26
 
27
27
  Xianxian uses one layered sitting body or one VP9 clip at a time. Expiring Host
28
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.
29
+ screen reading and applied-result delivery. Native travel is independent: the
30
+ renderer observes the actual desktop window coordinates, so a running clip can
31
+ appear only after the Host window really moves. Dragging suppresses that
32
+ observer and freezes a visibly held candidate instead of renaming the running
33
+ clip. Pounce requires an explicit play gesture by default; optional automatic
34
+ play is bounded by idle and cooldown timers. Reduced motion keeps semantic
35
+ poses static; a compatible Host also revokes its native movement lease.
34
36
  Other cats retain their own skins. The v3 living assets and their hashes are
35
37
  pinned in `source-lock.json`; the installable renderer carries all 21 assets
36
38
  itself, including the mid-edge `peek_fade` variant.
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.10",
3
+ "version": "0.1.0-alpha.12",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clowder-ai/companion",
3
- "version": "0.1.0-alpha.10",
3
+ "version": "0.1.0-alpha.12",
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.10",
3
+ "version": "0.1.0-alpha.12",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -0,0 +1,41 @@
1
+ const named = value => typeof value === 'string' && value.trim().length > 0;
2
+ const actor = value => value && named(value.catId) && named(value.displayName);
3
+ const model = value => value === null || (named(value) && value.length <= 160);
4
+
5
+ function validSnapshot(value) {
6
+ return value?.v === 1 && value.name === '猫猫球'
7
+ && actor(value.partner) && named(value.partner.skin)
8
+ && actor(value.live) && value.live.transport === 'gpt_live_v3' && model(value.live.verifiedModel)
9
+ && actor(value.deep) && value.deep.catId === value.partner.catId && model(value.deep.verifiedModel);
10
+ }
11
+
12
+ function display(snapshot, partnerLabel) {
13
+ return {
14
+ title: snapshot.name,
15
+ companionKnown: true,
16
+ partnerLabel,
17
+ avatarCatId: snapshot.partner.catId,
18
+ skin: snapshot.partner.skin,
19
+ liveLabel: `Live 快端:${snapshot.live.displayName} · ${snapshot.live.verifiedModel ?? '型号未核实'}`,
20
+ deepLabel: `深思端:${snapshot.deep.displayName} · ${snapshot.deep.verifiedModel ?? '型号未核实'}`,
21
+ };
22
+ }
23
+
24
+ /** The current face comes from the Host's selected duty, never the background carrier. */
25
+ export function currentCompanionIdentity(state) {
26
+ if (!actor(state?.duty) || !actor(state?.carrier) || !named(state.skin)
27
+ || state.liveTransport?.kind !== 'gpt_live_v3' || !model(state.liveTransport.verifiedModel)) return null;
28
+ const snapshot = {
29
+ v: 1, name: '猫猫球',
30
+ partner: { ...state.duty, skin: state.skin },
31
+ live: { ...state.carrier, transport: state.liveTransport.kind, verifiedModel: state.liveTransport.verifiedModel },
32
+ deep: { ...state.duty, verifiedModel: null },
33
+ };
34
+ return display(snapshot, `${snapshot.partner.displayName}陪伴中`);
35
+ }
36
+
37
+ /** A missing old snapshot never borrows today's preference. Keep the message's real author visible. */
38
+ export function historicalCompanionIdentity(snapshot, authorLabel) {
39
+ if (!validSnapshot(snapshot)) return { authorLabel, companionKnown: false };
40
+ return { ...display(snapshot, `当时由${snapshot.partner.displayName}陪伴`), authorLabel };
41
+ }
@@ -1,24 +1,5 @@
1
- // F317 living Xianxian package v3. Values are CSS anchors from its reviewed manifest.
2
- const scale = 120 / 290;
3
- const groundY = 10 + 262.5 * scale;
4
- const clips = {
5
- 'running-left': { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
6
- 'running-right': { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
7
- running: { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
8
- play: { name: 'pounce', size: [304.8, 312.6], ground: 303.9, centre: 201.9, rate: 1.5 },
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
- 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 },
14
- review: { name: 'carry', size: [344.3, 290.9], ground: 263, centre: 198.3 },
15
- pending_decision: { name: 'wait', size: [197.9, 252.8], ground: 249.6, centre: 98.4, loop: true },
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 },
19
- staged_thought: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
20
- };
21
- export const isLivingClip = action => Object.hasOwn(clips, action);
1
+ import { isLivingClip, livingClipFor, placeLivingClip } from './living-geometry.mjs';
2
+ export { isLivingClip } from './living-geometry.mjs';
22
3
  const setHidden = (node, hidden) => {
23
4
  if (!node) return;
24
5
  node.toggleAttribute?.('hidden', hidden);
@@ -51,10 +32,7 @@ export class LivingBody {
51
32
  this.cancelFade();
52
33
  this.root.dataset.pose = this.pose(action);
53
34
  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;
35
+ const clip = !reducedMotion ? livingClipFor(action, dockZone) : null;
58
36
  if (!clip) {
59
37
  this.hideVideos();
60
38
  setHidden(this.sit, false);
@@ -111,21 +89,18 @@ export class LivingBody {
111
89
  video.src = `./skins/xianxian-living/clips/${clip.name}.webm`;
112
90
  video.loop = clip.loop === true;
113
91
  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`;
92
+ const placement = placeLivingClip(this.action, clip, this.root.dataset.dockEdge);
93
+ video.style.width = `${placement.width}px`;
94
+ video.style.height = `${placement.height}px`;
95
+ video.style.left = `${placement.left}px`;
96
+ video.style.top = `${placement.top}px`;
122
97
  video.style.animation = clip.endGround
123
98
  ? `living-wake-anchor ${clip.durationMs}ms linear forwards` : 'none';
124
99
  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`);
100
+ video.style.setProperty?.('--living-left-start', `${placement.left}px`);
101
+ video.style.setProperty?.('--living-top-start', `${placement.top}px`);
102
+ video.style.setProperty?.('--living-left-end', `${placement.endLeft}px`);
103
+ video.style.setProperty?.('--living-top-end', `${placement.endTop}px`);
129
104
  }
130
105
  try { video.currentTime = 0; } catch { /* New source is not yet seekable. */ }
131
106
  video.onended = () => { if (revision === this.revision) onEnded(); };
@@ -0,0 +1,96 @@
1
+ // F317 living Xianxian package v3. All values are reviewed package CSS anchors;
2
+ // every clip uses one stage scale. content bounds are the union of decoded
3
+ // alpha pixels and exist to size the native transparent surface, not to apply
4
+ // per-action "looks right" scaling.
5
+ export const LIVING_GEOMETRY = Object.freeze({
6
+ pet: Object.freeze({ width: 120, height: 130 }),
7
+ stage: Object.freeze({ size: 290, centre: 145, ground: 262.5 }),
8
+ surface: Object.freeze({ left: -32, top: -18, right: 143, bottom: 138 }),
9
+ sitContent: Object.freeze({ left: 8.8, top: 3.4, right: 94.2, bottom: 109.2 }),
10
+ });
11
+
12
+ export const LIVING_SCALE = LIVING_GEOMETRY.pet.width / LIVING_GEOMETRY.stage.size;
13
+ export const LIVING_REFERENCE = Object.freeze({
14
+ centre: LIVING_GEOMETRY.stage.centre * LIVING_SCALE,
15
+ ground: LIVING_GEOMETRY.stage.ground * LIVING_SCALE,
16
+ });
17
+
18
+ const clip = (name, size, ground, centre, content, options = {}) => Object.freeze({
19
+ name, size: Object.freeze(size), ground, centre, content: Object.freeze(content), ...options,
20
+ });
21
+
22
+ const library = Object.freeze({
23
+ trot: clip('trot', [265, 236], 234.8, 132.5,
24
+ { left: 1.5, top: 2, right: 263.5, bottom: 235.5 }, { loop: true }),
25
+ pounce: clip('pounce', [304.8, 312.6], 303.9, 201.9,
26
+ { left: 3, top: 0, right: 302, bottom: 312 }, { rate: 1.5 }),
27
+ sleep: clip('sleep', [271.2, 168], 161, 133.2,
28
+ { left: 2.5, top: 2.5, right: 268.5, bottom: 166.5 }, { loop: true }),
29
+ wake: clip('wake', [400.2, 282.7], 244.2, 200.5,
30
+ { left: 3, top: 3, right: 397, bottom: 280.5 },
31
+ { endGround: 277.4, endCentre: 197.7, durationMs: 7_250, crossfadeMs: 250 }),
32
+ peek: clip('peek', [153.5, 234.3], 234.3, 76.75,
33
+ { left: 2.5, top: 2.5, right: 153, bottom: 234 }, { loop: true }),
34
+ peek_fade: clip('peek_fade', [153.5, 234.3], 234.3, 76.75,
35
+ { left: 2.5, top: 2.5, right: 153, bottom: 231.5 }, { loop: true }),
36
+ carry: clip('carry', [344.3, 290.9], 263, 198.3,
37
+ { left: 4, top: 4, right: 340, bottom: 288.5 }),
38
+ wait: clip('wait', [197.9, 252.8], 249.6, 98.4,
39
+ { left: 4, top: 4, right: 194, bottom: 251 }, { loop: true }),
40
+ think: clip('think', [266.4, 256.9], 253.7, 132.1,
41
+ { left: 3.5, top: 3.5, right: 262, bottom: 255 }, { loop: true }),
42
+ });
43
+
44
+ const actionClip = Object.freeze({
45
+ 'running-left': 'trot',
46
+ 'running-right': 'trot',
47
+ running: 'trot',
48
+ play: 'pounce',
49
+ sleeping: 'sleep',
50
+ waking: 'wake',
51
+ peek: 'peek',
52
+ delivering: 'carry',
53
+ review: 'carry',
54
+ pending_decision: 'wait',
55
+ working: 'think',
56
+ fetching: 'think',
57
+ studying: 'think',
58
+ staged_thought: 'think',
59
+ });
60
+
61
+ export const isLivingClip = action => Object.hasOwn(actionClip, action);
62
+
63
+ export function livingClipFor(action, dockZone) {
64
+ const name = action === 'peek' && dockZone !== 'corner' ? 'peek_fade' : actionClip[action];
65
+ return name ? library[name] : null;
66
+ }
67
+
68
+ export function placeLivingClip(action, selected, dockEdge) {
69
+ const width = selected.size[0] * LIVING_SCALE;
70
+ const height = selected.size[1] * LIVING_SCALE;
71
+ const left = action === 'peek'
72
+ ? (dockEdge === 'left' ? LIVING_GEOMETRY.surface.left : LIVING_GEOMETRY.surface.right - width)
73
+ : LIVING_REFERENCE.centre - selected.centre * LIVING_SCALE;
74
+ const top = action === 'peek'
75
+ ? LIVING_GEOMETRY.surface.bottom - height
76
+ : LIVING_REFERENCE.ground - selected.ground * LIVING_SCALE;
77
+ return Object.freeze({
78
+ left, top, width, height,
79
+ endLeft: selected.endCentre === undefined
80
+ ? undefined : LIVING_REFERENCE.centre - selected.endCentre * LIVING_SCALE,
81
+ endTop: selected.endGround === undefined
82
+ ? undefined : LIVING_REFERENCE.ground - selected.endGround * LIVING_SCALE,
83
+ });
84
+ }
85
+
86
+ export function livingContentRect(selected, placement, endpoint = 'start') {
87
+ const left = endpoint === 'end' ? placement.endLeft : placement.left;
88
+ const top = endpoint === 'end' ? placement.endTop : placement.top;
89
+ if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
90
+ return Object.freeze({
91
+ left: left + selected.content.left * LIVING_SCALE,
92
+ top: top + selected.content.top * LIVING_SCALE,
93
+ right: left + selected.content.right * LIVING_SCALE,
94
+ bottom: top + selected.content.bottom * LIVING_SCALE,
95
+ });
96
+ }
@@ -1,6 +1,11 @@
1
1
  const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
2
2
  const validExpiry = (value, now) => Number.isFinite(value) && value > now;
3
3
  const workKinds = new Set(['reasoning', 'tool', 'workspace', 'screen_reading']);
4
+ const normalizeTravel = (travel, now, suppressed) => travel && validId(travel.eventId)
5
+ && travel.status === 'active' && Number.isFinite(travel.dx) && Number.isFinite(travel.dy)
6
+ && Math.hypot(travel.dx, travel.dy) >= 1 && validExpiry(travel.expiresAt, now)
7
+ && !suppressed.has(travel.eventId)
8
+ ? { eventId: travel.eventId, dx: travel.dx, dy: travel.dy, expiresAt: travel.expiresAt } : null;
4
9
 
5
10
  /** A bounded, expiring projection of Host-owned motion facts. */
6
11
  export class MotionFacts {
@@ -28,15 +33,39 @@ export class MotionFacts {
28
33
  delivery: delivery && validId(delivery.resultId) && delivery.status === 'applied'
29
34
  && validExpiry(delivery.expiresAt, now)
30
35
  ? { 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,
36
+ travel: normalizeTravel(travel, now, this.suppressedTravel),
35
37
  };
36
38
  if (suppressTravel) this.suppressTravel();
37
39
  this.scheduleExpiry();
38
40
  }
39
41
 
42
+ syncNative(value) {
43
+ const now = this.now();
44
+ this.prune(now);
45
+ const input = value && typeof value === 'object' ? value : {};
46
+ const work = input.work;
47
+ const delivery = input.delivery;
48
+ const travel = this.snapshot.travel?.expiresAt > now ? this.snapshot.travel : null;
49
+ this.snapshot = {
50
+ work: work && validId(work.taskId) && work.status === 'active' && workKinds.has(work.kind)
51
+ && validExpiry(work.expiresAt, now)
52
+ ? { taskId: work.taskId, kind: work.kind, expiresAt: work.expiresAt } : null,
53
+ delivery: delivery && validId(delivery.resultId) && delivery.status === 'applied'
54
+ && validExpiry(delivery.expiresAt, now)
55
+ ? { resultId: delivery.resultId, expiresAt: delivery.expiresAt } : null,
56
+ travel,
57
+ };
58
+ this.scheduleExpiry();
59
+ }
60
+
61
+ syncTravel(value, suppressTravel = false) {
62
+ const now = this.now();
63
+ this.prune(now);
64
+ this.snapshot.travel = normalizeTravel(value, now, this.suppressedTravel);
65
+ if (suppressTravel) this.suppressTravel();
66
+ this.scheduleExpiry();
67
+ }
68
+
40
69
  consumeDelivery() {
41
70
  const delivery = this.snapshot.delivery;
42
71
  if (!delivery || this.seenResults.has(delivery.resultId)) return null;
@@ -0,0 +1,89 @@
1
+ const validPosition = value => value && Number.isFinite(value.x) && Number.isFinite(value.y);
2
+
3
+ /** Observe the native window itself. Running animation follows measured desktop
4
+ * movement; renderer state never invents a target or claims a move succeeded. */
5
+ export class NativeWindowTravel {
6
+ constructor({
7
+ readPosition,
8
+ changed,
9
+ now = Date.now,
10
+ setTimer = globalThis.setTimeout,
11
+ clearTimer = globalThis.clearTimeout,
12
+ intervalMs = 80,
13
+ expiresMs = 240,
14
+ }) {
15
+ this.readPosition = readPosition;
16
+ this.changed = changed;
17
+ this.now = now;
18
+ this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
19
+ this.clearTimer = id => clearTimer.call(globalThis, id);
20
+ this.intervalMs = Math.max(32, intervalMs);
21
+ this.expiresMs = Math.max(this.intervalMs, expiresMs);
22
+ this.sequence = 0;
23
+ this.manual = false;
24
+ this.closed = false;
25
+ this.active = null;
26
+ }
27
+
28
+ start() {
29
+ if (this.closed || this.timer !== undefined) return;
30
+ this.previous = this.read();
31
+ this.schedule();
32
+ }
33
+
34
+ setManual(value) {
35
+ const manual = value === true;
36
+ if (manual === this.manual) return;
37
+ this.manual = manual;
38
+ this.previous = this.read();
39
+ if (manual) this.clearActive();
40
+ }
41
+
42
+ tick() {
43
+ this.timer = undefined;
44
+ if (this.closed) return;
45
+ const next = this.read();
46
+ const now = this.now();
47
+ if (next && this.previous) {
48
+ const dx = next.x - this.previous.x;
49
+ const dy = next.y - this.previous.y;
50
+ if (!this.manual && Math.hypot(dx, dy) >= 1) {
51
+ const eventId = this.active?.eventId ?? `native-${Math.trunc(now)}-${++this.sequence}`;
52
+ this.active = { eventId, status: 'active', dx, dy, expiresAt: now + this.expiresMs };
53
+ this.changed({ ...this.active });
54
+ } else if (this.active && this.active.expiresAt <= now) {
55
+ this.clearActive();
56
+ }
57
+ }
58
+ this.previous = next;
59
+ this.schedule();
60
+ }
61
+
62
+ close() {
63
+ if (this.closed) return;
64
+ this.closed = true;
65
+ if (this.timer !== undefined) this.clearTimer(this.timer);
66
+ this.timer = undefined;
67
+ this.clearActive();
68
+ }
69
+
70
+ read() {
71
+ try {
72
+ const value = this.readPosition();
73
+ return validPosition(value) ? { x: value.x, y: value.y } : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ schedule() {
80
+ if (this.closed || this.timer !== undefined) return;
81
+ this.timer = this.setTimer(() => this.tick(), this.intervalMs);
82
+ }
83
+
84
+ clearActive() {
85
+ if (!this.active) return;
86
+ this.active = null;
87
+ this.changed(null);
88
+ }
89
+ }
@@ -0,0 +1,22 @@
1
+ // F229 pet.json frame durations, plus the three F258 rows pinned in source-lock.json.
2
+ export const atlas = {
3
+ idle: { row: 0, frames: [1200, 400, 400, 500, 500, 800] },
4
+ 'running-right': { row: 1, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
5
+ 'running-left': { row: 2, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
6
+ waving: { row: 3, frames: [140, 140, 140, 280] },
7
+ jumping: { row: 4, frames: [140, 140, 140, 140, 280] },
8
+ failed: { row: 5, frames: [140, 140, 140, 140, 140, 140, 140, 240] },
9
+ waiting: { row: 6, frames: [150, 150, 150, 150, 150, 260] },
10
+ running: { row: 7, frames: [120, 120, 120, 120, 120, 220] },
11
+ review: { row: 8, frames: [150, 150, 150, 150, 150, 280] },
12
+ };
13
+ export const additional = {
14
+ sleeping: { file: 'xianxian-sleeping-row.png', frames: [700, 700, 700, 700, 700, 700] },
15
+ working: { file: 'xianxian-working-row.png', frames: [520, 560, 520, 640, 520, 560, 520, 640] },
16
+ staged_thought: { file: 'xianxian-staged-thought-row.png', frames: [1200, 500, 700, 900, 1200, 500] },
17
+ };
18
+ export const visualAlias = { fetching: 'working', studying: 'staged_thought', delivering: 'review', held: 'idle' };
19
+ export const workAction = { reasoning: 'staged_thought', tool: 'working', workspace: 'fetching', screen_reading: 'studying' };
20
+ export const signals = { connected: 'waving', received: 'jumping', failed: 'failed' };
21
+ export const staticSkins = { 'ragdoll-v1': 'ragdoll-v1.png', 'yarn-ball': 'yarn-ball.png' };
22
+ export const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
@@ -1,28 +1,6 @@
1
1
  import { isLivingClip } from './living-body.mjs';
2
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;
3
+ import { additional, atlas, signals, staticSkins, validId, visualAlias, workAction } from './pet-motion-assets.mjs';
26
4
 
27
5
  /** Select one truthful body from Host facts and local, user-controlled gestures. */
28
6
  export class PetMotion {
@@ -75,10 +53,19 @@ export class PetMotion {
75
53
  }
76
54
 
77
55
  syncSnapshot(value) {
56
+ this.syncFacts(value, false);
57
+ }
58
+
59
+ syncNativeSnapshot(value) {
60
+ this.syncFacts(value, true);
61
+ }
62
+
63
+ syncFacts(value, nativeOnly) {
78
64
  const now = this.now();
79
65
  this.hasNativeWork = value !== null && value !== undefined;
80
66
  for (const [id, expiresAt] of this.seenEvents) if (expiresAt <= now) this.seenEvents.delete(id);
81
- this.facts.sync(value, this.dragging);
67
+ if (nativeOnly) this.facts.syncNative(value);
68
+ else this.facts.sync(value, this.dragging);
82
69
  if (this.snapshot.work) {
83
70
  this.resting = false;
84
71
  this.cancelIdleTimers();
@@ -91,6 +78,16 @@ export class PetMotion {
91
78
  this.refresh();
92
79
  }
93
80
 
81
+ syncTravel(value) {
82
+ this.facts.syncTravel(value, this.dragging);
83
+ if (this.resting && this.snapshot.travel) {
84
+ this.resting = false;
85
+ this.cancelRest();
86
+ this.transient = { action: 'waking' };
87
+ }
88
+ this.refresh();
89
+ }
90
+
94
91
  setPendingDecision(value) {
95
92
  const pending = value === true;
96
93
  if (this.pendingDecision === pending) return;
@@ -179,6 +176,7 @@ export class PetMotion {
179
176
  }
180
177
 
181
178
  refresh(force = false) {
179
+ this.element.dataset.listening = String(this.listening());
182
180
  const selection = this.select();
183
181
  this.base = this.baseAction();
184
182
  const action = this.resolve(selection.action);
@@ -208,10 +206,9 @@ export class PetMotion {
208
206
  const { dx, dy } = this.snapshot.travel;
209
207
  return { action: Math.abs(dx) >= Math.abs(dy) ? dx < 0 ? 'running-left' : 'running-right' : 'running', oneShot: false };
210
208
  }
211
- if (['talking', 'connecting'].includes(this.phase) && !this.context?.muted)
212
- return { action: 'waiting', oneShot: false };
213
209
  if (this.transient?.action === 'play') return { action: 'play', oneShot: true };
214
210
  if (this.dockedEdge) return { action: 'peek', oneShot: false };
211
+ if (this.listening()) return { action: 'waiting', oneShot: false };
215
212
  if (this.resting) return { action: 'sleeping', oneShot: false };
216
213
  return { action: 'idle', oneShot: false };
217
214
  }
@@ -220,11 +217,13 @@ export class PetMotion {
220
217
  if (this.pendingDecision) return 'pending_decision';
221
218
  const work = this.activeWorkAction();
222
219
  if (work) return work;
223
- if (['talking', 'connecting'].includes(this.phase) && !this.context?.muted) return 'waiting';
224
220
  if (this.dockedEdge) return 'peek';
221
+ if (this.listening()) return 'waiting';
225
222
  return 'idle';
226
223
  }
227
224
 
225
+ listening() { return ['talking', 'connecting'].includes(this.phase) && !this.context?.muted; }
226
+
228
227
  activeWorkAction() {
229
228
  if (this.snapshot.work) return workAction[this.snapshot.work.kind];
230
229
  if (!this.hasNativeWork && this.context?.nativeActivity === 'tool_running') return 'working';
@@ -11,6 +11,8 @@ import { detectDockedEdge } from './living-edge.mjs';
11
11
  import { bindPetControls } from './pet-controls.mjs';
12
12
  import { decisionBadge, decisionRows } from './decision-view.mjs';
13
13
  import { normalizeNativeWork } from './native-work-motion.mjs';
14
+ import { NativeWindowTravel } from './native-window-travel.mjs';
15
+ import { currentCompanionIdentity } from './companion-identity.mjs';
14
16
 
15
17
  const $ = id => document.getElementById(id);
16
18
  const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
@@ -19,6 +21,10 @@ const transcript = new TranscriptView($('transcript'));
19
21
  const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
20
22
  const motion = new PetMotion($('pet'), { livingBody: new LivingBody({ root: $('pet'), sit: $('living-sit'),
21
23
  video: $('living-video'), transitionVideo: $('living-video-transition') }) });
24
+ const nativeTravel = new NativeWindowTravel({
25
+ readPosition: () => ({ x: window.screenX, y: window.screenY }),
26
+ changed: value => motion.syncTravel(value),
27
+ });
22
28
  let sharing = false, pendingScreen = false, loading = false, latestHistory, previousPhase = 'idle';
23
29
  let observedHistory = false, lastAssistantId;
24
30
  let resultPreviewUntil = 0;
@@ -32,11 +38,13 @@ if (!window.clowderCompanion) {
32
38
  status('请从 Clowder 的聊聊入口打开猫猫球');
33
39
  document.querySelectorAll('button').forEach(button => { button.disabled = true; });
34
40
  } else {
41
+ nativeTravel.start();
35
42
  const client = createCompanionClient(window.clowderCompanion);
36
43
  const controls = bindPetControls(client, {
37
44
  error: error => status(explainError(error)),
38
45
  moved: (dx, dy) => {
39
- if (dx !== 'stop') { didMove = true; motion.move(dx, dy); return; }
46
+ if (dx !== 'stop') { didMove = true; nativeTravel.setManual(true); motion.move(dx, dy); return; }
47
+ nativeTravel.setManual(false);
40
48
  motion.stopMove();
41
49
  if (!didMove) return;
42
50
  didMove = false;
@@ -108,21 +116,28 @@ if (!window.clowderCompanion) {
108
116
  label('mic', value.muted ? '取消静音' : '静音'); $('mic').setAttribute('aria-pressed', String(value.muted));
109
117
  label('speaker', value.silent ? '开启播音' : '关闭播音');
110
118
  const identity = value.identity;
119
+ const companionIdentity = currentCompanionIdentity(identity);
111
120
  motion.setContext({ skin: identity?.skin, phase: value.phase, nativeActivity: identity?.nativeActivity ?? 'none', muted: value.muted });
112
- motion.syncSnapshot(normalizeNativeWork(identity?.nativeWork));
121
+ motion.syncNativeSnapshot(normalizeNativeWork(identity?.nativeWork));
113
122
  if (justConnected) motion.signal('connected');
114
123
  if (showFailure) motion.signal('failed');
115
124
  if (identity) {
116
125
  $('menu-play').hidden = identity.skin !== 'xianxian-codex';
117
- $('chat-name').textContent = threadTitle ?? identity.displayName; $('menu-name').textContent = identity.displayName;
118
- $('pet').setAttribute('aria-label', `${identity.displayName}:点击交流,右键更多,拖动移动`);
126
+ const companionLabel = companionIdentity
127
+ ? `${companionIdentity.title} · ${companionIdentity.partnerLabel}` : identity.displayName;
128
+ $('chat-name').textContent = threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
129
+ $('pet').setAttribute('aria-label', `${companionLabel}:点击交流,右键更多,拖动移动`);
119
130
  $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
120
131
  $('documents').setAttribute('aria-pressed', String(identity.documentsAllowed));
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}`;
132
+ if (companionIdentity) {
133
+ $('connection-scope').textContent = `${companionIdentity.partnerLabel} · ${companionIdentity.liveLabel} · ${companionIdentity.deepLabel}`;
134
+ } else {
135
+ const live = identity.liveTransport?.kind === 'gpt_live_v3'
136
+ ? 'GPT Live · 实时型号未证实' : '实时载体未确认';
137
+ $('connection-scope').textContent = identity.duty.catId === identity.carrier.catId
138
+ ? `${identity.duty.displayName} · ${live}`
139
+ : `${identity.duty.displayName} · ${live} · 绑定载体 ${identity.carrier.displayName}`;
140
+ }
126
141
  }
127
142
  if (showFailure) void controls.show('actions');
128
143
  },
@@ -227,6 +242,6 @@ if (!window.clowderCompanion) {
227
242
  void conversation.refresh(); void readHistory(); void readDecisions(); void controls.show('none');
228
243
  window.addEventListener('beforeunload', () => {
229
244
  clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(decisionMonitor);
230
- unsubscribe(); motion.close(); void conversation.end();
245
+ unsubscribe(); nativeTravel.close(); motion.close(); void conversation.end();
231
246
  });
232
247
  }