@clowder-ai/companion 0.1.0-alpha.11 → 0.1.0-alpha.13

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
@@ -10,10 +10,19 @@ visible and ends the call in one click. Screen sharing has its own explicit
10
10
  picker and termination badge. There is no microphone action on a double-click,
11
11
  page load, body tap or drag.
12
12
 
13
+ When a compatible Host advertises receive-only audio, **只听** opens the same
14
+ conversation with playback but never requests or toggles a microphone. Older
15
+ Hosts omit that capability, so the control stays hidden and ordinary voice keeps
16
+ the exact legacy connect request. Text remains available independently.
17
+
13
18
  Typing works with voice off. The Host uses the existing owner conversation,
14
19
  configured duty cat, ordinary message routing and idempotency. The small history
15
20
  view reads a bounded recent subset; **完整聊天** opens the canonical conversation.
16
21
  No second transcript database, credentials or selectable Host identity live here.
22
+ When the Host supplies a valid per-message identity snapshot, each history row
23
+ keeps its real author while separately showing the partner, Live carrier and
24
+ deep cat saved with that message. Legacy rows omit this context instead of
25
+ borrowing today's selected companion.
17
26
 
18
27
  ## Host integration
19
28
 
@@ -26,11 +35,13 @@ SDP. Hiding releases media; the existing Clowder **聊聊** entry restores the c
26
35
 
27
36
  Xianxian uses one layered sitting body or one VP9 clip at a time. Expiring Host
28
37
  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.
38
+ screen reading and applied-result delivery. Native travel is independent: the
39
+ renderer observes the actual desktop window coordinates, so a running clip can
40
+ appear only after the Host window really moves. Dragging suppresses that
41
+ observer and freezes a visibly held candidate instead of renaming the running
42
+ clip. Pounce requires an explicit play gesture by default; optional automatic
43
+ play is bounded by idle and cooldown timers. Reduced motion keeps semantic
44
+ poses static; a compatible Host also revokes its native movement lease.
34
45
  Other cats retain their own skins. The v3 living assets and their hashes are
35
46
  pinned in `source-lock.json`; the installable renderer carries all 21 assets
36
47
  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.11",
