@dsh-plus/llm-pi 0.1.42 → 0.1.43

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,147 @@
1
+ /**
2
+ * 目录浏览器的单行:路径 id(可复制)+ 名称 + 协议徽标 + 参数行 + 折叠的参数明细
3
+ * + 「添加到 route」(不可添加时按钮文案即原因)。与浏览器主体拆开,
4
+ * 便于各自演进(行内只依赖服务端给的模型事实)。
5
+ * @module llm-pi/client/views/catalog-row
6
+ */
7
+
8
+ import {
9
+ ChevronDownIcon,
10
+ copyText,
11
+ IconCheckOutlineRegular,
12
+ IconCopyOutlineRegular,
13
+ } from '@dsh-plus/shared/client'
14
+ import { type ReactElement, useEffect, useRef, useState } from 'react'
15
+
16
+ import type { WireModelInfo } from '../api.ts'
17
+ import type { Translate } from '../i18n.ts'
18
+ import type { AddBlockReason, AddEligibility } from '../route-fit.ts'
19
+
20
+ /** 计数展示(千分位;不引本地化依赖)。 */
21
+ function count(value: number): string {
22
+ return value.toLocaleString()
23
+ }
24
+
25
+ /** 参数行的键值短展示:字符串化,长值截断。 */
26
+ function shortValue(value: unknown): string {
27
+ const text = typeof value === 'string' ? value : JSON.stringify(value)
28
+ if (text === undefined) return 'undefined'
29
+ return text.length > 120 ? `${text.slice(0, 117)}…` : text
30
+ }
31
+
32
+ /** 禁止添加的原因文案。 */
33
+ export function blockText(t: Translate, reason: AddBlockReason, detail?: string): string {
34
+ if (reason === 'unsupported') {
35
+ return `${t('blockUnsupported')}${detail === undefined ? '' : `(${detail})`}`
36
+ }
37
+ if (reason === 'duplicate') return t('blockDuplicate')
38
+ if (reason === 'protocol-conflict') {
39
+ return `${t('blockConflict')}${detail === undefined || detail === '' ? '' : `(${detail})`}`
40
+ }
41
+ if (reason === 'route-mixed') return t('blockMixed')
42
+ return t('blockNoRoute')
43
+ }
44
+
45
+ export interface CatalogRowProps {
46
+ t: Translate
47
+ model: WireModelInfo
48
+ disabled: boolean
49
+ eligibility: AddEligibility
50
+ onAdd(): void
51
+ }
52
+
53
+ /** 单行:路径 id(可复制)+ 名称 + 参数行 + 折叠的参数明细 + 添加按钮。 */
54
+ export function CatalogRow(props: CatalogRowProps): ReactElement {
55
+ const { t, model } = props
56
+ const [details, setDetails] = useState(false)
57
+ const [copied, setCopied] = useState(false)
58
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
59
+ useEffect(
60
+ () => () => {
61
+ if (timer.current !== null) clearTimeout(timer.current)
62
+ },
63
+ [],
64
+ )
65
+ const blocked = props.eligibility.ok ? undefined : props.eligibility
66
+ const compatKeys = Object.keys(model.compat ?? {})
67
+ const restKeys = Object.keys(model.rest)
68
+ return (
69
+ <div className="lpc-catRow">
70
+ <div className="lpc-catHead">
71
+ <span className="lpc-catPath">{model.path}</span>
72
+ <button
73
+ type="button"
74
+ className="lpc-iconBtn"
75
+ title={copied ? t('browserCopied') : t('browserCopy')}
76
+ aria-label={t('browserCopy')}
77
+ onClick={() => {
78
+ void copyText(model.path).then((ok) => {
79
+ if (!ok) return
80
+ setCopied(true)
81
+ if (timer.current !== null) clearTimeout(timer.current)
82
+ timer.current = setTimeout(() => setCopied(false), 1500)
83
+ })
84
+ }}
85
+ >
86
+ {copied ? <IconCheckOutlineRegular size={14} /> : <IconCopyOutlineRegular size={14} />}
87
+ </button>
88
+ <span className="lpc-catName">{model.name}</span>
89
+ <span className={model.servable ? 'lpc-badge lpc-badgeSet' : 'lpc-badge lpc-badgeUnset'}>
90
+ {model.api}
91
+ </span>
92
+ <button
93
+ type="button"
94
+ className="lpc-btn lpc-btnGhost lpc-btnSmall"
95
+ disabled={props.disabled || blocked !== undefined}
96
+ title={blocked === undefined ? undefined : blockText(t, blocked.reason, blocked.detail)}
97
+ onClick={props.onAdd}
98
+ >
99
+ {blocked === undefined ? t('browserAdd') : blockText(t, blocked.reason, blocked.detail)}
100
+ </button>
101
+ </div>
102
+ <p className="lpc-catParams">
103
+ {`${t('browserContextShort')} ${count(model.contextWindow)} · ${t('browserMaxShort')} ${count(model.maxTokens)} · `}
104
+ {model.input
105
+ .map((m) => (m === 'image' ? t('browserInputImage') : t('browserInputText')))
106
+ .join('/')}
107
+ {model.reasoning ? ` · ${t('browserReasoning')}` : ''}
108
+ {compatKeys.length > 0 ? ` · compat ${compatKeys.length}` : ''}
109
+ </p>
110
+ <button
111
+ type="button"
112
+ className="lpc-catDetailsToggle"
113
+ aria-expanded={details}
114
+ onClick={() => setDetails(!details)}
115
+ >
116
+ <ChevronDownIcon className={`lpc-chevron${details ? ' lpc-chevronOpen' : ''}`} />
117
+ <span>{t('browserDetails')}</span>
118
+ </button>
119
+ {details ? (
120
+ <div className="lpc-catDetails">
121
+ {model.thinkingLevelMap !== undefined ? (
122
+ <p className="lpc-catParams">
123
+ {`${t('browserThinkMap')}:${Object.entries(model.thinkingLevelMap)
124
+ .map(([level, wire]) => `${level}=${wire ?? '-'}`)
125
+ .join(' ')}`}
126
+ </p>
127
+ ) : null}
128
+ {compatKeys.length > 0 ? (
129
+ <>
130
+ <p className="lpc-catDetailLabel">{t('browserCompat')}</p>
131
+ <pre className="lpc-catJson">{JSON.stringify(model.compat, null, 2)}</pre>
132
+ </>
133
+ ) : null}
134
+ {restKeys.length > 0 ? (
135
+ <>
136
+ <p className="lpc-catDetailLabel">{t('browserExtra')}</p>
137
+ <pre className="lpc-catJson">
138
+ {restKeys.map((key) => `${key}: ${shortValue(model.rest[key])}`).join('\n')}
139
+ </pre>
140
+ </>
141
+ ) : null}
142
+ <p className="lpc-catParams">{`baseUrl:${model.baseUrl}`}</p>
143
+ </div>
144
+ ) : null}
145
+ </div>
146
+ )
147
+ }
@@ -2,10 +2,12 @@
2
2
  * compat 覆盖编辑器:按当前 api 渲染字段组(与服务端 compat.ts 字段表一致)。
