ai-growth 1.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.
Files changed (75) hide show
  1. package/.env.example +19 -0
  2. package/AGENTS.md +232 -0
  3. package/IMPLEMENTATION_PLAN.md +207 -0
  4. package/LICENSE +21 -0
  5. package/PRODUCT_SPEC.md +1776 -0
  6. package/README.md +384 -0
  7. package/SKILL.md +121 -0
  8. package/START-HERE.md +156 -0
  9. package/dist/api/app.js +206 -0
  10. package/dist/api/server.js +42 -0
  11. package/dist/cli/main.js +799 -0
  12. package/dist/core/actions.js +264 -0
  13. package/dist/core/context.js +7 -0
  14. package/dist/core/directions.js +26 -0
  15. package/dist/core/evidence.js +155 -0
  16. package/dist/core/goals.js +188 -0
  17. package/dist/core/lifeContext.js +41 -0
  18. package/dist/core/memory.js +35 -0
  19. package/dist/core/metrics.js +74 -0
  20. package/dist/core/profile.js +95 -0
  21. package/dist/core/progress.js +244 -0
  22. package/dist/core/prompts/index.js +40 -0
  23. package/dist/core/replan.js +100 -0
  24. package/dist/core/rollover.js +68 -0
  25. package/dist/core/rolloverTypes.js +1 -0
  26. package/dist/core/stateMachine.js +61 -0
  27. package/dist/core/status.js +57 -0
  28. package/dist/core/types.js +9 -0
  29. package/dist/daemon/main.js +64 -0
  30. package/dist/db/cli.js +19 -0
  31. package/dist/db/client.js +36 -0
  32. package/dist/db/migrate.js +34 -0
  33. package/dist/db/migrations/V1__init.sql +249 -0
  34. package/dist/device/card.js +257 -0
  35. package/dist/device/deck.js +157 -0
  36. package/dist/device/fontCoverage.js +162 -0
  37. package/dist/device/http.js +170 -0
  38. package/dist/device/png.js +111 -0
  39. package/dist/device/provision.js +185 -0
  40. package/dist/device/render.js +194 -0
  41. package/dist/device/renderCard.js +48 -0
  42. package/dist/device/report.js +146 -0
  43. package/dist/device/serial.js +201 -0
  44. package/dist/device/token.js +78 -0
  45. package/dist/device/types.js +2 -0
  46. package/dist/mcp/ctx.js +48 -0
  47. package/dist/mcp/server.js +15 -0
  48. package/dist/mcp/tools.js +586 -0
  49. package/dist/passport/builder.js +83 -0
  50. package/dist/passport/state.js +81 -0
  51. package/dist/passport/sync.js +61 -0
  52. package/dist/reminder/notify.js +30 -0
  53. package/dist/reminder/scheduler.js +87 -0
  54. package/dist/reminder/service.js +102 -0
  55. package/dist/shared/config.js +50 -0
  56. package/dist/shared/result.js +27 -0
  57. package/dist/shared/time.js +67 -0
  58. package/dist/shared/util.js +21 -0
  59. package/dist/ui/app.js +500 -0
  60. package/dist/ui/index.html +38 -0
  61. package/dist/ui/style.css +367 -0
  62. package/docs/images/cards.png +0 -0
  63. package/docs/images/cover.png +0 -0
  64. package/launchd/com.lairey.ai-growth.daemon.plist +45 -0
  65. package/llms.txt +52 -0
  66. package/package.json +79 -0
  67. package/scripts/check-device-cards.mjs +177 -0
  68. package/scripts/doctor.sh +194 -0
  69. package/scripts/gen-device-assets.mjs +188 -0
  70. package/scripts/install.sh +291 -0
  71. package/scripts/preview-cards.mjs +120 -0
  72. package/scripts/reset-dev-data.sh +92 -0
  73. package/scripts/smoke-device.mjs +401 -0
  74. package/scripts/uninstall.sh +63 -0
  75. package/server.json +56 -0
