@dsh-plus/llm-pi 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client/api.ts CHANGED
@@ -1,15 +1,32 @@
1
1
  /**
2
- * 自定义端点通道:仅剩「模型目录」(配置读写已迁移到 ctx.remote.settings
3
- * 直连传输,0.1.2-alpha.1 起 connection.api.settings 已移除;见 scope.ts 与
4
- * card.tsx)。目录响应附带 kitSource 与 models-dev 状态,供卡片的状态行
5
- * 展示(运行期诊断,非配置数据)。
2
+ * 自定义端点通道:只剩浏览器半拿不到的两样东西——运行期套件事实(生效版本、
3
+ * 协议集合、目录规模、诊断)与 pi-ai 内置目录搜索(配置读写走 ctx.remote.settings
4
+ * 直连,见 card.tsx / shared 的 scope.ts)。
6
5
  * @module llm-pi/client/api
7
6
  */
8
7
 
9
- export interface WireModelsDevStatus {
10
- fetchedAt: string | null
11
- providers: number
12
- error: string | null
8
+ import type { CompatValue } from './constants.ts'
9
+
10
+ /** 运行期套件事实(服务端 kitInfo() 的镜像)。 */
11
+ export interface WireKitInfo {
12
+ source: string
13
+ root?: string
14
+ versions: { piAi?: string; piAiAdapter?: string; dsh?: string }
15
+ verifiedRange: string
16
+ versionNotice?: string
17
+ protocols: string[]
18
+ protocolSource: string
19
+ catalog: { providers: number; models: number; generatedAt?: number }
20
+ compatSource: string
21
+ diagnostics: string[]
22
+ }
23
+
24
+ /** 内置 provider 条目。 */
25
+ export interface WireProviderEntry {
26
+ id: string
27
+ name: string
28
+ baseUrl?: string
29
+ modelCount: number
13
30
  }
14
31
 
15
32
  export interface WireProvider {
@@ -54,9 +71,6 @@ export interface WireModel {
54
71
  /** settings 命名空间的解析值(llm-pi 无 secret 字段,value 即完整配置)。 */
55
72
  export interface ConfigValue {
56
73
  enabled: boolean
57
- catalogUrl: string
58
- catalogRefreshHours: number
59
- catalogProxy: string
60
74
  providers: Record<string, WireProvider>
61
75
  }
62
76
 
@@ -65,18 +79,59 @@ export type ConfigPatch = ConfigValue
65
79
 
66
80
  /** compat 字段表(服务端从官方包推导后下发;浏览器半不手抄)。 */
67
81
  export interface WireCompatTable {
68
- fields: Record<string, Record<string, 'boolean' | 'integer' | 'number' | 'object' | string[]>>
82
+ fields: Record<string, Record<string, CompatValue>>
69
83
  source: string
70
84
  problem?: string
71
85
  }
72
86
 
73
- /** GET /catalog?provider=&source= 返回(kitSource 为运行期套件来源诊断)。 */
74
- export interface CatalogResult {
75
- providers: string[]
76
- models: string[]
77
- status?: WireModelsDevStatus
78
- kitSource?: string
79
- compat?: WireCompatTable
87
+ /** 内置目录的单个模型(服务端 catalog/browse.ts 的镜像)。 */
88
+ export interface WireModelInfo {
89
+ provider: string
90
+ id: string
91
+ path: string
92
+ name: string
93
+ api: string
94
+ baseUrl: string
95
+ contextWindow: number
96
+ maxTokens: number
97
+ input: string[]
98
+ reasoning: boolean
99
+ thinkingLevelMap?: Record<string, string | null>
100
+ compat?: Record<string, unknown>
101
+ /** 目录给出但未单列的其余字段(cost / inputLimits 等),原样展示。 */
102
+ rest: Record<string, unknown>
103
+ /** 当前生效协议集合能否服务该模型。 */
104
+ servable: boolean
105
+ }
106
+
107
+ /** `GET /catalog` 响应:运行期事实 + provider/协议索引 + compat 字段表。 */
108
+ export interface CatalogMeta {
109
+ kit: WireKitInfo
110
+ providers: WireProviderEntry[]
111
+ /** `{provider: {modelId: api}}`:推断 route 生效协议用。 */
112
+ apis: Record<string, Record<string, string>>
113
+ compat: WireCompatTable
114
+ }
115
+
116
+ /** `GET /catalog/models` 响应。 */
117
+ export interface CatalogPage {
118
+ total: number
119
+ servableHidden: number
120
+ offset: number
121
+ limit: number
122
+ items: WireModelInfo[]
123
+ }
124
+
125
+ /** 目录搜索条件(与 server 端同名参数一一对应)。 */
126
+ export interface CatalogQuery {
127
+ q?: string
128
+ provider?: string
129
+ api?: string
130
+ reasoning?: boolean
131
+ image?: boolean
132
+ servableOnly?: boolean
133
+ offset?: number
134
+ limit?: number
80
135
  }
81
136
 
82
137
  const ROUTE_CATALOG = '/dsh-plus/llm-pi/catalog'
@@ -87,23 +142,31 @@ async function parse<T>(res: Response): Promise<T> {
87
142
  return body
88
143
  }
89
144
 
90
- /** 目录查询:provider 为空时只返回该源的 provider 列表。 */
91
- export async function fetchCatalog(
92
- provider: string,
93
- source: 'builtin' | 'models-dev',
94
- ): Promise<CatalogResult> {
95
- const url = `${ROUTE_CATALOG}?provider=${encodeURIComponent(provider)}&source=${source}`
96
- return parse<CatalogResult>(await fetch(url, { credentials: 'same-origin' }))
145
+ /** 运行期事实 + 目录索引(卡片挂载时一次)。 */
146
+ export async function fetchCatalogMeta(): Promise<CatalogMeta> {
147
+ return parse<CatalogMeta>(await fetch(ROUTE_CATALOG, { credentials: 'same-origin' }))
97
148
  }
98
149
 
99
- /** 手动拉取 models.dev 目录:POST /catalog/refresh → 最新快照状态。 */
100
- export async function refreshCatalog(): Promise<{
101
- status: WireModelsDevStatus
102
- }> {
103
- return parse<{ status: WireModelsDevStatus }>(
104
- await fetch(`${ROUTE_CATALOG}/refresh`, {
105
- method: 'POST',
150
+ /** 目录搜索(模糊 q + provider/api/能力/分页)。 */
151
+ export async function searchCatalog(
152
+ query: CatalogQuery,
153
+ signal?: AbortSignal,
154
+ ): Promise<CatalogPage> {
155
+ const params = new URLSearchParams()
156
+ if (query.q !== undefined && query.q.length > 0) params.set('q', query.q)
157
+ if (query.provider !== undefined && query.provider.length > 0)
158
+ params.set('provider', query.provider)
159
+ if (query.api !== undefined && query.api.length > 0) params.set('api', query.api)
160
+ if (query.reasoning === true) params.set('reasoning', '1')
161
+ if (query.image === true) params.set('image', '1')
162
+ if (query.servableOnly === false) params.set('servable', '0')
163
+ if (query.offset !== undefined) params.set('offset', String(query.offset))
164
+ if (query.limit !== undefined) params.set('limit', String(query.limit))
165
+ const suffix = params.toString()
166
+ return parse<CatalogPage>(
167
+ await fetch(`${ROUTE_CATALOG}/models${suffix === '' ? '' : `?${suffix}`}`, {
106
168
  credentials: 'same-origin',
169
+ ...(signal === undefined ? {} : { signal }),
107
170
  }),
108
171
  )
109
172
  }
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * 「LLM 路由」配置卡片:经 injectPluginConfigCard 注册(插件页
3
3
  * plugins.row.config / plugins.bundle.config,view 分发 summary/page)。
4
- * 顶部:enabled / catalogUrl / catalogRefreshHours / 只读状态行(kitSource、
5
- * modelsDevStatus,来自模型目录端点)+ 保存(settings.update 全量深合并)与
6
- * 错误/成功提示;下方为 providers 路由列表(新增/删除/字段编辑/compat/模型
7
- * 目录,见 views/)。外壳与基础控件走 @dsh-plus/shared/client 套件。
4
+ *
5
+ * 页面结构(自上而下,除根字段外**默认全部收起**,避免长列表划不到头):
6
+ * 1. 根字段:启用开关 + 运行期状态行(生效版本/安装树/目录规模/协议/compat 来源/诊断);
7
+ * 2. 「内置模型目录」浏览器(折叠小节,默认收起):搜索/筛选/复制路径 id/
8
+ * 一键把 `{id, extends}` 加到目标 route(添加后自动展开该 route 并回显);
9
+ * 3. Provider 路由列表(每 route 收起;route 内模型行同样默认收起)。
10
+ *
11
+ * 数据来源:配置读写走官方 remote.settings(scope/api);运行期事实与目录搜索
12
+ * 走本插件 `/catalog` 端点(浏览器半读不到 pi-ai 包)。草稿/保存与目录索引各由
13
+ * 本文件的 hook 承担,全部状态(浏览器开合、目标 route、route 展开态)在卡片持有,
14
+ * 子组件受控。
8
15
  * @module llm-pi/client/card
9
16
  */
10
17
 
@@ -19,18 +26,21 @@ import {
19
26
  type Scope,
20
27
  } from '@dsh-plus/shared/client'
21
28
  import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
22
- import { type ConfigValue, fetchCatalog, refreshCatalog, type WireModelsDevStatus } from './api.ts'
23
- import { installCompatFields } from './constants.ts'
29
+ import { type CatalogMeta, type ConfigValue, fetchCatalogMeta, type WireModelInfo } from './api.ts'
30
+ import { installCompatFields, installProtocols } from './constants.ts'
24
31
  import {
32
+ addModelEntry,
25
33
  type Draft,
26
34
  draftFromValue,
27
35
  emptyProviderDraft,
28
- numTextOk,
29
36
  type ProviderDraft,
30
37
  toPatch,
31
38
  } from './draft.ts'
39
+ import { CollapseSection } from './fields.tsx'
32
40
  import type { Translate } from './i18n.ts'
33
41
  import { RootFields } from './root-fields.tsx'
42
+ import { type ApiIndex, routeApiFacts, targetRoutes } from './route-fit.ts'
43
+ import { CatalogBrowser } from './views/catalog-browser.tsx'
34
44
  import { ProvidersSection } from './views/providers.tsx'
35
45
 
36
46
  export interface CardProps extends PluginConfigViewProps {
@@ -39,29 +49,94 @@ export interface CardProps extends PluginConfigViewProps {
39
49
  api: NamespaceSettingsApi
40
50
  }
41
51
 
42
- /** 运行期诊断行数据来源:模型目录端点(compat 字段表同批安装)。 */
43
- function useRuntimeDiagnostics(): {
44
- kitSource: string | null
45
- modelsDevStatus: WireModelsDevStatus | null
46
- setModelsDevStatus(next: WireModelsDevStatus): void
47
- } {
48
- const [kitSource, setKitSource] = useState<string | null>(null)
49
- const [modelsDevStatus, setModelsDevStatus] = useState<WireModelsDevStatus | null>(null)
52
+ /** 运行期事实 + 目录索引:卡片挂载拉一次(同时安装协议集合与 compat 字段表)。 */
53
+ function useCatalogMeta(): { meta: CatalogMeta | null; error: string } {
54
+ const [meta, setMeta] = useState<CatalogMeta | null>(null)
55
+ const [error, setError] = useState('')
50
56
  useEffect(() => {
51
57
  let alive = true
52
- fetchCatalog('', 'models-dev')
58
+ fetchCatalogMeta()
53
59
  .then((result) => {
54
60
  if (!alive) return
55
- setKitSource(result.kitSource ?? null)
56
- setModelsDevStatus(result.status ?? null)
57
- if (result.compat !== undefined) installCompatFields(result.compat.fields)
61
+ installProtocols(result.kit.protocols)
62
+ installCompatFields(result.compat.fields)
63
+ setMeta(result)
64
+ })
65
+ .catch((reason: unknown) => {
66
+ if (!alive) return
67
+ setError(reason instanceof Error ? reason.message : String(reason))
58
68
  })
59
- .catch(() => {})
60
69
  return () => {
61
70
  alive = false
62
71
  }
63
72
  }, [])
64
- return { kitSource, modelsDevStatus, setModelsDevStatus }
73
+ return { meta, error }
74
+ }
75
+
76
+ /**
77
+ * 草稿与保存:首次拿到解析值后播种草稿;后续 Host 更新不覆盖在途编辑
78
+ * (与官方 staged 表单一致)。dirty 由"提交形状"双向比较得出。
79
+ */
80
+ function useCardDraft(options: {
81
+ value: ConfigValue | undefined
82
+ scope: Scope
83
+ api: NamespaceSettingsApi
84
+ t: Translate
85
+ }) {
86
+ const { value, scope, api, t } = options
87
+ const [draft, setDraft] = useState<Draft | null>(null)
88
+ const [epoch, setEpoch] = useState(0)
89
+ const [saving, setSaving] = useState(false)
90
+ const [status, setStatus] = useState<CardStatusState>(IDLE_STATUS)
91
+
92
+ useEffect(() => {
93
+ if (value === undefined || draft !== null) return
94
+ setDraft(draftFromValue(value))
95
+ }, [value, draft])
96
+
97
+ const dirty = useMemo(
98
+ () =>
99
+ value !== undefined &&
100
+ draft !== null &&
101
+ JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))),
102
+ [value, draft],
103
+ )
104
+ const onSave = (): void => {
105
+ if (draft === null) return
106
+ setSaving(true)
107
+ const revision = scope.getSnapshot().revision
108
+ api
109
+ .update(toPatch(draft) as unknown as Record<string, unknown>, revision)
110
+ .then(async () => {
111
+ await scope.load()
112
+ const next = scope.getSnapshot().value as ConfigValue | undefined
113
+ if (next !== undefined) setDraft(draftFromValue(next))
114
+ setEpoch((current) => current + 1)
115
+ setStatus({ kind: 'ok', text: t('saveOk') })
116
+ })
117
+ .catch((error: unknown) => {
118
+ const message = error instanceof Error ? error.message : String(error)
119
+ setStatus({ kind: 'error', text: `${t('saveFailed')}${message}` })
120
+ })
121
+ .finally(() => setSaving(false))
122
+ }
123
+ const onDiscard = (): void => {
124
+ if (value === undefined) return
125
+ setDraft(draftFromValue(value))
126
+ setEpoch((current) => current + 1)
127
+ setStatus(IDLE_STATUS)
128
+ }
129
+ return {
130
+ draft,
131
+ setDraft,
132
+ dirty,
133
+ saving,
134
+ status,
135
+ idle: () => setStatus(IDLE_STATUS),
136
+ epoch,
137
+ onSave,
138
+ onDiscard,
139
+ }
65
140
  }
66
141
 
67
142
  /** 路由级草稿改写:改字段 / 新增 route / 删除 route(返回新草稿,不改入参)。 */
@@ -80,26 +155,17 @@ function withoutRoute(draft: Draft, route: string): Draft {
80
155
  return { ...draft, providers: next }
81
156
  }
82
157
 
83
- /** 卡片动作:手动拉取目录、放弃、保存。 */
158
+ /** 卡片动作:放弃、保存(模型目录已唯一来自 pi-ai,无可拉取项)。 */
84
159
  function cardActions(options: {
85
160
  t: Translate
86
161
  dirty: boolean
87
- invalid: boolean
88
162
  saving: boolean
89
- refreshing: boolean
90
163
  disabled: boolean
91
- onRefresh(): void
92
164
  onDiscard(): void
93
165
  onSave(): void
94
166
  }): CardAction[] {
95
167
  const { t } = options
96
168
  return [
97
- {
98
- key: 'refresh',
99
- label: t(options.refreshing ? 'refreshingCatalog' : 'refreshCatalog'),
100
- disabled: options.disabled || options.refreshing,
101
- onClick: options.onRefresh,
102
- },
103
169
  {
104
170
  key: 'discard',
105
171
  label: t('discard'),
@@ -110,12 +176,18 @@ function cardActions(options: {
110
176
  key: 'save',
111
177
  label: t(options.saving ? 'saving' : 'save'),
112
178
  variant: 'primary',
113
- disabled: !options.dirty || options.invalid || options.saving || options.disabled,
179
+ disabled: !options.dirty || options.saving || options.disabled,
114
180
  onClick: options.onSave,
115
181
  },
116
182
  ]
117
183
  }
118
184
 
185
+ /** 打开浏览器时的目标 route 选择:优先当前目标,否则第一个候选。 */
186
+ function pickTarget(targets: readonly string[], preferred: string): string {
187
+ if (preferred !== '' && targets.includes(preferred)) return preferred
188
+ return targets[0] ?? ''
189
+ }
190
+
119
191
  export function LlmPiCard(props: CardProps): ReactElement | string | null {
120
192
  const { t, scope, api } = props
121
193
  const snapshot = useSyncExternalStore(
@@ -123,35 +195,16 @@ export function LlmPiCard(props: CardProps): ReactElement | string | null {
123
195
  () => scope.getSnapshot(),
124
196
  )
125
197
  const value = snapshot.value as ConfigValue | undefined
126
- const [draft, setDraft] = useState<Draft | null>(null)
127
- const [epoch, setEpoch] = useState(0)
128
- const [saving, setSaving] = useState(false)
129
- const [refreshing, setRefreshing] = useState(false)
130
- const [status, setStatus] = useState<CardStatusState>(IDLE_STATUS)
131
- const { kitSource, modelsDevStatus, setModelsDevStatus } = useRuntimeDiagnostics()
132
-
133
- // 首次拿到解析值后播种草稿;后续 Host 更新不覆盖在途编辑(与官方 staged 表单一致)。
134
- useEffect(() => {
135
- if (value === undefined || draft !== null) return
136
- setDraft(draftFromValue(value))
137
- }, [value, draft])
138
-
139
- const dirty = useMemo(
140
- () =>
141
- value !== undefined &&
142
- draft !== null &&
143
- JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))),
144
- [value, draft],
145
- )
146
- const invalid = useMemo(() => {
147
- if (draft === null) return false
148
- return (
149
- !numTextOk(draft.catalogRefreshHours) ||
150
- Object.values(draft.providers).some((provider) =>
151
- provider.models.some((model) => model.id.trim() === ''),
152
- )
153
- )
154
- }, [draft])
198
+ const { draft, setDraft, dirty, saving, status, idle, epoch, onSave, onDiscard } = useCardDraft({
199
+ value,
200
+ scope,
201
+ api,
202
+ t,
203
+ })
204
+ const [browserOpen, setBrowserOpen] = useState(false)
205
+ const [browserTarget, setBrowserTarget] = useState('')
206
+ const [openRoutes, setOpenRoutes] = useState<Record<string, boolean>>({})
207
+ const { meta, error: metaError } = useCatalogMeta()
155
208
 
156
209
  // 插件页 summary 视图只出一行简介(hooks 已全部落定,可安全提前返回)。
157
210
  if (props.view === 'summary') return t('summaryLine')
@@ -160,53 +213,30 @@ export function LlmPiCard(props: CardProps): ReactElement | string | null {
160
213
  return <CardLoading prefix="lpc" text={t('loading')} />
161
214
  }
162
215
 
163
- const setProvider = (route: string, patch: Partial<ProviderDraft>): void => {
164
- setDraft(withProviderPatch(draft, route, patch))
165
- setStatus(IDLE_STATUS)
216
+ const apis: ApiIndex = meta?.apis ?? {}
217
+ const targets = targetRoutes(draft.providers)
218
+ const target = pickTarget(targets, browserTarget)
219
+ const openBrowserFor = (route: string): void => {
220
+ setBrowserTarget(route)
221
+ setBrowserOpen(true)
166
222
  }
167
223
  const onAddRoute = (key: string): void => {
168
224
  setDraft(withNewRoute(draft, key))
169
- setStatus(IDLE_STATUS)
170
- }
171
- const onRemoveRoute = (route: string): void => {
172
- setDraft(withoutRoute(draft, route))
173
- setStatus(IDLE_STATUS)
174
- }
175
- const onSave = (): void => {
176
- setSaving(true)
177
- const revision = scope.getSnapshot().revision
178
- api
179
- .update(toPatch(draft) as unknown as Record<string, unknown>, revision)
180
- .then(async () => {
181
- await scope.load()
182
- const next = scope.getSnapshot().value as ConfigValue | undefined
183
- if (next !== undefined) setDraft(draftFromValue(next))
184
- setEpoch((value) => value + 1)
185
- setStatus({ kind: 'ok', text: t('saveOk') })
186
- })
187
- .catch((error: unknown) => {
188
- const message = error instanceof Error ? error.message : String(error)
189
- setStatus({ kind: 'error', text: `${t('saveFailed')}${message}` })
190
- })
191
- .finally(() => setSaving(false))
192
- }
193
- const onDiscard = (): void => {
194
- setDraft(draftFromValue(value))
195
- setEpoch((value) => value + 1)
196
- setStatus(IDLE_STATUS)
225
+ setOpenRoutes((prev) => ({ ...prev, [key]: true }))
226
+ setBrowserTarget((prev) => (prev === '' ? key : prev))
227
+ idle()
197
228
  }
198
- const onRefreshCatalog = (): void => {
199
- setRefreshing(true)
200
- refreshCatalog()
201
- .then((result) => {
202
- setModelsDevStatus(result.status)
203
- setStatus({ kind: 'ok', text: t('refreshOk') })
204
- })
205
- .catch((error: unknown) => {
206
- const message = error instanceof Error ? error.message : String(error)
207
- setStatus({ kind: 'error', text: `${t('refreshFailed')}${message}` })
208
- })
209
- .finally(() => setRefreshing(false))
229
+ /**
230
+ * 目录一键添加:把 `{id, extends: 'provider/model'}` 写进目标 route 草稿
231
+ * (不预填 name/容量——继承才能跟随 pi-ai 目录升级),并展开该 route 给出反馈。
232
+ */
233
+ const onAddModel = (route: string, model: WireModelInfo): boolean => {
234
+ const result = addModelEntry(draft, route, model)
235
+ if (!result.added) return false
236
+ setDraft(result.draft)
237
+ setOpenRoutes((prev) => ({ ...prev, [route]: true }))
238
+ idle()
239
+ return true
210
240
  }
211
241
 
212
242
  const disabled = !snapshot.writable
@@ -220,37 +250,67 @@ export function LlmPiCard(props: CardProps): ReactElement | string | null {
220
250
  dirtyLabel={t('unsaved')}
221
251
  readOnlyNotice={disabled ? t('readOnly') : undefined}
222
252
  status={status}
223
- actions={cardActions({
224
- t,
225
- dirty,
226
- invalid,
227
- saving,
228
- refreshing,
229
- disabled,
230
- onRefresh: onRefreshCatalog,
231
- onDiscard,
232
- onSave,
233
- })}
253
+ actions={cardActions({ t, dirty, saving, disabled, onDiscard, onSave })}
234
254
  >
235
255
  <RootFields
236
256
  t={t}
237
257
  draft={draft}
238
258
  disabled={disabled}
239
- kitSource={kitSource}
240
- modelsDevStatus={modelsDevStatus}
259
+ kit={meta?.kit ?? null}
260
+ kitError={metaError}
241
261
  onEdit={(patch) => {
242
262
  setDraft({ ...draft, ...patch })
243
- setStatus(IDLE_STATUS)
263
+ idle()
244
264
  }}
245
265
  />
266
+ <CollapseSection
267
+ id="lpc-catalog-browser"
268
+ title={t('browserGroup')}
269
+ defaultOpen={false}
270
+ open={browserOpen}
271
+ onToggle={() => setBrowserOpen((open) => !open)}
272
+ meta={meta === null ? '' : `${meta.kit.catalog.models}`}
273
+ >
274
+ <CatalogBrowser
275
+ t={t}
276
+ targets={targets}
277
+ apis={apis}
278
+ providers={meta?.providers ?? []}
279
+ target={target}
280
+ onTargetChange={setBrowserTarget}
281
+ providerOf={(route) => draft.providers[route]}
282
+ routeApiOf={(route) => {
283
+ const provider = draft.providers[route]
284
+ return provider === undefined ? undefined : routeApiFacts(provider, apis).api
285
+ }}
286
+ onAdd={onAddModel}
287
+ disabled={disabled}
288
+ {...(metaError === '' ? {} : { metaError })}
289
+ />
290
+ </CollapseSection>
246
291
  <ProvidersSection
247
292
  providers={draft.providers}
293
+ openRoutes={openRoutes}
248
294
  epoch={epoch}
249
295
  disabled={disabled}
250
296
  t={t}
251
297
  onAddRoute={onAddRoute}
252
- onRemoveRoute={onRemoveRoute}
253
- onPatchProvider={setProvider}
298
+ onRemoveRoute={(route) => {
299
+ setDraft(withoutRoute(draft, route))
300
+ idle()
301
+ }}
302
+ onPatchProvider={(route, patch) => {
303
+ setDraft(withProviderPatch(draft, route, patch))
304
+ idle()
305
+ }}
306
+ onToggleRoute={(route, open) => setOpenRoutes((prev) => ({ ...prev, [route]: open }))}
307
+ onCollapseAll={() => setOpenRoutes({})}
308
+ onExpandAll={() =>
309
+ setOpenRoutes(
310
+ Object.fromEntries(Object.keys(draft.providers).map((route) => [route, true])),
311
+ )
312
+ }
313
+ onAddFromCatalog={openBrowserFor}
254
314
  />
255
315
  </CardChrome>
256
316
  )
@@ -9,8 +9,8 @@
9
9
  * scope 与基础控件走 @dsh-plus/shared/client 套件(收编自本插件原实现)。
10
10
  * 配置读写经 ctx.remote.settings 直连(0.1.2-alpha.1 起 connection.api.settings
11
11
  * 已移除;0.1.7 起 settingsScope 服务亦删除(configForms 取代)——插件卡片自始
12
- * 直连 remote.settings,见 @dsh-plus/shared/client scope.ts);自定义端点仅剩「模型目录」
13
- * (api.ts,含 kitSource/models-dev 运行期诊断)。
12
+ * 直连 remote.settings,见 @dsh-plus/shared/client scope.ts);自定义端点只剩
13
+ * `/catalog`(api.ts:运行期套件事实 + pi-ai 内置目录搜索,浏览器半读不到 pi-ai 包)。
14
14
  * 卡片 key/rowId 约定见 @dsh-plus/shared/client/config-slots。
15
15
  * @module @dsh-plus/llm-pi/client
16
16
  */
@@ -1,12 +1,16 @@
1
1
  /**
2
- * 浏览器半内联常量:与服务端 packages/llm-pi/src/config.ts、compat.ts 逐字对齐。
2
+ * 浏览器半内联常量:与服务端 packages/llm-pi/src/config.ts 逐字对齐。
3
3
  * 浏览器半不能 import 服务端模块(tsdown 只打包 client 侧入口),
4
4
  * 改动服务端这些常量时必须同步本文件。
5
+ *
6
+ * 两个集合改为**服务端下发**(与服务端校验同源,浏览器半不再手抄):
7
+ * - 线协议:取自已装官方适配器的 supportedProtocols()(`installProtocols`);
8
+ * - compat 字段表:取自官方 bundle + Config schema 推导(`installCompatFields`)。
5
9
  * @module llm-pi/client/constants
6
10
  */
7
11
 
8
- /** 协议枚举(来源:config.ts PROTOCOL_IDS)。 */
9
- export const PROTOCOL_IDS = [
12
+ /** 协议兜底集合(来源:config.ts FALLBACK_PROTOCOLS);服务端下发前/失败时使用。 */
13
+ export const FALLBACK_PROTOCOLS = [
10
14
  'openai-completions',
11
15
  'openai-responses',
12
16
  'anthropic-messages',
@@ -29,15 +33,31 @@ export const BUDGET_KEYS = ['minimal', 'low', 'medium', 'high'] as const
29
33
 
30
34
  export type CompatValue = 'boolean' | 'integer' | 'number' | 'object' | readonly string[]
31
35
 
36
+ /**
37
+ * 生效协议集合:由服务端 /catalog 下发(服务端与官方适配器同源推导),
38
+ * 未拿到前用 FALLBACK_PROTOCOLS。
39
+ */
40
+ let protocols: string[] = [...FALLBACK_PROTOCOLS]
41
+
42
+ /** 安装服务端下发的协议集合(card 挂载时调用)。 */
43
+ export function installProtocols(list: readonly string[]): void {
44
+ if (list.length > 0) protocols = [...list]
45
+ }
46
+
47
+ /** 当前可选协议(顺序即官方表顺序)。 */
48
+ export function protocolOptions(): readonly string[] {
49
+ return protocols
50
+ }
51
+
32
52
  /**
33
53
  * 逐协议 compat 字段表:**服务端推导结果经 /catalog 下发**,浏览器半不再手抄
34
- * (手抄遗漏官方新增字段 = UI 不渲染 + 保存被后端拒绝;见 compat-gates.ts)。
54
+ * (手抄遗漏官方新增字段 = UI 不渲染 + 保存被后端拒绝;见 official-surface.ts)。
35
55
  * 未拿到服务端表时用 EMPTY(UI 只渲染提示行),不放内置副本兜底——宁可少画
36
56
  * 控件,也不给出可能与官方不符的字段集。
37
57
  */
38
58
  let compatTable: Record<string, Record<string, CompatValue>> = {}
39
59
 
40
- /** 安装服务端下发的字段表(card 挂载/刷新目录时调用)。 */
60
+ /** 安装服务端下发的字段表(card 挂载时调用)。 */
41
61
  export function installCompatFields(table: Record<string, Record<string, CompatValue>>): void {
42
62
  compatTable = table
43
63
  }