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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +243 -2
- package/README.zh.md +225 -0
- package/SECURITY.md +35 -0
- package/cordis.patch.yml +9 -0
- package/docs/curl.png +0 -0
- package/docs/settings.png +0 -0
- package/examples/opencode.json +18 -0
- package/icon.svg +13 -0
- package/package.json +68 -4
- package/src/admin.js +178 -0
- package/src/client.js +517 -0
- package/src/config.js +89 -0
- package/src/dsh/catalog.js +97 -0
- package/src/index.js +28 -0
- package/src/mirror.js +286 -0
- package/src/openai/convert.js +197 -0
- package/src/openai/errors.js +68 -0
- package/src/openai/media.js +120 -0
- package/src/openai/stream.js +294 -0
- package/src/server.js +624 -0
- package/src/session/key.js +129 -0
- package/src/session/registry.js +209 -0
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
|
+
})
|