3
3
  * boolean → 三态下拉(未设置/true/false),枚举 → 下拉(含未设置),
4
4
  * object → JSON 文本框(本地文本状态,合法时写入草稿,非法仅提示)。
5
- * 未知/非法值由后端校验兜底(PUT 失败会返回校验明细)。
5
+ * 未知/非法值由后端校验兜底(保存失败会返回校验明细)。
6
+ * 字段多(如 completions 19 项)时提供字段名过滤,折叠标题显示"已设 M/K"。
6
7
  * @module llm-pi/client/views/compat
7
8
  */
8
9
  import type { ReactElement } from 'react'
10
+ import { useState } from 'react'
9
11
 
10
12
  import { COMPAT_FALLBACK_API, compatFieldSpec, compatFieldsOf } from '../constants.ts'
11
13
  import { CollapseSection, IntegerField, JsonField, SelectField } from '../fields.tsx'
@@ -25,6 +27,9 @@ export function pruneCompatForApi(
25
27
  return next
26
28
  }
27
29
 
30
+ /** 字段数超过该值时提供过滤框(19 项一屏放不下)。 */
31
+ const FILTER_THRESHOLD = 10
32
+
28
33
  export interface CompatEditorProps {
29
34
  idPrefix: string
30
35
  api: string
@@ -37,9 +42,14 @@ export interface CompatEditorProps {
37
42
  }
38
43
 
39
44
  export function CompatEditor(props: CompatEditorProps): ReactElement {
45
+ const [filter, setFilter] = useState('')
40
46
  const effective =
41
47
  props.api !== '' && compatFieldsOf(props.api).length > 0 ? props.api : COMPAT_FALLBACK_API
42
- const fields = compatFieldsOf(effective)
48
+ const allFields = compatFieldsOf(effective)
49
+ const needle = filter.trim().toLowerCase()
50
+ const fields =
51
+ needle === '' ? allFields : allFields.filter((f) => f.toLowerCase().includes(needle))
52
+ const setCount = allFields.filter((field) => props.compat[field] !== undefined).length
43
53
  const setField = (field: string, value: unknown): void => {
44
54
  const next = { ...props.compat }
45
55
  if (value === undefined) delete next[field]
@@ -47,13 +57,31 @@ export function CompatEditor(props: CompatEditorProps): ReactElement {
47
57
  props.onEdit(next)
48
58
  }
49
59
  return (
50
- <div className={`lpc-field lpc-wide`}>
60
+ <div className={'lpc-field lpc-wide'}>
51
61
  <CollapseSection
52
62
  id={`${props.idPrefix}-collapse`}
53
63
  title={props.t('compatGroup')}
64
+ meta={
65
+ allFields.length === 0
66
+ ? ''
67
+ : `${props.t('compatSetCount')} ${setCount}/${allFields.length}`
68
+ }
54
69
  defaultOpen={false}
55
70
  >
56
71
  {props.api === '' ? <p className="lpc-hint">{props.t('compatApiHint')}</p> : null}
72
+ {allFields.length > FILTER_THRESHOLD ? (
73
+ <div className="lpc-catBar">
74
+ <input
75
+ className="lpc-input lpc-catSearch"
76
+ type="search"
77
+ value={filter}
78
+ placeholder={props.t('compatFilter')}
79
+ aria-label={props.t('compatFilter')}
80
+ disabled={props.disabled === true}
81
+ onChange={(event) => setFilter(event.target.value)}
82
+ />
83
+ </div>
84
+ ) : null}
57
85
  <div className="lpc-grid">
58
86
  {fields.map((field) => {
59
87
  const spec = compatFieldSpec(effective, field)
@@ -1,147 +1,125 @@
1
1
  /**
2
- * 模型目录编辑:每行一个模型(id/extends/name/容量/模态/reasoningEfforts/compat),
3
- * extends 输入框带 datalist 候选——候选来自 /catalog 端点:选定 source
4
- * (builtin / models-dev)与 provider 后拉取其 models 列表。
2
+ * 模型目录编辑:每行一个模型(id/extends/name/容量/模态/reasoningEfforts/compat/
3
+ * 其余字段),**行默认折叠**——route 内模型一多,展开态会把配置页拉得极长。
4
+ * 工具条提供"全部展开/收起"与 id/继承源过滤;继承源候选不再内联 datalist,
5
+ * 改由卡片顶部「内置模型目录」浏览器承担(搜索 + 一键添加)。
5
6
  * @module llm-pi/client/views/models
6
7
  */
7
8
 
8
- import { CheckRow } from '@dsh-plus/shared/client'
9
- import { type ReactElement, useEffect, useState } from 'react'
10
- import { fetchCatalog, type WireModelsDevStatus } from '../api.ts'
9
+ import { CheckRow, ChevronDownIcon } from '@dsh-plus/shared/client'
10
+ import { type ReactElement, useState } from 'react'
11
+
11
12
  import { MODALITIES, THINKING_LEVELS } from '../constants.ts'
12
13
  import { emptyModelDraft, extraOfJson, type ModelDraft, type ReasoningDraft } from '../draft.ts'
13
14
  import { JsonField, TextField } from '../fields.tsx'
14
15
  import type { Translate } from '../i18n.ts'
15
16
  import { CompatEditor } from './compat.tsx'
16
17
 
17
- type CatalogSource = 'builtin' | 'models-dev'
18
-
19
- function catalogNote(status: WireModelsDevStatus | undefined, t: Translate): string {
20
- if (status === undefined) return ''
21
- if (status.error !== null) return `${t('modelsDevError')}${status.error}`
22
- return `${t('modelsDevStatusLine')}:${status.providers} 个 provider,快照 ${status.fetchedAt ?? '-'}`
23
- }
24
-
25
18
  export interface ModelsTableProps {
26
19
  route: string
27
20
  api: string
28
- /** 默认候选 provider(route 级 extends)。 */
29
- defaultProvider: string
30
21
  models: ModelDraft[]
31
22
  epoch: number
32
23
  disabled?: boolean
33
24
  t: Translate
34
25
  onModels(next: ModelDraft[]): void
26
+ /** 打开卡片顶部「内置模型目录」并把本 route 设为添加目标。 */
27
+ onAddFromCatalog(): void
28
+ }
29
+
30
+ /** 行标题摘要:继承源与显示名(空则回退 id)。 */
31
+ function rowSummary(model: ModelDraft): string {
32
+ const parts: string[] = []
33
+ if (model.extends.trim() !== '') parts.push(`extends ${model.extends.trim()}`)
34
+ if (model.name.trim() !== '' && model.name.trim() !== model.id.trim()) {
35
+ parts.push(model.name.trim())
36
+ }
37
+ return parts.join(' · ')
35
38
  }
36
39
 
37
40
  export function ModelsTable(props: ModelsTableProps): ReactElement {
38
41
  const { t } = props
39
- const [source, setSource] = useState<CatalogSource>('builtin')
40
- const [providerIds, setProviderIds] = useState<string[]>([])
41
- const [provider, setProvider] = useState('')
42
- const [candidateModels, setCandidateModels] = useState<string[]>([])
43
- const [note, setNote] = useState('')
44
- const listId = `lpc-models-${props.route.replace(/[^a-zA-Z0-9_-]/g, '_')}`
42
+ const [openRows, setOpenRows] = useState<Record<number, boolean>>({})
43
+ const [filter, setFilter] = useState('')
44
+ const needle = filter.trim().toLowerCase()
45
+ const visible = props.models
46
+ .map((model, index) => ({ model, index }))
47
+ .filter(({ model }) =>
48
+ needle === ''
49
+ ? true
50
+ : model.id.toLowerCase().includes(needle) ||
51
+ model.extends.toLowerCase().includes(needle) ||
52
+ model.name.toLowerCase().includes(needle),
53
+ )
54
+ const hidden = props.models.length - visible.length
55
+ const allOpen =
56
+ props.models.length > 0 && props.models.every((_, index) => openRows[index] === true)
45
57
 
46
- // biome-ignore lint/correctness/useExhaustiveDependencies: 目录拉取仅随 source 切换重跑;t/defaultProvider 取首帧值即可
47
- useEffect(() => {
48
- let alive = true
49
- setNote(t('catalogLoading'))
50
- void (async () => {
51
- try {
52
- const list = await fetchCatalog('', source)
53
- if (!alive) return
54
- setProviderIds(list.providers)
55
- const preferred = list.providers.includes(props.defaultProvider)
56
- ? props.defaultProvider
57
- : (list.providers[0] ?? '')
58
- setProvider(preferred)
59
- setNote(catalogNote(list.status, t))
60
- if (preferred === '') return
61
- const result = await fetchCatalog(preferred, source)
62
- if (alive) setCandidateModels(result.models)
63
- } catch {
64
- if (alive) setNote(t('catalogFailed'))
65
- }
66
- })()
67
- return () => {
68
- alive = false
69
- }
70
- }, [source])
71
-
72
- const onProviderChange = (value: string): void => {
73
- setProvider(value)
74
- if (value === '') {
75
- setCandidateModels([])
58
+ const toggleAll = (): void => {
59
+ if (allOpen) {
60
+ setOpenRows({})
76
61
  return
77
62
  }
78
- void fetchCatalog(value, source)
79
- .then((result) => setCandidateModels(result.models))
80
- .catch(() => setNote(t('catalogFailed')))
63
+ setOpenRows(Object.fromEntries(props.models.map((_, index) => [index, true])))
81
64
  }
82
65
 
83
66
  return (
84
67
  <div className="lpc-models">
85
68
  <div className="lpc-modelHead">
86
- <span className="lpc-modelTitle">{t('modelsGroup')}</span>
69
+ <span className="lpc-modelTitle">{`${t('modelsGroup')}(${props.models.length})`}</span>
87
70
  <button
88
71
  type="button"
89
72
  className="lpc-btn lpc-btnGhost lpc-btnSmall"
90
- disabled={props.disabled === true}
91
- onClick={() => props.onModels([...props.models, emptyModelDraft()])}
73
+ disabled={props.disabled === true || props.models.length === 0}
74
+ onClick={toggleAll}
92
75
  >
93
- {t('addModel')}
76
+ {allOpen ? t('modelsCollapseAll') : t('modelsExpandAll')}
94
77
  </button>
95
- </div>
96
- <div className="lpc-catalogBar">
97
- <label className="lpc-catalogLabel" htmlFor={`${listId}-source`}>
98
- {t('catalogSource')}
99
- </label>
100
- <select
101
- id={`${listId}-source`}
102
- className="lpc-input lpc-select lpc-catalogSelect"
103
- value={source}
78
+ <button
79
+ type="button"
80
+ className="lpc-btn lpc-btnGhost lpc-btnSmall"
104
81
  disabled={props.disabled === true}
105
- onChange={(event) => setSource(event.target.value as CatalogSource)}
82
+ onClick={props.onAddFromCatalog}
106
83
  >
107
- <option value="builtin">builtin</option>
108
- <option value="models-dev">models-dev</option>
109
- </select>
110
- <label className="lpc-catalogLabel" htmlFor={`${listId}-provider`}>
111
- {t('catalogProvider')}
112
- </label>
113
- <select
114
- id={`${listId}-provider`}
115
- className="lpc-input lpc-select lpc-catalogSelect"
116
- value={provider}
117
- disabled={props.disabled === true || providerIds.length === 0}
118
- onChange={(event) => onProviderChange(event.target.value)}
84
+ {t('addFromCatalog')}
85
+ </button>
86
+ <button
87
+ type="button"
88
+ className="lpc-btn lpc-btnGhost lpc-btnSmall"
89
+ disabled={props.disabled === true}
90
+ onClick={() => props.onModels([...props.models, emptyModelDraft()])}
119
91
  >
120
- <option value="">-</option>
121
- {providerIds.map((id) => (
122
- <option key={id} value={id}>
123
- {id}
124
- </option>
125
- ))}
126
- </select>
92
+ {t('addModel')}
93
+ </button>
127
94
  </div>
128
- {note !== '' ? <p className="lpc-hint">{note}</p> : null}
129
- <datalist id={listId}>
130
- {candidateModels.map((id) => (
131
- <option key={id} value={id} />
132
- ))}
133
- </datalist>
134
- {props.models.map((model, index) => (
95
+ {props.models.length > 6 ? (
96
+ <div className="lpc-catBar">
97
+ <input
98
+ className="lpc-input lpc-catSearch"
99
+ type="search"
100
+ value={filter}
101
+ placeholder={t('modelsFilterPlaceholder')}
102
+ aria-label={t('modelsFilter')}
103
+ disabled={props.disabled === true}
104
+ onChange={(event) => setFilter(event.target.value)}
105
+ />
106
+ {hidden > 0 ? (
107
+ <span className="lpc-catNote">{`${t('modelsHidden')} ${hidden}`}</span>
108
+ ) : null}
109
+ </div>
110
+ ) : null}
111
+ {visible.map(({ model, index }) => (
135
112
  <ModelRow
136
- // biome-ignore lint/suspicious/noArrayIndexKey: model.id 可重复(手填),index 前缀保证 key 唯一且随行序稳定
137
113
  key={`${index}:${model.id}`}
138
114
  index={index}
139
115
  model={model}
140
116
  api={props.api}
141
- listId={listId}
117
+ uid={props.route.replace(/[^a-zA-Z0-9_-]/g, '_')}
118
+ open={openRows[index] === true}
142
119
  epoch={props.epoch}
143
120
  disabled={props.disabled === true}
144
121
  t={t}
122
+ onToggle={() => setOpenRows((prev) => ({ ...prev, [index]: prev[index] !== true }))}
145
123
  onPatch={(patch) =>
146
124
  props.onModels(props.models.map((m, i) => (i === index ? { ...m, ...patch } : m)))
147
125
  }
@@ -156,23 +134,38 @@ export interface ModelRowProps {
156
134
  index: number
157
135
  model: ModelDraft
158
136
  api: string
159
- listId: string
137
+ /** route 派生的 id 前缀(DOM id 唯一性)。 */
138
+ uid: string
139
+ open: boolean
160
140
  epoch: number
161
141
  disabled?: boolean
162
142
  t: Translate
143
+ onToggle(): void
163
144
  onPatch(patch: Partial<ModelDraft>): void
164
145
  onRemove(): void
165
146
  }
166
147
 
148
+ /** 单行:折叠头(序号/id/摘要) + 展开体(全部字段)。 */
167
149
  export function ModelRow(props: ModelRowProps): ReactElement {
168
150
  const { model, t } = props
169
- const id = `${props.listId}-m${props.index}`
151
+ const id = `lpc-m-${props.uid}-${props.index}`
152
+ const summary = rowSummary(model)
170
153
  return (
171
154
  <div className="lpc-modelRow">
172
155
  <div className="lpc-modelHead">
173
- <span className="lpc-modelTitle">
174
- {t('modelRow')} {props.index + 1}
175
- </span>
156
+ <button
157
+ type="button"
158
+ className="lpc-routeToggle"
159
+ aria-expanded={props.open}
160
+ onClick={props.onToggle}
161
+ >
162
+ <ChevronDownIcon className={`lpc-chevron${props.open ? ' lpc-chevronOpen' : ''}`} />
163
+ <span className="lpc-modelTitle">{`#${props.index + 1}`}</span>
164
+ <span className={model.id.trim() === '' ? 'lpc-invalid' : 'lpc-routeKey'}>
165
+ {model.id.trim() === '' ? t('modelUntitled') : model.id}
166
+ </span>
167
+ {summary !== '' ? <span className="lpc-routeApi">{summary}</span> : null}
168
+ </button>
176
169
  <button
177
170
  type="button"
178
171
  className="lpc-btn lpc-btnGhost lpc-btnSmall"
@@ -182,100 +175,103 @@ export function ModelRow(props: ModelRowProps): ReactElement {
182
175
  {t('deleteModel')}
183
176
  </button>
184
177
  </div>
185
- <div className="lpc-grid">
186
- <TextField
187
- id={`${id}-id`}
188
- label={t('modelId')}
189
- hint={t('modelIdHint')}
190
- value={model.id}
191
- disabled={props.disabled === true}
192
- invalid={model.id.trim() === ''}
193
- invalidLabel={t('modelIdRequired')}
194
- onEdit={(value) => props.onPatch({ id: value })}
195
- />
196
- <TextField
197
- id={`${id}-extends`}
198
- label={t('modelExtends')}
199
- hint={t('modelExtendsHint')}
200
- value={model.extends}
201
- list={props.listId}
202
- disabled={props.disabled === true}
203
- onEdit={(value) => props.onPatch({ extends: value })}
204
- />
205
- <TextField
206
- id={`${id}-name`}
207
- label={t('modelName')}
208
- value={model.name}
209
- disabled={props.disabled === true}
210
- onEdit={(value) => props.onPatch({ name: value })}
211
- />
212
- <TextField
213
- id={`${id}-ctx`}
214
- label={t('contextWindow')}
215
- numeric
216
- value={model.contextWindow}
217
- disabled={props.disabled === true}
218
- onEdit={(value) => props.onPatch({ contextWindow: value })}
219
- />
220
- <TextField
221
- id={`${id}-max`}
222
- label={t('maxTokens')}
223
- numeric
224
- value={model.maxTokens}
225
- disabled={props.disabled === true}
226
- onEdit={(value) => props.onPatch({ maxTokens: value })}
227
- />
228
- <div className="lpc-field">
229
- <span className="lpc-label">{t('input')}</span>
230
- {MODALITIES.map((modality) => (
231
- <CheckRow
232
- prefix="lpc"
233
- key={modality}
234
- id={`${id}-input-${modality}`}
235
- label={modality}
236
- checked={model.input[modality]}
178
+ {props.open ? (
179
+ <div className="lpc-modelBody">
180
+ <div className="lpc-grid">
181
+ <TextField
182
+ id={`${id}-id`}
183
+ label={t('modelId')}
184
+ hint={t('modelIdHint')}
185
+ value={model.id}
237
186
  disabled={props.disabled === true}
238
- onEdit={(checked) =>
239
- props.onPatch({
240
- input: { ...model.input, [modality]: checked },
241
- })
242
- }
187
+ invalid={model.id.trim() === ''}
188
+ invalidLabel={t('modelIdRequired')}
189
+ onEdit={(value) => props.onPatch({ id: value })}
243
190
  />
244
- ))}
191
+ <TextField
192
+ id={`${id}-extends`}
193
+ label={t('modelExtends')}
194
+ hint={t('modelExtendsHint')}
195
+ value={model.extends}
196
+ disabled={props.disabled === true}
197
+ onEdit={(value) => props.onPatch({ extends: value })}
198
+ />
199
+ <TextField
200
+ id={`${id}-name`}
201
+ label={t('modelName')}
202
+ value={model.name}
203
+ disabled={props.disabled === true}
204
+ onEdit={(value) => props.onPatch({ name: value })}
205
+ />
206
+ <TextField
207
+ id={`${id}-ctx`}
208
+ label={t('contextWindow')}
209
+ numeric
210
+ value={model.contextWindow}
211
+ disabled={props.disabled === true}
212
+ onEdit={(value) => props.onPatch({ contextWindow: value })}
213
+ />
214
+ <TextField
215
+ id={`${id}-max`}
216
+ label={t('maxTokens')}
217
+ numeric
218
+ value={model.maxTokens}
219
+ disabled={props.disabled === true}
220
+ onEdit={(value) => props.onPatch({ maxTokens: value })}
221
+ />
222
+ <div className="lpc-field">
223
+ <span className="lpc-label">{t('input')}</span>
224
+ {MODALITIES.map((modality) => (
225
+ <CheckRow
226
+ prefix="lpc"
227
+ key={modality}
228
+ id={`${id}-input-${modality}`}
229
+ label={modality}
230
+ checked={model.input[modality]}
231
+ disabled={props.disabled === true}
232
+ onEdit={(checked) =>
233
+ props.onPatch({
234
+ input: { ...model.input, [modality]: checked },
235
+ })
236
+ }
237
+ />
238
+ ))}
239
+ </div>
240
+ </div>
241
+ <ReasoningEditor
242
+ idPrefix={`${id}-re`}
243
+ value={model.reasoningEfforts}
244
+ disabled={props.disabled === true}
245
+ t={t}
246
+ onEdit={(reasoningEfforts) => props.onPatch({ reasoningEfforts })}
247
+ />
248
+ <CompatEditor
249
+ idPrefix={`${id}-compat`}
250
+ api={props.api}
251
+ compat={model.compat}
252
+ epoch={props.epoch}
253
+ disabled={props.disabled === true}
254
+ wide
255
+ t={t}
256
+ onEdit={(compat) => props.onPatch({ compat })}
257
+ />
258
+ <JsonField
259
+ id={`${id}-extra`}
260
+ label={t('extraFields')}
261
+ hint={t('extraFieldsHint')}
262
+ invalidText={t('invalidJson')}
263
+ value={Object.keys(model.extra).length === 0 ? undefined : model.extra}
264
+ epoch={props.epoch}
265
+ disabled={props.disabled === true}
266
+ wide
267
+ onEdit={(extra) => {
268
+ const next = extraOfJson(extra)
269
+ // 合法但非对象的 JSON(数组/标量)忽略这次编辑,不静默清空已配置字段。
270
+ if (next !== undefined) props.onPatch({ extra: next })
271
+ }}
272
+ />
245
273
  </div>
246
- </div>
247
- <ReasoningEditor
248
- idPrefix={`${id}-re`}
249
- value={model.reasoningEfforts}
250
- disabled={props.disabled === true}
251
- t={t}
252
- onEdit={(reasoningEfforts) => props.onPatch({ reasoningEfforts })}
253
- />
254
- <CompatEditor
255
- idPrefix={`${id}-compat`}
256
- api={props.api}
257
- compat={model.compat}
258
- epoch={props.epoch}
259
- disabled={props.disabled === true}
260
- wide
261
- t={t}
262
- onEdit={(compat) => props.onPatch({ compat })}
263
- />
264
- <JsonField
265
- id={`${id}-extra`}
266
- label={t('extraFields')}
267
- hint={t('extraFieldsHint')}
268
- invalidText={t('invalidJson')}
269
- value={Object.keys(model.extra).length === 0 ? undefined : model.extra}
270
- epoch={props.epoch}
271
- disabled={props.disabled === true}
272
- wide
273
- onEdit={(extra) => {
274
- const next = extraOfJson(extra)
275
- // 合法但非对象的 JSON(数组/标量)忽略这次编辑,不静默清空已配置字段。
276
- if (next !== undefined) props.onPatch({ extra: next })
277
- }}
278
- />
274
+ ) : null}
279
275
  </div>
280
276
  )
281
277
  }