@clowder-ai/companion 0.1.0-alpha.2 → 0.1.0-alpha.4

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 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.2",
3
+ "version": "0.1.0-alpha.4",
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.1.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-yVHMFz5zEuDTwQO/pgNRScotBqRzGd9o9wIIhadJDNQ="
43
+ "integrity": "sha256-xYeE8V8E5IA/XsLIkbLATsVze+7Fea6eGkjJIdUTKzM="
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.2",
3
+ "version": "0.1.0-alpha.4",
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.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.2",
3
+ "version": "0.1.0-alpha.4",
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.1.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-yVHMFz5zEuDTwQO/pgNRScotBqRzGd9o9wIIhadJDNQ="
43
+ "integrity": "sha256-xYeE8V8E5IA/XsLIkbLATsVze+7Fea6eGkjJIdUTKzM="
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,
@@ -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('连接超时 · 点击开始聊天重试'); }, 60_000);
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;
@@ -67,6 +70,17 @@ export class CompanionConversation {
67
70
  catch { this.show('麦克风已关闭 · 连接收尾尚未确认'); }
68
71
  finally { if (this.stopping === operation) this.stopping = undefined; }
69
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
+ }
70
84
  async refresh() {
71
85
  if (this.refreshing) return;
72
86
  this.refreshing = true;
@@ -76,11 +90,11 @@ export class CompanionConversation {
76
90
  if (generation !== this.generation) return;
77
91
  this.identity = identity;
78
92
  if (this.active && ['closed', 'failed', 'idle'].includes(identity.phase)) {
79
- await this.end('连接已结束 · 点击开始聊天继续');
93
+ await this.end('语音连接已中断 · 点击语音聊重试', true);
80
94
  } else this.show(this.message ?? '点开始聊天,直接对我说话');
81
95
  } catch (error) {
82
96
  if (generation === this.generation) {
83
- if (this.active) await this.end(explainError(error));
97
+ if (this.active) await this.end(explainError(error), true);
84
98
  else this.show(explainError(error));
85
99
  }
86
100
  } finally { this.refreshing = false; }
@@ -101,7 +115,7 @@ export class CompanionConversation {
101
115
  }
102
116
  async send(text) {
103
117
  text = text.trim();
104
- 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;
105
119
  if (!this.pendingText || this.pendingText.text !== text) this.pendingText = { text, id: this.uuid() };
106
120
  const input = this.pendingText;
107
121
  const generation = this.generation;
@@ -109,12 +123,14 @@ export class CompanionConversation {
109
123
  try {
110
124
  await this.client.text(input.text, input.id);
111
125
  if (this.pendingText === input) this.pendingText = undefined;
112
- 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;
113
129
  this.transcript({ type: 'transcript', role: 'user', text, typed: true });
114
- this.show(this.listening());
130
+ this.show(this.active ? this.listening() : '已发送 · 回答会留在同一段聊天中');
115
131
  return true;
116
132
  } catch (error) {
117
- if (this.current(generation)) this.show(explainError(error));
133
+ if (generation === this.generation) this.show(explainError(error));
118
134
  return false;
119
135
  } finally { this.sending = false; }
120
136
  }
@@ -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
- <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"></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
Binary file
@@ -1,309 +1,63 @@
1
- * {
2
- box-sizing: border-box;
3
- }
4
- html,
5
- body {
6
- margin: 0;
7
- width: 100%;
8
- height: 100%;
9
- overflow: hidden;
10
- background: transparent;
11
- }
12
- body {
13
- font:
14
- 13px -apple-system,
15
- BlinkMacSystemFont,
16
- "PingFang SC",
17
- sans-serif;
18
- color: var(--cafe-text);
19
- display: flex;
20
- flex-direction: column;
21
- align-items: center;
22
- justify-content: flex-end;
23
- padding: 10px;
24
- }
25
- [hidden] {
26
- display: none !important;
27
- }
28
- button,
29
- input {
30
- font: inherit;
31
- }
32
- button {
33
- cursor: pointer;
34
- border: 0;
35
- color: inherit;
36
- }
37
- button:focus-visible,
38
- input:focus-visible {
39
- outline: 2px solid var(--cafe-accent);
40
- outline-offset: 3px;
41
- }
42
- button:disabled {
43
- opacity: 0.5;
44
- cursor: default;
45
- }
46
- #pet {
47
- width: 96px;
48
- height: 104px;
49
- flex-shrink: 0;
50
- background: url("./skins/yanyan-codex.webp") 0 0 / 768px 936px;
51
- animation: idle 4.8s steps(6) infinite;
52
- -webkit-app-region: drag;
53
- filter: drop-shadow(0 4px 5px #0003);
54
- }
55
- @keyframes idle {
56
- to {
57
- background-position-x: -576px;
58
- }
59
- }
60
- #dock {
61
- width: 100%;
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
+ #transcript p[data-author]::before { content: attr(data-author); display: block; color: var(--cafe-text-muted); font-size: 10px; margin-bottom: 2px; }
62
+ #transcript p[data-live="true"] { border-left: 2px solid var(--cafe-accent); }
63
+ #history { padding: 0; font-size: 10px; color: var(--cafe-text-secondary); }
@@ -4,102 +4,114 @@ 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 status = text => { $('status').textContent = text; };
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 expanded = false;
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
- status('请从 Clowder 的插件页面打开猫猫球');
17
- for (const id of ['begin', 'documents', 'history']) $(id).disabled = true;
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, label) => client.screenOpen(selectionId, label),
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 = value.pending === true;
30
- $('share').textContent = sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享屏幕';
31
- $('share').setAttribute('aria-pressed', String(sharing));
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
- transcript: event => event.type === 'turn-done' ? transcript.finish(event) : transcript.append(event.role, event.text, !event.typed),
51
+ transcript: event => {
52
+ if (event.type === 'turn-done') { transcript.finish(event); void readHistory(); }
53
+ else if (event.typed) void readHistory();
54
+ else transcript.append(event.role, event.text, true);
55
+ },
39
56
  render(value) {
57
+ const showFailure = value.failed && previousPhase !== 'idle' && value.phase === 'idle';
58
+ if (previousPhase !== 'idle' && value.phase === 'idle') { transcript.reset(); void readHistory(); }
59
+ previousPhase = value.phase;
40
60
  const active = value.phase !== 'idle';
41
- document.body.className = value.phase === 'talking' ? 'connected' : active ? 'connecting' : '';
42
- $('idle').hidden = active; $('controls').hidden = !active;
43
- $('share').hidden = value.phase !== 'talking';
44
- $('compose').querySelector('button').disabled = value.phase !== 'talking';
45
- $('begin').disabled = !value.identity;
46
- status(value.message);
61
+ $('begin').hidden = active; $('begin').disabled = !value.identity;
62
+ label('begin', value.failed ? '重试语音' : '语音聊');
63
+ $('mic').hidden = value.phase !== 'talking'; $('speaker').hidden = !active;
64
+ $('active-actions').hidden = !active;
65
+ for (const id of ['share', 'menu-share']) $(id).disabled = value.phase !== 'talking';
66
+ $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
67
+ status(value.message ?? '');
68
+ $('status').hidden = !value.message || /^(点|正在听|语音已结束|已发送)/.test(value.message);
69
+ $('call-badge').hidden = !active;
70
+ $('call-badge').dataset.state = value.muted ? 'muted' : value.phase;
71
+ const callLabel = value.phase === 'connecting' ? '正在连接,点此取消' : value.muted ? '麦克风已静音,点此结束' : '语音进行中,点此结束';
72
+ $('call-badge').title = callLabel; $('call-badge').setAttribute('aria-label', callLabel);
73
+ label('mic', value.muted ? '取消静音' : '静音'); $('mic').setAttribute('aria-pressed', String(value.muted));
74
+ label('speaker', value.silent ? '开启播音' : '关闭播音');
47
75
  const identity = value.identity;
48
76
  if (identity) {
49
- $('identity').textContent = identity.displayName;
50
- $('chat-name').textContent = identity.displayName;
51
- $('pet').setAttribute('aria-label', `${identity.displayName},拖动可以移动`);
77
+ $('chat-name').textContent = identity.displayName; $('menu-name').textContent = identity.displayName;
78
+ $('pet').setAttribute('aria-label', `${identity.displayName}:点击交流,右键更多,拖动移动`);
52
79
  $('pet').dataset.skin = identity.skin;
53
- $('dock').setAttribute('aria-label', `和${identity.displayName}交流`);
54
- $('message').setAttribute('aria-label', `给${identity.displayName}写一句`);
55
- document.documentElement.style.setProperty('--speaker-label', JSON.stringify(identity.carrier.displayName));
56
- $('documents').textContent = `资料查询 · ${identity.documentsAllowed ? '开启' : '暂停'}`;
80
+ $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
57
81
  $('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;
82
+ $('connection-scope').textContent = identity.duty.catId === identity.carrier.catId ? '交流保存在同一段聊天中' : `${identity.duty.displayName} · 实时语音由${identity.carrier.displayName}承载`;
69
83
  }
84
+ if (showFailure) void controls.show('actions');
70
85
  },
71
86
  });
