@kidsinai/kids-client 0.0.9 → 0.0.11
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 +8 -0
- package/src/core/serve-manager.ts +73 -12
- package/src/core/store.ts +1 -0
- package/src/index.tsx +56 -1
- package/src/render/ink/App.tsx +18 -1
- package/src/render/ink/screens/ErrorScreen.tsx +27 -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.11",
|
|
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). */
|
|
@@ -39,6 +46,7 @@ export function readEnv(): KidsClientEnv {
|
|
|
39
46
|
return {
|
|
40
47
|
opencodeBaseUrl: process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096",
|
|
41
48
|
opencodeServerPassword: password,
|
|
49
|
+
opencodeServerUsername: process.env.OPENCODE_SERVER_USERNAME || "opencode",
|
|
42
50
|
deeprouterApiKey: process.env.DEEPROUTER_API_KEY ?? "",
|
|
43
51
|
bypassGateway: process.env.KIDS_LLM_BYPASS_GATEWAY === "1",
|
|
44
52
|
coursePack: process.env.KIDS_COURSE_PACK || null,
|
|
@@ -10,15 +10,36 @@
|
|
|
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" }
|
|
16
17
|
| { kind: "spawned"; pid: number }
|
|
18
|
+
// Someone else is holding the port (TCP accepts) but our password doesn't
|
|
19
|
+
// unlock /app. Usually a stale `opencode serve` from a previous run with a
|
|
20
|
+
// different OPENCODE_SERVER_PASSWORD. Telling the kid to retry won't help —
|
|
21
|
+
// they need to free the port (`kids-opencode --shutdown`).
|
|
22
|
+
| { kind: "port_taken_auth_mismatch"; port: string }
|
|
23
|
+
// We spawned a child but it exited before becoming ready (e.g. EADDRINUSE,
|
|
24
|
+
// missing config). exitCode/stderr surface the real cause instead of
|
|
25
|
+
// making the kid wait 10s for a generic timeout.
|
|
26
|
+
| { kind: "spawn_failed"; exitCode: number | null; stderrTail: string }
|
|
17
27
|
| { kind: "timeout"; lastError: string }
|
|
18
28
|
|
|
29
|
+
/** Tri-state probe result; lets ensureReady() distinguish "nobody home"
|
|
30
|
+
* from "someone's home but won't let me in". */
|
|
31
|
+
export type ProbeResult = "ok" | "auth_mismatch" | "offline"
|
|
32
|
+
|
|
19
33
|
export interface ServeManagerOptions {
|
|
20
34
|
baseUrl: string
|
|
21
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
|
|
22
43
|
opencodeBin: string
|
|
23
44
|
/** Max wait for readiness probe in ms. Default 10s. */
|
|
24
45
|
readyTimeoutMs?: number
|
|
@@ -31,17 +52,30 @@ export interface ServeManagerOptions {
|
|
|
31
52
|
export class ServeManager {
|
|
32
53
|
private child: Subprocess | null = null
|
|
33
54
|
private opts: ServeManagerOptions
|
|
55
|
+
// Recent stderr lines from the spawned child, used for spawn_failed
|
|
56
|
+
// diagnostics. Bounded so it doesn't grow unbounded over a long session.
|
|
57
|
+
private stderrTail: string[] = []
|
|
58
|
+
private static STDERR_TAIL_MAX = 20
|
|
34
59
|
|
|
35
60
|
constructor(opts: ServeManagerOptions) {
|
|
36
61
|
this.opts = opts
|
|
37
62
|
}
|
|
38
63
|
|
|
39
64
|
/**
|
|
40
|
-
* Probe baseUrl. If already up, no-op.
|
|
41
|
-
*
|
|
65
|
+
* Probe baseUrl. If already up, no-op. If something else holds the port
|
|
66
|
+
* with a different password, return port_taken_auth_mismatch (don't try
|
|
67
|
+
* to spawn — bind would just fail with EADDRINUSE). Otherwise spawn
|
|
68
|
+
* `opencode serve`, hook stderr parsing, and race the readiness poll
|
|
69
|
+
* against the child's exit so port conflicts surface in <1s instead of
|
|
70
|
+
* timing out after 10s.
|
|
42
71
|
*/
|
|
43
72
|
async ensureReady(): Promise<ServeReadiness> {
|
|
44
|
-
|
|
73
|
+
const initial = await this.probe()
|
|
74
|
+
if (initial === "ok") return { kind: "already_running" }
|
|
75
|
+
if (initial === "auth_mismatch") {
|
|
76
|
+
const url = new URL(this.opts.baseUrl)
|
|
77
|
+
return { kind: "port_taken_auth_mismatch", port: url.port || "4096" }
|
|
78
|
+
}
|
|
45
79
|
|
|
46
80
|
const url = new URL(this.opts.baseUrl)
|
|
47
81
|
const proc = spawn({
|
|
@@ -49,6 +83,7 @@ export class ServeManager {
|
|
|
49
83
|
env: {
|
|
50
84
|
...process.env,
|
|
51
85
|
OPENCODE_SERVER_PASSWORD: this.opts.serverPassword,
|
|
86
|
+
OPENCODE_SERVER_USERNAME: this.opts.serverUsername,
|
|
52
87
|
},
|
|
53
88
|
stdout: "pipe",
|
|
54
89
|
stderr: "pipe",
|
|
@@ -61,9 +96,19 @@ export class ServeManager {
|
|
|
61
96
|
const start = Date.now()
|
|
62
97
|
let lastError = "no response"
|
|
63
98
|
while (Date.now() - start < timeout) {
|
|
64
|
-
|
|
99
|
+
// If the child died on its own (bind failure, bad args), don't keep
|
|
100
|
+
// polling — surface the exit immediately with whatever stderr we got.
|
|
101
|
+
if (proc.exitCode !== null) {
|
|
102
|
+
return {
|
|
103
|
+
kind: "spawn_failed",
|
|
104
|
+
exitCode: proc.exitCode,
|
|
105
|
+
stderrTail: this.stderrTail.join("\n"),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const status = await this.probe()
|
|
109
|
+
if (status === "ok") return { kind: "spawned", pid: proc.pid ?? -1 }
|
|
65
110
|
await new Promise((r) => setTimeout(r, 200))
|
|
66
|
-
lastError = "still booting"
|
|
111
|
+
lastError = status === "auth_mismatch" ? "auth mismatch on /app" : "still booting"
|
|
67
112
|
}
|
|
68
113
|
return { kind: "timeout", lastError }
|
|
69
114
|
}
|
|
@@ -77,17 +122,20 @@ export class ServeManager {
|
|
|
77
122
|
this.child = null
|
|
78
123
|
}
|
|
79
124
|
|
|
80
|
-
/** GET /app with Basic Auth.
|
|
81
|
-
|
|
125
|
+
/** GET /app with Basic Auth.
|
|
126
|
+
* 200 → "ok"; 401/403 → "auth_mismatch" (port owned by another instance
|
|
127
|
+
* whose password differs); anything else (network refused, 5xx, timeout)
|
|
128
|
+
* → "offline". */
|
|
129
|
+
private async probe(): Promise<ProbeResult> {
|
|
82
130
|
try {
|
|
83
131
|
const res = await fetch(`${this.opts.baseUrl}/app`, {
|
|
84
132
|
headers: {
|
|
85
|
-
authorization:
|
|
133
|
+
authorization: buildAuthHeader(this.opts.serverUsername, this.opts.serverPassword),
|
|
86
134
|
},
|
|
87
135
|
})
|
|
88
|
-
return res.
|
|
136
|
+
return classifyProbeStatus(res.status)
|
|
89
137
|
} catch {
|
|
90
|
-
return
|
|
138
|
+
return "offline"
|
|
91
139
|
}
|
|
92
140
|
}
|
|
93
141
|
|
|
@@ -112,11 +160,24 @@ export class ServeManager {
|
|
|
112
160
|
private handleLine(line: string): void {
|
|
113
161
|
if (!line) return
|
|
114
162
|
const audit = parseAuditLine(line)
|
|
115
|
-
if (audit)
|
|
116
|
-
|
|
163
|
+
if (audit) {
|
|
164
|
+
this.opts.onAuditLine?.(audit)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
this.stderrTail.push(line)
|
|
168
|
+
if (this.stderrTail.length > ServeManager.STDERR_TAIL_MAX) {
|
|
169
|
+
this.stderrTail.shift()
|
|
170
|
+
}
|
|
171
|
+
this.opts.onDebugLine?.(line)
|
|
117
172
|
}
|
|
118
173
|
}
|
|
119
174
|
|
|
175
|
+
export function classifyProbeStatus(status: number): ProbeResult {
|
|
176
|
+
if (status >= 200 && status < 300) return "ok"
|
|
177
|
+
if (status === 401 || status === 403) return "auth_mismatch"
|
|
178
|
+
return "offline"
|
|
179
|
+
}
|
|
180
|
+
|
|
120
181
|
export function parseAuditLine(line: string): unknown | null {
|
|
121
182
|
const prefixes = ["[kids-audit] ", "[kids-tui-audit] "]
|
|
122
183
|
for (const prefix of prefixes) {
|
package/src/core/store.ts
CHANGED
package/src/index.tsx
CHANGED
|
@@ -64,6 +64,7 @@ interface AppHandlers {
|
|
|
64
64
|
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
65
65
|
onDangerousAcknowledge: () => void
|
|
66
66
|
onErrorRetry: () => void | Promise<void>
|
|
67
|
+
onReconfigure: () => void
|
|
67
68
|
onQuit: () => void | Promise<void>
|
|
68
69
|
onAbort: () => void
|
|
69
70
|
onHelpBack: () => void
|
|
@@ -189,6 +190,12 @@ function makeHandlers(
|
|
|
189
190
|
// Pre-boot error retry: re-run main isn't trivial; just exit.
|
|
190
191
|
process.exit(1)
|
|
191
192
|
},
|
|
193
|
+
onReconfigure: () => {
|
|
194
|
+
// From an error screen, jump into the setup wizard so the parent can
|
|
195
|
+
// change provider / paste a new API key. onSetupContinue knows whether
|
|
196
|
+
// we're in first-run (resolve gate) or post-boot (reload env + retry).
|
|
197
|
+
store.update({ screen: { kind: "setup" } })
|
|
198
|
+
},
|
|
192
199
|
onQuit: async () => {
|
|
193
200
|
const s = servicesHolder.current
|
|
194
201
|
if (s) return s.quit()
|
|
@@ -211,6 +218,16 @@ function makeHandlers(
|
|
|
211
218
|
onSetupContinue: async () => {
|
|
212
219
|
const r = getResolveSetup()
|
|
213
220
|
if (r) r()
|
|
221
|
+
// Post-boot reconfigure path: services are already up but the env they
|
|
222
|
+
// were booted with is stale. Re-source the env file (the wizard wrote
|
|
223
|
+
// it) and replay the same recovery as [Enter] Retry on the error
|
|
224
|
+
// screen — push the loading screen and re-run readiness probe.
|
|
225
|
+
const s = servicesHolder.current
|
|
226
|
+
if (s) {
|
|
227
|
+
reloadEnvFile(env.configDir)
|
|
228
|
+
Object.assign(env, readEnv())
|
|
229
|
+
await s.handlers.onErrorRetry()
|
|
230
|
+
}
|
|
214
231
|
},
|
|
215
232
|
onSetupSkip: () => {
|
|
216
233
|
const r = getResolveSetup()
|
|
@@ -245,6 +262,7 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
|
|
|
245
262
|
const serve = new ServeManager({
|
|
246
263
|
baseUrl: env.opencodeBaseUrl,
|
|
247
264
|
serverPassword: env.opencodeServerPassword,
|
|
265
|
+
serverUsername: env.opencodeServerUsername,
|
|
248
266
|
opencodeBin: env.opencodeBin,
|
|
249
267
|
onAuditLine: (event) => {
|
|
250
268
|
audit.push(event)
|
|
@@ -254,6 +272,26 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
|
|
|
254
272
|
})
|
|
255
273
|
|
|
256
274
|
const readiness = await serve.ensureReady()
|
|
275
|
+
if (readiness.kind === "port_taken_auth_mismatch") {
|
|
276
|
+
store.update({
|
|
277
|
+
screen: {
|
|
278
|
+
kind: "error",
|
|
279
|
+
variant: "port_taken",
|
|
280
|
+
detail: `port ${readiness.port} held by another opencode serve`,
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
if (readiness.kind === "spawn_failed") {
|
|
286
|
+
store.update({
|
|
287
|
+
screen: {
|
|
288
|
+
kind: "error",
|
|
289
|
+
variant: "serve_unreachable",
|
|
290
|
+
detail: `opencode serve exited (${readiness.exitCode}): ${readiness.stderrTail || "no stderr"}`,
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
257
295
|
if (readiness.kind === "timeout") {
|
|
258
296
|
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: readiness.lastError } })
|
|
259
297
|
return null
|
|
@@ -262,6 +300,7 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
|
|
|
262
300
|
const client = createKidsClient({
|
|
263
301
|
baseUrl: env.opencodeBaseUrl,
|
|
264
302
|
serverPassword: env.opencodeServerPassword,
|
|
303
|
+
serverUsername: env.opencodeServerUsername,
|
|
265
304
|
})
|
|
266
305
|
const session = new SessionManager(client)
|
|
267
306
|
|
|
@@ -491,7 +530,23 @@ function makeFullHandlers(
|
|
|
491
530
|
},
|
|
492
531
|
})
|
|
493
532
|
const again = await serve.ensureReady()
|
|
494
|
-
if (again.kind === "
|
|
533
|
+
if (again.kind === "port_taken_auth_mismatch") {
|
|
534
|
+
store.update({
|
|
535
|
+
screen: {
|
|
536
|
+
kind: "error",
|
|
537
|
+
variant: "port_taken",
|
|
538
|
+
detail: `port ${again.port} held by another opencode serve`,
|
|
539
|
+
},
|
|
540
|
+
})
|
|
541
|
+
} else if (again.kind === "spawn_failed") {
|
|
542
|
+
store.update({
|
|
543
|
+
screen: {
|
|
544
|
+
kind: "error",
|
|
545
|
+
variant: "serve_unreachable",
|
|
546
|
+
detail: `opencode serve exited (${again.exitCode}): ${again.stderrTail || "no stderr"}`,
|
|
547
|
+
},
|
|
548
|
+
})
|
|
549
|
+
} else if (again.kind === "timeout") {
|
|
495
550
|
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: again.lastError } })
|
|
496
551
|
} else {
|
|
497
552
|
store.update({ screen: { kind: "startup" } })
|
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
|
|
@@ -117,6 +133,7 @@ export function App(deps: AppDeps): React.ReactElement {
|
|
|
117
133
|
detail={state.screen.detail}
|
|
118
134
|
locale={deps.locale}
|
|
119
135
|
onRetry={deps.onErrorRetry}
|
|
136
|
+
onReconfigure={RECONFIGURABLE_VARIANTS.has(state.screen.variant) ? deps.onReconfigure : undefined}
|
|
120
137
|
onQuit={deps.onQuit}
|
|
121
138
|
/>
|
|
122
139
|
)
|
|
@@ -21,12 +21,20 @@ interface ErrorScreenProps {
|
|
|
21
21
|
detail?: string
|
|
22
22
|
onRetry?: () => void
|
|
23
23
|
onQuit?: () => void
|
|
24
|
+
/**
|
|
25
|
+
* Open the setup wizard so the parent can change provider / paste a new
|
|
26
|
+
* API key. Wired by AppDeps only for config-related variants
|
|
27
|
+
* (serve_unreachable / port_taken / auth_failed / config_missing) — retry
|
|
28
|
+
* alone won't fix a wrong key.
|
|
29
|
+
*/
|
|
30
|
+
onReconfigure?: () => void
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorScreenProps): React.ReactElement {
|
|
33
|
+
export function ErrorScreen({ variant, locale, detail, onRetry, onQuit, onReconfigure }: ErrorScreenProps): React.ReactElement {
|
|
27
34
|
const theme = getTheme()
|
|
28
35
|
useInput((input, key) => {
|
|
29
36
|
if (key.return && onRetry) onRetry()
|
|
37
|
+
else if ((input === "c" || input === "C") && onReconfigure) onReconfigure()
|
|
30
38
|
else if (input === "q" && onQuit) onQuit()
|
|
31
39
|
})
|
|
32
40
|
const t = STRINGS[locale][variant]
|
|
@@ -52,6 +60,12 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorS
|
|
|
52
60
|
<Text color={theme.fg}> {t.retry}</Text>
|
|
53
61
|
</Box>
|
|
54
62
|
)}
|
|
63
|
+
{onReconfigure && (
|
|
64
|
+
<Box marginRight={2}>
|
|
65
|
+
<Text color={theme.accent}>[c]</Text>
|
|
66
|
+
<Text color={theme.fg}> {STRINGS[locale].reconfigure}</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
)}
|
|
55
69
|
{onQuit && (
|
|
56
70
|
<Box>
|
|
57
71
|
<Text color={theme.accent}>[q]</Text>
|
|
@@ -66,11 +80,17 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorS
|
|
|
66
80
|
const STRINGS = {
|
|
67
81
|
"zh-Hans": {
|
|
68
82
|
quit: "退出",
|
|
83
|
+
reconfigure: "改设置(换 key / 换 provider)",
|
|
69
84
|
serve_unreachable: {
|
|
70
85
|
title: "AI 老师还没起来",
|
|
71
86
|
body: "后台 AI 服务好像没启动。要不要再试一次?",
|
|
72
87
|
retry: "重试",
|
|
73
88
|
},
|
|
89
|
+
port_taken: {
|
|
90
|
+
title: "另一个 AI 老师还在占着位子",
|
|
91
|
+
body: "请家长打开终端,跑一下:\n\n kids-opencode --shutdown\n\n然后按 Enter 再试。",
|
|
92
|
+
retry: "已经关掉了,再试",
|
|
93
|
+
},
|
|
74
94
|
network_down: {
|
|
75
95
|
title: "网络有点问题",
|
|
76
96
|
body: "我没办法连上 AI。等会儿再来,或者问家长检查网络。",
|
|
@@ -99,11 +119,17 @@ const STRINGS = {
|
|
|
99
119
|
},
|
|
100
120
|
en: {
|
|
101
121
|
quit: "Quit",
|
|
122
|
+
reconfigure: "Change settings (switch key / provider)",
|
|
102
123
|
serve_unreachable: {
|
|
103
124
|
title: "AI teacher didn't start",
|
|
104
125
|
body: "The background AI service isn't running. Try again?",
|
|
105
126
|
retry: "Retry",
|
|
106
127
|
},
|
|
128
|
+
port_taken: {
|
|
129
|
+
title: "Another AI teacher is still holding the seat",
|
|
130
|
+
body: "Ask a parent to open a terminal and run:\n\n kids-opencode --shutdown\n\nThen press Enter to try again.",
|
|
131
|
+
retry: "Done — try again",
|
|
132
|
+
},
|
|
107
133
|
network_down: {
|
|
108
134
|
title: "Network trouble",
|
|
109
135
|
body: "I can't reach the AI. Try later, or ask an adult to check the connection.",
|