@clowder-ai/companion 0.1.0-alpha.1 → 0.1.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/NOTICE +10 -2
  2. package/README.md +48 -0
  3. package/manifest.json +5 -5
  4. package/package.json +17 -6
  5. package/plugin.yaml +5 -5
  6. package/renderer/client.mjs +6 -0
  7. package/renderer/companion-identity.mjs +41 -0
  8. package/renderer/conversation.mjs +34 -17
  9. package/renderer/decision-view.mjs +30 -0
  10. package/renderer/index.html +84 -26
  11. package/renderer/living-body.mjs +147 -0
  12. package/renderer/living-edge.mjs +16 -0
  13. package/renderer/living-geometry.mjs +96 -0
  14. package/renderer/motion-facts.mjs +79 -0
  15. package/renderer/native-work-motion.mjs +24 -0
  16. package/renderer/pet-controls.mjs +84 -0
  17. package/renderer/pet-motion.mjs +352 -0
  18. package/renderer/recent-bubble.mjs +39 -0
  19. package/renderer/skins/ragdoll-v1.png +0 -0
  20. package/renderer/skins/xianxian-codex.webp +0 -0
  21. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  22. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  23. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  24. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  25. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  26. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  27. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  28. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  29. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  30. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  31. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  32. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  33. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  34. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  35. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  36. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  37. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  38. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  39. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  40. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  41. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  42. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  43. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  44. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  45. package/renderer/skins/xianxian-working-row.png +0 -0
  46. package/renderer/skins/yarn-ball.png +0 -0
  47. package/renderer/style.css +114 -308
  48. package/renderer/surface.mjs +198 -63
  49. package/renderer/transcript-view.mjs +42 -7
  50. package/source-lock.json +138 -4
package/NOTICE CHANGED
@@ -1,4 +1,12 @@
1
- Companion imagery and theme tokens originate in Clowder AI (MIT).
2
- See source-lock.json for the exact public source revision and content hashes.
1
+ The original companion atlases and theme tokens originate in Clowder AI (MIT).
2
+ See source-lock.json for their exact public source revision and content hashes.
3
+ The legacy ragdoll-v1 and yarn-ball stills are AI-assisted derivatives of the
4
+ listed public reference, restoring the complete cat silhouette and transparent
5
+ background. Their reference and resulting hashes are recorded separately.
6
+ The living Xianxian layers and clips are F317 assets: CVO-generated source video,
7
+ keyframes by codex-astra, and deterministic extraction/alignment by opus55.
8
+ source-lock.json pins the 20 bundled distributable files, their geometry file,
9
+ and the source package manifest digest. Source video and private workspace paths
10
+ are not bundled.
3
11
  The conversation transport and controls continue the F317 companion implementation
4
12
  by the Clowder AI contributors. No private runtime data or pet metadata is included.
package/README.md ADDED
@@ -0,0 +1,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.1",
3
+ "version": "0.1.0-alpha.11",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -37,14 +37,14 @@
37
37
  "type": "desktop-window",
38
38
  "id": "companion",
39
39
  "role": "companion",
40
- "bridgeVersion": "1.0.0",
40
+ "bridgeVersion": "1.3.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-yVHMFz5zEuDTwQO/pgNRScotBqRzGd9o9wIIhadJDNQ="
43
+ "integrity": "sha256-x3ponGs/dhxPH5D1rT9JrLqJpFA6rxrgMjSImhksoHQ="
44
44
  },
