@clowder-ai/companion 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (57) hide show
  1. package/NOTICE +10 -2
  2. package/README.md +59 -0
  3. package/manifest.json +5 -5
  4. package/package.json +19 -6
  5. package/plugin.yaml +5 -5
  6. package/renderer/call-transcript-view.mjs +238 -0
  7. package/renderer/client.mjs +13 -1
  8. package/renderer/companion-identity.mjs +42 -0
  9. package/renderer/conversation.mjs +144 -24
  10. package/renderer/decision-view.mjs +124 -0
  11. package/renderer/errors.mjs +4 -4
  12. package/renderer/index.html +168 -26
  13. package/renderer/living-body.mjs +147 -0
  14. package/renderer/living-edge.mjs +16 -0
  15. package/renderer/living-geometry.mjs +96 -0
  16. package/renderer/motion-facts.mjs +108 -0
  17. package/renderer/native-window-travel.mjs +89 -0
  18. package/renderer/native-work-motion.mjs +39 -0
  19. package/renderer/peer.mjs +1 -1
  20. package/renderer/pet-controls.mjs +130 -0
  21. package/renderer/pet-motion-assets.mjs +22 -0
  22. package/renderer/pet-motion.mjs +343 -0
  23. package/renderer/recent-bubble.mjs +74 -0
  24. package/renderer/settings-controller.mjs +171 -0
  25. package/renderer/settings-view.mjs +234 -0
  26. package/renderer/skins/ragdoll-v1.png +0 -0
  27. package/renderer/skins/xianxian-codex.webp +0 -0
  28. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  29. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  30. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  31. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  32. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  33. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  34. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  35. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  36. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  37. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  38. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  39. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  40. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  41. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  42. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  43. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  44. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  45. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  46. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  47. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  48. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  49. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  50. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  51. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  52. package/renderer/skins/xianxian-working-row.png +0 -0
  53. package/renderer/skins/yarn-ball.png +0 -0
  54. package/renderer/style.css +240 -308
  55. package/renderer/surface.mjs +350 -69
  56. package/renderer/transcript-view.mjs +60 -7
  57. package/source-lock.json +138 -4
package/NOTICE CHANGED
@@ -1,4 +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
+ The legacy ragdoll-v1 and yarn-ball stills are AI-assisted derivatives of the
4
+ listed public reference, restoring the complete cat silhouette and transparent
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.
3
11
  The conversation transport and controls continue the F317 companion implementation
4
12
  by the Clowder AI contributors. No private runtime data or pet metadata is included.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # Companion surface
