mocode-pet-app 0.1.0

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.
@@ -0,0 +1,58 @@
1
+ <svg id="pet-svg-root" width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="bodyGrad" x1="48" y1="48" x2="208" y2="220" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#1b263b"/>
5
+ <stop offset="1" stop-color="#0d1b2a"/>
6
+ </linearGradient>
7
+ <linearGradient id="cyanGrad" x1="60" y1="90" x2="196" y2="170" gradientUnits="userSpaceOnUse">
8
+ <stop offset="0" stop-color="#2afadf"/>
9
+ <stop offset="1" stop-color="#42a5f5"/>
10
+ </linearGradient>
11
+ <filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
12
+ <feGaussianBlur stdDeviation="3" result="blur"/>
13
+ <feMerge>
14
+ <feMergeNode in="blur"/>
15
+ <feMergeNode in="SourceGraphic"/>
16
+ </feMerge>
17
+ </filter>
18
+ </defs>
19
+
20
+ <!-- Antenna (id 供各状态切换灯光颜色/闪烁速率,不改变原始视觉/动画) -->
21
+ <line x1="128" y1="30" x2="128" y2="50" stroke="#2afadf" stroke-width="4" stroke-linecap="round"/>
22
+ <circle id="pet-antenna" cx="128" cy="24" r="7" fill="#2afadf" filter="url(#glow)">
23
+ <animate attributeName="opacity" values="1;0.3;1" dur="1.6s" repeatCount="indefinite"/>
24
+ </circle>
25
+
26
+ <!-- Body / terminal head(id 供 tool_call/error 状态的抖动/摆动变换) -->
27
+ <g id="pet-body-group">
28
+ <rect id="pet-body" x="40" y="50" width="176" height="150" rx="28" fill="url(#bodyGrad)" stroke="url(#cyanGrad)" stroke-width="3"/>
29
+
30
+ <!-- Screen face -->
31
+ <rect x="60" y="72" width="136" height="90" rx="14" fill="#0a1420" stroke="#2afadf" stroke-opacity="0.4" stroke-width="1.5"/>
32
+
33
+ <!-- Eyes (blinking) -->
34
+ <g filter="url(#glow)">
35
+ <rect x="86" y="104" width="14" height="26" rx="6" fill="url(#cyanGrad)">
36
+ <animate attributeName="height" values="26;2;26" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
37
+ <animate attributeName="y" values="104;117;104" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
38
+ </rect>
39
+ <rect x="156" y="104" width="14" height="26" rx="6" fill="url(#cyanGrad)">
40
+ <animate attributeName="height" values="26;2;26" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
41
+ <animate attributeName="y" values="104;117;104" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
42
+ </rect>
43
+ </g>
44
+
45
+ <!-- Mouth: simple dash(id 供 speaking 状态开合动画) -->
46
+ <rect id="pet-mouth" x="112" y="147" width="32" height="6" rx="3" fill="#2afadf" fill-opacity="0.8" filter="url(#glow)"/>
47
+ </g>
48
+
49
+ <!-- Arms(id 供 tool_call 状态的摆动动画) -->
50
+ <path id="pet-arm-left" d="M40 120 Q16 130 20 158" stroke="url(#cyanGrad)" stroke-width="8" stroke-linecap="round" fill="none"/>
51
+ <path id="pet-arm-right" d="M216 120 Q240 130 236 158" stroke="url(#cyanGrad)" stroke-width="8" stroke-linecap="round" fill="none"/>
52
+
53
+ <!-- Legs / feet -->
54
+ <rect x="76" y="200" width="20" height="26" rx="8" fill="url(#bodyGrad)" stroke="url(#cyanGrad)" stroke-width="2.5"/>
55
+ <rect x="160" y="200" width="20" height="26" rx="8" fill="url(#bodyGrad)" stroke="url(#cyanGrad)" stroke-width="2.5"/>
56
+ <rect x="66" y="222" width="40" height="10" rx="5" fill="#2afadf" fill-opacity="0.6"/>
57
+ <rect x="150" y="222" width="40" height="10" rx="5" fill="#2afadf" fill-opacity="0.6"/>
58
+ </svg>
package/bin/pet-app.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // mocode-pet-app 可执行入口:用 electron 运行 dist/main.js。
3
+ // mocode 主包的 src/pet/bridge.ts 通过 require.resolve('mocode-pet-app/bin/pet-app.js')
4
+ // 定位本文件路径,再以 electron 可执行文件 spawn 它(而不是直接 node 运行——
5
+ // electron 的 main 进程需要 electron 自带的运行时,不能用纯 node 跑)。
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import path from 'node:path';
10
+ import electronPath from 'electron';
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const mainScript = path.join(__dirname, '..', 'dist', 'main.js');
14
+
15
+ const child = spawn(String(electronPath), [mainScript], {
16
+ stdio: 'ignore',
17
+ detached: true,
18
+ windowsHide: true,
19
+ });
20
+ child.unref();
@@ -0,0 +1,58 @@
1
+ <svg id="pet-svg-root" width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="bodyGrad" x1="48" y1="48" x2="208" y2="220" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#1b263b"/>
5
+ <stop offset="1" stop-color="#0d1b2a"/>
6
+ </linearGradient>
7
+ <linearGradient id="cyanGrad" x1="60" y1="90" x2="196" y2="170" gradientUnits="userSpaceOnUse">
8
+ <stop offset="0" stop-color="#2afadf"/>
9
+ <stop offset="1" stop-color="#42a5f5"/>
10
+ </linearGradient>
11
+ <filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
12
+ <feGaussianBlur stdDeviation="3" result="blur"/>
13
+ <feMerge>
14
+ <feMergeNode in="blur"/>
15
+ <feMergeNode in="SourceGraphic"/>
16
+ </feMerge>
17
+ </filter>
18
+ </defs>
19
+
20
+ <!-- Antenna (id 供各状态切换灯光颜色/闪烁速率,不改变原始视觉/动画) -->
21
+ <line x1="128" y1="30" x2="128" y2="50" stroke="#2afadf" stroke-width="4" stroke-linecap="round"/>
22
+ <circle id="pet-antenna" cx="128" cy="24" r="7" fill="#2afadf" filter="url(#glow)">
23
+ <animate attributeName="opacity" values="1;0.3;1" dur="1.6s" repeatCount="indefinite"/>
24
+ </circle>
25
+
26
+ <!-- Body / terminal head(id 供 tool_call/error 状态的抖动/摆动变换) -->
27
+ <g id="pet-body-group">
28
+ <rect id="pet-body" x="40" y="50" width="176" height="150" rx="28" fill="url(#bodyGrad)" stroke="url(#cyanGrad)" stroke-width="3"/>
29
+
30
+ <!-- Screen face -->
31
+ <rect x="60" y="72" width="136" height="90" rx="14" fill="#0a1420" stroke="#2afadf" stroke-opacity="0.4" stroke-width="1.5"/>
32
+
33
+ <!-- Eyes (blinking) -->
34
+ <g filter="url(#glow)">
35
+ <rect x="86" y="104" width="14" height="26" rx="6" fill="url(#cyanGrad)">
36
+ <animate attributeName="height" values="26;2;26" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
37
+ <animate attributeName="y" values="104;117;104" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
38
+ </rect>
39
+ <rect x="156" y="104" width="14" height="26" rx="6" fill="url(#cyanGrad)">
40
+ <animate attributeName="height" values="26;2;26" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
41
+ <animate attributeName="y" values="104;117;104" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
42
+ </rect>
43
+ </g>
44
+
45
+ <!-- Mouth: simple dash(id 供 speaking 状态开合动画) -->
46
+ <rect id="pet-mouth" x="112" y="147" width="32" height="6" rx="3" fill="#2afadf" fill-opacity="0.8" filter="url(#glow)"/>
47
+ </g>
48
+
49
+ <!-- Arms(id 供 tool_call 状态的摆动动画) -->
50
+ <path id="pet-arm-left" d="M40 120 Q16 130 20 158" stroke="url(#cyanGrad)" stroke-width="8" stroke-linecap="round" fill="none"/>
51
+ <path id="pet-arm-right" d="M216 120 Q240 130 236 158" stroke="url(#cyanGrad)" stroke-width="8" stroke-linecap="round" fill="none"/>
52
+
53
+ <!-- Legs / feet -->
54
+ <rect x="76" y="200" width="20" height="26" rx="8" fill="url(#bodyGrad)" stroke="url(#cyanGrad)" stroke-width="2.5"/>
55
+ <rect x="160" y="200" width="20" height="26" rx="8" fill="url(#bodyGrad)" stroke="url(#cyanGrad)" stroke-width="2.5"/>
56
+ <rect x="66" y="222" width="40" height="10" rx="5" fill="#2afadf" fill-opacity="0.6"/>
57
+ <rect x="150" y="222" width="40" height="10" rx="5" fill="#2afadf" fill-opacity="0.6"/>
58
+ </svg>
package/dist/main.js ADDED
@@ -0,0 +1,284 @@
1
+ // 桌宠 Electron 主进程:WS Server 单例 + 最新连接覆盖 + BrowserWindow 生命周期。
2
+ // 不感知 mocode CLI 内部逻辑,只是纯粹的状态转发枢纽:接收 WS state 消息 → IPC 推给渲染进程。
3
+ import { app, BrowserWindow, screen } from 'electron';
4
+ import { WebSocketServer, WebSocket } from 'ws';
5
+ import { createServer as createHttpServer } from 'node:http';
6
+ import { fileURLToPath } from 'node:url';
7
+ import path from 'node:path';
8
+ import { parseClientMessage, } from './protocol.js';
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ /** 默认端口;MOCODE_PET_PORT 环境变量覆盖(与 mocode 主包 src/pet/bridge.ts 保持一致的默认假设)。 */
11
+ const DEFAULT_PORT = 47821;
12
+ function petPort() {
13
+ const v = process.env.MOCODE_PET_PORT;
14
+ const n = v ? Number(v) : NaN;
15
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_PORT;
16
+ }
17
+ /** 心跳:每 15s 检查一次,10s 内无 pong 视为死连接(design.md 默认假设)。 */
18
+ const HEARTBEAT_CHECK_INTERVAL_MS = 15000;
19
+ const HEARTBEAT_TIMEOUT_MS = 10000;
20
+ /** 状态展示超时后自动回落(design.md 默认假设:done/aborted/error 短暂展示 1500ms)。 */
21
+ const TRANSIENT_STATE_TIMEOUT_MS = 1500;
22
+ /** 全局连接表 + 唯一活跃连接引用("最新连接覆盖"算法的状态载体,见 design.md Low-Level)。 */
23
+ const connections = new Map();
24
+ let activeSocket = null;
25
+ let mainWindow = null;
26
+ let transientTimer = null;
27
+ /** 把状态推给渲染进程(IPC)。渲染进程窗口未就绪时静默丢弃(下次状态到达会重推)。 */
28
+ function broadcastToRenderer(state, meta) {
29
+ if (!mainWindow || mainWindow.isDestroyed())
30
+ return;
31
+ try {
32
+ mainWindow.webContents.send('pet:state', { state, meta });
33
+ }
34
+ catch {
35
+ // 渲染进程尚未加载完毕或已崩溃:静默,不影响 Server 侧状态机
36
+ }
37
+ if (transientTimer) {
38
+ clearTimeout(transientTimer);
39
+ transientTimer = null;
40
+ }
41
+ if (state === 'done' || state === 'aborted' || state === 'error') {
42
+ transientTimer = setTimeout(() => {
43
+ // 展示超时:done/aborted → idle;error → thinking(工具报错后 agent 通常还会继续跑)。
44
+ // 简化实现:统一回 idle,若 agent 仍在跑,下一个 hook 事件会立即覆盖为正确状态。
45
+ broadcastToRenderer('idle');
46
+ }, TRANSIENT_STATE_TIMEOUT_MS);
47
+ }
48
+ }
49
+ /**
50
+ * 新连接建立时的处理:注册 ConnectionRecord,立即成为唯一 active(覆盖之前的 active)。
51
+ * 见 design.md "最新连接覆盖"算法:不断开旧连接,只是把它降级为非活跃(isActive=false)。
52
+ */
53
+ function onConnection(socket) {
54
+ // 步骤 1:把之前的 active 连接降级(至多一条为 active,核心不变量)
55
+ if (activeSocket && connections.has(activeSocket)) {
56
+ connections.get(activeSocket).isActive = false;
57
+ }
58
+ // 步骤 2:新连接成为唯一 active(clientId 在收到 hello 前先留空,占位用连接本身作 key)
59
+ const record = {
60
+ socket,
61
+ clientId: '',
62
+ connectedAt: Date.now(),
63
+ lastPongAt: Date.now(),
64
+ isActive: true,
65
+ };
66
+ connections.set(socket, record);
67
+ activeSocket = socket;
68
+ send(socket, { type: 'welcome', isActive: true, ts: Date.now() });
69
+ broadcastToRenderer('idle'); // 新连接刚建立还没收到它的第一条 state,先归 idle 兜底
70
+ socket.on('message', (data) => onMessage(socket, String(data)));
71
+ socket.once('close', () => onDisconnect(socket));
72
+ socket.once('error', () => onDisconnect(socket));
73
+ }
74
+ /**
75
+ * 连接断开处理。若断开的是活跃连接:立即广播 idle,且不提升任何"次新"连接为新的活跃状态源
76
+ * (design.md Requirement 3.2 的核心约束)。
77
+ */
78
+ function onDisconnect(socket) {
79
+ const wasActive = socket === activeSocket;
80
+ connections.delete(socket);
81
+ if (wasActive) {
82
+ activeSocket = null; // 强制清空,不回退到任何仍 open 的连接
83
+ broadcastToRenderer('idle');
84
+ }
85
+ // wasActive = false:该连接原本就被忽略,断开对当前状态源无影响
86
+ }
87
+ function onMessage(socket, raw) {
88
+ touchAlive(socket); // 收到任意消息即视为存活,刷新心跳超时判定基准
89
+ const msg = parseClientMessage(raw);
90
+ if (!msg)
91
+ return; // 畸形消息:静默丢弃,连接不断开(Requirement 6.1)
92
+ const record = connections.get(socket);
93
+ if (!record)
94
+ return;
95
+ switch (msg.type) {
96
+ case 'hello':
97
+ record.clientId = msg.clientId;
98
+ return;
99
+ case 'ping':
100
+ send(socket, { type: 'pong', ts: Date.now() });
101
+ return;
102
+ case 'bye':
103
+ onDisconnect(socket);
104
+ try {
105
+ socket.close(1000);
106
+ }
107
+ catch {
108
+ // no-op
109
+ }
110
+ return;
111
+ case 'state':
112
+ if (socket !== activeSocket)
113
+ return; // 非活跃连接的状态消息静默丢弃
114
+ broadcastToRenderer(msg.state, msg.meta);
115
+ return;
116
+ default:
117
+ return;
118
+ }
119
+ }
120
+ function send(socket, msg) {
121
+ try {
122
+ socket.send(JSON.stringify(msg));
123
+ }
124
+ catch {
125
+ // 静默:发送失败不影响 Server 主循环
126
+ }
127
+ }
128
+ /** 心跳巡检:每 HEARTBEAT_CHECK_INTERVAL_MS 检查所有连接,超时未 pong 的视为死连接并清理。 */
129
+ function startHeartbeatSweep() {
130
+ const timer = setInterval(() => {
131
+ const now = Date.now();
132
+ for (const [socket, record] of connections) {
133
+ if (now - record.lastPongAt > HEARTBEAT_TIMEOUT_MS + HEARTBEAT_CHECK_INTERVAL_MS) {
134
+ try {
135
+ socket.terminate();
136
+ }
137
+ catch {
138
+ // no-op
139
+ }
140
+ onDisconnect(socket);
141
+ }
142
+ }
143
+ }, HEARTBEAT_CHECK_INTERVAL_MS);
144
+ timer.unref?.();
145
+ return timer;
146
+ }
147
+ /** pong 到达时更新 lastPongAt(客户端主动发 ping,这里作为 server 收 ping 后回 pong 的对端确认;
148
+ * 同时 server 也可能主动关心 client 是否存活——此处简化为"收到任意消息即视为存活",
149
+ * 在 onMessage 里对每条消息更新,避免额外维护 ping/pong 状态机的复杂度)。 */
150
+ function touchAlive(socket) {
151
+ const record = connections.get(socket);
152
+ if (record)
153
+ record.lastPongAt = Date.now();
154
+ }
155
+ /**
156
+ * 启动 WS Server;若端口已被占用则尝试 WS 握手验证占用方是否为桌宠自身。
157
+ * 验证通过(是桌宠)→ 静默退出(exit 0,已有实例在跑,不重复常驻)。
158
+ * 验证失败(端口被别的服务占用)→ 退出并附带诊断信息(exit 非 0)。
159
+ */
160
+ async function startServer(port) {
161
+ return new Promise((resolve, reject) => {
162
+ const httpServer = createHttpServer();
163
+ const wss = new WebSocketServer({ server: httpServer });
164
+ httpServer.once('error', async (err) => {
165
+ if (err.code === 'EADDRINUSE') {
166
+ const isPet = await probeIsPetServer(port);
167
+ if (isPet) {
168
+ console.log('[pet-app] 端口已有桌宠实例在运行,本进程退出。');
169
+ process.exit(0);
170
+ }
171
+ else {
172
+ console.error(`[pet-app] 端口 ${port} 被非桌宠进程占用,无法启动。`);
173
+ process.exit(1);
174
+ }
175
+ }
176
+ else {
177
+ reject(err);
178
+ }
179
+ });
180
+ wss.on('connection', (socket) => {
181
+ touchAlive(socket);
182
+ onConnection(socket);
183
+ });
184
+ httpServer.listen(port, '127.0.0.1', () => {
185
+ resolve(wss);
186
+ });
187
+ });
188
+ }
189
+ /** 探测占用端口的服务是否是桌宠自身(能完成一次 WS 握手并收到 welcome)。 */
190
+ function probeIsPetServer(port) {
191
+ return new Promise((resolve) => {
192
+ let settled = false;
193
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`);
194
+ const timer = setTimeout(() => {
195
+ if (settled)
196
+ return;
197
+ settled = true;
198
+ try {
199
+ ws.terminate();
200
+ }
201
+ catch {
202
+ // no-op
203
+ }
204
+ resolve(false);
205
+ }, 500);
206
+ ws.once('open', () => {
207
+ if (settled)
208
+ return;
209
+ settled = true;
210
+ clearTimeout(timer);
211
+ try {
212
+ ws.close();
213
+ }
214
+ catch {
215
+ // no-op
216
+ }
217
+ resolve(true);
218
+ });
219
+ ws.once('error', () => {
220
+ if (settled)
221
+ return;
222
+ settled = true;
223
+ clearTimeout(timer);
224
+ resolve(false);
225
+ });
226
+ });
227
+ }
228
+ /** 跨平台悬浮窗配置(design.md 跨平台 BrowserWindow 配置差异表)。 */
229
+ function createPetWindow() {
230
+ const { width } = screen.getPrimaryDisplay().workAreaSize;
231
+ const winSize = 220;
232
+ const margin = 24;
233
+ const win = new BrowserWindow({
234
+ width: winSize,
235
+ height: winSize,
236
+ x: width - winSize - margin,
237
+ y: screen.getPrimaryDisplay().workAreaSize.height - winSize - margin,
238
+ transparent: true,
239
+ frame: false,
240
+ alwaysOnTop: true,
241
+ skipTaskbar: true,
242
+ hasShadow: false,
243
+ resizable: false,
244
+ fullscreenable: false,
245
+ minimizable: false,
246
+ maximizable: false,
247
+ focusable: false,
248
+ webPreferences: {
249
+ preload: path.join(__dirname, 'renderer', 'preload.js'),
250
+ contextIsolation: true,
251
+ nodeIntegration: false,
252
+ },
253
+ });
254
+ if (process.platform === 'darwin') {
255
+ win.setAlwaysOnTop(true, 'floating');
256
+ app.dock?.hide();
257
+ }
258
+ else {
259
+ win.setAlwaysOnTop(true);
260
+ }
261
+ // 鼠标穿透:允许用户点击桌面下层内容,不遮挡操作。悬浮窗本身仍可接收自身渲染的动画更新。
262
+ win.setIgnoreMouseEvents(true);
263
+ win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
264
+ win.webContents.on('render-process-gone', () => {
265
+ // 渲染进程崩溃但主进程(WS Server)存活:重建一次窗口,不重启 WS Server、不断开现有客户端连接。
266
+ console.error('[pet-app] 渲染进程崩溃,尝试重建窗口。');
267
+ if (mainWindow && !mainWindow.isDestroyed()) {
268
+ mainWindow.destroy();
269
+ }
270
+ mainWindow = createPetWindow();
271
+ });
272
+ return win;
273
+ }
274
+ app.whenReady().then(async () => {
275
+ const port = petPort();
276
+ await startServer(port);
277
+ startHeartbeatSweep();
278
+ mainWindow = createPetWindow();
279
+ });
280
+ app.on('window-all-closed', () => {
281
+ // 用户关闭悬浮窗:整个桌宠进程退出(WS Server 一并关闭,已连 mocode 客户端会收到 close 事件,
282
+ // 按 bridge.ts 的"连接异常不自动重连"策略,下次 /pet 会重新探测并拉起新实例)。
283
+ app.quit();
284
+ });
@@ -0,0 +1,87 @@
1
+ // 桌宠 WS 协议:pet-app 子包侧的类型定义与消息校验。
2
+ // 与 src/pet/protocol.ts(mocode 主包侧)字段保持一致,但两处各自维护副本——
3
+ // 子包不依赖主包源码(两者是独立发布单元,主包只在需要时 spawn 子包的可执行文件)。
4
+ /** 全部合法状态值(供运行时校验)。 */
5
+ export const PET_STATES = [
6
+ 'idle',
7
+ 'thinking',
8
+ 'speaking',
9
+ 'tool_call',
10
+ 'done',
11
+ 'aborted',
12
+ 'error',
13
+ ];
14
+ /** 判断值是否为合法 PetState(供消息校验,非法值丢弃不崩)。 */
15
+ export function isValidPetState(v) {
16
+ return typeof v === 'string' && PET_STATES.includes(v);
17
+ }
18
+ /** 校验并解析一条原始 JSON 字符串为 ClientMessage;失败(JSON 非法/缺字段/type 未知)返回 null。
19
+ * 永不抛错——Server 据此静默丢弃畸形消息,不断开连接(Requirement 6.1)。 */
20
+ export function parseClientMessage(raw) {
21
+ let obj;
22
+ try {
23
+ obj = JSON.parse(raw);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ if (typeof obj !== 'object' || obj === null)
29
+ return null;
30
+ const m = obj;
31
+ if (typeof m.ts !== 'number')
32
+ return null;
33
+ switch (m.type) {
34
+ case 'hello':
35
+ if (typeof m.clientId !== 'string' || typeof m.pid !== 'number' || typeof m.cwd !== 'string') {
36
+ return null;
37
+ }
38
+ return { type: 'hello', clientId: m.clientId, pid: m.pid, cwd: m.cwd, ts: m.ts };
39
+ case 'state': {
40
+ if (typeof m.clientId !== 'string' || !isValidPetState(m.state))
41
+ return null;
42
+ let meta;
43
+ if (m.meta && typeof m.meta === 'object') {
44
+ const mm = m.meta;
45
+ meta = {};
46
+ if (typeof mm.toolName === 'string')
47
+ meta.toolName = mm.toolName;
48
+ if (typeof mm.errorMessage === 'string')
49
+ meta.errorMessage = mm.errorMessage;
50
+ }
51
+ return { type: 'state', clientId: m.clientId, state: m.state, meta, ts: m.ts };
52
+ }
53
+ case 'ping':
54
+ return { type: 'ping', ts: m.ts };
55
+ case 'bye':
56
+ if (typeof m.clientId !== 'string')
57
+ return null;
58
+ return { type: 'bye', clientId: m.clientId, ts: m.ts };
59
+ default:
60
+ return null;
61
+ }
62
+ }
63
+ /** 校验并解析一条原始 JSON 字符串为 ServerMessage;失败返回 null(同上,永不抛错)。 */
64
+ export function parseServerMessage(raw) {
65
+ let obj;
66
+ try {
67
+ obj = JSON.parse(raw);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ if (typeof obj !== 'object' || obj === null)
73
+ return null;
74
+ const m = obj;
75
+ if (typeof m.ts !== 'number')
76
+ return null;
77
+ switch (m.type) {
78
+ case 'welcome':
79
+ if (typeof m.isActive !== 'boolean')
80
+ return null;
81
+ return { type: 'welcome', isActive: m.isActive, ts: m.ts };
82
+ case 'pong':
83
+ return { type: 'pong', ts: m.ts };
84
+ default:
85
+ return null;
86
+ }
87
+ }
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self' data:; connect-src 'self' file:;" />
6
+ <title>mocode pet</title>
7
+ <link rel="stylesheet" href="style.css" />
8
+ </head>
9
+ <body>
10
+ <div id="pet-container" class="pet-idle"></div>
11
+ <script src="renderer.js" type="module"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,10 @@
1
+ // Electron preload:通过 contextBridge 把主进程 IPC 推送的状态安全暴露给渲染进程(contextIsolation=true,
2
+ // 不给渲染进程任何 Node/Electron API 访问权限,只转发一个只读回调注册接口)。
3
+ import { contextBridge, ipcRenderer } from 'electron';
4
+ contextBridge.exposeInMainWorld('petBridge', {
5
+ onState: (callback) => {
6
+ ipcRenderer.on('pet:state', (_event, payload) => {
7
+ callback(payload.state, payload.meta);
8
+ });
9
+ },
10
+ });
@@ -0,0 +1,46 @@
1
+ // 渲染进程:纯展示逻辑,不含业务判断。接收 preload 暴露的 petBridge.onState 回调,
2
+ // 按 PetState 切换外层容器的 CSS class,驱动 style.css 里定义的 @keyframes 动画。
3
+ // mascot.svg 本身的呼吸灯/眨眼动画(inline <animate>)始终运行,不受这里的 class 切换影响。
4
+ const ALL_STATE_CLASSES = [
5
+ 'pet-idle',
6
+ 'pet-thinking',
7
+ 'pet-speaking',
8
+ 'pet-tool',
9
+ 'pet-done',
10
+ 'pet-aborted',
11
+ 'pet-error',
12
+ ];
13
+ /** PetState → CSS class 映射表(design.md 动画映射表)。 */
14
+ const STATE_CLASS = {
15
+ idle: 'pet-idle',
16
+ thinking: 'pet-thinking',
17
+ speaking: 'pet-speaking',
18
+ tool_call: 'pet-tool',
19
+ done: 'pet-done',
20
+ aborted: 'pet-aborted',
21
+ error: 'pet-error',
22
+ };
23
+ function applyState(container, state) {
24
+ container.classList.remove(...ALL_STATE_CLASSES);
25
+ container.classList.add(STATE_CLASS[state] ?? 'pet-idle');
26
+ }
27
+ window.addEventListener('DOMContentLoaded', async () => {
28
+ const container = document.getElementById('pet-container');
29
+ if (!container)
30
+ return;
31
+ applyState(container, 'idle');
32
+ // inline mascot.svg(而非 <img>/<object>)以保留其内部 id,供 style.css 的
33
+ // #pet-antenna / #pet-body-group / #pet-mouth / #pet-arm-left / #pet-arm-right 选择器生效
34
+ // (外部引用的 SVG 内容不可被外部 CSS 选中,必须 inline 进同一文档)。
35
+ try {
36
+ // 构建后 assets/ 与 renderer/ 同级复制到 dist/(见 scripts/copy-static.mjs),故用 ../assets。
37
+ const res = await fetch('../assets/mascot.svg');
38
+ const svgText = await res.text();
39
+ container.insertAdjacentHTML('afterbegin', svgText);
40
+ }
41
+ catch {
42
+ // 素材加载失败:容器保持空白,不影响状态机/IPC 正常工作(降级为无形象但仍可运行)
43
+ }
44
+ window.petBridge.onState((state) => applyState(container, state));
45
+ });
46
+ export {};
@@ -0,0 +1,131 @@
1
+ /* 桌宠渲染进程样式:状态驱动 CSS 动画,叠加在 mascot.svg 之上(不修改素材文件本身的结构)。
2
+ * 容器 #pet-container 承载 mascot.svg 的 <object>/inline 内容;通过外层 class 切换驱动
3
+ * transform / filter 变换,SVG 内置的呼吸灯(#pet-antenna)/眨眼动画始终独立运行不受影响。 */
4
+
5
+ html, body {
6
+ margin: 0;
7
+ padding: 0;
8
+ width: 100%;
9
+ height: 100%;
10
+ background: transparent;
11
+ overflow: hidden;
12
+ -webkit-app-region: drag; /* 允许拖拽悬浮窗(鼠标穿透关闭区域外);实际穿透行为由主进程 setIgnoreMouseEvents 控制 */
13
+ }
14
+
15
+ #pet-container {
16
+ width: 100%;
17
+ height: 100%;
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ }
22
+
23
+ #pet-container svg {
24
+ width: 90%;
25
+ height: 90%;
26
+ }
27
+
28
+ /* ── idle:轻微上下浮动呼吸,机身静止 ── */
29
+ @keyframes pet-float {
30
+ 0%, 100% { transform: translateY(0); }
31
+ 50% { transform: translateY(-6px); }
32
+ }
33
+ .pet-idle #pet-body-group {
34
+ animation: pet-float 3s ease-in-out infinite;
35
+ }
36
+
37
+ /* ── thinking:天线灯加速闪烁 + 头部左右轻摆 ── */
38
+ @keyframes pet-think-nod {
39
+ 0%, 100% { transform: rotate(0deg); }
40
+ 50% { transform: rotate(-4deg); }
41
+ }
42
+ @keyframes pet-antenna-fast-blink {
43
+ 0%, 100% { opacity: 1; }
44
+ 50% { opacity: 0.25; }
45
+ }
46
+ .pet-thinking #pet-body-group {
47
+ animation: pet-think-nod 0.9s ease-in-out infinite;
48
+ transform-origin: 128px 125px;
49
+ }
50
+ .pet-thinking #pet-antenna {
51
+ animation: pet-antenna-fast-blink 0.6s ease-in-out infinite;
52
+ }
53
+
54
+ /* ── speaking:嘴部开合模拟说话 ── */
55
+ @keyframes pet-mouth-talk {
56
+ 0%, 100% { transform: scaleY(1); }
57
+ 50% { transform: scaleY(2.4); }
58
+ }
59
+ .pet-speaking #pet-mouth {
60
+ animation: pet-mouth-talk 0.3s ease-in-out infinite;
61
+ transform-origin: 128px 150px;
62
+ }
63
+
64
+ /* ── tool_call:双臂交替摆动,天线灯常亮不闪 ── */
65
+ @keyframes pet-arm-swing-left {
66
+ 0%, 100% { transform: rotate(0deg); }
67
+ 50% { transform: rotate(-12deg); }
68
+ }
69
+ @keyframes pet-arm-swing-right {
70
+ 0%, 100% { transform: rotate(0deg); }
71
+ 50% { transform: rotate(12deg); }
72
+ }
73
+ .pet-tool #pet-arm-left {
74
+ animation: pet-arm-swing-left 0.5s ease-in-out infinite;
75
+ transform-origin: 40px 120px;
76
+ }
77
+ .pet-tool #pet-arm-right {
78
+ animation: pet-arm-swing-right 0.5s ease-in-out infinite 0.25s;
79
+ transform-origin: 216px 120px;
80
+ }
81
+ .pet-tool #pet-antenna {
82
+ animation: none;
83
+ opacity: 1;
84
+ }
85
+
86
+ /* ── done:短暂放大弹回 + 眼睛描边变绿(用滤镜近似) ── */
87
+ @keyframes pet-done-bounce {
88
+ 0% { transform: scale(1); }
89
+ 40% { transform: scale(1.1); }
90
+ 100% { transform: scale(1); }
91
+ }
92
+ .pet-done #pet-body-group {
93
+ animation: pet-done-bounce 0.4s ease-out 1;
94
+ transform-origin: 128px 125px;
95
+ }
96
+ .pet-done #pet-antenna {
97
+ filter: drop-shadow(0 0 6px #4ade80);
98
+ }
99
+
100
+ /* ── aborted:头部短暂低垂 + 天线灯熄灭 ── */
101
+ @keyframes pet-aborted-droop {
102
+ 0% { transform: rotate(0deg); }
103
+ 50% { transform: rotate(8deg); }
104
+ 100% { transform: rotate(0deg); }
105
+ }
106
+ .pet-aborted #pet-body-group {
107
+ animation: pet-aborted-droop 0.3s ease-in-out 1;
108
+ transform-origin: 128px 125px;
109
+ }
110
+ .pet-aborted #pet-antenna {
111
+ animation: none;
112
+ opacity: 0;
113
+ }
114
+
115
+ /* ── error:机身横向抖动 + 天线灯变红闪烁 ── */
116
+ @keyframes pet-error-shake {
117
+ 0%, 100% { transform: translateX(0); }
118
+ 25% { transform: translateX(-6px); }
119
+ 75% { transform: translateX(6px); }
120
+ }
121
+ @keyframes pet-error-blink-red {
122
+ 0%, 100% { opacity: 1; }
123
+ 50% { opacity: 0.3; }
124
+ }
125
+ .pet-error #pet-body-group {
126
+ animation: pet-error-shake 0.3s ease-in-out 1;
127
+ }
128
+ .pet-error #pet-antenna {
129
+ fill: #f87171;
130
+ animation: pet-error-blink-red 0.3s ease-in-out 2;
131
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "mocode-pet-app",
3
+ "version": "0.1.0",
4
+ "description": "mocode 桌宠:独立 Electron 悬浮窗,展示 agent 执行状态动画。作为 mocode-ai 的可选子包,不随主包安装强制拉取。",
5
+ "private": false,
6
+ "type": "module",
7
+ "main": "dist/main.js",
8
+ "bin": {
9
+ "mocode-pet-app": "bin/pet-app.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "bin",
14
+ "assets"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json && node scripts/copy-static.mjs",
21
+ "start": "electron dist/main.js",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "dependencies": {
25
+ "electron": "43.0.0",
26
+ "ws": "8.21.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/ws": "8.5.13",
30
+ "typescript": "5.7.2"
31
+ },
32
+ "author": "wxy",
33
+ "license": "MIT"
34
+ }