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.
- package/.env.example +19 -0
- package/AGENTS.md +232 -0
- package/IMPLEMENTATION_PLAN.md +207 -0
- package/LICENSE +21 -0
- package/PRODUCT_SPEC.md +1776 -0
- package/README.md +384 -0
- package/SKILL.md +121 -0
- package/START-HERE.md +156 -0
- package/dist/api/app.js +206 -0
- package/dist/api/server.js +42 -0
- package/dist/cli/main.js +799 -0
- package/dist/core/actions.js +264 -0
- package/dist/core/context.js +7 -0
- package/dist/core/directions.js +26 -0
- package/dist/core/evidence.js +155 -0
- package/dist/core/goals.js +188 -0
- package/dist/core/lifeContext.js +41 -0
- package/dist/core/memory.js +35 -0
- package/dist/core/metrics.js +74 -0
- package/dist/core/profile.js +95 -0
- package/dist/core/progress.js +244 -0
- package/dist/core/prompts/index.js +40 -0
- package/dist/core/replan.js +100 -0
- package/dist/core/rollover.js +68 -0
- package/dist/core/rolloverTypes.js +1 -0
- package/dist/core/stateMachine.js +61 -0
- package/dist/core/status.js +57 -0
- package/dist/core/types.js +9 -0
- package/dist/daemon/main.js +64 -0
- package/dist/db/cli.js +19 -0
- package/dist/db/client.js +36 -0
- package/dist/db/migrate.js +34 -0
- package/dist/db/migrations/V1__init.sql +249 -0
- package/dist/device/card.js +257 -0
- package/dist/device/deck.js +157 -0
- package/dist/device/fontCoverage.js +162 -0
- package/dist/device/http.js +170 -0
- package/dist/device/png.js +111 -0
- package/dist/device/provision.js +185 -0
- package/dist/device/render.js +194 -0
- package/dist/device/renderCard.js +48 -0
- package/dist/device/report.js +146 -0
- package/dist/device/serial.js +201 -0
- package/dist/device/token.js +78 -0
- package/dist/device/types.js +2 -0
- package/dist/mcp/ctx.js +48 -0
- package/dist/mcp/server.js +15 -0
- package/dist/mcp/tools.js +586 -0
- package/dist/passport/builder.js +83 -0
- package/dist/passport/state.js +81 -0
- package/dist/passport/sync.js +61 -0
- package/dist/reminder/notify.js +30 -0
- package/dist/reminder/scheduler.js +87 -0
- package/dist/reminder/service.js +102 -0
- package/dist/shared/config.js +50 -0
- package/dist/shared/result.js +27 -0
- package/dist/shared/time.js +67 -0
- package/dist/shared/util.js +21 -0
- package/dist/ui/app.js +500 -0
- package/dist/ui/index.html +38 -0
- package/dist/ui/style.css +367 -0
- package/docs/images/cards.png +0 -0
- package/docs/images/cover.png +0 -0
- package/launchd/com.lairey.ai-growth.daemon.plist +45 -0
- package/llms.txt +52 -0
- package/package.json +79 -0
- package/scripts/check-device-cards.mjs +177 -0
- package/scripts/doctor.sh +194 -0
- package/scripts/gen-device-assets.mjs +188 -0
- package/scripts/install.sh +291 -0
- package/scripts/preview-cards.mjs +120 -0
- package/scripts/reset-dev-data.sh +92 -0
- package/scripts/smoke-device.mjs +401 -0
- package/scripts/uninstall.sh +63 -0
- package/server.json +56 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
const ESC = {
|
|
2
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
3
|
+
};
|
|
4
|
+
/** 目标/任务标题来自用户输入,进 HTML 前必须转义 */
|
|
5
|
+
export function esc(s) {
|
|
6
|
+
return String(s).replace(/[&<>"']/g, (c) => ESC[c]);
|
|
7
|
+
}
|
|
8
|
+
const CSS = `
|
|
9
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
10
|
+
html,body{width:240px;height:320px;background:#000;overflow:hidden;
|
|
11
|
+
font-family:"PingFang SC","Hiragino Sans GB","Heiti SC","Microsoft YaHei",sans-serif;
|
|
12
|
+
-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
|
|
13
|
+
.card{position:relative;width:240px;height:320px;border-radius:30px;overflow:hidden;color:#eaf4ff;
|
|
14
|
+
background:radial-gradient(130% 85% at 18% -5%,#13324b 0%,#0c1c2b 52%,#060d15 100%)}
|
|
15
|
+
.card::after{content:"";position:absolute;inset:0;border-radius:30px;
|
|
16
|
+
box-shadow:inset 0 0 0 1px rgba(140,190,225,.13)}
|
|
17
|
+
.hd{display:flex;justify-content:space-between;align-items:center;
|
|
18
|
+
padding:15px 18px 0 18px;font-size:11px;color:#7fa8c4;letter-spacing:.2px}
|
|
19
|
+
.bat{display:flex;align-items:center;gap:3px}
|
|
20
|
+
.bat .b{width:22px;height:10px;border:1.5px solid #6f97b2;border-radius:3px;padding:1.5px}
|
|
21
|
+
.bat .b i{display:block;height:100%;border-radius:1px;background:#4ade80}
|
|
22
|
+
.bat .b i.low{background:#f87171}
|
|
23
|
+
.bat .b i.mid{background:#fbbf24}
|
|
24
|
+
.bat .c{width:2px;height:5px;background:#6f97b2;border-radius:0 1px 1px 0;margin-left:-3px}
|
|
25
|
+
.bat .n{font-size:10px;color:#89aec6}
|
|
26
|
+
.wrap{padding:14px 18px 0 18px}
|
|
27
|
+
.track{display:flex;align-items:center;gap:5px}
|
|
28
|
+
.nd{width:11px;height:11px;border-radius:3px;flex:none}
|
|
29
|
+
.nd.done{background:#4ade80}
|
|
30
|
+
.nd.cur{background:#ffd166;box-shadow:0 0 9px rgba(255,209,102,.55)}
|
|
31
|
+
.nd.todo{border:1.5px solid #35566b}
|
|
32
|
+
.stg{margin-top:9px;font-size:11.5px;color:#8fb6cf;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
33
|
+
.kick{font-size:9.5px;letter-spacing:1.6px;color:#ffd166;font-weight:500}
|
|
34
|
+
.kick.dim{color:#6f97b2}
|
|
35
|
+
.ttl{margin-top:9px;font-weight:600;line-height:1.34;word-break:break-word;
|
|
36
|
+
display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden}
|
|
37
|
+
.ttl.s1{font-size:23px}
|
|
38
|
+
.ttl.s2{font-size:19px}
|
|
39
|
+
.ttl.s3{font-size:16px}
|
|
40
|
+
.meta{margin-top:11px;font-size:11px;color:#7fa8c4;line-height:1.6;word-break:break-word;
|
|
41
|
+
display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:4;overflow:hidden}
|
|
42
|
+
.ft{position:absolute;left:0;right:0;bottom:0;padding:0 18px 15px 18px}
|
|
43
|
+
.bar{height:4px;border-radius:2px;background:#1c3547;overflow:hidden}
|
|
44
|
+
.bar i{display:block;height:100%;background:linear-gradient(90deg,#4ade80,#a3e635)}
|
|
45
|
+
.bar i.full{background:linear-gradient(90deg,#4ade80,#86efac)}
|
|
46
|
+
.hint{margin-top:9px;display:flex;justify-content:space-between;font-size:10px;color:#5d7f96}
|
|
47
|
+
.list{margin-top:13px;display:flex;flex-direction:column;gap:8px}
|
|
48
|
+
.row{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:1.35}
|
|
49
|
+
.tick{width:15px;height:15px;border-radius:5px;flex:none;margin-top:1px;position:relative;
|
|
50
|
+
border:1.5px solid #3d6277}
|
|
51
|
+
.row.ok .tick{background:#4ade80;border-color:#4ade80}
|
|
52
|
+
.row.ok .tick::after{content:"";position:absolute;left:4.5px;top:1.5px;width:4px;height:8px;
|
|
53
|
+
border:2px solid #062417;border-top:0;border-left:0;transform:rotate(42deg)}
|
|
54
|
+
.row.ok .tx{color:#5d7f96;text-decoration:line-through}
|
|
55
|
+
.row.now .tick{border-color:#ffd166}
|
|
56
|
+
.tx{flex:1;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;
|
|
57
|
+
-webkit-line-clamp:2;overflow:hidden}
|
|
58
|
+
.tag{margin-left:6px;font-size:9px;padding:1px 5px;border-radius:4px;
|
|
59
|
+
background:rgba(255,209,102,.16);color:#ffd166;vertical-align:1px}
|
|
60
|
+
.big{position:absolute;left:0;right:0;top:74px;text-align:center;padding:0 22px}
|
|
61
|
+
.ring{width:76px;height:76px;margin:0 auto;border-radius:50%;position:relative;
|
|
62
|
+
background:radial-gradient(circle at 50% 40%,rgba(74,222,128,.22),rgba(74,222,128,.04) 70%)}
|
|
63
|
+
.ring::after{content:"";position:absolute;left:26px;top:20px;width:19px;height:34px;
|
|
64
|
+
border:4px solid #4ade80;border-top:0;border-left:0;transform:rotate(42deg);border-radius:2px}
|
|
65
|
+
.big h2{margin-top:16px;font-size:22px;font-weight:600}
|
|
66
|
+
.big p{margin-top:9px;font-size:11.5px;color:#7fa8c4;line-height:1.7}
|
|
67
|
+
.facts{margin:16px 20px 0;display:flex;gap:8px;position:absolute;left:0;right:0;top:196px}
|
|
68
|
+
.fact{flex:1;text-align:center;background:rgba(120,180,220,.07);border-radius:10px;padding:9px 4px}
|
|
69
|
+
.fact b{display:block;font-size:17px;font-weight:600;color:#eaf4ff}
|
|
70
|
+
.fact span{font-size:9.5px;color:#7fa8c4}
|
|
71
|
+
/* 完成页:用 flex 纵向排布,避免绝对定位的统计卡把上面的文字压住(实测会重叠)。
|
|
72
|
+
⚠ 容器类名【不能】叫 .done —— 阶段节点轨道里的已完成格子是 .nd.done,
|
|
73
|
+
两个 .done 会互相命中:所有已完成的节点会被这条 position:absolute 抓住,
|
|
74
|
+
全部叠成卡片左上角的一个绿点,轨道上的进度就看不见了。
|
|
75
|
+
所以页级容器统一用与「状态」不重名的词(finish / big / facts)。 */
|
|
76
|
+
.finish{position:absolute;left:0;right:0;top:38px;bottom:44px;display:flex;flex-direction:column;
|
|
77
|
+
justify-content:space-between}
|
|
78
|
+
.finish .dc{display:flex;flex-direction:column;align-items:center;text-align:center;padding:0 22px}
|
|
79
|
+
.finish .dc h2{margin-top:16px;font-size:22px;font-weight:600}
|
|
80
|
+
.finish .dc p{margin-top:9px;font-size:11.5px;color:#7fa8c4;line-height:1.7}
|
|
81
|
+
.finish .facts{position:static;left:auto;right:auto;top:auto;margin:0 20px;display:flex;gap:8px}
|
|
82
|
+
.icon{width:60px;height:60px;margin:0 auto;position:relative;opacity:.95}
|
|
83
|
+
.icon.warn{border-radius:50%;background:rgba(251,191,36,.14)}
|
|
84
|
+
.icon.warn::after{content:"";position:absolute;left:28px;top:14px;width:4px;height:20px;
|
|
85
|
+
border-radius:2px;background:#fbbf24}
|
|
86
|
+
.icon.warn::before{content:"";position:absolute;left:28px;top:40px;width:4px;height:4px;
|
|
87
|
+
border-radius:50%;background:#fbbf24}
|
|
88
|
+
.icon.sync{border-radius:50%;background:rgba(127,168,196,.12)}
|
|
89
|
+
.icon.sync::after{content:"";position:absolute;left:17px;top:17px;width:26px;height:26px;
|
|
90
|
+
border-radius:50%;border:3px solid #7fa8c4;border-right-color:transparent;border-bottom-color:transparent;
|
|
91
|
+
transform:rotate(-45deg)}
|
|
92
|
+
.icon.sync::before{content:"";position:absolute;left:16px;top:11px;width:0;height:0;
|
|
93
|
+
border:6px solid transparent;border-bottom-color:#7fa8c4;border-top:0}
|
|
94
|
+
.spin{animation:none}
|
|
95
|
+
`;
|
|
96
|
+
function battery(soc) {
|
|
97
|
+
if (soc === null || soc < 0) {
|
|
98
|
+
return '<div class="bat"><div class="b"><i style="width:0"></i></div><div class="c"></div></div>';
|
|
99
|
+
}
|
|
100
|
+
const pct = Math.max(0, Math.min(100, Math.round(soc)));
|
|
101
|
+
const cls = pct < 20 ? 'low' : pct < 50 ? 'mid' : '';
|
|
102
|
+
return `<div class="bat"><div class="b"><i class="${cls}" style="width:${pct}%"></i></div><div class="c"></div><span class="n">${pct}%</span></div>`;
|
|
103
|
+
}
|
|
104
|
+
/** 节点轨道最多画这么多格,超了就只画当前阶段附近的窗口(真值看下面的文字) */
|
|
105
|
+
const TRACK_MAX = 12;
|
|
106
|
+
function track(stage) {
|
|
107
|
+
if (!stage)
|
|
108
|
+
return '';
|
|
109
|
+
const { total, order } = stage;
|
|
110
|
+
// 阶段数过多时轨道会横向溢出(240px 放不下);此时窗口化显示,
|
|
111
|
+
// 权威数字始终由下面那行「阶段 order/total · 标题」给出。
|
|
112
|
+
const shown = Math.min(total, TRACK_MAX);
|
|
113
|
+
const start = total > TRACK_MAX
|
|
114
|
+
? Math.min(Math.max(order - Math.floor(TRACK_MAX / 2), 1), total - TRACK_MAX + 1)
|
|
115
|
+
: 1;
|
|
116
|
+
const nodes = [];
|
|
117
|
+
for (let i = 0; i < shown; i++) {
|
|
118
|
+
const n = start + i;
|
|
119
|
+
const cls = n < order ? 'done' : n === order ? 'cur' : 'todo';
|
|
120
|
+
nodes.push(`<div class="nd ${cls}"></div>`);
|
|
121
|
+
}
|
|
122
|
+
return `<div class="wrap"><div class="track">${nodes.join('')}</div>
|
|
123
|
+
<div class="stg">阶段 ${order}/${total} · ${esc(stage.title)}</div></div>`;
|
|
124
|
+
}
|
|
125
|
+
/** 标题越长字号越小,保证一定放得下 */
|
|
126
|
+
function titleClass(t) {
|
|
127
|
+
const n = [...t].length;
|
|
128
|
+
if (n <= 12)
|
|
129
|
+
return 's1';
|
|
130
|
+
if (n <= 20)
|
|
131
|
+
return 's2';
|
|
132
|
+
return 's3';
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 卡壳。
|
|
136
|
+
* ⚠ dateLabel 为空时【不画日期】:固件内置兜底卡(未配置/离线/开机)是构建期渲染的,
|
|
137
|
+
* 而设备没有备电 RTC。把构建那天的日期画进图里,等于让设备天天说"今天是构建日"——
|
|
138
|
+
* 这正是铁律 8 要禁的伪造时间。时间只能来自服务端。
|
|
139
|
+
*/
|
|
140
|
+
function shell(inner, st) {
|
|
141
|
+
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
|
|
142
|
+
<style>${CSS}</style></head><body><div class="card">
|
|
143
|
+
<div class="hd"><div>${st.dateLabel ? esc(st.dateLabel) : ''}</div>${battery(st.battery)}</div>
|
|
144
|
+
${inner}
|
|
145
|
+
</div></body></html>`;
|
|
146
|
+
}
|
|
147
|
+
/** 今日总览:阶段轨道 + 条目列表 + 进度 */
|
|
148
|
+
function overview(st) {
|
|
149
|
+
const rows = st.items.map((it) => {
|
|
150
|
+
const cls = it.done ? 'ok' : it.kind === 'MAIN_QUEST' ? 'now' : '';
|
|
151
|
+
const tag = it.kind === 'MAIN_QUEST' ? '<span class="tag">主线</span>' : '';
|
|
152
|
+
return `<div class="row ${cls}"><div class="tick"></div><div class="tx">${esc(it.title)}${tag}</div></div>`;
|
|
153
|
+
}).join('');
|
|
154
|
+
const pct = st.total ? Math.round((st.doneCount / st.total) * 100) : 0;
|
|
155
|
+
return shell(`${track(st.stage)}
|
|
156
|
+
<div class="wrap"><div class="kick">今天要做的事</div>
|
|
157
|
+
<div class="list">${rows || '<div class="row"><div class="tx" style="color:#5d7f96">今天没有安排</div></div>'}</div></div>
|
|
158
|
+
<div class="ft"><div class="bar"><i style="width:${pct}%"></i></div>
|
|
159
|
+
<div class="hint"><span>完成 ${st.doneCount} / ${st.total}</span><span>上下键切换 · 确定查看</span></div></div>`, st);
|
|
160
|
+
}
|
|
161
|
+
/** 单条任务:大字标题 + 为什么今天 + 完成标准 */
|
|
162
|
+
function task(st, spec) {
|
|
163
|
+
const it = st.items[spec.itemIndex ?? 0];
|
|
164
|
+
if (!it)
|
|
165
|
+
return overview(st);
|
|
166
|
+
const isMain = it.kind === 'MAIN_QUEST';
|
|
167
|
+
const metaBits = [];
|
|
168
|
+
if (it.minutes)
|
|
169
|
+
metaBits.push(`约 ${it.minutes} 分钟`);
|
|
170
|
+
if (it.why)
|
|
171
|
+
metaBits.push(esc(it.why));
|
|
172
|
+
if (it.completionCriteria)
|
|
173
|
+
metaBits.push(`完成标准:${esc(it.completionCriteria)}`);
|
|
174
|
+
const pct = st.total ? Math.round((st.doneCount / st.total) * 100) : 0;
|
|
175
|
+
return shell(`${track(st.stage)}
|
|
176
|
+
<div class="wrap"><div class="kick${isMain ? '' : ' dim'}">${isMain ? '今天这一件事' : '今天的小事'}</div>
|
|
177
|
+
<div class="ttl ${titleClass(it.title)}">${esc(it.title)}</div>
|
|
178
|
+
${metaBits.length ? `<div class="meta">${metaBits.join('<br>')}</div>` : ''}</div>
|
|
179
|
+
<div class="ft"><div class="bar"><i style="width:${pct}%"></i></div>
|
|
180
|
+
<div class="hint"><span>完成 ${st.doneCount} / ${st.total}</span><span>${it.done ? '已完成' : '确定 = 完成'}</span></div></div>`, st);
|
|
181
|
+
}
|
|
182
|
+
/** 全部完成 */
|
|
183
|
+
function alldone(st) {
|
|
184
|
+
const ev = st.facts?.evidence ?? 0;
|
|
185
|
+
const mainDone = st.facts?.mainDone ?? 0;
|
|
186
|
+
return shell(`<div class="finish">
|
|
187
|
+
<div class="dc">
|
|
188
|
+
<div class="ring"></div>
|
|
189
|
+
<h2>今天做完了</h2>
|
|
190
|
+
<p>${esc(st.dateLabel)}${st.stage ? `<br>阶段推进到 ${st.stage.order}/${st.stage.total}` : '<br>继续保持'}</p>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="facts">
|
|
193
|
+
<div class="fact"><b>${st.doneCount}/${st.total}</b><span>今日任务</span></div>
|
|
194
|
+
<div class="fact"><b>${ev}</b><span>累积证据</span></div>
|
|
195
|
+
<div class="fact"><b>${mainDone}</b><span>主线完成</span></div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
<div class="ft"><div class="bar"><i class="full" style="width:100%"></i></div>
|
|
199
|
+
<div class="hint"><span>全部完成</span><span>上下键回看</span></div></div>`, st);
|
|
200
|
+
}
|
|
201
|
+
/** 今天还没排计划(能连上电脑,但 Agent 还没排) */
|
|
202
|
+
function noplan(st) {
|
|
203
|
+
return shell(`<div class="big">
|
|
204
|
+
<div class="icon warn"></div>
|
|
205
|
+
<h2>今天还没排计划</h2>
|
|
206
|
+
<p>回家跟 Agent 说一声<br>「开启今日任务」<br>它会把今天安排到这张卡上</p>
|
|
207
|
+
</div>
|
|
208
|
+
<div class="ft"><div class="hint" style="justify-content:center"><span>${esc(st.dateLabel)}</span></div></div>`, st);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 连不上电脑(固件内置兜底卡)。
|
|
212
|
+
* 这里刻意不写"上次同步:xx"——构建期渲染的卡不知道用户哪天开机,写死一个时间就是假的。
|
|
213
|
+
*/
|
|
214
|
+
function offline(st) {
|
|
215
|
+
return shell(`<div class="big">
|
|
216
|
+
<div class="icon sync"></div>
|
|
217
|
+
<h2>暂时连不上电脑</h2>
|
|
218
|
+
<p>回到家里同一个 Wi-Fi 就会自动同步<br>按「确定」可以立刻重试</p>
|
|
219
|
+
</div>
|
|
220
|
+
<div class="ft"><div class="hint"><span>确定 = 重试</span><span>上下键 = 翻卡</span></div></div>`, st);
|
|
221
|
+
}
|
|
222
|
+
/** 正在连接(固件内置兜底卡) */
|
|
223
|
+
function booting(st) {
|
|
224
|
+
return shell(`<div class="big" style="top:96px">
|
|
225
|
+
<p style="font-size:14px;font-weight:600;color:#eaf4ff;letter-spacing:.3px">FoloToy AI Passport</p>
|
|
226
|
+
<p style="font-size:11px;color:#7fa8c4;margin-top:8px">自驱动成长工具</p>
|
|
227
|
+
<div class="bar" style="margin:22px 14px 0 14px"><i style="width:38%"></i></div></div>
|
|
228
|
+
<div class="ft"><div class="hint" style="justify-content:center"><span>正在连接电脑…</span></div></div>`, st);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* 未配置(固件内置兜底卡)——告诉用户下一步做什么,而不是报错。
|
|
232
|
+
*
|
|
233
|
+
* ⚠ 文案必须指向**一条命令**:这条卡是刷完固件后开机第一眼看到的东西,
|
|
234
|
+
* 如果写"用串口把 Wi-Fi 填进来",用户就得装工具链、找串口、还躲不过
|
|
235
|
+
* "串口只能输 ASCII、中文 Wi-Fi 名填不进去"这个坑 —— 那就不是能发布的应用了。
|
|
236
|
+
* 所以直接给出 `ai-growth device setup`,那条命令会自动处理 hex 与验证。
|
|
237
|
+
*/
|
|
238
|
+
function setup(st) {
|
|
239
|
+
return shell(`<div class="big">
|
|
240
|
+
<div class="icon warn"></div>
|
|
241
|
+
<h2>还没连上电脑</h2>
|
|
242
|
+
<p>在电脑上跑一条命令就配好:<br>ai-growth device setup<br>--ssid "Wi-Fi名" --pass "密码"</p>
|
|
243
|
+
</div>
|
|
244
|
+
<div class="ft"><div class="hint" style="justify-content:center"><span>配好后会自动重启并取卡</span></div></div>`, st);
|
|
245
|
+
}
|
|
246
|
+
export function buildCardHtml(st, spec) {
|
|
247
|
+
switch (spec.kind) {
|
|
248
|
+
case 'overview': return overview(st);
|
|
249
|
+
case 'task': return task(st, spec);
|
|
250
|
+
case 'alldone': return alldone(st);
|
|
251
|
+
case 'noplan': return noplan(st);
|
|
252
|
+
case 'offline': return offline(st);
|
|
253
|
+
case 'booting': return booting(st);
|
|
254
|
+
case 'setup': return setup(st);
|
|
255
|
+
default: return overview(st);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设备「今日卡」的牌组模型与二进制载荷编码。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点:
|
|
5
|
+
* - 牌组由**服务端**决定(几张卡、每张是什么)。设备是纯显示器 + 遥控器,
|
|
6
|
+
* 只按 index 取图,不需要理解"什么是今日主线"。这样设备端逻辑极小、
|
|
7
|
+
* 也不会和电脑产生理解偏差。
|
|
8
|
+
* - meta 段是小的 JSON(固件用 cJSON 解析),像素段是裸 RGB565 小端,
|
|
9
|
+
* 固件按 band 流式读取、逐段 blit,不需要在 RAM 里装下整张 153KB 图。
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { getActiveGoals, getCurrentStage, listStages } from '../core/goals.js';
|
|
13
|
+
import { getTodayPlan } from '../core/actions.js';
|
|
14
|
+
import { resolveTimezone } from '../core/profile.js';
|
|
15
|
+
export const MAGIC = Buffer.from('AGCD', 'ascii');
|
|
16
|
+
export const PAYLOAD_VERSION = 1;
|
|
17
|
+
export const PIXEL_FORMAT_RGB565_LE = 1;
|
|
18
|
+
export const CARD_W = 240;
|
|
19
|
+
export const CARD_H = 320;
|
|
20
|
+
export const HEADER_SIZE = 14;
|
|
21
|
+
/** "9月20日 周日" —— 一律按配置时区算(铁律 8:绝不按 UTC 截断) */
|
|
22
|
+
export function dateLabelIn(tz, at = new Date()) {
|
|
23
|
+
const parts = new Intl.DateTimeFormat('zh-CN', {
|
|
24
|
+
timeZone: tz, month: 'numeric', day: 'numeric', weekday: 'short',
|
|
25
|
+
}).formatToParts(at);
|
|
26
|
+
const get = (t) => parts.find((p) => p.type === t)?.value ?? '';
|
|
27
|
+
return `${get('month')}月${get('day')}日 ${get('weekday')}`;
|
|
28
|
+
}
|
|
29
|
+
function countRows(ctx, from) {
|
|
30
|
+
return ctx.db.prepare(`SELECT COUNT(*) AS n FROM ${from}`).get().n;
|
|
31
|
+
}
|
|
32
|
+
/** 从 Core 汇总出设备卡的展示状态。只读,不改任何状态。 */
|
|
33
|
+
export function buildCardState(ctx, opts = {}) {
|
|
34
|
+
const tz = resolveTimezone(ctx);
|
|
35
|
+
const { plan, actions } = getTodayPlan(ctx);
|
|
36
|
+
const goals = getActiveGoals(ctx);
|
|
37
|
+
const primary = goals[0];
|
|
38
|
+
const stages = primary ? listStages(ctx, primary.id) : [];
|
|
39
|
+
const current = primary ? getCurrentStage(ctx, primary.id) : null;
|
|
40
|
+
const items = actions.map((a) => ({
|
|
41
|
+
id: a.id,
|
|
42
|
+
kind: a.kind,
|
|
43
|
+
title: a.title,
|
|
44
|
+
status: a.status,
|
|
45
|
+
done: a.status === 'COMPLETED',
|
|
46
|
+
minutes: a.estimatedMinutes ?? undefined,
|
|
47
|
+
why: a.whyToday ?? undefined,
|
|
48
|
+
completionCriteria: a.completionCriteria ?? undefined,
|
|
49
|
+
}));
|
|
50
|
+
const doneCount = items.filter((i) => i.done).length;
|
|
51
|
+
return {
|
|
52
|
+
dateLabel: dateLabelIn(tz),
|
|
53
|
+
dateKey: plan?.dateKey ?? dateLabelIn(tz),
|
|
54
|
+
stage: current && stages.length ? { title: current.title, order: current.order, total: stages.length } : null,
|
|
55
|
+
items,
|
|
56
|
+
doneCount,
|
|
57
|
+
total: items.length,
|
|
58
|
+
battery: opts.battery ?? null,
|
|
59
|
+
facts: {
|
|
60
|
+
evidence: countRows(ctx, 'evidence'),
|
|
61
|
+
mainDone: countRows(ctx, `actions WHERE kind='MAIN_QUEST' AND status='COMPLETED' AND deleted_at IS NULL`),
|
|
62
|
+
},
|
|
63
|
+
offlineSince: opts.offlineSince,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** 牌组内容指纹:条目状态变了(比如完成了一件)就换 id,设备据此判断要重取 */
|
|
67
|
+
export function deckIdOf(state) {
|
|
68
|
+
const key = [state.dateKey, state.items.map((i) => `${i.id}:${i.status}`).join(','), state.total].join('|');
|
|
69
|
+
return createHash('sha256').update(key).digest('hex').slice(0, 12);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 排牌组。
|
|
73
|
+
* 有任务:总览 → 每条任务各一张 →(全部完成时)完成页
|
|
74
|
+
* 没任务:单张「今天还没排计划」
|
|
75
|
+
*/
|
|
76
|
+
export function buildDeck(state) {
|
|
77
|
+
if (state.items.length === 0) {
|
|
78
|
+
return [{ kind: 'noplan', title: '今天还没排计划' }];
|
|
79
|
+
}
|
|
80
|
+
const cards = [{ kind: 'overview', title: '今日总览' }];
|
|
81
|
+
state.items.forEach((it, i) => {
|
|
82
|
+
cards.push({
|
|
83
|
+
kind: 'task',
|
|
84
|
+
itemIndex: i,
|
|
85
|
+
title: `${it.kind === 'MAIN_QUEST' ? '主线' : '小事'}:${it.title}`,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
if (state.doneCount === state.total && state.total > 0) {
|
|
89
|
+
cards.push({ kind: 'alldone', title: '今天做完了' });
|
|
90
|
+
}
|
|
91
|
+
return cards;
|
|
92
|
+
}
|
|
93
|
+
/** 这张卡上按「确定」应当执行的动作(只有未完成的任务卡有) */
|
|
94
|
+
export function actionIdFor(state, spec) {
|
|
95
|
+
if (spec.kind !== 'task' || spec.itemIndex === undefined)
|
|
96
|
+
return null;
|
|
97
|
+
const it = state.items[spec.itemIndex];
|
|
98
|
+
if (!it || it.done)
|
|
99
|
+
return null;
|
|
100
|
+
return it.id;
|
|
101
|
+
}
|
|
102
|
+
export function buildCardMeta(state, spec, index, count) {
|
|
103
|
+
return {
|
|
104
|
+
v: PAYLOAD_VERSION,
|
|
105
|
+
deckId: deckIdOf(state),
|
|
106
|
+
index,
|
|
107
|
+
count,
|
|
108
|
+
kind: spec.kind,
|
|
109
|
+
actionId: actionIdFor(state, spec),
|
|
110
|
+
doneCount: state.doneCount,
|
|
111
|
+
total: state.total,
|
|
112
|
+
battery: state.battery,
|
|
113
|
+
dateKey: state.dateKey,
|
|
114
|
+
dateLabel: state.dateLabel,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** 头部 + meta JSON + 裸 RGB565 —— 固件流式解析,不需要整图进 RAM */
|
|
118
|
+
export function encodeCardPayload(meta, pixels) {
|
|
119
|
+
if (pixels.length !== CARD_W * CARD_H * 2) {
|
|
120
|
+
throw new Error(`CARD_PAYLOAD: 像素长度 ${pixels.length},期望 ${CARD_W * CARD_H * 2}`);
|
|
121
|
+
}
|
|
122
|
+
const metaBuf = Buffer.from(JSON.stringify(meta), 'utf8');
|
|
123
|
+
const head = Buffer.alloc(HEADER_SIZE);
|
|
124
|
+
MAGIC.copy(head, 0);
|
|
125
|
+
head.writeUInt8(PAYLOAD_VERSION, 4);
|
|
126
|
+
head.writeUInt8(PIXEL_FORMAT_RGB565_LE, 5);
|
|
127
|
+
head.writeUInt16LE(CARD_W, 6);
|
|
128
|
+
head.writeUInt16LE(CARD_H, 8);
|
|
129
|
+
head.writeUInt32LE(metaBuf.length, 10);
|
|
130
|
+
return Buffer.concat([head, metaBuf, pixels]);
|
|
131
|
+
}
|
|
132
|
+
/** 与固件对称的解析实现,用于测试与模拟器 */
|
|
133
|
+
export function decodeCardPayload(buf) {
|
|
134
|
+
if (buf.length < HEADER_SIZE)
|
|
135
|
+
throw new Error('CARD_PAYLOAD: 太短');
|
|
136
|
+
if (!buf.subarray(0, 4).equals(MAGIC))
|
|
137
|
+
throw new Error('CARD_PAYLOAD: magic 不匹配');
|
|
138
|
+
const version = buf.readUInt8(4);
|
|
139
|
+
if (version !== PAYLOAD_VERSION)
|
|
140
|
+
throw new Error(`CARD_PAYLOAD: 版本 ${version} 不支持`);
|
|
141
|
+
if (buf.readUInt8(5) !== PIXEL_FORMAT_RGB565_LE)
|
|
142
|
+
throw new Error('CARD_PAYLOAD: 像素格式不支持');
|
|
143
|
+
const w = buf.readUInt16LE(6);
|
|
144
|
+
const h = buf.readUInt16LE(8);
|
|
145
|
+
const metaLen = buf.readUInt32LE(10);
|
|
146
|
+
if (w !== CARD_W || h !== CARD_H)
|
|
147
|
+
throw new Error(`CARD_PAYLOAD: 尺寸 ${w}x${h} 不符`);
|
|
148
|
+
const metaEnd = HEADER_SIZE + metaLen;
|
|
149
|
+
if (buf.length < metaEnd)
|
|
150
|
+
throw new Error('CARD_PAYLOAD: meta 被截断');
|
|
151
|
+
const meta = JSON.parse(buf.toString('utf8', HEADER_SIZE, metaEnd));
|
|
152
|
+
const pixels = buf.subarray(metaEnd);
|
|
153
|
+
if (pixels.length !== CARD_W * CARD_H * 2) {
|
|
154
|
+
throw new Error(`CARD_PAYLOAD: 像素段 ${pixels.length} 字节不符`);
|
|
155
|
+
}
|
|
156
|
+
return { meta, pixels: Buffer.from(pixels) };
|
|
157
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 中文字形覆盖检查(宿主侧)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么设备端的字体问题要在宿主侧解决:
|
|
5
|
+
* AI Passport 固件里能用的中文字体是「子集」——官方文档 lvgl-chinese-fonts.md 明确说
|
|
6
|
+
* 内置的 Source Han Sans 只是部分字形,而默认 Montserrat 一个汉字都没有。
|
|
7
|
+
* 我们的做法是让【卡片的每一个字都变成像素】(服务端 Chrome 出图 → RGB565 → 设备直写屏幕),
|
|
8
|
+
* 设备端不调用任何文本绘制。于是「字体覆盖」这件事整体搬到了宿主:
|
|
9
|
+
* 只要宿主渲染时没画出豆腐块,设备上就不可能出现豆腐块。
|
|
10
|
+
*
|
|
11
|
+
* 这个文件就是那条保证的检查器。它不用任何新依赖:直接让同一套 Chrome 渲染一张
|
|
12
|
+
* 「每格一个字符」的网格图,再把每格与【已知缺字】的参考格做位图比对。
|
|
13
|
+
*
|
|
14
|
+
* 方法边界(必须说清,不要夸大):
|
|
15
|
+
* - 它检测的是「宿主实际画出来的结果」,不是字体 cmap。缺字在 Chrome 里会落成
|
|
16
|
+
* .notdef(豆腐块)或者空白,这两种都能被抓到;但如果某个 fallback 字体把缺字
|
|
17
|
+
* 画成了另一个形状,本检查会漏判。
|
|
18
|
+
* - 所以内置了一个【负对照】:U+E010(私用区,任何字体都没有映射)必须被判为缺字,
|
|
19
|
+
* 并且与已知存在的 'A' 必须判为不同。负对照不成立就直接报 detector 失效,
|
|
20
|
+
* 而不是「全部通过」——否则这个检查就是摆设。
|
|
21
|
+
*/
|
|
22
|
+
import { decodePng } from './png.js';
|
|
23
|
+
import { screenshotPng } from './render.js';
|
|
24
|
+
/** 单元格边长。28px 字号的中文在这个尺寸里不会贴边,留出抗锯齿余量。 */
|
|
25
|
+
export const CELL = 40;
|
|
26
|
+
const FONT_SIZE = 28;
|
|
27
|
+
const COLS = 16;
|
|
28
|
+
/** 单次渲染的最大行数:再高就分批,避免一张巨图。 */
|
|
29
|
+
const MAX_ROWS = 24;
|
|
30
|
+
/** 私用区码点,任何常见字体都不提供映射 —— 用它当"缺字长相"的参考。 */
|
|
31
|
+
export const CTRL_MISSING = '\uE010';
|
|
32
|
+
/** 已知存在的中文字("爱")与拉丁字母,用来确认检查器确实能区分"有字形"。 */
|
|
33
|
+
export const CTRL_PRESENT = 'A';
|
|
34
|
+
export const CTRL_PRESENT_CJK = '\u7231';
|
|
35
|
+
const FONT_STACK = '"PingFang SC","Hiragino Sans GB","Heiti SC","Microsoft YaHei",sans-serif';
|
|
36
|
+
/** 位图完全一致(含长度)*/
|
|
37
|
+
export function masksEqual(a, b) {
|
|
38
|
+
if (a.length !== b.length)
|
|
39
|
+
return false;
|
|
40
|
+
for (let i = 0; i < a.length; i++)
|
|
41
|
+
if (a[i] !== b[i])
|
|
42
|
+
return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
function hasInk(mask) {
|
|
46
|
+
for (let i = 0; i < mask.length; i++)
|
|
47
|
+
if (mask[i])
|
|
48
|
+
return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 纯函数:给定「字符 → 单元格墨迹位图」,判定哪些字符缺字形。
|
|
53
|
+
* 与 Chrome 解耦,因此可以在单元测试里用合成位图覆盖(含负对照失效的分支)。
|
|
54
|
+
*/
|
|
55
|
+
export function classifyCoverage(masks) {
|
|
56
|
+
const refMissing = masks.get(CTRL_MISSING);
|
|
57
|
+
const refLatin = masks.get(CTRL_PRESENT);
|
|
58
|
+
const refCjk = masks.get(CTRL_PRESENT_CJK);
|
|
59
|
+
// 负对照必须三个方向都比:参考"缺字"要区别于拉丁真字形、也要区别于中文真字形,
|
|
60
|
+
// 并且两个真字形彼此可区分。少比 refMissing↔refCjk 这一条,就会出现
|
|
61
|
+
// 「参考缺字其实画的是中文真字形」而检查照样"通过"的假阳性 —— 那样整个检查就是摆设。
|
|
62
|
+
const detectorOk = !!refMissing && !!refLatin && !!refCjk &&
|
|
63
|
+
!masksEqual(refMissing, refLatin) &&
|
|
64
|
+
!masksEqual(refMissing, refCjk) &&
|
|
65
|
+
!masksEqual(refLatin, refCjk);
|
|
66
|
+
// 对照不成立就一个字符都不判:宁可报"检查无效",也不给出一份不可信的结论。
|
|
67
|
+
if (!detectorOk) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
missing: [],
|
|
71
|
+
noInk: [],
|
|
72
|
+
detectorOk: false,
|
|
73
|
+
checked: 0,
|
|
74
|
+
message: `字形覆盖检查不可信:负对照失效(U+E010 与 'A'/爱 的渲染结果相同或缺失),未判定任何字符`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const missing = [];
|
|
78
|
+
const noInk = [];
|
|
79
|
+
let checked = 0;
|
|
80
|
+
for (const [ch, mask] of masks) {
|
|
81
|
+
if (ch === CTRL_MISSING || ch === CTRL_PRESENT || ch === CTRL_PRESENT_CJK)
|
|
82
|
+
continue;
|
|
83
|
+
if (/^\s$/.test(ch))
|
|
84
|
+
continue; // 空格不参与判定
|
|
85
|
+
checked++;
|
|
86
|
+
if (!hasInk(mask)) {
|
|
87
|
+
noInk.push(ch);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// 与参考豆腐块位图一致 = 这个字符没有字形
|
|
91
|
+
if (refMissing && masksEqual(mask, refMissing))
|
|
92
|
+
missing.push(ch);
|
|
93
|
+
}
|
|
94
|
+
const ok = missing.length === 0 && noInk.length === 0;
|
|
95
|
+
const message = ok
|
|
96
|
+
? `字形覆盖:${checked} 个字符全部有字形(负对照有效)`
|
|
97
|
+
: `字形覆盖失败:缺字形 ${missing.length} 个${missing.length ? `(${missing.slice(0, 20).join('')})` : ''}` +
|
|
98
|
+
`${noInk.length ? `,无墨迹 ${noInk.length} 个(${noInk.slice(0, 20).join('')})` : ''}`;
|
|
99
|
+
return { ok, missing, noInk, detectorOk, checked, message };
|
|
100
|
+
}
|
|
101
|
+
function escapeHtmlChar(ch) {
|
|
102
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
103
|
+
return `&#x${c.toString(16).toUpperCase()};`;
|
|
104
|
+
}
|
|
105
|
+
/** 生成一屏绝对定位的字符网格;绝对定位是为了让单元格与像素坐标严格对齐。 */
|
|
106
|
+
function buildGridHtml(chars) {
|
|
107
|
+
const rows = Math.ceil(chars.length / COLS);
|
|
108
|
+
const width = COLS * CELL;
|
|
109
|
+
const height = rows * CELL;
|
|
110
|
+
const cells = chars.map((ch, i) => {
|
|
111
|
+
const x = (i % COLS) * CELL;
|
|
112
|
+
const y = Math.floor(i / COLS) * CELL;
|
|
113
|
+
return `<span style="position:absolute;left:${x}px;top:${y}px;width:${CELL}px;height:${CELL}px;` +
|
|
114
|
+
`line-height:${CELL}px;text-align:center;font-size:${FONT_SIZE}px;overflow:hidden">${escapeHtmlChar(ch)}</span>`;
|
|
115
|
+
}).join('');
|
|
116
|
+
const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><style>
|
|
117
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
118
|
+
html,body{width:${width}px;height:${height}px;background:#ffffff;overflow:hidden;
|
|
119
|
+
font-family:${FONT_STACK};-webkit-font-smoothing:antialiased;color:#000000}
|
|
120
|
+
</style></head><body>${cells}</body></html>`;
|
|
121
|
+
return { html, width, height };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 渲染网格并抽出每个字符的墨迹位图。
|
|
125
|
+
* 返回的 Map 一定包含三个对照字符,调用方不必自己补。
|
|
126
|
+
*/
|
|
127
|
+
export async function renderGlyphMasks(text) {
|
|
128
|
+
const uniq = [...new Set([...text])].filter((c) => c !== '\n' && c !== '\r' && c !== '\t');
|
|
129
|
+
// 只保留「可打印」字符?不:把全部字符都测一遍更保险,制表符等已在上面剔除。
|
|
130
|
+
const all = [CTRL_MISSING, CTRL_PRESENT, CTRL_PRESENT_CJK, ...uniq];
|
|
131
|
+
const masks = new Map();
|
|
132
|
+
for (let start = 0; start < all.length; start += COLS * MAX_ROWS) {
|
|
133
|
+
const batch = all.slice(start, start + COLS * MAX_ROWS);
|
|
134
|
+
const { html, width, height } = buildGridHtml(batch);
|
|
135
|
+
const png = await screenshotPng(html, { width, height });
|
|
136
|
+
const img = decodePng(png);
|
|
137
|
+
if (img.width !== width || img.height !== height) {
|
|
138
|
+
throw new Error(`FONT_COVERAGE_SIZE: 网格渲染出 ${img.width}x${img.height},期望 ${width}x${height}`);
|
|
139
|
+
}
|
|
140
|
+
for (let i = 0; i < batch.length; i++) {
|
|
141
|
+
const cx = (i % COLS) * CELL;
|
|
142
|
+
const cy = Math.floor(i / COLS) * CELL;
|
|
143
|
+
const mask = new Uint8Array(CELL * CELL);
|
|
144
|
+
for (let y = 0; y < CELL; y++) {
|
|
145
|
+
for (let x = 0; x < CELL; x++) {
|
|
146
|
+
// cell 内坐标 (x, y) → 图像坐标
|
|
147
|
+
const px = cx + x;
|
|
148
|
+
const py = cy + y;
|
|
149
|
+
const s = (py * img.width + px) * img.channels;
|
|
150
|
+
const lum = (img.data[s] * 299 + img.data[s + 1] * 587 + img.data[s + 2] * 114) / 1000;
|
|
151
|
+
mask[y * CELL + x] = lum < 160 ? 1 : 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
masks.set(batch[i], mask);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return masks;
|
|
158
|
+
}
|
|
159
|
+
/** 端到端:给一段文本,返回覆盖检查结论。 */
|
|
160
|
+
export async function checkGlyphCoverage(text) {
|
|
161
|
+
return classifyCoverage(await renderGlyphMasks(text));
|
|
162
|
+
}
|