3
+ "version": "0.1.0-alpha.13",
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-x3ponGs/dhxPH5D1rT9JrLqJpFA6rxrgMjSImhksoHQ="
43
+ "integrity": "sha256-vGmfNZUU2zOlDNsQUqPjrvcwmO6p+Qdi3aPwlI8Wcyc="
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.11",
3
+ "version": "0.1.0-alpha.13",
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.20",
30
- "@clowder-ai/plugin-sdk": "0.1.0-beta.16"
29
+ "@clowder-ai/plugin-contract": "0.1.0-beta.21",
30
+ "@clowder-ai/plugin-sdk": "0.1.0-beta.17"
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.11",
3
+ "version": "0.1.0-alpha.13",
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-x3ponGs/dhxPH5D1rT9JrLqJpFA6rxrgMjSImhksoHQ="
43
+ "integrity": "sha256-vGmfNZUU2zOlDNsQUqPjrvcwmO6p+Qdi3aPwlI8Wcyc="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
@@ -19,7 +19,7 @@ export function createCompanionClient(bridge) {
19
19
  return {
20
20
  state: () => invoke({ kind: 'state' }, 'state'),
21
21
  prepare: () => invoke({ kind: 'prepare' }, 'state'),
22
- connectAudio: () => invoke({ kind: 'audio.connect' }, 'ok'),
22
+ connectAudio: (mode) => invoke(mode === undefined ? { kind: 'audio.connect' } : { kind: 'audio.connect', mode }, 'ok'),
23
23
  closeAudio: () => invoke({ kind: 'audio.close' }, 'ok'),
24
24
  muteMicrophone: (muted) => invoke({ kind: 'audio.microphone', muted }, 'ok'),
25
25
  muteSpeaker: (muted) => invoke({ kind: 'audio.speaker', muted }, 'ok'),
@@ -1,12 +1,13 @@
1
1
  const named = value => typeof value === 'string' && value.trim().length > 0;
2
2
  const actor = value => value && named(value.catId) && named(value.displayName);
3
3
  const model = value => value === null || (named(value) && value.length <= 160);
4
+ const snapshotActor = value => actor(value) && value.catId.length <= 160 && value.displayName.length <= 160;
4
5
 
5
6
  function validSnapshot(value) {
6
7
  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);
8
+ && snapshotActor(value.partner) && named(value.partner.skin) && value.partner.skin.length <= 160
9
+ && snapshotActor(value.live) && value.live.transport === 'gpt_live_v3' && model(value.live.verifiedModel)
10
+ && snapshotActor(value.deep) && value.deep.catId === value.partner.catId && model(value.deep.verifiedModel);
10
11
  }
11
12
 
12
13
  function display(snapshot, partnerLabel) {
@@ -7,26 +7,41 @@ export class CompanionConversation {
7
7
  this.active = false;
8
8
  this.generation = 0;
9
9
  this.phase = 'idle';
10
+ this.audioMode = null;
11
+ this.retryMode = null;
10
12
  this.muted = false;
11
13
  this.silent = false;
12
14
  this.failed = false;
13
15
  }
14
16
  show(message) {
15
17
  this.message = message;
16
- this.render({ phase: this.phase, identity: this.identity, muted: this.muted, silent: this.silent, failed: this.failed, message });
18
+ this.render({ phase: this.phase, identity: this.identity, audioMode: this.audioMode, retryMode: this.retryMode,
19
+ muted: this.muted, silent: this.silent, failed: this.failed, message });
17
20
  }
18
21
  current(generation) { return this.active && generation === this.generation; }
19
- listening() { return this.muted ? '麦克风已静音' : '正在听,直接说话就好'; }
20
- async begin() {
22
+ listening() {
23
+ if (this.audioMode === 'receive_only') return '只听模式 · 麦克风未启用';
24
+ return this.muted ? '麦克风已静音' : '正在听,直接说话就好';
25
+ }
26
+ retrying(prefix) {
27
+ return this.audioMode === 'receive_only'
28
+ ? `${prefix} · 麦克风未启用,点击只听重试`
29
+ : `${prefix} · 点击语音聊重试`;
30
+ }
31
+ async begin(mode = 'duplex') {
21
32
  if (this.active || this.stopping || this.changingDocuments) return;
33
+ if (mode === 'receive_only' && !this.identity?.audio?.supportedModes?.includes(mode)) return;
34
+ if (!['duplex', 'receive_only'].includes(mode)) return;
22
35
  this.active = true;
36
+ this.audioMode = mode;
37
+ this.retryMode = null;
23
38
  this.failed = false;
24
39
  this.phase = 'connecting';
25
40
  const generation = ++this.generation;
26
41
  // Must reach the isolated preload while the click still has user activation.
27
42
  const preparation = this.client.prepare();
28
43
  this.show('正在连接…');
29
- this.deadline = setTimeout(() => { if (this.current(generation)) void this.end('连接超时 · 点击语音聊重试', true); }, 60_000);
44
+ this.deadline = setTimeout(() => { if (this.current(generation)) void this.end(this.retrying('连接超时'), true); }, 60_000);
30
45
  try {
31
46
  const peer = this.createPeer(event => {
32
47
  if (!this.current(generation)) return;
@@ -37,12 +52,14 @@ export class CompanionConversation {
37
52
  if (event.type === 'error') void this.end(explainError(event), true);
38
53
  });
39
54
  this.peer = peer;
40
- peer.muteMic(this.muted); peer.muteSpeaker(this.silent);
55
+ if (mode === 'duplex') peer.muteMic(this.muted);
56
+ peer.muteSpeaker(this.silent);
41
57
  // Submit capture intent while this same click is active. The Host waits
42
58
  // for its matching preparation before creating the media document.
43
- const [identity] = await Promise.all([preparation, peer.connect()]);
59
+ const [identity] = await Promise.all([preparation, peer.connect(mode === 'receive_only' ? mode : undefined)]);
44
60
  if (!this.current(generation)) { await peer.close(); return; }
45
61
  if (identity.phase !== 'ready') throw { code: 'unavailable' };
62
+ if (mode === 'receive_only' && !identity.audio?.supportedModes?.includes(mode)) throw { code: 'unavailable' };
46
63
  this.identity = identity;
47
64
  this.show(this.message);
48
65
  } catch (error) {
@@ -56,30 +73,42 @@ export class CompanionConversation {
56
73
  clearTimeout(this.deadline);
57
74
  const peer = this.peer;
58
75
  this.peer = undefined;
76
+ const releasedMode = this.audioMode;
77
+ this.audioMode = null;
78
+ this.retryMode = this.failed ? releasedMode : null;
59
79
  this.phase = 'idle';
60
80
  this.show(message);
61
81
  await Promise.allSettled([peer?.close(), this.stopScreen()]);
62
82
  }
63
- async end(message = '语音已结束 · 麦克风已关闭', failed = false) {
83
+ async end(message, failed = false) {
64
84
  if (this.stopping) return this.stopping;
85
+ const receiveOnly = this.audioMode === 'receive_only';
86
+ message ??= this.audioMode === 'receive_only' ? '只听已结束 · 麦克风未启用' : '语音已结束 · 麦克风已关闭';
65
87
  this.failed = failed;
66
88
  const local = this.releaseLocal(message);
67
89
  const operation = Promise.all([local, this.client.stop()]);
68
90
  this.stopping = operation;
69
91
  try { await operation; }
70
- catch { this.show('麦克风已关闭 · 连接收尾尚未确认'); }
92
+ catch { this.show(receiveOnly ? '麦克风未启用 · 连接收尾尚未确认' : '麦克风已关闭 · 连接收尾尚未确认'); }
71
93
  finally { if (this.stopping === operation) this.stopping = undefined; }
72
94
  }
73
95
  async hostStopped(reason) {
74
96
  // Stop echoes must not erase a more useful local failure or manual ending.
75
97
  if (!this.active) return;
76
- const messages = {
98
+ const receiveOnly = this.audioMode === 'receive_only';
99
+ const messages = receiveOnly ? {
100
+ locked: '屏幕已锁定 · 只听已停止,解锁后可点击只听继续',
101
+ suspended: '电脑已休眠 · 只听已停止,可点击只听继续',
102
+ hidden: '猫猫已收起 · 只听已停止',
103
+ } : {
77
104
  locked: '屏幕已锁定 · 语音已停止,解锁后可点击语音聊继续',
78
105
  suspended: '电脑已休眠 · 语音已停止,可点击语音聊继续',
79
106
  hidden: '猫猫已收起 · 语音已停止',
80
107
  };
81
108
  this.failed = !Object.hasOwn(messages, reason);
82
- await this.releaseLocal(messages[reason] ?? '语音连接已中断 · 麦克风已关闭,点击语音聊重试');
109
+ await this.releaseLocal(messages[reason] ?? (receiveOnly
110
+ ? '只听连接已中断 · 麦克风未启用,点击只听重试'
111
+ : '语音连接已中断 · 麦克风已关闭,点击语音聊重试'));
83
112
  }
84
113
  async refresh() {
85
114
  if (this.refreshing) return;
@@ -89,8 +118,9 @@ export class CompanionConversation {
89
118
  const identity = await this.client.state();
90
119
  if (generation !== this.generation) return;
91
120
  this.identity = identity;
121
+ if (this.active && identity.audio?.activeMode) this.audioMode = identity.audio.activeMode;
92
122
  if (this.active && ['closed', 'failed', 'idle'].includes(identity.phase)) {
93
- await this.end('语音连接已中断 · 点击语音聊重试', true);
123
+ await this.end(this.retrying('语音连接已中断'), true);
94
124
  } else this.show(this.message ?? '点开始聊天,直接对我说话');
95
125
  } catch (error) {
96
126
  if (generation === this.generation) {
@@ -134,6 +164,10 @@ export class CompanionConversation {
134
164
  return false;
135
165
  } finally { this.sending = false; }
136
166
  }
137
- muteMic() { this.muted = !this.muted; this.peer?.muteMic(this.muted); this.show(this.phase === 'talking' ? this.listening() : this.message); }
167
+ muteMic() {
168
+ if (this.audioMode === 'receive_only') return;
169
+ this.muted = !this.muted; this.peer?.muteMic(this.muted);
170
+ this.show(this.phase === 'talking' ? this.listening() : this.message);
171
+ }
138
172
  muteSpeaker() { this.silent = !this.silent; this.peer?.muteSpeaker(this.silent); this.show(this.message); }
139
173
  }
@@ -4,6 +4,7 @@
4
4
  <body>
5
5
  <svg class="icon-definitions" aria-hidden="true">
6
6
  <symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3M8 21h8"/></symbol>
7
+ <symbol id="i-listen" viewBox="0 0 24 24"><path d="M4 13v-1a8 8 0 0 1 16 0v1M4 13h3v7H6a2 2 0 0 1-2-2v-5ZM20 13h-3v7h1a2 2 0 0 0 2-2v-5Z"/></symbol>
7
8
  <symbol id="i-keyboard" viewBox="0 0 24 24"><rect x="2.5" y="5" width="19" height="14" rx="3"/><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M7 15h10"/></symbol>
8
9
  <symbol id="i-history" viewBox="0 0 24 24"><path d="M3 11a9 9 0 1 1 2.6 7.4M3 4v7h7M12 7v5l3 2"/></symbol>
9
10
  <symbol id="i-screen" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="13" rx="2.5"/><path d="M8 21h8M12 17v4"/></symbol>
@@ -36,7 +37,7 @@
36
37
  <video id="living-video-transition" muted playsinline preload="metadata" aria-hidden="true" hidden></video>
37
38
  </button>
38
39
  <div id="indicators">
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>
40
+ <button id="call-badge" hidden aria-label="结束语音" title="结束语音"><svg viewBox="0 0 24 24" aria-hidden="true"><use id="call-badge-icon" href="#i-mic"/></svg></button>
40
41
  <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>
41
42
  <button id="pending-badge" hidden aria-label="查看待决事项" title="查看待决事项"><span id="pending-count">?</span></button>
42
43
  </div>
@@ -48,6 +49,7 @@
48
49
  <p id="status" role="status" hidden></p>
49
50
  <div class="action-row">
50
51
  <button id="begin" class="primary" data-action="begin"><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">语音聊</span></button>
52
+ <button id="listen" data-action="listen" hidden><svg class="icon" aria-hidden="true"><use href="#i-listen"/></svg><span class="label">只听</span></button>
51
53
  <button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>打字</span></button>
52
54
  <button id="mic" data-action="mute" hidden><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">静音</span></button>
53
55
  </div>
@@ -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
+ }
package/renderer/peer.mjs CHANGED
@@ -10,7 +10,7 @@ export class VoicePeer {
10
10
  }
11
11
  });
12
12
  }
13
- async connect() { if (!this.closed) await this.client.connectAudio(); }
13
+ async connect(mode) { if (!this.closed) await this.client.connectAudio(mode); }
14
14
  muteMic(muted) { void this.client.muteMicrophone(muted).catch(() => this.failed()); }
15
15
  muteSpeaker(muted) { void this.client.muteSpeaker(muted).catch(() => this.failed()); }
16
16
  failed() { if (!this.closed) this.onEvent({ type: 'error', code: 'unavailable' }); }
@@ -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;
@@ -50,6 +50,7 @@ button:disabled { opacity: .45; cursor: default; }
50
50
  #indicators button:hover { background: #ab4842; }
51
51
  #indicators button[data-state="connecting"] { background: #a77b36; animation: pulse 1.8s infinite; }
52
52
  #indicators button[data-state="muted"] { background: #5f6670; }
53
+ #indicators button[data-state="receive-only"] { background: #476dac; }
53
54
  #indicators svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
54
55
  #share-badge { background: #476dac !important; }
55
56
  #pending-badge { background: #9b6841 !important; font-size: 11px; font-weight: 700; }
@@ -100,6 +101,7 @@ button:disabled { opacity: .45; cursor: default; }
100
101
  #pet[data-skin="yarn-ball"] { background-image: url("./skins/yarn-ball.png"); animation: none; background-size: contain; background-repeat: no-repeat; background-position: center; }
101
102
  #transcript { height: 230px; font-size: 13px; }
102
103
  #transcript p[data-author]::before { content: attr(data-author); display: block; color: var(--cafe-text-muted); font-size: 10px; margin-bottom: 2px; }
104
+ #transcript p[data-companion]::after { content: attr(data-companion) " · " attr(data-companion-live) " · " attr(data-companion-deep); display: block; color: var(--cafe-text-muted); font-size: 9px; line-height: 1.35; margin-top: 4px; }
103
105
  #transcript p[data-live="true"] { border-left: 2px solid var(--cafe-accent); }
104
106
  #history { padding: 0; font-size: 10px; color: var(--cafe-text-secondary); }
105
107
  #decisions { width: 320px; min-height: 180px; padding: 8px 12px 12px; display: flex; flex-direction: column; overflow: hidden; }
@@ -11,6 +11,7 @@ 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';
14
15
  import { currentCompanionIdentity } from './companion-identity.mjs';
15
16
 
16
17
  const $ = id => document.getElementById(id);
@@ -20,6 +21,10 @@ const transcript = new TranscriptView($('transcript'));
20
21
  const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
21
22
  const motion = new PetMotion($('pet'), { livingBody: new LivingBody({ root: $('pet'), sit: $('living-sit'),
22
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
+ });
23
28
  let sharing = false, pendingScreen = false, loading = false, latestHistory, previousPhase = 'idle';
24
29
  let observedHistory = false, lastAssistantId;
25
30
  let resultPreviewUntil = 0;
@@ -33,11 +38,13 @@ if (!window.clowderCompanion) {
33
38
  status('请从 Clowder 的聊聊入口打开猫猫球');
34
39
  document.querySelectorAll('button').forEach(button => { button.disabled = true; });
35
40
  } else {
41
+ nativeTravel.start();
36
42
  const client = createCompanionClient(window.clowderCompanion);
37
43
  const controls = bindPetControls(client, {
38
44
  error: error => status(explainError(error)),
39
45
  moved: (dx, dy) => {
40
- 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);
41
48
  motion.stopMove();
42
49
  if (!didMove) return;
43
50
  didMove = false;
@@ -47,6 +54,7 @@ if (!window.clowderCompanion) {
47
54
  changed: panel => { if (panel === 'chat') void readHistory(); if (panel === 'decisions') void readDecisions(); },
48
55
  action(kind) {
49
56
  if (kind === 'begin') { transcript.reset(); bubble.reset(); void conversation.begin(); void controls.show('none'); }
57
+ if (kind === 'listen') { transcript.reset(); bubble.reset(); void conversation.begin('receive_only'); void controls.show('none'); }
50
58
  if (kind === 'stop') void conversation.end();
51
59
  if (kind === 'mute') conversation.muteMic();
52
60
  if (kind === 'speaker') conversation.muteSpeaker();
@@ -93,25 +101,36 @@ if (!window.clowderCompanion) {
93
101
  if (previousPhase !== 'idle' && value.phase === 'idle') { transcript.reset(); bubble.reset(); void readHistory(); }
94
102
  previousPhase = value.phase;
95
103
  const active = value.phase !== 'idle';
104
+ const receiveOnly = value.audioMode === 'receive_only';
105
+ const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
106
+ const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
96
107
  void controls.setAmbient(showAmbient(active));
97
108
  $('begin').hidden = active; $('begin').disabled = !value.identity;
98
- label('begin', value.failed ? '重试语音' : '语音聊');
99
- $('mic').hidden = value.phase !== 'talking'; $('speaker').hidden = !active;
109
+ $('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
110
+ label('begin', value.failed && !retryReceiveOnly ? '重试语音' : '语音聊');
111
+ label('listen', retryReceiveOnly ? '重试只听' : '只听');
112
+ $('begin').className = retryReceiveOnly ? '' : 'primary';
113
+ $('listen').className = retryReceiveOnly ? 'primary' : '';
114
+ $('mic').hidden = value.phase !== 'talking' || receiveOnly; $('speaker').hidden = !active;
100
115
  $('active-actions').hidden = !active;
101
116
  $('share').disabled = value.phase !== 'talking';
102
117
  $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
103
118
  status(value.message ?? '');
104
- $('status').hidden = !value.message || /^(点|正在听|语音已结束|已发送)/.test(value.message);
119
+ $('status').hidden = !value.message || /^(点|正在听|只听模式|只听已结束|语音已结束|已发送)/.test(value.message);
105
120
  $('call-badge').hidden = !active;
106
- $('call-badge').dataset.state = value.muted ? 'muted' : value.phase;
107
- const callLabel = value.phase === 'connecting' ? '正在连接,点此取消' : value.muted ? '麦克风已静音,点此结束' : '语音进行中,点此结束';
121
+ $('call-badge').dataset.state = receiveOnly ? 'receive-only' : value.muted ? 'muted' : value.phase;
122
+ $('call-badge-icon').setAttribute('href', receiveOnly ? '#i-listen' : '#i-mic');
123
+ const callLabel = value.phase === 'connecting' ? (receiveOnly ? '正在连接只听,麦克风未启用,点此取消' : '正在连接,点此取消')
124
+ : receiveOnly ? '只听进行中,麦克风未启用,点此结束'
125
+ : value.muted ? '麦克风已静音,点此结束' : '语音进行中,点此结束';
108
126
  $('call-badge').title = callLabel; $('call-badge').setAttribute('aria-label', callLabel);
109
127
  label('mic', value.muted ? '取消静音' : '静音'); $('mic').setAttribute('aria-pressed', String(value.muted));
110
128
  label('speaker', value.silent ? '开启播音' : '关闭播音');
111
129
  const identity = value.identity;
112
130
  const companionIdentity = currentCompanionIdentity(identity);
113
- motion.setContext({ skin: identity?.skin, phase: value.phase, nativeActivity: identity?.nativeActivity ?? 'none', muted: value.muted });
114
- motion.syncSnapshot(normalizeNativeWork(identity?.nativeWork));
131
+ motion.setContext({ skin: identity?.skin, phase: value.phase,
132
+ nativeActivity: identity?.nativeActivity ?? 'none', muted: value.muted || receiveOnly });
133
+ motion.syncNativeSnapshot(normalizeNativeWork(identity?.nativeWork));
115
134
  if (justConnected) motion.signal('connected');
116
135
  if (showFailure) motion.signal('failed');
117
136
  if (identity) {
@@ -235,6 +254,6 @@ if (!window.clowderCompanion) {
235
254
  void conversation.refresh(); void readHistory(); void readDecisions(); void controls.show('none');
236
255
  window.addEventListener('beforeunload', () => {
237
256
  clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(decisionMonitor);
238
- unsubscribe(); motion.close(); void conversation.end();
257
+ unsubscribe(); nativeTravel.close(); motion.close(); void conversation.end();
239
258
  });
240
259
  }
@@ -1,3 +1,5 @@
1
+ import { historicalCompanionIdentity } from './companion-identity.mjs';
2
+
1
3
  export class TranscriptView {
2
4
  constructor(container) {
3
5
  this.container = container;
@@ -57,7 +59,23 @@ export class TranscriptView {
57
59
  const row = previous.get(message.id) ?? this.container.ownerDocument.createElement('p');
58
60
  row.className = message.role; row.dataset.messageId = message.id;
59
61
  if (row.textContent !== message.text) row.textContent = message.text;
60
- row.title = message.name; row.dataset.author = message.name;
62
+ const identity = historicalCompanionIdentity(message.companionIdentity, message.name);
63
+ row.dataset.author = identity.authorLabel;
64
+ if (identity.companionKnown) {
65
+ row.dataset.companion = identity.partnerLabel;
66
+ row.dataset.companionLive = identity.liveLabel;
67
+ row.dataset.companionDeep = identity.deepLabel;
68
+ row.dataset.companionAvatar = identity.avatarCatId;
69
+ row.dataset.companionSkin = identity.skin;
70
+ row.title = [identity.authorLabel, identity.partnerLabel, identity.liveLabel, identity.deepLabel].join('\n');
71
+ row.setAttribute('aria-label', [identity.authorLabel, message.text,
72
+ identity.partnerLabel, identity.liveLabel, identity.deepLabel].join('。'));
73
+ } else {
74
+ for (const key of ['companion', 'companionLive', 'companionDeep', 'companionAvatar', 'companionSkin'])
75
+ delete row.dataset[key];
76
+ row.title = identity.authorLabel;
77
+ row.removeAttribute?.('aria-label');
78
+ }
61
79
  this.historyRows.set(message.id, row);
62
80
  return row;
63
81
  });