2
+
3
+ At rest the desktop shows only your selected cat. Click the cat for voice or text;
4
+ right-click for history, screen sharing, household access, sound and hiding.
5
+ Dragging moves the cat and folds its controls. Escape folds a panel without
6
+ ending the conversation or clearing its draft.
7
+
8
+ Voice requires an explicit **语音聊** click. An active microphone badge remains
9
+ visible and ends the call in one click. Screen sharing has its own explicit
10
+ picker and termination badge. There is no microphone action on a double-click,
11
+ page load, body tap or drag.
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
+
18
+ Typing works with voice off. The Host uses the existing owner conversation,
19
+ configured duty cat, ordinary message routing and idempotency. The small history
20
+ view reads a bounded recent subset; **完整聊天** opens the canonical conversation.
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.
26
+
27
+ ## Host integration
28
+
29
+ This surface requires the additive desktop bridge **1.3.0**. The Host computes
30
+ placement within the display work area, keeps the cat as its stable anchor, and
31
+ owns bounded drag gestures and transparent-area hit testing. Older 1.0-only
32
+ Hosts reject this manifest before installation. Media admission and capture
33
+ remain in the trusted Host; this package receives neither streams nor provider
34
+ SDP. Hiding releases media; the existing Clowder **聊聊** entry restores the cat.
35
+
36
+ Xianxian uses one layered sitting body or one VP9 clip at a time. Expiring Host
37
+ facts with task, result and event identities select tool work, workspace fetch,
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.
45
+ Other cats retain their own skins. The v3 living assets and their hashes are
46
+ pinned in `source-lock.json`; the installable renderer carries all 21 assets
47
+ itself, including the mid-edge `peek_fade` variant.
48
+
49
+ The Host keeps a small transparent margin left of the pet hit target so the
50
+ pounce's tail is visible without turning empty space into a click target.
51
+
52
+ ## Verification
53
+
54
+ Run the repository's contract and SDK builds, then `pnpm --filter
55
+ @clowder-ai/companion test` and `pnpm --filter @clowder-ai/companion lint`.
56
+ `node packages/companion/scripts/preview.mjs` serves the accepted interaction
57
+ spike at `/spike/` and the actual built renderer with an explicitly simulated
58
+ bridge at `/previous`. The latter covers visual states and text controls; it
59
+ does not prove native movement, real capture or model delivery.
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.20",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -37,14 +37,14 @@
37
37
  "type": "desktop-window",
38
38
  "id": "companion",
39
39
  "role": "companion",
40
- "bridgeVersion": "1.0.0",
40
+ "bridgeVersion": "1.3.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-yVHMFz5zEuDTwQO/pgNRScotBqRzGd9o9wIIhadJDNQ="
43
+ "integrity": "sha256-jaXXDHzwGVDNnFoOhrj5nvqAXMlf02Jw8CeC7Xay538="
44
44
  },
45
45
  "presentation": {
46
- "width": 330,
47
- "height": 350,
46
+ "width": 300,
47
+ "height": 270,
48
48
  "frame": false,
49
49
  "transparent": true,
50
50
  "alwaysOnTop": true,
package/package.json CHANGED
@@ -1,22 +1,35 @@
1
1
  {
2
2
  "name": "@clowder-ai/companion",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.20",
4
4
  "description": "The desktop voice body for your Clowder companion",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "files": ["renderer", "plugin.yaml", "manifest.json", "LICENSE", "NOTICE", "source-lock.json"],
8
- "exports": { "./manifest": "./manifest.json" },
9
- "engines": { "node": ">=22.12.0" },
7
+ "files": [
8
+ "renderer",
9
+ "plugin.yaml",
10
+ "manifest.json",
11
+ "LICENSE",
12
+ "NOTICE",
13
+ "source-lock.json"
14
+ ],
15
+ "exports": {
16
+ "./manifest": "./manifest.json"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.12.0"
20
+ },
10
21
  "scripts": {
11
22
  "build": "node scripts/build.mjs",
12
23
  "test": "node --test test/*.test.mjs",
24
+ "test:browser": "pnpm build && node --test test/panel-layout.browser.mjs",
13
25
  "typecheck": "node scripts/check.mjs",
14
26
  "lint": "node scripts/check.mjs",
15
27
  "prepack": "pnpm build"
16
28
  },
17
29
  "devDependencies": {
18
- "@clowder-ai/plugin-contract": "0.1.0-beta.16",
19
- "@clowder-ai/plugin-sdk": "0.1.0-beta.11"
30
+ "@clowder-ai/plugin-contract": "0.1.0-beta.24",
31
+ "@clowder-ai/plugin-sdk": "0.1.0-beta.20",
32
+ "playwright": "1.61.1"
20
33
  },
21
34
  "repository": {
22
35
  "type": "git",
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.20",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -37,14 +37,14 @@
37
37
  "type": "desktop-window",
38
38
  "id": "companion",
39
39
  "role": "companion",
40
- "bridgeVersion": "1.0.0",
40
+ "bridgeVersion": "1.3.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-yVHMFz5zEuDTwQO/pgNRScotBqRzGd9o9wIIhadJDNQ="
43
+ "integrity": "sha256-jaXXDHzwGVDNnFoOhrj5nvqAXMlf02Jw8CeC7Xay538="
44
44
  },
45
45
  "presentation": {
46
- "width": 330,
47
- "height": 350,
46
+ "width": 300,
47
+ "height": 270,
48
48
  "frame": false,
49
49
  "transparent": true,
50
50
  "alwaysOnTop": true,
@@ -0,0 +1,238 @@
1
+ const MAX_LIVE_ROWS = 8;
2
+
3
+ /**
4
+ * Projection of the Host-bound transcript for one trusted call.
5
+ *
6
+ * Durable rows are keyed only by Host messageId. Ephemeral audio is keyed by
7
+ * provider item/turn identity and is discarded when the Host projects the
8
+ * matching durable source. Text equality is deliberately never used.
9
+ */
10
+ export class CallTranscriptView {
11
+ constructor(container, { onUnread = () => {} } = {}) {
12
+ this.container = container;
13
+ this.onUnread = onUnread;
14
+ this.durableRows = new Map();
15
+ this.liveRows = new Map();
16
+ this.liveSequence = 0;
17
+ this.optimisticIds = new Set();
18
+ this.unread = 0;
19
+ this.initialized = false;
20
+ container.addEventListener?.('scroll', () => {
21
+ if (this.nearBottom()) this.setUnread(0);
22
+ });
23
+ }
24
+
25
+ setCall(callId) {
26
+ if (this.callId === callId) return;
27
+ this.callId = callId ?? null;
28
+ this.scope = undefined;
29
+ this.durableRows.clear();
30
+ this.liveRows.clear();
31
+ this.liveSequence = 0;
32
+ this.optimisticIds.clear();
33
+ this.initialized = false;
34
+ this.container.replaceChildren();
35
+ this.container.dataset.callId = this.callId ?? '';
36
+ this.container.dataset.hasMore = 'false';
37
+ this.setUnread(0);
38
+ }
39
+
40
+ reset() { this.setCall(null); }
41
+
42
+ nearBottom() {
43
+ return this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
44
+ }
45
+
46
+ scrollToBottom() {
47
+ this.container.scrollTop = this.container.scrollHeight;
48
+ this.setUnread(0);
49
+ }
50
+
51
+ setUnread(count) {
52
+ if (this.unread === count) return;
53
+ this.unread = count;
54
+ this.onUnread(count);
55
+ }
56
+
57
+ visibleAnchor() {
58
+ const viewport = this.container.getBoundingClientRect?.();
59
+ if (!viewport) return null;
60
+ for (const [id, row] of this.durableRows) {
61
+ const rect = row.getBoundingClientRect?.();
62
+ if (rect && rect.bottom > viewport.top && rect.top < viewport.bottom) {
63
+ return { id, offset: rect.top - viewport.top };
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ createRow() {
70
+ return this.container.ownerDocument.createElement('p');
71
+ }
72
+
73
+ updateDurable(row, value) {
74
+ row.className = `call-transcript-row ${value.source.kind} ${value.role}`;
75
+ row.textContent = value.text;
76
+ row.dataset.messageId = value.messageId;
77
+ row.dataset.source = value.source.kind;
78
+ row.dataset.role = value.role;
79
+ delete row.dataset.live;
80
+ delete row.dataset.itemId;
81
+ delete row.dataset.turnId;
82
+ delete row.dataset.optimistic;
83
+ if (value.source.kind === 'voice') {
84
+ row.dataset.itemId = value.source.nativeItemId;
85
+ if (value.source.nativeTurnId) row.dataset.turnId = value.source.nativeTurnId;
86
+ delete row.dataset.clientMessageId;
87
+ } else {
88
+ row.dataset.clientMessageId = value.source.clientMessageId;
89
+ }
90
+ row.setAttribute?.('aria-label', value.source.kind === 'typed'
91
+ ? `你打字说:${value.text}`
92
+ : value.role === 'assistant' ? `猫猫:${value.text}` : `语音转写:${value.text}`);
93
+ return row;
94
+ }
95
+
96
+ removeReconciledLive(source) {
97
+ if (source.kind !== 'voice') return false;
98
+ let removed = false;
99
+ for (const [key, row] of this.liveRows) {
100
+ if ((row.dataset.itemId && row.dataset.itemId === source.nativeItemId)
101
+ || (source.nativeTurnId && row.dataset.turnId === source.nativeTurnId)) {
102
+ this.liveRows.delete(key);
103
+ removed = true;
104
+ }
105
+ }
106
+ return removed;
107
+ }
108
+
109
+ render() {
110
+ this.container.replaceChildren(...this.durableRows.values(), ...this.liveRows.values());
111
+ }
112
+
113
+ load(reply) {
114
+ if (!this.callId || reply?.kind !== 'transcript' || reply.scope?.callId !== this.callId) return false;
115
+ const pinned = this.nearBottom();
116
+ const readingPosition = this.container.scrollTop;
117
+ const anchor = pinned ? null : this.visibleAnchor();
118
+ const previous = this.durableRows;
119
+ const previousIds = new Set(previous.keys());
120
+ const next = new Map();
121
+ let added = 0;
122
+
123
+ for (const value of reply.rows) {
124
+ const reconciledLive = this.removeReconciledLive(value.source);
125
+ const row = previous.get(value.messageId) ?? this.createRow();
126
+ this.updateDurable(row, value);
127
+ this.optimisticIds.delete(value.messageId);
128
+ next.set(value.messageId, row);
129
+ if (this.initialized && !previousIds.has(value.messageId) && !reconciledLive) added += 1;
130
+ }
131
+ // An accepted delivery already has a durable Host messageId. A transcript
132
+ // read can race the MessageStore projection, so keep that exact row until
133
+ // the same id appears; never fall back to matching its text.
134
+ for (const id of this.optimisticIds) {
135
+ const row = previous.get(id);
136
+ if (row && !next.has(id)) next.set(id, row);
137
+ }
138
+
139
+ this.scope = reply.scope;
140
+ this.durableRows = next;
141
+ this.container.dataset.callId = reply.scope.callId;
142
+ this.container.dataset.hasMore = String(reply.hasMore);
143
+ this.render();
144
+
145
+ if (pinned) {
146
+ this.container.scrollTop = this.container.scrollHeight;
147
+ this.setUnread(0);
148
+ } else if (anchor) {
149
+ const row = this.durableRows.get(anchor.id) ?? this.durableRows.values().next().value;
150
+ const rect = row?.getBoundingClientRect?.();
151
+ const viewport = this.container.getBoundingClientRect?.();
152
+ this.container.scrollTop = rect && viewport
153
+ ? this.container.scrollTop + rect.top - viewport.top - anchor.offset
154
+ : readingPosition;
155
+ if (added) this.setUnread(this.unread + added);
156
+ } else {
157
+ this.container.scrollTop = readingPosition;
158
+ if (added) this.setUnread(this.unread + added);
159
+ }
160
+ this.initialized = true;
161
+ return true;
162
+ }
163
+
164
+ liveKey(event) {
165
+ if (event.itemId) return `item:${event.itemId}`;
166
+ if (event.turnId) return `turn:${event.turnId}`;
167
+ return `ephemeral:${++this.liveSequence}`;
168
+ }
169
+
170
+ appendLive(event) {
171
+ if (!this.callId || event?.type !== 'transcript' || event.callId !== this.callId) return false;
172
+ const pinned = this.nearBottom();
173
+ const key = this.liveKey(event);
174
+ let row = this.liveRows.get(key);
175
+ const isNew = !row;
176
+ if (!row) {
177
+ row = this.createRow();
178
+ row.className = `call-transcript-row voice ${event.role}`;
179
+ row.dataset.live = 'true';
180
+ row.dataset.source = 'voice';
181
+ row.dataset.role = event.role;
182
+ if (event.itemId) row.dataset.itemId = event.itemId;
183
+ if (event.turnId) row.dataset.turnId = event.turnId;
184
+ this.liveRows.set(key, row);
185
+ while (this.liveRows.size > MAX_LIVE_ROWS) this.liveRows.delete(this.liveRows.keys().next().value);
186
+ }
187
+ row.textContent = `${row.textContent}${event.text}`.slice(-16000);
188
+ row.setAttribute?.('aria-label', `${event.role === 'assistant' ? '猫猫' : '语音转写'},实时:${row.textContent}`);
189
+ this.render();
190
+ if (pinned) {
191
+ this.container.scrollTop = this.container.scrollHeight;
192
+ this.setUnread(0);
193
+ } else if (isNew) this.setUnread(this.unread + 1);
194
+ return true;
195
+ }
196
+
197
+ appendTyped({ receipt, text }) {
198
+ if (!this.callId || receipt?.delivery !== 'accepted' || receipt.callId !== this.callId) return false;
199
+ const pinned = this.nearBottom();
200
+ let row = this.durableRows.get(receipt.messageId);
201
+ const isNew = !row;
202
+ if (!row) {
203
+ row = this.createRow();
204
+ this.durableRows.set(receipt.messageId, row);
205
+ }
206
+ this.updateDurable(row, {
207
+ messageId: receipt.messageId,
208
+ role: 'user',
209
+ text,
210
+ source: { kind: 'typed', clientMessageId: receipt.clientMessageId, callId: receipt.callId },
211
+ });
212
+ row.dataset.optimistic = 'true';
213
+ this.optimisticIds.add(receipt.messageId);
214
+ while (this.optimisticIds.size > MAX_LIVE_ROWS) {
215
+ const oldest = this.optimisticIds.values().next().value;
216
+ this.optimisticIds.delete(oldest);
217
+ if (this.durableRows.get(oldest)?.dataset.optimistic === 'true') this.durableRows.delete(oldest);
218
+ }
219
+ this.render();
220
+ if (pinned) {
221
+ this.container.scrollTop = this.container.scrollHeight;
222
+ this.setUnread(0);
223
+ } else if (isNew) this.setUnread(this.unread + 1);
224
+ return true;
225
+ }
226
+
227
+ finish(event) {
228
+ if (!this.callId || event?.type !== 'turn-done' || event.callId !== this.callId) return false;
229
+ for (const row of this.liveRows.values()) {
230
+ const matchesTurn = event.turnId && row.dataset.turnId === event.turnId;
231
+ const matchesUnkeyedRole = !event.turnId && event.role && !row.dataset.itemId
232
+ && !row.dataset.turnId && row.dataset.role === event.role;
233
+ if (matchesTurn || matchesUnkeyedRole) row.dataset.complete = 'true';
234
+ }
235
+ this.render();
236
+ return true;
237
+ }
238
+ }
@@ -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'),
@@ -36,7 +36,19 @@ export function createCompanionClient(bridge) {
36
36
  screenFrame: (selectionId, frame) => invoke({ kind: 'screen.frame', selectionId, frame }, 'ok'),
37
37
  screenClose: () => invoke({ kind: 'screen.close' }, 'ok'),
38
38
  openConversation: () => invoke({ kind: 'conversation.open' }, 'navigation'),
39
+ readDecisions: (offset = 0, limit = 20) => invoke({ kind: 'decisions.read', offset, limit }, 'decisions'),
40
+ openDecision: (variantRef, target) => invoke({ kind: 'decision.open', variantRef, target }, 'navigation'),
41
+ inspectF221: (proposalId) => invoke({ kind: 'f221.inspect', proposalId }, 'decision-trial'),
39
42
  resize: (expanded) => invoke({ kind: 'view.resize', expanded }, 'ok'),
43
+ layout: (panel, width, height) => invoke({ kind: 'view.layout', panel, width, height }, 'layout'),
44
+ drag: (phase) => invoke({ kind: 'view.drag', phase }, 'ok'),
45
+ hide: () => invoke({ kind: 'view.hide' }, 'ok'),
46
+ resetPosition: () => invoke({ kind: 'view.reset' }, 'ok'),
47
+ readSettings: () => invoke({ kind: 'settings.read' }, 'settings'),
48
+ updateSetting: (field, value) => invoke({ kind: 'settings.update', field, value }, 'settings-update'),
49
+ disableCompanion: () => invoke({ kind: 'companion.disable' }, 'companion-lifecycle'),
50
+ readConversation: () => invoke({ kind: 'conversation.read' }, 'conversation'),
51
+ readTranscript: () => invoke({ kind: 'transcript.read' }, 'transcript'),
40
52
  subscribe: (listener) => bridge.subscribe(listener),
41
53
  };
42
54
  }
@@ -0,0 +1,42 @@
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
+ const snapshotActor = value => actor(value) && value.catId.length <= 160 && value.displayName.length <= 160;
5
+
6
+ function validSnapshot(value) {
7
+ return value?.v === 1 && value.name === '猫猫球'
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);
11
+ }
12
+
13
+ function display(snapshot, partnerLabel) {
14
+ return {
15
+ title: snapshot.name,
16
+ companionKnown: true,
17
+ partnerLabel,
18
+ avatarCatId: snapshot.partner.catId,
19
+ skin: snapshot.partner.skin,
20
+ liveLabel: `Live 快端:${snapshot.live.displayName} · ${snapshot.live.verifiedModel ?? '型号未核实'}`,
21
+ deepLabel: `深思端:${snapshot.deep.displayName} · ${snapshot.deep.verifiedModel ?? '型号未核实'}`,
22
+ };
23
+ }
24
+
25
+ /** The current face comes from the Host's selected duty, never the background carrier. */
26
+ export function currentCompanionIdentity(state) {
27
+ if (!actor(state?.duty) || !actor(state?.carrier) || !named(state.skin)
28
+ || state.liveTransport?.kind !== 'gpt_live_v3' || !model(state.liveTransport.verifiedModel)) return null;
29
+ const snapshot = {
30
+ v: 1, name: '猫猫球',
31
+ partner: { ...state.duty, skin: state.skin },
32
+ live: { ...state.carrier, transport: state.liveTransport.kind, verifiedModel: state.liveTransport.verifiedModel },
33
+ deep: { ...state.duty, verifiedModel: null },
34
+ };
35
+ return display(snapshot, `${snapshot.partner.displayName}陪伴中`);
36
+ }
37
+
38
+ /** A missing old snapshot never borrows today's preference. Keep the message's real author visible. */
39
+ export function historicalCompanionIdentity(snapshot, authorLabel) {
40
+ if (!validSnapshot(snapshot)) return { authorLabel, companionKnown: false };
41
+ return { ...display(snapshot, `当时由${snapshot.partner.displayName}陪伴`), authorLabel };
42
+ }