@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
@@ -0,0 +1,43 @@
1
+ import { HttpError, readJsonBody, sendData } from '../http.js'
2
+ import { asRecord, requireRuntime, type ApiContext } from './shared.js'
3
+
4
+ export async function handleActions(ctx: ApiContext): Promise<void> {
5
+ const body = asRecord(await readJsonBody(ctx.req))
6
+ const action = body['action']
7
+
8
+ if (
9
+ typeof action !== 'string' ||
10
+ action.length === 0 ||
11
+ action.length > 128
12
+ ) {
13
+ throw new HttpError(400, 'action is required')
14
+ }
15
+
16
+ const runtime = requireRuntime(ctx.deps)
17
+ const params = body['params'] ?? undefined
18
+ const result = await runtime.api.call(action as never, params as never)
19
+
20
+ sendData(ctx.res, 200, { result: result ?? null })
21
+ }
22
+
23
+ export async function handleRuntimeCommand(ctx: ApiContext): Promise<void> {
24
+ if (ctx.pathname.endsWith('/restart')) {
25
+ sendData(ctx.res, 200, {
26
+ ok: await ctx.deps.restart(),
27
+ })
28
+
29
+ return
30
+ }
31
+
32
+ if (ctx.pathname.endsWith('/pause')) {
33
+ ctx.deps.pause()
34
+ sendData(ctx.res, 200, { ok: true })
35
+
36
+ return
37
+ }
38
+
39
+ if (ctx.pathname.endsWith('/resume')) {
40
+ ctx.deps.resume()
41
+ sendData(ctx.res, 200, { ok: true })
42
+ }
43
+ }
@@ -0,0 +1,42 @@
1
+ import { maskConfig } from '../config-store.js'
2
+ import { readJsonBody, sendData } from '../http.js'
3
+ import { asRecord, type ApiContext } from './shared.js'
4
+
5
+ export async function handleConfigGet(ctx: ApiContext): Promise<void> {
6
+ const document = await ctx.deps.readConfig()
7
+ const masked = maskConfig(document.config)
8
+
9
+ sendData(ctx.res, 200, {
10
+ config: masked.config,
11
+ meta: {
12
+ path: document.path,
13
+ hash: document.hash,
14
+ accessTokenSet: masked.accessTokenSet,
15
+ webuiPasswordSet: masked.webuiPasswordSet,
16
+ },
17
+ })
18
+ }
19
+
20
+ export async function handleConfigValidate(ctx: ApiContext): Promise<void> {
21
+ const body = asRecord(await readJsonBody(ctx.req))
22
+ const result = await ctx.deps.validateConfig(body['config'])
23
+
24
+ sendData(ctx.res, 200, {
25
+ ok: result.ok,
26
+ issues: result.issues,
27
+ })
28
+ }
29
+
30
+ export async function handleConfigPut(ctx: ApiContext): Promise<void> {
31
+ const body = asRecord(await readJsonBody(ctx.req))
32
+ const baseHash = body['baseHash']
33
+
34
+ sendData(
35
+ ctx.res,
36
+ 200,
37
+ await ctx.deps.applyConfig(
38
+ body['config'],
39
+ typeof baseHash === 'string' ? baseHash : undefined
40
+ )
41
+ )
42
+ }
@@ -0,0 +1,122 @@
1
+ import { SqlitePluginKV } from '../../plugin/kv.js'
2
+ import { HttpError, readJsonBody, sendData } from '../http.js'
3
+ import {
4
+ asRecord,
5
+ clampInt,
6
+ requireRuntime,
7
+ type ApiContext,
8
+ } from './shared.js'
9
+
10
+ function assertPluginId(value: string): string {
11
+ if (!/^\w[\w.-]*$/.test(value)) {
12
+ throw new HttpError(400, `invalid plugin id: ${value}`)
13
+ }
14
+
15
+ return value
16
+ }
17
+
18
+ function assertKey(value: string): string {
19
+ if (value.length === 0 || value.length > 512) {
20
+ throw new HttpError(400, 'invalid key length')
21
+ }
22
+
23
+ return value
24
+ }
25
+
26
+ function parseKvValue(value: string): unknown {
27
+ try {
28
+ return JSON.parse(value) as unknown
29
+ } catch {
30
+ return value
31
+ }
32
+ }
33
+
34
+ interface KvRow {
35
+ key: string
36
+ value: string
37
+ updated_at: number
38
+ }
39
+
40
+ export async function handleKvPlugins(ctx: ApiContext): Promise<void> {
41
+ const runtime = requireRuntime(ctx.deps)
42
+ const rows = runtime.database.connection
43
+ .prepare(
44
+ 'SELECT plugin_id, COUNT(*) AS count FROM plugin_kv GROUP BY plugin_id ORDER BY plugin_id'
45
+ )
46
+ .all() as Array<{ plugin_id: string; count: number }>
47
+
48
+ sendData(
49
+ ctx.res,
50
+ 200,
51
+ rows.map((row) => ({ pluginId: row.plugin_id, count: row.count }))
52
+ )
53
+ }
54
+
55
+ export async function handleKvList(ctx: ApiContext): Promise<void> {
56
+ const runtime = requireRuntime(ctx.deps)
57
+ const pluginId = assertPluginId(ctx.search.get('pluginId') ?? '')
58
+ const prefix = ctx.search.get('prefix') ?? ''
59
+ const limit = clampInt(ctx.search.get('limit'), 100, 1, 500)
60
+ const offset = clampInt(ctx.search.get('offset'), 0, 0, 10_000_000)
61
+ const pattern =
62
+ prefix === ''
63
+ ? '%'
64
+ : `${prefix.replace(/[%_]/g, (match) => `\\${match}`)}%`
65
+ const rows = runtime.database.connection
66
+ .prepare(
67
+ `SELECT key, value, updated_at FROM plugin_kv
68
+ WHERE plugin_id = ? AND key LIKE ? ESCAPE '\\'
69
+ ORDER BY key
70
+ LIMIT ? OFFSET ?`
71
+ )
72
+ .all(pluginId, pattern, limit, offset) as unknown as KvRow[]
73
+ const totalRow = runtime.database.connection
74
+ .prepare(
75
+ `SELECT COUNT(*) AS count FROM plugin_kv
76
+ WHERE plugin_id = ? AND key LIKE ? ESCAPE '\\'`
77
+ )
78
+ .get(pluginId, pattern) as { count: number } | undefined
79
+
80
+ sendData(ctx.res, 200, {
81
+ items: rows.map((row) => ({
82
+ pluginId,
83
+ key: row.key,
84
+ value: parseKvValue(row.value),
85
+ updatedAt: row.updated_at,
86
+ })),
87
+ total: totalRow?.count ?? 0,
88
+ limit,
89
+ offset,
90
+ })
91
+ }
92
+
93
+ export async function handleKvPut(ctx: ApiContext): Promise<void> {
94
+ const body = asRecord(await readJsonBody(ctx.req))
95
+ const runtime = requireRuntime(ctx.deps)
96
+ const pluginId = assertPluginId(
97
+ typeof body['pluginId'] === 'string' ? body['pluginId'] : ''
98
+ )
99
+ const key = assertKey(typeof body['key'] === 'string' ? body['key'] : '')
100
+
101
+ if (typeof body['value'] === 'function') {
102
+ throw new HttpError(400, 'invalid value')
103
+ }
104
+
105
+ await new SqlitePluginKV(runtime.database, pluginId).set(
106
+ key,
107
+ body['value'] as never
108
+ )
109
+
110
+ sendData(ctx.res, 200, { ok: true })
111
+ }
112
+
113
+ export async function handleKvDelete(ctx: ApiContext): Promise<void> {
114
+ const runtime = requireRuntime(ctx.deps)
115
+ const pluginId = assertPluginId(ctx.search.get('pluginId') ?? '')
116
+ const key = assertKey(ctx.search.get('key') ?? '')
117
+ const deleted = await new SqlitePluginKV(runtime.database, pluginId).delete(
118
+ key
119
+ )
120
+
121
+ sendData(ctx.res, 200, { ok: deleted })
122
+ }
@@ -0,0 +1,151 @@
1
+ import { stripPluginEnvelope } from '../../plugin/host.js'
2
+ import { HttpError, isRecord, readJsonBody, sendData } from '../http.js'
3
+ import { asRecord, requireRuntime, type ApiContext } from './shared.js'
4
+
5
+ const ENVELOPE_KEYS = new Set(['enabled', 'private', 'groups'])
6
+
7
+ export async function handlePluginsList(ctx: ApiContext): Promise<void> {
8
+ const runtime = requireRuntime(ctx.deps)
9
+ const statuses = new Map(
10
+ runtime.kernel.listPlugins().map((entry) => [entry.id, entry])
11
+ )
12
+ const plugins = runtime.plugins.map((definition) => {
13
+ const id = definition.manifest.id
14
+ const status = statuses.get(id)
15
+ const envelope = asRecord(runtime.config.plugins[id]) as Record<
16
+ string,
17
+ unknown
18
+ >
19
+
20
+ return {
21
+ id,
22
+ version: definition.manifest.version,
23
+ description: definition.manifest.description,
24
+ dependencies: definition.manifest.dependencies ?? [],
25
+ defaultEnabled: definition.manifest.defaultEnabled ?? false,
26
+ enabled: status?.enabled ?? false,
27
+ loaded: status?.loaded ?? false,
28
+ config: envelope,
29
+ options: stripPluginEnvelope(envelope as never) as Record<
30
+ string,
31
+ unknown
32
+ >,
33
+ }
34
+ })
35
+ const installed = runtime.installer.enabled
36
+ ? await runtime.installer.list()
37
+ : []
38
+
39
+ sendData(ctx.res, 200, {
40
+ plugins,
41
+ installed,
42
+ installerEnabled: runtime.installer.enabled,
43
+ configHash: (await ctx.deps.readConfig()).hash,
44
+ })
45
+ }
46
+
47
+ export async function handlePluginConfigPut(ctx: ApiContext): Promise<void> {
48
+ const body = asRecord(await readJsonBody(ctx.req))
49
+ const id = ctx.segments[2]
50
+ const document = await ctx.deps.readConfig()
51
+
52
+ if (id === undefined) throw new HttpError(404, 'unknown plugin')
53
+
54
+ if (!isRecord(document.config.plugins[id])) {
55
+ throw new HttpError(404, `unknown plugin: ${id}`)
56
+ }
57
+
58
+ const existing = asRecord(document.config.plugins[id]) as unknown as Record<
59
+ string,
60
+ unknown
61
+ >
62
+ const next = { ...existing }
63
+
64
+ if (typeof body['enabled'] === 'boolean') {
65
+ next['enabled'] = body['enabled']
66
+ }
67
+
68
+ if (typeof body['private'] === 'boolean') {
69
+ next['private'] = body['private']
70
+ }
71
+
72
+ if (body['groups'] !== undefined) next['groups'] = body['groups']
73
+
74
+ if (body['options'] !== undefined) {
75
+ if (!isRecord(body['options'])) {
76
+ throw new HttpError(400, 'options must be an object')
77
+ }
78
+
79
+ for (const key of Object.keys(next)) {
80
+ if (!ENVELOPE_KEYS.has(key)) delete next[key]
81
+ }
82
+
83
+ for (const [key, value] of Object.entries(body['options'])) {
84
+ if (!ENVELOPE_KEYS.has(key)) next[key] = value
85
+ }
86
+ }
87
+
88
+ const config = {
89
+ ...document.config,
90
+ plugins: {
91
+ ...document.config.plugins,
92
+ [id]: next,
93
+ },
94
+ }
95
+ const baseHash = body['baseHash']
96
+
97
+ sendData(
98
+ ctx.res,
99
+ 200,
100
+ await ctx.deps.applyConfig(
101
+ config,
102
+ typeof baseHash === 'string' ? baseHash : undefined
103
+ )
104
+ )
105
+ }
106
+
107
+ export async function handleInstallerAction(ctx: ApiContext): Promise<void> {
108
+ const runtime = requireRuntime(ctx.deps)
109
+
110
+ if (!runtime.installer.enabled) {
111
+ throw new HttpError(403, '插件安装器未启用')
112
+ }
113
+
114
+ const body = asRecord(await readJsonBody(ctx.req))
115
+ const target = typeof body['target'] === 'string' ? body['target'] : ''
116
+ const spec = typeof body['spec'] === 'string' ? body['spec'] : ''
117
+
118
+ if (ctx.pathname.endsWith('/install')) {
119
+ if (!spec) throw new HttpError(400, 'spec is required')
120
+
121
+ sendData(
122
+ ctx.res,
123
+ 200,
124
+ await ctx.deps.runExclusive(() => runtime.installer.install(spec))
125
+ )
126
+
127
+ return
128
+ }
129
+
130
+ if (ctx.pathname.endsWith('/update')) {
131
+ sendData(
132
+ ctx.res,
133
+ 200,
134
+ await ctx.deps.runExclusive(() =>
135
+ runtime.installer.update(target || undefined)
136
+ )
137
+ )
138
+
139
+ return
140
+ }
141
+
142
+ sendData(
143
+ ctx.res,
144
+ 200,
145
+ await ctx.deps.runExclusive(async () =>
146
+ runtime.installer.remove(target, undefined, {
147
+ force: body['force'] === true,
148
+ })
149
+ )
150
+ )
151
+ }
@@ -0,0 +1,188 @@
1
+ import {
2
+ SqliteSessionStore,
3
+ type SessionMessage,
4
+ type SessionRole,
5
+ type SessionState,
6
+ } from '../../storage/session.js'
7
+ import { HttpError, isRecord, readJsonBody, sendData } from '../http.js'
8
+ import {
9
+ asRecord,
10
+ clampInt,
11
+ requireRuntime,
12
+ type ApiContext,
13
+ } from './shared.js'
14
+
15
+ const MAX_SESSION_MESSAGES = 10_000
16
+
17
+ function parseSessionMessages(value: unknown): SessionMessage[] {
18
+ if (!Array.isArray(value)) {
19
+ throw new HttpError(400, 'messages must be an array')
20
+ }
21
+
22
+ if (value.length > MAX_SESSION_MESSAGES) {
23
+ throw new HttpError(400, 'messages too long')
24
+ }
25
+
26
+ return value.map((item, index) => {
27
+ if (!isRecord(item)) {
28
+ throw new HttpError(400, `invalid message at index ${index}`)
29
+ }
30
+
31
+ const role = item['role']
32
+ const content = item['content']
33
+ const at = item['at']
34
+
35
+ if (role !== 'system' && role !== 'user' && role !== 'assistant') {
36
+ throw new HttpError(400, `invalid message role at index ${index}`)
37
+ }
38
+
39
+ if (typeof content !== 'string') {
40
+ throw new HttpError(
41
+ 400,
42
+ `invalid message content at index ${index}`
43
+ )
44
+ }
45
+
46
+ if (typeof at !== 'number' || !Number.isFinite(at)) {
47
+ throw new HttpError(400, `invalid message time at index ${index}`)
48
+ }
49
+
50
+ return { role: role as SessionRole, content, at }
51
+ })
52
+ }
53
+
54
+ function clearableString(
55
+ value: unknown,
56
+ fallback: string | undefined
57
+ ): string | undefined {
58
+ if (value === null) return undefined
59
+ if (value === undefined) return fallback
60
+ if (typeof value === 'string' && value.trim().length > 0) {
61
+ return value
62
+ }
63
+
64
+ return undefined
65
+ }
66
+
67
+ export async function handleSessionList(ctx: ApiContext): Promise<void> {
68
+ const runtime = requireRuntime(ctx.deps)
69
+ const store = new SqliteSessionStore(runtime.database)
70
+ const limit = clampInt(ctx.search.get('limit'), 50, 1, 200)
71
+ const offset = clampInt(ctx.search.get('offset'), 0, 0, 10_000_000)
72
+ const query = ctx.search.get('query')?.slice(0, 200) || undefined
73
+ const items = await store.list({ limit, offset, query })
74
+ const total = await store.count(query)
75
+
76
+ sendData(ctx.res, 200, { items, total, limit, offset })
77
+ }
78
+
79
+ function sessionIdOf(ctx: ApiContext): string {
80
+ const id = ctx.segments[2]
81
+
82
+ if (!id) throw new HttpError(404, 'session not found')
83
+
84
+ return id
85
+ }
86
+
87
+ function requireSessionStore(ctx: ApiContext): SqliteSessionStore {
88
+ const runtime = requireRuntime(ctx.deps)
89
+
90
+ return new SqliteSessionStore(runtime.database)
91
+ }
92
+
93
+ async function sessionDetail(ctx: ApiContext): Promise<SessionState> {
94
+ const id = sessionIdOf(ctx)
95
+ const state = await requireSessionStore(ctx).get(id)
96
+
97
+ if (!state) throw new HttpError(404, 'session not found')
98
+
99
+ return state
100
+ }
101
+
102
+ export async function handleSessionGet(ctx: ApiContext): Promise<void> {
103
+ sendData(ctx.res, 200, await sessionDetail(ctx))
104
+ }
105
+
106
+ export async function handleSessionPut(ctx: ApiContext): Promise<void> {
107
+ const body = asRecord(await readJsonBody(ctx.req))
108
+ await sessionDetail(ctx)
109
+
110
+ const messages =
111
+ body['messages'] === undefined
112
+ ? undefined
113
+ : parseSessionMessages(body['messages'])
114
+
115
+ const baseline = await sessionDetail(ctx)
116
+ const state: SessionState = {
117
+ sessionId: sessionIdOf(ctx),
118
+ provider: clearableString(body['provider'], baseline.provider),
119
+ model: clearableString(body['model'], baseline.model),
120
+ persona: clearableString(body['persona'], baseline.persona),
121
+ messages: messages ?? baseline.messages,
122
+ updatedAt: Date.now(),
123
+ }
124
+
125
+ await requireSessionStore(ctx).save(state)
126
+ sendData(ctx.res, 200, state)
127
+ }
128
+
129
+ export async function handleSessionMessages(ctx: ApiContext): Promise<void> {
130
+ const body = asRecord(await readJsonBody(ctx.req))
131
+ const role = body['role']
132
+ const content = body['content']
133
+
134
+ if (role !== 'system' && role !== 'user' && role !== 'assistant') {
135
+ throw new HttpError(400, 'invalid message role')
136
+ }
137
+
138
+ if (
139
+ typeof content !== 'string' ||
140
+ content.length === 0 ||
141
+ content.length > 100_000
142
+ ) {
143
+ throw new HttpError(400, 'invalid message content')
144
+ }
145
+
146
+ const state = await sessionDetail(ctx)
147
+ const message = { role: role as SessionRole, content, at: Date.now() }
148
+ const next: SessionState = {
149
+ ...state,
150
+ messages: [...state.messages, message],
151
+ updatedAt: Date.now(),
152
+ }
153
+
154
+ await requireSessionStore(ctx).save(next)
155
+ sendData(ctx.res, 200, next)
156
+ }
157
+
158
+ export async function handleSessionSend(ctx: ApiContext): Promise<void> {
159
+ const body = asRecord(await readJsonBody(ctx.req))
160
+ const id = sessionIdOf(ctx)
161
+ const message = body['message']
162
+ const autoEscape = body['autoEscape']
163
+
164
+ if (
165
+ typeof message !== 'string' ||
166
+ message.length === 0 ||
167
+ message.length > 20_000
168
+ ) {
169
+ throw new HttpError(400, 'invalid message')
170
+ }
171
+
172
+ const runtime = requireRuntime(ctx.deps)
173
+
174
+ await sessionDetail(ctx)
175
+
176
+ const result = await runtime.api.send(
177
+ id,
178
+ message,
179
+ typeof autoEscape === 'boolean' ? { autoEscape } : undefined
180
+ )
181
+
182
+ sendData(ctx.res, 200, { result: result ?? null })
183
+ }
184
+
185
+ export async function handleSessionDelete(ctx: ApiContext): Promise<void> {
186
+ await requireSessionStore(ctx).clear(sessionIdOf(ctx))
187
+ sendData(ctx.res, 200, { cleared: true })
188
+ }
@@ -0,0 +1,42 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import type { AuthSession } from '../auth.js'
3
+ import { HttpError, isRecord, type URLSearchParamsLike } from '../http.js'
4
+ import type { WebUiDeps, WebUiRuntime } from '../types.js'
5
+
6
+ export interface ApiContext {
7
+ deps: WebUiDeps
8
+ req: IncomingMessage
9
+ res: ServerResponse
10
+ method: string
11
+ pathname: string
12
+ segments: string[]
13
+ search: URLSearchParamsLike
14
+ session: AuthSession
15
+ }
16
+
17
+ export function requireRuntime(deps: WebUiDeps): WebUiRuntime {
18
+ const runtime = deps.getRuntime()
19
+
20
+ if (!runtime) throw new HttpError(503, '运行时不可用,请稍后再试')
21
+
22
+ return runtime
23
+ }
24
+
25
+ export function clampInt(
26
+ value: string | null,
27
+ fallback: number,
28
+ min: number,
29
+ max: number
30
+ ): number {
31
+ if (value === null) return fallback
32
+
33
+ const parsed = Number.parseInt(value, 10)
34
+
35
+ if (!Number.isFinite(parsed)) return fallback
36
+
37
+ return Math.min(Math.max(parsed, min), max)
38
+ }
39
+
40
+ export function asRecord(value: unknown): Record<string, unknown> {
41
+ return isRecord(value) ? value : {}
42
+ }