@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 +1 -1
- package/package.json +1 -1
- package/plugin.yaml +1 -1
- package/renderer/conversation.mjs +15 -2
- package/renderer/skins/xianxian-codex.webp +0 -0
- package/renderer/style.css +2 -0
- package/renderer/surface.mjs +9 -5
- package/renderer/transcript-view.mjs +16 -12
- package/source-lock.json +8 -1
package/manifest.json
CHANGED
package/package.json
CHANGED
package/plugin.yaml
CHANGED
|
@@ -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
|
-
|
|
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
|
package/renderer/style.css
CHANGED
|
@@ -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); }
|
package/renderer/surface.mjs
CHANGED
|
@@ -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 =>
|
|
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 ||
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
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.
|
|
43
|
-
this.
|
|
44
|
-
|
|
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
|
|
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": "
|
|
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",
|