@clowder-ai/companion 0.1.0-alpha.1 → 0.1.0-alpha.10
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 +48 -0
- package/manifest.json +5 -5
- package/package.json +17 -6
- package/plugin.yaml +5 -5
- package/renderer/client.mjs +6 -0
- package/renderer/conversation.mjs +34 -17
- package/renderer/decision-view.mjs +30 -0
- package/renderer/index.html +84 -26
- package/renderer/living-body.mjs +172 -0
- package/renderer/living-edge.mjs +16 -0
- package/renderer/motion-facts.mjs +79 -0
- package/renderer/native-work-motion.mjs +24 -0
- package/renderer/pet-controls.mjs +84 -0
- package/renderer/pet-motion.mjs +350 -0
- package/renderer/recent-bubble.mjs +39 -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 +114 -308
- package/renderer/surface.mjs +189 -62
- package/renderer/transcript-view.mjs +42 -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,48 @@
|
|
|
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.3.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
|
+
Xianxian uses one layered sitting body or one VP9 clip at a time. Expiring Host
|
|
28
|
+
facts with task, result and event identities select tool work, workspace fetch,
|
|
29
|
+
screen reading, applied-result delivery and autonomous target movement.
|
|
30
|
+
Dragging instead freezes a visibly held candidate and cancels the old target;
|
|
31
|
+
it never renames the running clip. Pounce requires an explicit play gesture by
|
|
32
|
+
default; optional automatic play is bounded by idle and cooldown timers.
|
|
33
|
+
Reduced motion keeps semantic poses static and suppresses roaming and pounce.
|
|
34
|
+
Other cats retain their own skins. The v3 living assets and their hashes are
|
|
35
|
+
pinned in `source-lock.json`; the installable renderer carries all 21 assets
|
|
36
|
+
itself, including the mid-edge `peek_fade` variant.
|
|
37
|
+
|
|
38
|
+
The Host keeps a small transparent margin left of the pet hit target so the
|
|
39
|
+
pounce's tail is visible without turning empty space into a click target.
|
|
40
|
+
|
|
41
|
+
## Verification
|
|
42
|
+
|
|
43
|
+
Run the repository's contract and SDK builds, then `pnpm --filter
|
|
44
|
+
@clowder-ai/companion test` and `pnpm --filter @clowder-ai/companion lint`.
|
|
45
|
+
`node packages/companion/scripts/preview.mjs` serves the accepted interaction
|
|
46
|
+
spike at `/spike/` and the actual built renderer with an explicitly simulated
|
|
47
|
+
bridge at `/previous`. The latter covers visual states and text controls; it
|
|
48
|
+
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.10",
|
|
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-x3ponGs/dhxPH5D1rT9JrLqJpFA6rxrgMjSImhksoHQ="
|
|
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.10",
|
|
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.20",
|
|
30
|
+
"@clowder-ai/plugin-sdk": "0.1.0-beta.16"
|
|
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.10",
|
|
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-x3ponGs/dhxPH5D1rT9JrLqJpFA6rxrgMjSImhksoHQ="
|
|
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
|
@@ -36,7 +36,13 @@ 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
|
+
inspectF221: (proposalId) => invoke({ kind: 'f221.inspect', proposalId }, 'decision-trial'),
|
|
39
41
|
resize: (expanded) => invoke({ kind: 'view.resize', expanded }, 'ok'),
|
|
42
|
+
layout: (panel, width, height) => invoke({ kind: 'view.layout', panel, width, height }, 'layout'),
|
|
43
|
+
drag: (phase) => invoke({ kind: 'view.drag', phase }, 'ok'),
|
|
44
|
+
hide: () => invoke({ kind: 'view.hide' }, 'ok'),
|
|
45
|
+
readConversation: () => invoke({ kind: 'conversation.read' }, 'conversation'),
|
|
40
46
|
subscribe: (listener) => bridge.subscribe(listener),
|
|
41
47
|
};
|
|
42
48
|
}
|
|
@@ -9,41 +9,44 @@ 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
|
-
const identity = await preparation;
|
|
30
|
-
if (!this.current(generation)) return;
|
|
31
|
-
if (identity.phase !== 'ready') throw { code: 'unavailable' };
|
|
32
|
-
this.identity = identity;
|
|
33
31
|
const peer = this.createPeer(event => {
|
|
34
32
|
if (!this.current(generation)) return;
|
|
35
33
|
if (event.type === 'connected') { clearTimeout(this.deadline); this.phase = 'talking'; this.show(this.listening()); }
|
|
36
34
|
if (event.type === 'recovering') this.show('连接暂时中断 · 正在等待恢复');
|
|
37
35
|
if (event.type === 'recovered') this.show(this.listening());
|
|
38
36
|
if (event.type === 'transcript' || event.type === 'turn-done') this.transcript(event);
|
|
39
|
-
if (event.type === 'error') void this.end(explainError(event));
|
|
37
|
+
if (event.type === 'error') void this.end(explainError(event), true);
|
|
40
38
|
});
|
|
41
39
|
this.peer = peer;
|
|
42
40
|
peer.muteMic(this.muted); peer.muteSpeaker(this.silent);
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
// Submit capture intent while this same click is active. The Host waits
|
|
42
|
+
// for its matching preparation before creating the media document.
|
|
43
|
+
const [identity] = await Promise.all([preparation, peer.connect()]);
|
|
44
|
+
if (!this.current(generation)) { await peer.close(); return; }
|
|
45
|
+
if (identity.phase !== 'ready') throw { code: 'unavailable' };
|
|
46
|
+
this.identity = identity;
|
|
47
|
+
this.show(this.message);
|
|
45
48
|
} catch (error) {
|
|
46
|
-
if (this.current(generation)) await this.end(explainError(error));
|
|
49
|
+
if (this.current(generation)) await this.end(explainError(error), true);
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
async releaseLocal(message) {
|
|
@@ -57,8 +60,9 @@ export class CompanionConversation {
|
|
|
57
60
|
this.show(message);
|
|
58
61
|
await Promise.allSettled([peer?.close(), this.stopScreen()]);
|
|
59
62
|
}
|
|
60
|
-
async end(message = '语音已结束 · 麦克风已关闭') {
|
|
63
|
+
async end(message = '语音已结束 · 麦克风已关闭', failed = false) {
|
|
61
64
|
if (this.stopping) return this.stopping;
|
|
65
|
+
this.failed = failed;
|
|
62
66
|
const local = this.releaseLocal(message);
|
|
63
67
|
const operation = Promise.all([local, this.client.stop()]);
|
|
64
68
|
this.stopping = operation;
|
|
@@ -66,6 +70,17 @@ export class CompanionConversation {
|
|
|
66
70
|
catch { this.show('麦克风已关闭 · 连接收尾尚未确认'); }
|
|
67
71
|
finally { if (this.stopping === operation) this.stopping = undefined; }
|
|
68
72
|
}
|
|
73
|
+
async hostStopped(reason) {
|
|
74
|
+
// Stop echoes must not erase a more useful local failure or manual ending.
|
|
75
|
+
if (!this.active) return;
|
|
76
|
+
const messages = {
|
|
77
|
+
locked: '屏幕已锁定 · 语音已停止,解锁后可点击语音聊继续',
|
|
78
|
+
suspended: '电脑已休眠 · 语音已停止,可点击语音聊继续',
|
|
79
|
+
hidden: '猫猫已收起 · 语音已停止',
|
|
80
|
+
};
|
|
81
|
+
this.failed = !Object.hasOwn(messages, reason);
|
|
82
|
+
await this.releaseLocal(messages[reason] ?? '语音连接已中断 · 麦克风已关闭,点击语音聊重试');
|
|
83
|
+
}
|
|
69
84
|
async refresh() {
|
|
70
85
|
if (this.refreshing) return;
|
|
71
86
|
this.refreshing = true;
|
|
@@ -75,11 +90,11 @@ export class CompanionConversation {
|
|
|
75
90
|
if (generation !== this.generation) return;
|
|
76
91
|
this.identity = identity;
|
|
77
92
|
if (this.active && ['closed', 'failed', 'idle'].includes(identity.phase)) {
|
|
78
|
-
await this.end('
|
|
93
|
+
await this.end('语音连接已中断 · 点击语音聊重试', true);
|
|
79
94
|
} else this.show(this.message ?? '点开始聊天,直接对我说话');
|
|
80
95
|
} catch (error) {
|
|
81
96
|
if (generation === this.generation) {
|
|
82
|
-
if (this.active) await this.end(explainError(error));
|
|
97
|
+
if (this.active) await this.end(explainError(error), true);
|
|
83
98
|
else this.show(explainError(error));
|
|
84
99
|
}
|
|
85
100
|
} finally { this.refreshing = false; }
|
|
@@ -100,7 +115,7 @@ export class CompanionConversation {
|
|
|
100
115
|
}
|
|
101
116
|
async send(text) {
|
|
102
117
|
text = text.trim();
|
|
103
|
-
if (!text || !this.
|
|
118
|
+
if (!text || !this.identity || this.phase === 'connecting' || this.stopping || this.sending) return false;
|
|
104
119
|
if (!this.pendingText || this.pendingText.text !== text) this.pendingText = { text, id: this.uuid() };
|
|
105
120
|
const input = this.pendingText;
|
|
106
121
|
const generation = this.generation;
|
|
@@ -108,12 +123,14 @@ export class CompanionConversation {
|
|
|
108
123
|
try {
|
|
109
124
|
await this.client.text(input.text, input.id);
|
|
110
125
|
if (this.pendingText === input) this.pendingText = undefined;
|
|
111
|
-
|
|
126
|
+
// Voice may have ended; an accepted message still retires this draft.
|
|
127
|
+
// Keep the newer voice status rather than announcing that we are listening.
|
|
128
|
+
if (generation !== this.generation) return true;
|
|
112
129
|
this.transcript({ type: 'transcript', role: 'user', text, typed: true });
|
|
113
|
-
this.show(this.listening());
|
|
130
|
+
this.show(this.active ? this.listening() : '已发送 · 回答会留在同一段聊天中');
|
|
114
131
|
return true;
|
|
115
132
|
} catch (error) {
|
|
116
|
-
if (this.
|
|
133
|
+
if (generation === this.generation) this.show(explainError(error));
|
|
117
134
|
return false;
|
|
118
135
|
} finally { this.sending = false; }
|
|
119
136
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** F246 remains the decision truth; linked F310 receipts are enrichment, not another item. */
|
|
2
|
+
export function decisionBadge(page) {
|
|
3
|
+
if (!page || page.status !== 'available')
|
|
4
|
+
return { visible: true, label: '?', title: '待决事项暂不可读' };
|
|
5
|
+
const total = page.approvalCount + page.otherNeedsMeCount;
|
|
6
|
+
return {
|
|
7
|
+
visible: total > 0,
|
|
8
|
+
label: total > 99 ? '99+' : String(total),
|
|
9
|
+
title: `${page.approvalCount} 项审批事项,${page.otherNeedsMeCount} 项其他待处理`,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function decisionRows(page) {
|
|
14
|
+
if (!page || page.status !== 'available') return [];
|
|
15
|
+
const state = {
|
|
16
|
+
open: '待你决定', accepted: '已接纳 · 待核结果', rejected: '已拒绝',
|
|
17
|
+
closed_without_decision: '已关闭',
|
|
18
|
+
};
|
|
19
|
+
return [
|
|
20
|
+
...page.approvals.map(item => ({
|
|
21
|
+
title: item.summary || '待决策事项',
|
|
22
|
+
meta: `${item.sourceFeatureId} · ${state[item.resolution] ?? '状态待核'} · ${item.materializationState === 'outcome_unknown' ? '结果未知' : item.materializationState === 'failed' ? '执行失败' : item.linkedNeedsMe ? '已关联任务' : '原处卡片'}`,
|
|
23
|
+
proposalId: item.proposalId,
|
|
24
|
+
previewable: item.sourceFeatureId === 'F221' &&
|
|
25
|
+
/^[A-Za-z0-9_-]{1,200}$/.test(item.proposalId) &&
|
|
26
|
+
item.resolution === 'open' && item.materializationState === 'not_started',
|
|
27
|
+
})),
|
|
28
|
+
...page.otherNeedsMe.map(item => ({ title: item.summary || '待处理事项', meta: '其他待处理 · 请向猫猫询问详情' })),
|
|
29
|
+
];
|
|
30
|
+
}
|
package/renderer/index.html
CHANGED
|
@@ -1,31 +1,89 @@
|
|
|
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">
|
|
17
|
+
<svg id="living-sit" viewBox="0 0 600 600" aria-hidden="true" hidden>
|
|
18
|
+
<defs>
|
|
19
|
+
<mask id="living-eye-left" maskUnits="userSpaceOnUse" x="0" y="0" width="600" height="600"><image href="./skins/xianxian-living/layers/whiteL.webp" width="600" height="600"/></mask>
|
|
20
|
+
<mask id="living-eye-right" maskUnits="userSpaceOnUse" x="0" y="0" width="600" height="600"><image href="./skins/xianxian-living/layers/whiteR.webp" width="600" height="600"/></mask>
|
|
21
|
+
</defs>
|
|
22
|
+
<g data-layer="tail"><image href="./skins/xianxian-living/layers/tail.webp" width="600" height="600"/></g>
|
|
23
|
+
<image href="./skins/xianxian-living/layers/body.webp" width="600" height="600"/>
|
|
24
|
+
<g data-layer="head">
|
|
25
|
+
<g data-layer="ear-left"><image href="./skins/xianxian-living/layers/earL.webp" width="600" height="600"/></g>
|
|
26
|
+
<g data-layer="ear-right"><image href="./skins/xianxian-living/layers/earR.webp" width="600" height="600"/></g>
|
|
27
|
+
<image href="./skins/xianxian-living/layers/head.webp" width="600" height="600"/>
|
|
28
|
+
<g mask="url(#living-eye-left)"><image data-layer="iris-left" href="./skins/xianxian-living/layers/irisL.webp" width="600" height="600"/></g>
|
|
29
|
+
<g mask="url(#living-eye-right)"><image data-layer="iris-right" href="./skins/xianxian-living/layers/irisR.webp" width="600" height="600"/></g>
|
|
30
|
+
<image data-layer="closed" href="./skins/xianxian-living/layers/closed.webp" width="600" height="600"/>
|
|
31
|
+
<image data-layer="mouth" href="./skins/xianxian-living/layers/mouth.webp" width="600" height="600"/>
|
|
32
|
+
<image data-layer="scared" href="./skins/xianxian-living/layers/scared.webp" width="600" height="600"/>
|
|
33
|
+
</g>
|
|
34
|
+
</svg>
|
|
35
|
+
<video id="living-video" muted playsinline preload="metadata" aria-hidden="true" hidden></video>
|
|
36
|
+
<video id="living-video-transition" muted playsinline preload="metadata" aria-hidden="true" hidden></video>
|
|
37
|
+
</button>
|
|
38
|
+
<div id="indicators">
|
|
39
|
+
<button id="call-badge" hidden aria-label="结束语音" title="结束语音"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4M8 22h8"/></svg></button>
|
|
40
|
+
<button id="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
|
+
<button id="pending-badge" hidden aria-label="查看待决事项" title="查看待决事项"><span id="pending-count">?</span></button>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
<section id="bubble" class="floating" aria-label="最近的两句话" hidden>
|
|
45
|
+
<p id="bubble-first" hidden></p><p id="bubble-second" hidden></p>
|
|
46
|
+
</section>
|
|
47
|
+
<section id="actions" class="floating" aria-label="找猫猫" hidden>
|
|
48
|
+
<p id="status" role="status" hidden></p>
|
|
49
|
+
<div class="action-row">
|
|
50
|
+
<button id="begin" class="primary" data-action="begin"><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">语音聊</span></button>
|
|
51
|
+
<button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>打字</span></button>
|
|
52
|
+
<button id="mic" data-action="mute" hidden><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">静音</span></button>
|
|
53
|
+
</div>
|
|
54
|
+
<div id="active-actions" class="action-row" hidden>
|
|
55
|
+
<button data-action="share" id="share"><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span class="label">共享画面</span></button>
|
|
56
|
+
<button id="end" data-action="stop"><svg class="icon" aria-hidden="true"><use href="#i-end"/></svg><span>结束通话</span></button>
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
<section id="menu" class="floating" aria-label="猫猫的菜单" hidden>
|
|
60
|
+
<strong id="menu-name" class="menu-title">猫猫</strong>
|
|
61
|
+
<button data-action="history"><svg class="icon" aria-hidden="true"><use href="#i-history"/></svg><span>聊天记录</span></button>
|
|
62
|
+
<button data-action="decisions"><span aria-hidden="true">◇</span><span>待你看</span><span id="menu-pending" class="menu-status">—</span></button>
|
|
63
|
+
<button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>打字</span></button>
|
|
64
|
+
<button id="menu-play" data-action="play" hidden><span aria-hidden="true">✦</span><span>逗宪宪</span></button>
|
|
65
|
+
<div class="menu-divider" role="separator"></div>
|
|
66
|
+
<button id="speaker" data-action="speaker" hidden><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">关闭播音</span></button>
|
|
67
|
+
<button id="menu-share" data-action="share" title="语音连接后可共享画面"><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span class="label">共享画面</span></button>
|
|
68
|
+
<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>
|
|
69
|
+
<div class="menu-divider" role="separator"></div>
|
|
70
|
+
<button data-action="hide" class="quiet-action" title="可从 Clowder 的聊聊入口叫回来"><svg class="icon" aria-hidden="true"><use href="#i-moon"/></svg><span>收起猫猫</span></button>
|
|
71
|
+
</section>
|
|
72
|
+
<section id="chat" class="floating" aria-label="和猫猫写几句" hidden>
|
|
73
|
+
<header><strong id="chat-name">和猫猫写几句</strong><button data-action="dismiss" aria-label="收起文字">收起</button></header>
|
|
74
|
+
<div id="transcript" role="log" aria-live="polite"><p class="empty">想说什么,就写在这里。</p></div>
|
|
75
|
+
<form id="compose"><textarea id="message" aria-label="给猫猫写一句" placeholder="写一句…" rows="2" maxlength="8000"></textarea><button type="submit" class="primary">发送</button></form>
|
|
76
|
+
<small id="chat-status" role="status"></small>
|
|
77
|
+
<small><span id="connection-scope">交流保存在同一段聊天中</span> <button id="history">完整聊天 ↗</button></small>
|
|
78
|
+
</section>
|
|
79
|
+
<p id="announcement" class="sr-only" role="status"></p>
|
|
80
|
+
<section id="decisions" class="floating" aria-label="待你看" hidden>
|
|
81
|
+
<header><strong>待你看</strong><button id="decision-reload" type="button">刷新</button></header>
|
|
82
|
+
<p id="decision-status" role="status">正在读取待决事项…</p>
|
|
83
|
+
<ul id="decision-list"></ul>
|
|
84
|
+
<button id="decision-more" type="button" hidden>查看更多</button>
|
|
85
|
+
<small>这里只查看原处事项。要决定时请打开对应卡片。</small>
|
|
86
|
+
</section>
|
|
87
|
+
</main>
|
|
30
88
|
<script type="module" src="./surface.mjs"></script>
|
|
31
89
|
</body></html>
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// F317 living Xianxian package v3. Values are CSS anchors from its reviewed manifest.
|
|
2
|
+
const scale = 120 / 290;
|
|
3
|
+
const groundY = 10 + 262.5 * scale;
|
|
4
|
+
const clips = {
|
|
5
|
+
'running-left': { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
|
|
6
|
+
'running-right': { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
|
|
7
|
+
running: { name: 'trot', size: [265, 236], ground: 234.8, centre: 132.5, loop: true },
|
|
8
|
+
play: { name: 'pounce', size: [304.8, 312.6], ground: 303.9, centre: 201.9, rate: 1.5 },
|
|
9
|
+
sleeping: { name: 'sleep', size: [271.2, 168], ground: 161, centre: 133.2, loop: true },
|
|
10
|
+
waking: { name: 'wake', size: [400.2, 282.7], ground: 244.2, centre: 200.5,
|
|
11
|
+
endGround: 277.4, endCentre: 197.7, durationMs: 7_250, crossfadeMs: 250 },
|
|
12
|
+
peek: { name: 'peek', size: [153.5, 234.3], ground: 234.3, centre: 76.75, loop: true },
|
|
13
|
+
delivering: { name: 'carry', size: [344.3, 290.9], ground: 263, centre: 198.3 },
|
|
14
|
+
review: { name: 'carry', size: [344.3, 290.9], ground: 263, centre: 198.3 },
|
|
15
|
+
pending_decision: { name: 'wait', size: [197.9, 252.8], ground: 249.6, centre: 98.4, loop: true },
|
|
16
|
+
working: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
|
|
17
|
+
fetching: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
|
|
18
|
+
studying: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
|
|
19
|
+
staged_thought: { name: 'think', size: [266.4, 256.9], ground: 253.7, centre: 132.1, loop: true },
|
|
20
|
+
};
|
|
21
|
+
export const isLivingClip = action => Object.hasOwn(clips, action);
|
|
22
|
+
const setHidden = (node, hidden) => {
|
|
23
|
+
if (!node) return;
|
|
24
|
+
node.toggleAttribute?.('hidden', hidden);
|
|
25
|
+
node.hidden = hidden;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One visible body: a layered sit or one muted clip, with a bounded sleep/wake hand-off. */
|
|
29
|
+
export class LivingBody {
|
|
30
|
+
constructor({ root, sit, video, transitionVideo, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout }) {
|
|
31
|
+
this.root = root;
|
|
32
|
+
this.sit = sit;
|
|
33
|
+
this.video = video;
|
|
34
|
+
this.standby = transitionVideo;
|
|
35
|
+
this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
|
|
36
|
+
this.clearTimer = id => clearTimer.call(globalThis, id);
|
|
37
|
+
this.action = '';
|
|
38
|
+
this.revision = 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
show(action, { reducedMotion = false, onEnded = () => {} } = {}) {
|
|
42
|
+
const dockEdge = this.root.dataset.dockEdge;
|
|
43
|
+
const dockZone = this.root.dataset.dockZone;
|
|
44
|
+
if (this.action === action && this.reducedMotion === reducedMotion && this.dockEdge === dockEdge && this.dockZone === dockZone) return;
|
|
45
|
+
const previous = this.action;
|
|
46
|
+
this.action = action;
|
|
47
|
+
this.reducedMotion = reducedMotion;
|
|
48
|
+
this.dockEdge = dockEdge;
|
|
49
|
+
this.dockZone = dockZone;
|
|
50
|
+
const revision = ++this.revision;
|
|
51
|
+
this.cancelFade();
|
|
52
|
+
this.root.dataset.pose = this.pose(action);
|
|
53
|
+
this.root.dataset.direction = action === 'running-left' || (action === 'peek' && dockEdge === 'left') ? 'left' : 'right';
|
|
54
|
+
const base = clips[action];
|
|
55
|
+
const clip = !reducedMotion && base
|
|
56
|
+
? { ...base, name: action === 'peek' && dockZone !== 'corner' ? 'peek_fade' : base.name }
|
|
57
|
+
: null;
|
|
58
|
+
if (!clip) {
|
|
59
|
+
this.hideVideos();
|
|
60
|
+
setHidden(this.sit, false);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const crossfade = previous === 'sleeping' && action === 'waking' && this.standby;
|
|
64
|
+
if (crossfade) this.crossfade(clip, revision, onEnded);
|
|
65
|
+
else this.showClip(this.video, clip, revision, onEnded);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pose(action) {
|
|
69
|
+
if (action === 'failed') return 'scared';
|
|
70
|
+
if (action === 'waiting') return 'listening';
|
|
71
|
+
if (['sleeping', 'pending_decision', 'held', 'working', 'fetching', 'studying'].includes(action))
|
|
72
|
+
return action === 'pending_decision' ? 'pending' : action;
|
|
73
|
+
return 'sit';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
showClip(video, clip, revision, onEnded) {
|
|
77
|
+
this.retireVideo(this.standby);
|
|
78
|
+
this.configure(video, clip, revision, onEnded);
|
|
79
|
+
video.style.opacity = '1';
|
|
80
|
+
setHidden(this.sit, true);
|
|
81
|
+
setHidden(video, false);
|
|
82
|
+
this.play(video, revision, onEnded);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
crossfade(clip, revision, onEnded) {
|
|
86
|
+
const outgoing = this.video;
|
|
87
|
+
const incoming = this.standby;
|
|
88
|
+
this.configure(incoming, clip, revision, onEnded);
|
|
89
|
+
incoming.style.transition = `opacity ${clip.crossfadeMs}ms linear`;
|
|
90
|
+
outgoing.style.transition = `opacity ${clip.crossfadeMs}ms linear`;
|
|
91
|
+
incoming.style.opacity = '0';
|
|
92
|
+
outgoing.style.opacity = '1';
|
|
93
|
+
setHidden(this.sit, true);
|
|
94
|
+
setHidden(outgoing, false);
|
|
95
|
+
setHidden(incoming, false);
|
|
96
|
+
this.play(incoming, revision, onEnded);
|
|
97
|
+
incoming.style.opacity = '1';
|
|
98
|
+
outgoing.style.opacity = '0';
|
|
99
|
+
this.video = incoming;
|
|
100
|
+
this.standby = outgoing;
|
|
101
|
+
this.fadeTimer = this.setTimer(() => {
|
|
102
|
+
this.fadeTimer = undefined;
|
|
103
|
+
if (revision !== this.revision) return;
|
|
104
|
+
this.retireVideo(outgoing);
|
|
105
|
+
}, clip.crossfadeMs);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
configure(video, clip, revision, onEnded) {
|
|
109
|
+
video.onended = null;
|
|
110
|
+
video.pause();
|
|
111
|
+
video.src = `./skins/xianxian-living/clips/${clip.name}.webm`;
|
|
112
|
+
video.loop = clip.loop === true;
|
|
113
|
+
video.playbackRate = clip.rate ?? 1;
|
|
114
|
+
video.style.width = `${clip.size[0] * scale}px`;
|
|
115
|
+
video.style.height = `${clip.size[1] * scale}px`;
|
|
116
|
+
const left = this.action === 'peek'
|
|
117
|
+
? (this.root.dataset.dockEdge === 'left' ? -32 : 128 - clip.size[0] * scale)
|
|
118
|
+
: 60 - clip.centre * scale;
|
|
119
|
+
const top = this.action === 'peek' ? 138 - clip.size[1] * scale : groundY - clip.ground * scale;
|
|
120
|
+
video.style.left = `${left}px`;
|
|
121
|
+
video.style.top = `${top}px`;
|
|
122
|
+
video.style.animation = clip.endGround
|
|
123
|
+
? `living-wake-anchor ${clip.durationMs}ms linear forwards` : 'none';
|
|
124
|
+
if (clip.endGround) {
|
|
125
|
+
video.style.setProperty?.('--living-left-start', `${left}px`);
|
|
126
|
+
video.style.setProperty?.('--living-top-start', `${top}px`);
|
|
127
|
+
video.style.setProperty?.('--living-left-end', `${60 - clip.endCentre * scale}px`);
|
|
128
|
+
video.style.setProperty?.('--living-top-end', `${groundY - clip.endGround * scale}px`);
|
|
129
|
+
}
|
|
130
|
+
try { video.currentTime = 0; } catch { /* New source is not yet seekable. */ }
|
|
131
|
+
video.onended = () => { if (revision === this.revision) onEnded(); };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
play(video, revision, onEnded) {
|
|
135
|
+
void video.play().catch(() => {
|
|
136
|
+
if (revision !== this.revision) return;
|
|
137
|
+
this.hideVideos();
|
|
138
|
+
setHidden(this.sit, false);
|
|
139
|
+
onEnded();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
hideVideos() {
|
|
144
|
+
for (const video of [this.video, this.standby]) {
|
|
145
|
+
this.retireVideo(video);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
retireVideo(video) {
|
|
150
|
+
if (!video) return;
|
|
151
|
+
video.onended = null;
|
|
152
|
+
video.pause();
|
|
153
|
+
setHidden(video, true);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
cancelFade() {
|
|
157
|
+
if (this.fadeTimer !== undefined) this.clearTimer(this.fadeTimer);
|
|
158
|
+
this.fadeTimer = undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
close() {
|
|
162
|
+
this.revision++;
|
|
163
|
+
this.cancelFade();
|
|
164
|
+
this.hideVideos();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
hide() {
|
|
168
|
+
this.close();
|
|
169
|
+
setHidden(this.sit, true);
|
|
170
|
+
this.action = '';
|
|
171
|
+
}
|
|
172
|
+
}
|