@clowder-ai/companion 0.1.0-alpha.3 → 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/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.4",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clowder-ai/companion",
3
- "version": "0.1.0-alpha.3",
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",
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.4",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -70,6 +70,17 @@ export class CompanionConversation {
70
70
  catch { this.show('麦克风已关闭 · 连接收尾尚未确认'); }
71
71
  finally { if (this.stopping === operation) this.stopping = undefined; }
72
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
+ }
73
84
  async refresh() {
74
85
  if (this.refreshing) return;
75
86
  this.refreshing = true;
@@ -79,7 +90,7 @@ export class CompanionConversation {
79
90
  if (generation !== this.generation) return;
80
91
  this.identity = identity;
81
92
  if (this.active && ['closed', 'failed', 'idle'].includes(identity.phase)) {
82
- await this.end('连接已结束 · 点击开始聊天继续');
93
+ await this.end('语音连接已中断 · 点击语音聊重试', true);
83
94
  } else this.show(this.message ?? '点开始聊天,直接对我说话');
84
95
  } catch (error) {
85
96
  if (generation === this.generation) {
@@ -112,7 +123,9 @@ export class CompanionConversation {
112
123
  try {
113
124
  await this.client.text(input.text, input.id);
114
125
  if (this.pendingText === input) this.pendingText = undefined;
115
- if (generation !== this.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;
116
129
  this.transcript({ type: 'transcript', role: 'user', text, typed: true });
117
130
  this.show(this.active ? this.listening() : '已发送 · 回答会留在同一段聊天中');
118
131
  return true;
Binary file
@@ -58,4 +58,6 @@ button:disabled { opacity: .45; cursor: default; }
58
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
59
  #transcript { height: 230px; font-size: 13px; }
60
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); }
61
63
  #history { padding: 0; font-size: 10px; color: var(--cafe-text-secondary); }
@@ -48,9 +48,14 @@ if (!window.clowderCompanion) {
48
48
  }
49
49
  const conversation = new CompanionConversation({
50
50
  client, createPeer: callback => new VoicePeer(callback, client), stopScreen: () => screen.stop(),
51
- 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
+ },
52
56
  render(value) {
53
57
  const showFailure = value.failed && previousPhase !== 'idle' && value.phase === 'idle';
58
+ if (previousPhase !== 'idle' && value.phase === 'idle') { transcript.reset(); void readHistory(); }
54
59
  previousPhase = value.phase;
55
60
  const active = value.phase !== 'idle';
56
61
  $('begin').hidden = active; $('begin').disabled = !value.identity;
@@ -80,15 +85,14 @@ if (!window.clowderCompanion) {
80
85
  },
81
86
  });
82
87
  async function readHistory() {
83
- if (loading || conversation.active || controls.panel !== 'chat') return;
88
+ if (loading || controls.panel !== 'chat') return;
84
89
  loading = true;
85
90
  try {
86
91
  const history = await client.readConversation();
87
- if (conversation.active) return;
88
92
  $('history').textContent = history.hasMore ? '更早聊天 ↗' : '完整聊天 ↗';
89
93
  const serialized = JSON.stringify(history.messages);
90
94
  if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
91
- } catch (error) { status(explainError(error)); }
95
+ } catch { $('chat-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示'; }
92
96
  finally { loading = false; }
93
97
  }
94
98
  $('call-badge').onclick = () => void conversation.end();
@@ -104,7 +108,7 @@ if (!window.clowderCompanion) {
104
108
  }
105
109
  };
106
110
  const unsubscribe = client.subscribe(event => {
107
- if (event.kind === 'media-stopped') void conversation.releaseLocal('语音已停止');
111
+ if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
108
112
  if (event.kind === 'view-dismiss') controls.dismiss();
109
113
  });
110
114
  const monitor = setInterval(() => { void conversation.refresh(); void readHistory(); }, 3000);
@@ -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,41 +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
40
  load(messages) {
41
41
  const pinned = this.nearBottom();
42
- this.reset();
43
- this.container.replaceChildren(...messages.map(message => {
44
- const row = this.container.ownerDocument.createElement('p');
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');
45
46
  row.className = message.role; row.dataset.messageId = message.id;
46
- row.textContent = message.text; row.title = message.name;
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);
47
50
  return row;
48
- }));
51
+ });
52
+ this.container.replaceChildren(...rows, ...this.activeRows.values());
49
53
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
50
54
  }
51
55
  }
package/source-lock.json CHANGED
@@ -15,7 +15,14 @@
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",