@@ -0,0 +1,170 @@
1
+ import { ok, err, DomainError } from '../shared/result.js';
2
+ import { runDailyRollover } from '../core/rollover.js';
3
+ import { todayIn, resolveTimezone } from '../core/profile.js';
4
+ import { deckSummary, renderCardAt } from './renderCard.js';
5
+ import { applyDeviceReport } from './report.js';
6
+ import { checkDeviceAccess, extractToken, deviceToken, isDeviceLanEnabled } from './token.js';
7
+ export const DEVICE_PREFIX = '/api/device';
8
+ export const SERVICE_NAME = 'ai-growth';
9
+ function sendJson(res, status, body) {
10
+ const text = JSON.stringify(body, null, 2);
11
+ res.writeHead(status, {
12
+ 'Content-Type': 'application/json; charset=utf-8',
13
+ 'Cache-Control': 'no-store',
14
+ });
15
+ res.end(text);
16
+ }
17
+ function sendBinary(res, body) {
18
+ res.writeHead(200, {
19
+ 'Content-Type': 'application/octet-stream',
20
+ 'Content-Length': String(body.length),
21
+ 'Cache-Control': 'no-store',
22
+ });
23
+ res.end(body);
24
+ }
25
+ function readBody(req) {
26
+ return new Promise((resolve) => {
27
+ let data = '';
28
+ req.on('data', (c) => { data += c; });
29
+ req.on('end', () => {
30
+ try {
31
+ resolve(data ? JSON.parse(data) : {});
32
+ }
33
+ catch {
34
+ resolve({});
35
+ }
36
+ });
37
+ });
38
+ }
39
+ /**
40
+ * 解析设备上报的电量,并**量化到 10% 一档**。
41
+ *
42
+ * 🔴 为什么要量化:卡片是现场用无头 Chrome 渲染的(1.1~1.2 秒),而缓存键是
43
+ * `sha256(卡片 HTML)` —— 电量画在卡片的页脚里,所以电量一变,整张卡就得重渲一次。
44
+ * 实测:同一张卡 `bat=88` 命中缓存(1ms),`bat=87` 就变成 **1091ms**(重新渲染)。
45
+ * 而设备每次取卡都会上报当前电量,读数本身还有 ±1~2% 的抖动 —— 于是几乎每次
46
+ * 按上下键都在踩"电量变了→重渲",用户感觉就是"切换要等一两秒"。
47
+ * 量化到 10% 一档,抖动被吸收掉,同时页脚那个电量仍然是有意义的参考值。
48
+ */
49
+ export function parseBattery(v) {
50
+ if (v === null || v === '')
51
+ return null;
52
+ const n = Number(v);
53
+ if (!Number.isFinite(n))
54
+ return null;
55
+ const clamped = Math.max(-1, Math.min(100, Math.round(n)));
56
+ if (clamped < 0)
57
+ return clamped; // -1 = 读不到电量
58
+ return Math.min(100, Math.round(clamped / 10) * 10);
59
+ }
60
+ /**
61
+ * 后台把牌组里其它卡也渲一遍(**不阻塞响应**)。
62
+ *
63
+ * 渲染一张卡要 1.1~1.2 秒(无头 Chrome 现场画),而牌组通常只有 2~4 张。
64
+ * 设备是按上下键逐张翻的 —— 提前渲完,翻页就是缓存命中(几毫秒),
65
+ * 而不是每按一次都等一整次渲染。
66
+ *
67
+ * ⚠ 串行执行:同时拉起多个 Chrome 会把机器压住,反而拖慢正在等的那一次。
68
+ * ⚠ 预热失败不影响任何功能(用户翻到那张时照样会现场渲一次),所以这里吞掉异常。
69
+ */
70
+ let warmingDeck = false;
71
+ async function warmDeckInBackground(ctx, count, skipIndex, battery, dataDir) {
72
+ if (warmingDeck)
73
+ return;
74
+ warmingDeck = true;
75
+ try {
76
+ for (let i = 0; i < count; i++) {
77
+ if (i === skipIndex)
78
+ continue;
79
+ await renderCardAt(ctx, i, dataDir, { battery });
80
+ }
81
+ }
82
+ catch {
83
+ /* 预热是优化,失败不需要让任何人知道 */
84
+ }
85
+ finally {
86
+ warmingDeck = false;
87
+ }
88
+ }
89
+ /**
90
+ * 处理 `/api/device/*`。返回 true 表示已经响应(调用方不要再走后面的路由)。
91
+ */
92
+ export async function handleDeviceRequest(args) {
93
+ const { ctx, req, res, path, url, cfg } = args;
94
+ if (!path.startsWith(DEVICE_PREFIX))
95
+ return false;
96
+ const lanEnabled = isDeviceLanEnabled(cfg, ctx());
97
+ const c = ctx();
98
+ const access = checkDeviceAccess({
99
+ lanEnabled,
100
+ expected: deviceToken(c),
101
+ provided: extractToken(req.headers['x-device-token'], url.searchParams.get('token')),
102
+ });
103
+ if (access === 'NOT_CONFIGURED') {
104
+ return sendJson(res, 503, err('DEVICE_NOT_CONFIGURED', '局域网访问已开启但还没有设备令牌。在电脑上运行 `ai-growth device --rotate` 生成一个。')), true;
105
+ }
106
+ if (access === 'TOKEN_REQUIRED' || access === 'TOKEN_INVALID') {
107
+ return sendJson(res, 401, err(access, access === 'TOKEN_REQUIRED'
108
+ ? '缺少设备令牌。请求头 X-Device-Token 或查询参数 token 必填。'
109
+ : '设备令牌不正确。在电脑上运行 `ai-growth device` 查看当前令牌。')), true;
110
+ }
111
+ const m = path.slice(DEVICE_PREFIX.length);
112
+ if (m === '/ping' && req.method === 'GET') {
113
+ return sendJson(res, 200, ok({
114
+ service: SERVICE_NAME,
115
+ today: todayIn(c),
116
+ timezone: resolveTimezone(c),
117
+ lan: lanEnabled,
118
+ })), true;
119
+ }
120
+ if (m === '/deck' && req.method === 'GET') {
121
+ runDailyRollover(c); // 与 /api/today 同一保证:设备永远看到"今天",不会看到昨日残留
122
+ return sendJson(res, 200, ok(deckSummary(c, { battery: parseBattery(url.searchParams.get('bat')) }))), true;
123
+ }
124
+ if (m === '/status' && req.method === 'GET') {
125
+ runDailyRollover(c);
126
+ const s = deckSummary(c, { battery: parseBattery(url.searchParams.get('bat')) });
127
+ return sendJson(res, 200, ok({
128
+ today: s.today,
129
+ dateLabel: s.dateLabel,
130
+ deckId: s.deckId,
131
+ count: s.count,
132
+ doneCount: s.doneCount,
133
+ total: s.total,
134
+ stage: s.stage,
135
+ nextActionId: s.cards.find((x) => x.actionId)?.actionId ?? null,
136
+ })), true;
137
+ }
138
+ if (m === '/card' && req.method === 'GET') {
139
+ const raw = url.searchParams.get('i');
140
+ if (raw === null) {
141
+ return sendJson(res, 400, err('VALIDATION_ERROR', '缺少参数 i(第几张卡)')), true;
142
+ }
143
+ const index = Number(raw);
144
+ if (!Number.isInteger(index) || index < 0) {
145
+ return sendJson(res, 400, err('VALIDATION_ERROR', `参数 i 非法:${raw}`)), true;
146
+ }
147
+ runDailyRollover(c);
148
+ const battery = parseBattery(url.searchParams.get('bat'));
149
+ const card = await renderCardAt(c, index, cfg.dataDir, { battery });
150
+ if (!card) {
151
+ return sendJson(res, 404, err('NOT_FOUND', `卡序号 ${index} 越界(牌组可能刚变了,重新取 0 号即可)`)), true;
152
+ }
153
+ // 预热牌组里其它卡:设备按上下键翻页时就能命中缓存(几毫秒),而不是每次等 1.2 秒渲染。
154
+ // 故意不 await —— 它跑在后台,本次响应立刻返回。
155
+ void warmDeckInBackground(c, deckSummary(c, { battery }).count, index, battery, cfg.dataDir);
156
+ return sendBinary(res, card.payload), true;
157
+ }
158
+ if (m === '/action' && req.method === 'POST') {
159
+ const input = await readBody(req);
160
+ const actionId = typeof input.actionId === 'string' ? input.actionId : '';
161
+ const op = input.op;
162
+ const occurred = typeof input.occurredDateKey === 'string' ? input.occurredDateKey : undefined;
163
+ const reason = typeof input.reason === 'string' ? input.reason : undefined;
164
+ const result = applyDeviceReport(c, { actionId, op, occurredDateKey: occurred, reason });
165
+ const status = result.outcome === 'CONFLICT' ? 409 : 200;
166
+ return sendJson(res, status, ok(result)), true;
167
+ }
168
+ return sendJson(res, 404, err('NOT_FOUND', `未知的设备接口:${path}`)), true;
169
+ }
170
+ export { DomainError };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 极简 PNG 解码器 —— 只为解 Chrome headless 的 `--screenshot` 输出。
3
+ *
4
+ * 为什么自己写:设备卡是**服务端渲染**的(固件不碰中文字体),渲染管线是
5
+ * Chrome 截图 → PNG → RGB565。为这一个用途引入 native 依赖不值得,
6
+ * 而 PNG 解码所需的 inflate 是 Node 内置 zlib 就有的。
7
+ *
8
+ * 支持范围(正好覆盖 Chrome 的截图输出,实测为色彩类型 2/6、位深 8、非隔行):
9
+ * - 色彩类型 2(RGB)、6(RGBA)
10
+ * - 位深 8
11
+ * - 非隔行(interlace = 0)
12
+ * - 全部 5 种扫描线滤波器
13
+ * 超出范围一律抛错,不做"尽力而为"的静默降级。
14
+ */
15
+ import { inflateSync } from 'node:zlib';
16
+ const SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
17
+ function paeth(a, b, c) {
18
+ const p = a + b - c;
19
+ const pa = Math.abs(p - a);
20
+ const pb = Math.abs(p - b);
21
+ const pc = Math.abs(p - c);
22
+ if (pa <= pb && pa <= pc)
23
+ return a;
24
+ return pb <= pc ? b : c;
25
+ }
26
+ export function decodePng(buf) {
27
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
28
+ if (b.length < 8 + 25)
29
+ throw new Error('PNG_DECODE: buffer too small');
30
+ for (let i = 0; i < 8; i++)
31
+ if (b[i] !== SIG[i])
32
+ throw new Error('PNG_DECODE: bad signature');
33
+ let width = 0;
34
+ let height = 0;
35
+ let bitDepth = 0;
36
+ let colorType = 0;
37
+ let interlace = 0;
38
+ const idat = [];
39
+ let off = 8;
40
+ let sawIhdr = false;
41
+ while (off + 8 <= b.length) {
42
+ const len = b.readUInt32BE(off);
43
+ const type = b.toString('ascii', off + 4, off + 8);
44
+ const body = off + 8;
45
+ if (body + len + 4 > b.length)
46
+ throw new Error('PNG_DECODE: truncated chunk');
47
+ if (type === 'IHDR') {
48
+ if (len !== 13)
49
+ throw new Error('PNG_DECODE: bad IHDR length');
50
+ width = b.readUInt32BE(body);
51
+ height = b.readUInt32BE(body + 4);
52
+ bitDepth = b[body + 8];
53
+ colorType = b[body + 9];
54
+ interlace = b[body + 12];
55
+ sawIhdr = true;
56
+ }
57
+ else if (type === 'IDAT') {
58
+ idat.push(b.subarray(body, body + len));
59
+ }
60
+ else if (type === 'IEND') {
61
+ break;
62
+ }
63
+ off = body + len + 4;
64
+ }
65
+ if (!sawIhdr)
66
+ throw new Error('PNG_DECODE: no IHDR');
67
+ if (!idat.length)
68
+ throw new Error('PNG_DECODE: no IDAT');
69
+ if (interlace !== 0)
70
+ throw new Error('PNG_DECODE: interlaced PNG unsupported');
71
+ if (bitDepth !== 8)
72
+ throw new Error(`PNG_DECODE: bit depth ${bitDepth} unsupported (need 8)`);
73
+ if (colorType !== 2 && colorType !== 6) {
74
+ throw new Error(`PNG_DECODE: color type ${colorType} unsupported (need 2 or 6)`);
75
+ }
76
+ if (width <= 0 || height <= 0)
77
+ throw new Error('PNG_DECODE: zero-sized image');
78
+ const channels = colorType === 6 ? 4 : 3;
79
+ const stride = width * channels;
80
+ const raw = inflateSync(Buffer.concat(idat));
81
+ const expected = height * (stride + 1);
82
+ if (raw.length < expected) {
83
+ throw new Error(`PNG_DECODE: inflated ${raw.length} bytes, expected ${expected}`);
84
+ }
85
+ const out = new Uint8Array(stride * height);
86
+ let prev = new Uint8Array(stride);
87
+ for (let y = 0; y < height; y++) {
88
+ const ft = raw[y * (stride + 1)];
89
+ const line = raw.subarray(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride);
90
+ const cur = out.subarray(y * stride, (y + 1) * stride);
91
+ for (let x = 0; x < stride; x++) {
92
+ const a = x >= channels ? cur[x - channels] : 0;
93
+ const bb = prev[x];
94
+ const c = x >= channels ? prev[x - channels] : 0;
95
+ let v = line[x];
96
+ if (ft === 1)
97
+ v += a;
98
+ else if (ft === 2)
99
+ v += bb;
100
+ else if (ft === 3)
101
+ v += (a + bb) >> 1;
102
+ else if (ft === 4)
103
+ v += paeth(a, bb, c);
104
+ else if (ft !== 0)
105
+ throw new Error(`PNG_DECODE: unknown filter type ${ft} on row ${y}`);
106
+ cur[x] = v & 0xff;
107
+ }
108
+ prev = cur;
109
+ }
110
+ return { width, height, channels, data: out };
111
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 一条命令把 AI Passport 配好,并**自己确认成功**。
3
+ *
4
+ * 为什么需要它(产品层面的理由):这仓库要作为「应用」发布到 FoloToy 玩法社区。
5
+ * 如果用户刷完之后还得跑几个脚本、或者让 Agent 一步步读日志排查,那就不是能发布的东西。
6
+ * 所以这里把整条链路收成一个命令,而且**验证也自己做掉** ——
7
+ * 前提是设备固件开机就直接进卡页并自动取卡(见固件 main.c 的 APP_DEMO_INDEX),
8
+ * 于是"配置→重启→取卡"的结论会直接出现在串口日志里,不需要用户按任何键。
9
+ *
10
+ * ⚠ 一个必须知道的前提:**串口控制台是 ASCII 通道**。
11
+ * ESP-IDF 的 esp_console 在把输入交给命令前会过一条 sanitize(),
12
+ * 它只保留 isprint() 为真的字节,中文(UTF-8 的 0x80~0xFF)会被整段丢掉。
13
+ * 所以凭据含非 ASCII(或空格)时必须走 growth_set_hex,用 hex 把字节送过去。
14
+ * 这里自动判断该走哪条,调用方不用关心。
15
+ */
16
+ import { request } from 'node:http';
17
+ import { DomainError } from '../shared/result.js';
18
+ import { findSerialPorts, SerialSession } from './serial.js';
19
+ /**
20
+ * 本机 apiPort 上有没有服务在跑。
21
+ *
22
+ * 为什么要单独探一下:如果服务没起来,设备**一定**取不到卡,但那种失败要等到
23
+ * 串口日志里出现"取卡失败"才看得出来(几十秒后),而且报出来的原因是 Wi-Fi 层的,
24
+ * 会把人往错方向带。所以在动手之前先问一句,失败就直接说清是服务没跑。
25
+ * 401 也算"活着"——那说明服务在,只是我们没带令牌。
26
+ */
27
+ export function probeService(port, timeoutMs = 1500) {
28
+ return new Promise((resolve) => {
29
+ const req = request({ host: '127.0.0.1', port, path: '/api/device/ping', method: 'GET', timeout: timeoutMs }, (res) => { res.resume(); resolve(true); });
30
+ req.on('timeout', () => { req.destroy(); resolve(false); });
31
+ req.on('error', () => resolve(false));
32
+ req.end();
33
+ });
34
+ }
35
+ /**
36
+ * 固件侧 esp_console 的命令行上限(对应 growth_config.c 的 max_cmdline_length)。
37
+ *
38
+ * ⚠ 为什么要在宿主侧也拦一次:命令行编辑器**超长是静默丢字符**的 ——
39
+ * 命令照样能被解析,只是末尾少几个字符。最典型的表现是令牌被截断(中文 Wi-Fi 名
40
+ * 走 hex 后一行会明显变长,我们实测刚好 129 字符 > 当时的 128 上限),
41
+ * 于是设备一直回 401,而设备日志里只有"HTTP 401 令牌不对",完全看不出是被截了。
42
+ * 与其让用户去猜,不如在发出去之前就拦住并说清楚。
43
+ */
44
+ export const MAX_DEVICE_CMDLINE = 512;
45
+ /** 留出余量:编辑器在临界点上的行为不值得去试 */
46
+ export function commandFits(line) {
47
+ return line.length <= MAX_DEVICE_CMDLINE - 8;
48
+ }
49
+ /** 串口命令的单个参数:纯 ASCII 且不含空格才能直接传,否则必须走 hex */
50
+ function isConsoleSafe(s) {
51
+ return /^[\x20-\x7e]*$/.test(s) && !s.includes(' ');
52
+ }
53
+ function toHex(s) {
54
+ return Buffer.from(s, 'utf8').toString('hex');
55
+ }
56
+ function pickPort(requested) {
57
+ const candidates = findSerialPorts();
58
+ if (requested)
59
+ return { path: requested, candidates };
60
+ if (candidates.length === 0) {
61
+ throw new DomainError('DEVICE_NOT_FOUND', '没有找到串口设备。请确认:①设备已开机 ②用的是能传数据的 USB 线(不是仅充电线)③直接插电脑而不是走 hub。');
62
+ }
63
+ if (candidates.length > 1) {
64
+ throw new DomainError('MULTIPLE_DEVICES', `找到多个串口:${candidates.map((c) => c.path).join('、')}。用 --port 明确指定要配哪一个,避免配错设备。`);
65
+ }
66
+ return { path: candidates[0].path, candidates };
67
+ }
68
+ /** 从 growth_status 的输出里抽出已经配置好的 ssid(没有则 null) */
69
+ function parseSsid(statusText) {
70
+ const m = statusText.match(/wifi ssid\s*:\s*(.*)/);
71
+ if (!m)
72
+ return null;
73
+ const v = m[1].trim();
74
+ return v && v !== '(none)' ? v : null;
75
+ }
76
+ export async function provisionDevice(input) {
77
+ const steps = [];
78
+ const evidence = [];
79
+ const { path, candidates } = pickPort(input.serialPath);
80
+ const session = SerialSession.open(path);
81
+ try {
82
+ // ① 开端口会让设备复位(USB-Serial-JTAG),先把可能涌出的启动日志吃掉。
83
+ const boot = await session.drain(1500);
84
+ if (boot)
85
+ evidence.push(...boot.split('\n').filter((l) => l.trim()).slice(0, 3));
86
+ steps.push({ name: '找到串口并打开', ok: true, detail: `${path}(候选 ${candidates.length} 个)` });
87
+ // ② 读当前配置:判断"是不是已经配过了",也确认控制台活着
88
+ const status = await session.sendAndWait('growth_status', ['configured :'], 6000);
89
+ if (!status.matched) {
90
+ throw new DomainError('DEVICE_NO_CONSOLE', '设备没有回应串口命令。可能是固件不是这一版(缺 AI Growth 页),或者串口被别的程序占着(网页刷机工具/monitor 都会占)。');
91
+ }
92
+ const previousSsid = parseSsid(status.text);
93
+ steps.push({
94
+ name: '读取设备当前配置',
95
+ ok: true,
96
+ detail: previousSsid ? `已配置过:${previousSsid}` : '尚未配置',
97
+ });
98
+ if (/wifi ssid/.test(status.text))
99
+ evidence.push(...status.text.split('\n').filter((l) => l.startsWith('wifi ssid')));
100
+ // ③ 写配置。凭据含非 ASCII/空格 → 必须 hex,否则控制台会把字节丢掉
101
+ const needsHex = !isConsoleSafe(input.ssid) || !isConsoleSafe(input.password);
102
+ const usedCommand = needsHex ? 'growth_set_hex' : 'growth_set';
103
+ const ssidArg = needsHex ? toHex(input.ssid) : input.ssid;
104
+ const passArg = needsHex ? toHex(input.password) : (input.password === '' ? '""' : input.password);
105
+ const line = [
106
+ usedCommand, ssidArg, passArg, input.host, String(input.apiPort), input.token,
107
+ ].join(' ');
108
+ if (!commandFits(line)) {
109
+ throw new DomainError('COMMAND_TOO_LONG', `这条配置命令有 ${line.length} 字符,超过设备的串口命令行上限(${MAX_DEVICE_CMDLINE})。`
110
+ + '超长会被设备**静默截断**(末尾少几个字符,典型后果是令牌不完整 → 设备一直 401)。'
111
+ + '请缩短 Wi-Fi 名,或先 `ai-growth device --rotate` 换一个更短的令牌后重试。');
112
+ }
113
+ const saved = await session.sendAndWait(line, ['saved.', 'error:'], 8000);
114
+ if (saved.matched !== 'saved.') {
115
+ const errLine = (saved.text.match(/error:[^\n]*/) ?? ['设备没有回应写入命令'])[0];
116
+ steps.push({ name: '写入配置', ok: false, detail: errLine });
117
+ throw new DomainError('DEVICE_WRITE_FAILED', `设备拒绝了配置:${errLine}`);
118
+ }
119
+ steps.push({
120
+ name: '写入配置',
121
+ ok: true,
122
+ detail: `${usedCommand}${needsHex ? '(含非 ASCII/空格,已用 hex 编码绕过控制台的 ASCII 限制)' : ''}`,
123
+ });
124
+ if (input.skipVerify) {
125
+ return { serialPath: path, usedCommand, previousSsid, verdict: 'UNKNOWN', ok: true, steps, evidence };
126
+ }
127
+ // ④ 设备会立刻重启、开机直接进卡页并自动取卡 —— 结论就在日志里,不需要用户按键。
128
+ // 这几个 pattern 就是固件自己的结论行。
129
+ const verdictWait = input.verifyTimeoutMs ?? 30000;
130
+ const log = await session.drain(verdictWait);
131
+ const lines = log.split('\n').map((l) => l.trim()).filter(Boolean);
132
+ let verdict = 'UNKNOWN';
133
+ if (/显示第\s*\d+\s*\/\s*\d+\s*张/.test(log))
134
+ verdict = 'FETCHED';
135
+ else if (/连不上 Wi-Fi/.test(log))
136
+ verdict = 'WIFI_FAIL';
137
+ else if (/取卡失败/.test(log))
138
+ verdict = 'FETCH_FAIL';
139
+ else if (/还没配置/.test(log))
140
+ verdict = 'NOT_CONFIGURED';
141
+ for (const re of [/显示第.*/, /连不上 Wi-Fi.*/, /取卡失败.*/, /还没配置.*/, /wifi:mode.*/]) {
142
+ const hit = lines.find((l) => re.test(l));
143
+ if (hit)
144
+ evidence.push(hit);
145
+ }
146
+ const result = {
147
+ serialPath: path,
148
+ usedCommand,
149
+ previousSsid,
150
+ verdict,
151
+ ok: verdict === 'FETCHED',
152
+ steps,
153
+ evidence,
154
+ };
155
+ if (verdict === 'FETCHED') {
156
+ steps.push({ name: '设备端确认取卡成功', ok: true, detail: '日志里出现「显示第 N/M 张」' });
157
+ }
158
+ else {
159
+ steps.push({ name: '设备端确认取卡', ok: false, detail: `结论:${verdict}` });
160
+ result.nextAction = nextActionFor(verdict, input);
161
+ }
162
+ return result;
163
+ }
164
+ finally {
165
+ session.close();
166
+ }
167
+ }
168
+ /** 把设备端的失败结论翻译成"下一步该干什么",让 Agent 不用自己猜 */
169
+ function nextActionFor(verdict, input) {
170
+ switch (verdict) {
171
+ case 'WIFI_FAIL':
172
+ return `设备连不上 Wi-Fi「${input.ssid}」。两种最常见原因:`
173
+ + '① 这个网络只有 5GHz —— ESP32-C3 只支持 2.4GHz(很多路由器会用同一个名字同时广播 2.4G/5G,那就没问题,'
174
+ + '先确认名字在 2.4G 上也有);② 密码不对。改完重跑本命令即可。';
175
+ case 'FETCH_FAIL':
176
+ return `Wi-Fi 通了,但设备够不到电脑(${input.host}:${input.apiPort})。检查:`
177
+ + '① 服务是否以局域网模式在跑(AI_GROWTH_DEVICE_LAN=1 ai-growth serve;ai-growth device 会显示状态);'
178
+ + '② 电脑与设备是否在同一个 Wi-Fi/局域网;③ 令牌是否与设备里的一致。';
179
+ case 'NOT_CONFIGURED':
180
+ return '设备仍认为未配置,说明配置没写进去。重跑本命令;仍失败就把串口日志发出来。';
181
+ default:
182
+ return '在等待窗口内没有看到设备的取卡结论。设备可能还在开机或卡在某一步 —— '
183
+ + '再跑一次本命令,或查看串口实时日志定位。';
184
+ }
185
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * 设备卡渲染管线:HTML → Chrome headless 截图 → PNG → RGB565 → 磁盘缓存。
3
+ *
4
+ * 为什么走「服务端出图」而不是让设备自己画:
5
+ * AI Passport 默认的 Montserrat 字体没有中文字形,而目标标题是用户自由输入的
6
+ * 任意汉字(官方文档把这种情形定义为"动态内容需明确的字符契约"的高危场景)。
7
+ * 服务端把整张卡渲染成像素,固件就完全不必碰字库/字形覆盖。
8
+ *
9
+ * 安全边界说明:Chrome 只读一个临时 HTML、只写一个临时 PNG,两个路径都在
10
+ * 系统临时目录,不涉及用户的 Desktop/Documents/Downloads。
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { tmpdir } from 'node:os';
17
+ import { decodePng } from './png.js';
18
+ export const CARD_WIDTH = 240;
19
+ export const CARD_HEIGHT = 320;
20
+ /** 单张卡的渲染上限。Chrome 冷启动 + 字体加载实测 1~2 秒,留足余量后硬超时。 */
21
+ const RENDER_TIMEOUT_MS = Number(process.env.AI_GROWTH_RENDER_TIMEOUT_MS ?? 20000);
22
+ const CHROME_CANDIDATES = [
23
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
24
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
25
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
26
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
27
+ ];
28
+ export function findChrome() {
29
+ const override = process.env.AI_GROWTH_CHROME;
30
+ if (override && existsSync(override))
31
+ return override;
32
+ for (const p of CHROME_CANDIDATES)
33
+ if (existsSync(p))
34
+ return p;
35
+ return null;
36
+ }
37
+ /** RGBA/RGB 像素 → RGB565 小端字节流(面板原生格式) */
38
+ export function toRgb565(png) {
39
+ const { width, height, channels, data } = png;
40
+ const out = Buffer.allocUnsafe(width * height * 2);
41
+ let o = 0;
42
+ for (let i = 0; i < width * height; i++) {
43
+ const s = i * channels;
44
+ const r = data[s];
45
+ const g = data[s + 1];
46
+ const b = data[s + 2];
47
+ const v = ((r & 0xf8) << 8) | ((g & 0xfc) << 3) | (b >> 3);
48
+ out[o++] = v & 0xff;
49
+ out[o++] = (v >> 8) & 0xff;
50
+ }
51
+ return out;
52
+ }
53
+ /**
54
+ * 用 Chrome headless 把一段 HTML 渲染成 PNG。
55
+ * 每张卡一次进程调用(约 1 秒);上层按内容哈希做磁盘缓存,所以只会付一次。
56
+ *
57
+ * size 默认是设备卡的 240×320;字形覆盖检查要渲染更大的网格,所以开放出来。
58
+ * 调用方必须保证 HTML 的布局尺寸与这里一致(否则截出来的图会被裁掉)。
59
+ */
60
+ export function screenshotPng(html, size = { width: CARD_WIDTH, height: CARD_HEIGHT }) {
61
+ const chrome = findChrome();
62
+ if (!chrome) {
63
+ return Promise.reject(new Error('CHROME_NOT_FOUND: 渲染设备卡需要 Chrome/Chromium。可用 AI_GROWTH_CHROME 指定可执行文件路径。'));
64
+ }
65
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
66
+ const htmlPath = join(tmpdir(), `aig-card-${stamp}.html`);
67
+ const pngPath = join(tmpdir(), `aig-card-${stamp}.png`);
68
+ const profileDir = join(tmpdir(), `aig-chrome-${stamp}`);
69
+ writeFileSync(htmlPath, html, 'utf8');
70
+ const args = [
71
+ '--headless=new',
72
+ '--disable-gpu',
73
+ '--no-sandbox',
74
+ '--no-first-run',
75
+ '--no-default-browser-check',
76
+ '--disable-extensions',
77
+ '--hide-scrollbars',
78
+ '--force-device-scale-factor=1',
79
+ `--user-data-dir=${profileDir}`,
80
+ `--window-size=${size.width},${size.height}`,
81
+ // 给字体加载与首帧留出确定的时间预算,避免截到空白帧
82
+ '--virtual-time-budget=1500',
83
+ `--screenshot=${pngPath}`,
84
+ `file://${htmlPath}`,
85
+ ];
86
+ return new Promise((resolve, reject) => {
87
+ // detached:Chrome 出图后不一定自己退出(实测会一直挂着),
88
+ // 所以不能等 close 事件,必须轮询截图文件并在拿到后主动杀整个进程组。
89
+ const child = spawn(chrome, args, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
90
+ let err = '';
91
+ child.stderr?.on('data', (d) => { err += String(d); });
92
+ let settled = false;
93
+ const cleanup = () => {
94
+ try {
95
+ process.kill(-child.pid, 'SIGKILL');
96
+ }
97
+ catch { /* 已退出 */ }
98
+ try {
99
+ child.kill('SIGKILL');
100
+ }
101
+ catch { /* 已退出 */ }
102
+ for (const p of [htmlPath, pngPath]) {
103
+ try {
104
+ rmSync(p, { force: true });
105
+ }
106
+ catch { /* ignore */ }
107
+ }
108
+ try {
109
+ rmSync(profileDir, { recursive: true, force: true });
110
+ }
111
+ catch { /* ignore */ }
112
+ };
113
+ const fail = (e) => {
114
+ if (settled)
115
+ return;
116
+ settled = true;
117
+ cleanup();
118
+ reject(e);
119
+ };
120
+ const done = (buf) => {
121
+ if (settled)
122
+ return;
123
+ settled = true;
124
+ cleanup();
125
+ resolve(buf);
126
+ };
127
+ child.on('error', (e) => fail(e));
128
+ const deadline = Date.now() + RENDER_TIMEOUT_MS;
129
+ let lastSize = -1;
130
+ let stable = 0;
131
+ const poll = () => {
132
+ if (settled)
133
+ return;
134
+ if (Date.now() > deadline) {
135
+ fail(new Error(`CHROME_TIMEOUT: ${RENDER_TIMEOUT_MS}ms 内没拿到截图。${err.slice(-300)}`));
136
+ return;
137
+ }
138
+ try {
139
+ if (existsSync(pngPath)) {
140
+ const size = statSync(pngPath).size;
141
+ // 连续两次大小一致才认为写完了(避免读到半个文件)
142
+ if (size > 0 && size === lastSize)
143
+ stable++;
144
+ else
145
+ stable = 0;
146
+ lastSize = size;
147
+ if (stable >= 2) {
148
+ done(readFileSync(pngPath));
149
+ return;
150
+ }
151
+ }
152
+ }
153
+ catch (e) {
154
+ fail(e instanceof Error ? e : new Error(String(e)));
155
+ return;
156
+ }
157
+ setTimeout(poll, 80);
158
+ };
159
+ poll();
160
+ });
161
+ }
162
+ function cacheDir(dataDir) {
163
+ const d = join(dataDir, 'device-cache');
164
+ mkdirSync(d, { recursive: true });
165
+ return d;
166
+ }
167
+ /**
168
+ * 渲染一张卡并落到磁盘缓存。key 由调用方按「内容 + 状态」算好,
169
+ * 所以同一张卡(哪怕同一天多次看)只会真正渲染一次。
170
+ */
171
+ export async function renderCardCached(html, key, dataDir) {
172
+ const hash = createHash('sha256').update(html).digest('hex').slice(0, 24);
173
+ const file = join(cacheDir(dataDir), `${hash}.rgb565`);
174
+ if (existsSync(file)) {
175
+ const pixels = readFileSync(file);
176
+ if (pixels.length === CARD_WIDTH * CARD_HEIGHT * 2) {
177
+ return { pixels, width: CARD_WIDTH, height: CARD_HEIGHT, cached: true };
178
+ }
179
+ // 尺寸不对说明缓存来自旧版本,丢掉重渲
180
+ try {
181
+ rmSync(file, { force: true });
182
+ }
183
+ catch { /* ignore */ }
184
+ }
185
+ const png = await screenshotPng(html);
186
+ const decoded = decodePng(png);
187
+ if (decoded.width !== CARD_WIDTH || decoded.height !== CARD_HEIGHT) {
188
+ throw new Error(`CARD_SIZE_MISMATCH: 渲染出 ${decoded.width}x${decoded.height},期望 ${CARD_WIDTH}x${CARD_HEIGHT}`);
189
+ }
190
+ const pixels = toRgb565(decoded);
191
+ writeFileSync(file, pixels);
192
+ void key;
193
+ return { pixels, width: CARD_WIDTH, height: CARD_HEIGHT, cached: false };
194
+ }