@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.
- package/NOTICE +10 -2
- package/README.md +59 -0
- package/manifest.json +5 -5
- package/package.json +19 -6
- package/plugin.yaml +5 -5
- package/renderer/call-transcript-view.mjs +238 -0
- package/renderer/client.mjs +13 -1
- package/renderer/companion-identity.mjs +42 -0
- package/renderer/conversation.mjs +144 -24
- package/renderer/decision-view.mjs +124 -0
- package/renderer/errors.mjs +4 -4
- package/renderer/index.html +168 -26
- package/renderer/living-body.mjs +147 -0
- package/renderer/living-edge.mjs +16 -0
- package/renderer/living-geometry.mjs +96 -0
- package/renderer/motion-facts.mjs +108 -0
- package/renderer/native-window-travel.mjs +89 -0
- package/renderer/native-work-motion.mjs +39 -0
- package/renderer/peer.mjs +1 -1
- package/renderer/pet-controls.mjs +130 -0
- package/renderer/pet-motion-assets.mjs +22 -0
- package/renderer/pet-motion.mjs +343 -0
- package/renderer/recent-bubble.mjs +74 -0
- package/renderer/settings-controller.mjs +171 -0
- package/renderer/settings-view.mjs +234 -0
- package/renderer/skins/ragdoll-v1.png +0 -0
- package/renderer/skins/xianxian-codex.webp +0 -0
- package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
- package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
- package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
- package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
- package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
- package/renderer/skins/xianxian-living/clips/think.webm +0 -0
- package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
- package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
- package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
- package/renderer/skins/xianxian-living/layers/body.webp +0 -0
- package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
- package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
- package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
- package/renderer/skins/xianxian-living/layers/head.webp +0 -0
- package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
- package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
- package/renderer/skins/xianxian-living/layers/meta.json +88 -0
- package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
- package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
- package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
- package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
- package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
- package/renderer/skins/xianxian-sleeping-row.png +0 -0
- package/renderer/skins/xianxian-staged-thought-row.png +0 -0
- package/renderer/skins/xianxian-working-row.png +0 -0
- package/renderer/skins/yarn-ball.png +0 -0
- package/renderer/style.css +240 -308
- package/renderer/surface.mjs +350 -69
- package/renderer/transcript-view.mjs +60 -7
- package/source-lock.json +138 -4
package/NOTICE
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
See source-lock.json for
|
|
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.
|
|
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.
|
|
40
|
+
"bridgeVersion": "1.3.0",
|
|
41
41
|
"surface": {
|
|
42
42
|
"entrypoint": "renderer/index.html",
|
|
43
|
-
"integrity": "sha256-
|
|
43
|
+
"integrity": "sha256-jaXXDHzwGVDNnFoOhrj5nvqAXMlf02Jw8CeC7Xay538="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
|
-
"width":
|
|
47
|
-
"height":
|
|
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.
|
|
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": [
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
19
|
-
"@clowder-ai/plugin-sdk": "0.1.0-beta.
|
|
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.
|
|
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.
|
|
40
|
+
"bridgeVersion": "1.3.0",
|
|
41
41
|
"surface": {
|
|
42
42
|
"entrypoint": "renderer/index.html",
|
|
43
|
-
"integrity": "sha256-
|
|
43
|
+
"integrity": "sha256-jaXXDHzwGVDNnFoOhrj5nvqAXMlf02Jw8CeC7Xay538="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
|
-
"width":
|
|
47
|
-
"height":
|
|
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
|
+
}
|
package/renderer/client.mjs
CHANGED
|
@@ -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
|
+
}
|