@kidsinai/kids-client 0.0.2 → 0.0.3
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/env.ts +11 -4
- package/src/core/setup.ts +185 -0
- package/src/core/store.ts +1 -0
- package/src/index.tsx +34 -0
- package/src/render/ink/App.tsx +6 -0
- package/src/render/ink/components/KidsLogo.tsx +70 -0
- package/src/render/ink/screens/SetupScreen.tsx +232 -0
- package/src/render/ink/screens/StartupScreen.tsx +32 -27
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.3",
|
|
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/env.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function readEnv(): KidsClientEnv {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export function validateEnv(env: KidsClientEnv): { ok: true } | { ok: false; reason: string; variant: "config_missing" | "auth_failed" } {
|
|
53
|
+
export function validateEnv(env: KidsClientEnv): { ok: true } | { ok: false; reason: string; variant: "config_missing" | "auth_failed" | "needs_setup" } {
|
|
54
54
|
if (!env.opencodeServerPassword) {
|
|
55
55
|
return {
|
|
56
56
|
ok: false,
|
|
@@ -58,11 +58,18 @@ export function validateEnv(env: KidsClientEnv): { ok: true } | { ok: false; rea
|
|
|
58
58
|
variant: "config_missing",
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
// Accept any supported provider's API key, not just DeepRouter. The
|
|
62
|
+
// setup wizard writes whatever the parent picked into ~/.config/kids-opencode/env
|
|
63
|
+
// which the wrapper sources before exec.
|
|
64
|
+
const hasAnyKey =
|
|
65
|
+
env.deeprouterApiKey
|
|
66
|
+
|| process.env.ANTHROPIC_API_KEY
|
|
67
|
+
|| process.env.OPENAI_API_KEY
|
|
68
|
+
if (!env.bypassGateway && !hasAnyKey) {
|
|
62
69
|
return {
|
|
63
70
|
ok: false,
|
|
64
|
-
reason: "
|
|
65
|
-
variant: "
|
|
71
|
+
reason: "No LLM provider key found. The first-run setup wizard will walk you through this.",
|
|
72
|
+
variant: "needs_setup",
|
|
66
73
|
}
|
|
67
74
|
}
|
|
68
75
|
return { ok: true }
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run setup wizard backend.
|
|
3
|
+
*
|
|
4
|
+
* Writes:
|
|
5
|
+
* ~/.config/kids-opencode/env (KEY=value, chmod 600)
|
|
6
|
+
* ~/.config/kids-opencode/opencode.json (provider + model rewritten)
|
|
7
|
+
*
|
|
8
|
+
* The env file is sourced by bin/kids-opencode before exec'ing
|
|
9
|
+
* kids-client, so the LLM key becomes available to the AI engine
|
|
10
|
+
* (which reads it via opencode.json's `{env:NAME}` interpolation) without
|
|
11
|
+
* polluting the user's shell rc.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs"
|
|
15
|
+
import { dirname, join } from "node:path"
|
|
16
|
+
|
|
17
|
+
export type ProviderId = "anthropic" | "openai" | "deeprouter"
|
|
18
|
+
|
|
19
|
+
export interface ProviderChoice {
|
|
20
|
+
id: ProviderId
|
|
21
|
+
label: string
|
|
22
|
+
hint: string
|
|
23
|
+
envVar: string
|
|
24
|
+
apiKeyUrl: string
|
|
25
|
+
/** opencode.json provider block to use. apiKey defaults to "{env:<envVar>}". */
|
|
26
|
+
config: (envVar: string) => Record<string, unknown>
|
|
27
|
+
/** Default model id for this provider. */
|
|
28
|
+
defaultModel: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const PROVIDERS: ProviderChoice[] = [
|
|
32
|
+
{
|
|
33
|
+
id: "anthropic",
|
|
34
|
+
label: "Anthropic Claude (recommended)",
|
|
35
|
+
hint: "Best for ages 12+. ~$5/month for typical kid use.",
|
|
36
|
+
envVar: "ANTHROPIC_API_KEY",
|
|
37
|
+
apiKeyUrl: "https://console.anthropic.com/settings/keys",
|
|
38
|
+
config: (env) => ({
|
|
39
|
+
anthropic: { apiKey: `{env:${env}}` },
|
|
40
|
+
}),
|
|
41
|
+
defaultModel: "anthropic/claude-3-5-sonnet-20241022",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "openai",
|
|
45
|
+
label: "OpenAI GPT-4",
|
|
46
|
+
hint: "Also works. ~$5-10/month for typical kid use.",
|
|
47
|
+
envVar: "OPENAI_API_KEY",
|
|
48
|
+
apiKeyUrl: "https://platform.openai.com/api-keys",
|
|
49
|
+
config: (env) => ({
|
|
50
|
+
openai: { apiKey: `{env:${env}}` },
|
|
51
|
+
}),
|
|
52
|
+
defaultModel: "openai/gpt-4o",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "deeprouter",
|
|
56
|
+
label: "DeepRouter (Airbotix's own gateway)",
|
|
57
|
+
hint: "Not yet live for public use; recommended for staff dogfood only.",
|
|
58
|
+
envVar: "DEEPROUTER_API_KEY",
|
|
59
|
+
apiKeyUrl: "https://app.airbotix.ai/portal/wallet",
|
|
60
|
+
config: (env) => ({
|
|
61
|
+
deeprouter: {
|
|
62
|
+
type: "openai-compatible",
|
|
63
|
+
baseURL: "https://api.deeprouter.ai/v1",
|
|
64
|
+
apiKey: `{env:${env}}`,
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
defaultModel: "deeprouter/claude-3-5-sonnet",
|
|
68
|
+
},
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
export function findProvider(id: ProviderId): ProviderChoice {
|
|
72
|
+
const p = PROVIDERS.find((p) => p.id === id)
|
|
73
|
+
if (!p) throw new Error(`unknown provider: ${id}`)
|
|
74
|
+
return p
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SaveOptions {
|
|
78
|
+
configDir: string
|
|
79
|
+
provider: ProviderId
|
|
80
|
+
apiKey: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Persist the user's choice. Idempotent — re-running overwrites with the
|
|
85
|
+
* latest. The env file is line-based KEY=value; we preserve unrelated
|
|
86
|
+
* lines so multi-provider setups don't lose state.
|
|
87
|
+
*/
|
|
88
|
+
export function saveSetup(opts: SaveOptions): void {
|
|
89
|
+
const provider = findProvider(opts.provider)
|
|
90
|
+
ensureConfigDir(opts.configDir)
|
|
91
|
+
|
|
92
|
+
// 1. Write the env file with the provider's key.
|
|
93
|
+
const envPath = join(opts.configDir, "env")
|
|
94
|
+
const existing = readEnvFile(envPath)
|
|
95
|
+
existing[provider.envVar] = opts.apiKey
|
|
96
|
+
writeEnvFile(envPath, existing)
|
|
97
|
+
|
|
98
|
+
// 2. Rewrite opencode.json provider section.
|
|
99
|
+
const configPath = join(opts.configDir, "opencode.json")
|
|
100
|
+
const config = readJsonOrEmpty(configPath)
|
|
101
|
+
config.provider = provider.config(provider.envVar)
|
|
102
|
+
config.model = provider.defaultModel
|
|
103
|
+
if (!config.permission) {
|
|
104
|
+
config.permission = {
|
|
105
|
+
default: "ask",
|
|
106
|
+
tools: { read: "ask", write: "ask", edit: "ask", glob: "ask", grep: "ask", webfetch: "ask" },
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!config.agent) {
|
|
110
|
+
config.agent = { tools: ["read", "write", "edit", "glob", "grep", "webfetch"] }
|
|
111
|
+
}
|
|
112
|
+
if (!Array.isArray(config.plugin)) {
|
|
113
|
+
config.plugin = ["@kidsinai/kids-opencode-plugin"]
|
|
114
|
+
}
|
|
115
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8")
|
|
116
|
+
chmodSync(configPath, 0o600)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** True if the user already has a valid key for any supported provider. */
|
|
120
|
+
export function hasAnyProviderKey(configDir: string): boolean {
|
|
121
|
+
const env = readEnvFile(join(configDir, "env"))
|
|
122
|
+
if (env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.DEEPROUTER_API_KEY) return true
|
|
123
|
+
// Also accept keys present in the parent shell env (advanced users).
|
|
124
|
+
return !!(
|
|
125
|
+
process.env.ANTHROPIC_API_KEY
|
|
126
|
+
|| process.env.OPENAI_API_KEY
|
|
127
|
+
|| process.env.DEEPROUTER_API_KEY
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Crude check of API key shape — refuses obvious typos. */
|
|
132
|
+
export function looksLikeApiKey(provider: ProviderId, key: string): boolean {
|
|
133
|
+
const trimmed = key.trim()
|
|
134
|
+
if (trimmed.length < 20) return false
|
|
135
|
+
switch (provider) {
|
|
136
|
+
case "anthropic": return trimmed.startsWith("sk-ant-")
|
|
137
|
+
case "openai": return trimmed.startsWith("sk-") || trimmed.startsWith("sk-proj-")
|
|
138
|
+
case "deeprouter": return trimmed.length >= 24
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── internals ────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function ensureConfigDir(dir: string): void {
|
|
145
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
146
|
+
try { chmodSync(dir, 0o700) } catch { /* not fatal */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readEnvFile(path: string): Record<string, string> {
|
|
150
|
+
if (!existsSync(path)) return {}
|
|
151
|
+
const out: Record<string, string> = {}
|
|
152
|
+
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
|
153
|
+
const line = raw.trim()
|
|
154
|
+
if (!line || line.startsWith("#")) continue
|
|
155
|
+
const eq = line.indexOf("=")
|
|
156
|
+
if (eq <= 0) continue
|
|
157
|
+
const key = line.slice(0, eq).trim()
|
|
158
|
+
let value = line.slice(eq + 1).trim()
|
|
159
|
+
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
|
|
160
|
+
out[key] = value
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeEnvFile(path: string, vars: Record<string, string>): void {
|
|
166
|
+
ensureConfigDir(dirname(path))
|
|
167
|
+
const lines: string[] = [
|
|
168
|
+
"# Generated by kids-opencode setup wizard. Edit at your own risk.",
|
|
169
|
+
"# The wrapper sources this file before launching the AI engine.",
|
|
170
|
+
]
|
|
171
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
172
|
+
lines.push(`${k}="${v}"`)
|
|
173
|
+
}
|
|
174
|
+
writeFileSync(path, lines.join("\n") + "\n", "utf8")
|
|
175
|
+
try { chmodSync(path, 0o600) } catch { /* not fatal */ }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function readJsonOrEmpty(path: string): Record<string, unknown> {
|
|
179
|
+
if (!existsSync(path)) return {}
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
|
|
182
|
+
} catch {
|
|
183
|
+
return {}
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/core/store.ts
CHANGED
package/src/index.tsx
CHANGED
|
@@ -29,6 +29,7 @@ import { readLastSession, writeLastSession } from "./core/last-session.ts"
|
|
|
29
29
|
import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
|
|
30
30
|
import { App } from "./render/ink/App.tsx"
|
|
31
31
|
import { detectDangerousTopicEn, detectDangerousTopicZh } from "./dangerous-topic-bridge.ts"
|
|
32
|
+
import { saveSetup, type ProviderId } from "./core/setup.ts"
|
|
32
33
|
import type { InstalledPack } from "./core/course-pack.ts"
|
|
33
34
|
import { findMission, loadCoursePack } from "@kidsinai/kids-opencode-plugin"
|
|
34
35
|
|
|
@@ -44,6 +45,14 @@ async function main(): Promise<void> {
|
|
|
44
45
|
|
|
45
46
|
const check = validateEnv(env)
|
|
46
47
|
if (!check.ok) {
|
|
48
|
+
if (check.variant === "needs_setup") {
|
|
49
|
+
// First-run wizard. Render the setup screen; the wizard's onSave
|
|
50
|
+
// writes config + env file, then re-launches main() to pick up
|
|
51
|
+
// the new key.
|
|
52
|
+
store.update({ screen: { kind: "setup" } })
|
|
53
|
+
renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
|
|
54
|
+
return
|
|
55
|
+
}
|
|
47
56
|
store.update({ screen: { kind: "error", variant: check.variant, detail: check.reason } })
|
|
48
57
|
renderApp(store, env, installedPacks, baseHandlers(store, env, null, null, null))
|
|
49
58
|
return
|
|
@@ -197,6 +206,28 @@ interface AppHandlers {
|
|
|
197
206
|
onPickerBack: () => void
|
|
198
207
|
onMissionNext: () => void
|
|
199
208
|
onMissionBack: () => void
|
|
209
|
+
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
210
|
+
onSetupSkip: () => void
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function makeSetupHandlers(store: Store, env: ReturnType<typeof readEnv>): Pick<AppHandlers, "onSetupSave" | "onSetupSkip"> {
|
|
214
|
+
return {
|
|
215
|
+
onSetupSave: async (provider, apiKey) => {
|
|
216
|
+
try {
|
|
217
|
+
saveSetup({ configDir: env.configDir, provider, apiKey })
|
|
218
|
+
return { ok: true }
|
|
219
|
+
} catch (err) {
|
|
220
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
onSetupSkip: () => {
|
|
224
|
+
// After setup completes (or user skips), tell the user to restart so
|
|
225
|
+
// the wrapper picks up the new env file. Re-launching main() in-process
|
|
226
|
+
// would require tearing down Ink which is messy; a re-exec is cleaner.
|
|
227
|
+
process.stderr.write("\nKids OpenCode: setup saved. Please run `kids-opencode` again to start.\n")
|
|
228
|
+
process.exit(0)
|
|
229
|
+
},
|
|
230
|
+
}
|
|
200
231
|
}
|
|
201
232
|
|
|
202
233
|
/**
|
|
@@ -212,6 +243,7 @@ function baseHandlers(
|
|
|
212
243
|
serve: ServeManager | null,
|
|
213
244
|
): AppHandlers {
|
|
214
245
|
const noop = (): void => {}
|
|
246
|
+
const setup = makeSetupHandlers(store, env)
|
|
215
247
|
return {
|
|
216
248
|
onStart: noop,
|
|
217
249
|
onPrompt: noop,
|
|
@@ -245,6 +277,7 @@ function baseHandlers(
|
|
|
245
277
|
onPickerBack: () => store.update({ screen: { kind: "startup" } }),
|
|
246
278
|
onMissionNext: noop,
|
|
247
279
|
onMissionBack: noop,
|
|
280
|
+
...setup,
|
|
248
281
|
}
|
|
249
282
|
}
|
|
250
283
|
|
|
@@ -447,6 +480,7 @@ function fullHandlers(
|
|
|
447
480
|
})
|
|
448
481
|
},
|
|
449
482
|
onMissionBack: () => store.update({ screen: { kind: "mission" } }),
|
|
483
|
+
...makeSetupHandlers(store, env),
|
|
450
484
|
}
|
|
451
485
|
}
|
|
452
486
|
|
package/src/render/ink/App.tsx
CHANGED
|
@@ -21,6 +21,8 @@ import { HelpScreen } from "./screens/HelpScreen.tsx"
|
|
|
21
21
|
import { CoursePackPicker } from "./screens/CoursePackPicker.tsx"
|
|
22
22
|
import { MissionCompleteScreen } from "./screens/MissionCompleteScreen.tsx"
|
|
23
23
|
import { LoadingScreen } from "./screens/LoadingScreen.tsx"
|
|
24
|
+
import { SetupScreen } from "./screens/SetupScreen.tsx"
|
|
25
|
+
import type { ProviderId } from "../../core/setup.ts"
|
|
24
26
|
|
|
25
27
|
export interface AppDeps {
|
|
26
28
|
store: Store
|
|
@@ -38,6 +40,8 @@ export interface AppDeps {
|
|
|
38
40
|
onPickerBack: () => void
|
|
39
41
|
onMissionNext: () => void
|
|
40
42
|
onMissionBack: () => void
|
|
43
|
+
onSetupSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
44
|
+
onSetupSkip: () => void
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
export function App(deps: AppDeps): React.ReactElement {
|
|
@@ -69,6 +73,8 @@ export function App(deps: AppDeps): React.ReactElement {
|
|
|
69
73
|
switch (state.screen.kind) {
|
|
70
74
|
case "loading":
|
|
71
75
|
return <LoadingScreen locale={deps.locale} message={state.screen.message} />
|
|
76
|
+
case "setup":
|
|
77
|
+
return <SetupScreen locale={deps.locale} onSave={deps.onSetupSave} onSkip={deps.onSetupSkip} />
|
|
72
78
|
case "startup":
|
|
73
79
|
return <StartupScreen locale={deps.locale} coursePack={state.coursePack} onStart={deps.onStart} />
|
|
74
80
|
case "mission":
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Kids OpenCode" ASCII art logo, color-matched to the brand mark.
|
|
3
|
+
*
|
|
4
|
+
* Brand colors (per the official logo):
|
|
5
|
+
* K — cyan/blue
|
|
6
|
+
* I — orange/yellow
|
|
7
|
+
* D — green
|
|
8
|
+
* S — magenta/purple
|
|
9
|
+
*
|
|
10
|
+
* Each letter is 6 rows × ~7-8 cols of block characters. We render row-by-row
|
|
11
|
+
* with separate <Text> colors per letter to get the multi-color effect Ink
|
|
12
|
+
* can't otherwise produce inside a single string.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React from "react"
|
|
16
|
+
import { Box, Text } from "ink"
|
|
17
|
+
import { getTheme } from "../theme.ts"
|
|
18
|
+
|
|
19
|
+
// Block-letter rows. Each letter is a fixed-width column for clean alignment.
|
|
20
|
+
const ROWS: Array<{ K: string; I: string; D: string; S: string }> = [
|
|
21
|
+
{ K: "██╗ ██╗", I: "██╗", D: "██████╗ ", S: "███████╗" },
|
|
22
|
+
{ K: "██║ ██╔╝", I: "██║", D: "██╔══██╗", S: "██╔════╝" },
|
|
23
|
+
{ K: "█████╔╝ ", I: "██║", D: "██║ ██║", S: "███████╗" },
|
|
24
|
+
{ K: "██╔═██╗ ", I: "██║", D: "██║ ██║", S: "╚════██║" },
|
|
25
|
+
{ K: "██║ ██╗", I: "██║", D: "██████╔╝", S: "███████║" },
|
|
26
|
+
{ K: "╚═╝ ╚═╝", I: "╚═╝", D: "╚═════╝ ", S: "╚══════╝" },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const SPARKLE_ROW_TOP = " ✦ ⭐ ✦ ⭐ "
|
|
30
|
+
const SPARKLE_ROW_BOTTOM = " ⭐ ✦ ⭐ ✦ "
|
|
31
|
+
|
|
32
|
+
export function KidsLogo(): React.ReactElement {
|
|
33
|
+
const theme = getTheme()
|
|
34
|
+
// Colors approximate the brand logo. Use "Bright" variants so dark terminals pop.
|
|
35
|
+
const cK = "cyanBright"
|
|
36
|
+
const cI = "yellow"
|
|
37
|
+
const cD = "greenBright"
|
|
38
|
+
const cS = "magentaBright"
|
|
39
|
+
const gap = " " // 2-col gap between letters
|
|
40
|
+
return (
|
|
41
|
+
<Box flexDirection="column" alignItems="center">
|
|
42
|
+
<Text color={theme.accent}>{SPARKLE_ROW_TOP}</Text>
|
|
43
|
+
{ROWS.map((row, i) => (
|
|
44
|
+
<Box key={i} flexDirection="row">
|
|
45
|
+
<Text color={cK}>{row.K}</Text>
|
|
46
|
+
<Text>{gap}</Text>
|
|
47
|
+
<Text color={cI}>{row.I}</Text>
|
|
48
|
+
<Text>{gap}</Text>
|
|
49
|
+
<Text color={cD}>{row.D}</Text>
|
|
50
|
+
<Text>{gap}</Text>
|
|
51
|
+
<Text color={cS}>{row.S}</Text>
|
|
52
|
+
</Box>
|
|
53
|
+
))}
|
|
54
|
+
<Box marginTop={1}>
|
|
55
|
+
<Text color={cK} bold>{"<"}</Text>
|
|
56
|
+
<Text color={cK} bold>{" O"}</Text>
|
|
57
|
+
<Text color={cD} bold>{"p"}</Text>
|
|
58
|
+
<Text color={cD} bold>{"e"}</Text>
|
|
59
|
+
<Text color={cD} bold>{"n"}</Text>
|
|
60
|
+
<Text color={cD} bold>{"C"}</Text>
|
|
61
|
+
<Text color={cK} bold>{"o"}</Text>
|
|
62
|
+
<Text color={cS} bold>{"d"}</Text>
|
|
63
|
+
<Text color={cS} bold>{"e "}</Text>
|
|
64
|
+
<Text color={cS} bold>{">"}</Text>
|
|
65
|
+
</Box>
|
|
66
|
+
<Text color={cS}>{" ━━━━━━━━━━━━━ "}</Text>
|
|
67
|
+
<Text color={theme.accent}>{SPARKLE_ROW_BOTTOM}</Text>
|
|
68
|
+
</Box>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run setup wizard. Triggered when kids-client detects no LLM
|
|
3
|
+
* provider key is configured.
|
|
4
|
+
*
|
|
5
|
+
* Audience: a parent (the kid sees the intro and is told to grab a
|
|
6
|
+
* grown-up). The wizard walks through:
|
|
7
|
+
* 1. Welcome / "this part needs a grown-up"
|
|
8
|
+
* 2. Pick provider (Anthropic / OpenAI / DeepRouter)
|
|
9
|
+
* 3. Paste API key (with link to where to get one)
|
|
10
|
+
* 4. Save → re-validate → route to startup
|
|
11
|
+
*
|
|
12
|
+
* The choice is persisted via core/setup.ts (writes ~/.config/kids-opencode/env
|
|
13
|
+
* + updates opencode.json provider section).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useState } from "react"
|
|
17
|
+
import { Box, Text, useInput } from "ink"
|
|
18
|
+
import TextInput from "ink-text-input"
|
|
19
|
+
import { getTheme } from "../theme.ts"
|
|
20
|
+
import { KidsLogo } from "../components/KidsLogo.tsx"
|
|
21
|
+
import {
|
|
22
|
+
findProvider,
|
|
23
|
+
looksLikeApiKey,
|
|
24
|
+
PROVIDERS,
|
|
25
|
+
type ProviderId,
|
|
26
|
+
} from "../../../core/setup.ts"
|
|
27
|
+
|
|
28
|
+
type Step = "intro" | "provider" | "apikey" | "saving" | "done" | "error"
|
|
29
|
+
|
|
30
|
+
interface SetupScreenProps {
|
|
31
|
+
locale: "zh-Hans" | "en"
|
|
32
|
+
onSave: (provider: ProviderId, apiKey: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
33
|
+
onSkip: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SetupScreen({ locale, onSave, onSkip }: SetupScreenProps): React.ReactElement {
|
|
37
|
+
const theme = getTheme()
|
|
38
|
+
const t = STRINGS[locale]
|
|
39
|
+
const [step, setStep] = useState<Step>("intro")
|
|
40
|
+
const [providerIdx, setProviderIdx] = useState(0)
|
|
41
|
+
const [apiKey, setApiKey] = useState("")
|
|
42
|
+
const [errorMsg, setErrorMsg] = useState("")
|
|
43
|
+
|
|
44
|
+
useInput((input, key) => {
|
|
45
|
+
if (step === "intro") {
|
|
46
|
+
if (key.return) setStep("provider")
|
|
47
|
+
else if (input === "s" || input === "S") onSkip()
|
|
48
|
+
} else if (step === "provider") {
|
|
49
|
+
if (key.upArrow) setProviderIdx((i) => Math.max(0, i - 1))
|
|
50
|
+
else if (key.downArrow) setProviderIdx((i) => Math.min(PROVIDERS.length - 1, i + 1))
|
|
51
|
+
else if (key.return) setStep("apikey")
|
|
52
|
+
else if (key.escape) setStep("intro")
|
|
53
|
+
} else if (step === "done") {
|
|
54
|
+
if (key.return) onSkip() // continue to startup
|
|
55
|
+
} else if (step === "error") {
|
|
56
|
+
if (key.return) setStep("apikey")
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const provider = PROVIDERS[providerIdx]!
|
|
61
|
+
const providerObj = findProvider(provider.id)
|
|
62
|
+
|
|
63
|
+
if (step === "intro") {
|
|
64
|
+
return (
|
|
65
|
+
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
|
66
|
+
<KidsLogo />
|
|
67
|
+
<Box marginTop={2} borderStyle="round" borderColor={theme.warn} paddingX={2} paddingY={1} flexDirection="column">
|
|
68
|
+
<Text color={theme.warn} bold>{t.introTitle}</Text>
|
|
69
|
+
<Box marginTop={1} flexDirection="column">
|
|
70
|
+
<Text color={theme.fg}>{t.introLine1}</Text>
|
|
71
|
+
<Text color={theme.fg}>{t.introLine2}</Text>
|
|
72
|
+
</Box>
|
|
73
|
+
<Box marginTop={1}>
|
|
74
|
+
<Text color={theme.fgDim}>{t.introCost}</Text>
|
|
75
|
+
</Box>
|
|
76
|
+
</Box>
|
|
77
|
+
<Box marginTop={1}>
|
|
78
|
+
<Text color={theme.accent}>{t.introContinue}</Text>
|
|
79
|
+
</Box>
|
|
80
|
+
</Box>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (step === "provider") {
|
|
85
|
+
return (
|
|
86
|
+
<Box flexDirection="column" borderStyle="double" borderColor={theme.accent} paddingX={2} paddingY={1}>
|
|
87
|
+
<Text color={theme.accent} bold>{t.providerTitle}</Text>
|
|
88
|
+
<Box marginTop={1} flexDirection="column">
|
|
89
|
+
{PROVIDERS.map((p, i) => {
|
|
90
|
+
const active = i === providerIdx
|
|
91
|
+
return (
|
|
92
|
+
<Box key={p.id}>
|
|
93
|
+
<Text color={active ? theme.kid : theme.fg}>{active ? "▶ " : " "}</Text>
|
|
94
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
95
|
+
<Text color={active ? theme.accent : theme.fg} bold={active}>{p.label}</Text>
|
|
96
|
+
<Text color={theme.fgDim} dimColor={!active}> {p.hint}</Text>
|
|
97
|
+
{active && <Text color={theme.fgDim}> {t.getKey}: {p.apiKeyUrl}</Text>}
|
|
98
|
+
</Box>
|
|
99
|
+
</Box>
|
|
100
|
+
)
|
|
101
|
+
})}
|
|
102
|
+
</Box>
|
|
103
|
+
<Box marginTop={1}>
|
|
104
|
+
<Text color={theme.accent}>{t.providerKeys}</Text>
|
|
105
|
+
</Box>
|
|
106
|
+
</Box>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (step === "apikey") {
|
|
111
|
+
return (
|
|
112
|
+
<Box flexDirection="column" borderStyle="double" borderColor={theme.accent} paddingX={2} paddingY={1}>
|
|
113
|
+
<Text color={theme.accent} bold>{t.apiKeyTitle(providerObj.label)}</Text>
|
|
114
|
+
<Box marginTop={1} flexDirection="column">
|
|
115
|
+
<Text color={theme.fgDim}>{t.apiKeyHint(providerObj.apiKeyUrl)}</Text>
|
|
116
|
+
</Box>
|
|
117
|
+
<Box marginTop={1}>
|
|
118
|
+
<Text color={theme.kid}>🔑 </Text>
|
|
119
|
+
<TextInput
|
|
120
|
+
value={apiKey}
|
|
121
|
+
onChange={setApiKey}
|
|
122
|
+
onSubmit={(v) => {
|
|
123
|
+
const k = v.trim()
|
|
124
|
+
if (!looksLikeApiKey(provider.id, k)) {
|
|
125
|
+
setErrorMsg(t.apiKeyInvalid(providerObj.envVar))
|
|
126
|
+
setStep("error")
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
setStep("saving")
|
|
130
|
+
void onSave(provider.id, k).then((res) => {
|
|
131
|
+
if (res.ok) {
|
|
132
|
+
setStep("done")
|
|
133
|
+
} else {
|
|
134
|
+
setErrorMsg(res.reason)
|
|
135
|
+
setStep("error")
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
}}
|
|
139
|
+
placeholder={t.apiKeyPlaceholder(providerObj.envVar)}
|
|
140
|
+
mask="*"
|
|
141
|
+
/>
|
|
142
|
+
</Box>
|
|
143
|
+
<Box marginTop={1}>
|
|
144
|
+
<Text color={theme.fgDim}>{t.apiKeyEnter}</Text>
|
|
145
|
+
</Box>
|
|
146
|
+
</Box>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (step === "saving") {
|
|
151
|
+
return (
|
|
152
|
+
<Box paddingY={1} paddingX={2}>
|
|
153
|
+
<Text color={theme.accent}>{t.saving}</Text>
|
|
154
|
+
</Box>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (step === "error") {
|
|
159
|
+
return (
|
|
160
|
+
<Box flexDirection="column" borderStyle="round" borderColor={theme.danger} paddingX={2} paddingY={1}>
|
|
161
|
+
<Text color={theme.danger} bold>{t.errTitle}</Text>
|
|
162
|
+
<Box marginTop={1}>
|
|
163
|
+
<Text color={theme.fg}>{errorMsg}</Text>
|
|
164
|
+
</Box>
|
|
165
|
+
<Box marginTop={1}>
|
|
166
|
+
<Text color={theme.accent}>{t.errRetry}</Text>
|
|
167
|
+
</Box>
|
|
168
|
+
</Box>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// step === "done"
|
|
173
|
+
return (
|
|
174
|
+
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
|
175
|
+
<KidsLogo />
|
|
176
|
+
<Box marginTop={2} flexDirection="column" alignItems="center">
|
|
177
|
+
<Text color={theme.success} bold>{t.doneTitle}</Text>
|
|
178
|
+
<Box marginTop={1}>
|
|
179
|
+
<Text color={theme.fg}>{t.doneNext}</Text>
|
|
180
|
+
</Box>
|
|
181
|
+
<Box marginTop={1}>
|
|
182
|
+
<Text color={theme.accent}>{t.doneHint}</Text>
|
|
183
|
+
</Box>
|
|
184
|
+
</Box>
|
|
185
|
+
</Box>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const STRINGS = {
|
|
190
|
+
"zh-Hans": {
|
|
191
|
+
introTitle: "👋 这一步需要家长帮忙",
|
|
192
|
+
introLine1: "AI 老师要用一个 \"API key\" 才能工作 —— 就像给它一把钥匙。",
|
|
193
|
+
introLine2: "家长打开账号给 AI 服务(Anthropic / OpenAI 等),拿到 key 粘进来就行。",
|
|
194
|
+
introCost: "通常 ~$5/月,普通孩子用够了。",
|
|
195
|
+
introContinue: "[Enter] 让家长来 · [s] 暂时跳过",
|
|
196
|
+
providerTitle: "选一个 AI 服务",
|
|
197
|
+
providerKeys: "[↑↓] 选 · [Enter] 下一步 · [Esc] 返回",
|
|
198
|
+
getKey: "去拿 key",
|
|
199
|
+
apiKeyTitle: (label: string) => `输入 ${label} 的 API key`,
|
|
200
|
+
apiKeyHint: (url: string) => `没 key?打开浏览器:${url}`,
|
|
201
|
+
apiKeyPlaceholder: (env: string) => `${env}(粘进来后按 Enter)`,
|
|
202
|
+
apiKeyEnter: "[Enter] 保存 · 你的 key 只存在本地",
|
|
203
|
+
apiKeyInvalid: (env: string) => `这看起来不是有效的 ${env}。再试一次。`,
|
|
204
|
+
saving: "保存中…",
|
|
205
|
+
errTitle: "出了点问题",
|
|
206
|
+
errRetry: "[Enter] 再试",
|
|
207
|
+
doneTitle: "🎉 搞定!家长任务完成。",
|
|
208
|
+
doneNext: "你可以让孩子继续了。下一屏是启动屏。",
|
|
209
|
+
doneHint: "[Enter] 开始",
|
|
210
|
+
},
|
|
211
|
+
en: {
|
|
212
|
+
introTitle: "👋 Grown-up help needed for this part",
|
|
213
|
+
introLine1: "The AI teacher needs an \"API key\" to work — think of it as a password.",
|
|
214
|
+
introLine2: "A parent opens an account with an AI service (Anthropic / OpenAI), copies the key, pastes it here.",
|
|
215
|
+
introCost: "Usually ~$5/month for typical kid use.",
|
|
216
|
+
introContinue: "[Enter] Hand to a grown-up · [s] Skip for now",
|
|
217
|
+
providerTitle: "Pick an AI service",
|
|
218
|
+
providerKeys: "[↑↓] choose · [Enter] next · [Esc] back",
|
|
219
|
+
getKey: "Get key at",
|
|
220
|
+
apiKeyTitle: (label: string) => `Enter your ${label} API key`,
|
|
221
|
+
apiKeyHint: (url: string) => `Don't have a key yet? Open: ${url}`,
|
|
222
|
+
apiKeyPlaceholder: (env: string) => `${env} (paste then Enter)`,
|
|
223
|
+
apiKeyEnter: "[Enter] save · Your key stays on this machine.",
|
|
224
|
+
apiKeyInvalid: (env: string) => `That doesn't look like a valid ${env}. Try again.`,
|
|
225
|
+
saving: "Saving…",
|
|
226
|
+
errTitle: "Something went wrong",
|
|
227
|
+
errRetry: "[Enter] Try again",
|
|
228
|
+
doneTitle: "🎉 All set! Grown-up step done.",
|
|
229
|
+
doneNext: "You can hand it back to the kid now. Next screen is the welcome.",
|
|
230
|
+
doneHint: "[Enter] Start",
|
|
231
|
+
},
|
|
232
|
+
} as const
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
* §3.1 Startup screen — first impression. Must paint within 5s.
|
|
3
3
|
*
|
|
4
4
|
* Quick keys:
|
|
5
|
-
* Enter → start a free-play session
|
|
6
|
-
* c → choose a Course Pack
|
|
7
|
-
* r → resume the last session
|
|
8
|
-
* h → show
|
|
5
|
+
* Enter → start a free-play session OR continue if a course pack is set
|
|
6
|
+
* c → choose a Course Pack
|
|
7
|
+
* r → resume the last session
|
|
8
|
+
* h → show kid-friendly help
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import React from "react"
|
|
12
12
|
import { Box, Text, useInput } from "ink"
|
|
13
13
|
import { getTheme } from "../theme.ts"
|
|
14
|
+
import { KidsLogo } from "../components/KidsLogo.tsx"
|
|
14
15
|
import { KeyHints } from "../components/KeyHints.tsx"
|
|
15
16
|
|
|
16
17
|
interface StartupScreenProps {
|
|
@@ -29,23 +30,23 @@ export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProp
|
|
|
29
30
|
})
|
|
30
31
|
const t = STRINGS[locale]
|
|
31
32
|
return (
|
|
32
|
-
<Box flexDirection="column" paddingY={1}>
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
</
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
</
|
|
44
|
-
<Box marginTop={1}>
|
|
45
|
-
<Text color={theme.warn}>{t.helpline}</Text>
|
|
46
|
-
</Box>
|
|
33
|
+
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
|
34
|
+
<KidsLogo />
|
|
35
|
+
<Box marginTop={1} flexDirection="column" alignItems="center">
|
|
36
|
+
<Text color={theme.kid} bold>{t.tagline}</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
<Box marginTop={1} flexDirection="column" alignItems="center">
|
|
39
|
+
<Text color={theme.fg}>{t.line1}</Text>
|
|
40
|
+
<Text color={theme.fg}>{t.line2}</Text>
|
|
41
|
+
<Text color={theme.fg}>{t.line3}</Text>
|
|
42
|
+
</Box>
|
|
43
|
+
<Box marginTop={1}>
|
|
44
|
+
<Text color={theme.fgDim}>{t.disclaim}</Text>
|
|
47
45
|
</Box>
|
|
48
46
|
<Box marginTop={1}>
|
|
47
|
+
<Text color={theme.warn} bold>{t.helpline}</Text>
|
|
48
|
+
</Box>
|
|
49
|
+
<Box marginTop={2}>
|
|
49
50
|
<KeyHints hints={[
|
|
50
51
|
{ key: "Enter", label: coursePack ? t.startCourse : t.startFree },
|
|
51
52
|
{ key: "c", label: t.pickCourse },
|
|
@@ -59,10 +60,12 @@ export function StartupScreen({ locale, coursePack, onStart }: StartupScreenProp
|
|
|
59
60
|
|
|
60
61
|
const STRINGS = {
|
|
61
62
|
"zh-Hans": {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
tagline: "🤖 你的 AI 编程伙伴 🤖",
|
|
64
|
+
line1: "✨ 跟 AI 一起做真实的项目",
|
|
65
|
+
line2: "💡 没有工程师术语,听得懂",
|
|
66
|
+
line3: "🎯 做完一关 → 庆祝下一关",
|
|
67
|
+
disclaim: "我不是真人,有时候会答错。问家长或老师。",
|
|
68
|
+
helpline: "🇦🇺 紧急求助:Kids Helpline 1800 55 1800",
|
|
66
69
|
startFree: "开始新项目",
|
|
67
70
|
startCourse: "继续 Course Pack",
|
|
68
71
|
pickCourse: "选 Course Pack",
|
|
@@ -70,10 +73,12 @@ const STRINGS = {
|
|
|
70
73
|
help: "帮助",
|
|
71
74
|
},
|
|
72
75
|
en: {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
tagline: "🤖 Your AI coding buddy 🤖",
|
|
77
|
+
line1: "✨ Build real projects with AI help",
|
|
78
|
+
line2: "💡 No engineering jargon — easy to follow",
|
|
79
|
+
line3: "🎯 Finish a mission → celebrate the next one",
|
|
80
|
+
disclaim: "I'm not a real person and I can be wrong. Ask a parent or teacher.",
|
|
81
|
+
helpline: "🇦🇺 Emergency: Kids Helpline 1800 55 1800",
|
|
77
82
|
startFree: "Start a new project",
|
|
78
83
|
startCourse: "Continue Course Pack",
|
|
79
84
|
pickCourse: "Pick a Course Pack",
|