@jsbot-tellus/core 2.5.0 → 2.6.0

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.
Files changed (175) hide show
  1. package/README.md +8 -5
  2. package/dist/app/cli.d.ts +10 -0
  3. package/dist/app/cli.d.ts.map +1 -0
  4. package/dist/app/cli.js +37 -0
  5. package/dist/app/cli.js.map +1 -0
  6. package/dist/app/runtime.d.ts +121 -0
  7. package/dist/app/runtime.d.ts.map +1 -0
  8. package/dist/app/runtime.js +296 -0
  9. package/dist/app/runtime.js.map +1 -0
  10. package/dist/app.js +63 -273
  11. package/dist/app.js.map +1 -1
  12. package/dist/core/plugin/command-params.d.ts +3 -37
  13. package/dist/core/plugin/command-params.d.ts.map +1 -1
  14. package/dist/core/plugin/command-params.js +2 -580
  15. package/dist/core/plugin/command-params.js.map +1 -1
  16. package/dist/core/plugin/install/paths.d.ts +7 -0
  17. package/dist/core/plugin/install/paths.d.ts.map +1 -0
  18. package/dist/core/plugin/install/paths.js +49 -0
  19. package/dist/core/plugin/install/paths.js.map +1 -0
  20. package/dist/core/plugin/install/state.d.ts +23 -0
  21. package/dist/core/plugin/install/state.d.ts.map +1 -0
  22. package/dist/core/plugin/install/state.js +129 -0
  23. package/dist/core/plugin/install/state.js.map +1 -0
  24. package/dist/core/plugin/install/validate-script.d.ts +3 -0
  25. package/dist/core/plugin/install/validate-script.d.ts.map +1 -0
  26. package/dist/core/plugin/install/validate-script.js +154 -0
  27. package/dist/core/plugin/install/validate-script.js.map +1 -0
  28. package/dist/core/plugin/install-dir.d.ts +3 -28
  29. package/dist/core/plugin/install-dir.d.ts.map +1 -1
  30. package/dist/core/plugin/install-dir.js +3 -326
  31. package/dist/core/plugin/install-dir.js.map +1 -1
  32. package/dist/core/plugin/installer/checks.d.ts +6 -0
  33. package/dist/core/plugin/installer/checks.d.ts.map +1 -0
  34. package/dist/core/plugin/installer/checks.js +99 -0
  35. package/dist/core/plugin/installer/checks.js.map +1 -0
  36. package/dist/core/plugin/installer/support.d.ts +37 -0
  37. package/dist/core/plugin/installer/support.d.ts.map +1 -0
  38. package/dist/core/plugin/installer/support.js +67 -0
  39. package/dist/core/plugin/installer/support.js.map +1 -0
  40. package/dist/core/plugin/installer/warnings.d.ts +5 -0
  41. package/dist/core/plugin/installer/warnings.d.ts.map +1 -0
  42. package/dist/core/plugin/installer/warnings.js +28 -0
  43. package/dist/core/plugin/installer/warnings.js.map +1 -0
  44. package/dist/core/plugin/installer.d.ts +4 -30
  45. package/dist/core/plugin/installer.d.ts.map +1 -1
  46. package/dist/core/plugin/installer.js +21 -202
  47. package/dist/core/plugin/installer.js.map +1 -1
  48. package/dist/core/plugin/params/compile.d.ts +3 -0
  49. package/dist/core/plugin/params/compile.d.ts.map +1 -0
  50. package/dist/core/plugin/params/compile.js +23 -0
  51. package/dist/core/plugin/params/compile.js.map +1 -0
  52. package/dist/core/plugin/params/declaration.d.ts +9 -0
  53. package/dist/core/plugin/params/declaration.d.ts.map +1 -0
  54. package/dist/core/plugin/params/declaration.js +286 -0
  55. package/dist/core/plugin/params/declaration.js.map +1 -0
  56. package/dist/core/plugin/params/input.d.ts +5 -0
  57. package/dist/core/plugin/params/input.d.ts.map +1 -0
  58. package/dist/core/plugin/params/input.js +196 -0
  59. package/dist/core/plugin/params/input.js.map +1 -0
  60. package/dist/core/plugin/params/reply.d.ts +3 -0
  61. package/dist/core/plugin/params/reply.d.ts.map +1 -0
  62. package/dist/core/plugin/params/reply.js +49 -0
  63. package/dist/core/plugin/params/reply.js.map +1 -0
  64. package/dist/core/plugin/params/type-level.d.ts +24 -0
  65. package/dist/core/plugin/params/type-level.d.ts.map +1 -0
  66. package/dist/core/plugin/params/type-level.js +2 -0
  67. package/dist/core/plugin/params/type-level.js.map +1 -0
  68. package/dist/core/plugin/params/types.d.ts +33 -0
  69. package/dist/core/plugin/params/types.d.ts.map +1 -0
  70. package/dist/core/plugin/params/types.js +12 -0
  71. package/dist/core/plugin/params/types.js.map +1 -0
  72. package/dist/core/plugin/params/values.d.ts +7 -0
  73. package/dist/core/plugin/params/values.d.ts.map +1 -0
  74. package/dist/core/plugin/params/values.js +111 -0
  75. package/dist/core/plugin/params/values.js.map +1 -0
  76. package/dist/core/web/api.d.ts +2 -14
  77. package/dist/core/web/api.d.ts.map +1 -1
  78. package/dist/core/web/api.js +8 -500
  79. package/dist/core/web/api.js.map +1 -1
  80. package/dist/core/web/routes/actions.d.ts +4 -0
  81. package/dist/core/web/routes/actions.d.ts.map +1 -0
  82. package/dist/core/web/routes/actions.js +33 -0
  83. package/dist/core/web/routes/actions.js.map +1 -0
  84. package/dist/core/web/routes/config.d.ts +5 -0
  85. package/dist/core/web/routes/config.d.ts.map +1 -0
  86. package/dist/core/web/routes/config.js +30 -0
  87. package/dist/core/web/routes/config.js.map +1 -0
  88. package/dist/core/web/routes/kv.d.ts +6 -0
  89. package/dist/core/web/routes/kv.d.ts.map +1 -0
  90. package/dist/core/web/routes/kv.js +80 -0
  91. package/dist/core/web/routes/kv.js.map +1 -0
  92. package/dist/core/web/routes/plugins.d.ts +5 -0
  93. package/dist/core/web/routes/plugins.d.ts.map +1 -0
  94. package/dist/core/web/routes/plugins.js +98 -0
  95. package/dist/core/web/routes/plugins.js.map +1 -0
  96. package/dist/core/web/routes/sessions.d.ts +8 -0
  97. package/dist/core/web/routes/sessions.d.ts.map +1 -0
  98. package/dist/core/web/routes/sessions.js +130 -0
  99. package/dist/core/web/routes/sessions.js.map +1 -0
  100. package/dist/core/web/routes/shared.d.ts +18 -0
  101. package/dist/core/web/routes/shared.d.ts.map +1 -0
  102. package/dist/core/web/routes/shared.js +19 -0
  103. package/dist/core/web/routes/shared.js.map +1 -0
  104. package/dist/core/web/routes/status.d.ts +6 -0
  105. package/dist/core/web/routes/status.d.ts.map +1 -0
  106. package/dist/core/web/routes/status.js +132 -0
  107. package/dist/core/web/routes/status.js.map +1 -0
  108. package/dist/core/web/server/auth.d.ts +17 -0
  109. package/dist/core/web/server/auth.d.ts.map +1 -0
  110. package/dist/core/web/server/auth.js +68 -0
  111. package/dist/core/web/server/auth.js.map +1 -0
  112. package/dist/core/web/server/csp.d.ts +11 -0
  113. package/dist/core/web/server/csp.d.ts.map +1 -0
  114. package/dist/core/web/server/csp.js +31 -0
  115. package/dist/core/web/server/csp.js.map +1 -0
  116. package/dist/core/web/server/request.d.ts +6 -0
  117. package/dist/core/web/server/request.d.ts.map +1 -0
  118. package/dist/core/web/server/request.js +33 -0
  119. package/dist/core/web/server/request.js.map +1 -0
  120. package/dist/core/web/server/security.d.ts +4 -0
  121. package/dist/core/web/server/security.d.ts.map +1 -0
  122. package/dist/core/web/server/security.js +12 -0
  123. package/dist/core/web/server/security.js.map +1 -0
  124. package/dist/core/web/server/sse.d.ts +18 -0
  125. package/dist/core/web/server/sse.d.ts.map +1 -0
  126. package/dist/core/web/server/sse.js +49 -0
  127. package/dist/core/web/server/sse.js.map +1 -0
  128. package/dist/core/web/server/static.d.ts +16 -0
  129. package/dist/core/web/server/static.d.ts.map +1 -0
  130. package/dist/core/web/server/static.js +80 -0
  131. package/dist/core/web/server/static.js.map +1 -0
  132. package/dist/core/web/server.d.ts +0 -7
  133. package/dist/core/web/server.d.ts.map +1 -1
  134. package/dist/core/web/server.js +41 -263
  135. package/dist/core/web/server.js.map +1 -1
  136. package/docs/architecture.md +28 -11
  137. package/docs/configuration.md +1 -1
  138. package/docs/migration.md +2 -2
  139. package/docs/plugin-api.md +24 -11
  140. package/docs/plugin-development.md +1 -1
  141. package/package.json +19 -17
  142. package/src/app/cli.ts +47 -0
  143. package/src/app/runtime.ts +522 -0
  144. package/src/app.ts +87 -395
  145. package/src/core/plugin/command-params.ts +8 -871
  146. package/src/core/plugin/install/paths.ts +72 -0
  147. package/src/core/plugin/install/state.ts +196 -0
  148. package/src/core/plugin/install/validate-script.ts +154 -0
  149. package/src/core/plugin/install-dir.ts +19 -427
  150. package/src/core/plugin/installer/checks.ts +137 -0
  151. package/src/core/plugin/installer/support.ts +120 -0
  152. package/src/core/plugin/installer/warnings.ts +52 -0
  153. package/src/core/plugin/installer.ts +52 -301
  154. package/src/core/plugin/params/compile.ts +41 -0
  155. package/src/core/plugin/params/declaration.ts +394 -0
  156. package/src/core/plugin/params/input.ts +281 -0
  157. package/src/core/plugin/params/reply.ts +59 -0
  158. package/src/core/plugin/params/type-level.ts +75 -0
  159. package/src/core/plugin/params/types.ts +55 -0
  160. package/src/core/plugin/params/values.ts +150 -0
  161. package/src/core/web/api.ts +33 -759
  162. package/src/core/web/routes/actions.ts +43 -0
  163. package/src/core/web/routes/config.ts +42 -0
  164. package/src/core/web/routes/kv.ts +122 -0
  165. package/src/core/web/routes/plugins.ts +151 -0
  166. package/src/core/web/routes/sessions.ts +188 -0
  167. package/src/core/web/routes/shared.ts +42 -0
  168. package/src/core/web/routes/status.ts +191 -0
  169. package/src/core/web/server/auth.ts +126 -0
  170. package/src/core/web/server/csp.ts +33 -0
  171. package/src/core/web/server/request.ts +38 -0
  172. package/src/core/web/server/security.ts +14 -0
  173. package/src/core/web/server/sse.ts +74 -0
  174. package/src/core/web/server/static.ts +112 -0
  175. package/src/core/web/server.ts +41 -383
