@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.
@@ -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
+ }