45
45
  "presentation": {
46
- "width": 330,
47
- "height": 350,
46
+ "width": 300,
47
+ "height": 270,
48
48
  "frame": false,
49
49
  "transparent": true,
50
50
  "alwaysOnTop": true,
package/package.json CHANGED
@@ -1,12 +1,23 @@
1
1
  {
2
2
  "name": "@clowder-ai/companion",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.11",
4
4
  "description": "The desktop voice body for your Clowder companion",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "files": ["renderer", "plugin.yaml", "manifest.json", "LICENSE", "NOTICE", "source-lock.json"],
8
- "exports": { "./manifest": "./manifest.json" },
9
- "engines": { "node": ">=22.12.0" },
7
+ "files": [
8
+ "renderer",
9
+ "plugin.yaml",
10
+ "manifest.json",
11
+ "LICENSE",
12
+ "NOTICE",
13
+ "source-lock.json"
14
+ ],
15
+ "exports": {
16
+ "./manifest": "./manifest.json"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.12.0"
20
+ },
10
21
  "scripts": {
11
22
  "build": "node scripts/build.mjs",
12
23
  "test": "node --test test/*.test.mjs",
@@ -15,8 +26,8 @@
15
26
  "prepack": "pnpm build"
16
27
  },
17
28
  "devDependencies": {
18
- "@clowder-ai/plugin-contract": "0.1.0-beta.16",
19
- "@clowder-ai/plugin-sdk": "0.1.0-beta.11"
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.1",
3
+ "version": "0.1.0-alpha.11",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -37,14 +37,14 @@
37
37
  "type": "desktop-window",
38
38
  "id": "companion",
39
39
  "role": "companion",
40
- "bridgeVersion": "1.0.0",
40
+ "bridgeVersion": "1.3.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-yVHMFz5zEuDTwQO/pgNRScotBqRzGd9o9wIIhadJDNQ="
43
+ "integrity": "sha256-x3ponGs/dhxPH5D1rT9JrLqJpFA6rxrgMjSImhksoHQ="
44
44
  },
45
45
  "presentation": {
46
- "width": 330,
47
- "height": 350,
46
+ "width": 300,
47
+ "height": 270,
48
48
  "frame": false,
49
49
  "transparent": true,
50
50
  "alwaysOnTop": true,
@@ -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
  }
@@ -0,0 +1,41 @@
1
+ const named = value => typeof value === 'string' && value.trim().length > 0;
2
+ const actor = value => value && named(value.catId) && named(value.displayName);
3
+ const model = value => value === null || (named(value) && value.length <= 160);
4
+
5
+ function validSnapshot(value) {
6
+ return value?.v === 1 && value.name === '猫猫球'
7
+ && actor(value.partner) && named(value.partner.skin)
8
+ && actor(value.live) && value.live.transport === 'gpt_live_v3' && model(value.live.verifiedModel)
9
+ && actor(value.deep) && value.deep.catId === value.partner.catId && model(value.deep.verifiedModel);
10
+ }
11
+
12
+ function display(snapshot, partnerLabel) {
13
+ return {
14
+ title: snapshot.name,
15
+ companionKnown: true,
16
+ partnerLabel,
17
+ avatarCatId: snapshot.partner.catId,
18
+ skin: snapshot.partner.skin,
19
+ liveLabel: `Live 快端:${snapshot.live.displayName} · ${snapshot.live.verifiedModel ?? '型号未核实'}`,
20
+ deepLabel: `深思端:${snapshot.deep.displayName} · ${snapshot.deep.verifiedModel ?? '型号未核实'}`,
21
+ };
22
+ }
23
+
24
+ /** The current face comes from the Host's selected duty, never the background carrier. */
25
+ export function currentCompanionIdentity(state) {
26
+ if (!actor(state?.duty) || !actor(state?.carrier) || !named(state.skin)
27
+ || state.liveTransport?.kind !== 'gpt_live_v3' || !model(state.liveTransport.verifiedModel)) return null;
28
+ const snapshot = {
29
+ v: 1, name: '猫猫球',
30
+ partner: { ...state.duty, skin: state.skin },
31
+ live: { ...state.carrier, transport: state.liveTransport.kind, verifiedModel: state.liveTransport.verifiedModel },
32
+ deep: { ...state.duty, verifiedModel: null },
33
+ };
34
+ return display(snapshot, `${snapshot.partner.displayName}陪伴中`);
35
+ }
36
+
37
+ /** A missing old snapshot never borrows today's preference. Keep the message's real author visible. */
38
+ export function historicalCompanionIdentity(snapshot, authorLabel) {
39
+ if (!validSnapshot(snapshot)) return { authorLabel, companionKnown: false };
40
+ return { ...display(snapshot, `当时由${snapshot.partner.displayName}陪伴`), authorLabel };
41
+ }
@@ -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('连接超时 · 点击开始聊天重试'); }, 60_000);
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
- await peer.connect();
44
- if (!this.current(generation)) await peer.close();
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.active || this.phase !== 'talking' || this.sending) return false;
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
- if (!this.current(generation)) return false;
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.current(generation)) this.show(explainError(error));
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
+ }
@@ -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
- <section id="details" hidden>
7
- <header><strong id="chat-name">猫猫</strong><span>本次交流</span><button type="button" id="collapse" aria-label="收起文字">收起 ⌄</button></header>
8
- <div id="transcript" role="log" aria-live="polite"><p class="empty">可以直接说,也可以写一句。</p></div>
9
- <form id="compose"><input id="message" aria-label="给猫猫写一句" placeholder="写一句…" maxlength="8000"><button type="submit" aria-label="发送">↑</button></form>
10
- </section>
11
- <div id="pet" aria-label="猫猫,拖动可以移动" role="img"></div>
12
- <section id="dock" aria-label="和猫猫交流">
13
- <p id="identity">猫猫球</p>
14
- <div class="voice-state"><div id="orb" aria-hidden="true"></div><p id="status" role="status">点开始聊天,直接对我说话</p></div>
15
- <div id="idle"><button type="button" id="begin" disabled>开始聊天</button></div>
16
- <div id="controls" hidden>
17
- <button type="button" id="mic" aria-label="关闭麦克风" title="关闭麦克风"><svg aria-hidden="true" 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 18v4M8 22h8"/></svg><span>静音</span></button>
18
- <button type="button" id="speaker" aria-label="关闭播音" title="关闭播音"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m11 4-6 5H2v6h3l6 5zM15 8a6 6 0 0 1 0 8M18 4a11 11 0 0 1 0 16"/></svg><span>关声音</span></button>
19
- <button type="button" id="write" aria-label="展开文字交流" aria-expanded="false" title="写一句 / 看文字"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M13 5H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-7M15 4l5 5M9 15l1-5 8-8 4 4-8 8z"/></svg><span>文字</span></button>
20
- <button type="button" id="end" aria-label="结束语音" title="结束本次通话"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m7 7 10 10M7 17 17 7"/></svg><span>结束</span></button>
21
- </div>
22
- <div id="access">
23
- <button type="button" id="history">打开聊天</button>
24
- <button type="button" id="share" hidden title="选择窗口或屏幕后共享给当前模型;不共享系统声音">共享屏幕</button>
25
- <button type="button" id="documents" title="资料查询设置;不包含屏幕共享">资料查询</button>
26
- </div>
27
- <p id="screen-status" role="status" hidden></p>
28
- <p id="connection-scope"></p>
29
- </section>
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>