@kidsinai/kids-client 0.0.22 → 0.0.23
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/models.ts +7 -2
- package/src/core/serve-manager.ts +24 -2
- package/src/core/session.ts +39 -29
- package/src/index.tsx +26 -5
- package/src/render/ink/App.tsx +5 -2
- package/src/render/ink/screens/StartupScreen.tsx +5 -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.23",
|
|
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/models.ts
CHANGED
|
@@ -17,8 +17,13 @@ export async function listModels(client: OpencodeClient): Promise<ModelChoice[]>
|
|
|
17
17
|
}
|
|
18
18
|
let raw: unknown
|
|
19
19
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Prefer config.providers: it returns the configured/usable providers as
|
|
21
|
+
// { providers:[{id,models:[…]}], default } which flattenModels understands.
|
|
22
|
+
// provider.list returns the full models.dev catalog in a different shape
|
|
23
|
+
// ({ all, connected, default }) that flattens to 0 — that mismatch was why
|
|
24
|
+
// the /model picker showed "No models available". Verified vs serve 1.15.x.
|
|
25
|
+
if (typeof api.config?.providers === "function") raw = await api.config.providers()
|
|
26
|
+
else if (typeof api.provider?.list === "function") raw = await api.provider.list()
|
|
22
27
|
else return []
|
|
23
28
|
} catch {
|
|
24
29
|
return []
|
|
@@ -12,6 +12,21 @@
|
|
|
12
12
|
import { spawn, type Subprocess } from "bun"
|
|
13
13
|
import { buildAuthHeader } from "./connection.ts"
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Overall budget for the readiness poll. First boot in a large repo (file
|
|
17
|
+
* watcher + git + plugin load) can take >10s, so give it room before we
|
|
18
|
+
* surface a serve_unreachable error.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_READY_TIMEOUT_MS = 30_000
|
|
21
|
+
/**
|
|
22
|
+
* Per-probe ceiling. CRITICAL: Bun's `fetch` has no default timeout, so a
|
|
23
|
+
* serve that ACCEPTS the connection but stalls mid-bootstrap (holds `/app`
|
|
24
|
+
* open without sending headers) would hang the probe — and therefore
|
|
25
|
+
* `ensureReady()` — forever, freezing the kid on "Starting AI engine…". An
|
|
26
|
+
* AbortSignal bounds every probe so a stuck serve becomes a retry, not a hang.
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 2_000
|
|
29
|
+
|
|
15
30
|
export type ServeReadiness =
|
|
16
31
|
| { kind: "already_running" }
|
|
17
32
|
| { kind: "spawned"; pid: number }
|
|
@@ -41,8 +56,10 @@ export interface ServeManagerOptions {
|
|
|
41
56
|
*/
|
|
42
57
|
serverUsername: string
|
|
43
58
|
opencodeBin: string
|
|
44
|
-
/** Max wait for readiness
|
|
59
|
+
/** Max total wait for readiness in ms. Default {@link DEFAULT_READY_TIMEOUT_MS}. */
|
|
45
60
|
readyTimeoutMs?: number
|
|
61
|
+
/** Per-probe abort ceiling in ms. Default {@link DEFAULT_PROBE_TIMEOUT_MS}. */
|
|
62
|
+
probeTimeoutMs?: number
|
|
46
63
|
/** Called for every parsed `[kids-audit]` JSON line on stderr. */
|
|
47
64
|
onAuditLine?: (event: unknown) => void
|
|
48
65
|
/** Called for every other (non-audit) stderr line. Useful for debug log. */
|
|
@@ -92,7 +109,7 @@ export class ServeManager {
|
|
|
92
109
|
|
|
93
110
|
if (proc.stderr) void this.pipeStderr(proc.stderr)
|
|
94
111
|
|
|
95
|
-
const timeout = this.opts.readyTimeoutMs ??
|
|
112
|
+
const timeout = this.opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS
|
|
96
113
|
const start = Date.now()
|
|
97
114
|
let lastError = "no response"
|
|
98
115
|
while (Date.now() - start < timeout) {
|
|
@@ -132,9 +149,14 @@ export class ServeManager {
|
|
|
132
149
|
headers: {
|
|
133
150
|
authorization: buildAuthHeader(this.opts.serverUsername, this.opts.serverPassword),
|
|
134
151
|
},
|
|
152
|
+
// Without this, a serve that accepts the socket but stalls mid-boot
|
|
153
|
+
// hangs the probe forever (Bun fetch has no default timeout).
|
|
154
|
+
signal: AbortSignal.timeout(this.opts.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS),
|
|
135
155
|
})
|
|
136
156
|
return classifyProbeStatus(res.status)
|
|
137
157
|
} catch {
|
|
158
|
+
// AbortError (timeout) and connection-refused both land here → treat as
|
|
159
|
+
// "nobody answering yet" so ensureReady() retries instead of freezing.
|
|
138
160
|
return "offline"
|
|
139
161
|
}
|
|
140
162
|
}
|
package/src/core/session.ts
CHANGED
|
@@ -67,11 +67,13 @@ export class SessionManager {
|
|
|
67
67
|
*/
|
|
68
68
|
async loadMessages(sessionID: string): Promise<ChatMessage[]> {
|
|
69
69
|
this.currentSessionId = sessionID
|
|
70
|
-
const api = (this.client as unknown as { session?: { messages?: (p: unknown) => Promise<unknown> } }).session
|
|
70
|
+
const api = (this.client as unknown as { session?: { messages?: (p: unknown, o?: unknown) => Promise<unknown> } }).session
|
|
71
71
|
if (typeof api?.messages !== "function") return []
|
|
72
72
|
let raw: unknown
|
|
73
73
|
try {
|
|
74
|
-
|
|
74
|
+
// SDK v2 flat params: `sessionID` → path, `limit` → query. There is no
|
|
75
|
+
// `order` param — the route already returns messages chronologically.
|
|
76
|
+
raw = await api.messages({ sessionID, limit: 200 }, SDK_THROW)
|
|
75
77
|
} catch {
|
|
76
78
|
return []
|
|
77
79
|
}
|
|
@@ -104,15 +106,18 @@ export class SessionManager {
|
|
|
104
106
|
const sessionID = this.currentSessionId!
|
|
105
107
|
const api = (this.client as unknown as { session?: { prompt: (parameters: unknown, options?: unknown) => Promise<unknown> } }).session
|
|
106
108
|
if (!api?.prompt) throw new Error("SDK v2: client.session.prompt unavailable")
|
|
107
|
-
// SDK
|
|
108
|
-
//
|
|
109
|
-
//
|
|
109
|
+
// SDK v2 prompt takes ONE flat params object; buildClientParams routes
|
|
110
|
+
// `sessionID` → URL path and `parts`/`model`/`agent` → body. A flat
|
|
111
|
+
// { sessionID, prompt:{text} } leaves body undefined — serve 1.15.x rejects
|
|
112
|
+
// it ("Expected object" / "Missing key parts"), which surfaced as endless
|
|
113
|
+
// "thinking…" then a Network-trouble error. Verified against 1.15.x: this
|
|
114
|
+
// shape returns 200. Pass SDK_THROW so 4xx/5xx surface as exceptions.
|
|
110
115
|
// `model` (from the /model picker) is a "providerID/modelID" string; the SDK
|
|
111
116
|
// wants it split into { providerID, modelID }.
|
|
112
117
|
const model = splitModelId(opts?.model)
|
|
113
118
|
const payload = {
|
|
114
119
|
sessionID,
|
|
115
|
-
|
|
120
|
+
parts: [{ type: "text", text }],
|
|
116
121
|
...(model ? { model } : {}),
|
|
117
122
|
...(opts?.agent ? { agent: opts.agent } : {}),
|
|
118
123
|
}
|
|
@@ -163,38 +168,43 @@ function splitModelId(id: string | undefined): { providerID: string; modelID: st
|
|
|
163
168
|
|
|
164
169
|
/** session.messages returns `{ items }` or `{ data: { items } }`. */
|
|
165
170
|
function unwrapItems(result: unknown): unknown[] {
|
|
171
|
+
if (Array.isArray(result)) return result
|
|
166
172
|
if (result && typeof result === "object") {
|
|
167
|
-
const r = result as { items?: unknown; data?:
|
|
173
|
+
const r = result as { items?: unknown; data?: unknown }
|
|
168
174
|
if (Array.isArray(r.items)) return r.items
|
|
169
|
-
|
|
175
|
+
// SDK v2 with throwOnError returns { data: T[], request, response }.
|
|
176
|
+
if (Array.isArray(r.data)) return r.data
|
|
177
|
+
const di = (r.data as { items?: unknown })?.items
|
|
178
|
+
if (Array.isArray(di)) return di
|
|
170
179
|
}
|
|
171
180
|
return []
|
|
172
181
|
}
|
|
173
182
|
|
|
174
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* Map a server SessionMessage to our ChatMessage; null = skip (tool/control).
|
|
185
|
+
*
|
|
186
|
+
* SDK v2 list items are `{ info: Message, parts: Part[] }`: the role/id/time
|
|
187
|
+
* live on `info`, and the visible text is the concatenation of the `text`
|
|
188
|
+
* parts. (The pre-v2 flat `{ type, text, content }` shape no longer applies.)
|
|
189
|
+
*/
|
|
175
190
|
export function mapServerMessage(m: unknown): ChatMessage | null {
|
|
176
191
|
if (!m || typeof m !== "object") return null
|
|
177
192
|
const o = m as {
|
|
178
|
-
id?: string
|
|
179
|
-
type?: string
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
.join("")
|
|
194
|
-
.trim()
|
|
195
|
-
if (!text) return null
|
|
196
|
-
return { id, actor: "agent", text, streaming: false, ts }
|
|
197
|
-
}
|
|
193
|
+
info?: { id?: string; role?: string; time?: { created?: number } }
|
|
194
|
+
parts?: Array<{ type?: string; text?: string }>
|
|
195
|
+
}
|
|
196
|
+
const info = o.info
|
|
197
|
+
if (!info || typeof info !== "object") return null
|
|
198
|
+
const id = info.id ?? `srv-${info.time?.created ?? 0}`
|
|
199
|
+
const ts = typeof info.time?.created === "number" ? info.time.created : 0
|
|
200
|
+
const text = (Array.isArray(o.parts) ? o.parts : [])
|
|
201
|
+
.filter((p) => p?.type === "text" && typeof p.text === "string")
|
|
202
|
+
.map((p) => p.text)
|
|
203
|
+
.join("")
|
|
204
|
+
.trim()
|
|
205
|
+
if (!text) return null
|
|
206
|
+
if (info.role === "user") return { id, actor: "kid", text, streaming: false, ts }
|
|
207
|
+
if (info.role === "assistant") return { id, actor: "agent", text, streaming: false, ts }
|
|
198
208
|
return null
|
|
199
209
|
}
|
|
200
210
|
|
package/src/index.tsx
CHANGED
|
@@ -54,7 +54,7 @@ interface ServiceSet {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
interface FullHandlers {
|
|
57
|
-
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
57
|
+
onStart: (mode: "free" | "course" | "resume" | "help" | "settings") => void
|
|
58
58
|
onPrompt: (text: string) => Promise<void>
|
|
59
59
|
onPermissionReply: (decision: "allow" | "deny" | "edit") => Promise<void>
|
|
60
60
|
onAbort: () => Promise<void>
|
|
@@ -66,7 +66,7 @@ interface FullHandlers {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
interface AppHandlers {
|
|
69
|
-
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
69
|
+
onStart: (mode: "free" | "course" | "resume" | "help" | "settings") => void
|
|
70
70
|
onPrompt: (text: string) => void
|
|
71
71
|
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
72
72
|
onDangerousAcknowledge: () => void
|
|
@@ -212,7 +212,7 @@ function makeHandlers(
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
return {
|
|
215
|
-
onStart: ifBooted((s, mode: "free" | "course" | "resume" | "help") => s.handlers.onStart(mode)),
|
|
215
|
+
onStart: ifBooted((s, mode: "free" | "course" | "resume" | "help" | "settings") => s.handlers.onStart(mode)),
|
|
216
216
|
onPrompt: ifBooted((s, text: string) => s.handlers.onPrompt(text)),
|
|
217
217
|
onPermissionReply: ifBooted((s, d: "allow" | "deny" | "edit") => s.handlers.onPermissionReply(d)),
|
|
218
218
|
onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
|
|
@@ -571,6 +571,11 @@ function makeFullHandlers(
|
|
|
571
571
|
store.update({ screen: { kind: "help" } })
|
|
572
572
|
return
|
|
573
573
|
}
|
|
574
|
+
if (mode === "settings") {
|
|
575
|
+
// Re-open the setup wizard to change provider / API key / model.
|
|
576
|
+
store.update({ screen: { kind: "setup" } })
|
|
577
|
+
return
|
|
578
|
+
}
|
|
574
579
|
if (mode === "course") {
|
|
575
580
|
store.update({ screen: { kind: "course_picker" } })
|
|
576
581
|
return
|
|
@@ -650,7 +655,8 @@ function makeFullHandlers(
|
|
|
650
655
|
try {
|
|
651
656
|
await session.prompt(text, { model: snap.selectedModel ?? undefined })
|
|
652
657
|
} catch (err) {
|
|
653
|
-
|
|
658
|
+
const detail = errMessage(err)
|
|
659
|
+
store.update({ thinking: false, screen: { kind: "error", variant: classifyLlmError(detail), detail } })
|
|
654
660
|
}
|
|
655
661
|
},
|
|
656
662
|
onPermissionReply: async (decision) => {
|
|
@@ -840,7 +846,7 @@ function handlePluginAudit(event: unknown, store: Store): void {
|
|
|
840
846
|
* codes like WALLET_INSUFFICIENT / FAMILY_PAUSED (platform-backend §7) or
|
|
841
847
|
* plain English ("insufficient credits", "rate limit", "402").
|
|
842
848
|
*/
|
|
843
|
-
function classifyLlmError(msg: string): "stars_exhausted" | "network_down" {
|
|
849
|
+
function classifyLlmError(msg: string): "stars_exhausted" | "auth_failed" | "network_down" {
|
|
844
850
|
const m = msg.toLowerCase()
|
|
845
851
|
if (
|
|
846
852
|
m.includes("wallet_insufficient")
|
|
@@ -853,6 +859,21 @@ function classifyLlmError(msg: string): "stars_exhausted" | "network_down" {
|
|
|
853
859
|
) {
|
|
854
860
|
return "stars_exhausted"
|
|
855
861
|
}
|
|
862
|
+
// Auth/sign-in failures (e.g. ChatGPT OAuth token invalidated, 401) are NOT
|
|
863
|
+
// network problems — the fix is to re-authenticate, so route to the
|
|
864
|
+
// reconfigurable error screen instead of the dead-end "can't reach AI".
|
|
865
|
+
if (
|
|
866
|
+
m.includes("token_invalidated")
|
|
867
|
+
|| m.includes("authentication token")
|
|
868
|
+
|| m.includes("sign in again")
|
|
869
|
+
|| m.includes("signing in")
|
|
870
|
+
|| m.includes("invalidated")
|
|
871
|
+
|| m.includes("unauthorized")
|
|
872
|
+
|| m.includes("invalid_api_key")
|
|
873
|
+
|| m.includes("401")
|
|
874
|
+
) {
|
|
875
|
+
return "auth_failed"
|
|
876
|
+
}
|
|
856
877
|
return "network_down"
|
|
857
878
|
}
|
|
858
879
|
|
package/src/render/ink/App.tsx
CHANGED
|
@@ -42,7 +42,7 @@ export interface AppDeps {
|
|
|
42
42
|
store: Store
|
|
43
43
|
locale: "zh-Hans" | "en"
|
|
44
44
|
installedPacks: InstalledPack[]
|
|
45
|
-
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
45
|
+
onStart: (mode: "free" | "course" | "resume" | "help" | "settings") => void
|
|
46
46
|
onPrompt: (text: string) => void
|
|
47
47
|
onPermissionReply: (decision: "allow" | "deny" | "edit") => void
|
|
48
48
|
onDangerousAcknowledge: () => void
|
|
@@ -106,7 +106,10 @@ export function App(deps: AppDeps): React.ReactElement {
|
|
|
106
106
|
|
|
107
107
|
const screen = renderScreen(state, deps)
|
|
108
108
|
return (
|
|
109
|
-
|
|
109
|
+
// paddingTop gives every screen a row of breathing room instead of being
|
|
110
|
+
// glued to the terminal's top edge. It's constant, so the App's footprint
|
|
111
|
+
// still never changes between renders (see the height-lock note above).
|
|
112
|
+
<Box width={width} height={height} flexDirection="column" paddingTop={1}>
|
|
110
113
|
{screen}
|
|
111
114
|
</Box>
|
|
112
115
|
)
|
|
@@ -23,7 +23,7 @@ interface StartupScreenProps {
|
|
|
23
23
|
locale: "zh-Hans" | "en"
|
|
24
24
|
coursePack: string | null
|
|
25
25
|
toast: ToastState | null
|
|
26
|
-
onStart: (mode: "free" | "course" | "resume" | "help") => void
|
|
26
|
+
onStart: (mode: "free" | "course" | "resume" | "help" | "settings") => void
|
|
27
27
|
onOpenWallet: () => void
|
|
28
28
|
onQuit: () => void
|
|
29
29
|
}
|
|
@@ -36,6 +36,7 @@ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet
|
|
|
36
36
|
else if (input === "f") onStart("free")
|
|
37
37
|
else if (input === "r") onStart("resume")
|
|
38
38
|
else if (input === "w" || input === "W") onOpenWallet()
|
|
39
|
+
else if (input === "s" || input === "S") onStart("settings")
|
|
39
40
|
else if (input === "h") onStart("help")
|
|
40
41
|
else if (input === "q" || input === "Q") onQuit()
|
|
41
42
|
})
|
|
@@ -64,6 +65,7 @@ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet
|
|
|
64
65
|
{ key: "f", label: t.startFree },
|
|
65
66
|
{ key: "r", label: t.resume },
|
|
66
67
|
{ key: "w", label: t.wallet },
|
|
68
|
+
{ key: "s", label: t.settings },
|
|
67
69
|
{ key: "h", label: t.help },
|
|
68
70
|
{ key: "q", label: t.quit },
|
|
69
71
|
]} />
|
|
@@ -90,6 +92,7 @@ const STRINGS = {
|
|
|
90
92
|
pickCourse: "选 Course Pack",
|
|
91
93
|
resume: "继续上次",
|
|
92
94
|
wallet: "钱包 / 充值(开浏览器)",
|
|
95
|
+
settings: "设置 / 换模型",
|
|
93
96
|
help: "帮助",
|
|
94
97
|
quit: "退出",
|
|
95
98
|
},
|
|
@@ -105,6 +108,7 @@ const STRINGS = {
|
|
|
105
108
|
pickCourse: "Pick a Course Pack",
|
|
106
109
|
resume: "Resume last session",
|
|
107
110
|
wallet: "Wallet / Top up (opens browser)",
|
|
111
|
+
settings: "Settings / change model",
|
|
108
112
|
help: "Help",
|
|
109
113
|
quit: "Quit",
|
|
110
114
|
},
|