@dsh-plus/llm-pi 0.1.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/LICENSE +21 -0
- package/lib/client.js +1904 -0
- package/lib/index.d.ts +90 -0
- package/lib/index.js +1247 -0
- package/package.json +62 -0
- package/src/catalog/builtin.ts +75 -0
- package/src/catalog/models-dev.ts +248 -0
- package/src/client/api.ts +109 -0
- package/src/client/card.tsx +239 -0
- package/src/client/client.ts +63 -0
- package/src/client/constants.ts +97 -0
- package/src/client/draft.ts +293 -0
- package/src/client/fields.tsx +272 -0
- package/src/client/i18n.ts +184 -0
- package/src/client/styles.ts +92 -0
- package/src/client/views/compat.tsx +104 -0
- package/src/client/views/models.tsx +292 -0
- package/src/client/views/provider-fields.tsx +177 -0
- package/src/client/views/providers.tsx +176 -0
- package/src/compat.ts +146 -0
- package/src/config-api.ts +135 -0
- package/src/config.ts +210 -0
- package/src/discovery.ts +176 -0
- package/src/index.ts +33 -0
- package/src/inherit.ts +85 -0
- package/src/profiles.ts +340 -0
- package/src/resolve-dsh.ts +203 -0
- package/src/service.ts +238 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置卡片基础控件:文本/数字输入、勾选行、下拉、键值对编辑、JSON 文本框。
|
|
3
|
+
* 视觉对齐官方卡片字段(label + hint + 控件纵列),样式类前缀 lpc-。
|
|
4
|
+
* @module llm-pi/client/fields
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useState, type ReactElement } from 'react'
|
|
7
|
+
|
|
8
|
+
import type { HeaderPair } from './draft.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 可折叠小节:复杂项(compat/retryPolicy 等)默认折叠,避免卡片过长;
|
|
12
|
+
* 基础项(baseURL 等)保持展开。标题按钮切换展开态。
|
|
13
|
+
*/
|
|
14
|
+
export interface CollapseSectionProps {
|
|
15
|
+
id: string
|
|
16
|
+
title: string
|
|
17
|
+
/** 默认展开态;复杂项传 false(默认折叠)。 */
|
|
18
|
+
defaultOpen: boolean
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
children: ReactElement | ReactElement[] | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function CollapseSection(props: CollapseSectionProps): ReactElement {
|
|
24
|
+
const [open, setOpen] = useState(props.defaultOpen)
|
|
25
|
+
return (
|
|
26
|
+
<div className="lpc-collapse">
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
className="lpc-collapseHead"
|
|
30
|
+
id={props.id}
|
|
31
|
+
aria-expanded={open}
|
|
32
|
+
onClick={() => setOpen(!open)}
|
|
33
|
+
>
|
|
34
|
+
<span className={`lpc-chevron${open ? ' lpc-chevronOpen' : ''}`}>▾</span>
|
|
35
|
+
<span className="lpc-collapseTitle">{props.title}</span>
|
|
36
|
+
</button>
|
|
37
|
+
{open ? <div className="lpc-collapseBody">{props.children}</div> : null}
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TextFieldProps {
|
|
43
|
+
id: string
|
|
44
|
+
label: string
|
|
45
|
+
hint?: string
|
|
46
|
+
value: string
|
|
47
|
+
disabled?: boolean
|
|
48
|
+
invalid?: boolean
|
|
49
|
+
invalidLabel?: string
|
|
50
|
+
numeric?: boolean
|
|
51
|
+
/** datalist 的 id(extends 候选)。 */
|
|
52
|
+
list?: string
|
|
53
|
+
wide?: boolean
|
|
54
|
+
onEdit(text: string): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function TextField(props: TextFieldProps): ReactElement {
|
|
58
|
+
const invalid = props.invalid === true
|
|
59
|
+
return (
|
|
60
|
+
<div className={`lpc-field${props.wide === true ? ' lpc-wide' : ''}`}>
|
|
61
|
+
<div className="lpc-head">
|
|
62
|
+
<label className="lpc-label" htmlFor={props.id}>
|
|
63
|
+
{props.label}
|
|
64
|
+
</label>
|
|
65
|
+
</div>
|
|
66
|
+
<input
|
|
67
|
+
id={props.id}
|
|
68
|
+
className={`lpc-input${invalid ? ' lpc-inputInvalid' : ''}`}
|
|
69
|
+
type="text"
|
|
70
|
+
inputMode={props.numeric === true ? 'numeric' : undefined}
|
|
71
|
+
list={props.list}
|
|
72
|
+
aria-invalid={invalid || undefined}
|
|
73
|
+
value={props.value}
|
|
74
|
+
disabled={props.disabled === true}
|
|
75
|
+
onChange={(event) => props.onEdit(event.target.value)}
|
|
76
|
+
/>
|
|
77
|
+
<p className={invalid ? 'lpc-invalid' : 'lpc-hint'}>
|
|
78
|
+
{invalid ? (props.invalidLabel ?? '') : (props.hint ?? '')}
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface CheckRowProps {
|
|
85
|
+
id: string
|
|
86
|
+
label: string
|
|
87
|
+
checked: boolean
|
|
88
|
+
disabled?: boolean
|
|
89
|
+
onEdit(checked: boolean): void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function CheckRow(props: CheckRowProps): ReactElement {
|
|
93
|
+
return (
|
|
94
|
+
<div className="lpc-checkRow">
|
|
95
|
+
<input
|
|
96
|
+
id={props.id}
|
|
97
|
+
type="checkbox"
|
|
98
|
+
checked={props.checked}
|
|
99
|
+
disabled={props.disabled === true}
|
|
100
|
+
onChange={(event) => props.onEdit(event.target.checked)}
|
|
101
|
+
/>
|
|
102
|
+
<label htmlFor={props.id}>{props.label}</label>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface SelectFieldProps {
|
|
108
|
+
id: string
|
|
109
|
+
label: string
|
|
110
|
+
value: string
|
|
111
|
+
options: readonly string[]
|
|
112
|
+
/** 是否渲染"未设置"(value='')项,及其中文文案。 */
|
|
113
|
+
unsetLabel?: string
|
|
114
|
+
disabled?: boolean
|
|
115
|
+
wide?: boolean
|
|
116
|
+
onEdit(value: string): void
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function SelectField(props: SelectFieldProps): ReactElement {
|
|
120
|
+
return (
|
|
121
|
+
<div className={`lpc-field${props.wide === true ? ' lpc-wide' : ''}`}>
|
|
122
|
+
<div className="lpc-head">
|
|
123
|
+
<label className="lpc-label" htmlFor={props.id}>
|
|
124
|
+
{props.label}
|
|
125
|
+
</label>
|
|
126
|
+
</div>
|
|
127
|
+
<select
|
|
128
|
+
id={props.id}
|
|
129
|
+
className="lpc-input lpc-select"
|
|
130
|
+
value={props.value}
|
|
131
|
+
disabled={props.disabled === true}
|
|
132
|
+
onChange={(event) => props.onEdit(event.target.value)}
|
|
133
|
+
>
|
|
134
|
+
{props.unsetLabel !== undefined ? <option value="">{props.unsetLabel}</option> : null}
|
|
135
|
+
{props.options.map((option) => (
|
|
136
|
+
<option key={option} value={option}>
|
|
137
|
+
{option}
|
|
138
|
+
</option>
|
|
139
|
+
))}
|
|
140
|
+
</select>
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface KeyValueEditorProps {
|
|
146
|
+
id: string
|
|
147
|
+
label: string
|
|
148
|
+
hint?: string
|
|
149
|
+
pairs: HeaderPair[]
|
|
150
|
+
disabled?: boolean
|
|
151
|
+
keyPlaceholder: string
|
|
152
|
+
valuePlaceholder: string
|
|
153
|
+
addLabel: string
|
|
154
|
+
removeLabel: string
|
|
155
|
+
onEdit(pairs: HeaderPair[]): void
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function KeyValueEditor(props: KeyValueEditorProps): ReactElement {
|
|
159
|
+
const update = (index: number, patch: Partial<HeaderPair>): void => {
|
|
160
|
+
props.onEdit(props.pairs.map((pair, i) => (i === index ? { ...pair, ...patch } : pair)))
|
|
161
|
+
}
|
|
162
|
+
const remove = (index: number): void => {
|
|
163
|
+
props.onEdit(props.pairs.filter((_, i) => i !== index))
|
|
164
|
+
}
|
|
165
|
+
return (
|
|
166
|
+
<div className="lpc-field lpc-wide">
|
|
167
|
+
<div className="lpc-head">
|
|
168
|
+
<label className="lpc-label" htmlFor={props.id}>
|
|
169
|
+
{props.label}
|
|
170
|
+
</label>
|
|
171
|
+
</div>
|
|
172
|
+
{props.pairs.map((pair, index) => (
|
|
173
|
+
<div className="lpc-kvRow" key={index}>
|
|
174
|
+
<input
|
|
175
|
+
id={index === 0 ? props.id : undefined}
|
|
176
|
+
className="lpc-input"
|
|
177
|
+
value={pair.key}
|
|
178
|
+
placeholder={props.keyPlaceholder}
|
|
179
|
+
disabled={props.disabled === true}
|
|
180
|
+
onChange={(event) => update(index, { key: event.target.value })}
|
|
181
|
+
/>
|
|
182
|
+
<input
|
|
183
|
+
className="lpc-input"
|
|
184
|
+
value={pair.value}
|
|
185
|
+
placeholder={props.valuePlaceholder}
|
|
186
|
+
disabled={props.disabled === true}
|
|
187
|
+
onChange={(event) => update(index, { value: event.target.value })}
|
|
188
|
+
/>
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
className="lpc-btn lpc-btnGhost lpc-btnSmall"
|
|
192
|
+
disabled={props.disabled === true}
|
|
193
|
+
onClick={() => remove(index)}
|
|
194
|
+
>
|
|
195
|
+
{props.removeLabel}
|
|
196
|
+
</button>
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
<div className="lpc-kvAdd">
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
className="lpc-btn lpc-btnGhost lpc-btnSmall"
|
|
203
|
+
disabled={props.disabled === true}
|
|
204
|
+
onClick={() => props.onEdit([...props.pairs, { key: '', value: '' }])}
|
|
205
|
+
>
|
|
206
|
+
{props.addLabel}
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
{props.hint !== undefined ? <p className="lpc-hint">{props.hint}</p> : null}
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function toJsonText(value: unknown): string {
|
|
215
|
+
return value === undefined ? '' : JSON.stringify(value, null, 2)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseJsonText(text: string): { ok: true; value: unknown } | { ok: false } {
|
|
219
|
+
if (text.trim() === '') return { ok: true, value: undefined }
|
|
220
|
+
try {
|
|
221
|
+
return { ok: true, value: JSON.parse(text) }
|
|
222
|
+
} catch {
|
|
223
|
+
return { ok: false }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface JsonFieldProps {
|
|
228
|
+
id: string
|
|
229
|
+
label: string
|
|
230
|
+
hint?: string
|
|
231
|
+
invalidText: string
|
|
232
|
+
value: unknown
|
|
233
|
+
/** 放弃/保存后外部重置草稿时自增,驱动本地文本重新播种。 */
|
|
234
|
+
epoch: number
|
|
235
|
+
disabled?: boolean
|
|
236
|
+
wide?: boolean
|
|
237
|
+
onEdit(value: unknown): void
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function JsonField(props: JsonFieldProps): ReactElement {
|
|
241
|
+
const [text, setText] = useState(() => toJsonText(props.value))
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
setText(toJsonText(props.value))
|
|
244
|
+
}, [props.epoch])
|
|
245
|
+
const parsed = parseJsonText(text)
|
|
246
|
+
return (
|
|
247
|
+
<div className={`lpc-field${props.wide === true ? ' lpc-wide' : ''}`}>
|
|
248
|
+
<div className="lpc-head">
|
|
249
|
+
<label className="lpc-label" htmlFor={props.id}>
|
|
250
|
+
{props.label}
|
|
251
|
+
</label>
|
|
252
|
+
</div>
|
|
253
|
+
<textarea
|
|
254
|
+
id={props.id}
|
|
255
|
+
className={`lpc-input lpc-textarea${parsed.ok ? '' : ' lpc-inputInvalid'}`}
|
|
256
|
+
rows={4}
|
|
257
|
+
spellCheck={false}
|
|
258
|
+
aria-invalid={parsed.ok ? undefined : true}
|
|
259
|
+
value={text}
|
|
260
|
+
disabled={props.disabled === true}
|
|
261
|
+
onChange={(event) => {
|
|
262
|
+
setText(event.target.value)
|
|
263
|
+
const result = parseJsonText(event.target.value)
|
|
264
|
+
props.onEdit(result.ok ? result.value : undefined)
|
|
265
|
+
}}
|
|
266
|
+
/>
|
|
267
|
+
<p className={parsed.ok ? 'lpc-hint' : 'lpc-invalid'}>
|
|
268
|
+
{parsed.ok ? (props.hint ?? '') : props.invalidText}
|
|
269
|
+
</p>
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置卡片文案(zh/en)。经 ctx.locale.register 注册、bind 取用,与官方卡片同机制。
|
|
3
|
+
* @module llm-pi/client/i18n
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const NS = 'dsh-plus-llm-pi'
|
|
7
|
+
|
|
8
|
+
export const zh: Record<string, string> = {
|
|
9
|
+
title: 'LLM 路由(llm-pi)',
|
|
10
|
+
description: '自定义 LLM 路由:协议、compat、模型目录与 models.dev 目录兜底。',
|
|
11
|
+
enabled: '启用插件',
|
|
12
|
+
catalogUrl: 'models.dev 目录数据端点',
|
|
13
|
+
catalogUrlHint: '快照数据源;一般无需修改。',
|
|
14
|
+
catalogRefreshHours: '目录刷新间隔(小时)',
|
|
15
|
+
catalogRefreshHoursHint: '0 = 不自动拉取(可手动拉取或读已有缓存);>0 = 每 N 小时自动拉取。',
|
|
16
|
+
catalogProxy: '拉取代理地址',
|
|
17
|
+
catalogProxyHint: 'HTTP 代理(如 http://127.0.0.1:7890);留空直连。',
|
|
18
|
+
kitSource: '模块来源',
|
|
19
|
+
modelsDevStatus: 'models.dev 快照',
|
|
20
|
+
modelsDevEmpty: '未拉取(无缓存数据;可手动拉取)',
|
|
21
|
+
modelsDevStatusLine: '已加载',
|
|
22
|
+
modelsDevError: '加载失败:',
|
|
23
|
+
refreshCatalog: '手动拉取',
|
|
24
|
+
refreshingCatalog: '拉取中…',
|
|
25
|
+
refreshOk: '目录已拉取。',
|
|
26
|
+
refreshFailed: '拉取失败:',
|
|
27
|
+
advancedGroup: '高级设置(retryPolicy / compat)',
|
|
28
|
+
providersGroup: 'Provider 路由',
|
|
29
|
+
addRoute: '新增 route',
|
|
30
|
+
addRoutePlaceholder: '新 route 键名(如 my-llm)',
|
|
31
|
+
routeEmpty: 'route 键名不能为空。',
|
|
32
|
+
routeDuplicate: '该 route 已存在。',
|
|
33
|
+
deleteRoute: '删除 route',
|
|
34
|
+
providerFields: '基础字段',
|
|
35
|
+
extends: '继承内置 provider',
|
|
36
|
+
extendsHint: '如 openai、anthropic;提供 api/baseURL 默认值与模型查找源。',
|
|
37
|
+
displayName: '显示名',
|
|
38
|
+
api: '线协议',
|
|
39
|
+
baseURL: '端点 URL',
|
|
40
|
+
baseURLHint: '缺省继承 extends 源的端点。',
|
|
41
|
+
apiKeyEnv: '凭据引用名',
|
|
42
|
+
apiKeyEnvHint: '如 NEWAPI_API_KEY(凭据服务或环境变量)。',
|
|
43
|
+
defaultContextWindow: '默认上下文容量',
|
|
44
|
+
defaultMaxTokens: '默认输出上限',
|
|
45
|
+
defaultInput: '默认输入模态',
|
|
46
|
+
reasoning: '默认 reasoning 档位',
|
|
47
|
+
thinkingBudgets: 'thinking 档位预算',
|
|
48
|
+
cacheRetention: '提示缓存保留',
|
|
49
|
+
transport: '流式传输',
|
|
50
|
+
timeoutMs: 'HTTP 超时(毫秒)',
|
|
51
|
+
websocketConnectTimeoutMs: 'WebSocket 连接超时(毫秒)',
|
|
52
|
+
streamIdleTimeoutMs: '流空闲超时(毫秒)',
|
|
53
|
+
headers: '请求头',
|
|
54
|
+
headersHint: '键值对;键为空的整行会被忽略。',
|
|
55
|
+
key: '键',
|
|
56
|
+
value: '值',
|
|
57
|
+
add: '添加',
|
|
58
|
+
remove: '删除',
|
|
59
|
+
retryPolicy: '重试策略(JSON)',
|
|
60
|
+
retryPolicyHint: 'dsh-llm RetryPolicy 形状;非法 JSON 不会提交。',
|
|
61
|
+
invalidJson: 'JSON 格式错误(该字段不会提交)。',
|
|
62
|
+
compatGroup: 'Compat 覆盖',
|
|
63
|
+
compatApiHint: 'api 未设置时暂按 openai-completions 字段组渲染;保存时后端按实际协议校验。',
|
|
64
|
+
compatUnset: '未设置',
|
|
65
|
+
modelsGroup: '模型目录',
|
|
66
|
+
addModel: '添加模型',
|
|
67
|
+
deleteModel: '删除',
|
|
68
|
+
modelRow: '模型',
|
|
69
|
+
modelId: '模型 id',
|
|
70
|
+
modelIdHint: '发送给 provider 的标识,必填。',
|
|
71
|
+
modelIdRequired: '模型 id 必填。',
|
|
72
|
+
modelExtends: '继承源',
|
|
73
|
+
modelExtendsHint: '"provider/model" 或裸 model id(候选来自下方目录)。',
|
|
74
|
+
modelName: '显示名',
|
|
75
|
+
contextWindow: '上下文容量',
|
|
76
|
+
maxTokens: '输出上限',
|
|
77
|
+
input: '输入模态',
|
|
78
|
+
reasoningEfforts: 'reasoningEfforts',
|
|
79
|
+
nonReasoning: '非推理模型',
|
|
80
|
+
catalogSource: '候选来源',
|
|
81
|
+
catalogProvider: '候选 provider',
|
|
82
|
+
catalogLoading: '目录加载中…',
|
|
83
|
+
catalogFailed: '目录加载失败。',
|
|
84
|
+
save: '保存',
|
|
85
|
+
saving: '保存中…',
|
|
86
|
+
discard: '放弃',
|
|
87
|
+
unsaved: '未保存',
|
|
88
|
+
saveOk: '已保存。',
|
|
89
|
+
saveFailed: '保存失败:',
|
|
90
|
+
loading: '加载中…',
|
|
91
|
+
readOnly: '当前部署无 settings provider,配置为只读;请编辑 settings.yaml。',
|
|
92
|
+
expand: '展开',
|
|
93
|
+
collapse: '收起',
|
|
94
|
+
invalidNumber: '请输入有效数字',
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const en: Record<string, string> = {
|
|
98
|
+
title: 'LLM routes (llm-pi)',
|
|
99
|
+
description: 'Custom LLM routes: protocol, compat, model catalog and models.dev fallback.',
|
|
100
|
+
enabled: 'Enable plugin',
|
|
101
|
+
catalogUrl: 'models.dev catalog endpoint',
|
|
102
|
+
catalogUrlHint: 'Snapshot data source; usually no change needed.',
|
|
103
|
+
catalogRefreshHours: 'Catalog refresh (hours)',
|
|
104
|
+
catalogRefreshHoursHint: '0 = no auto refresh (manual refresh or existing cache); >0 = refresh every N hours.',
|
|
105
|
+
catalogProxy: 'Fetch proxy',
|
|
106
|
+
catalogProxyHint: 'HTTP proxy (e.g. http://127.0.0.1:7890); leave empty for direct.',
|
|
107
|
+
kitSource: 'Module source',
|
|
108
|
+
modelsDevStatus: 'models.dev snapshot',
|
|
109
|
+
modelsDevEmpty: 'Not fetched (no cached data; use manual refresh)',
|
|
110
|
+
modelsDevStatusLine: 'Loaded',
|
|
111
|
+
modelsDevError: 'Load failed: ',
|
|
112
|
+
refreshCatalog: 'Refresh now',
|
|
113
|
+
refreshingCatalog: 'Refreshing…',
|
|
114
|
+
refreshOk: 'Catalog refreshed.',
|
|
115
|
+
refreshFailed: 'Refresh failed: ',
|
|
116
|
+
advancedGroup: 'Advanced (retryPolicy / compat)',
|
|
117
|
+
providersGroup: 'Provider routes',
|
|
118
|
+
addRoute: 'Add route',
|
|
119
|
+
addRoutePlaceholder: 'New route key (e.g. my-llm)',
|
|
120
|
+
routeEmpty: 'Route key must not be empty.',
|
|
121
|
+
routeDuplicate: 'This route already exists.',
|
|
122
|
+
deleteRoute: 'Delete route',
|
|
123
|
+
providerFields: 'Basic fields',
|
|
124
|
+
extends: 'Inherit built-in provider',
|
|
125
|
+
extendsHint: 'e.g. openai, anthropic; provides api/baseURL defaults and the model lookup source.',
|
|
126
|
+
displayName: 'Display name',
|
|
127
|
+
api: 'Wire protocol',
|
|
128
|
+
baseURL: 'Base URL',
|
|
129
|
+
baseURLHint: 'Defaults to the extends source endpoint.',
|
|
130
|
+
apiKeyEnv: 'Credential reference',
|
|
131
|
+
apiKeyEnvHint: 'e.g. NEWAPI_API_KEY (credential service or environment variable).',
|
|
132
|
+
defaultContextWindow: 'Default context window',
|
|
133
|
+
defaultMaxTokens: 'Default max tokens',
|
|
134
|
+
defaultInput: 'Default input modalities',
|
|
135
|
+
reasoning: 'Default reasoning level',
|
|
136
|
+
thinkingBudgets: 'Thinking budgets',
|
|
137
|
+
cacheRetention: 'Cache retention',
|
|
138
|
+
transport: 'Streaming transport',
|
|
139
|
+
timeoutMs: 'HTTP timeout (ms)',
|
|
140
|
+
websocketConnectTimeoutMs: 'WebSocket connect timeout (ms)',
|
|
141
|
+
streamIdleTimeoutMs: 'Stream idle timeout (ms)',
|
|
142
|
+
headers: 'Request headers',
|
|
143
|
+
headersHint: 'Key/value pairs; rows with an empty key are ignored.',
|
|
144
|
+
key: 'Key',
|
|
145
|
+
value: 'Value',
|
|
146
|
+
add: 'Add',
|
|
147
|
+
remove: 'Remove',
|
|
148
|
+
retryPolicy: 'Retry policy (JSON)',
|
|
149
|
+
retryPolicyHint: 'dsh-llm RetryPolicy shape; invalid JSON is not submitted.',
|
|
150
|
+
invalidJson: 'Invalid JSON (this field will not be submitted).',
|
|
151
|
+
compatGroup: 'Compat overrides',
|
|
152
|
+
compatApiHint: 'When api is unset, fields render per openai-completions; the backend validates per the effective protocol.',
|
|
153
|
+
compatUnset: 'Unset',
|
|
154
|
+
modelsGroup: 'Model catalog',
|
|
155
|
+
addModel: 'Add model',
|
|
156
|
+
deleteModel: 'Delete',
|
|
157
|
+
modelRow: 'Model',
|
|
158
|
+
modelId: 'Model id',
|
|
159
|
+
modelIdHint: 'The identifier sent to the provider; required.',
|
|
160
|
+
modelIdRequired: 'Model id is required.',
|
|
161
|
+
modelExtends: 'Inherits from',
|
|
162
|
+
modelExtendsHint: '"provider/model" or a bare model id (candidates from the catalog below).',
|
|
163
|
+
modelName: 'Display name',
|
|
164
|
+
contextWindow: 'Context window',
|
|
165
|
+
maxTokens: 'Max tokens',
|
|
166
|
+
input: 'Input modalities',
|
|
167
|
+
reasoningEfforts: 'reasoningEfforts',
|
|
168
|
+
nonReasoning: 'Non-reasoning model',
|
|
169
|
+
catalogSource: 'Candidate source',
|
|
170
|
+
catalogProvider: 'Candidate provider',
|
|
171
|
+
catalogLoading: 'Loading catalog…',
|
|
172
|
+
catalogFailed: 'Failed to load catalog.',
|
|
173
|
+
save: 'Save',
|
|
174
|
+
saving: 'Saving…',
|
|
175
|
+
discard: 'Discard',
|
|
176
|
+
unsaved: 'Unsaved',
|
|
177
|
+
saveOk: 'Saved.',
|
|
178
|
+
saveFailed: 'Save failed: ',
|
|
179
|
+
loading: 'Loading…',
|
|
180
|
+
readOnly: 'No settings provider in this deployment; edit settings.yaml instead.',
|
|
181
|
+
expand: 'Expand',
|
|
182
|
+
collapse: 'Collapse',
|
|
183
|
+
invalidNumber: 'Enter a valid number',
|
|
184
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置卡片样式:沿用官方 data-plugin / data-plugin-css 约定(HMR 据此卸载),
|
|
3
|
+
* 视觉对齐官方卡片(--dsw-alias-* 变量),不覆盖上游任何选择器。
|
|
4
|
+
* @module llm-pi/client/styles
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const PLUGIN_ID = '@dsh-plus/llm-pi'
|
|
8
|
+
export const STYLE_TAG_ID = `${PLUGIN_ID}/card.css`
|
|
9
|
+
|
|
10
|
+
export const cardCss = `
|
|
11
|
+
.lpc-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
|
|
12
|
+
.lpc-card:hover{border-color:var(--dsw-alias-label-dimmed)}
|
|
13
|
+
.lpc-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
|
|
14
|
+
.lpc-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
|
|
15
|
+
.lpc-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
16
|
+
.lpc-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
|
|
17
|
+
.lpc-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
|
|
18
|
+
.lpc-description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
|
|
19
|
+
.lpc-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s;font-size:12px}
|
|
20
|
+
.lpc-chevronOpen{transform:rotate(180deg)}
|
|
21
|
+
.lpc-pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}
|
|
22
|
+
.lpc-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
|
|
23
|
+
.lpc-field{flex-direction:column;gap:6px;padding:12px 0;display:flex;min-width:0}
|
|
24
|
+
.lpc-field+.lpc-field{border-top:1px solid var(--dsw-alias-border-l2)}
|
|
25
|
+
.lpc-head{align-items:center;gap:8px;display:flex}
|
|
26
|
+
.lpc-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}
|
|
27
|
+
.lpc-input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;width:100%;box-sizing:border-box;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}
|
|
28
|
+
.lpc-input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
|
|
29
|
+
.lpc-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
|
|
30
|
+
.lpc-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
|
31
|
+
.lpc-select{appearance:none}
|
|
32
|
+
.lpc-textarea{height:auto;min-height:72px;padding:8px 12px;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;resize:vertical}
|
|
33
|
+
.lpc-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}
|
|
34
|
+
.lpc-invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}
|
|
35
|
+
.lpc-checkRow{align-items:center;gap:8px;display:flex;padding:3px 0}
|
|
36
|
+
.lpc-checkRow input{accent-color:var(--dsw-alias-brand-primary)}
|
|
37
|
+
.lpc-checkRow label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;cursor:pointer}
|
|
38
|
+
.lpc-groupLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;padding:14px 0 2px;margin:0}
|
|
39
|
+
.lpc-readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}
|
|
40
|
+
.lpc-statusRow{color:var(--dsw-alias-label-tertiary);margin:6px 0 0;font-size:12px;line-height:1.6;word-break:break-all}
|
|
41
|
+
.lpc-footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex;flex-wrap:wrap}
|
|
42
|
+
.lpc-status{min-width:0;color:var(--dsw-alias-label-secondary);flex:1;margin:0;font-size:12px;line-height:1.5}
|
|
43
|
+
.lpc-statusError{color:var(--dsw-alias-label-error)}
|
|
44
|
+
.lpc-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5;flex:none}
|
|
45
|
+
.lpc-btnGhost{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}
|
|
46
|
+
.lpc-btnGhost:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
|
|
47
|
+
.lpc-btnPrimary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
|
|
48
|
+
.lpc-btn:disabled{opacity:.4;cursor:default}
|
|
49
|
+
.lpc-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
|
|
50
|
+
.lpc-btnSmall{padding:2px 10px;font-size:12px}
|
|
51
|
+
.lpc-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}
|
|
52
|
+
.lpc-gridNested{margin-top:2px}
|
|
53
|
+
.lpc-wide{grid-column:1 / -1}
|
|
54
|
+
.lpc-addRoute{display:flex;gap:8px;align-items:center;padding:10px 0}
|
|
55
|
+
.lpc-addRoute .lpc-input{flex:1;min-width:0}
|
|
56
|
+
.lpc-route{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;margin:10px 0;background:var(--dsw-alias-bg-layer-3)}
|
|
57
|
+
.lpc-routeHead{display:flex;align-items:center;gap:8px;padding:6px 10px}
|
|
58
|
+
.lpc-routeToggle{appearance:none;background:0 0;border:0;font:inherit;color:inherit;cursor:pointer;display:flex;align-items:center;gap:8px;flex:1;min-width:0;text-align:left;padding:4px 0;border-radius:6px}
|
|
59
|
+
.lpc-routeToggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
60
|
+
.lpc-routeKey{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
61
|
+
.lpc-routeApi{color:var(--dsw-alias-label-tertiary);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
62
|
+
.lpc-routeBody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 14px;padding-bottom:6px}
|
|
63
|
+
.lpc-kvRow{display:flex;gap:8px;align-items:center}
|
|
64
|
+
.lpc-kvRow .lpc-input{flex:1;min-width:0}
|
|
65
|
+
.lpc-kvAdd{padding-top:8px}
|
|
66
|
+
.lpc-modelRow{border:1px dashed var(--dsw-alias-border-l2);border-radius:10px;margin:10px 0;padding:0 14px;background:var(--dsw-alias-bg-layer-3)}
|
|
67
|
+
.lpc-modelHead{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 0}
|
|
68
|
+
.lpc-modelTitle{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}
|
|
69
|
+
.lpc-catalogBar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:4px 0}
|
|
70
|
+
.lpc-catalogLabel{color:var(--dsw-alias-label-tertiary);font-size:12px}
|
|
71
|
+
.lpc-catalogSelect{width:auto;height:30px}
|
|
72
|
+
.lpc-collapse{border-top:1px solid var(--dsw-alias-border-l2);margin:2px 0}
|
|
73
|
+
.lpc-collapseHead{appearance:none;background:0 0;border:0;font:inherit;color:inherit;cursor:pointer;display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:10px 0;border-radius:6px}
|
|
74
|
+
.lpc-collapseHead:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
75
|
+
.lpc-collapseTitle{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}
|
|
76
|
+
.lpc-collapseBody{border-top:1px dashed var(--dsw-alias-border-l2);padding-bottom:6px}
|
|
77
|
+
.lpc-refreshBtn{margin-left:8px;vertical-align:middle}
|
|
78
|
+
`
|
|
79
|
+
|
|
80
|
+
/** 幂等注入样式标签;返回标签(已存在或环境无 document 时为 null)。 */
|
|
81
|
+
export function injectStyle(): HTMLStyleElement | null {
|
|
82
|
+
if (typeof document === 'undefined') return null
|
|
83
|
+
if (document.querySelector(`style[data-plugin-css=${JSON.stringify(STYLE_TAG_ID)}]`) !== null) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
const tag = document.createElement('style')
|
|
87
|
+
tag.dataset.plugin = PLUGIN_ID
|
|
88
|
+
tag.dataset.pluginCss = STYLE_TAG_ID
|
|
89
|
+
tag.textContent = cardCss
|
|
90
|
+
document.head.appendChild(tag)
|
|
91
|
+
return tag
|
|
92
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compat 覆盖编辑器:按当前 api 渲染字段组(与服务端 compat.ts 字段表一致)。
|
|
3
|
+
* boolean → 三态下拉(未设置/true/false),枚举 → 下拉(含未设置),
|
|
4
|
+
* object → JSON 文本框(本地文本状态,合法时写入草稿,非法仅提示)。
|
|
5
|
+
* 未知/非法值由后端校验兜底(PUT 失败会返回校验明细)。
|
|
6
|
+
* @module llm-pi/client/views/compat
|
|
7
|
+
*/
|
|
8
|
+
import type { ReactElement } from 'react'
|
|
9
|
+
|
|
10
|
+
import { COMPAT_FALLBACK_API, compatFieldSpec, compatFieldsOf } from '../constants.ts'
|
|
11
|
+
import { CollapseSection, JsonField, SelectField } from '../fields.tsx'
|
|
12
|
+
|
|
13
|
+
/** api 变更后裁剪 compat:只保留新渲染组的字段,避免保存时被后端拒绝。 */
|
|
14
|
+
export function pruneCompatForApi(compat: Record<string, unknown>, api: string): Record<string, unknown> {
|
|
15
|
+
const group = api !== '' && compatFieldsOf(api).length > 0 ? api : COMPAT_FALLBACK_API
|
|
16
|
+
const fields = new Set(compatFieldsOf(group))
|
|
17
|
+
const next: Record<string, unknown> = {}
|
|
18
|
+
for (const [key, value] of Object.entries(compat)) {
|
|
19
|
+
if (fields.has(key)) next[key] = value
|
|
20
|
+
}
|
|
21
|
+
return next
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CompatEditorProps {
|
|
25
|
+
idPrefix: string
|
|
26
|
+
api: string
|
|
27
|
+
compat: Record<string, unknown>
|
|
28
|
+
epoch: number
|
|
29
|
+
disabled?: boolean
|
|
30
|
+
wide?: boolean
|
|
31
|
+
t(key: string): string
|
|
32
|
+
onEdit(next: Record<string, unknown>): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function CompatEditor(props: CompatEditorProps): ReactElement {
|
|
36
|
+
const effective = props.api !== '' && compatFieldsOf(props.api).length > 0 ? props.api : COMPAT_FALLBACK_API
|
|
37
|
+
const fields = compatFieldsOf(effective)
|
|
38
|
+
const setField = (field: string, value: unknown): void => {
|
|
39
|
+
const next = { ...props.compat }
|
|
40
|
+
if (value === undefined) delete next[field]
|
|
41
|
+
else next[field] = value
|
|
42
|
+
props.onEdit(next)
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
<div className={`lpc-field lpc-wide`}>
|
|
46
|
+
<CollapseSection id={`${props.idPrefix}-collapse`} title={props.t('compatGroup')} defaultOpen={false}>
|
|
47
|
+
<>
|
|
48
|
+
{props.api === '' ? <p className="lpc-hint">{props.t('compatApiHint')}</p> : null}
|
|
49
|
+
<div className="lpc-grid">
|
|
50
|
+
{fields.map((field) => {
|
|
51
|
+
const spec = compatFieldSpec(effective, field)
|
|
52
|
+
if (spec === 'boolean') {
|
|
53
|
+
return (
|
|
54
|
+
<SelectField
|
|
55
|
+
key={field}
|
|
56
|
+
id={`${props.idPrefix}-${field}`}
|
|
57
|
+
label={field}
|
|
58
|
+
value={props.compat[field] === undefined ? '' : String(props.compat[field])}
|
|
59
|
+
options={['true', 'false']}
|
|
60
|
+
unsetLabel={props.t('compatUnset')}
|
|
61
|
+
disabled={props.disabled === true}
|
|
62
|
+
onEdit={(value) => {
|
|
63
|
+
if (value === '') setField(field, undefined)
|
|
64
|
+
else setField(field, value === 'true')
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
if (spec === 'object') {
|
|
70
|
+
return (
|
|
71
|
+
<JsonField
|
|
72
|
+
key={field}
|
|
73
|
+
id={`${props.idPrefix}-${field}`}
|
|
74
|
+
label={field}
|
|
75
|
+
value={props.compat[field]}
|
|
76
|
+
epoch={props.epoch}
|
|
77
|
+
disabled={props.disabled === true}
|
|
78
|
+
invalidText={props.t('invalidJson')}
|
|
79
|
+
onEdit={(value) => setField(field, value)}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
<SelectField
|
|
85
|
+
key={field}
|
|
86
|
+
id={`${props.idPrefix}-${field}`}
|
|
87
|
+
label={field}
|
|
88
|
+
value={props.compat[field] === undefined ? '' : String(props.compat[field])}
|
|
89
|
+
options={spec}
|
|
90
|
+
unsetLabel={props.t('compatUnset')}
|
|
91
|
+
disabled={props.disabled === true}
|
|
92
|
+
onEdit={(value) => {
|
|
93
|
+
if (value === '') setField(field, undefined)
|
|
94
|
+
else setField(field, value)
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
)
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
</>
|
|
101
|
+
</CollapseSection>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|