@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 +16 -5
- package/manifest.json +2 -2
- package/package.json +3 -3
- package/plugin.yaml +2 -2
- package/renderer/client.mjs +1 -1
- package/renderer/companion-identity.mjs +4 -3
- package/renderer/conversation.mjs +46 -12
- package/renderer/index.html +3 -1
- package/renderer/motion-facts.mjs +33 -4
- package/renderer/native-window-travel.mjs +89 -0
- package/renderer/peer.mjs +1 -1
- package/renderer/pet-motion-assets.mjs +22 -0
- package/renderer/pet-motion.mjs +21 -24
- package/renderer/style.css +2 -0
- package/renderer/surface.mjs +28 -9
- package/renderer/transcript-view.mjs +19 -1
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
30
|
-
"@clowder-ai/plugin-sdk": "0.1.0-beta.
|
|
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.
|
|
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-
|
|
43
|
+
"integrity": "sha256-vGmfNZUU2zOlDNsQUqPjrvcwmO6p+Qdi3aPwlI8Wcyc="
|
|
44
44
|
},
|
|
45
45
|
"presentation": {
|
|
46
46
|
"width": 300,
|
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'),
|
|
@@ -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
|
-
&&
|
|
8
|
-
&&
|
|
9
|
-
&&
|
|
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,
|
|
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() {
|
|
20
|
-
|
|
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('连接超时
|
|
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
|
-
|
|
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
|
|
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
|
|
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('语音连接已中断
|
|
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() {
|
|
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
|
}
|
package/renderer/index.html
CHANGED
|
@@ -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"><
|
|
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:
|
|
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;
|
package/renderer/pet-motion.mjs
CHANGED
|
@@ -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.
|
|
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;
|
package/renderer/style.css
CHANGED
|
@@ -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; }
|
package/renderer/surface.mjs
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
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 || /^(
|
|
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
|
-
|
|
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,
|
|
114
|
-
|
|
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
|
-
|
|
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
|
});
|