@kidsinai/kids-client 0.0.10 → 0.0.12
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/package.json +1 -1
- package/src/core/connection.ts +18 -1
- package/src/core/env.ts +15 -0
- package/src/core/serve-manager.ts +10 -1
- package/src/core/wallet-link.ts +76 -0
- package/src/index.tsx +40 -0
- package/src/render/ink/App.tsx +28 -2
- package/src/render/ink/screens/ErrorScreen.tsx +41 -3
- package/src/render/ink/screens/StartupScreen.tsx +14 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@kidsinai/kids-client",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.12",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Own-client TUI for Kids OpenCode — talks to local `opencode serve` via @opencode-ai/sdk v2 with kid-warm rendering, mission progress, permission dialog, and stderr-tail audit pipeline.",
|
|
7
7
|
"license": "MIT",
|
package/src/core/connection.ts
CHANGED
|
@@ -13,10 +13,17 @@ export type OpencodeClient = ReturnType<typeof createOpencodeClient>
|
|
|
13
13
|
export interface ConnectionOptions {
|
|
14
14
|
baseUrl: string
|
|
15
15
|
serverPassword: string
|
|
16
|
+
/**
|
|
17
|
+
* Must match upstream's `OPENCODE_SERVER_USERNAME` (default "opencode").
|
|
18
|
+
* Sending an empty username here yields a 401 against opencode ≥1.x —
|
|
19
|
+
* see opencode-kernel server/auth.ts `authorized()` which requires
|
|
20
|
+
* `credentials.username === config.username`.
|
|
21
|
+
*/
|
|
22
|
+
serverUsername: string
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export function createKidsClient(opts: ConnectionOptions): OpencodeClient {
|
|
19
|
-
const authHeader =
|
|
26
|
+
const authHeader = buildAuthHeader(opts.serverUsername, opts.serverPassword)
|
|
20
27
|
return createOpencodeClient({
|
|
21
28
|
baseUrl: opts.baseUrl,
|
|
22
29
|
headers: {
|
|
@@ -24,3 +31,13 @@ export function createKidsClient(opts: ConnectionOptions): OpencodeClient {
|
|
|
24
31
|
},
|
|
25
32
|
} as Parameters<typeof createOpencodeClient>[0])
|
|
26
33
|
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Construct the HTTP Basic-Auth header opencode serve expects.
|
|
37
|
+
* Exposed so serve-manager's probe + tests can share the exact format
|
|
38
|
+
* upstream `authorized()` checks against (username MUST match config,
|
|
39
|
+
* default "opencode" — empty username produces 401 on opencode ≥1.x).
|
|
40
|
+
*/
|
|
41
|
+
export function buildAuthHeader(username: string, password: string): string {
|
|
42
|
+
return "Basic " + btoa(`${username}:${password}`)
|
|
43
|
+
}
|
package/src/core/env.ts
CHANGED
|
@@ -14,6 +14,13 @@ export interface KidsClientEnv {
|
|
|
14
14
|
opencodeBaseUrl: string
|
|
15
15
|
/** HTTP Basic Auth password for serve. Mandatory. */
|
|
16
16
|
opencodeServerPassword: string
|
|
17
|
+
/**
|
|
18
|
+
* HTTP Basic Auth *username* for serve. Defaults to "opencode" to match
|
|
19
|
+
* upstream's `OPENCODE_SERVER_USERNAME` default (opencode-kernel
|
|
20
|
+
* packages/opencode/src/server/auth.ts) — sending an empty username here
|
|
21
|
+
* causes a 401 against opencode ≥1.x even when the password is correct.
|
|
22
|
+
*/
|
|
23
|
+
opencodeServerUsername: string
|
|
17
24
|
/** DeepRouter tenant key. May be empty when using BYOK bypass. */
|
|
18
25
|
deeprouterApiKey: string
|
|
19
26
|
/** True if the wrapper set KIDS_LLM_BYPASS_GATEWAY=1 (BYOK dogfood mode). */
|
|
@@ -30,6 +37,12 @@ export interface KidsClientEnv {
|
|
|
30
37
|
configDir: string
|
|
31
38
|
/** When true, the client renders a "Tony banner" / suppresses interactive prompts (CI). */
|
|
32
39
|
noBanner: boolean
|
|
40
|
+
/**
|
|
41
|
+
* Airbotix Portal base URL — used by the [w] Wallet / Top-up shortcut to
|
|
42
|
+
* deep-link parents into login + Airwallex top-up. Defaults to
|
|
43
|
+
* https://app.airbotix.ai; staging overrides via AIRBOTIX_PORTAL_URL.
|
|
44
|
+
*/
|
|
45
|
+
portalBaseUrl: string
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
export function readEnv(): KidsClientEnv {
|
|
@@ -39,6 +52,7 @@ export function readEnv(): KidsClientEnv {
|
|
|
39
52
|
return {
|
|
40
53
|
opencodeBaseUrl: process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096",
|
|
41
54
|
opencodeServerPassword: password,
|
|
55
|
+
opencodeServerUsername: process.env.OPENCODE_SERVER_USERNAME || "opencode",
|
|
42
56
|
deeprouterApiKey: process.env.DEEPROUTER_API_KEY ?? "",
|
|
43
57
|
bypassGateway: process.env.KIDS_LLM_BYPASS_GATEWAY === "1",
|
|
44
58
|
coursePack: process.env.KIDS_COURSE_PACK || null,
|
|
@@ -47,6 +61,7 @@ export function readEnv(): KidsClientEnv {
|
|
|
47
61
|
opencodeBin: process.env.OPENCODE_BIN ?? "opencode",
|
|
48
62
|
configDir: process.env.KIDS_OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "kids-opencode"),
|
|
49
63
|
noBanner: process.env.KIDS_OPENCODE_NO_BANNER === "1",
|
|
64
|
+
portalBaseUrl: process.env.AIRBOTIX_PORTAL_URL || "https://app.airbotix.ai",
|
|
50
65
|
}
|
|
51
66
|
}
|
|
52
67
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { spawn, type Subprocess } from "bun"
|
|
13
|
+
import { buildAuthHeader } from "./connection.ts"
|
|
13
14
|
|
|
14
15
|
export type ServeReadiness =
|
|
15
16
|
| { kind: "already_running" }
|
|
@@ -32,6 +33,13 @@ export type ProbeResult = "ok" | "auth_mismatch" | "offline"
|
|
|
32
33
|
export interface ServeManagerOptions {
|
|
33
34
|
baseUrl: string
|
|
34
35
|
serverPassword: string
|
|
36
|
+
/**
|
|
37
|
+
* Must match upstream's `OPENCODE_SERVER_USERNAME` (default "opencode").
|
|
38
|
+
* The probe sends this as the Basic-auth username — upstream's
|
|
39
|
+
* authorized() requires an exact match, so an empty username produces
|
|
40
|
+
* a 401 even when the password is correct.
|
|
41
|
+
*/
|
|
42
|
+
serverUsername: string
|
|
35
43
|
opencodeBin: string
|
|
36
44
|
/** Max wait for readiness probe in ms. Default 10s. */
|
|
37
45
|
readyTimeoutMs?: number
|
|
@@ -75,6 +83,7 @@ export class ServeManager {
|
|
|
75
83
|
env: {
|
|
76
84
|
...process.env,
|
|
77
85
|
OPENCODE_SERVER_PASSWORD: this.opts.serverPassword,
|
|
86
|
+
OPENCODE_SERVER_USERNAME: this.opts.serverUsername,
|
|
78
87
|
},
|
|
79
88
|
stdout: "pipe",
|
|
80
89
|
stderr: "pipe",
|
|
@@ -121,7 +130,7 @@ export class ServeManager {
|
|
|
121
130
|
try {
|
|
122
131
|
const res = await fetch(`${this.opts.baseUrl}/app`, {
|
|
123
132
|
headers: {
|
|
124
|
-
authorization:
|
|
133
|
+
authorization: buildAuthHeader(this.opts.serverUsername, this.opts.serverPassword),
|
|
125
134
|
},
|
|
126
135
|
})
|
|
127
136
|
return classifyProbeStatus(res.status)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallet / login deep-link to Airbotix Portal.
|
|
3
|
+
*
|
|
4
|
+
* V0 strategy: open the parent's default browser to portal/wallet?from=cli.
|
|
5
|
+
* Portal handles auth (login first if no session) and Airwallex hosted card
|
|
6
|
+
* entry. TUI does not touch card data — PCI scope stays in the browser.
|
|
7
|
+
*
|
|
8
|
+
* A stable device-id (random UUID, persisted under configDir) is included
|
|
9
|
+
* so platform-backend can later correlate top-ups with the local install
|
|
10
|
+
* for V1 device-link polling. Today portal just logs it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process"
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
import { randomUUID } from "node:crypto"
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_PORTAL_BASE_URL = "https://app.airbotix.ai"
|
|
19
|
+
|
|
20
|
+
export function getOrCreateDeviceId(configDir: string): string {
|
|
21
|
+
const p = join(configDir, "device-id")
|
|
22
|
+
if (existsSync(p)) {
|
|
23
|
+
const v = readFileSync(p, "utf8").trim()
|
|
24
|
+
if (v) return v
|
|
25
|
+
}
|
|
26
|
+
mkdirSync(configDir, { recursive: true })
|
|
27
|
+
const id = randomUUID()
|
|
28
|
+
writeFileSync(p, id + "\n", { mode: 0o600 })
|
|
29
|
+
return id
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WalletUrlOpts {
|
|
33
|
+
portalBaseUrl?: string
|
|
34
|
+
deviceId: string
|
|
35
|
+
locale?: "zh-Hans" | "en"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildWalletUrl(opts: WalletUrlOpts): string {
|
|
39
|
+
const base = (opts.portalBaseUrl || DEFAULT_PORTAL_BASE_URL).replace(/\/+$/, "")
|
|
40
|
+
const params = new URLSearchParams({
|
|
41
|
+
from: "cli",
|
|
42
|
+
device: opts.deviceId,
|
|
43
|
+
})
|
|
44
|
+
if (opts.locale) params.set("lang", opts.locale)
|
|
45
|
+
return `${base}/portal/wallet?${params.toString()}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type OpenResult = { ok: true } | { ok: false; reason: string }
|
|
49
|
+
|
|
50
|
+
export function openInBrowser(url: string): OpenResult {
|
|
51
|
+
const platform = process.platform
|
|
52
|
+
let cmd: string
|
|
53
|
+
let args: string[]
|
|
54
|
+
if (platform === "darwin") {
|
|
55
|
+
cmd = "open"
|
|
56
|
+
args = [url]
|
|
57
|
+
} else if (platform === "win32") {
|
|
58
|
+
// `start` is a cmd.exe builtin; the empty "" is the window title slot.
|
|
59
|
+
cmd = "cmd"
|
|
60
|
+
args = ["/c", "start", "", url]
|
|
61
|
+
} else {
|
|
62
|
+
cmd = "xdg-open"
|
|
63
|
+
args = [url]
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" })
|
|
67
|
+
child.on("error", () => {
|
|
68
|
+
// Swallow async spawn errors — TUI already showed a toast saying we
|
|
69
|
+
// tried, and the parent can copy the URL from the toast as fallback.
|
|
70
|
+
})
|
|
71
|
+
child.unref()
|
|
72
|
+
return { ok: true }
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import { reloadEnvFile } from "./core/env-reload.ts"
|
|
|
37
37
|
import { hasSeenTour, markTourSeen } from "./core/tour-marker.ts"
|
|
38
38
|
import type { InstalledPack } from "./core/course-pack.ts"
|
|
39
39
|
import { loadCoursePack } from "@kidsinai/kids-opencode-plugin"
|
|
40
|
+
import { buildWalletUrl, getOrCreateDeviceId, openInBrowser } from "./core/wallet-link.ts"
|
|
40
41
|
|
|
41
42
|
interface ServiceSet {
|
|
42
43
|
audit: AuditPipeline
|
|
@@ -64,6 +65,7 @@ interface AppHandlers {
|
|
|
64
65
|
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
65
66
|
onDangerousAcknowledge: () => void
|
|
66
67
|
onErrorRetry: () => void | Promise<void>
|
|
68
|
+
onReconfigure: () => void
|
|
67
69
|
onQuit: () => void | Promise<void>
|
|
68
70
|
onAbort: () => void
|
|
69
71
|
onHelpBack: () => void
|
|
@@ -76,6 +78,7 @@ interface AppHandlers {
|
|
|
76
78
|
onSetupSkip: () => void
|
|
77
79
|
onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
|
|
78
80
|
onTourDone: () => void
|
|
81
|
+
onOpenWallet: () => void
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
async function main(): Promise<void> {
|
|
@@ -189,6 +192,12 @@ function makeHandlers(
|
|
|
189
192
|
// Pre-boot error retry: re-run main isn't trivial; just exit.
|
|
190
193
|
process.exit(1)
|
|
191
194
|
},
|
|
195
|
+
onReconfigure: () => {
|
|
196
|
+
// From an error screen, jump into the setup wizard so the parent can
|
|
197
|
+
// change provider / paste a new API key. onSetupContinue knows whether
|
|
198
|
+
// we're in first-run (resolve gate) or post-boot (reload env + retry).
|
|
199
|
+
store.update({ screen: { kind: "setup" } })
|
|
200
|
+
},
|
|
192
201
|
onQuit: async () => {
|
|
193
202
|
const s = servicesHolder.current
|
|
194
203
|
if (s) return s.quit()
|
|
@@ -211,6 +220,16 @@ function makeHandlers(
|
|
|
211
220
|
onSetupContinue: async () => {
|
|
212
221
|
const r = getResolveSetup()
|
|
213
222
|
if (r) r()
|
|
223
|
+
// Post-boot reconfigure path: services are already up but the env they
|
|
224
|
+
// were booted with is stale. Re-source the env file (the wizard wrote
|
|
225
|
+
// it) and replay the same recovery as [Enter] Retry on the error
|
|
226
|
+
// screen — push the loading screen and re-run readiness probe.
|
|
227
|
+
const s = servicesHolder.current
|
|
228
|
+
if (s) {
|
|
229
|
+
reloadEnvFile(env.configDir)
|
|
230
|
+
Object.assign(env, readEnv())
|
|
231
|
+
await s.handlers.onErrorRetry()
|
|
232
|
+
}
|
|
214
233
|
},
|
|
215
234
|
onSetupSkip: () => {
|
|
216
235
|
const r = getResolveSetup()
|
|
@@ -231,6 +250,25 @@ function makeHandlers(
|
|
|
231
250
|
const r = getResolveTour()
|
|
232
251
|
if (r) r()
|
|
233
252
|
},
|
|
253
|
+
onOpenWallet: () => {
|
|
254
|
+
const deviceId = getOrCreateDeviceId(env.configDir)
|
|
255
|
+
const url = buildWalletUrl({
|
|
256
|
+
portalBaseUrl: env.portalBaseUrl,
|
|
257
|
+
deviceId,
|
|
258
|
+
locale: env.locale,
|
|
259
|
+
})
|
|
260
|
+
const result = openInBrowser(url)
|
|
261
|
+
const okText = env.locale === "zh-Hans"
|
|
262
|
+
? `已在浏览器打开:${url}`
|
|
263
|
+
: `Opened in your browser: ${url}`
|
|
264
|
+
const failText = env.locale === "zh-Hans"
|
|
265
|
+
? `没办法自动开浏览器。请手动打开:${url}`
|
|
266
|
+
: `Couldn't auto-open the browser. Open manually: ${url}`
|
|
267
|
+
flashToast(store, {
|
|
268
|
+
kind: result.ok ? "success" : "warn",
|
|
269
|
+
text: result.ok ? okText : failText,
|
|
270
|
+
})
|
|
271
|
+
},
|
|
234
272
|
}
|
|
235
273
|
}
|
|
236
274
|
|
|
@@ -245,6 +283,7 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
|
|
|
245
283
|
const serve = new ServeManager({
|
|
246
284
|
baseUrl: env.opencodeBaseUrl,
|
|
247
285
|
serverPassword: env.opencodeServerPassword,
|
|
286
|
+
serverUsername: env.opencodeServerUsername,
|
|
248
287
|
opencodeBin: env.opencodeBin,
|
|
249
288
|
onAuditLine: (event) => {
|
|
250
289
|
audit.push(event)
|
|
@@ -282,6 +321,7 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
|
|
|
282
321
|
const client = createKidsClient({
|
|
283
322
|
baseUrl: env.opencodeBaseUrl,
|
|
284
323
|
serverPassword: env.opencodeServerPassword,
|
|
324
|
+
serverUsername: env.opencodeServerUsername,
|
|
285
325
|
})
|
|
286
326
|
const session = new SessionManager(client)
|
|
287
327
|
|
package/src/render/ink/App.tsx
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import React, { useSyncExternalStore } from "react"
|
|
13
13
|
import type { InstalledPack } from "../../core/course-pack.ts"
|
|
14
|
-
import type { Store } from "../../core/store.ts"
|
|
14
|
+
import type { ErrorVariant, Store } from "../../core/store.ts"
|
|
15
15
|
import { StartupScreen } from "./screens/StartupScreen.tsx"
|
|
16
16
|
import { MissionScreen } from "./screens/MissionScreen.tsx"
|
|
17
17
|
import { PermissionModal } from "./screens/PermissionModal.tsx"
|
|
@@ -25,6 +25,17 @@ import { SetupScreen } from "./screens/SetupScreen.tsx"
|
|
|
25
25
|
import { TourScreen } from "./screens/TourScreen.tsx"
|
|
26
26
|
import type { ProviderId } from "../../core/setup.ts"
|
|
27
27
|
|
|
28
|
+
// Variants where the root cause may be a stale / wrong API key or a missing
|
|
29
|
+
// provider config — showing a [c] Change settings option that opens the setup
|
|
30
|
+
// wizard actually helps. Pure runtime/network problems (network_down,
|
|
31
|
+
// stars_exhausted, ai_hung) are excluded; they need a different recovery.
|
|
32
|
+
const RECONFIGURABLE_VARIANTS: ReadonlySet<ErrorVariant> = new Set([
|
|
33
|
+
"serve_unreachable",
|
|
34
|
+
"port_taken",
|
|
35
|
+
"auth_failed",
|
|
36
|
+
"config_missing",
|
|
37
|
+
])
|
|
38
|
+
|
|
28
39
|
export interface AppDeps {
|
|
29
40
|
store: Store
|
|
30
41
|
locale: "zh-Hans" | "en"
|
|
@@ -34,6 +45,11 @@ export interface AppDeps {
|
|
|
34
45
|
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
35
46
|
onDangerousAcknowledge: () => void
|
|
36
47
|
onErrorRetry: () => void
|
|
48
|
+
/**
|
|
49
|
+
* Jump from the error screen into the setup wizard. Only wired for
|
|
50
|
+
* config-related variants — see RECONFIGURABLE_VARIANTS below.
|
|
51
|
+
*/
|
|
52
|
+
onReconfigure: () => void
|
|
37
53
|
onQuit: () => void
|
|
38
54
|
onAbort: () => void
|
|
39
55
|
onHelpBack: () => void
|
|
@@ -46,6 +62,13 @@ export interface AppDeps {
|
|
|
46
62
|
onSetupSkip: () => void
|
|
47
63
|
onSetupOAuthHandoff: (provider: ProviderId) => Promise<void>
|
|
48
64
|
onTourDone: () => void
|
|
65
|
+
/**
|
|
66
|
+
* Open the Airbotix Portal wallet/login page in the parent's default
|
|
67
|
+
* browser. Wired into [w] on StartupScreen and into the
|
|
68
|
+
* `stars_exhausted` ErrorScreen so parents can top up without
|
|
69
|
+
* remembering the URL.
|
|
70
|
+
*/
|
|
71
|
+
onOpenWallet: () => void
|
|
49
72
|
}
|
|
50
73
|
|
|
51
74
|
export function App(deps: AppDeps): React.ReactElement {
|
|
@@ -82,7 +105,7 @@ export function App(deps: AppDeps): React.ReactElement {
|
|
|
82
105
|
case "tour":
|
|
83
106
|
return <TourScreen locale={deps.locale} onDone={deps.onTourDone} />
|
|
84
107
|
case "startup":
|
|
85
|
-
return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
|
|
108
|
+
return <StartupScreen locale={deps.locale} coursePack={state.coursePack} toast={state.toast} onStart={deps.onStart} onOpenWallet={deps.onOpenWallet} />
|
|
86
109
|
case "mission":
|
|
87
110
|
return <MissionScreen state={state} locale={deps.locale} onPrompt={deps.onPrompt} onAbort={deps.onAbort} />
|
|
88
111
|
case "help":
|
|
@@ -116,7 +139,10 @@ export function App(deps: AppDeps): React.ReactElement {
|
|
|
116
139
|
variant={state.screen.variant}
|
|
117
140
|
detail={state.screen.detail}
|
|
118
141
|
locale={deps.locale}
|
|
142
|
+
toast={state.toast}
|
|
119
143
|
onRetry={deps.onErrorRetry}
|
|
144
|
+
onReconfigure={RECONFIGURABLE_VARIANTS.has(state.screen.variant) ? deps.onReconfigure : undefined}
|
|
145
|
+
onOpenWallet={state.screen.variant === "stars_exhausted" ? deps.onOpenWallet : undefined}
|
|
120
146
|
onQuit={deps.onQuit}
|
|
121
147
|
/>
|
|
122
148
|
)
|
|
@@ -14,19 +14,36 @@ import React from "react"
|
|
|
14
14
|
import { Box, Text, useInput } from "ink"
|
|
15
15
|
import { getTheme } from "../theme.ts"
|
|
16
16
|
import type { ErrorVariant } from "../../../core/store.ts"
|
|
17
|
+
import { Toast, type ToastState } from "../components/Toast.tsx"
|
|
17
18
|
|
|
18
19
|
interface ErrorScreenProps {
|
|
19
20
|
variant: ErrorVariant
|
|
20
21
|
locale: "zh-Hans" | "en"
|
|
21
22
|
detail?: string
|
|
23
|
+
toast?: ToastState | null
|
|
22
24
|
onRetry?: () => void
|
|
23
25
|
onQuit?: () => void
|
|
26
|
+
/**
|
|
27
|
+
* Open the setup wizard so the parent can change provider / paste a new
|
|
28
|
+
* API key. Wired by AppDeps only for config-related variants
|
|
29
|
+
* (serve_unreachable / port_taken / auth_failed / config_missing) — retry
|
|
30
|
+
* alone won't fix a wrong key.
|
|
31
|
+
*/
|
|
32
|
+
onReconfigure?: () => void
|
|
33
|
+
/**
|
|
34
|
+
* Open the Airbotix Portal wallet page in the parent's default browser.
|
|
35
|
+
* Wired only for `stars_exhausted` so retry-alone (which won't change the
|
|
36
|
+
* balance) is not the only option.
|
|
37
|
+
*/
|
|
38
|
+
onOpenWallet?: () => void
|
|
24
39
|
}
|
|
25
40
|
|
|
26
|
-
export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorScreenProps): React.ReactElement {
|
|
41
|
+
export function ErrorScreen({ variant, locale, detail, toast, onRetry, onQuit, onReconfigure, onOpenWallet }: ErrorScreenProps): React.ReactElement {
|
|
27
42
|
const theme = getTheme()
|
|
28
43
|
useInput((input, key) => {
|
|
29
44
|
if (key.return && onRetry) onRetry()
|
|
45
|
+
else if ((input === "c" || input === "C") && onReconfigure) onReconfigure()
|
|
46
|
+
else if ((input === "w" || input === "W") && onOpenWallet) onOpenWallet()
|
|
30
47
|
else if (input === "q" && onQuit) onQuit()
|
|
31
48
|
})
|
|
32
49
|
const t = STRINGS[locale][variant]
|
|
@@ -52,6 +69,18 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorS
|
|
|
52
69
|
<Text color={theme.fg}> {t.retry}</Text>
|
|
53
70
|
</Box>
|
|
54
71
|
)}
|
|
72
|
+
{onReconfigure && (
|
|
73
|
+
<Box marginRight={2}>
|
|
74
|
+
<Text color={theme.accent}>[c]</Text>
|
|
75
|
+
<Text color={theme.fg}> {STRINGS[locale].reconfigure}</Text>
|
|
76
|
+
</Box>
|
|
77
|
+
)}
|
|
78
|
+
{onOpenWallet && (
|
|
79
|
+
<Box marginRight={2}>
|
|
80
|
+
<Text color={theme.accent}>[w]</Text>
|
|
81
|
+
<Text color={theme.fg}> {STRINGS[locale].topUp}</Text>
|
|
82
|
+
</Box>
|
|
83
|
+
)}
|
|
55
84
|
{onQuit && (
|
|
56
85
|
<Box>
|
|
57
86
|
<Text color={theme.accent}>[q]</Text>
|
|
@@ -59,6 +88,11 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorS
|
|
|
59
88
|
</Box>
|
|
60
89
|
)}
|
|
61
90
|
</Box>
|
|
91
|
+
{toast && (
|
|
92
|
+
<Box marginTop={1}>
|
|
93
|
+
<Toast toast={toast} />
|
|
94
|
+
</Box>
|
|
95
|
+
)}
|
|
62
96
|
</Box>
|
|
63
97
|
)
|
|
64
98
|
}
|
|
@@ -66,6 +100,8 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorS
|
|
|
66
100
|
const STRINGS = {
|
|
67
101
|
"zh-Hans": {
|
|
68
102
|
quit: "退出",
|
|
103
|
+
reconfigure: "改设置(换 key / 换 provider)",
|
|
104
|
+
topUp: "去充值(开浏览器)",
|
|
69
105
|
serve_unreachable: {
|
|
70
106
|
title: "AI 老师还没起来",
|
|
71
107
|
body: "后台 AI 服务好像没启动。要不要再试一次?",
|
|
@@ -83,7 +119,7 @@ const STRINGS = {
|
|
|
83
119
|
},
|
|
84
120
|
stars_exhausted: {
|
|
85
121
|
title: "今天的 ⭐ 用完了",
|
|
86
|
-
body: "今天先到这里啦!\n你做得很好,我们明天接着来。\n
|
|
122
|
+
body: "今天先到这里啦!\n你做得很好,我们明天接着来。\n或者按 [w] 让家长去充值,回来按 Enter 接着做。",
|
|
87
123
|
retry: "找完家长了,再试一次",
|
|
88
124
|
},
|
|
89
125
|
auth_failed: {
|
|
@@ -104,6 +140,8 @@ const STRINGS = {
|
|
|
104
140
|
},
|
|
105
141
|
en: {
|
|
106
142
|
quit: "Quit",
|
|
143
|
+
reconfigure: "Change settings (switch key / provider)",
|
|
144
|
+
topUp: "Top up (opens browser)",
|
|
107
145
|
serve_unreachable: {
|
|
108
146
|
title: "AI teacher didn't start",
|
|
109
147
|
body: "The background AI service isn't running. Try again?",
|
|
@@ -121,7 +159,7 @@ const STRINGS = {
|
|
|
121
159
|
},
|
|
122
160
|
stars_exhausted: {
|
|
123
161
|
title: "Out of ⭐ for today",
|
|
124
|
-
body: "Great work today!\nWe'll pick this up tomorrow.\nOr
|
|
162
|
+
body: "Great work today!\nWe'll pick this up tomorrow.\nOr press [w] so a parent can top up, then press Enter to keep going.",
|
|
125
163
|
retry: "Asked a parent — try again",
|
|
126
164
|
},
|
|
127
165
|
auth_failed: {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Enter → start a free-play session OR continue if a course pack is set
|
|
6
6
|
* c → choose a Course Pack
|
|
7
7
|
* r → resume the last session
|
|
8
|
+
* w → open Airbotix Portal wallet / login in the parent's browser
|
|
8
9
|
* h → show kid-friendly help
|
|
9
10
|
*/
|
|
10
11
|
|
|
@@ -13,19 +14,23 @@ import { Box, Text, useInput } from "ink"
|
|
|
13
14
|
import { getTheme } from "../theme.ts"
|
|
14
15
|
import { KidsLogo } from "../components/KidsLogo.tsx"
|
|
15
16
|
import { KeyHints } from "../components/KeyHints.tsx"
|
|
17
|
+
import { Toast, type ToastState } from "../components/Toast.tsx"
|
|
16
18
|
|
|
17
19
|
interface StartupScreenProps {
|
|
18
20
|
locale: "zh-Hans" | "en"
|
|
19
21
|
coursePack: string | null
|
|
22
|
+
toast: ToastState | null
|
|
20
23
|
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
24
|
+
onOpenWallet: () => void
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProps): React.ReactElement {
|
|
27
|
+
export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet }: StartupScreenProps): React.ReactElement {
|
|
24
28
|
const theme = getTheme()
|
|
25
29
|
useInput((input, key) => {
|
|
26
30
|
if (key.return) onStart(coursePack ? "course" : "free")
|
|
27
31
|
else if (input === "c") onStart("course")
|
|
28
32
|
else if (input === "r") onStart("resume")
|
|
33
|
+
else if (input === "w" || input === "W") onOpenWallet()
|
|
29
34
|
else if (input === "h") onStart("help")
|
|
30
35
|
})
|
|
31
36
|
const t = STRINGS[locale]
|
|
@@ -51,9 +56,15 @@ export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProp
|
|
|
51
56
|
{ key: "Enter", label: coursePack ? t.startCourse : t.startFree },
|
|
52
57
|
{ key: "c", label: t.pickCourse },
|
|
53
58
|
{ key: "r", label: t.resume },
|
|
59
|
+
{ key: "w", label: t.wallet },
|
|
54
60
|
{ key: "h", label: t.help },
|
|
55
61
|
]} />
|
|
56
62
|
</Box>
|
|
63
|
+
{toast && (
|
|
64
|
+
<Box marginTop={1}>
|
|
65
|
+
<Toast toast={toast} />
|
|
66
|
+
</Box>
|
|
67
|
+
)}
|
|
57
68
|
</Box>
|
|
58
69
|
)
|
|
59
70
|
}
|
|
@@ -70,6 +81,7 @@ const STRINGS = {
|
|
|
70
81
|
startCourse: "继续 Course Pack",
|
|
71
82
|
pickCourse: "选 Course Pack",
|
|
72
83
|
resume: "继续上次",
|
|
84
|
+
wallet: "钱包 / 充值(开浏览器)",
|
|
73
85
|
help: "帮助",
|
|
74
86
|
},
|
|
75
87
|
en: {
|
|
@@ -83,6 +95,7 @@ const STRINGS = {
|
|
|
83
95
|
startCourse: "Continue Course Pack",
|
|
84
96
|
pickCourse: "Pick a Course Pack",
|
|
85
97
|
resume: "Resume last session",
|
|
98
|
+
wallet: "Wallet / Top up (opens browser)",
|
|
86
99
|
help: "Help",
|
|
87
100
|
},
|
|
88
101
|
} as const
|