72
- function details(value) {
73
- expanded = value;
74
- $('details').hidden = !value;
75
- $('write').setAttribute('aria-expanded', String(value));
76
- void client.resize(value).catch(error => status(explainError(error)));
87
+ async function readHistory() {
88
+ if (loading || controls.panel !== 'chat') return;
89
+ loading = true;
90
+ try {
91
+ const history = await client.readConversation();
92
+ $('history').textContent = history.hasMore ? '更早聊天 ↗' : '完整聊天 ↗';
93
+ const serialized = JSON.stringify(history.messages);
94
+ if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
95
+ } catch { $('chat-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示'; }
96
+ finally { loading = false; }
77
97
  }
78
- $('begin').onclick = () => { transcript.reset(); void conversation.begin(); };
79
- $('end').onclick = () => void conversation.end();
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('屏幕共享已停止'));
98
+ $('call-badge').onclick = () => void conversation.end();
99
+ $('share-badge').onclick = toggleScreen;
85
100
  $('history').onclick = () => void client.openConversation().then(result => {
86
- if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看猫猫球聊天');
101
+ if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看');
87
102
  }).catch(error => status(explainError(error)));
88
- $('documents').onclick = () => void conversation.documents(conversation.identity?.documentsAllowed === false);
89
103
  $('compose').onsubmit = async event => {
90
- event.preventDefault();
91
- const text = $('message').value;
104
+ event.preventDefault(); const text = $('message').value;
92
105
  if (await conversation.send(text)) {
93
106
  if ($('message').value === text) $('message').value = '';
107
+ latestHistory = undefined; void readHistory();
94
108
  }
95
109
  };
96
110
  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();
111
+ if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
112
+ if (event.kind === 'view-dismiss') controls.dismiss();
104
113
  });
