@clowder-ai/companion 0.1.0-alpha.2 → 0.1.0-alpha.3
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 +3 -0
- package/README.md +37 -0
- package/manifest.json +5 -5
- package/package.json +17 -6
- package/plugin.yaml +5 -5
- package/renderer/client.mjs +4 -0
- package/renderer/conversation.mjs +13 -10
- package/renderer/index.html +50 -26
- package/renderer/pet-controls.mjs +67 -0
- package/renderer/skins/ragdoll-v1.png +0 -0
- package/renderer/skins/yarn-ball.png +0 -0
- package/renderer/style.css +61 -309
- package/renderer/surface.mjs +71 -63
- package/renderer/transcript-view.mjs +11 -0
- package/source-lock.json +16 -2
package/NOTICE
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
Companion imagery and theme tokens originate in Clowder AI (MIT).
|
|
2
2
|
See source-lock.json for the 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.
|
|
3
6
|
The conversation transport and controls continue the F317 companion implementation
|
|
4
7
|
by the Clowder AI contributors. No private runtime data or pet metadata is included.
|
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
Typing works with voice off. The Host uses the existing owner conversation,
|
|
14
|
+
configured duty cat, ordinary message routing and idempotency. The small history
|
|
15
|
+
view reads a bounded recent subset; **完整聊天** opens the canonical conversation.
|
|
16
|
+
No second transcript database, credentials or selectable Host identity live here.
|
|
17
|
+
|
|
18
|
+
## Host integration
|
|
19
|
+
|
|
20
|
+
This surface requires the additive desktop bridge **1.1.0**. The Host computes
|
|
21
|
+
placement within the display work area, keeps the cat as its stable anchor, and
|
|
22
|
+
owns bounded drag gestures and transparent-area hit testing. Older 1.0-only
|
|
23
|
+
Hosts reject this manifest before installation. Media admission and capture
|
|
24
|
+
remain in the trusted Host; this package receives neither streams nor provider
|
|
25
|
+
SDP. Hiding releases media; the existing Clowder **聊聊** entry restores the cat.
|
|
26
|
+
|
|
27
|
+
The original animated skin bytes remain unchanged. Public static skin derivatives
|
|
28
|
+
and the shared color tokens retain their exact provenance in `source-lock.json`.
|
|
29
|
+
|
|
30
|
+
## Verification
|
|
31
|
+
|
|
32
|
+
Run the repository's contract and SDK builds, then `pnpm --filter
|
|
33
|
+
@clowder-ai/companion test` and `pnpm --filter @clowder-ai/companion lint`.
|
|
34
|
+
`node packages/companion/scripts/preview.mjs` serves the accepted interaction
|
|
35
|
+
spike at `/spike/` and the actual built renderer with an explicitly simulated
|
|
36
|
+
bridge at `/previous`. The latter covers visual states and text controls; it
|
|
37
|
+
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.3",
|
|
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.1.0",
|
|
41
41
|
"surface": {
|
|
42
42
|
"entrypoint": "renderer/index.html",
|
|
43
|
-
"integrity": "sha256-
|
|
43
|
+
"integrity": "sha256-xYeE8V8E5IA/XsLIkbLATsVze+7Fea6eGkjJIdUTKzM="
|
|
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,12 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clowder-ai/companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
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",
|
|
@@ -15,8 +26,8 @@
|
|
|
15
26
|
"prepack": "pnpm build"
|
|
16
27
|
},
|
|
17
28
|
"devDependencies": {
|
|
18
|
-
"@clowder-ai/plugin-contract": "0.1.0-beta.
|
|
19
|
-
"@clowder-ai/plugin-sdk": "0.1.0-beta.
|
|
29
|
+
"@clowder-ai/plugin-contract": "0.1.0-beta.17",
|
|
30
|
+
"@clowder-ai/plugin-sdk": "0.1.0-beta.13"
|
|
20
31
|
},
|
|
21
32
|
"repository": {
|
|
22
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.3",
|
|
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.1.0",
|
|
41
41
|
"surface": {
|
|
42
42
|
"entrypoint": "renderer/index.html",
|
|
43
|
-
"integrity": "sha256-
|
|
43
|
+
"integrity": "sha256-xYeE8V8E5IA/XsLIkbLATsVze+7Fea6eGkjJIdUTKzM="
|
|
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/renderer/client.mjs
CHANGED
|
@@ -37,6 +37,10 @@ export function createCompanionClient(bridge) {
|
|
|
37
37
|
screenClose: () => invoke({ kind: 'screen.close' }, 'ok'),
|
|
38
38
|
openConversation: () => invoke({ kind: 'conversation.open' }, 'navigation'),
|
|
39
39
|
resize: (expanded) => invoke({ kind: 'view.resize', expanded }, 'ok'),
|
|
40
|
+
layout: (panel, width, height) => invoke({ kind: 'view.layout', panel, width, height }, 'layout'),
|
|
41
|
+
drag: (phase) => invoke({ kind: 'view.drag', phase }, 'ok'),
|
|
42
|
+
hide: () => invoke({ kind: 'view.hide' }, 'ok'),
|
|
43
|
+
readConversation: () => invoke({ kind: 'conversation.read' }, 'conversation'),
|
|
40
44
|
subscribe: (listener) => bridge.subscribe(listener),
|
|
41
45
|
};
|
|
42
46
|
}
|
|
@@ -9,22 +9,24 @@ export class CompanionConversation {
|
|
|
9
9
|
this.phase = 'idle';
|
|
10
10
|
this.muted = false;
|
|
11
11
|
this.silent = false;
|
|
12
|
+
this.failed = false;
|
|
12
13
|
}
|
|
13
14
|
show(message) {
|
|
14
15
|
this.message = message;
|
|
15
|
-
this.render({ phase: this.phase, identity: this.identity, muted: this.muted, silent: this.silent, message });
|
|
16
|
+
this.render({ phase: this.phase, identity: this.identity, muted: this.muted, silent: this.silent, failed: this.failed, message });
|
|
16
17
|
}
|
|
17
18
|
current(generation) { return this.active && generation === this.generation; }
|
|
18
19
|
listening() { return this.muted ? '麦克风已静音' : '正在听,直接说话就好'; }
|
|
19
20
|
async begin() {
|
|
20
21
|
if (this.active || this.stopping || this.changingDocuments) return;
|
|
21
22
|
this.active = true;
|
|
23
|
+
this.failed = false;
|
|
22
24
|
this.phase = 'connecting';
|
|
23
25
|
const generation = ++this.generation;
|
|
24
26
|
// Must reach the isolated preload while the click still has user activation.
|
|
25
27
|
const preparation = this.client.prepare();
|
|
26
28
|
this.show('正在连接…');
|
|
27
|
-
this.deadline = setTimeout(() => { if (this.current(generation)) void this.end('连接超时 ·
|
|
29
|
+
this.deadline = setTimeout(() => { if (this.current(generation)) void this.end('连接超时 · 点击语音聊重试', true); }, 60_000);
|
|
28
30
|
try {
|
|
29
31
|
const peer = this.createPeer(event => {
|
|
30
32
|
if (!this.current(generation)) return;
|
|
@@ -32,7 +34,7 @@ export class CompanionConversation {
|
|
|
32
34
|
if (event.type === 'recovering') this.show('连接暂时中断 · 正在等待恢复');
|
|
33
35
|
if (event.type === 'recovered') this.show(this.listening());
|
|
34
36
|
if (event.type === 'transcript' || event.type === 'turn-done') this.transcript(event);
|
|
35
|
-
if (event.type === 'error') void this.end(explainError(event));
|
|
37
|
+
if (event.type === 'error') void this.end(explainError(event), true);
|
|
36
38
|
});
|
|
37
39
|
this.peer = peer;
|
|
38
40
|
peer.muteMic(this.muted); peer.muteSpeaker(this.silent);
|
|
@@ -44,7 +46,7 @@ export class CompanionConversation {
|
|
|
44
46
|
this.identity = identity;
|
|
45
47
|
this.show(this.message);
|
|
46
48
|
} catch (error) {
|
|
47
|
-
if (this.current(generation)) await this.end(explainError(error));
|
|
49
|
+
if (this.current(generation)) await this.end(explainError(error), true);
|
|
48
50
|
}
|
|
49
51
|
}
|
|
50
52
|
async releaseLocal(message) {
|
|
@@ -58,8 +60,9 @@ export class CompanionConversation {
|
|
|
58
60
|
this.show(message);
|
|
59
61
|
await Promise.allSettled([peer?.close(), this.stopScreen()]);
|
|
60
62
|
}
|
|
61
|
-
async end(message = '语音已结束 · 麦克风已关闭') {
|
|
63
|
+
async end(message = '语音已结束 · 麦克风已关闭', failed = false) {
|
|
62
64
|
if (this.stopping) return this.stopping;
|
|
65
|
+
this.failed = failed;
|
|
63
66
|
const local = this.releaseLocal(message);
|
|
64
67
|
const operation = Promise.all([local, this.client.stop()]);
|
|
65
68
|
this.stopping = operation;
|
|
@@ -80,7 +83,7 @@ export class CompanionConversation {
|
|
|
80
83
|
} else this.show(this.message ?? '点开始聊天,直接对我说话');
|
|
81
84
|
} catch (error) {
|
|
82
85
|
if (generation === this.generation) {
|
|
83
|
-
if (this.active) await this.end(explainError(error));
|
|
86
|
+
if (this.active) await this.end(explainError(error), true);
|
|
84
87
|
else this.show(explainError(error));
|
|
85
88
|
}
|
|
86
89
|
} finally { this.refreshing = false; }
|
|
@@ -101,7 +104,7 @@ export class CompanionConversation {
|
|
|
101
104
|
}
|
|
102
105
|
async send(text) {
|
|
103
106
|
text = text.trim();
|
|
104
|
-
if (!text || !this.
|
|
107
|
+
if (!text || !this.identity || this.phase === 'connecting' || this.stopping || this.sending) return false;
|
|
105
108
|
if (!this.pendingText || this.pendingText.text !== text) this.pendingText = { text, id: this.uuid() };
|
|
106
109
|
const input = this.pendingText;
|
|
107
110
|
const generation = this.generation;
|
|
@@ -109,12 +112,12 @@ export class CompanionConversation {
|
|
|
109
112
|
try {
|
|
110
113
|
await this.client.text(input.text, input.id);
|
|
111
114
|
if (this.pendingText === input) this.pendingText = undefined;
|
|
112
|
-
if (
|
|
115
|
+
if (generation !== this.generation) return false;
|
|
113
116
|
this.transcript({ type: 'transcript', role: 'user', text, typed: true });
|
|
114
|
-
this.show(this.listening());
|
|
117
|
+
this.show(this.active ? this.listening() : '已发送 · 回答会留在同一段聊天中');
|
|
115
118
|
return true;
|
|
116
119
|
} catch (error) {
|
|
117
|
-
if (this.
|
|
120
|
+
if (generation === this.generation) this.show(explainError(error));
|
|
118
121
|
return false;
|
|
119
122
|
} finally { this.sending = false; }
|
|
120
123
|
}
|
package/renderer/index.html
CHANGED
|
@@ -1,31 +1,55 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="zh-CN">
|
|
3
|
-
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4
|
-
<title>猫猫球</title><link rel="stylesheet" href="./theme.css"><link rel="stylesheet" href="./style.css"></head>
|
|
3
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>猫猫球</title><link rel="stylesheet" href="./style.css"></head>
|
|
5
4
|
<body>
|
|
6
|
-
<
|
|
7
|
-
<
|
|
8
|
-
<
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
5
|
+
<svg class="icon-definitions" aria-hidden="true">
|
|
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-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
|
+
<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
|
+
<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>
|
|
10
|
+
<symbol id="i-book" viewBox="0 0 24 24"><path d="M12 6c-3-2-6-2-9-1v14c3-1 6-1 9 1 3-2 6-2 9-1V5c-3-1-6-1-9 1v14"/></symbol>
|
|
11
|
+
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M20.5 14A8.5 8.5 0 0 1 10 3.5 8.5 8.5 0 1 0 20.5 14Z"/></symbol>
|
|
12
|
+
<symbol id="i-end" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="3"/></symbol>
|
|
13
|
+
</svg>
|
|
14
|
+
<main id="desktop" aria-label="猫猫球">
|
|
15
|
+
<div id="anchor">
|
|
16
|
+
<button id="pet" aria-label="猫猫:点击交流,右键更多,拖动移动" aria-expanded="false" aria-controls="actions menu chat"></button>
|
|
17
|
+
<div id="indicators">
|
|
18
|
+
<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>
|
|
19
|
+
<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>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
<section id="actions" class="floating" aria-label="找猫猫" hidden>
|
|
23
|
+
<p id="status" role="status" hidden></p>
|
|
24
|
+
<div class="action-row">
|
|
25
|
+
<button id="begin" class="primary" data-action="begin"><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">语音聊</span></button>
|
|
26
|
+
<button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>打字</span></button>
|
|
27
|
+
<button id="mic" data-action="mute" hidden><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">静音</span></button>
|
|
28
|
+
</div>
|
|
29
|
+
<div id="active-actions" class="action-row" hidden>
|
|
30
|
+
<button data-action="share" id="share"><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span class="label">共享画面</span></button>
|
|
31
|
+
<button id="end" data-action="stop"><svg class="icon" aria-hidden="true"><use href="#i-end"/></svg><span>结束通话</span></button>
|
|
32
|
+
</div>
|
|
33
|
+
</section>
|
|
34
|
+
<section id="menu" class="floating" aria-label="猫猫的菜单" hidden>
|
|
35
|
+
<strong id="menu-name" class="menu-title">猫猫</strong>
|
|
36
|
+
<button data-action="history"><svg class="icon" aria-hidden="true"><use href="#i-history"/></svg><span>聊天记录</span></button>
|
|
37
|
+
<button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>打字</span></button>
|
|
38
|
+
<div class="menu-divider" role="separator"></div>
|
|
39
|
+
<button id="speaker" data-action="speaker" hidden><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">关闭播音</span></button>
|
|
40
|
+
<button id="menu-share" data-action="share" title="语音连接后可共享画面"><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span class="label">共享画面</span></button>
|
|
41
|
+
<button id="documents" data-action="documents" aria-pressed="true"><svg class="icon" aria-hidden="true"><use href="#i-book"/></svg><span>资料查询</span><span class="menu-status">开启</span></button>
|
|
42
|
+
<div class="menu-divider" role="separator"></div>
|
|
43
|
+
<button data-action="hide" class="quiet-action" title="可从 Clowder 的聊聊入口叫回来"><svg class="icon" aria-hidden="true"><use href="#i-moon"/></svg><span>收起猫猫</span></button>
|
|
44
|
+
</section>
|
|
45
|
+
<section id="chat" class="floating" aria-label="和猫猫写几句" hidden>
|
|
46
|
+
<header><strong id="chat-name">和猫猫写几句</strong><button data-action="dismiss" aria-label="收起文字">收起</button></header>
|
|
47
|
+
<div id="transcript" role="log" aria-live="polite"><p class="empty">想说什么,就写在这里。</p></div>
|
|
48
|
+
<form id="compose"><textarea id="message" aria-label="给猫猫写一句" placeholder="写一句…" rows="2" maxlength="8000"></textarea><button type="submit" class="primary">发送</button></form>
|
|
49
|
+
<small id="chat-status" role="status"></small>
|
|
50
|
+
<small><span id="connection-scope">交流保存在同一段聊天中</span> <button id="history">完整聊天 ↗</button></small>
|
|
51
|
+
</section>
|
|
52
|
+
<p id="announcement" class="sr-only" role="status"></p>
|
|
53
|
+
</main>
|
|
30
54
|
<script type="module" src="./surface.mjs"></script>
|
|
31
55
|
</body></html>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Transient controls. The Host owns native placement, movement and hiding. */
|
|
2
|
+
export function bindPetControls(client, { action, error, changed }) {
|
|
3
|
+
const $ = id => document.getElementById(id);
|
|
4
|
+
const body = $('pet');
|
|
5
|
+
let panel = 'none', layoutRevision = 0, drag, suppressClick = false;
|
|
6
|
+
async function show(next) {
|
|
7
|
+
if (panel !== next && $(next)) $(next).style.maxHeight = '500px';
|
|
8
|
+
panel = next;
|
|
9
|
+
for (const id of ['actions', 'menu', 'chat']) $(id).hidden = id !== next;
|
|
10
|
+
body.setAttribute('aria-expanded', String(next !== 'none'));
|
|
11
|
+
const node = $(next);
|
|
12
|
+
const revision = ++layoutRevision;
|
|
13
|
+
try {
|
|
14
|
+
const placed = await client.layout(next, Math.max(120, Math.ceil(node?.offsetWidth ?? 120)), Math.max(32, Math.ceil(node?.offsetHeight ?? 130)));
|
|
15
|
+
if (revision !== layoutRevision) return;
|
|
16
|
+
$('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
|
|
17
|
+
if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
|
|
18
|
+
if (next === 'chat') $('message').focus({ preventScroll: true });
|
|
19
|
+
if (next === 'menu') $('menu').querySelector('button').focus({ preventScroll: true });
|
|
20
|
+
changed(next);
|
|
21
|
+
} catch (cause) { error(cause); }
|
|
22
|
+
}
|
|
23
|
+
body.onclick = event => {
|
|
24
|
+
if (suppressClick) { suppressClick = false; return; }
|
|
25
|
+
if (event.detail > 1) return;
|
|
26
|
+
void show(panel === 'actions' ? 'none' : 'actions');
|
|
27
|
+
};
|
|
28
|
+
body.oncontextmenu = event => { event.preventDefault(); void show('menu'); };
|
|
29
|
+
body.onkeydown = event => {
|
|
30
|
+
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) { event.preventDefault(); void show('menu'); }
|
|
31
|
+
};
|
|
32
|
+
body.onpointerdown = event => {
|
|
33
|
+
if (event.button !== 0) return;
|
|
34
|
+
drag = { id: event.pointerId, x: event.screenX, y: event.screenY };
|
|
35
|
+
body.setPointerCapture(event.pointerId);
|
|
36
|
+
void client.drag('start').catch(error);
|
|
37
|
+
};
|
|
38
|
+
body.onpointermove = event => {
|
|
39
|
+
if (drag && Math.hypot(event.screenX - drag.x, event.screenY - drag.y) >= 6) suppressClick = true;
|
|
40
|
+
};
|
|
41
|
+
for (const name of ['pointerup', 'pointercancel']) body.addEventListener(name, () => {
|
|
42
|
+
drag = undefined; void client.drag('end').catch(error);
|
|
43
|
+
});
|
|
44
|
+
document.addEventListener('pointerdown', event => {
|
|
45
|
+
if (!event.target.closest('#anchor, .floating')) void show('none');
|
|
46
|
+
});
|
|
47
|
+
document.addEventListener('keydown', event => {
|
|
48
|
+
if (event.key === 'Escape' && panel !== 'none') { event.preventDefault(); void show('none'); body.focus(); }
|
|
49
|
+
});
|
|
50
|
+
document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
|
|
51
|
+
const kind = button.dataset.action;
|
|
52
|
+
if (kind === 'write' || kind === 'history') { void show('chat'); return; }
|
|
53
|
+
if (kind === 'dismiss') { void show('none'); return; }
|
|
54
|
+
action(kind);
|
|
55
|
+
});
|
|
56
|
+
const sizes = new WeakMap();
|
|
57
|
+
const observer = new ResizeObserver(entries => {
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const size = `${entry.contentRect.width}:${entry.contentRect.height}`;
|
|
60
|
+
const previous = sizes.get(entry.target); sizes.set(entry.target, size);
|
|
61
|
+
if (previous && previous !== size && entry.target.id === panel && !entry.target.hidden) void show(panel);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
for (const id of ['actions', 'menu', 'chat']) observer.observe($(id));
|
|
65
|
+
window.addEventListener('beforeunload', () => observer.disconnect());
|
|
66
|
+
return { get panel() { return panel; }, show, dismiss() { if (drag) suppressClick = true; void show('none'); } };
|
|
67
|
+
}
|
|
Binary file
|
|
Binary file
|
package/renderer/style.css
CHANGED
|
@@ -1,309 +1,61 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
button {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
button:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
@
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
flex-shrink: 0;
|
|
63
|
-
padding: 11px 12px 10px;
|
|
64
|
-
background: var(--cafe-surface-elevated);
|
|
65
|
-
border: 1px solid var(--cafe-border-subtle);
|
|
66
|
-
border-radius: 20px;
|
|
67
|
-
box-shadow: 0 4px 16px #0002;
|
|
68
|
-
}
|
|
69
|
-
.voice-state {
|
|
70
|
-
display: flex;
|
|
71
|
-
align-items: center;
|
|
72
|
-
justify-content: center;
|
|
73
|
-
gap: 7px;
|
|
74
|
-
min-height: 25px;
|
|
75
|
-
}
|
|
76
|
-
#orb {
|
|
77
|
-
width: 8px;
|
|
78
|
-
height: 8px;
|
|
79
|
-
flex-shrink: 0;
|
|
80
|
-
border-radius: 50%;
|
|
81
|
-
background: var(--cafe-text-muted);
|
|
82
|
-
}
|
|
83
|
-
body.connecting #orb {
|
|
84
|
-
background: var(--semantic-warning);
|
|
85
|
-
animation: pulse 1.5s ease-in-out infinite;
|
|
86
|
-
}
|
|
87
|
-
body.connected #orb {
|
|
88
|
-
background: var(--semantic-success);
|
|
89
|
-
animation: pulse 3.5s ease-in-out infinite;
|
|
90
|
-
}
|
|
91
|
-
@keyframes pulse {
|
|
92
|
-
50% {
|
|
93
|
-
opacity: 0.5;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
#status {
|
|
97
|
-
margin: 0;
|
|
98
|
-
line-height: 1.45;
|
|
99
|
-
text-align: center;
|
|
100
|
-
font-size: 12px;
|
|
101
|
-
overflow-wrap: anywhere;
|
|
102
|
-
}
|
|
103
|
-
#idle {
|
|
104
|
-
padding: 7px 0 4px;
|
|
105
|
-
}
|
|
106
|
-
#begin {
|
|
107
|
-
width: 100%;
|
|
108
|
-
border-radius: 12px;
|
|
109
|
-
padding: 10px 20px;
|
|
110
|
-
font-weight: 600;
|
|
111
|
-
background: var(--accent-100);
|
|
112
|
-
}
|
|
113
|
-
#begin:hover {
|
|
114
|
-
background: var(--accent-200);
|
|
115
|
-
}
|
|
116
|
-
#controls {
|
|
117
|
-
display: grid;
|
|
118
|
-
grid-template-columns: repeat(4, 1fr);
|
|
119
|
-
gap: 4px;
|
|
120
|
-
padding: 6px 0 9px;
|
|
121
|
-
}
|
|
122
|
-
#controls button {
|
|
123
|
-
display: flex;
|
|
124
|
-
flex-direction: column;
|
|
125
|
-
align-items: center;
|
|
126
|
-
justify-content: center;
|
|
127
|
-
gap: 5px;
|
|
128
|
-
min-height: 51px;
|
|
129
|
-
border-radius: 11px;
|
|
130
|
-
background: transparent;
|
|
131
|
-
font-size: 11px;
|
|
132
|
-
white-space: nowrap;
|
|
133
|
-
}
|
|
134
|
-
#controls button:hover {
|
|
135
|
-
background: var(--cafe-surface);
|
|
136
|
-
}
|
|
137
|
-
#controls button[aria-pressed="true"],
|
|
138
|
-
#controls button[aria-expanded="true"] {
|
|
139
|
-
background: var(--accent-100);
|
|
140
|
-
color: var(--accent-700);
|
|
141
|
-
}
|
|
142
|
-
#controls #end {
|
|
143
|
-
color: var(--semantic-critical);
|
|
144
|
-
}
|
|
145
|
-
#controls #end:hover {
|
|
146
|
-
background: var(--semantic-critical-surface);
|
|
147
|
-
}
|
|
148
|
-
#controls svg {
|
|
149
|
-
width: 19px;
|
|
150
|
-
height: 19px;
|
|
151
|
-
stroke: currentColor;
|
|
152
|
-
fill: none;
|
|
153
|
-
stroke-width: 1.8;
|
|
154
|
-
stroke-linecap: round;
|
|
155
|
-
stroke-linejoin: round;
|
|
156
|
-
}
|
|
157
|
-
#access {
|
|
158
|
-
display: flex;
|
|
159
|
-
align-items: center;
|
|
160
|
-
justify-content: center;
|
|
161
|
-
gap: 8px;
|
|
162
|
-
padding-top: 7px;
|
|
163
|
-
border-top: 1px solid var(--cafe-border-subtle);
|
|
164
|
-
}
|
|
165
|
-
#share {
|
|
166
|
-
flex: 1;
|
|
167
|
-
min-height: 32px;
|
|
168
|
-
padding: 6px 10px;
|
|
169
|
-
border-radius: 9px;
|
|
170
|
-
font-weight: 600;
|
|
171
|
-
background: var(--accent-100);
|
|
172
|
-
color: var(--accent-700);
|
|
173
|
-
}
|
|
174
|
-
#share:hover {
|
|
175
|
-
background: var(--accent-200);
|
|
176
|
-
}
|
|
177
|
-
#share[aria-pressed="true"] {
|
|
178
|
-
background: var(--semantic-success-surface);
|
|
179
|
-
color: var(--semantic-success);
|
|
180
|
-
}
|
|
181
|
-
#documents {
|
|
182
|
-
background: transparent;
|
|
183
|
-
color: var(--cafe-text-secondary);
|
|
184
|
-
font-size: 11px;
|
|
185
|
-
padding: 7px 3px;
|
|
186
|
-
border-radius: 6px;
|
|
187
|
-
}
|
|
188
|
-
#documents:hover {
|
|
189
|
-
background: var(--cafe-surface);
|
|
190
|
-
}
|
|
191
|
-
#screen-status {
|
|
192
|
-
margin: 8px 0 0;
|
|
193
|
-
padding: 7px 9px;
|
|
194
|
-
font-size: 11px;
|
|
195
|
-
line-height: 1.5;
|
|
196
|
-
overflow-wrap: anywhere;
|
|
197
|
-
border-radius: 8px;
|
|
198
|
-
background: var(--semantic-warning-surface);
|
|
199
|
-
color: var(--cafe-text-secondary);
|
|
200
|
-
}
|
|
201
|
-
#screen-status[data-state="sharing"] {
|
|
202
|
-
background: var(--semantic-success-surface);
|
|
203
|
-
}
|
|
204
|
-
#connection-scope {
|
|
205
|
-
margin: 7px 0 0;
|
|
206
|
-
text-align: center;
|
|
207
|
-
font-size: 10px;
|
|
208
|
-
line-height: 1.4;
|
|
209
|
-
color: var(--cafe-text-muted);
|
|
210
|
-
}
|
|
211
|
-
#details {
|
|
212
|
-
align-self: stretch;
|
|
213
|
-
min-height: 0;
|
|
214
|
-
flex: 1;
|
|
215
|
-
margin-bottom: 5px;
|
|
216
|
-
background: var(--cafe-surface-elevated);
|
|
217
|
-
border: 1px solid var(--cafe-border-subtle);
|
|
218
|
-
border-radius: 18px;
|
|
219
|
-
overflow: hidden;
|
|
220
|
-
display: flex;
|
|
221
|
-
flex-direction: column;
|
|
222
|
-
box-shadow: 0 3px 16px #0002;
|
|
223
|
-
}
|
|
224
|
-
#details header {
|
|
225
|
-
display: flex;
|
|
226
|
-
align-items: center;
|
|
227
|
-
gap: 9px;
|
|
228
|
-
padding: 10px 14px;
|
|
229
|
-
border-bottom: 1px solid var(--cafe-border-subtle);
|
|
230
|
-
}
|
|
231
|
-
#details header span {
|
|
232
|
-
color: var(--cafe-text-muted);
|
|
233
|
-
font-size: 11px;
|
|
234
|
-
flex: 1;
|
|
235
|
-
}
|
|
236
|
-
#collapse {
|
|
237
|
-
background: transparent;
|
|
238
|
-
font-size: 12px;
|
|
239
|
-
padding: 4px;
|
|
240
|
-
color: var(--cafe-text-secondary);
|
|
241
|
-
}
|
|
242
|
-
#transcript {
|
|
243
|
-
min-height: 0;
|
|
244
|
-
flex: 1;
|
|
245
|
-
overflow-y: auto;
|
|
246
|
-
padding: 10px 14px;
|
|
247
|
-
line-height: 1.65;
|
|
248
|
-
}
|
|
249
|
-
#transcript p {
|
|
250
|
-
margin: 0 0 12px;
|
|
251
|
-
white-space: pre-wrap;
|
|
252
|
-
overflow-wrap: anywhere;
|
|
253
|
-
}
|
|
254
|
-
#transcript .user {
|
|
255
|
-
color: var(--cafe-text-secondary);
|
|
256
|
-
}
|
|
257
|
-
#transcript .user::before,
|
|
258
|
-
#transcript .assistant::before {
|
|
259
|
-
display: block;
|
|
260
|
-
margin-bottom: 3px;
|
|
261
|
-
font-size: 10px;
|
|
262
|
-
color: var(--cafe-text-muted);
|
|
263
|
-
}
|
|
264
|
-
#transcript .user::before {
|
|
265
|
-
content: "你";
|
|
266
|
-
}
|
|
267
|
-
#transcript .assistant::before {
|
|
268
|
-
content: var(--speaker-label, "猫猫");
|
|
269
|
-
}
|
|
270
|
-
#transcript .tool {
|
|
271
|
-
font-size: 11px;
|
|
272
|
-
color: var(--cafe-text-secondary);
|
|
273
|
-
}
|
|
274
|
-
.empty {
|
|
275
|
-
color: var(--cafe-text-muted);
|
|
276
|
-
}
|
|
277
|
-
#compose {
|
|
278
|
-
display: flex;
|
|
279
|
-
align-items: center;
|
|
280
|
-
gap: 6px;
|
|
281
|
-
border-top: 1px solid var(--cafe-border-subtle);
|
|
282
|
-
padding: 9px;
|
|
283
|
-
}
|
|
284
|
-
#message {
|
|
285
|
-
width: 100%;
|
|
286
|
-
min-width: 0;
|
|
287
|
-
border: 0;
|
|
288
|
-
padding: 5px 3px;
|
|
289
|
-
background: transparent;
|
|
290
|
-
color: inherit;
|
|
291
|
-
}
|
|
292
|
-
#compose button {
|
|
293
|
-
border-radius: 50%;
|
|
294
|
-
background: var(--accent-100);
|
|
295
|
-
min-width: 30px;
|
|
296
|
-
height: 30px;
|
|
297
|
-
}
|
|
298
|
-
@media (prefers-reduced-motion: reduce) {
|
|
299
|
-
#pet,
|
|
300
|
-
body.connecting #orb,
|
|
301
|
-
body.connected #orb {
|
|
302
|
-
animation: none;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
#history { background: transparent; color: var(--cafe-text-secondary); padding: 7px 3px; font-size: 11px; }
|
|
306
|
-
#identity { margin: 0 0 3px; text-align: center; font-weight: 600; }
|
|
307
|
-
#pet[data-skin="xianxian-codex"] { background-image: url("./skins/xianxian-codex.webp"); }
|
|
308
|
-
#pet[data-skin="ragdoll-v1"], #pet[data-skin="yarn-ball"] { background: url("./skins/ragdoll-v1.png") center / contain no-repeat; animation: none; }
|
|
309
|
-
#pet[data-skin="yarn-ball"] { background-image: url("./skins/yarn-ball.png"); }
|
|
1
|
+
@import url("./theme.css");
|
|
2
|
+
* { box-sizing: border-box; }
|
|
3
|
+
[hidden] { display: none !important; }
|
|
4
|
+
body { margin: 0; overflow: hidden; color: var(--cafe-text); background: transparent; font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; }
|
|
5
|
+
button, textarea, input { font: inherit; }
|
|
6
|
+
button { cursor: pointer; color: inherit; }
|
|
7
|
+
button:focus-visible, textarea:focus-visible { outline: 2px solid var(--cafe-accent); outline-offset: 3px; }
|
|
8
|
+
button:disabled { opacity: .45; cursor: default; }
|
|
9
|
+
.icon-definitions { position: absolute; width: 0; height: 0; overflow: hidden; }
|
|
10
|
+
.icon { width: 17px; height: 17px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
|
|
11
|
+
#desktop { position: relative; width: 100vw; height: 100vh; }
|
|
12
|
+
#anchor { position: absolute; left: 8px; top: 8px; width: 120px; height: 130px; }
|
|
13
|
+
#pet { width: 120px; height: 130px; display: block; border: 0; padding: 0; background: url("./skins/xianxian-codex.webp") 0 0 / 960px 1170px; animation: breathe 4.8s steps(6) infinite; filter: drop-shadow(0 6px 4px #0002); touch-action: none; cursor: grab; }
|
|
14
|
+
#pet.dragging { cursor: grabbing; }
|
|
15
|
+
@keyframes breathe { to { background-position-x: -720px; } }
|
|
16
|
+
#indicators { position: absolute; bottom: 0; right: 0; display: flex; gap: 4px; }
|
|
17
|
+
#indicators button { width: 32px; height: 32px; border: 1px solid #fff9; border-radius: 50%; display: grid; place-items: center; background: #236b59; color: white; box-shadow: 0 2px 5px #0003; }
|
|
18
|
+
#indicators button:hover { background: #ab4842; }
|
|
19
|
+
#indicators button[data-state="connecting"] { background: #a77b36; animation: pulse 1.8s infinite; }
|
|
20
|
+
#indicators button[data-state="muted"] { background: #5f6670; }
|
|
21
|
+
#indicators svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
|
|
22
|
+
#share-badge { background: #476dac !important; }
|
|
23
|
+
@keyframes pulse { 50% { opacity: .65; } }
|
|
24
|
+
.floating { position: absolute; z-index: 3; border-radius: 13px; border: 1px solid color-mix(in srgb, var(--cafe-border-subtle), transparent 30%); background: color-mix(in srgb, var(--cafe-surface-elevated) 96%, transparent); box-shadow: 0 10px 26px -10px #24272c2e, 0 2px 5px #24272c0a; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); }
|
|
25
|
+
.floating { max-height: 500px; overflow: auto; }
|
|
26
|
+
.floating button { border: 0; background: transparent; border-radius: 7px; padding: 8px 11px; font-weight: 400; }
|
|
27
|
+
.floating button:hover:not(:disabled) { background: var(--cafe-surface); }
|
|
28
|
+
.floating .primary { background: var(--accent-100); color: var(--accent-900); }
|
|
29
|
+
#actions { padding: 5px; width: max-content; }
|
|
30
|
+
.action-row { display: flex; align-items: center; gap: 4px; }
|
|
31
|
+
.action-row button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 35px; font-size: 12px; white-space: nowrap; }
|
|
32
|
+
.action-row button .icon { width: 16px; height: 16px; }
|
|
33
|
+
#active-actions { border-top: 1px solid var(--cafe-border-subtle); margin-top: 5px; padding-top: 5px; }
|
|
34
|
+
#status { max-width: 220px; font-size: 12px; line-height: 1.5; margin: 7px 10px; color: var(--cafe-text-secondary); }
|
|
35
|
+
#menu { width: 204px; padding: 5px; }
|
|
36
|
+
#menu .menu-title { display: block; padding: 7px 11px 6px; color: var(--cafe-text-muted); font-size: 10px; font-weight: 500; letter-spacing: .04em; }
|
|
37
|
+
#menu button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 33px; text-align: left; font-size: 12px; line-height: 1.4; }
|
|
38
|
+
#menu button .icon { color: var(--cafe-text-secondary); width: 16px; height: 16px; }
|
|
39
|
+
#menu button:disabled { opacity: .36; }
|
|
40
|
+
#menu .menu-status { margin-left: auto; color: var(--cafe-text-muted); font-size: 10px; }
|
|
41
|
+
#menu [aria-pressed="true"] .menu-status { color: var(--accent-700); }
|
|
42
|
+
#menu .menu-divider { height: 1px; margin: 4px 8px; background: color-mix(in srgb, var(--cafe-border-subtle), transparent 28%); }
|
|
43
|
+
#menu .quiet-action { color: var(--cafe-text-secondary); }
|
|
44
|
+
#chat { width: 320px; display: flex; flex-direction: column; }
|
|
45
|
+
#chat header { padding: 7px 9px 7px 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cafe-border-subtle); }
|
|
46
|
+
#chat header button { font-size: 12px; color: var(--cafe-text-secondary); }
|
|
47
|
+
#transcript { height: 145px; overflow: auto; padding: 14px; line-height: 1.7; }
|
|
48
|
+
#transcript p { margin: 0 0 10px; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
49
|
+
#transcript p:not(.empty) { background: var(--accent-50); padding: 7px 10px; border-radius: 9px; }
|
|
50
|
+
.empty { color: var(--cafe-text-muted); font-size: 13px; }
|
|
51
|
+
#compose { display: flex; align-items: flex-end; padding: 10px; gap: 8px; border-top: 1px solid var(--cafe-border-subtle); }
|
|
52
|
+
#message { resize: none; border: 0; min-width: 0; flex: 1; background: transparent; padding: 5px; color: inherit; line-height: 1.5; }
|
|
53
|
+
#chat small { color: var(--cafe-text-muted); font-size: 10px; padding: 0 14px 10px; }
|
|
54
|
+
.sr-only { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; }
|
|
55
|
+
@media (prefers-reduced-motion: reduce) { #pet, #indicators button { animation: none; } }
|
|
56
|
+
#pet[data-skin="yanyan-codex"] { background-image: url("./skins/yanyan-codex.webp"); }
|
|
57
|
+
#pet[data-skin="ragdoll-v1"] { background-image: url("./skins/ragdoll-v1.png"); animation: none; background-size: contain; background-repeat: no-repeat; background-position: center; }
|
|
58
|
+
#pet[data-skin="yarn-ball"] { background-image: url("./skins/yarn-ball.png"); animation: none; background-size: contain; background-repeat: no-repeat; background-position: center; }
|
|
59
|
+
#transcript { height: 230px; font-size: 13px; }
|
|
60
|
+
#transcript .assistant { background: transparent; }
|
|
61
|
+
#history { padding: 0; font-size: 10px; color: var(--cafe-text-secondary); }
|
package/renderer/surface.mjs
CHANGED
|
@@ -4,102 +4,110 @@ import { explainError } from './errors.mjs';
|
|
|
4
4
|
import { VoicePeer } from './peer.mjs';
|
|
5
5
|
import { ScreenShare } from './screen-share.mjs';
|
|
6
6
|
import { TranscriptView } from './transcript-view.mjs';
|
|
7
|
+
import { bindPetControls } from './pet-controls.mjs';
|
|
7
8
|
|
|
8
9
|
const $ = id => document.getElementById(id);
|
|
9
|
-
const
|
|
10
|
+
const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
|
|
11
|
+
const status = text => { $('status').textContent = text; $('chat-status').textContent = text; $('announcement').textContent = text; };
|
|
10
12
|
const transcript = new TranscriptView($('transcript'));
|
|
11
|
-
let
|
|
12
|
-
let sharing = false;
|
|
13
|
-
let pendingScreen = false;
|
|
13
|
+
let sharing = false, pendingScreen = false, loading = false, latestHistory, previousPhase = 'idle';
|
|
14
14
|
|
|
15
15
|
if (!window.clowderCompanion) {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
$('actions').hidden = false; $('status').hidden = false;
|
|
17
|
+
status('请从 Clowder 的聊聊入口打开猫猫球');
|
|
18
|
+
document.querySelectorAll('button').forEach(button => { button.disabled = true; });
|
|
18
19
|
} else {
|
|
19
20
|
const client = createCompanionClient(window.clowderCompanion);
|
|
21
|
+
const controls = bindPetControls(client, {
|
|
22
|
+
error: error => status(explainError(error)),
|
|
23
|
+
changed: panel => { if (panel === 'chat') void readHistory(); },
|
|
24
|
+
action(kind) {
|
|
25
|
+
if (kind === 'begin') { transcript.reset(); void conversation.begin(); void controls.show('none'); }
|
|
26
|
+
if (kind === 'stop') void conversation.end();
|
|
27
|
+
if (kind === 'mute') conversation.muteMic();
|
|
28
|
+
if (kind === 'speaker') conversation.muteSpeaker();
|
|
29
|
+
if (kind === 'share') toggleScreen();
|
|
30
|
+
if (kind === 'documents') void conversation.documents(conversation.identity?.documentsAllowed === false);
|
|
31
|
+
if (kind === 'hide') void client.hide().catch(error => status(explainError(error)));
|
|
32
|
+
},
|
|
33
|
+
});
|
|
20
34
|
const screen = new ScreenShare({
|
|
21
35
|
screenRequest: () => client.screenPick(),
|
|
22
|
-
screenStart: (selectionId,
|
|
36
|
+
screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
|
|
23
37
|
screenFrame: (selectionId, frame) => client.screenFrame(selectionId, {
|
|
24
38
|
...frame, frameId: crypto.randomUUID(), sourceLabel: '所选画面', observedAt: Date.now(),
|
|
25
|
-
}),
|
|
26
|
-
screenStop: () => client.screenClose(),
|
|
39
|
+
}), screenStop: () => client.screenClose(),
|
|
27
40
|
}, value => {
|
|
28
|
-
sharing = value.sharing;
|
|
29
|
-
pendingScreen
|
|
30
|
-
$('share').
|
|
31
|
-
$('share').
|
|
32
|
-
$('screen-status').hidden = !sharing && !pendingScreen && value.message === '未共享屏幕';
|
|
33
|
-
$('screen-status').textContent = sharing ? `正在共享:${value.label}` : value.message;
|
|
34
|
-
$('screen-status').dataset.state = sharing ? 'sharing' : pendingScreen ? 'pending' : 'stopped';
|
|
41
|
+
sharing = value.sharing; pendingScreen = value.pending === true;
|
|
42
|
+
for (const id of ['share', 'menu-share']) label(id, sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享画面');
|
|
43
|
+
$('share-badge').hidden = !sharing && !pendingScreen;
|
|
44
|
+
$('share-badge').title = sharing ? `正在共享:${value.label},点此停止` : '正在选屏,点此取消';
|
|
35
45
|
});
|
|
46
|
+
function toggleScreen() {
|
|
47
|
+
void (sharing || pendingScreen ? screen.stop('屏幕共享已停止') : screen.start()).catch(error => status(explainError(error)));
|
|
48
|
+
}
|
|
36
49
|
const conversation = new CompanionConversation({
|
|
37
50
|
client, createPeer: callback => new VoicePeer(callback, client), stopScreen: () => screen.stop(),
|
|
38
51
|
transcript: event => event.type === 'turn-done' ? transcript.finish(event) : transcript.append(event.role, event.text, !event.typed),
|
|
39
52
|
render(value) {
|
|
53
|
+
const showFailure = value.failed && previousPhase !== 'idle' && value.phase === 'idle';
|
|
54
|
+
previousPhase = value.phase;
|
|
40
55
|
const active = value.phase !== 'idle';
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
$('
|
|
44
|
-
$('
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
$('begin').hidden = active; $('begin').disabled = !value.identity;
|
|
57
|
+
label('begin', value.failed ? '重试语音' : '语音聊');
|
|
58
|
+
$('mic').hidden = value.phase !== 'talking'; $('speaker').hidden = !active;
|
|
59
|
+
$('active-actions').hidden = !active;
|
|
60
|
+
for (const id of ['share', 'menu-share']) $(id).disabled = value.phase !== 'talking';
|
|
61
|
+
$('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
|
|
62
|
+
status(value.message ?? '');
|
|
63
|
+
$('status').hidden = !value.message || /^(点|正在听|语音已结束|已发送)/.test(value.message);
|
|
64
|
+
$('call-badge').hidden = !active;
|
|
65
|
+
$('call-badge').dataset.state = value.muted ? 'muted' : value.phase;
|
|
66
|
+
const callLabel = value.phase === 'connecting' ? '正在连接,点此取消' : value.muted ? '麦克风已静音,点此结束' : '语音进行中,点此结束';
|
|
67
|
+
$('call-badge').title = callLabel; $('call-badge').setAttribute('aria-label', callLabel);
|
|
68
|
+
label('mic', value.muted ? '取消静音' : '静音'); $('mic').setAttribute('aria-pressed', String(value.muted));
|
|
69
|
+
label('speaker', value.silent ? '开启播音' : '关闭播音');
|
|
47
70
|
const identity = value.identity;
|
|
48
71
|
if (identity) {
|
|
49
|
-
$('identity').textContent = identity.displayName;
|
|
50
|
-
$('
|
|
51
|
-
$('pet').setAttribute('aria-label', `${identity.displayName},拖动可以移动`);
|
|
72
|
+
$('chat-name').textContent = identity.displayName; $('menu-name').textContent = identity.displayName;
|
|
73
|
+
$('pet').setAttribute('aria-label', `${identity.displayName}:点击交流,右键更多,拖动移动`);
|
|
52
74
|
$('pet').dataset.skin = identity.skin;
|
|
53
|
-
$('
|
|
54
|
-
$('message').setAttribute('aria-label', `给${identity.displayName}写一句`);
|
|
55
|
-
document.documentElement.style.setProperty('--speaker-label', JSON.stringify(identity.carrier.displayName));
|
|
56
|
-
$('documents').textContent = `资料查询 · ${identity.documentsAllowed ? '开启' : '暂停'}`;
|
|
75
|
+
$('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
|
|
57
76
|
$('documents').setAttribute('aria-pressed', String(identity.documentsAllowed));
|
|
58
|
-
$('connection-scope').textContent = identity.duty.catId === identity.carrier.catId
|
|
59
|
-
? (active && identity.documentsAllowed ? `资料工具 · ${identity.toolsReady ? '已连接' : '连接中'}` : '交流保存在同一段聊天中')
|
|
60
|
-
: `${identity.duty.displayName} · 实时语音由${identity.carrier.displayName}承载`;
|
|
61
|
-
}
|
|
62
|
-
for (const [id, muted, target, off, on] of [
|
|
63
|
-
['mic', value.muted, '麦克风', '取消静音', '静音'],
|
|
64
|
-
['speaker', value.silent, '播音', '开声音', '关声音'],
|
|
65
|
-
]) {
|
|
66
|
-
$(id).setAttribute('aria-pressed', String(muted));
|
|
67
|
-
$(id).setAttribute('aria-label', `${muted ? '开启' : '关闭'}${target}`);
|
|
68
|
-
$(id).querySelector('span').textContent = muted ? off : on;
|
|
77
|
+
$('connection-scope').textContent = identity.duty.catId === identity.carrier.catId ? '交流保存在同一段聊天中' : `${identity.duty.displayName} · 实时语音由${identity.carrier.displayName}承载`;
|
|
69
78
|
}
|
|
79
|
+
if (showFailure) void controls.show('actions');
|
|
70
80
|
},
|
|
71
81
|
});
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
async function readHistory() {
|
|
83
|
+
if (loading || conversation.active || controls.panel !== 'chat') return;
|
|
84
|
+
loading = true;
|
|
85
|
+
try {
|
|
86
|
+
const history = await client.readConversation();
|
|
87
|
+
if (conversation.active) return;
|
|
88
|
+
$('history').textContent = history.hasMore ? '更早聊天 ↗' : '完整聊天 ↗';
|
|
89
|
+
const serialized = JSON.stringify(history.messages);
|
|
90
|
+
if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
|
|
91
|
+
} catch (error) { status(explainError(error)); }
|
|
92
|
+
finally { loading = false; }
|
|
77
93
|
}
|
|
78
|
-
$('
|
|
79
|
-
$('
|
|
80
|
-
$('mic').onclick = () => conversation.muteMic();
|
|
81
|
-
$('speaker').onclick = () => conversation.muteSpeaker();
|
|
82
|
-
$('write').onclick = () => details(!expanded);
|
|
83
|
-
$('collapse').onclick = () => details(false);
|
|
84
|
-
$('share').onclick = () => void (sharing || pendingScreen ? screen.stop('屏幕共享已停止') : screen.start()).catch(() => status('屏幕共享已停止'));
|
|
94
|
+
$('call-badge').onclick = () => void conversation.end();
|
|
95
|
+
$('share-badge').onclick = toggleScreen;
|
|
85
96
|
$('history').onclick = () => void client.openConversation().then(result => {
|
|
86
|
-
if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder
|
|
97
|
+
if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看');
|
|
87
98
|
}).catch(error => status(explainError(error)));
|
|
88
|
-
$('documents').onclick = () => void conversation.documents(conversation.identity?.documentsAllowed === false);
|
|
89
99
|
$('compose').onsubmit = async event => {
|
|
90
|
-
event.preventDefault();
|
|
91
|
-
const text = $('message').value;
|
|
100
|
+
event.preventDefault(); const text = $('message').value;
|
|
92
101
|
if (await conversation.send(text)) {
|
|
93
102
|
if ($('message').value === text) $('message').value = '';
|
|
103
|
+
latestHistory = undefined; void readHistory();
|
|
94
104
|
}
|
|
95
105
|
};
|
|
96
106
|
const unsubscribe = client.subscribe(event => {
|
|
97
|
-
if (event.kind === 'media-stopped') void conversation.releaseLocal('语音已停止
|
|
98
|
-
|
|
99
|
-
const monitor = setInterval(() => void conversation.refresh(), 3000);
|
|
100
|
-
void conversation.refresh();
|
|
101
|
-
window.addEventListener('beforeunload', () => {
|
|
102
|
-
clearInterval(monitor); unsubscribe();
|
|
103
|
-
void conversation.end();
|
|
107
|
+
if (event.kind === 'media-stopped') void conversation.releaseLocal('语音已停止');
|
|
108
|
+
if (event.kind === 'view-dismiss') controls.dismiss();
|
|
104
109
|
});
|
|
110
|
+
const monitor = setInterval(() => { void conversation.refresh(); void readHistory(); }, 3000);
|
|
111
|
+
void conversation.refresh(); void controls.show('none');
|
|
112
|
+
window.addEventListener('beforeunload', () => { clearInterval(monitor); unsubscribe(); void conversation.end(); });
|
|
105
113
|
}
|
|
@@ -37,4 +37,15 @@ export class TranscriptView {
|
|
|
37
37
|
this.activeRows.clear();
|
|
38
38
|
this.completed.clear();
|
|
39
39
|
}
|
|
40
|
+
load(messages) {
|
|
41
|
+
const pinned = this.nearBottom();
|
|
42
|
+
this.reset();
|
|
43
|
+
this.container.replaceChildren(...messages.map(message => {
|
|
44
|
+
const row = this.container.ownerDocument.createElement('p');
|
|
45
|
+
row.className = message.role; row.dataset.messageId = message.id;
|
|
46
|
+
row.textContent = message.text; row.title = message.name;
|
|
47
|
+
return row;
|
|
48
|
+
}));
|
|
49
|
+
if (pinned) this.container.scrollTop = this.container.scrollHeight;
|
|
50
|
+
}
|
|
40
51
|
}
|
package/source-lock.json
CHANGED
|
@@ -20,12 +20,26 @@
|
|
|
20
20
|
{
|
|
21
21
|
"source": "packages/web/public/concierge/skins/ragdoll-v1/idle.png",
|
|
22
22
|
"destination": "assets/skins/ragdoll-v1.png",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "24e5c4dea8d6ee35bf69a5612417088ff15f08c40404cbb18bfb99caad25564e",
|
|
24
|
+
"derivation": {
|
|
25
|
+
"method": "native-imagegen",
|
|
26
|
+
"date": "2026-09-20",
|
|
27
|
+
"referenceSha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025",
|
|
28
|
+
"purpose": "Restore the complete original grey-point cat silhouette on transparent alpha; remove the cropped room background.",
|
|
29
|
+
"author": "codex-astra"
|
|
30
|
+
}
|
|
24
31
|
},
|
|
25
32
|
{
|
|
26
33
|
"source": "packages/web/public/concierge/sprites/ragdoll/idle.png",
|
|
27
34
|
"destination": "assets/skins/yarn-ball.png",
|
|
28
|
-
"sha256": "
|
|
35
|
+
"sha256": "24e5c4dea8d6ee35bf69a5612417088ff15f08c40404cbb18bfb99caad25564e",
|
|
36
|
+
"derivation": {
|
|
37
|
+
"method": "native-imagegen",
|
|
38
|
+
"date": "2026-09-20",
|
|
39
|
+
"referenceSha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025",
|
|
40
|
+
"purpose": "Restore the complete original grey-point cat silhouette on transparent alpha; remove the cropped room background.",
|
|
41
|
+
"author": "codex-astra"
|
|
42
|
+
}
|
|
29
43
|
}
|
|
30
44
|
]
|
|
31
45
|
}
|