@kidsinai/kids-client 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/bin/kids-client +4 -0
- package/package.json +45 -0
- package/src/core/audit-pipeline.ts +93 -0
- package/src/core/check-runner.ts +77 -0
- package/src/core/connection.ts +26 -0
- package/src/core/course-pack.ts +89 -0
- package/src/core/env.ts +69 -0
- package/src/core/events.ts +168 -0
- package/src/core/last-session.ts +48 -0
- package/src/core/serve-manager.ts +132 -0
- package/src/core/session.ts +63 -0
- package/src/core/store.ts +165 -0
- package/src/dangerous-topic-bridge.ts +50 -0
- package/src/index.tsx +513 -0
- package/src/render/ink/App.tsx +112 -0
- package/src/render/ink/components/ChatStream.tsx +62 -0
- package/src/render/ink/components/Header.tsx +35 -0
- package/src/render/ink/components/Input.tsx +28 -0
- package/src/render/ink/components/KeyHints.tsx +21 -0
- package/src/render/ink/components/Thinking.tsx +21 -0
- package/src/render/ink/components/Toast.tsx +29 -0
- package/src/render/ink/screens/CoursePackPicker.tsx +90 -0
- package/src/render/ink/screens/DangerousTopicModal.tsx +63 -0
- package/src/render/ink/screens/ErrorScreen.tsx +133 -0
- package/src/render/ink/screens/HelpScreen.tsx +96 -0
- package/src/render/ink/screens/LoadingScreen.tsx +33 -0
- package/src/render/ink/screens/MissionCompleteScreen.tsx +112 -0
- package/src/render/ink/screens/MissionScreen.tsx +85 -0
- package/src/render/ink/screens/PermissionModal.tsx +77 -0
- package/src/render/ink/screens/StartupScreen.tsx +83 -0
- package/src/render/ink/theme.ts +58 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persists "last session" metadata so the [r] Resume option on the
|
|
3
|
+
* Startup screen has something to offer.
|
|
4
|
+
*
|
|
5
|
+
* Note: this is NOT LLM-session resumption (PRD §5.3, deferred to V1
|
|
6
|
+
* because client owns serve subprocess). It only remembers which
|
|
7
|
+
* course/mission the kid was working on so they jump back into the
|
|
8
|
+
* MissionScreen without re-entering flags.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"
|
|
12
|
+
import { dirname, join } from "node:path"
|
|
13
|
+
|
|
14
|
+
export interface LastSession {
|
|
15
|
+
coursePack: string | null
|
|
16
|
+
mission: string | null
|
|
17
|
+
/** ISO timestamp of the last user action. */
|
|
18
|
+
lastActiveAt: string
|
|
19
|
+
/** Working directory at the time, so resume picks the right project. */
|
|
20
|
+
projectDir: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function lastSessionPath(configDir: string): string {
|
|
24
|
+
return join(configDir, "last-session.json")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readLastSession(configDir: string): LastSession | null {
|
|
28
|
+
const path = lastSessionPath(configDir)
|
|
29
|
+
if (!existsSync(path)) return null
|
|
30
|
+
try {
|
|
31
|
+
const raw = readFileSync(path, "utf8")
|
|
32
|
+
const parsed = JSON.parse(raw) as LastSession
|
|
33
|
+
if (typeof parsed.lastActiveAt !== "string") return null
|
|
34
|
+
return parsed
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function writeLastSession(configDir: string, session: LastSession): void {
|
|
41
|
+
const path = lastSessionPath(configDir)
|
|
42
|
+
try {
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
44
|
+
writeFileSync(path, JSON.stringify(session, null, 2), "utf8")
|
|
45
|
+
} catch {
|
|
46
|
+
// Resume is a nice-to-have; silently skip if we can't persist.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the local `opencode serve` subprocess for the lifetime of this client.
|
|
3
|
+
*
|
|
4
|
+
* Per docs/v2-api-verification.md Q3 (resolved 2026-05-16): plugin cannot
|
|
5
|
+
* publish custom events through SDK public API, so the audit pipeline
|
|
6
|
+
* tails serve stderr and parses `[kids-audit] {...}` lines.
|
|
7
|
+
*
|
|
8
|
+
* V0 scope cut: client crash kills serve. Session-resume (PRD §5.3) is
|
|
9
|
+
* deferred to V1. See Q3 spike notes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn, type Subprocess } from "bun"
|
|
13
|
+
|
|
14
|
+
export type ServeReadiness =
|
|
15
|
+
| { kind: "already_running" }
|
|
16
|
+
| { kind: "spawned"; pid: number }
|
|
17
|
+
| { kind: "timeout"; lastError: string }
|
|
18
|
+
|
|
19
|
+
export interface ServeManagerOptions {
|
|
20
|
+
baseUrl: string
|
|
21
|
+
serverPassword: string
|
|
22
|
+
opencodeBin: string
|
|
23
|
+
/** Max wait for readiness probe in ms. Default 10s. */
|
|
24
|
+
readyTimeoutMs?: number
|
|
25
|
+
/** Called for every parsed `[kids-audit]` JSON line on stderr. */
|
|
26
|
+
onAuditLine?: (event: unknown) => void
|
|
27
|
+
/** Called for every other (non-audit) stderr line. Useful for debug log. */
|
|
28
|
+
onDebugLine?: (line: string) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ServeManager {
|
|
32
|
+
private child: Subprocess | null = null
|
|
33
|
+
private opts: ServeManagerOptions
|
|
34
|
+
|
|
35
|
+
constructor(opts: ServeManagerOptions) {
|
|
36
|
+
this.opts = opts
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Probe baseUrl. If already up, no-op. Otherwise spawn `opencode serve`
|
|
41
|
+
* as a child, hook stderr parsing, poll until /app responds 200.
|
|
42
|
+
*/
|
|
43
|
+
async ensureReady(): Promise<ServeReadiness> {
|
|
44
|
+
if (await this.probe()) return { kind: "already_running" }
|
|
45
|
+
|
|
46
|
+
const url = new URL(this.opts.baseUrl)
|
|
47
|
+
const proc = spawn({
|
|
48
|
+
cmd: [this.opts.opencodeBin, "serve", "--hostname", url.hostname, "--port", url.port || "4096"],
|
|
49
|
+
env: {
|
|
50
|
+
...process.env,
|
|
51
|
+
OPENCODE_SERVER_PASSWORD: this.opts.serverPassword,
|
|
52
|
+
},
|
|
53
|
+
stdout: "pipe",
|
|
54
|
+
stderr: "pipe",
|
|
55
|
+
})
|
|
56
|
+
this.child = proc
|
|
57
|
+
|
|
58
|
+
if (proc.stderr) void this.pipeStderr(proc.stderr)
|
|
59
|
+
|
|
60
|
+
const timeout = this.opts.readyTimeoutMs ?? 10_000
|
|
61
|
+
const start = Date.now()
|
|
62
|
+
let lastError = "no response"
|
|
63
|
+
while (Date.now() - start < timeout) {
|
|
64
|
+
if (await this.probe()) return { kind: "spawned", pid: proc.pid ?? -1 }
|
|
65
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
66
|
+
lastError = "still booting"
|
|
67
|
+
}
|
|
68
|
+
return { kind: "timeout", lastError }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Kill the serve child (V0 MVP: on client exit). */
|
|
72
|
+
async shutdown(): Promise<void> {
|
|
73
|
+
if (this.child && !this.child.killed) {
|
|
74
|
+
this.child.kill()
|
|
75
|
+
await this.child.exited
|
|
76
|
+
}
|
|
77
|
+
this.child = null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** GET /app with Basic Auth. Returns true on 200. */
|
|
81
|
+
private async probe(): Promise<boolean> {
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(`${this.opts.baseUrl}/app`, {
|
|
84
|
+
headers: {
|
|
85
|
+
authorization: "Basic " + btoa(`:${this.opts.serverPassword}`),
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
return res.ok
|
|
89
|
+
} catch {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async pipeStderr(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
95
|
+
const decoder = new TextDecoder()
|
|
96
|
+
let buf = ""
|
|
97
|
+
const reader = stream.getReader()
|
|
98
|
+
while (true) {
|
|
99
|
+
const { value, done } = await reader.read()
|
|
100
|
+
if (done) break
|
|
101
|
+
buf += decoder.decode(value, { stream: true })
|
|
102
|
+
let nl: number
|
|
103
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
104
|
+
const line = buf.slice(0, nl)
|
|
105
|
+
buf = buf.slice(nl + 1)
|
|
106
|
+
this.handleLine(line)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (buf.length > 0) this.handleLine(buf)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private handleLine(line: string): void {
|
|
113
|
+
if (!line) return
|
|
114
|
+
const audit = parseAuditLine(line)
|
|
115
|
+
if (audit) this.opts.onAuditLine?.(audit)
|
|
116
|
+
else this.opts.onDebugLine?.(line)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function parseAuditLine(line: string): unknown | null {
|
|
121
|
+
const prefixes = ["[kids-audit] ", "[kids-tui-audit] "]
|
|
122
|
+
for (const prefix of prefixes) {
|
|
123
|
+
if (line.startsWith(prefix)) {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(line.slice(prefix.length))
|
|
126
|
+
} catch {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle wrapper. Thin shim around SDK v2 session resource.
|
|
3
|
+
*
|
|
4
|
+
* Public API:
|
|
5
|
+
* - createSession(): create a new session, return its ID
|
|
6
|
+
* - prompt(text): send a kid message; returns immediately (SSE delivers stream)
|
|
7
|
+
* - abort(): stop the in-flight prompt; session stays live
|
|
8
|
+
*
|
|
9
|
+
* Errors propagate to caller; UI maps to ErrorScreen variants.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { OpencodeClient } from "./connection.ts"
|
|
13
|
+
|
|
14
|
+
export class SessionManager {
|
|
15
|
+
private client: OpencodeClient
|
|
16
|
+
private currentSessionId: string | null = null
|
|
17
|
+
|
|
18
|
+
constructor(client: OpencodeClient) {
|
|
19
|
+
this.client = client
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getId(): string | null {
|
|
23
|
+
return this.currentSessionId
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async create(): Promise<string> {
|
|
27
|
+
const api = (this.client as unknown as { session?: { create: (input?: unknown) => Promise<{ data?: { id?: string } } | { id?: string } | string> } }).session
|
|
28
|
+
if (!api?.create) throw new Error("SDK v2: client.session.create unavailable")
|
|
29
|
+
const result = await api.create({})
|
|
30
|
+
const id = extractId(result)
|
|
31
|
+
if (!id) throw new Error("SDK v2 session.create returned no id")
|
|
32
|
+
this.currentSessionId = id
|
|
33
|
+
return id
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async prompt(text: string, opts?: { model?: string; agent?: string }): Promise<void> {
|
|
37
|
+
if (!this.currentSessionId) await this.create()
|
|
38
|
+
const sessionID = this.currentSessionId!
|
|
39
|
+
const api = (this.client as unknown as { session?: { prompt: (sessionID: string, body: unknown) => Promise<unknown> } }).session
|
|
40
|
+
if (!api?.prompt) throw new Error("SDK v2: client.session.prompt unavailable")
|
|
41
|
+
await api.prompt(sessionID, {
|
|
42
|
+
parts: [{ type: "text", text }],
|
|
43
|
+
model: opts?.model,
|
|
44
|
+
agent: opts?.agent,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async abort(): Promise<void> {
|
|
49
|
+
if (!this.currentSessionId) return
|
|
50
|
+
const api = (this.client as unknown as { session?: { abort: (sessionID: string) => Promise<unknown> } }).session
|
|
51
|
+
if (!api?.abort) return
|
|
52
|
+
await api.abort(this.currentSessionId)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractId(result: unknown): string | null {
|
|
57
|
+
if (typeof result === "string") return result
|
|
58
|
+
if (result && typeof result === "object") {
|
|
59
|
+
const r = result as { id?: string; data?: { id?: string } }
|
|
60
|
+
return r.id ?? r.data?.id ?? null
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process state store. Pure TS, no Ink imports — V1 Tauri (WebView)
|
|
3
|
+
* reuses this verbatim.
|
|
4
|
+
*
|
|
5
|
+
* Subscription pattern: dumb pub/sub. Renderers (Ink today, WebView later)
|
|
6
|
+
* subscribe to changes via getSnapshot + subscribe pair, suitable for
|
|
7
|
+
* React 18 useSyncExternalStore.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type Screen =
|
|
11
|
+
| { kind: "loading"; message?: string }
|
|
12
|
+
| { kind: "startup" }
|
|
13
|
+
| { kind: "mission" }
|
|
14
|
+
| { kind: "help" }
|
|
15
|
+
| { kind: "course_picker" }
|
|
16
|
+
| {
|
|
17
|
+
kind: "mission_complete"
|
|
18
|
+
missionId: string
|
|
19
|
+
missionTitle: string | null
|
|
20
|
+
passed: number
|
|
21
|
+
total: number
|
|
22
|
+
completionMessage: string
|
|
23
|
+
hasNextMission: boolean
|
|
24
|
+
}
|
|
25
|
+
| { kind: "error"; variant: ErrorVariant; detail?: string }
|
|
26
|
+
|
|
27
|
+
export type ErrorVariant =
|
|
28
|
+
| "serve_unreachable"
|
|
29
|
+
| "network_down"
|
|
30
|
+
| "stars_exhausted"
|
|
31
|
+
| "auth_failed"
|
|
32
|
+
| "config_missing"
|
|
33
|
+
| "ai_hung"
|
|
34
|
+
|
|
35
|
+
export interface ChatMessage {
|
|
36
|
+
id: string
|
|
37
|
+
actor: "kid" | "agent" | "system"
|
|
38
|
+
text: string
|
|
39
|
+
/** Stream still flowing for this message. */
|
|
40
|
+
streaming: boolean
|
|
41
|
+
ts: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PendingPermission {
|
|
45
|
+
requestID: string
|
|
46
|
+
tool?: string
|
|
47
|
+
/** Free-form text the kid sees ("AI 想要读取 index.html") */
|
|
48
|
+
summary: string
|
|
49
|
+
metadata: Record<string, unknown>
|
|
50
|
+
/** Plugin-reported predicted Stars cost for this tool call. */
|
|
51
|
+
starsEstimated?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DangerousTopic {
|
|
55
|
+
category: "self_harm" | "violence" | "adult" | "other"
|
|
56
|
+
snippet: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ToastState {
|
|
60
|
+
kind: "info" | "warn" | "success"
|
|
61
|
+
text: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface KidsClientState {
|
|
65
|
+
screen: Screen
|
|
66
|
+
sessionId: string | null
|
|
67
|
+
messages: ChatMessage[]
|
|
68
|
+
starsBalance: number
|
|
69
|
+
starsBudget: number
|
|
70
|
+
pendingPermission: PendingPermission | null
|
|
71
|
+
dangerousTopic: DangerousTopic | null
|
|
72
|
+
thinking: boolean
|
|
73
|
+
/** Set when wrapper passed --course or KIDS_COURSE_PACK. */
|
|
74
|
+
coursePack: string | null
|
|
75
|
+
mission: string | null
|
|
76
|
+
/** Resolved from CoursePack metadata. null in free-play. */
|
|
77
|
+
packTitle: string | null
|
|
78
|
+
missionTitle: string | null
|
|
79
|
+
/** 1-based current mission index within pack.missions; null in free-play. */
|
|
80
|
+
missionIndex: number | null
|
|
81
|
+
missionTotal: number | null
|
|
82
|
+
/** Transient toast (auto-dismisses after a few seconds in the orchestrator). */
|
|
83
|
+
toast: ToastState | null
|
|
84
|
+
/** Plugin emitted audit events kept for parent dashboard sync (capped). */
|
|
85
|
+
auditBuffer: unknown[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type Listener = (state: KidsClientState) => void
|
|
89
|
+
|
|
90
|
+
const INITIAL: KidsClientState = {
|
|
91
|
+
screen: { kind: "loading" },
|
|
92
|
+
sessionId: null,
|
|
93
|
+
messages: [],
|
|
94
|
+
starsBalance: 0,
|
|
95
|
+
starsBudget: 0,
|
|
96
|
+
pendingPermission: null,
|
|
97
|
+
dangerousTopic: null,
|
|
98
|
+
thinking: false,
|
|
99
|
+
coursePack: null,
|
|
100
|
+
mission: null,
|
|
101
|
+
packTitle: null,
|
|
102
|
+
missionTitle: null,
|
|
103
|
+
missionIndex: null,
|
|
104
|
+
missionTotal: null,
|
|
105
|
+
toast: null,
|
|
106
|
+
auditBuffer: [],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const AUDIT_BUFFER_CAP = 500
|
|
110
|
+
|
|
111
|
+
export class Store {
|
|
112
|
+
private state: KidsClientState = INITIAL
|
|
113
|
+
private listeners = new Set<Listener>()
|
|
114
|
+
|
|
115
|
+
getSnapshot(): KidsClientState {
|
|
116
|
+
return this.state
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
subscribe(fn: Listener): () => void {
|
|
120
|
+
this.listeners.add(fn)
|
|
121
|
+
return () => this.listeners.delete(fn)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
update(patch: Partial<KidsClientState>): void {
|
|
125
|
+
this.state = { ...this.state, ...patch }
|
|
126
|
+
this.notify()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
appendMessage(msg: ChatMessage): void {
|
|
130
|
+
this.state = { ...this.state, messages: [...this.state.messages, msg] }
|
|
131
|
+
this.notify()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
appendDelta(messageId: string, delta: string): void {
|
|
135
|
+
const messages = this.state.messages.map((m) =>
|
|
136
|
+
m.id === messageId ? { ...m, text: m.text + delta } : m,
|
|
137
|
+
)
|
|
138
|
+
this.state = { ...this.state, messages }
|
|
139
|
+
this.notify()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
endStream(messageId: string): void {
|
|
143
|
+
const messages = this.state.messages.map((m) =>
|
|
144
|
+
m.id === messageId ? { ...m, streaming: false } : m,
|
|
145
|
+
)
|
|
146
|
+
this.state = { ...this.state, messages, thinking: false }
|
|
147
|
+
this.notify()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pushAudit(event: unknown): void {
|
|
151
|
+
const next = [...this.state.auditBuffer, event]
|
|
152
|
+
if (next.length > AUDIT_BUFFER_CAP) next.shift()
|
|
153
|
+
this.state = { ...this.state, auditBuffer: next }
|
|
154
|
+
this.notify()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
reset(): void {
|
|
158
|
+
this.state = INITIAL
|
|
159
|
+
this.notify()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private notify(): void {
|
|
163
|
+
for (const fn of this.listeners) fn(this.state)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin re-export so the client can reuse kids-tui-plugin's pattern list
|
|
3
|
+
* without duplicating it. If the pattern set evolves there, we pick up
|
|
4
|
+
* the change for free via the workspace dep.
|
|
5
|
+
*
|
|
6
|
+
* The kids-tui-plugin export is the source of truth.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type DangerousCategory = "self_harm" | "violence" | "adult" | "other"
|
|
10
|
+
|
|
11
|
+
interface DangerousMatcher {
|
|
12
|
+
category: DangerousCategory
|
|
13
|
+
patterns: RegExp[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Curated mirror of the patterns we know live in kids-tui-plugin/src/dangerous-topic.ts.
|
|
17
|
+
// Keeping these here as a redundancy in case the upstream module isn't
|
|
18
|
+
// loaded yet (the workspace dep should resolve, but at the time of this
|
|
19
|
+
// commit kids-tui-plugin hasn't been imported into kids-client tests).
|
|
20
|
+
//
|
|
21
|
+
// When the actual file becomes available via the workspace, replace
|
|
22
|
+
// these arrays with `import { detect } from "@kidsinai/kids-opencode-tui-plugin"`.
|
|
23
|
+
const ZH: DangerousMatcher[] = [
|
|
24
|
+
{ category: "self_harm", patterns: [/想.{0,3}(自杀|死|结束.{0,2}生命)/, /(伤害自己|自残)/] },
|
|
25
|
+
{ category: "violence", patterns: [/(怎么.{0,3}杀|做.{0,2}炸弹|做.{0,2}武器)/] },
|
|
26
|
+
{ category: "adult", patterns: [/(色情|裸照|性.{0,2}行为)/] },
|
|
27
|
+
]
|
|
28
|
+
const EN: DangerousMatcher[] = [
|
|
29
|
+
{ category: "self_harm", patterns: [/\b(kill\s+myself|end\s+my\s+life|suicide|self[-\s]?harm)\b/i] },
|
|
30
|
+
{ category: "violence", patterns: [/\b(make\s+a\s+bomb|how\s+to\s+kill|weapon)\b/i] },
|
|
31
|
+
{ category: "adult", patterns: [/\b(nude|porn|sexual\s+act)\b/i] },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
function detect(text: string, matchers: DangerousMatcher[]): DangerousCategory | null {
|
|
35
|
+
if (!text) return null
|
|
36
|
+
for (const m of matchers) {
|
|
37
|
+
for (const p of m.patterns) {
|
|
38
|
+
if (p.test(text)) return m.category
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function detectDangerousTopicZh(text: string): DangerousCategory | null {
|
|
45
|
+
return detect(text, ZH)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function detectDangerousTopicEn(text: string): DangerousCategory | null {
|
|
49
|
+
return detect(text, EN)
|
|
50
|
+
}
|