114
+ const monitor = setInterval(() => { void conversation.refresh(); void readHistory(); }, 3000);
115
+ void conversation.refresh(); void controls.show('none');
116
+ window.addEventListener('beforeunload', () => { clearInterval(monitor); unsubscribe(); void conversation.end(); });
105
117
  }
@@ -2,6 +2,7 @@ export class TranscriptView {
2
2
  constructor(container) {
3
3
  this.container = container;
4
4
  this.activeRows = new Map();
5
+ this.historyRows = new Map();
5
6
  this.completed = new Set();
6
7
  }
7
8
  append(role, text, chunk = false) {
@@ -11,30 +12,44 @@ export class TranscriptView {
11
12
  if (!row) {
12
13
  row = this.container.ownerDocument.createElement('p');
13
14
  row.className = role;
15
+ if (chunk) { row.dataset.live = 'true'; row.dataset.author = `实时语音 · ${role === 'user' ? '你' : '猫猫'}`; }
14
16
  this.container.append(row);
15
17
  if (chunk) this.activeRows.set(role, row);
16
18
  }
17
- row.textContent += text;
19
+ row.textContent = (row.textContent + text).slice(-16000);
18
20
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
19
21
  }
20
22
  finish({ role, transcript, turnId }) {
21
23
  if (!['user', 'assistant'].includes(role) || (turnId && this.completed.has(turnId))) return;
22
24
  const pinned = this.nearBottom();
23
- let row = this.activeRows.get(role);
24
- if (!row && transcript) {
25
- this.append(role, '', true);
26
- row = this.activeRows.get(role);
27
- }
28
- if (row && typeof transcript === 'string') row.textContent = transcript;
25
+ // A provider caption is ephemeral. Only Host history can create a saved row.
26
+ this.activeRows.get(role)?.remove();
29
27
  this.activeRows.delete(role);
30
28
  if (turnId) this.completed.add(turnId);
29
+ if (this.completed.size > 256) this.completed.delete(this.completed.values().next().value);
31
30
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
32
31
  }
33
32
  nearBottom() {
34
33
  return this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
35
34
  }
36
35
  reset() {
36
+ for (const row of this.activeRows.values()) row.remove();
37
37
  this.activeRows.clear();
38
38
  this.completed.clear();
39
39
  }
40
+ load(messages) {
41
+ const pinned = this.nearBottom();
42
+ const previous = this.historyRows;
43
+ this.historyRows = new Map();
44
+ const rows = messages.map(message => {
45
+ const row = previous.get(message.id) ?? this.container.ownerDocument.createElement('p');
46
+ row.className = message.role; row.dataset.messageId = message.id;
47
+ if (row.textContent !== message.text) row.textContent = message.text;
48
+ row.title = message.name; row.dataset.author = message.name;
49
+ this.historyRows.set(message.id, row);
50
+ return row;
51
+ });
52
+ this.container.replaceChildren(...rows, ...this.activeRows.values());
53
+ if (pinned) this.container.scrollTop = this.container.scrollHeight;
54
+ }
40
55
  }
package/source-lock.json CHANGED
@@ -15,17 +15,38 @@
15
15
  {
16
16
  "source": "packages/web/public/concierge/skins/xianxian-codex/spritesheet.webp",
17
17
  "destination": "assets/skins/xianxian-codex.webp",
18
- "sha256": "5f653974735a4104dfdaf1845128494a5cb5164cb2f45fecb2072a167c80e8a5"
18
+ "sha256": "28e6a4211acac553c982627b402865cf7c079038fc4ad7954522ce37b1db90cb",
19
+ "derivation": {
20
+ "method": "reviewed-atlas-refresh",
21
+ "date": "2026-09-23",
22
+ "referenceSha256": "5f653974735a4104dfdaf1845128494a5cb5164cb2f45fecb2072a167c80e8a5",
23
+ "purpose": "Synchronize the family-owned repaired atlas: preserve opaque white fur and use a fixed crop and scale for the six idle frames.",
24
+ "author": "codex-astra"
25
+ }
19
26
  },
20
27
  {
21
28
  "source": "packages/web/public/concierge/skins/ragdoll-v1/idle.png",
22
29
  "destination": "assets/skins/ragdoll-v1.png",
23
- "sha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025"
30
+ "sha256": "24e5c4dea8d6ee35bf69a5612417088ff15f08c40404cbb18bfb99caad25564e",
31
+ "derivation": {
32
+ "method": "native-imagegen",
33
+ "date": "2026-09-20",
34
+ "referenceSha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025",
35
+ "purpose": "Restore the complete original grey-point cat silhouette on transparent alpha; remove the cropped room background.",
36
+ "author": "codex-astra"
37
+ }
24
38
  },
25
39
  {
26
40
  "source": "packages/web/public/concierge/sprites/ragdoll/idle.png",
27
41
  "destination": "assets/skins/yarn-ball.png",
28
- "sha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025"
42
+ "sha256": "24e5c4dea8d6ee35bf69a5612417088ff15f08c40404cbb18bfb99caad25564e",
43
+ "derivation": {
44
+ "method": "native-imagegen",
45
+ "date": "2026-09-20",
46
+ "referenceSha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025",
47
+ "purpose": "Restore the complete original grey-point cat silhouette on transparent alpha; remove the cropped room background.",
48
+ "author": "codex-astra"
49
+ }
29
50
  }
30
51
  ]
31
52
  }