@clowder-ai/companion 0.1.0-alpha.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clowder AI Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,4 @@
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.
3
+ The conversation transport and controls continue the F317 companion implementation
4
+ by the Clowder AI contributors. No private runtime data or pet metadata is included.
package/manifest.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "pluginId": "official.companion",
3
+ "version": "0.1.0-alpha.1",
4
+ "contractVersion": "0.1.0",
5
+ "name": "猫猫球",
6
+ "icon": {
7
+ "type": "png",
8
+ "src": "renderer/skins/ragdoll-v1.png"
9
+ },
10
+ "description": {
11
+ "default": "Talk, think and find your shared history with your companion.",
12
+ "translations": {
13
+ "zh-CN": "和猫猫说话、查资料,回到同一段聊天继续工作。"
14
+ }
15
+ },
16
+ "runtime": {
17
+ "transport": "builtin"
18
+ },
19
+ "features": [
20
+ {
21
+ "id": "companion",
22
+ "name": "猫猫球",
23
+ "resources": [],
24
+ "capabilities": [
25
+ "windows.create"
26
+ ],
27
+ "contributions": [
28
+ {
29
+ "type": "desktop-window",
30
+ "id": "companion"
31
+ }
32
+ ]
33
+ }
34
+ ],
35
+ "contributions": [
36
+ {
37
+ "type": "desktop-window",
38
+ "id": "companion",
39
+ "role": "companion",
40
+ "bridgeVersion": "1.0.0",
41
+ "surface": {
42
+ "entrypoint": "renderer/index.html",
43
+ "integrity": "sha256-yVHMFz5zEuDTwQO/pgNRScotBqRzGd9o9wIIhadJDNQ="
44
+ },
45
+ "presentation": {
46
+ "width": 330,
47
+ "height": 350,
48
+ "frame": false,
49
+ "transparent": true,
50
+ "alwaysOnTop": true,
51
+ "skipTaskbar": true
52
+ }
53
+ }
54
+ ]
55
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@clowder-ai/companion",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "The desktop voice body for your Clowder companion",
5
+ "license": "MIT",
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" },
10
+ "scripts": {
11
+ "build": "node scripts/build.mjs",
12
+ "test": "node --test test/*.test.mjs",
13
+ "typecheck": "node scripts/check.mjs",
14
+ "lint": "node scripts/check.mjs",
15
+ "prepack": "pnpm build"
16
+ },
17
+ "devDependencies": {
18
+ "@clowder-ai/plugin-contract": "0.1.0-beta.16",
19
+ "@clowder-ai/plugin-sdk": "0.1.0-beta.11"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/zts212653/clowder-ai-plugins.git",
24
+ "directory": "packages/companion"
25
+ }
26
+ }
package/plugin.yaml ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "pluginId": "official.companion",
3
+ "version": "0.1.0-alpha.1",
4
+ "contractVersion": "0.1.0",
5
+ "name": "猫猫球",
6
+ "icon": {
7
+ "type": "png",
8
+ "src": "renderer/skins/ragdoll-v1.png"
9
+ },
10
+ "description": {
11
+ "default": "Talk, think and find your shared history with your companion.",
12
+ "translations": {
13
+ "zh-CN": "和猫猫说话、查资料,回到同一段聊天继续工作。"
14
+ }
15
+ },
16
+ "runtime": {
17
+ "transport": "builtin"
18
+ },
19
+ "features": [
20
+ {
21
+ "id": "companion",
22
+ "name": "猫猫球",
23
+ "resources": [],
24
+ "capabilities": [
25
+ "windows.create"
26
+ ],
27
+ "contributions": [
28
+ {
29
+ "type": "desktop-window",
30
+ "id": "companion"
31
+ }
32
+ ]
33
+ }
34
+ ],
35
+ "contributions": [
36
+ {
37
+ "type": "desktop-window",
38
+ "id": "companion",
39
+ "role": "companion",
40
+ "bridgeVersion": "1.0.0",
41
+ "surface": {
42
+ "entrypoint": "renderer/index.html",
43
+ "integrity": "sha256-yVHMFz5zEuDTwQO/pgNRScotBqRzGd9o9wIIhadJDNQ="
44
+ },
45
+ "presentation": {
46
+ "width": 330,
47
+ "height": 350,
48
+ "frame": false,
49
+ "transparent": true,
50
+ "alwaysOnTop": true,
51
+ "skipTaskbar": true
52
+ }
53
+ }
54
+ ]
55
+ }
@@ -0,0 +1,42 @@
1
+ export class CompanionRequestError extends Error {
2
+ code;
3
+ constructor(code) {
4
+ super(code);
5
+ this.code = code;
6
+ this.name = 'CompanionRequestError';
7
+ }
8
+ }
9
+ /** Browser-safe client: no Node imports, credentials, second transcript store or transport fallback. */
10
+ export function createCompanionClient(bridge) {
11
+ async function invoke(command, kind) {
12
+ const reply = await bridge.request(command);
13
+ if (reply.kind === 'error')
14
+ throw new CompanionRequestError(reply.code);
15
+ if (reply.kind !== kind)
16
+ throw new CompanionRequestError('unavailable');
17
+ return reply;
18
+ }
19
+ return {
20
+ state: () => invoke({ kind: 'state' }, 'state'),
21
+ prepare: () => invoke({ kind: 'prepare' }, 'state'),
22
+ connectAudio: () => invoke({ kind: 'audio.connect' }, 'ok'),
23
+ closeAudio: () => invoke({ kind: 'audio.close' }, 'ok'),
24
+ muteMicrophone: (muted) => invoke({ kind: 'audio.microphone', muted }, 'ok'),
25
+ muteSpeaker: (muted) => invoke({ kind: 'audio.speaker', muted }, 'ok'),
26
+ stop: () => invoke({ kind: 'stop' }, 'ok'),
27
+ text: async (text, clientMessageId) => {
28
+ const receipt = await invoke({ kind: 'text', text, clientMessageId }, 'delivery');
29
+ if (receipt.delivery !== 'accepted')
30
+ throw new CompanionRequestError('unconfirmed');
31
+ return receipt;
32
+ },
33
+ documents: (allowed) => invoke({ kind: 'documents', allowed }, 'state'),
34
+ screenPick: async () => (await invoke({ kind: 'screen.pick' }, 'selection')).selectionId,
35
+ screenOpen: (selectionId, label) => invoke({ kind: 'screen.open', selectionId, label }, 'ok'),
36
+ screenFrame: (selectionId, frame) => invoke({ kind: 'screen.frame', selectionId, frame }, 'ok'),
37
+ screenClose: () => invoke({ kind: 'screen.close' }, 'ok'),
38
+ openConversation: () => invoke({ kind: 'conversation.open' }, 'navigation'),
39
+ resize: (expanded) => invoke({ kind: 'view.resize', expanded }, 'ok'),
40
+ subscribe: (listener) => bridge.subscribe(listener),
41
+ };
42
+ }
@@ -0,0 +1,122 @@
1
+ import { explainError } from './errors.mjs';
2
+
3
+ /** Local controls only. Durable messages, identity and preferences belong to the Host. */
4
+ export class CompanionConversation {
5
+ constructor({ client, createPeer, render, transcript, stopScreen, uuid = () => crypto.randomUUID() }) {
6
+ Object.assign(this, { client, createPeer, render, transcript, stopScreen, uuid });
7
+ this.active = false;
8
+ this.generation = 0;
9
+ this.phase = 'idle';
10
+ this.muted = false;
11
+ this.silent = false;
12
+ }
13
+ show(message) {
14
+ this.message = message;
15
+ this.render({ phase: this.phase, identity: this.identity, muted: this.muted, silent: this.silent, message });
16
+ }
17
+ current(generation) { return this.active && generation === this.generation; }
18
+ listening() { return this.muted ? '麦克风已静音' : '正在听,直接说话就好'; }
19
+ async begin() {
20
+ if (this.active || this.stopping || this.changingDocuments) return;
21
+ this.active = true;
22
+ this.phase = 'connecting';
23
+ const generation = ++this.generation;
24
+ // Must reach the isolated preload while the click still has user activation.
25
+ const preparation = this.client.prepare();
26
+ this.show('正在连接…');
27
+ this.deadline = setTimeout(() => { if (this.current(generation)) void this.end('连接超时 · 点击开始聊天重试'); }, 60_000);
28
+ 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
+ const peer = this.createPeer(event => {
34
+ if (!this.current(generation)) return;
35
+ if (event.type === 'connected') { clearTimeout(this.deadline); this.phase = 'talking'; this.show(this.listening()); }
36
+ if (event.type === 'recovering') this.show('连接暂时中断 · 正在等待恢复');
37
+ if (event.type === 'recovered') this.show(this.listening());
38
+ if (event.type === 'transcript' || event.type === 'turn-done') this.transcript(event);
39
+ if (event.type === 'error') void this.end(explainError(event));
40
+ });
41
+ this.peer = peer;
42
+ peer.muteMic(this.muted); peer.muteSpeaker(this.silent);
43
+ await peer.connect();
44
+ if (!this.current(generation)) await peer.close();
45
+ } catch (error) {
46
+ if (this.current(generation)) await this.end(explainError(error));
47
+ }
48
+ }
49
+ async releaseLocal(message) {
50
+ this.active = false;
51
+ ++this.generation;
52
+ this.pendingText = undefined;
53
+ clearTimeout(this.deadline);
54
+ const peer = this.peer;
55
+ this.peer = undefined;
56
+ this.phase = 'idle';
57
+ this.show(message);
58
+ await Promise.allSettled([peer?.close(), this.stopScreen()]);
59
+ }
60
+ async end(message = '语音已结束 · 麦克风已关闭') {
61
+ if (this.stopping) return this.stopping;
62
+ const local = this.releaseLocal(message);
63
+ const operation = Promise.all([local, this.client.stop()]);
64
+ this.stopping = operation;
65
+ try { await operation; }
66
+ catch { this.show('麦克风已关闭 · 连接收尾尚未确认'); }
67
+ finally { if (this.stopping === operation) this.stopping = undefined; }
68
+ }
69
+ async refresh() {
70
+ if (this.refreshing) return;
71
+ this.refreshing = true;
72
+ const generation = this.generation;
73
+ try {
74
+ const identity = await this.client.state();
75
+ if (generation !== this.generation) return;
76
+ this.identity = identity;
77
+ if (this.active && ['closed', 'failed', 'idle'].includes(identity.phase)) {
78
+ await this.end('连接已结束 · 点击开始聊天继续');
79
+ } else this.show(this.message ?? '点开始聊天,直接对我说话');
80
+ } catch (error) {
81
+ if (generation === this.generation) {
82
+ if (this.active) await this.end(explainError(error));
83
+ else this.show(explainError(error));
84
+ }
85
+ } finally { this.refreshing = false; }
86
+ }
87
+ async documents(allowed) {
88
+ if (this.changingDocuments) return;
89
+ this.changingDocuments = true;
90
+ // No implicit new microphone grant after changing a preference.
91
+ const changing = this.client.documents(allowed);
92
+ const local = this.releaseLocal('正在更新资料查询设置…');
93
+ try {
94
+ const identity = await changing;
95
+ await local;
96
+ this.identity = identity;
97
+ this.show(allowed ? '资料查询已开启 · 点击开始聊天继续' : '资料查询已暂停 · 点击开始聊天继续');
98
+ } catch (error) { this.show(explainError(error)); }
99
+ finally { this.changingDocuments = false; }
100
+ }
101
+ async send(text) {
102
+ text = text.trim();
103
+ if (!text || !this.active || this.phase !== 'talking' || this.sending) return false;
104
+ if (!this.pendingText || this.pendingText.text !== text) this.pendingText = { text, id: this.uuid() };
105
+ const input = this.pendingText;
106
+ const generation = this.generation;
107
+ this.sending = true;
108
+ try {
109
+ await this.client.text(input.text, input.id);
110
+ if (this.pendingText === input) this.pendingText = undefined;
111
+ if (!this.current(generation)) return false;
112
+ this.transcript({ type: 'transcript', role: 'user', text, typed: true });
113
+ this.show(this.listening());
114
+ return true;
115
+ } catch (error) {
116
+ if (this.current(generation)) this.show(explainError(error));
117
+ return false;
118
+ } finally { this.sending = false; }
119
+ }
120
+ muteMic() { this.muted = !this.muted; this.peer?.muteMic(this.muted); this.show(this.phase === 'talking' ? this.listening() : this.message); }
121
+ muteSpeaker() { this.silent = !this.silent; this.peer?.muteSpeaker(this.silent); this.show(this.message); }
122
+ }
@@ -0,0 +1,13 @@
1
+ const messages = {
2
+ permission_required: '请点击开始聊天,或在系统提示中允许本次使用',
3
+ session_required: '连接已结束 · 点击开始聊天继续',
4
+ busy: '上一段交流正在收尾 · 稍后再开始',
5
+ selection_changed: '猫猫球的选择已更新 · 点击开始聊天继续',
6
+ carrier_unavailable: '实时语音暂不可用 · 可以打开聊天继续交流',
7
+ cancelled: '操作已取消',
8
+ unconfirmed: '发送尚未确认 · 文字已保留,可检查聊天记录后重试',
9
+ };
10
+ export function explainError(error) {
11
+ if (error?.name === 'NotAllowedError') return '未获得麦克风权限 · 请在系统设置中允许后重试';
12
+ return messages[error?.code] ?? '连接暂时不可用 · 请重试或打开聊天继续';
13
+ }
@@ -0,0 +1,31 @@
1
+ <!doctype html>
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>
5
+ <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>
30
+ <script type="module" src="./surface.mjs"></script>
31
+ </body></html>
@@ -0,0 +1,23 @@
1
+ /** Presentation adapter only. The Host preload owns capture, SDP and the provider channel. */
2
+ export class VoicePeer {
3
+ constructor(onEvent, client) {
4
+ this.onEvent = onEvent;
5
+ this.client = client;
6
+ this.unsubscribe = client.subscribe(event => {
7
+ if (!this.closed && event.kind === 'audio') {
8
+ const { kind, ...value } = event;
9
+ this.onEvent(value);
10
+ }
11
+ });
12
+ }
13
+ async connect() { if (!this.closed) await this.client.connectAudio(); }
14
+ muteMic(muted) { void this.client.muteMicrophone(muted).catch(() => this.failed()); }
15
+ muteSpeaker(muted) { void this.client.muteSpeaker(muted).catch(() => this.failed()); }
16
+ failed() { if (!this.closed) this.onEvent({ type: 'error', code: 'unavailable' }); }
17
+ async close() {
18
+ if (this.closed) return;
19
+ this.closed = true;
20
+ this.unsubscribe();
21
+ await this.client.closeAudio();
22
+ }
23
+ }
@@ -0,0 +1,100 @@
1
+ export class ScreenShare {
2
+ constructor(api, onState, media = navigator.mediaDevices, elements = document) {
3
+ this.api = api;
4
+ this.onState = onState;
5
+ this.media = media;
6
+ this.elements = elements;
7
+ this.epoch = 0;
8
+ }
9
+ async start() {
10
+ if (this.pending || this.stream) return;
11
+ const stopped = this.stop();
12
+ const selection = this.api.screenRequest();
13
+ const epoch = this.epoch;
14
+ this.pending = true;
15
+ this.onState({ sharing: false, pending: true, message: '请在系统选择器中选择窗口或屏幕' });
16
+ let stream;
17
+ try {
18
+ const [, selectionId] = await Promise.all([stopped, selection]);
19
+ if (epoch !== this.epoch) return;
20
+ // macOS system picker supplies the exact source. Audio has a separate grant.
21
+ stream = await this.media.getDisplayMedia({ video: { frameRate: 2 }, audio: false });
22
+ if (epoch !== this.epoch) {
23
+ stream.getTracks().forEach((track) => {
24
+ track.stop();
25
+ });
26
+ return;
27
+ }
28
+ this.stream = stream;
29
+ const track = stream.getVideoTracks()[0];
30
+ if (!track) throw new Error('没有取得共享画面');
31
+ const checkTrack = () => {
32
+ if (track.readyState !== 'live' || track.muted) throw new Error('共享画面已结束');
33
+ };
34
+ const ended = () => void this.stop('画面已不可用 · 共享已停止').catch(() => {
35
+ this.onState({ sharing: false, pending: false, message: '画面已停止 · Host撤销尚未确认' });
36
+ });
37
+ track.onended = ended;
38
+ track.onmute = ended;
39
+ checkTrack();
40
+ const label = track.label || '所选画面';
41
+ await this.api.screenStart(selectionId, label);
42
+ if (epoch !== this.epoch) return;
43
+ checkTrack();
44
+ this.video = this.elements.createElement('video');
45
+ this.video.muted = true;
46
+ this.video.srcObject = stream;
47
+ await this.video.play();
48
+ if (epoch !== this.epoch) return;
49
+ this.canvas = this.elements.createElement('canvas');
50
+ const capture = async () => {
51
+ if (epoch !== this.epoch) return;
52
+ checkTrack();
53
+ const { videoWidth: width, videoHeight: height } = this.video;
54
+ if (!width || !height) throw new Error('暂时没有可用画面');
55
+ const scale = Math.min(1, 1280 / Math.max(width, height));
56
+ this.canvas.width = Math.max(1, Math.round(width * scale));
57
+ this.canvas.height = Math.max(1, Math.round(height * scale));
58
+ this.canvas.getContext('2d').drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);
59
+ await this.api.screenFrame(selectionId, {
60
+ width: this.canvas.width,
61
+ height: this.canvas.height,
62
+ image: this.canvas.toDataURL('image/jpeg', 0.7),
63
+ });
64
+ if (epoch === this.epoch)
65
+ this.timer = setTimeout(() => void capture().catch(() => this.stop('画面暂不可用 · 共享已停止')), 1500);
66
+ };
67
+ await capture();
68
+ if (epoch !== this.epoch) return;
69
+ this.pending = false;
70
+ this.onState({ sharing: true, pending: false, label });
71
+ } catch (error) {
72
+ stream?.getTracks().forEach((track) => {
73
+ track.stop();
74
+ });
75
+ if (epoch === this.epoch)
76
+ await this.stop(
77
+ error.name === 'NotAllowedError'
78
+ ? '未能开始共享:没有选定画面或系统未允许。可点共享屏幕重试。'
79
+ : '未能开始共享 · 请重试',
80
+ );
81
+ }
82
+ }
83
+ async stop(message = '未共享屏幕') {
84
+ this.epoch++;
85
+ this.pending = false;
86
+ clearTimeout(this.timer);
87
+ this.stream?.getTracks().forEach((track) => {
88
+ track.onended = null;
89
+ track.onmute = null;
90
+ track.stop();
91
+ });
92
+ this.stream = undefined;
93
+ this.video?.pause();
94
+ if (this.video) this.video.srcObject = null;
95
+ this.video = undefined;
96
+ this.canvas = undefined;
97
+ this.onState({ sharing: false, pending: false, message });
98
+ await this.api.screenStop();
99
+ }
100
+ }
Binary file
Binary file
Binary file
@@ -0,0 +1,309 @@
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"); }
@@ -0,0 +1,105 @@
1
+ import { createCompanionClient } from './client.mjs';
2
+ import { CompanionConversation } from './conversation.mjs';
3
+ import { explainError } from './errors.mjs';
4
+ import { VoicePeer } from './peer.mjs';
5
+ import { ScreenShare } from './screen-share.mjs';
6
+ import { TranscriptView } from './transcript-view.mjs';
7
+
8
+ const $ = id => document.getElementById(id);
9
+ const status = text => { $('status').textContent = text; };
10
+ const transcript = new TranscriptView($('transcript'));
11
+ let expanded = false;
12
+ let sharing = false;
13
+ let pendingScreen = false;
14
+
15
+ if (!window.clowderCompanion) {
16
+ status('请从 Clowder 的插件页面打开猫猫球');
17
+ for (const id of ['begin', 'documents', 'history']) $(id).disabled = true;
18
+ } else {
19
+ const client = createCompanionClient(window.clowderCompanion);
20
+ const screen = new ScreenShare({
21
+ screenRequest: () => client.screenPick(),
22
+ screenStart: (selectionId, label) => client.screenOpen(selectionId, label),
23
+ screenFrame: (selectionId, frame) => client.screenFrame(selectionId, {
24
+ ...frame, frameId: crypto.randomUUID(), sourceLabel: '所选画面', observedAt: Date.now(),
25
+ }),
26
+ screenStop: () => client.screenClose(),
27
+ }, 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';
35
+ });
36
+ const conversation = new CompanionConversation({
37
+ 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),
39
+ render(value) {
40
+ 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);
47
+ const identity = value.identity;
48
+ if (identity) {
49
+ $('identity').textContent = identity.displayName;
50
+ $('chat-name').textContent = identity.displayName;
51
+ $('pet').setAttribute('aria-label', `${identity.displayName},拖动可以移动`);
52
+ $('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 ? '开启' : '暂停'}`;
57
+ $('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;
69
+ }
70
+ },
71
+ });
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)));
77
+ }
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('屏幕共享已停止'));
85
+ $('history').onclick = () => void client.openConversation().then(result => {
86
+ if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看猫猫球聊天');
87
+ }).catch(error => status(explainError(error)));
88
+ $('documents').onclick = () => void conversation.documents(conversation.identity?.documentsAllowed === false);
89
+ $('compose').onsubmit = async event => {
90
+ event.preventDefault();
91
+ const text = $('message').value;
92
+ if (await conversation.send(text)) {
93
+ if ($('message').value === text) $('message').value = '';
94
+ }
95
+ };
96
+ 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();
104
+ });
105
+ }
@@ -0,0 +1,267 @@
1
+ /*
2
+ * F056 Phase E — OKLCH 系统化升级 · Design System Primitives
3
+ *
4
+ * 本文件持有"非身份性"七类色 token + Brand alias + Surface/Elevation/Scrim + cafe 兼容 alias。
5
+ *
6
+ * 拆分(每文件 ≤350 行硬限制 + design system 关注点分离):
7
+ * theme-tokens.css — primitives:Brand + Neutral + Accent + Semantic + Code + Chart + Avatar Fallback + Scrim + cafe alias
8
+ * cat-persona-tokens.css — Cat Persona 单 hue 派生(每只猫 4 档 × 2 mode)
9
+ * connector-tokens.css — IM connector 品牌色(独立体系,不随主题派生)
10
+ * theme-extras.css — F155 Guide tokens + warm-pastel Notice tokens
11
+ *
12
+ * 哲学:HSL 是给程序员算颜色方便的,OKLCH 是给用户感知一致的。
13
+ * 统一 token 派生公式让"换主题色 = 改一个 hue 值,全应用自动适配"。
14
+ */
15
+
16
+ :root {
17
+ /* ============================================================
18
+ * Layer 0: Brand + Hue/Chroma anchors
19
+ *
20
+ * --accent-hue 控制品牌/交互色(按钮、链接、accent scale)。
21
+ * --surface-hue 控制页面底色色调(独立于 accent,KD-34)。
22
+ * 原始 #fdf8f3 ≈ oklch(0.98 0.008 ~80),warm-beige/vanilla。
23
+ * ============================================================ */
24
+ --accent-hue: 50; /* warm gold — 品牌主色旋钮(aligned with INIT_LIGHT) */
25
+ --accent-chroma: 0.14;
26
+ --surface-hue: 80; /* warm beige — 页面底色旋钮,独立于 accent(KD-34) */
27
+ --surface-chroma: 0.01; /* surface 四档共享色度 — Tuner 调一个全变 */
28
+ --brand-cat-cafe-pink: oklch(0.62 var(--accent-chroma) var(--accent-hue)); /* 派生 — logo / splash */
29
+ }
30
+
31
+ /* ============================================================
32
+ * LIGHT MODE (default :root)
33
+ * ============================================================ */
34
+ :root {
35
+ /* --- 1. Neutral 11档(warm neutral, hue ~30, low chroma) --- */
36
+ --neutral-50: oklch(0.985 0.005 30);
37
+ --neutral-100: oklch(0.965 0.005 30);
38
+ --neutral-200: oklch(0.915 0.005 30);
39
+ --neutral-300: oklch(0.84 0.005 30);
40
+ --neutral-400: oklch(0.71 0.005 30);
41
+ --neutral-500: oklch(0.56 0.005 30);
42
+ --neutral-600: oklch(0.45 0.005 30);
43
+ --neutral-700: oklch(0.36 0.005 30);
44
+ --neutral-800: oklch(0.27 0.005 30);
45
+ --neutral-900: oklch(0.2 0.005 30);
46
+ --neutral-950: oklch(0.13 0.005 30);
47
+
48
+ /* --- 2. App Accent 9档(单 hue 派生)— L=0.55 base 保证 vs neutral-50 ≥4.5:1 (WCAG normal text) --- */
49
+ --accent-50: oklch(0.97 calc(var(--accent-chroma) * 0.2) var(--accent-hue));
50
+ --accent-100: oklch(0.94 calc(var(--accent-chroma) * 0.3) var(--accent-hue));
51
+ --accent-200: oklch(0.88 calc(var(--accent-chroma) * 0.5) var(--accent-hue));
52
+ --accent-300: oklch(0.78 calc(var(--accent-chroma) * 0.7) var(--accent-hue));
53
+ --accent-400: oklch(0.65 calc(var(--accent-chroma) * 0.9) var(--accent-hue));
54
+ --accent-500: oklch(0.55 var(--accent-chroma) var(--accent-hue)); /* base — 按钮文字 ≥4.5:1 */
55
+ --accent-600: oklch(0.45 var(--accent-chroma) var(--accent-hue)); /* hover */
56
+ --accent-700: oklch(0.35 calc(var(--accent-chroma) * 0.9) var(--accent-hue)); /* active */
57
+ --accent-900: oklch(0.2 calc(var(--accent-chroma) * 0.5) var(--accent-hue));
58
+
59
+ /* --- 4. Semantic 5色(固定,不派生)— L 选定保证 vs cafe-surface 对比度 ≥3:1(WCAG icon) --- */
60
+ --semantic-critical: oklch(0.55 0.12 35); /* unread + error + delete + critical 合并 */
61
+ --semantic-success: oklch(0.55 0.12 135);
62
+ --semantic-warning: oklch(0.55 0.12 45); /* 暖橙:对比度 ≥3:1 (icon) */
63
+ --semantic-info: oklch(0.55 0.12 210); /* 跨线程气泡蓝亦用 */
64
+ --semantic-spotlight: oklch(0.65 0.12 45); /* F155 guide + quest input glow 合并 */
65
+ --semantic-critical-surface: oklch(0.96 0.03 35);
66
+ --semantic-success-surface: oklch(0.96 0.03 135);
67
+ --semantic-warning-surface: oklch(0.96 0.04 45);
68
+ --semantic-info-surface: oklch(0.96 0.03 210);
69
+ --semantic-spotlight-surface: oklch(0.96 0.04 45);
70
+
71
+ /* --- 5. Code Block(github-light 风格固定 palette) --- */
72
+ --code-bg: oklch(0.9 0.005 30);
73
+ --code-text: oklch(0.19 0.02 30);
74
+ --code-keyword: oklch(0.5 0.18 320);
75
+ --code-string: oklch(0.5 0.15 140);
76
+ --code-comment: oklch(0.55 0.05 100);
77
+ --code-function: oklch(0.5 0.18 250);
78
+ --code-number: oklch(0.5 0.18 30);
79
+ --code-border: oklch(0.9 0.005 30);
80
+
81
+ /* --- 6. Chart palette 12色(OKLCH H 均匀分布 30°/60°) --- */
82
+ --chart-1: oklch(0.55 0.15 30);
83
+ --chart-2: oklch(0.55 0.15 90);
84
+ --chart-3: oklch(0.55 0.15 150);
85
+ --chart-4: oklch(0.55 0.15 210);
86
+ --chart-5: oklch(0.55 0.15 270);
87
+ --chart-6: oklch(0.55 0.15 330);
88
+ --chart-7: oklch(0.45 0.18 30);
89
+ --chart-8: oklch(0.45 0.18 90);
90
+ --chart-9: oklch(0.45 0.18 150);
91
+ --chart-10: oklch(0.45 0.18 210);
92
+ --chart-11: oklch(0.45 0.18 270);
93
+ --chart-12: oklch(0.45 0.18 330);
94
+
95
+ /* --- 7. Avatar Fallback 8色(hash-based 固定) --- */
96
+ --avatar-fallback-1: oklch(0.65 0.12 30);
97
+ --avatar-fallback-2: oklch(0.65 0.12 75);
98
+ --avatar-fallback-3: oklch(0.65 0.12 120);
99
+ --avatar-fallback-4: oklch(0.65 0.12 165);
100
+ --avatar-fallback-5: oklch(0.65 0.12 210);
101
+ --avatar-fallback-6: oklch(0.65 0.12 255);
102
+ --avatar-fallback-7: oklch(0.65 0.12 300);
103
+ --avatar-fallback-8: oklch(0.65 0.12 345);
104
+
105
+ /* --- Surface 四档(light: 基于 develop_base 实测 hex 反推 OKLCH) ---
106
+ * 四档共享 --surface-hue,chroma = --surface-chroma × 层级系数:
107
+ * sunken L=0.92 ×1.5 ActivityBar / code / field
108
+ * surface L=0.95 ×1.2 ThreadSidebar / RightPanel
109
+ * elevated L=0.99 ×0.5 Chat 主区(高 L 需低 C 趋白)
110
+ * canvas L=0.995 ×0.3 Card 浮起(最浅最白) */
111
+ --cafe-surface-sunken: oklch(0.92 calc(var(--surface-chroma) * 1.5) var(--surface-hue));
112
+ --cafe-surface: oklch(0.95 calc(var(--surface-chroma) * 1.2) var(--surface-hue));
113
+ --cafe-surface-elevated: oklch(0.99 calc(var(--surface-chroma) * 0.5) var(--surface-hue));
114
+ --cafe-surface-canvas: oklch(0.995 calc(var(--surface-chroma) * 0.3) var(--surface-hue));
115
+
116
+ /* --- Cafe semantic shortcuts(映射到 Neutral / App Accent / Semantic) --- */
117
+ --cafe-text: var(--neutral-900);
118
+ --cafe-text-secondary: var(--neutral-600);
119
+ --cafe-text-muted: var(--neutral-500);
120
+ --cafe-border: var(--neutral-300);
121
+ --cafe-border-subtle: var(--neutral-200);
122
+ --cafe-accent: var(--accent-500);
123
+ --cafe-accent-hover: var(--accent-600);
124
+ --cafe-accent-foreground: var(--neutral-50);
125
+ --cafe-crosspost: var(--semantic-info);
126
+ --cafe-interactive: var(--neutral-700);
127
+
128
+ /* --- Elevation 阴影(light: 单层黑阴影) --- */
129
+ --shadow-elevation-1: 0 1px 2px oklch(0 0 0 / 0.06);
130
+ --shadow-elevation-2: 0 4px 10px oklch(0 0 0 / 0.1);
131
+ --shadow-elevation-3: 0 14px 28px oklch(0 0 0 / 0.15);
132
+
133
+ /* --- Scrim 三档(modal / drawer / lightbox 后的 dim layer) --- */
134
+ --scrim-light: oklch(0 0 0 / 0.2);
135
+ --scrim-dim: oklch(0 0 0 / 0.4);
136
+ --scrim-heavy: oklch(0 0 0 / 0.65);
137
+
138
+ /* --- Overlay content text(always-light on dark scrim — lightbox / modal / tooltip) --- */
139
+ --overlay-fg: oklch(0.98 0 0);
140
+ --overlay-fg-muted: oklch(0.98 0 0 / 0.8);
141
+
142
+ /* --- 兼容旧通用 alias --- */
143
+ --color-cafe-accent: var(--cafe-accent);
144
+ --color-base-white: var(--cafe-surface);
145
+ --color-base-black: var(--cafe-text);
146
+ --bg-app: var(--cafe-surface);
147
+ --text-primary: var(--cafe-text);
148
+ --text-secondary: var(--cafe-text-secondary);
149
+ }
150
+
151
+ /* ============================================================
152
+ * DARK MODE
153
+ * ============================================================ */
154
+ [data-theme="dark"] {
155
+ /* Dark accent/surface hue/chroma — aligned with INIT_DARK */
156
+ --accent-hue: 35;
157
+ --accent-chroma: 0.08;
158
+ --surface-hue: 30;
159
+
160
+ /* --- 1. Neutral 11档(dark: L 反转) --- */
161
+ --neutral-50: oklch(0.13 0.005 30);
162
+ --neutral-100: oklch(0.18 0.005 30);
163
+ --neutral-200: oklch(0.24 0.005 30);
164
+ --neutral-300: oklch(0.35 0.005 30);
165
+ --neutral-400: oklch(0.4 0.005 30);
166
+ --neutral-500: oklch(0.66 0.005 30);
167
+ --neutral-600: oklch(0.75 0.005 30);
168
+ --neutral-700: oklch(0.84 0.005 30);
169
+ --neutral-800: oklch(0.88 0.005 30);
170
+ --neutral-900: oklch(0.95 0.005 30);
171
+ --neutral-950: oklch(0.98 0.005 30);
172
+
173
+ /* --- 2. App Accent 9档(dark: 略亮 + 降饱和) --- */
174
+ --accent-50: oklch(0.22 calc(var(--accent-chroma) * 0.3) var(--accent-hue));
175
+ --accent-100: oklch(0.28 calc(var(--accent-chroma) * 0.4) var(--accent-hue));
176
+ --accent-200: oklch(0.34 calc(var(--accent-chroma) * 0.5) var(--accent-hue));
177
+ --accent-300: oklch(0.44 calc(var(--accent-chroma) * 0.7) var(--accent-hue));
178
+ --accent-400: oklch(0.55 calc(var(--accent-chroma) * 0.8) var(--accent-hue));
179
+ --accent-500: oklch(0.68 calc(var(--accent-chroma) * 0.9) var(--accent-hue));
180
+ --accent-600: oklch(0.78 var(--accent-chroma) var(--accent-hue));
181
+ --accent-700: oklch(0.85 calc(var(--accent-chroma) * 0.9) var(--accent-hue));
182
+ --accent-900: oklch(0.95 calc(var(--accent-chroma) * 0.4) var(--accent-hue));
183
+
184
+ /* --- 4. Semantic 5色(dark: 略亮 + 微调) --- */
185
+ --semantic-critical: oklch(0.7 0.17 25);
186
+ --semantic-success: oklch(0.7 0.17 145);
187
+ --semantic-warning: oklch(0.7 0.17 70);
188
+ --semantic-info: oklch(0.7 0.17 230);
189
+ --semantic-spotlight: oklch(0.8 0.17 70);
190
+ --semantic-critical-surface: oklch(0.25 0.05 25);
191
+ --semantic-success-surface: oklch(0.25 0.05 145);
192
+ --semantic-warning-surface: oklch(0.25 0.05 70);
193
+ --semantic-info-surface: oklch(0.25 0.05 230);
194
+ --semantic-spotlight-surface: oklch(0.25 0.06 70);
195
+
196
+ /* --- 5. Code Block(vscode-dark+ 风格固定 palette) --- */
197
+ --code-bg: oklch(0.25 0.005 30);
198
+ --code-text: oklch(0.9 0.02 30);
199
+ --code-keyword: oklch(0.72 0.16 280);
200
+ --code-string: oklch(0.72 0.14 140);
201
+ --code-comment: oklch(0.5 0.04 100);
202
+ --code-function: oklch(0.78 0.14 210);
203
+ --code-number: oklch(0.72 0.16 30);
204
+ --code-border: oklch(0.35 0.005 30);
205
+
206
+ /* --- 6. Chart palette(dark: L 略提到 0.68/0.78) --- */
207
+ --chart-1: oklch(0.68 0.14 30);
208
+ --chart-2: oklch(0.68 0.14 90);
209
+ --chart-3: oklch(0.68 0.14 150);
210
+ --chart-4: oklch(0.68 0.14 210);
211
+ --chart-5: oklch(0.68 0.14 270);
212
+ --chart-6: oklch(0.68 0.14 330);
213
+ --chart-7: oklch(0.78 0.12 30);
214
+ --chart-8: oklch(0.78 0.12 90);
215
+ --chart-9: oklch(0.78 0.12 150);
216
+ --chart-10: oklch(0.78 0.12 210);
217
+ --chart-11: oklch(0.78 0.12 270);
218
+ --chart-12: oklch(0.78 0.12 330);
219
+
220
+ /* --- 7. Avatar Fallback(dark: L 略降) --- */
221
+ --avatar-fallback-1: oklch(0.55 0.13 30);
222
+ --avatar-fallback-2: oklch(0.55 0.13 75);
223
+ --avatar-fallback-3: oklch(0.55 0.13 120);
224
+ --avatar-fallback-4: oklch(0.55 0.13 165);
225
+ --avatar-fallback-5: oklch(0.55 0.13 210);
226
+ --avatar-fallback-6: oklch(0.55 0.13 255);
227
+ --avatar-fallback-7: oklch(0.55 0.13 300);
228
+ --avatar-fallback-8: oklch(0.55 0.13 345);
229
+
230
+ /* --- Surface 四档(dark — 铲屎官 2026-06-10 调优)---
231
+ * sunken=0.36 / surface=0.28 / elevated=0.21 / canvas=0.24
232
+ * Chroma = base chroma × 0.15 (0.0022/0.0018/0.0008/0.0004).
233
+ * 空间映射见 console-tokens.css(light/dark 统一,不再镜像) */
234
+ --cafe-surface-sunken: oklch(0.36 0.0022 var(--surface-hue));
235
+ --cafe-surface: oklch(0.28 0.0018 var(--surface-hue));
236
+ --cafe-surface-elevated: oklch(0.21 0.0008 var(--surface-hue));
237
+ --cafe-surface-canvas: oklch(0.24 0.0004 var(--surface-hue));
238
+
239
+ /* --- Cafe semantic shortcuts dark(重新映射 Neutral) --- */
240
+ --cafe-text: var(--neutral-900);
241
+ --cafe-text-secondary: var(--neutral-600);
242
+ --cafe-text-muted: var(--neutral-500);
243
+ --cafe-border: var(--neutral-300);
244
+ --cafe-border-subtle: var(--neutral-400);
245
+ --cafe-accent: var(--accent-500);
246
+ --cafe-accent-hover: var(--accent-600);
247
+ --cafe-accent-foreground: var(--neutral-50);
248
+ --cafe-crosspost: var(--semantic-info);
249
+ --cafe-interactive: var(--neutral-700);
250
+
251
+ /* --- Elevation 阴影(dark: inset 高光 + 深外阴影) --- */
252
+ --shadow-elevation-1: 0 1px 2px oklch(0 0 0 / 0.5), inset 0 1px 0 oklch(1 0 0 / 0.1);
253
+ --shadow-elevation-2: 0 6px 14px oklch(0 0 0 / 0.55), inset 0 1px 0 oklch(1 0 0 / 0.14);
254
+ --shadow-elevation-3: 0 16px 32px oklch(0 0 0 / 0.65), inset 0 1px 0 oklch(1 0 0 / 0.18);
255
+
256
+ /* --- Scrim(dark: 加深) --- */
257
+ --scrim-light: oklch(0 0 0 / 0.4);
258
+ --scrim-dim: oklch(0 0 0 / 0.6);
259
+ --scrim-heavy: oklch(0 0 0 / 0.8);
260
+
261
+ /* --- 兼容旧 alias --- */
262
+ --color-base-white: var(--cafe-surface);
263
+ --color-base-black: var(--cafe-text);
264
+ --bg-app: var(--cafe-surface);
265
+ --text-primary: var(--cafe-text);
266
+ --text-secondary: var(--cafe-text-secondary);
267
+ }
@@ -0,0 +1,40 @@
1
+ export class TranscriptView {
2
+ constructor(container) {
3
+ this.container = container;
4
+ this.activeRows = new Map();
5
+ this.completed = new Set();
6
+ }
7
+ append(role, text, chunk = false) {
8
+ const pinned = this.nearBottom();
9
+ this.container.querySelector('.empty')?.remove();
10
+ let row = chunk ? this.activeRows.get(role) : undefined;
11
+ if (!row) {
12
+ row = this.container.ownerDocument.createElement('p');
13
+ row.className = role;
14
+ this.container.append(row);
15
+ if (chunk) this.activeRows.set(role, row);
16
+ }
17
+ row.textContent += text;
18
+ if (pinned) this.container.scrollTop = this.container.scrollHeight;
19
+ }
20
+ finish({ role, transcript, turnId }) {
21
+ if (!['user', 'assistant'].includes(role) || (turnId && this.completed.has(turnId))) return;
22
+ 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;
29
+ this.activeRows.delete(role);
30
+ if (turnId) this.completed.add(turnId);
31
+ if (pinned) this.container.scrollTop = this.container.scrollHeight;
32
+ }
33
+ nearBottom() {
34
+ return this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
35
+ }
36
+ reset() {
37
+ this.activeRows.clear();
38
+ this.completed.clear();
39
+ }
40
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "repository": "https://github.com/zts212653/clowder-ai",
3
+ "commit": "bc9ff2d395a8522a4e5cc93fd317b65cdd9ea1bc",
4
+ "files": [
5
+ {
6
+ "source": "packages/web/src/app/theme-tokens.css",
7
+ "destination": "src/theme.css",
8
+ "sha256": "165b96ef77a04755bb84bc2278f5d52587fa40e893575e53ef5f85f2ec89e5cf"
9
+ },
10
+ {
11
+ "source": "packages/web/public/concierge/skins/yanyan-codex/spritesheet.webp",
12
+ "destination": "assets/skins/yanyan-codex.webp",
13
+ "sha256": "44aee212783ab15ec73f7ce44f67c894e9ab7a7f36674b71a1161d62dafc1d72"
14
+ },
15
+ {
16
+ "source": "packages/web/public/concierge/skins/xianxian-codex/spritesheet.webp",
17
+ "destination": "assets/skins/xianxian-codex.webp",
18
+ "sha256": "5f653974735a4104dfdaf1845128494a5cb5164cb2f45fecb2072a167c80e8a5"
19
+ },
20
+ {
21
+ "source": "packages/web/public/concierge/skins/ragdoll-v1/idle.png",
22
+ "destination": "assets/skins/ragdoll-v1.png",
23
+ "sha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025"
24
+ },
25
+ {
26
+ "source": "packages/web/public/concierge/sprites/ragdoll/idle.png",
27
+ "destination": "assets/skins/yarn-ball.png",
28
+ "sha256": "001c7f3660b348e2531694d9dc455378a0c007c765e0936a48e61eedf9c30025"
29
+ }
30
+ ]
31
+ }