dsh-endpoint 0.0.0-stage → 1.0.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.
package/src/admin.js ADDED
@@ -0,0 +1,178 @@
1
+ import { readFile, rename, writeFile, mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ /** Fields the settings page may write into the user overlay. */
5
+ const CONFIG_KEYS = ['host', 'port', 'defaultModel', 'modelAliases', 'session', 'limits', 'log']
6
+
7
+ /**
8
+ * The page writes a user overlay (`$DSH_HOME/dsh-endpoint/config.json`) instead
9
+ * of the Cordis entry config: the browser cannot reach the loader, and this is
10
+ * the same two-layer shape other plugins use (bootstrap config + user file).
11
+ */
12
+ export async function readOverlay(root) {
13
+ try {
14
+ const raw = await readFile(join(root, 'config.json'), 'utf8')
15
+ const parsed = JSON.parse(raw)
16
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
17
+ } catch (error) {
18
+ if (error?.code === 'ENOENT') return {}
19
+ throw error
20
+ }
21
+ }
22
+
23
+ export async function writeOverlay(root, overlay) {
24
+ await mkdir(root, { recursive: true })
25
+ const file = join(root, 'config.json')
26
+ const tmp = `${file}.tmp`
27
+ await writeFile(tmp, `${JSON.stringify(overlay, null, 2)}\n`, 'utf8')
28
+ await rename(tmp, file)
29
+ }
30
+
31
+ /** Deep-merge the user overlay over the entry config for the keys we own. */
32
+ export function mergeConfig(base, overlay) {
33
+ const out = { ...base }
34
+ for (const key of CONFIG_KEYS) {
35
+ if (!Object.hasOwn(overlay, key)) continue
36
+ const next = overlay[key]
37
+ const current = base[key]
38
+ if (isPlainObject(next) && isPlainObject(current)) out[key] = { ...current, ...next }
39
+ else out[key] = next
40
+ }
41
+ return out
42
+ }
43
+
44
+ /**
45
+ * Validate and normalise a patch coming from the settings page.
46
+ * @returns {{ patch: object, restartRequired: boolean }}
47
+ */
48
+ export function normalisePatch(input, base) {
49
+ if (!isPlainObject(input)) throw new Error('body must be a JSON object')
50
+ const patch = {}
51
+ let restartRequired = false
52
+ let rebind = false
53
+
54
+ if ('host' in input) {
55
+ const host = input.host
56
+ if (typeof host !== 'string' || host.length === 0) throw new Error('host must be a non-empty string')
57
+ if (host !== base.host) rebind = true
58
+ patch.host = host
59
+ }
60
+ if ('port' in input) {
61
+ const port = Number(input.port)
62
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('port must be an integer 0-65535')
63
+ if (port !== base.port) rebind = true
64
+ patch.port = port
65
+ }
66
+ if ('defaultModel' in input) {
67
+ if (typeof input.defaultModel !== 'string') throw new Error('defaultModel must be a string')
68
+ patch.defaultModel = input.defaultModel
69
+ }
70
+ if ('modelAliases' in input) {
71
+ if (!isPlainObject(input.modelAliases)) throw new Error('modelAliases must be an object')
72
+ for (const [name, target] of Object.entries(input.modelAliases)) {
73
+ if (typeof target !== 'string' || !target.includes('/')) {
74
+ throw new Error(`modelAliases.${name} must be "provider/model"`)
75
+ }
76
+ }
77
+ patch.modelAliases = input.modelAliases
78
+ }
79
+ if ('session' in input) {
80
+ const session = input.session
81
+ if (!isPlainObject(session)) throw new Error('session must be an object')
82
+ const next = {}
83
+ if ('mirror' in session) {
84
+ if (typeof session.mirror !== 'boolean') throw new Error('session.mirror must be a boolean')
85
+ if (session.mirror !== base.session.mirror) restartRequired = true
86
+ next.mirror = session.mirror
87
+ }
88
+ if ('mappingTtlDays' in session) {
89
+ const days = Number(session.mappingTtlDays)
90
+ if (!Number.isFinite(days) || days < 0) throw new Error('session.mappingTtlDays must be >= 0')
91
+ next.mappingTtlDays = days
92
+ }
93
+ if ('workspacePath' in session) {
94
+ if (typeof session.workspacePath !== 'string') throw new Error('session.workspacePath must be a string')
95
+ if (session.workspacePath !== base.session.workspacePath) restartRequired = true
96
+ next.workspacePath = session.workspacePath
97
+ }
98
+ patch.session = next
99
+ }
100
+
101
+ return { patch, restartRequired, rebind }
102
+ }
103
+
104
+ /** Register the settings-page routes on the GUI web server (loopback only). */
105
+ export function registerAdminRoutes(ctx, options) {
106
+ if (typeof ctx?.inject !== 'function') return
107
+ const { root, getConfig, onSave, getState, log } = options
108
+ ctx.inject(['webServer'], (webCtx) => {
109
+ webCtx.effect(() =>
110
+ webCtx.webServer.register({
111
+ kind: 'prefix',
112
+ path: '/dsh-endpoint',
113
+ handler: (req, res) => {
114
+ void handleAdmin(req, res, { root, getConfig, onSave, getState, log })
115
+ },
116
+ }),
117
+ )
118
+ })
119
+ }
120
+
121
+ async function handleAdmin(req, res, { root, getConfig, onSave, getState, log }) {
122
+ if (!isLoopback(req.socket?.remoteAddress)) {
123
+ return sendJson(res, 403, { error: 'loopback only' })
124
+ }
125
+ const url = new URL(req.url ?? '/', 'http://localhost')
126
+ try {
127
+ if (req.method === 'GET' && url.pathname === '/dsh-endpoint/state') {
128
+ return sendJson(res, 200, await getState())
129
+ }
130
+ if (req.method === 'GET' && url.pathname === '/dsh-endpoint/config') {
131
+ return sendJson(res, 200, getConfig())
132
+ }
133
+ if (req.method === 'PUT' && url.pathname === '/dsh-endpoint/config') {
134
+ const body = await readJson(req)
135
+ const { patch, restartRequired, rebind } = normalisePatch(body, getConfig())
136
+ const current = await readOverlay(root)
137
+ const overlay = { ...current, ...patch, ...(patch.session ? { session: { ...(current.session ?? {}), ...patch.session } } : {}) }
138
+ await writeOverlay(root, overlay)
139
+ const effective = mergeConfig(getConfig(), overlay)
140
+ await onSave?.(effective, patch)
141
+ log?.(
142
+ 'info',
143
+ `settings page saved config${rebind ? ' (listener rebound)' : ''}${restartRequired ? ' (restart required)' : ''}`,
144
+ )
145
+ return sendJson(res, 200, { saved: true, restartRequired, rebound: rebind, config: effective })
146
+ }
147
+ return sendJson(res, 404, { error: 'not found' })
148
+ } catch (error) {
149
+ return sendJson(res, 400, { error: String(error?.message ?? error) })
150
+ }
151
+ }
152
+
153
+ function isLoopback(address) {
154
+ if (typeof address !== 'string') return false
155
+ return address === '::1' || address === '127.0.0.1' || address.startsWith('127.') || address.startsWith('::ffff:127.')
156
+ }
157
+
158
+ function isPlainObject(value) {
159
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
160
+ }
161
+
162
+ async function readJson(req) {
163
+ const chunks = []
164
+ let size = 0
165
+ for await (const chunk of req) {
166
+ size += chunk.length
167
+ if (size > 256 * 1024) throw new Error('body too large')
168
+ chunks.push(chunk)
169
+ }
170
+ if (chunks.length === 0) return {}
171
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
172
+ }
173
+
174
+ function sendJson(res, status, payload) {
175
+ const body = JSON.stringify(payload)
176
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) })
177
+ res.end(body)
178
+ }
package/src/client.js ADDED
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Client half for dsh-endpoint.
3
+ *
4
+ * Loaded by DSH's module loader as a plain script (no top-level import/export):
5
+ * the factory receives `require` and must return the module object. It declares
6
+ * `inject: ['slots', 'locale']` — both are required before `apply` runs, and
7
+ * accessing either without declaring it would fail the client boot.
8
+ */
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-endpoint',
11
+ factory: function (require) {
12
+ var React = require('react')
13
+ var h = React.createElement
14
+
15
+ var NS = 'dsh-endpoint'
16
+
17
+ var DICT = {
18
+ zh: {
19
+ nav: '模型端点',
20
+ status: '状态',
21
+ address: '运行地址',
22
+ providers: 'Provider',
23
+ providersEmpty: '未注册',
24
+ sessions: '镜像会话',
25
+ mappings: '{n} 条映射',
26
+ endpoint: '端点',
27
+ host: '监听地址',
28
+ hostLocal: '127.0.0.1(仅本机)',
29
+ hostAll: '0.0.0.0(所有网卡)',
30
+ hostHintLocal: '仅本机可访问',
31
+ hostHintAll: '⚠ 没有鉴权,同网段任何人都能调用',
32
+ port: '端口',
33
+ models: '模型',
34
+ defaultModel: '默认模型',
35
+ defaultModelHint: '请求不带 model 时使用,格式 provider/model',
36
+ aliases: '模型别名',
37
+ aliasesHint: 'JSON 对象,例如 {"gpt-4o": "opencode-go/deepseek-v4.1-flash"}',
38
+ mirror: '会话镜像',
39
+ mirrorOn: '把每段 API 对话写成一个 DSH 会话(侧栏可见、只读)',
40
+ ttl: '映射保留',
41
+ ttlHint: '外部会话 ID 超过这个天数没再用,就解绑并归档',
42
+ ttlUnit: '天',
43
+ workspace: '工作区目录',
44
+ workspaceHint: '镜像会话归属的 workspace;留空用 $DSH_HOME/dsh-endpoint/conversations',
45
+ save: '保存',
46
+ saving: '保存中…',
47
+ savedLive: '已保存,即时生效。',
48
+ savedRebound: '已保存,监听地址已切换。',
49
+ savedRestart: '已保存。镜像开关 / 工作区目录需要重载插件后生效。',
50
+ client: '客户端接入',
51
+ loading: '正在读取端点状态…',
52
+ readStateFailed: '状态读取失败:',
53
+ readConfigFailed: '配置读取失败:',
54
+ saveFailed: '保存失败',
55
+ aliasesInvalid: '模型别名不是合法 JSON:',
56
+ },
57
+ en: {
58
+ nav: 'Model Endpoint',
59
+ status: 'Status',
60
+ address: 'Address',
61
+ providers: 'Providers',
62
+ providersEmpty: 'none registered',
63
+ sessions: 'Mirrored sessions',
64
+ mappings: '{n} mappings',
65
+ endpoint: 'Endpoint',
66
+ host: 'Listen address',
67
+ hostLocal: '127.0.0.1 (local only)',
68
+ hostAll: '0.0.0.0 (all interfaces)',
69
+ hostHintLocal: 'Reachable only from this machine',
70
+ hostHintAll: '⚠ No authentication: anyone on this network can use it',
71
+ port: 'Port',
72
+ models: 'Models',
73
+ defaultModel: 'Default model',
74
+ defaultModelHint: 'Used when a request omits `model`, as provider/model',
75
+ aliases: 'Model aliases',
76
+ aliasesHint: 'JSON object, e.g. {"gpt-4o": "opencode-go/deepseek-v4.1-flash"}',
77
+ mirror: 'Session mirror',
78
+ mirrorOn: 'Write each API conversation into a DSH session (visible in the sidebar, read-only)',
79
+ ttl: 'Mapping retention',
80
+ ttlHint: 'Days an external conversation id keeps its session after its last use',
81
+ ttlUnit: 'days',
82
+ workspace: 'Workspace directory',
83
+ workspaceHint: 'Workspace that groups mirrored sessions; empty means $DSH_HOME/dsh-endpoint/conversations',
84
+ save: 'Save',
85
+ saving: 'Saving…',
86
+ savedLive: 'Saved and applied.',
87
+ savedRebound: 'Saved; the listener moved to the new address.',
88
+ savedRestart: 'Saved. The mirror switch / workspace directory need a plugin reload.',
89
+ client: 'Client setup',
90
+ loading: 'Loading endpoint state…',
91
+ readStateFailed: 'Could not read status: ',
92
+ readConfigFailed: 'Could not read config: ',
93
+ saveFailed: 'Save failed',
94
+ aliasesInvalid: 'Model aliases are not valid JSON: ',
95
+ },
96
+ }
97
+
98
+ var TOKEN = {
99
+ text: 'var(--dsw-alias-label-primary)',
100
+ dim: 'var(--dsw-alias-label-tertiary, #8b8b8b)',
101
+ border: 'var(--dsw-alias-border-l2, #3a3a3a)',
102
+ bg: 'var(--dsw-alias-bg-layer-1, #1f1f1f)',
103
+ accent: 'var(--dsw-alias-state-business-primary, #4f8cff)',
104
+ }
105
+
106
+ var S = {
107
+ page: { maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 16, color: TOKEN.text },
108
+ card: {
109
+ border: '1px solid ' + TOKEN.border,
110
+ borderRadius: 12,
111
+ padding: 16,
112
+ display: 'flex',
113
+ flexDirection: 'column',
114
+ gap: 12,
115
+ },
116
+ title: { fontSize: 13, fontWeight: 600, color: TOKEN.dim, letterSpacing: '0.02em' },
117
+ row: { display: 'flex', alignItems: 'flex-start', gap: 16, minHeight: 34 },
118
+ label: { width: 120, flex: 'none', fontSize: 13, lineHeight: '30px' },
119
+ control: { flex: 1, display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 },
120
+ input: {
121
+ width: '100%',
122
+ boxSizing: 'border-box',
123
+ height: 30,
124
+ padding: '0 10px',
125
+ borderRadius: 8,
126
+ border: '1px solid ' + TOKEN.border,
127
+ background: TOKEN.bg,
128
+ color: TOKEN.text,
129
+ fontSize: 13,
130
+ outline: 'none',
131
+ },
132
+ area: {
133
+ width: '100%',
134
+ boxSizing: 'border-box',
135
+ minHeight: 90,
136
+ padding: '8px 10px',
137
+ borderRadius: 8,
138
+ border: '1px solid ' + TOKEN.border,
139
+ background: TOKEN.bg,
140
+ color: TOKEN.text,
141
+ fontFamily: 'ui-monospace, Menlo, monospace',
142
+ fontSize: 12,
143
+ resize: 'vertical',
144
+ },
145
+ hint: { fontSize: 12, color: TOKEN.dim, lineHeight: '18px' },
146
+ button: {
147
+ alignSelf: 'flex-start',
148
+ height: 30,
149
+ padding: '0 16px',
150
+ borderRadius: 8,
151
+ border: 'none',
152
+ background: TOKEN.accent,
153
+ color: '#fff',
154
+ fontSize: 13,
155
+ cursor: 'pointer',
156
+ },
157
+ mono: {
158
+ fontFamily: 'ui-monospace, Menlo, monospace',
159
+ fontSize: 12,
160
+ padding: '8px 10px',
161
+ borderRadius: 8,
162
+ border: '1px solid ' + TOKEN.border,
163
+ background: TOKEN.bg,
164
+ overflowX: 'auto',
165
+ whiteSpace: 'pre',
166
+ },
167
+ pill: {
168
+ display: 'inline-block',
169
+ padding: '2px 8px',
170
+ borderRadius: 999,
171
+ border: '1px solid ' + TOKEN.border,
172
+ fontSize: 12,
173
+ marginRight: 6,
174
+ color: TOKEN.text,
175
+ },
176
+ ok: { fontSize: 12, color: '#3fb950' },
177
+ err: { fontSize: 12, color: '#f85149' },
178
+ }
179
+
180
+ function Field(props) {
181
+ return h(
182
+ 'div',
183
+ { style: S.row },
184
+ h('div', { style: S.label }, props.label),
185
+ h(
186
+ 'div',
187
+ { style: S.control },
188
+ props.children,
189
+ props.hint === undefined ? null : h('div', { style: S.hint }, props.hint),
190
+ ),
191
+ )
192
+ }
193
+
194
+ function EndpointSection(props) {
195
+ var t = props.t
196
+ var locale = props.locale
197
+ var store = React.useMemo(
198
+ function () {
199
+ return {
200
+ subscribe: function (callback) {
201
+ return locale.subscribe(callback)
202
+ },
203
+ get: function () {
204
+ return locale.getSnapshot()
205
+ },
206
+ }
207
+ },
208
+ [locale],
209
+ )
210
+ // Re-render on locale switches; `t` itself reads the active locale at call time.
211
+ React.useSyncExternalStore(store.subscribe, store.get)
212
+
213
+ var statusHook = React.useState(null)
214
+ var status = statusHook[0]
215
+ var setStatus = statusHook[1]
216
+ var formHook = React.useState(null)
217
+ var form = formHook[0]
218
+ var setForm = formHook[1]
219
+ var msgHook = React.useState(null)
220
+ var message = msgHook[0]
221
+ var setMessage = msgHook[1]
222
+ var busyHook = React.useState(false)
223
+ var busy = busyHook[0]
224
+ var setBusy = busyHook[1]
225
+
226
+ React.useEffect(function () {
227
+ var cancelled = false
228
+ fetch('/dsh-endpoint/state')
229
+ .then(function (response) {
230
+ return response.json()
231
+ })
232
+ .then(function (value) {
233
+ if (!cancelled) setStatus(value)
234
+ })
235
+ .catch(function (error) {
236
+ if (!cancelled) setMessage({ kind: 'err', text: t('readStateFailed') + String(error) })
237
+ })
238
+ fetch('/dsh-endpoint/config')
239
+ .then(function (response) {
240
+ return response.json()
241
+ })
242
+ .then(function (config) {
243
+ if (cancelled) return
244
+ setForm({
245
+ host: config.host,
246
+ port: config.port,
247
+ defaultModel: config.defaultModel || '',
248
+ aliases: JSON.stringify(config.modelAliases || {}, null, 2),
249
+ mirror: config.session ? config.session.mirror !== false : true,
250
+ ttl: config.session ? config.session.mappingTtlDays : 3,
251
+ workspacePath: config.session ? config.session.workspacePath || '' : '',
252
+ })
253
+ })
254
+ .catch(function (error) {
255
+ if (!cancelled) setMessage({ kind: 'err', text: t('readConfigFailed') + String(error) })
256
+ })
257
+ return function () {
258
+ cancelled = true
259
+ }
260
+ }, [])
261
+
262
+ var update = function (patch) {
263
+ setForm(function (current) {
264
+ return Object.assign({}, current, patch)
265
+ })
266
+ setMessage(null)
267
+ }
268
+
269
+ var save = function () {
270
+ var aliases
271
+ try {
272
+ aliases = JSON.parse(form.aliases || '{}')
273
+ } catch (error) {
274
+ setMessage({ kind: 'err', text: t('aliasesInvalid') + String(error.message || error) })
275
+ return
276
+ }
277
+ setBusy(true)
278
+ setMessage(null)
279
+ fetch('/dsh-endpoint/config', {
280
+ method: 'PUT',
281
+ headers: { 'content-type': 'application/json' },
282
+ body: JSON.stringify({
283
+ host: form.host,
284
+ port: Number(form.port),
285
+ defaultModel: form.defaultModel,
286
+ modelAliases: aliases,
287
+ session: {
288
+ mirror: form.mirror,
289
+ mappingTtlDays: Number(form.ttl),
290
+ workspacePath: form.workspacePath,
291
+ },
292
+ }),
293
+ })
294
+ .then(function (response) {
295
+ return response.json().then(function (body) {
296
+ return { ok: response.ok, body: body }
297
+ })
298
+ })
299
+ .then(function (result) {
300
+ if (!result.ok) throw new Error(result.body.error || t('saveFailed'))
301
+ var text = result.body.restartRequired
302
+ ? t('savedRestart')
303
+ : result.body.rebound
304
+ ? t('savedRebound')
305
+ : t('savedLive')
306
+ setMessage({ kind: 'ok', text: text })
307
+ if (result.body.config) {
308
+ setStatus(function (current) {
309
+ return Object.assign({}, current, {
310
+ host: result.body.config.host,
311
+ port: result.body.config.port,
312
+ url: 'http://' + result.body.config.host + ':' + result.body.config.port,
313
+ })
314
+ })
315
+ }
316
+ })
317
+ .catch(function (error) {
318
+ setMessage({ kind: 'err', text: String(error.message || error) })
319
+ })
320
+ .then(function () {
321
+ setBusy(false)
322
+ })
323
+ }
324
+
325
+ if (form === null) {
326
+ return h('div', { style: S.page }, h('div', { style: S.hint }, message === null ? t('loading') : message.text))
327
+ }
328
+
329
+ var statusChildren =
330
+ status === null
331
+ ? [h('div', { key: 'loading', style: S.hint }, t('loading'))]
332
+ : [
333
+ h(Field, { key: 'url', label: t('address') }, h('div', { style: S.mono }, status.url)),
334
+ h(
335
+ Field,
336
+ { key: 'providers', label: t('providers') },
337
+ h(
338
+ 'div',
339
+ null,
340
+ (status.providers || []).map(function (name) {
341
+ return h('span', { key: name, style: S.pill }, name)
342
+ }),
343
+ (status.providers || []).length === 0 ? h('span', { style: S.hint }, t('providersEmpty')) : null,
344
+ ),
345
+ ),
346
+ h(
347
+ Field,
348
+ { key: 'sessions', label: t('sessions'), hint: status.stateRoot },
349
+ h('div', { style: S.hint }, t('mappings').replace('{n}', String(status.sessions || 0))),
350
+ ),
351
+ ]
352
+
353
+ return h(
354
+ 'div',
355
+ { style: S.page },
356
+ h('div', { style: S.card }, [h('div', { key: 't', style: S.title }, t('status'))].concat(statusChildren)),
357
+ h(
358
+ 'div',
359
+ { style: S.card },
360
+ h('div', { style: S.title }, t('endpoint')),
361
+ h(
362
+ Field,
363
+ {
364
+ label: t('host'),
365
+ hint: form.host === '0.0.0.0' ? t('hostHintAll') : t('hostHintLocal'),
366
+ },
367
+ h(
368
+ 'select',
369
+ {
370
+ style: S.input,
371
+ value: form.host,
372
+ onChange: function (event) {
373
+ update({ host: event.target.value })
374
+ },
375
+ },
376
+ h('option', { value: '127.0.0.1' }, t('hostLocal')),
377
+ h('option', { value: '0.0.0.0' }, t('hostAll')),
378
+ ),
379
+ ),
380
+ h(
381
+ Field,
382
+ { label: t('port') },
383
+ h('input', {
384
+ style: S.input,
385
+ type: 'number',
386
+ value: form.port,
387
+ onChange: function (event) {
388
+ update({ port: event.target.value })
389
+ },
390
+ }),
391
+ ),
392
+ ),
393
+ h(
394
+ 'div',
395
+ { style: S.card },
396
+ h('div', { style: S.title }, t('models')),
397
+ h(
398
+ Field,
399
+ { label: t('defaultModel'), hint: t('defaultModelHint') },
400
+ h('input', {
401
+ style: S.input,
402
+ value: form.defaultModel,
403
+ placeholder: 'opencode-go/deepseek-v4.1-flash',
404
+ onChange: function (event) {
405
+ update({ defaultModel: event.target.value })
406
+ },
407
+ }),
408
+ ),
409
+ h(
410
+ Field,
411
+ { label: t('aliases'), hint: t('aliasesHint') },
412
+ h('textarea', {
413
+ style: S.area,
414
+ value: form.aliases,
415
+ onChange: function (event) {
416
+ update({ aliases: event.target.value })
417
+ },
418
+ }),
419
+ ),
420
+ ),
421
+ h(
422
+ 'div',
423
+ { style: S.card },
424
+ h('div', { style: S.title }, t('mirror')),
425
+ h(
426
+ Field,
427
+ { label: t('mirror') },
428
+ h(
429
+ 'label',
430
+ { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, lineHeight: '30px' } },
431
+ h('input', {
432
+ type: 'checkbox',
433
+ checked: form.mirror,
434
+ onChange: function (event) {
435
+ update({ mirror: event.target.checked })
436
+ },
437
+ }),
438
+ t('mirrorOn'),
439
+ ),
440
+ ),
441
+ h(
442
+ Field,
443
+ { label: t('ttl'), hint: t('ttlHint') },
444
+ h(
445
+ 'div',
446
+ { style: { display: 'flex', alignItems: 'center', gap: 8 } },
447
+ h('input', {
448
+ style: Object.assign({}, S.input, { width: 100 }),
449
+ type: 'number',
450
+ value: form.ttl,
451
+ onChange: function (event) {
452
+ update({ ttl: event.target.value })
453
+ },
454
+ }),
455
+ h('span', { style: S.hint }, t('ttlUnit')),
456
+ ),
457
+ ),
458
+ h(
459
+ Field,
460
+ { label: t('workspace'), hint: t('workspaceHint') },
461
+ h('input', {
462
+ style: S.input,
463
+ value: form.workspacePath,
464
+ onChange: function (event) {
465
+ update({ workspacePath: event.target.value })
466
+ },
467
+ }),
468
+ ),
469
+ ),
470
+ h(
471
+ 'div',
472
+ { style: { display: 'flex', alignItems: 'center', gap: 12 } },
473
+ h('button', { style: S.button, onClick: save, disabled: busy }, busy ? t('saving') : t('save')),
474
+ message === null ? null : h('span', { style: message.kind === 'ok' ? S.ok : S.err }, message.text),
475
+ ),
476
+ h(
477
+ 'div',
478
+ { style: S.card },
479
+ h('div', { style: S.title }, t('client')),
480
+ h(
481
+ 'div',
482
+ { style: S.mono },
483
+ 'export OPENAI_BASE_URL=' +
484
+ (status ? status.url : 'http://127.0.0.1:43130') +
485
+ '/v1\nexport OPENAI_API_KEY=anything',
486
+ ),
487
+ ),
488
+ )
489
+ }
490
+
491
+ function apply(ctx) {
492
+ ctx.effect(function () {
493
+ return ctx.locale.register(NS, { zh: DICT.zh, en: DICT.en })
494
+ }, 'dsh-endpoint: dictionaries')
495
+ var t = ctx.locale.bind(NS)
496
+
497
+ ctx.slots.inject('settings.section', function* () {
498
+ yield ctx.slots.register(
499
+ {
500
+ name: 'settings.section',
501
+ id: 'endpoint-config',
502
+ order: 40,
503
+ label: function () {
504
+ return t('nav')
505
+ },
506
+ inject: function () {
507
+ return { t: t, locale: ctx.locale }
508
+ },
509
+ },
510
+ EndpointSection,
511
+ )
512
+ })
513
+ }
514
+
515
+ return { name: 'dsh-endpoint', inject: ['slots', 'locale'], apply: apply }
516
+ },
517
+ })