@@ -1,764 +1,38 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http'
2
- import type { Database } from '../storage/database.js'
3
- import { SqlitePluginKV } from '../plugin/kv.js'
4
- import { stripPluginEnvelope } from '../plugin/host.js'
1
+ import { HttpError } from './http.js'
5
2
  import {
6
- SqliteSessionStore,
7
- type SessionMessage,
8
- type SessionRole,
9
- type SessionState,
10
- } from '../storage/session.js'
11
- import { logLevels, type LogLevel } from '../logging/logger.js'
12
- import type { AuthSession } from './auth.js'
13
- import type { FeedEventType } from './event-feed.js'
14
- import { maskConfig } from './config-store.js'
15
- import type { WebUiDeps, WebUiRuntime } from './types.js'
3
+ handleStatus,
4
+ handleCommands,
5
+ handleLogsList,
6
+ handleEventsList,
7
+ } from './routes/status.js'
16
8
  import {
17
- HttpError,
18
- isRecord,
19
- readJsonBody,
20
- sendData,
21
- withTimeout,
22
- type URLSearchParamsLike,
23
- } from './http.js'
24
-
25
- export interface ApiContext {
26
- deps: WebUiDeps
27
- req: IncomingMessage
28
- res: ServerResponse
29
- method: string
30
- pathname: string
31
- segments: string[]
32
- search: URLSearchParamsLike
33
- session: AuthSession
34
- }
35
-
36
- type MaybeParams = URLSearchParams | URLSearchParamsLike
37
-
38
- const MAX_SESSION_MESSAGES = 10_000
39
-
40
- interface LoginValue {
41
- userId: number
42
- nickname: string
43
- }
44
-
45
- const loginCache = new WeakMap<
46
- WebUiRuntime,
47
- { at: number; value: LoginValue | null }
48
- >()
49
-
50
- function requireRuntime(deps: WebUiDeps): WebUiRuntime {
51
- const runtime = deps.getRuntime()
52
-
53
- if (!runtime) throw new HttpError(503, '运行时不可用,请稍后再试')
54
-
55
- return runtime
56
- }
57
-
58
- function countKvPlugins(database: Database): number {
59
- const row = database.connection
60
- .prepare('SELECT COUNT(DISTINCT plugin_id) AS count FROM plugin_kv')
61
- .get() as { count: number } | undefined
62
-
63
- return row?.count ?? 0
64
- }
65
-
66
- async function loginInfo(runtime: WebUiRuntime): Promise<LoginValue | null> {
67
- if (!runtime.kernel.isConnected) return null
68
-
69
- const cached = loginCache.get(runtime)
70
-
71
- if (cached && Date.now() - cached.at < 10_000) return cached.value
72
-
73
- let value: LoginValue | null = null
74
-
75
- try {
76
- const info = await withTimeout(runtime.api.getLoginInfo(), 2_000)
77
-
78
- if (typeof info.user_id === 'number') {
79
- value = {
80
- userId: info.user_id,
81
- nickname: info.nickname || String(info.user_id),
82
- }
83
- }
84
- } catch {
85
- value = null
86
- }
87
-
88
- loginCache.set(runtime, { at: Date.now(), value })
89
-
90
- return value
91
- }
92
-
93
- interface RuntimeStatus {
94
- generation: number
95
- state: string
96
- uptimeMs: number
97
- connected: boolean
98
- login: LoginValue | null
99
- counts: {
100
- plugins: number
101
- loadedPlugins: number
102
- enabledPlugins: number
103
- sessions: number
104
- kvPlugins: number
105
- }
106
- }
107
-
108
- async function runtimeStatus(runtime: WebUiRuntime): Promise<RuntimeStatus> {
109
- const statuses = runtime.kernel.listPlugins()
110
- const sessions = await new SqliteSessionStore(runtime.database).count()
111
-
112
- return {
113
- generation: runtime.generation,
114
- state: runtime.kernel.state,
115
- uptimeMs: runtime.kernel.uptimeMs,
116
- connected: runtime.kernel.isConnected,
117
- login: await loginInfo(runtime),
118
- counts: {
119
- plugins: statuses.length,
120
- loadedPlugins: statuses.filter((entry) => entry.loaded).length,
121
- enabledPlugins: statuses.filter((entry) => entry.enabled).length,
122
- sessions,
123
- kvPlugins: countKvPlugins(runtime.database),
124
- },
125
- }
126
- }
127
-
128
- function pathsInfo(deps: WebUiDeps) {
129
- return {
130
- config: deps.paths.configPath,
131
- data: deps.paths.dataDir,
132
- plugins: deps.paths.pluginDir,
133
- }
134
- }
135
-
136
- function assertLevel(value: string): LogLevel {
137
- if (!(logLevels as readonly string[]).includes(value)) {
138
- throw new HttpError(400, `invalid log level: ${value}`)
139
- }
140
-
141
- return value as LogLevel
142
- }
143
-
144
- const FEED_TYPES: readonly FeedEventType[] = [
145
- 'message',
146
- 'notice',
147
- 'request',
148
- 'meta',
149
- 'other',
150
- ]
151
-
152
- function assertFeedType(value: string): FeedEventType {
153
- if (!FEED_TYPES.includes(value as FeedEventType)) {
154
- throw new HttpError(400, `invalid event type: ${value}`)
155
- }
156
-
157
- return value as FeedEventType
158
- }
159
-
160
- function clampInt(
161
- value: string | null,
162
- fallback: number,
163
- min: number,
164
- max: number
165
- ): number {
166
- if (value === null) return fallback
167
-
168
- const parsed = Number.parseInt(value, 10)
169
-
170
- if (!Number.isFinite(parsed)) return fallback
171
-
172
- return Math.min(Math.max(parsed, min), max)
173
- }
174
-
175
- function asRecord(value: unknown): Record<string, unknown> {
176
- return isRecord(value) ? value : {}
177
- }
178
-
179
- function parseSessionMessages(value: unknown): SessionMessage[] {
180
- if (!Array.isArray(value)) {
181
- throw new HttpError(400, 'messages must be an array')
182
- }
183
-
184
- if (value.length > MAX_SESSION_MESSAGES) {
185
- throw new HttpError(400, 'messages too long')
186
- }
187
-
188
- return value.map((item, index) => {
189
- if (!isRecord(item)) {
190
- throw new HttpError(400, `invalid message at index ${index}`)
191
- }
192
-
193
- const role = item['role']
194
- const content = item['content']
195
- const at = item['at']
196
-
197
- if (role !== 'system' && role !== 'user' && role !== 'assistant') {
198
- throw new HttpError(400, `invalid message role at index ${index}`)
199
- }
200
-
201
- if (typeof content !== 'string') {
202
- throw new HttpError(
203
- 400,
204
- `invalid message content at index ${index}`
205
- )
206
- }
207
-
208
- if (typeof at !== 'number' || !Number.isFinite(at)) {
209
- throw new HttpError(400, `invalid message time at index ${index}`)
210
- }
211
-
212
- return { role: role as SessionRole, content, at }
213
- })
214
- }
215
-
216
- function clearableString(
217
- value: unknown,
218
- fallback: string | undefined
219
- ): string | undefined {
220
- if (value === null) return undefined
221
- if (value === undefined) return fallback
222
- if (typeof value === 'string' && value.trim().length > 0) {
223
- return value
224
- }
225
-
226
- return undefined
227
- }
228
-
229
- async function handleStatus(ctx: ApiContext): Promise<void> {
230
- const { deps, res } = ctx
231
- const runtime = deps.getRuntime()
232
- const webui = {
233
- listening: deps.getListening(),
234
- requiresProcessRestart: deps.getRequiresProcessRestart(),
235
- }
236
-
237
- if (!runtime) {
238
- sendData(res, 200, {
239
- restarting: deps.isRestarting(),
240
- startupError: deps.getStartupError(),
241
- runtime: null,
242
- webui,
243
- version: deps.version,
244
- paths: pathsInfo(deps),
245
- })
246
-
247
- return
248
- }
249
-
250
- sendData(res, 200, {
251
- restarting: deps.isRestarting(),
252
- startupError: deps.getStartupError(),
253
- runtime: await runtimeStatus(runtime),
254
- webui,
255
- version: deps.version,
256
- paths: pathsInfo(deps),
257
- })
258
- }
259
-
260
- async function handleLogsList(ctx: ApiContext): Promise<void> {
261
- const level = ctx.search.get('level')
262
-
263
- if (level) assertLevel(level)
264
-
265
- const scope = ctx.search.get('scope') ?? undefined
266
- const query = ctx.search.get('query') ?? undefined
267
- const limit = clampInt(ctx.search.get('limit'), 200, 1, 1_000)
268
-
269
- sendData(
270
- ctx.res,
271
- 200,
272
- ctx.deps.logs.list({
273
- ...(level ? { level: assertLevel(level) } : {}),
274
- scope,
275
- query,
276
- limit,
277
- })
278
- )
279
- }
280
-
281
- async function handleEventsList(ctx: ApiContext): Promise<void> {
282
- const type = ctx.search.get('type')
283
-
284
- if (type) assertFeedType(type)
285
-
286
- const limit = clampInt(ctx.search.get('limit'), 100, 1, 500)
287
-
288
- sendData(
289
- ctx.res,
290
- 200,
291
- ctx.deps.events.list({
292
- ...(type ? { type: assertFeedType(type) } : {}),
293
- limit,
294
- })
295
- )
296
- }
297
-
298
- async function handleSessionList(ctx: ApiContext): Promise<void> {
299
- const runtime = requireRuntime(ctx.deps)
300
- const store = new SqliteSessionStore(runtime.database)
301
- const limit = clampInt(ctx.search.get('limit'), 50, 1, 200)
302
- const offset = clampInt(ctx.search.get('offset'), 0, 0, 10_000_000)
303
- const query = ctx.search.get('query')?.slice(0, 200) || undefined
304
- const items = await store.list({ limit, offset, query })
305
- const total = await store.count(query)
306
-
307
- sendData(ctx.res, 200, { items, total, limit, offset })
308
- }
309
-
310
- function sessionIdOf(ctx: ApiContext): string {
311
- const id = ctx.segments[2]
312
-
313
- if (!id) throw new HttpError(404, 'session not found')
314
-
315
- return id
316
- }
317
-
318
- function requireSessionStore(ctx: ApiContext): SqliteSessionStore {
319
- const runtime = requireRuntime(ctx.deps)
320
-
321
- return new SqliteSessionStore(runtime.database)
322
- }
323
-
324
- async function sessionDetail(ctx: ApiContext): Promise<SessionState> {
325
- const id = sessionIdOf(ctx)
326
- const state = await requireSessionStore(ctx).get(id)
327
-
328
- if (!state) throw new HttpError(404, 'session not found')
329
-
330
- return state
331
- }
332
-
333
- async function handleSessionGet(ctx: ApiContext): Promise<void> {
334
- sendData(ctx.res, 200, await sessionDetail(ctx))
335
- }
336
-
337
- async function handleSessionPut(ctx: ApiContext): Promise<void> {
338
- const body = asRecord(await readJsonBody(ctx.req))
339
- await sessionDetail(ctx)
340
-
341
- const messages =
342
- body['messages'] === undefined
343
- ? undefined
344
- : parseSessionMessages(body['messages'])
345
-
346
- const baseline = await sessionDetail(ctx)
347
- const state: SessionState = {
348
- sessionId: sessionIdOf(ctx),
349
- provider: clearableString(body['provider'], baseline.provider),
350
- model: clearableString(body['model'], baseline.model),
351
- persona: clearableString(body['persona'], baseline.persona),
352
- messages: messages ?? baseline.messages,
353
- updatedAt: Date.now(),
354
- }
355
-
356
- await requireSessionStore(ctx).save(state)
357
- sendData(ctx.res, 200, state)
358
- }
359
-
360
- async function handleSessionMessages(ctx: ApiContext): Promise<void> {
361
- const body = asRecord(await readJsonBody(ctx.req))
362
- const role = body['role']
363
- const content = body['content']
364
-
365
- if (role !== 'system' && role !== 'user' && role !== 'assistant') {
366
- throw new HttpError(400, 'invalid message role')
367
- }
368
-
369
- if (
370
- typeof content !== 'string' ||
371
- content.length === 0 ||
372
- content.length > 100_000
373
- ) {
374
- throw new HttpError(400, 'invalid message content')
375
- }
376
-
377
- const state = await sessionDetail(ctx)
378
- const message = { role: role as SessionRole, content, at: Date.now() }
379
- const next: SessionState = {
380
- ...state,
381
- messages: [...state.messages, message],
382
- updatedAt: Date.now(),
383
- }
384
-
385
- await requireSessionStore(ctx).save(next)
386
- sendData(ctx.res, 200, next)
387
- }
388
-
389
- async function handleSessionSend(ctx: ApiContext): Promise<void> {
390
- const body = asRecord(await readJsonBody(ctx.req))
391
- const id = sessionIdOf(ctx)
392
- const message = body['message']
393
- const autoEscape = body['autoEscape']
394
-
395
- if (
396
- typeof message !== 'string' ||
397
- message.length === 0 ||
398
- message.length > 20_000
399
- ) {
400
- throw new HttpError(400, 'invalid message')
401
- }
402
-
403
- const runtime = requireRuntime(ctx.deps)
404
-
405
- await sessionDetail(ctx)
406
-
407
- const result = await runtime.api.send(
408
- id,
409
- message,
410
- typeof autoEscape === 'boolean' ? { autoEscape } : undefined
411
- )
412
-
413
- sendData(ctx.res, 200, { result: result ?? null })
414
- }
415
-
416
- async function handleSessionDelete(ctx: ApiContext): Promise<void> {
417
- await requireSessionStore(ctx).clear(sessionIdOf(ctx))
418
- sendData(ctx.res, 200, { cleared: true })
419
- }
420
-
421
- async function handlePluginsList(ctx: ApiContext): Promise<void> {
422
- const runtime = requireRuntime(ctx.deps)
423
- const statuses = new Map(
424
- runtime.kernel.listPlugins().map((entry) => [entry.id, entry])
425
- )
426
- const plugins = runtime.plugins.map((definition) => {
427
- const id = definition.manifest.id
428
- const status = statuses.get(id)
429
- const envelope = asRecord(runtime.config.plugins[id]) as Record<
430
- string,
431
- unknown
432
- >
433
-
434
- return {
435
- id,
436
- version: definition.manifest.version,
437
- description: definition.manifest.description,
438
- dependencies: definition.manifest.dependencies ?? [],
439
- defaultEnabled: definition.manifest.defaultEnabled ?? false,
440
- enabled: status?.enabled ?? false,
441
- loaded: status?.loaded ?? false,
442
- config: envelope,
443
- options: stripPluginEnvelope(envelope as never) as Record<
444
- string,
445
- unknown
446
- >,
447
- }
448
- })
449
- const installed = runtime.installer.enabled
450
- ? await runtime.installer.list()
451
- : []
452
-
453
- sendData(ctx.res, 200, {
454
- plugins,
455
- installed,
456
- installerEnabled: runtime.installer.enabled,
457
- configHash: (await ctx.deps.readConfig()).hash,
458
- })
459
- }
460
-
461
- const ENVELOPE_KEYS = new Set(['enabled', 'private', 'groups'])
462
-
463
- async function handlePluginConfigPut(ctx: ApiContext): Promise<void> {
464
- const body = asRecord(await readJsonBody(ctx.req))
465
- const id = ctx.segments[2]
466
- const document = await ctx.deps.readConfig()
467
-
468
- if (id === undefined) throw new HttpError(404, 'unknown plugin')
469
-
470
- if (!isRecord(document.config.plugins[id])) {
471
- throw new HttpError(404, `unknown plugin: ${id}`)
472
- }
473
-
474
- const existing = asRecord(document.config.plugins[id]) as unknown as Record<
475
- string,
476
- unknown
477
- >
478
- const next = { ...existing }
479
-
480
- if (typeof body['enabled'] === 'boolean') {
481
- next['enabled'] = body['enabled']
482
- }
483
-
484
- if (typeof body['private'] === 'boolean') {
485
- next['private'] = body['private']
486
- }
487
-
488
- if (body['groups'] !== undefined) next['groups'] = body['groups']
489
-
490
- if (body['options'] !== undefined) {
491
- if (!isRecord(body['options'])) {
492
- throw new HttpError(400, 'options must be an object')
493
- }
494
-
495
- for (const key of Object.keys(next)) {
496
- if (!ENVELOPE_KEYS.has(key)) delete next[key]
497
- }
498
-
499
- for (const [key, value] of Object.entries(body['options'])) {
500
- if (!ENVELOPE_KEYS.has(key)) next[key] = value
501
- }
502
- }
503
-
504
- const config = {
505
- ...document.config,
506
- plugins: {
507
- ...document.config.plugins,
508
- [id]: next,
509
- },
510
- }
511
- const baseHash = body['baseHash']
512
-
513
- sendData(
514
- ctx.res,
515
- 200,
516
- await ctx.deps.applyConfig(
517
- config,
518
- typeof baseHash === 'string' ? baseHash : undefined
519
- )
520
- )
521
- }
522
-
523
- async function handleInstallerAction(ctx: ApiContext): Promise<void> {
524
- const runtime = requireRuntime(ctx.deps)
525
-
526
- if (!runtime.installer.enabled) {
527
- throw new HttpError(403, '插件安装器未启用')
528
- }
529
-
530
- const body = asRecord(await readJsonBody(ctx.req))
531
- const target = typeof body['target'] === 'string' ? body['target'] : ''
532
- const spec = typeof body['spec'] === 'string' ? body['spec'] : ''
533
-
534
- if (ctx.pathname.endsWith('/install')) {
535
- if (!spec) throw new HttpError(400, 'spec is required')
536
-
537
- sendData(
538
- ctx.res,
539
- 200,
540
- await ctx.deps.runExclusive(() => runtime.installer.install(spec))
541
- )
542
-
543
- return
544
- }
545
-
546
- if (ctx.pathname.endsWith('/update')) {
547
- sendData(
548
- ctx.res,
549
- 200,
550
- await ctx.deps.runExclusive(() =>
551
- runtime.installer.update(target || undefined)
552
- )
553
- )
554
-
555
- return
556
- }
557
-
558
- sendData(
559
- ctx.res,
560
- 200,
561
- await ctx.deps.runExclusive(async () =>
562
- runtime.installer.remove(target, undefined, {
563
- force: body['force'] === true,
564
- })
565
- )
566
- )
567
- }
568
-
569
- async function handleConfigGet(ctx: ApiContext): Promise<void> {
570
- const document = await ctx.deps.readConfig()
571
- const masked = maskConfig(document.config)
572
-
573
- sendData(ctx.res, 200, {
574
- config: masked.config,
575
- meta: {
576
- path: document.path,
577
- hash: document.hash,
578
- accessTokenSet: masked.accessTokenSet,
579
- webuiPasswordSet: masked.webuiPasswordSet,
580
- },
581
- })
582
- }
583
-
584
- async function handleConfigValidate(ctx: ApiContext): Promise<void> {
585
- const body = asRecord(await readJsonBody(ctx.req))
586
- const result = await ctx.deps.validateConfig(body['config'])
587
-
588
- sendData(ctx.res, 200, {
589
- ok: result.ok,
590
- issues: result.issues,
591
- })
592
- }
593
-
594
- async function handleConfigPut(ctx: ApiContext): Promise<void> {
595
- const body = asRecord(await readJsonBody(ctx.req))
596
- const baseHash = body['baseHash']
597
-
598
- sendData(
599
- ctx.res,
600
- 200,
601
- await ctx.deps.applyConfig(
602
- body['config'],
603
- typeof baseHash === 'string' ? baseHash : undefined
604
- )
605
- )
606
- }
607
-
608
- function assertPluginId(value: string): string {
609
- if (!/^\w[\w.-]*$/.test(value)) {
610
- throw new HttpError(400, `invalid plugin id: ${value}`)
611
- }
612
-
613
- return value
614
- }
615
-
616
- function assertKey(value: string): string {
617
- if (value.length === 0 || value.length > 512) {
618
- throw new HttpError(400, 'invalid key length')
619
- }
620
-
621
- return value
622
- }
623
-
624
- function parseKvValue(value: string): unknown {
625
- try {
626
- return JSON.parse(value) as unknown
627
- } catch {
628
- return value
629
- }
630
- }
631
-
632
- interface KvRow {
633
- key: string
634
- value: string
635
- updated_at: number
636
- }
637
-
638
- async function handleKvPlugins(ctx: ApiContext): Promise<void> {
639
- const runtime = requireRuntime(ctx.deps)
640
- const rows = runtime.database.connection
641
- .prepare(
642
- 'SELECT plugin_id, COUNT(*) AS count FROM plugin_kv GROUP BY plugin_id ORDER BY plugin_id'
643
- )
644
- .all() as Array<{ plugin_id: string; count: number }>
645
-
646
- sendData(
647
- ctx.res,
648
- 200,
649
- rows.map((row) => ({ pluginId: row.plugin_id, count: row.count }))
650
- )
651
- }
652
-
653
- async function handleKvList(ctx: ApiContext): Promise<void> {
654
- const runtime = requireRuntime(ctx.deps)
655
- const pluginId = assertPluginId(ctx.search.get('pluginId') ?? '')
656
- const prefix = ctx.search.get('prefix') ?? ''
657
- const limit = clampInt(ctx.search.get('limit'), 100, 1, 500)
658
- const offset = clampInt(ctx.search.get('offset'), 0, 0, 10_000_000)
659
- const pattern =
660
- prefix === ''
661
- ? '%'
662
- : `${prefix.replace(/[%_]/g, (match) => `\\${match}`)}%`
663
- const rows = runtime.database.connection
664
- .prepare(
665
- `SELECT key, value, updated_at FROM plugin_kv
666
- WHERE plugin_id = ? AND key LIKE ? ESCAPE '\\'
667
- ORDER BY key
668
- LIMIT ? OFFSET ?`
669
- )
670
- .all(pluginId, pattern, limit, offset) as unknown as KvRow[]
671
- const totalRow = runtime.database.connection
672
- .prepare(
673
- `SELECT COUNT(*) AS count FROM plugin_kv
674
- WHERE plugin_id = ? AND key LIKE ? ESCAPE '\\'`
675
- )
676
- .get(pluginId, pattern) as { count: number } | undefined
677
-
678
- sendData(ctx.res, 200, {
679
- items: rows.map((row) => ({
680
- pluginId,
681
- key: row.key,
682
- value: parseKvValue(row.value),
683
- updatedAt: row.updated_at,
684
- })),
685
- total: totalRow?.count ?? 0,
686
- limit,
687
- offset,
688
- })
689
- }
690
-
691
- async function handleKvPut(ctx: ApiContext): Promise<void> {
692
- const body = asRecord(await readJsonBody(ctx.req))
693
- const runtime = requireRuntime(ctx.deps)
694
- const pluginId = assertPluginId(
695
- typeof body['pluginId'] === 'string' ? body['pluginId'] : ''
696
- )
697
- const key = assertKey(typeof body['key'] === 'string' ? body['key'] : '')
698
-
699
- if (typeof body['value'] === 'function') {
700
- throw new HttpError(400, 'invalid value')
701
- }
702
-
703
- await new SqlitePluginKV(runtime.database, pluginId).set(
704
- key,
705
- body['value'] as never
706
- )
707
-
708
- sendData(ctx.res, 200, { ok: true })
709
- }
710
-
711
- async function handleKvDelete(ctx: ApiContext): Promise<void> {
712
- const runtime = requireRuntime(ctx.deps)
713
- const pluginId = assertPluginId(ctx.search.get('pluginId') ?? '')
714
- const key = assertKey(ctx.search.get('key') ?? '')
715
- const deleted = await new SqlitePluginKV(runtime.database, pluginId).delete(
716
- key
717
- )
718
-
719
- sendData(ctx.res, 200, { ok: deleted })
720
- }
721
-
722
- async function handleActions(ctx: ApiContext): Promise<void> {
723
- const body = asRecord(await readJsonBody(ctx.req))
724
- const action = body['action']
725
-
726
- if (
727
- typeof action !== 'string' ||
728
- action.length === 0 ||
729
- action.length > 128
730
- ) {
731
- throw new HttpError(400, 'action is required')
732
- }
733
-
734
- const runtime = requireRuntime(ctx.deps)
735
- const params = body['params'] ?? undefined
736
- const result = await runtime.api.call(action as never, params as never)
737
-
738
- sendData(ctx.res, 200, { result: result ?? null })
739
- }
740
-
741
- async function handleRuntimeCommand(ctx: ApiContext): Promise<void> {
742
- if (ctx.pathname.endsWith('/restart')) {
743
- sendData(ctx.res, 200, {
744
- ok: await ctx.deps.restart(),
745
- })
746
-
747
- return
748
- }
749
-
750
- if (ctx.pathname.endsWith('/pause')) {
751
- ctx.deps.pause()
752
- sendData(ctx.res, 200, { ok: true })
753
-
754
- return
755
- }
9
+ handleSessionDelete,
10
+ handleSessionGet,
11
+ handleSessionList,
12
+ handleSessionMessages,
13
+ handleSessionPut,
14
+ handleSessionSend,
15
+ } from './routes/sessions.js'
16
+ import {
17
+ handleInstallerAction,
18
+ handlePluginConfigPut,
19
+ handlePluginsList,
20
+ } from './routes/plugins.js'
21
+ import {
22
+ handleConfigGet,
23
+ handleConfigPut,
24
+ handleConfigValidate,
25
+ } from './routes/config.js'
26
+ import {
27
+ handleKvDelete,
28
+ handleKvList,
29
+ handleKvPlugins,
30
+ handleKvPut,
31
+ } from './routes/kv.js'
32
+ import { handleActions, handleRuntimeCommand } from './routes/actions.js'
33
+ import type { ApiContext } from './routes/shared.js'
756
34
 
757
- if (ctx.pathname.endsWith('/resume')) {
758
- ctx.deps.resume()
759
- sendData(ctx.res, 200, { ok: true })
760
- }
761
- }
35
+ export type { ApiContext } from './routes/shared.js'
762
36
 
763
37
  export async function handleApiRoute(ctx: ApiContext): Promise<boolean> {
764
38
  const { method, pathname } = ctx
@@ -770,7 +44,7 @@ export async function handleApiRoute(ctx: ApiContext): Promise<boolean> {
770
44
  }
771
45
 
772
46
  if (method === 'GET' && pathname === '/api/commands') {
773
- sendData(ctx.res, 200, requireRuntime(ctx.deps).kernel.listCommands())
47
+ await handleCommands(ctx)
774
48
 
775
49
  return true
776
50
  }