@kidsinai/kids-client 0.0.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/README.md +114 -0
- package/bin/kids-client +4 -0
- package/package.json +45 -0
- package/src/core/audit-pipeline.ts +93 -0
- package/src/core/check-runner.ts +77 -0
- package/src/core/connection.ts +26 -0
- package/src/core/course-pack.ts +89 -0
- package/src/core/env.ts +69 -0
- package/src/core/events.ts +168 -0
- package/src/core/last-session.ts +48 -0
- package/src/core/serve-manager.ts +132 -0
- package/src/core/session.ts +63 -0
- package/src/core/store.ts +165 -0
- package/src/dangerous-topic-bridge.ts +50 -0
- package/src/index.tsx +513 -0
- package/src/render/ink/App.tsx +112 -0
- package/src/render/ink/components/ChatStream.tsx +62 -0
- package/src/render/ink/components/Header.tsx +35 -0
- package/src/render/ink/components/Input.tsx +28 -0
- package/src/render/ink/components/KeyHints.tsx +21 -0
- package/src/render/ink/components/Thinking.tsx +21 -0
- package/src/render/ink/components/Toast.tsx +29 -0
- package/src/render/ink/screens/CoursePackPicker.tsx +90 -0
- package/src/render/ink/screens/DangerousTopicModal.tsx +63 -0
- package/src/render/ink/screens/ErrorScreen.tsx +133 -0
- package/src/render/ink/screens/HelpScreen.tsx +96 -0
- package/src/render/ink/screens/LoadingScreen.tsx +33 -0
- package/src/render/ink/screens/MissionCompleteScreen.tsx +112 -0
- package/src/render/ink/screens/MissionScreen.tsx +85 -0
- package/src/render/ink/screens/PermissionModal.tsx +77 -0
- package/src/render/ink/screens/StartupScreen.tsx +83 -0
- package/src/render/ink/theme.ts +58 -0
package/src/index.tsx
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kids-client entry. Composes core/* and renders the Ink app.
|
|
3
|
+
*
|
|
4
|
+
* Boot sequence:
|
|
5
|
+
* 1. readEnv() — pull KIDS_* + OPENCODE_* from process.env
|
|
6
|
+
* 2. validateEnv() — hard-fail with ErrorScreen if password/key missing
|
|
7
|
+
* 3. ServeManager.ensureReady() — spawn `opencode serve` if down, poll /app
|
|
8
|
+
* 4. createKidsClient() — instantiate SDK v2 client w/ Basic Auth
|
|
9
|
+
* 5. SessionManager — ready to prompt
|
|
10
|
+
* 6. EventSubscriber.run() — SSE loop dispatches to store
|
|
11
|
+
* 7. Ink render(<App />) — kid sees Startup screen, picks a flow
|
|
12
|
+
*
|
|
13
|
+
* Cleanup on SIGINT / SIGTERM: stop subscriber, stop audit pipeline,
|
|
14
|
+
* kill serve child, exit. (V0 MVP: client crash takes serve with it.)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React from "react"
|
|
18
|
+
import { render } from "ink"
|
|
19
|
+
import { join } from "node:path"
|
|
20
|
+
import { readEnv, validateEnv } from "./core/env.ts"
|
|
21
|
+
import { ServeManager } from "./core/serve-manager.ts"
|
|
22
|
+
import { createKidsClient, type OpencodeClient } from "./core/connection.ts"
|
|
23
|
+
import { SessionManager } from "./core/session.ts"
|
|
24
|
+
import { EventSubscriber } from "./core/events.ts"
|
|
25
|
+
import { AuditPipeline } from "./core/audit-pipeline.ts"
|
|
26
|
+
import { Store } from "./core/store.ts"
|
|
27
|
+
import { listInstalledPacks, resolveContext } from "./core/course-pack.ts"
|
|
28
|
+
import { readLastSession, writeLastSession } from "./core/last-session.ts"
|
|
29
|
+
import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
|
|
30
|
+
import { App } from "./render/ink/App.tsx"
|
|
31
|
+
import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
|
|
32
|
+
import type { InstalledPack } from "./core/course-pack.ts"
|
|
33
|
+
import { findMission, loadCoursePack } from "@kidsinai/kids-opencode-plugin"
|
|
34
|
+
|
|
35
|
+
async function main(): Promise<void> {
|
|
36
|
+
const env = readEnv()
|
|
37
|
+
const store = new Store()
|
|
38
|
+
const installedPacks = listInstalledPacks()
|
|
39
|
+
store.update({
|
|
40
|
+
coursePack: env.coursePack,
|
|
41
|
+
mission: env.mission,
|
|
42
|
+
screen: { kind: "loading", message: env.locale === "zh-Hans" ? "正在唤醒 AI 老师…" : "Waking up the AI teacher…" },
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const check = validateEnv(env)
|
|
46
|
+
if (!check.ok) {
|
|
47
|
+
store.update({ screen: { kind: "error", variant: check.variant, detail: check.reason } })
|
|
48
|
+
renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Resolve course pack context up front (free-play if coursePack is null).
|
|
53
|
+
const ctx = resolveContext(env.coursePack, env.mission)
|
|
54
|
+
if (ctx) {
|
|
55
|
+
store.update({
|
|
56
|
+
packTitle: ctx.packTitle,
|
|
57
|
+
missionTitle: ctx.missionTitle,
|
|
58
|
+
missionIndex: ctx.missionIndex,
|
|
59
|
+
missionTotal: ctx.missionTotal,
|
|
60
|
+
starsBudget: ctx.starsBudget,
|
|
61
|
+
starsBalance: ctx.starsBudget, // start full; charges deduct via audit hook
|
|
62
|
+
})
|
|
63
|
+
} else if (env.coursePack) {
|
|
64
|
+
// Pack id provided but not found — surface as a toast on the startup screen.
|
|
65
|
+
store.update({
|
|
66
|
+
toast: {
|
|
67
|
+
kind: "warn",
|
|
68
|
+
text: env.locale === "zh-Hans"
|
|
69
|
+
? `没找到 Course Pack: ${env.coursePack}(按 c 重新选)`
|
|
70
|
+
: `Course Pack not found: ${env.coursePack} (press c to pick)`,
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const audit = new AuditPipeline({
|
|
76
|
+
bufferPath: join(env.configDir, "audit-buffer.jsonl"),
|
|
77
|
+
})
|
|
78
|
+
audit.start()
|
|
79
|
+
|
|
80
|
+
const serve = new ServeManager({
|
|
81
|
+
baseUrl: env.opencodeBaseUrl,
|
|
82
|
+
serverPassword: env.opencodeServerPassword,
|
|
83
|
+
opencodeBin: env.opencodeBin,
|
|
84
|
+
onAuditLine: (event) => {
|
|
85
|
+
audit.push(event)
|
|
86
|
+
store.pushAudit(event)
|
|
87
|
+
handlePluginAudit(event, store)
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const readiness = await serve.ensureReady()
|
|
92
|
+
if (readiness.kind === "timeout") {
|
|
93
|
+
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: readiness.lastError } })
|
|
94
|
+
renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, serve))
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const client = createKidsClient({
|
|
99
|
+
baseUrl: env.opencodeBaseUrl,
|
|
100
|
+
serverPassword: env.opencodeServerPassword,
|
|
101
|
+
})
|
|
102
|
+
const session = new SessionManager(client)
|
|
103
|
+
|
|
104
|
+
const subscriber = new EventSubscriber(client, {
|
|
105
|
+
onSessionCreated: (e) => {
|
|
106
|
+
store.update({ sessionId: e.sessionID })
|
|
107
|
+
writeLastSession(env.configDir, {
|
|
108
|
+
coursePack: env.coursePack,
|
|
109
|
+
mission: env.mission,
|
|
110
|
+
lastActiveAt: new Date().toISOString(),
|
|
111
|
+
projectDir: process.cwd(),
|
|
112
|
+
})
|
|
113
|
+
},
|
|
114
|
+
onMessagePartDelta: (e) => {
|
|
115
|
+
const snap = store.getSnapshot()
|
|
116
|
+
const active = snap.messages.find((m) => m.streaming && m.actor === "agent" && m.id === e.messageID)
|
|
117
|
+
if (!active) {
|
|
118
|
+
store.appendMessage({ id: e.messageID, actor: "agent", text: "", streaming: true, ts: Date.now() })
|
|
119
|
+
}
|
|
120
|
+
store.appendDelta(e.messageID, e.delta)
|
|
121
|
+
const message = store.getSnapshot().messages.find((m) => m.id === e.messageID)
|
|
122
|
+
if (message) {
|
|
123
|
+
const hit = env.locale === "zh-Hans" ? detectDangerousTopicZh(message.text) : detectDangerousTopicEn(message.text)
|
|
124
|
+
if (hit && !store.getSnapshot().dangerousTopic) {
|
|
125
|
+
store.update({ dangerousTopic: { category: hit, snippet: message.text.slice(-200) } })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
onTextEnded: (e) => store.endStream(e.messageID),
|
|
130
|
+
onPermissionAsked: (e) => {
|
|
131
|
+
// pickup of stars_estimated from the latest plugin audit event.
|
|
132
|
+
const recentAudit = store.getSnapshot().auditBuffer.slice(-10).reverse() as Array<Record<string, unknown>>
|
|
133
|
+
const matching = recentAudit.find(
|
|
134
|
+
(a) => a && typeof a === "object" && a.event === "tool.execute.before" && a.tool === e.tool,
|
|
135
|
+
)
|
|
136
|
+
const starsEstimated = typeof matching?.stars_estimated === "number" ? (matching.stars_estimated as number) : undefined
|
|
137
|
+
store.update({
|
|
138
|
+
pendingPermission: {
|
|
139
|
+
requestID: e.requestID,
|
|
140
|
+
tool: e.tool,
|
|
141
|
+
summary: summarisePermission(e, env.locale),
|
|
142
|
+
metadata: e.metadata ?? {},
|
|
143
|
+
starsEstimated,
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
},
|
|
147
|
+
onLlmError: (e) => {
|
|
148
|
+
store.update({ thinking: false, screen: { kind: "error", variant: "network_down", detail: e.message } })
|
|
149
|
+
},
|
|
150
|
+
onCompactionEnded: () => {
|
|
151
|
+
flashToast(store, {
|
|
152
|
+
kind: "info",
|
|
153
|
+
text: env.locale === "zh-Hans" ? "上下文压缩完成 ✓" : "Context compacted ✓",
|
|
154
|
+
})
|
|
155
|
+
},
|
|
156
|
+
onDisconnected: (reason) => {
|
|
157
|
+
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: reason } })
|
|
158
|
+
},
|
|
159
|
+
onReconnected: () => {
|
|
160
|
+
flashToast(store, {
|
|
161
|
+
kind: "success",
|
|
162
|
+
text: env.locale === "zh-Hans" ? "重新连上了 ✓" : "Reconnected ✓",
|
|
163
|
+
})
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
void subscriber.run()
|
|
167
|
+
|
|
168
|
+
// First screen: startup. If env already had a course pack, kid can either
|
|
169
|
+
// jump straight in (Enter) or pick a different one (c).
|
|
170
|
+
store.update({ screen: { kind: "startup" } })
|
|
171
|
+
|
|
172
|
+
const handleQuit = async (): Promise<void> => {
|
|
173
|
+
subscriber.stop()
|
|
174
|
+
await audit.stop()
|
|
175
|
+
await serve.shutdown()
|
|
176
|
+
process.exit(0)
|
|
177
|
+
}
|
|
178
|
+
process.on("SIGINT", () => void handleQuit())
|
|
179
|
+
process.on("SIGTERM", () => void handleQuit())
|
|
180
|
+
|
|
181
|
+
const handlers = fullHandlers(store, env, session, client, serve, handleQuit)
|
|
182
|
+
renderApp(store, env, installedPacks, handlers)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── handler factories ───────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
interface AppHandlers {
|
|
188
|
+
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
189
|
+
onPrompt: (text: string) => void
|
|
190
|
+
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
191
|
+
onDangerousAcknowledge: () => void
|
|
192
|
+
onErrorRetry: () => void | Promise<void>
|
|
193
|
+
onQuit: () => void | Promise<void>
|
|
194
|
+
onAbort: () => void
|
|
195
|
+
onHelpBack: () => void
|
|
196
|
+
onPickPack: (packId: string) => void
|
|
197
|
+
onPickerBack: () => void
|
|
198
|
+
onMissionNext: () => void
|
|
199
|
+
onMissionBack: () => void
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Minimal handlers for the pre-validation / pre-readiness error path.
|
|
204
|
+
* Many actions are no-ops because the app isn't fully booted; quit is
|
|
205
|
+
* the realistic action.
|
|
206
|
+
*/
|
|
207
|
+
function baseHandlers(
|
|
208
|
+
store: Store,
|
|
209
|
+
env: ReturnType<typeof readEnv>,
|
|
210
|
+
_session: SessionManager | null,
|
|
211
|
+
_client: OpencodeClient | null,
|
|
212
|
+
serve: ServeManager | null,
|
|
213
|
+
): AppHandlers {
|
|
214
|
+
const noop = (): void => {}
|
|
215
|
+
return {
|
|
216
|
+
onStart: noop,
|
|
217
|
+
onPrompt: noop,
|
|
218
|
+
onPermissionReply: noop,
|
|
219
|
+
onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
|
|
220
|
+
onErrorRetry: async () => {
|
|
221
|
+
if (!serve) {
|
|
222
|
+
process.exit(1)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
store.update({
|
|
226
|
+
screen: {
|
|
227
|
+
kind: "loading",
|
|
228
|
+
message: env.locale === "zh-Hans" ? "再试一次…" : "Trying again…",
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
const again = await serve.ensureReady()
|
|
232
|
+
if (again.kind === "timeout") {
|
|
233
|
+
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: again.lastError } })
|
|
234
|
+
} else {
|
|
235
|
+
store.update({ screen: { kind: "startup" } })
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
onQuit: async () => {
|
|
239
|
+
if (serve) await serve.shutdown()
|
|
240
|
+
process.exit(0)
|
|
241
|
+
},
|
|
242
|
+
onAbort: noop,
|
|
243
|
+
onHelpBack: () => store.update({ screen: { kind: "startup" } }),
|
|
244
|
+
onPickPack: noop,
|
|
245
|
+
onPickerBack: () => store.update({ screen: { kind: "startup" } }),
|
|
246
|
+
onMissionNext: noop,
|
|
247
|
+
onMissionBack: noop,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function fullHandlers(
|
|
252
|
+
store: Store,
|
|
253
|
+
env: ReturnType<typeof readEnv>,
|
|
254
|
+
session: SessionManager,
|
|
255
|
+
client: OpencodeClient,
|
|
256
|
+
serve: ServeManager,
|
|
257
|
+
quit: () => Promise<void>,
|
|
258
|
+
): AppHandlers {
|
|
259
|
+
const updateLastSession = (): void => {
|
|
260
|
+
writeLastSession(env.configDir, {
|
|
261
|
+
coursePack: store.getSnapshot().coursePack,
|
|
262
|
+
mission: store.getSnapshot().mission,
|
|
263
|
+
lastActiveAt: new Date().toISOString(),
|
|
264
|
+
projectDir: process.cwd(),
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
const refreshContext = (): void => {
|
|
268
|
+
const snap = store.getSnapshot()
|
|
269
|
+
const ctx = resolveContext(snap.coursePack, snap.mission)
|
|
270
|
+
if (ctx) {
|
|
271
|
+
store.update({
|
|
272
|
+
packTitle: ctx.packTitle,
|
|
273
|
+
missionTitle: ctx.missionTitle,
|
|
274
|
+
missionIndex: ctx.missionIndex,
|
|
275
|
+
missionTotal: ctx.missionTotal,
|
|
276
|
+
starsBudget: ctx.starsBudget,
|
|
277
|
+
starsBalance: ctx.starsBudget,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
onStart: (mode) => {
|
|
284
|
+
if (mode === "help") {
|
|
285
|
+
store.update({ screen: { kind: "help" } })
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
if (mode === "course") {
|
|
289
|
+
store.update({ screen: { kind: "course_picker" } })
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (mode === "resume") {
|
|
293
|
+
const last = readLastSession(env.configDir)
|
|
294
|
+
if (last && last.coursePack) {
|
|
295
|
+
store.update({ coursePack: last.coursePack, mission: last.mission })
|
|
296
|
+
refreshContext()
|
|
297
|
+
flashToast(store, {
|
|
298
|
+
kind: "info",
|
|
299
|
+
text:
|
|
300
|
+
env.locale === "zh-Hans"
|
|
301
|
+
? `继续上次:${last.coursePack}${last.mission ? " · " + last.mission : ""}`
|
|
302
|
+
: `Resuming: ${last.coursePack}${last.mission ? " · " + last.mission : ""}`,
|
|
303
|
+
})
|
|
304
|
+
} else {
|
|
305
|
+
flashToast(store, {
|
|
306
|
+
kind: "warn",
|
|
307
|
+
text: env.locale === "zh-Hans" ? "没找到上次的项目,先开始一个新的" : "No previous project found — starting fresh",
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
store.update({ screen: { kind: "mission" } })
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
// mode === "free" (or unrecognised) — enter MissionScreen.
|
|
314
|
+
store.update({ screen: { kind: "mission" } })
|
|
315
|
+
},
|
|
316
|
+
onPrompt: async (text) => {
|
|
317
|
+
const snap = store.getSnapshot()
|
|
318
|
+
store.appendMessage({ id: `kid-${Date.now()}`, actor: "kid", text, streaming: false, ts: Date.now() })
|
|
319
|
+
|
|
320
|
+
// In-TUI mission check intercept. Don't even hit the LLM.
|
|
321
|
+
if (snap.mission && isCompletionTrigger(text, env.locale)) {
|
|
322
|
+
const outcome = runCheck({
|
|
323
|
+
missionId: snap.mission,
|
|
324
|
+
packId: snap.coursePack ?? "",
|
|
325
|
+
locale: env.locale,
|
|
326
|
+
})
|
|
327
|
+
store.appendMessage({
|
|
328
|
+
id: `sys-${Date.now()}`,
|
|
329
|
+
actor: "system",
|
|
330
|
+
text: outcome.message + (outcome.details.length ? "\n" + outcome.details.join("\n") : ""),
|
|
331
|
+
streaming: false,
|
|
332
|
+
ts: Date.now(),
|
|
333
|
+
})
|
|
334
|
+
if (outcome.kind === "pass" && snap.coursePack && snap.mission) {
|
|
335
|
+
const pack = loadCoursePack(snap.coursePack)
|
|
336
|
+
const missions = pack?.missions ?? []
|
|
337
|
+
const idx = missions.findIndex((m) => m.id === snap.mission)
|
|
338
|
+
const hasNext = idx >= 0 && idx + 1 < missions.length
|
|
339
|
+
store.update({
|
|
340
|
+
screen: {
|
|
341
|
+
kind: "mission_complete",
|
|
342
|
+
missionId: snap.mission,
|
|
343
|
+
missionTitle: snap.missionTitle,
|
|
344
|
+
passed: outcome.result?.passed ?? 0,
|
|
345
|
+
total: outcome.result?.total ?? 0,
|
|
346
|
+
completionMessage: outcome.result?.completion_message ?? outcome.message,
|
|
347
|
+
hasNextMission: hasNext,
|
|
348
|
+
},
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Dangerous topic intercept on kid input.
|
|
355
|
+
const hit = env.locale === "zh-Hans" ? detectDangerousTopicZh(text) : detectDangerousTopicEn(text)
|
|
356
|
+
if (hit) {
|
|
357
|
+
store.update({ dangerousTopic: { category: hit, snippet: text } })
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Normal LLM prompt.
|
|
362
|
+
store.update({ thinking: true })
|
|
363
|
+
updateLastSession()
|
|
364
|
+
try {
|
|
365
|
+
await session.prompt(text)
|
|
366
|
+
} catch (err) {
|
|
367
|
+
store.update({ thinking: false, screen: { kind: "error", variant: "network_down", detail: errMessage(err) } })
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
onPermissionReply: async (decision) => {
|
|
371
|
+
const snap = store.getSnapshot()
|
|
372
|
+
const pending = snap.pendingPermission
|
|
373
|
+
if (!pending) return
|
|
374
|
+
store.update({ pendingPermission: null })
|
|
375
|
+
try {
|
|
376
|
+
const reply = decision === "allow" ? "once" : "reject"
|
|
377
|
+
const api = (client as unknown as { permission?: { reply: (id: string, body: unknown) => Promise<unknown> } }).permission
|
|
378
|
+
await api?.reply(pending.requestID, { reply })
|
|
379
|
+
if (decision === "edit") {
|
|
380
|
+
flashToast(store, {
|
|
381
|
+
kind: "info",
|
|
382
|
+
text:
|
|
383
|
+
env.locale === "zh-Hans"
|
|
384
|
+
? "你来改这一步,告诉 AI 你想怎么做"
|
|
385
|
+
: "You take this step — tell the AI what you'd prefer",
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
} catch {
|
|
389
|
+
// SSE will surface the timeout / error via onLlmError.
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
|
|
393
|
+
onErrorRetry: async () => {
|
|
394
|
+
store.update({
|
|
395
|
+
screen: {
|
|
396
|
+
kind: "loading",
|
|
397
|
+
message: env.locale === "zh-Hans" ? "再试一次…" : "Trying again…",
|
|
398
|
+
},
|
|
399
|
+
})
|
|
400
|
+
const again = await serve.ensureReady()
|
|
401
|
+
if (again.kind === "timeout") {
|
|
402
|
+
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: again.lastError } })
|
|
403
|
+
} else {
|
|
404
|
+
store.update({ screen: { kind: "startup" } })
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
onQuit: quit,
|
|
408
|
+
onAbort: async () => {
|
|
409
|
+
try {
|
|
410
|
+
await session.abort()
|
|
411
|
+
store.update({ thinking: false })
|
|
412
|
+
flashToast(store, {
|
|
413
|
+
kind: "warn",
|
|
414
|
+
text: env.locale === "zh-Hans" ? "已停止" : "Stopped",
|
|
415
|
+
})
|
|
416
|
+
} catch {
|
|
417
|
+
// ignore
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
onHelpBack: () => store.update({ screen: { kind: "startup" } }),
|
|
421
|
+
onPickPack: (packId) => {
|
|
422
|
+
store.update({ coursePack: packId, mission: null })
|
|
423
|
+
refreshContext()
|
|
424
|
+
store.update({ screen: { kind: "mission" } })
|
|
425
|
+
},
|
|
426
|
+
onPickerBack: () => store.update({ screen: { kind: "startup" } }),
|
|
427
|
+
onMissionNext: () => {
|
|
428
|
+
const snap = store.getSnapshot()
|
|
429
|
+
if (!snap.coursePack || !snap.mission) {
|
|
430
|
+
store.update({ screen: { kind: "mission" } })
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
const pack = loadCoursePack(snap.coursePack)
|
|
434
|
+
const missions = pack?.missions ?? []
|
|
435
|
+
const idx = missions.findIndex((m) => m.id === snap.mission)
|
|
436
|
+
const next = idx >= 0 && idx + 1 < missions.length ? missions[idx + 1] : null
|
|
437
|
+
if (!next) {
|
|
438
|
+
store.update({ screen: { kind: "mission" } })
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
store.update({ mission: next.id })
|
|
442
|
+
refreshContext()
|
|
443
|
+
store.update({ screen: { kind: "mission" } })
|
|
444
|
+
flashToast(store, {
|
|
445
|
+
kind: "success",
|
|
446
|
+
text: env.locale === "zh-Hans" ? `开始:${next.title}` : `Starting: ${next.title}`,
|
|
447
|
+
})
|
|
448
|
+
},
|
|
449
|
+
onMissionBack: () => store.update({ screen: { kind: "mission" } }),
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ─── utilities ───────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
function renderApp(
|
|
456
|
+
store: Store,
|
|
457
|
+
env: ReturnType<typeof readEnv>,
|
|
458
|
+
installedPacks: InstalledPack[],
|
|
459
|
+
handlers: AppHandlers,
|
|
460
|
+
): void {
|
|
461
|
+
render(
|
|
462
|
+
React.createElement(App, {
|
|
463
|
+
store,
|
|
464
|
+
locale: env.locale,
|
|
465
|
+
installedPacks,
|
|
466
|
+
...handlers,
|
|
467
|
+
}),
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function summarisePermission(
|
|
472
|
+
e: { tool?: string; metadata?: Record<string, unknown> },
|
|
473
|
+
locale: "zh-Hans" | "en",
|
|
474
|
+
): string {
|
|
475
|
+
if (locale === "zh-Hans") return `AI 想用「${e.tool ?? "工具"}」做下一步`
|
|
476
|
+
return `The AI wants to use "${e.tool ?? "a tool"}"`
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function handlePluginAudit(event: unknown, store: Store): void {
|
|
480
|
+
const e = event as { event?: string; stars_charged?: number; stars_estimated?: number }
|
|
481
|
+
if (!e || typeof e.event !== "string") return
|
|
482
|
+
if (e.event === "tool.execute.after" && typeof e.stars_charged === "number") {
|
|
483
|
+
const snap = store.getSnapshot()
|
|
484
|
+
const newBalance = Math.max(0, snap.starsBalance - e.stars_charged)
|
|
485
|
+
store.update({ starsBalance: newBalance })
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const TOAST_TTL_MS = 3500
|
|
490
|
+
function flashToast(store: Store, toast: { kind: "info" | "warn" | "success"; text: string }): void {
|
|
491
|
+
store.update({ toast })
|
|
492
|
+
setTimeout(() => {
|
|
493
|
+
const snap = store.getSnapshot()
|
|
494
|
+
if (snap.toast?.text === toast.text) {
|
|
495
|
+
store.update({ toast: null })
|
|
496
|
+
}
|
|
497
|
+
}, TOAST_TTL_MS)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function errMessage(err: unknown): string {
|
|
501
|
+
if (err instanceof Error) return err.message
|
|
502
|
+
return String(err)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Touch findMission so TS doesn't complain about the import being unused
|
|
506
|
+
// when typecheck runs against the v0.0.1 SDK that doesn't expose v2 yet.
|
|
507
|
+
void findMission
|
|
508
|
+
void loadCoursePack
|
|
509
|
+
|
|
510
|
+
void main().catch((err) => {
|
|
511
|
+
console.error("kids-client: fatal startup error:", err)
|
|
512
|
+
process.exit(1)
|
|
513
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level router. Reads store snapshot → picks which screen to render.
|
|
3
|
+
*
|
|
4
|
+
* The router is intentionally thin. All state mutation happens in the
|
|
5
|
+
* core/ layer; this component only translates state.screen → JSX.
|
|
6
|
+
*
|
|
7
|
+
* Modal overlays (dangerous-topic, pending-permission) preempt the
|
|
8
|
+
* routed screen — they're absolute-priority overlays that the kid must
|
|
9
|
+
* dispatch before the underlying screen can interact again.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useSyncExternalStore } from "react"
|
|
13
|
+
import type { InstalledPack } from "../../core/course-pack.ts"
|
|
14
|
+
import type { Store } from "../../core/store.ts"
|
|
15
|
+
import { StartupScreen } from "./screens/StartupScreen.tsx"
|
|
16
|
+
import { MissionScreen } from "./screens/MissionScreen.tsx"
|
|
17
|
+
import { PermissionModal } from "./screens/PermissionModal.tsx"
|
|
18
|
+
import { DangerousTopicModal } from "./screens/DangerousTopicModal.tsx"
|
|
19
|
+
import { ErrorScreen } from "./screens/ErrorScreen.tsx"
|
|
20
|
+
import { HelpScreen } from "./screens/HelpScreen.tsx"
|
|
21
|
+
import { CoursePackPicker } from "./screens/CoursePackPicker.tsx"
|
|
22
|
+
import { MissionCompleteScreen } from "./screens/MissionCompleteScreen.tsx"
|
|
23
|
+
import { LoadingScreen } from "./screens/LoadingScreen.tsx"
|
|
24
|
+
|
|
25
|
+
export interface AppDeps {
|
|
26
|
+
store: Store
|
|
27
|
+
locale: "zh-Hans" | "en"
|
|
28
|
+
installedPacks: InstalledPack[]
|
|
29
|
+
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
30
|
+
onPrompt: (text: string) => void
|
|
31
|
+
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
32
|
+
onDangerousAcknowledge: () => void
|
|
33
|
+
onErrorRetry: () => void
|
|
34
|
+
onQuit: () => void
|
|
35
|
+
onAbort: () => void
|
|
36
|
+
onHelpBack: () => void
|
|
37
|
+
onPickPack: (packId: string) => void
|
|
38
|
+
onPickerBack: () => void
|
|
39
|
+
onMissionNext: () => void
|
|
40
|
+
onMissionBack: () => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function App(deps: AppDeps): React.ReactElement {
|
|
44
|
+
const state = useSyncExternalStore(
|
|
45
|
+
(cb) => deps.store.subscribe(cb),
|
|
46
|
+
() => deps.store.getSnapshot(),
|
|
47
|
+
() => deps.store.getSnapshot(),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// Dangerous-topic overlay takes absolute priority — it has to be the
|
|
51
|
+
// first thing on screen the moment a pattern hits, even mid-stream.
|
|
52
|
+
if (state.dangerousTopic) {
|
|
53
|
+
return <DangerousTopicModal topic={state.dangerousTopic} locale={deps.locale} onAcknowledge={deps.onDangerousAcknowledge} />
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Permission modal is the next-highest priority.
|
|
57
|
+
if (state.pendingPermission) {
|
|
58
|
+
return (
|
|
59
|
+
<PermissionModal
|
|
60
|
+
permission={state.pendingPermission}
|
|
61
|
+
locale={deps.locale}
|
|
62
|
+
onAllow={() => deps.onPermissionReply("allow")}
|
|
63
|
+
onDeny={() => deps.onPermissionReply("deny")}
|
|
64
|
+
onEdit={() => deps.onPermissionReply("edit")}
|
|
65
|
+
/>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
switch (state.screen.kind) {
|
|
70
|
+
case "loading":
|
|
71
|
+
return <LoadingScreen locale={deps.locale} message={state.screen.message} />
|
|
72
|
+
case "startup":
|
|
73
|
+
return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
|
|
74
|
+
case "mission":
|
|
75
|
+
return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} />
|
|
76
|
+
case "help":
|
|
77
|
+
return <HelpScreen locale={deps.locale} onBack={deps.onHelpBack} />
|
|
78
|
+
case "course_picker":
|
|
79
|
+
return (
|
|
80
|
+
<CoursePackPicker
|
|
81
|
+
locale={deps.locale}
|
|
82
|
+
packs={deps.installedPacks}
|
|
83
|
+
onPick={deps.onPickPack}
|
|
84
|
+
onBack={deps.onPickerBack}
|
|
85
|
+
/>
|
|
86
|
+
)
|
|
87
|
+
case "mission_complete":
|
|
88
|
+
return (
|
|
89
|
+
<MissionCompleteScreen
|
|
90
|
+
locale={deps.locale}
|
|
91
|
+
missionId={state.screen.missionId}
|
|
92
|
+
missionTitle={state.screen.missionTitle}
|
|
93
|
+
passed={state.screen.passed}
|
|
94
|
+
total={state.screen.total}
|
|
95
|
+
completionMessage={state.screen.completionMessage}
|
|
96
|
+
hasNextMission={state.screen.hasNextMission}
|
|
97
|
+
onNext={deps.onMissionNext}
|
|
98
|
+
onBack={deps.onMissionBack}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
case "error":
|
|
102
|
+
return (
|
|
103
|
+
<ErrorScreen
|
|
104
|
+
variant={state.screen.variant}
|
|
105
|
+
detail={state.screen.detail}
|
|
106
|
+
locale={deps.locale}
|
|
107
|
+
onRetry={deps.onErrorRetry}
|
|
108
|
+
onQuit={deps.onQuit}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { Box, Static, Text } from "ink"
|
|
3
|
+
import { getTheme } from "../theme.ts"
|
|
4
|
+
import type { ChatMessage } from "../../../core/store.ts"
|
|
5
|
+
|
|
6
|
+
interface ChatStreamProps {
|
|
7
|
+
messages: ChatMessage[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ChatStream({ messages }: ChatStreamProps): React.ReactElement {
|
|
11
|
+
const theme = getTheme()
|
|
12
|
+
if (messages.length === 0) return <Box />
|
|
13
|
+
// Settled messages go into <Static> so Ink doesn't re-render the entire
|
|
14
|
+
// history every delta. The active streaming message (if any) renders
|
|
15
|
+
// below in the live region.
|
|
16
|
+
const finished = messages.filter((m) => !m.streaming)
|
|
17
|
+
const active = messages.find((m) => m.streaming)
|
|
18
|
+
return (
|
|
19
|
+
<Box flexDirection="column">
|
|
20
|
+
<Static items={finished}>
|
|
21
|
+
{(m) => (
|
|
22
|
+
<Box key={m.id} flexDirection="row" marginBottom={1}>
|
|
23
|
+
<ActorBadge actor={m.actor} />
|
|
24
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
25
|
+
<Text color={colorFor(m.actor, theme)}>{m.text || " "}</Text>
|
|
26
|
+
</Box>
|
|
27
|
+
</Box>
|
|
28
|
+
)}
|
|
29
|
+
</Static>
|
|
30
|
+
{active && (
|
|
31
|
+
<Box flexDirection="row" marginBottom={1}>
|
|
32
|
+
<ActorBadge actor={active.actor} />
|
|
33
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
34
|
+
<Text color={colorFor(active.actor, theme)}>{active.text || " "}</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
</Box>
|
|
37
|
+
)}
|
|
38
|
+
</Box>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ActorBadge({ actor }: { actor: ChatMessage["actor"] }): React.ReactElement {
|
|
43
|
+
const theme = getTheme()
|
|
44
|
+
const emoji = actor === "kid" ? "👦" : actor === "agent" ? "🤖" : "⚙️"
|
|
45
|
+
const color = colorFor(actor, theme)
|
|
46
|
+
return (
|
|
47
|
+
<Box marginRight={1}>
|
|
48
|
+
<Text color={color}>{emoji}</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function colorFor(actor: ChatMessage["actor"], theme: ReturnType<typeof getTheme>): string {
|
|
54
|
+
switch (actor) {
|
|
55
|
+
case "kid":
|
|
56
|
+
return theme.kid
|
|
57
|
+
case "agent":
|
|
58
|
+
return theme.agent
|
|
59
|
+
case "system":
|
|
60
|
+
return theme.system
|
|
61
|
+
}
|
|
62
|
+
}
|