@dsh-plus/llm-pi 0.1.41 → 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,117 @@
1
+ /**
2
+ * route 生效协议推断与「一键添加」可用性判定(纯函数,node --test 直测)。
3
+ *
4
+ * 为什么需要它:服务端物化的协议规则是「route 级 api 优先,其次逐模型继承值,
5
+ * 全 route 必须一致」;卡片要在**不保存**的前提下告诉用户某个模型能不能加进
6
+ * 某个 route(否则用户会撞上一次保存失败)。协议索引由服务端 /catalog 的
7
+ * `apis`(provider → modelId → api)提供,覆盖全部内置模型。
8
+ * @module llm-pi/client/route-fit
9
+ */
10
+
11
+ import type { WireModelInfo } from './api.ts'
12
+ import type { ProviderDraft } from './draft.ts'
13
+
14
+ /** 协议索引:`{provider: {modelId: api}}`(服务端 /catalog 的 apis)。 */
15
+ export type ApiIndex = Record<string, Record<string, string>>
16
+
17
+ /** route 生效协议事实。 */
18
+ export interface RouteApiFacts {
19
+ /** 唯一生效协议;混合或无法判定时为 undefined。 */
20
+ api?: string
21
+ /** 模型之间存在多个协议(保存会被服务端拒绝)。 */
22
+ mixed: boolean
23
+ /** 无法判定协议的模型数(条目既无 extends 又无 route 级 extends 源)。 */
24
+ unresolved: number
25
+ }
26
+
27
+ /**
28
+ * 解析条目指向的继承引用(与服务端 inherit.ts 同规则):
29
+ * `"provider/model"` 显式引用;裸 `"model"` 随 route 级 extends 源;
30
+ * 条目无 extends 时按同名模型挂在 route 级 extends 源下。
31
+ * 两段任一为空(无引用可解析)时返回空串。
32
+ */
33
+ export function entryReference(
34
+ entry: { id: string; extends: string },
35
+ routeExtends: string,
36
+ ): { provider: string; model: string } {
37
+ const raw = entry.extends.trim()
38
+ if (raw === '') return { provider: routeExtends.trim(), model: entry.id.trim() }
39
+ const slash = raw.indexOf('/')
40
+ if (slash > 0) return { provider: raw.slice(0, slash), model: raw.slice(slash + 1) }
41
+ return { provider: routeExtends.trim(), model: raw }
42
+ }
43
+
44
+ /**
45
+ * 推断 route 当前实际生效的协议:
46
+ * - route 级 `api` 显式给出即用它(物化时它压过继承值);
47
+ * - 否则逐模型查**其引用指向的模型**的协议(注意是引用目标,不是条目 id:
48
+ * 生产配置普遍用别名条目,如 `{id: 'glm-5.3', extends: 'zai/glm-5.3'}`):
49
+ * 单一 → 该协议;多个 → mixed;引用解析不出来计 unresolved。
50
+ */
51
+ export function routeApiFacts(provider: ProviderDraft, apis: ApiIndex): RouteApiFacts {
52
+ const explicit = provider.api.trim()
53
+ if (explicit !== '') return { api: explicit, mixed: false, unresolved: 0 }
54
+ const found = new Set<string>()
55
+ let unresolved = 0
56
+ for (const model of provider.models) {
57
+ const ref = entryReference(model, provider.extends)
58
+ if (ref.provider === '' || ref.model === '') {
59
+ unresolved += 1
60
+ continue
61
+ }
62
+ const api = apis[ref.provider]?.[ref.model]
63
+ if (api === undefined) unresolved += 1
64
+ else found.add(api)
65
+ }
66
+ if (found.size === 1) return { api: [...found][0] ?? '', mixed: false, unresolved }
67
+ return { mixed: found.size > 1, unresolved }
68
+ }
69
+
70
+ /** route 是否由 PiAiAdapter 服务(`adapter` 在卡片的 extra 里原样往返)。 */
71
+ export function isPiRoute(provider: ProviderDraft): boolean {
72
+ const adapter = provider.extra['adapter']
73
+ return adapter === undefined || adapter === 'pi'
74
+ }
75
+
76
+ /** 可作为「一键添加」目标的 route(deepseek 路由不适用 pi-ai 继承)。 */
77
+ export function targetRoutes(providers: Record<string, ProviderDraft>): string[] {
78
+ return Object.entries(providers)
79
+ .filter(([, provider]) => isPiRoute(provider))
80
+ .map(([route]) => route)
81
+ .sort((left, right) => left.localeCompare(right))
82
+ }
83
+
84
+ /** 不能添加的原因(界面据此给文案)。 */
85
+ export type AddBlockReason =
86
+ | 'unsupported'
87
+ | 'duplicate'
88
+ | 'protocol-conflict'
89
+ | 'route-mixed'
90
+ | 'no-route'
91
+
92
+ export type AddEligibility = { ok: true } | { ok: false; reason: AddBlockReason; detail?: string }
93
+
94
+ /**
95
+ * 判定某内置模型能否加进目标 route:
96
+ * 协议不受支持 → unsupported;route 不存在 → no-route;同 id 已在 → duplicate;
97
+ * route 已混用多个协议 → route-mixed(保存本来就会失败,先提示修好);
98
+ * route 已有确定协议且与模型不同 → protocol-conflict(保存一定失败,提前拦)。
99
+ */
100
+ export function addEligibility(options: {
101
+ model: WireModelInfo
102
+ provider: ProviderDraft | undefined
103
+ apis: ApiIndex
104
+ }): AddEligibility {
105
+ const { model, provider, apis } = options
106
+ if (!model.servable) return { ok: false, reason: 'unsupported', detail: model.api }
107
+ if (provider === undefined) return { ok: false, reason: 'no-route' }
108
+ if (provider.models.some((entry) => entry.id.trim() === model.id)) {
109
+ return { ok: false, reason: 'duplicate' }
110
+ }
111
+ const facts = routeApiFacts(provider, apis)
112
+ if (facts.mixed) return { ok: false, reason: 'route-mixed' }
113
+ if (facts.api !== undefined && facts.api !== model.api) {
114
+ return { ok: false, reason: 'protocol-conflict', detail: facts.api }
115
+ }
116
+ return { ok: true }
117
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * 配置卡片样式:基础规则走 @dsh-plus/shared/client 的 cardCss('lpc'),
3
- * 本文件补插件特有规则(route/model 折叠块、键值对行、网格、JSON 文本框)
4
- * 及窄屏响应式:views 的双列网格在窄屏转单列、route 头部按钮换行、
5
- * 键值对行纵向堆叠、文本框 16px 防 iOS 缩放。
3
+ * 本文件补插件特有规则(route/model 折叠块、目录浏览器行、键值对行、网格、
4
+ * JSON 文本框)及窄屏响应式:views 的双列网格在窄屏转单列、route 头部按钮换行、
5
+ * 键值对行纵向堆叠、文本框 16px 防 iOS 缩放、目录结果区限高滚动。
6
6
  * 沿用官方 data-plugin / data-plugin-css 约定(HMR 据此卸载)。
7
7
  * @module llm-pi/client/styles
8
8
  */
@@ -23,6 +23,11 @@ const extraCss = `
23
23
  .lpc-wide{grid-column:1 / -1}
24
24
  .lpc-addRoute{display:flex;gap:8px;align-items:center;padding:10px 0}
25
25
  .lpc-addRoute .lpc-input{flex:1;min-width:0}
26
+ .lpc-chevron{flex:none;transition:transform .18s}
27
+ .lpc-chevronOpen{transform:rotate(180deg)}
28
+ .lpc-iconBtn{appearance:none;border:none;background:0 0;cursor:pointer;color:var(--dsw-alias-label-tertiary);border-radius:6px;padding:4px;display:inline-flex;flex:none}
29
+ .lpc-iconBtn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
30
+ .lpc-iconBtn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
26
31
  .lpc-route{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;margin:10px 0;background:var(--dsw-alias-bg-layer-3)}
27
32
  .lpc-routeHead{display:flex;align-items:center;gap:8px;padding:6px 10px}
28
33
  .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}
@@ -34,17 +39,37 @@ const extraCss = `
34
39
  .lpc-kvRow .lpc-input{flex:1;min-width:0}
35
40
  .lpc-kvAdd{padding-top:8px}
36
41
  .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)}
37
- .lpc-modelHead{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 0}
38
- .lpc-modelTitle{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}
39
- .lpc-catalogBar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:4px 0}
40
- .lpc-catalogLabel{color:var(--dsw-alias-label-tertiary);font-size:12px}
41
- .lpc-catalogSelect{width:auto;height:30px}
42
+ .lpc-modelHead{display:flex;align-items:center;gap:8px;padding:8px 0}
43
+ .lpc-modelTitle{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;flex:none}
44
+ .lpc-modelRow .lpc-modelHead .lpc-routeToggle{flex:1}
45
+ .lpc-modelBody{border-top:1px dashed var(--dsw-alias-border-l2);padding-bottom:8px}
42
46
  .lpc-collapse{border-top:1px solid var(--dsw-alias-border-l2);margin:2px 0}
43
47
  .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}
44
48
  .lpc-collapseHead:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
45
49
  .lpc-collapseTitle{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}
50
+ .lpc-collapseMeta{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none;white-space:nowrap}
46
51
  .lpc-collapseBody{border-top:1px dashed var(--dsw-alias-border-l2);padding-bottom:6px}
47
- .lpc-refreshBtn{margin-left:8px;vertical-align:middle}
52
+ .lpc-cat{display:flex;flex-direction:column;gap:6px}
53
+ .lpc-catBar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:4px 0}
54
+ .lpc-catLabel{color:var(--dsw-alias-label-tertiary);font-size:12px;flex:none}
55
+ .lpc-catSearch{flex:1;min-width:180px}
56
+ .lpc-catSelect{width:auto;max-width:260px;height:30px}
57
+ .lpc-catNote{color:var(--dsw-alias-label-tertiary);font-size:12px;min-width:0;word-break:break-all}
58
+ .lpc-catSummary{color:var(--dsw-alias-label-secondary);margin:2px 0;font-size:12px}
59
+ .lpc-catList{display:flex;flex-direction:column;max-height:420px;overflow-y:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3)}
60
+ .lpc-catRow{border-bottom:1px solid var(--dsw-alias-border-l2);padding:8px 10px}
61
+ .lpc-catRow:last-child{border-bottom:none}
62
+ .lpc-catHead{display:flex;align-items:center;gap:8px;min-width:0}
63
+ .lpc-catPath{color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
64
+ .lpc-catName{color:var(--dsw-alias-label-secondary);font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
65
+ .lpc-catParams{color:var(--dsw-alias-label-tertiary);margin:2px 0;font-size:12px;line-height:1.6;word-break:break-all}
66
+ .lpc-catDetailsToggle{appearance:none;background:0 0;border:0;font:inherit;color:var(--dsw-alias-label-tertiary);cursor:pointer;display:inline-flex;align-items:center;gap:6px;padding:2px 0;font-size:12px;border-radius:6px}
67
+ .lpc-catDetailsToggle:hover{color:var(--dsw-alias-label-primary)}
68
+ .lpc-catDetailsToggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
69
+ .lpc-catDetails{padding:2px 0 4px}
70
+ .lpc-catDetailLabel{color:var(--dsw-alias-label-secondary);margin:6px 0 2px;font-size:12px;font-weight:600}
71
+ .lpc-catJson{background:var(--dsw-alias-bg-module-platform);border-radius:8px;color:var(--dsw-alias-label-secondary);margin:0;padding:8px 10px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-all}
72
+ .lpc-catMore{padding:6px 0}
48
73
  @media (max-width:767px){
49
74
  .lpc-grid{grid-template-columns:1fr}
50
75
  .lpc-routeHead{flex-wrap:wrap}
@@ -56,7 +81,12 @@ const extraCss = `
56
81
  .lpc-kvRow .lpc-btn{align-self:flex-end}
57
82
  .lpc-textarea{font-size:16px}
58
83
  .lpc-statusRow{display:flex;flex-direction:column;gap:4px}
59
- .lpc-catalogBar .lpc-input{flex:1;min-width:140px}
84
+ .lpc-catBar .lpc-input{flex:1;min-width:140px}
85
+ .lpc-catSelect{max-width:none}
86
+ .lpc-catHead{flex-wrap:wrap}
87
+ .lpc-catPath{white-space:normal;word-break:break-all}
88
+ .lpc-catName{flex-basis:100%;white-space:normal}
89
+ .lpc-catList{max-height:60vh}
60
90
  }
61
91
  `
62
92
 
@@ -0,0 +1,376 @@
1
+ /**
2
+ * 内置模型目录浏览器:搜索(服务端模糊匹配)/筛选/分页 + 复制路径 id +
3
+ * 一键把 `{id, extends: 'provider/model'}` 加到目标 route。
4
+ *
5
+ * 数据全部来自本插件的 `/catalog` 端点(浏览器半读不到 pi-ai 包):
6
+ * - `apis`(provider → modelId → api)用于推断目标 route 的生效协议,
7
+ * 在添加前拦下"协议不一致/重复 id/协议不受支持"这三类必然失败的添加;
8
+ * - 模型行的参数直接渲染服务端给的事实(含官方新增字段,见 `rest`)。
9
+ * 折叠由外层(card.tsx)控制,故本组件只管内容与交互。
10
+ *
11
+ * 结构:`useCatalogSearch`(拉取/分页状态)+ `BrowserToolbar`(搜索与筛选条)
12
+ * + `CatalogRow`(明细行),`CatalogBrowser` 只做编排。
13
+ * @module llm-pi/client/views/catalog-browser
14
+ */
15
+
16
+ import { CheckRow } from '@dsh-plus/shared/client'
17
+ import { type ReactElement, useEffect, useMemo, useRef, useState } from 'react'
18
+
19
+ import {
20
+ type CatalogQuery,
21
+ searchCatalog,
22
+ type WireModelInfo,
23
+ type WireProviderEntry,
24
+ } from '../api.ts'
25
+ import { protocolOptions } from '../constants.ts'
26
+ import type { ProviderDraft } from '../draft.ts'
27
+ import type { Translate } from '../i18n.ts'
28
+ import { type ApiIndex, addEligibility } from '../route-fit.ts'
29
+ import { CatalogRow } from './catalog-row.tsx'
30
+
31
+ /** 计数展示(千分位;不引本地化依赖)。 */
32
+ function count(value: number): string {
33
+ return value.toLocaleString()
34
+ }
35
+
36
+ /** 一页条数(服务端同样有上限;"加载更多"按此翻页)。 */
37
+ const PAGE_SIZE = 30
38
+ /** 搜索去抖毫秒(本地端点,短去抖即可)。 */
39
+ const DEBOUNCE_MS = 180
40
+
41
+ export interface CatalogBrowserProps {
42
+ t: Translate
43
+ /** 可作为目标的 route(已按 adapter: pi 过滤)。 */
44
+ targets: string[]
45
+ /** 目标 route 草稿(判定可否添加;不存在即 undefined)。 */
46
+ providerOf(route: string): ProviderDraft | undefined
47
+ /** 目标 route 的生效协议(选择器徽标用)。 */
48
+ routeApiOf(route: string): string | undefined
49
+ apis: ApiIndex
50
+ providers: WireProviderEntry[]
51
+ target: string
52
+ onTargetChange(route: string): void
53
+ /** 添加回调;返回 false 表示未添加(同 id 已存在)。 */
54
+ onAdd(route: string, model: WireModelInfo): boolean
55
+ disabled?: boolean
56
+ /** 目录索引拉取失败时的错误文案(此时只显示提示,不渲染筛选)。 */
57
+ metaError?: string
58
+ }
59
+
60
+ /** 计数展示(千分位;不引本地化依赖)。 */
61
+ /** 参数行的键值短展示:字符串化,长值截断。 */
62
+ /** 禁止添加的原因文案。 */
63
+ interface SearchState {
64
+ items: WireModelInfo[]
65
+ total: number
66
+ servableHidden: number
67
+ loading: boolean
68
+ error: string
69
+ }
70
+
71
+ const EMPTY_STATE: SearchState = {
72
+ items: [],
73
+ total: 0,
74
+ servableHidden: 0,
75
+ loading: false,
76
+ error: '',
77
+ }
78
+
79
+ /**
80
+ * 目录搜索状态机:条件变化重新拉第一页(旧请求按 requestId 丢弃,避免竞态覆盖),
81
+ * `loadMore` 追加下一页。
82
+ */
83
+ function useCatalogSearch(
84
+ filters: CatalogQuery,
85
+ enabled: boolean,
86
+ ): SearchState & { loadMore(): void } {
87
+ const [state, setState] = useState<SearchState>(EMPTY_STATE)
88
+ const requestId = useRef(0)
89
+
90
+ useEffect(() => {
91
+ if (!enabled) return
92
+ const id = requestId.current + 1
93
+ requestId.current = id
94
+ setState((prev) => ({ ...prev, loading: true, error: '' }))
95
+ const controller = new AbortController()
96
+ void searchCatalog({ ...filters, offset: 0, limit: PAGE_SIZE }, controller.signal)
97
+ .then((page) => {
98
+ if (requestId.current !== id) return
99
+ setState({
100
+ items: page.items,
101
+ total: page.total,
102
+ servableHidden: page.servableHidden,
103
+ loading: false,
104
+ error: '',
105
+ })
106
+ })
107
+ .catch((error: unknown) => {
108
+ if (requestId.current !== id) return
109
+ setState((prev) => ({
110
+ ...prev,
111
+ loading: false,
112
+ error: error instanceof Error ? error.message : String(error),
113
+ }))
114
+ })
115
+ return () => controller.abort()
116
+ }, [filters, enabled])
117
+
118
+ const loadMore = (): void => {
119
+ const id = requestId.current + 1
120
+ requestId.current = id
121
+ setState((prev) => ({ ...prev, loading: true }))
122
+ void searchCatalog({ ...filters, offset: state.items.length, limit: PAGE_SIZE })
123
+ .then((page) => {
124
+ if (requestId.current !== id) return
125
+ setState((prev) => ({
126
+ ...prev,
127
+ items: [...prev.items, ...page.items],
128
+ total: page.total,
129
+ servableHidden: page.servableHidden,
130
+ loading: false,
131
+ }))
132
+ })
133
+ .catch((error: unknown) => {
134
+ if (requestId.current !== id) return
135
+ setState((prev) => ({
136
+ ...prev,
137
+ loading: false,
138
+ error: error instanceof Error ? error.message : String(error),
139
+ }))
140
+ })
141
+ }
142
+ return { ...state, loadMore }
143
+ }
144
+
145
+ /** 搜索框输入(本地态)→ 去抖后的查询串。 */
146
+ function useDebouncedQuery(query: string): string {
147
+ const [debounced, setDebounced] = useState('')
148
+ useEffect(() => {
149
+ const timer = setTimeout(() => setDebounced(query), DEBOUNCE_MS)
150
+ return () => clearTimeout(timer)
151
+ }, [query])
152
+ return debounced
153
+ }
154
+
155
+ interface ToolbarProps {
156
+ t: Translate
157
+ query: string
158
+ onQuery(value: string): void
159
+ provider: string
160
+ onProvider(value: string): void
161
+ api: string
162
+ onApi(value: string): void
163
+ reasoning: boolean
164
+ onReasoning(value: boolean): void
165
+ image: boolean
166
+ onImage(value: boolean): void
167
+ servableOnly: boolean
168
+ onServableOnly(value: boolean): void
169
+ providers: WireProviderEntry[]
170
+ targets: string[]
171
+ target: string
172
+ onTarget(value: string): void
173
+ targetApi: string | undefined
174
+ disabled: boolean
175
+ }
176
+
177
+ /** 搜索/筛选/目标 route 三条工具条。 */
178
+ function BrowserToolbar(props: ToolbarProps): ReactElement {
179
+ const { t } = props
180
+ return (
181
+ <>
182
+ <div className="lpc-catBar">
183
+ <input
184
+ className="lpc-input lpc-catSearch"
185
+ type="search"
186
+ value={props.query}
187
+ placeholder={t('browserSearchPlaceholder')}
188
+ aria-label={t('browserSearch')}
189
+ disabled={props.disabled}
190
+ onChange={(event) => props.onQuery(event.target.value)}
191
+ />
192
+ <select
193
+ className="lpc-input lpc-select lpc-catSelect"
194
+ value={props.provider}
195
+ aria-label={t('browserProvider')}
196
+ disabled={props.disabled}
197
+ onChange={(event) => props.onProvider(event.target.value)}
198
+ >
199
+ <option value="">{`${t('browserProvider')}:${t('browserAll')}`}</option>
200
+ {props.providers.map((entry) => (
201
+ <option key={entry.id} value={entry.id}>
202
+ {`${entry.id}(${entry.modelCount})`}
203
+ </option>
204
+ ))}
205
+ </select>
206
+ <select
207
+ className="lpc-input lpc-select lpc-catSelect"
208
+ value={props.api}
209
+ aria-label={t('browserApi')}
210
+ disabled={props.disabled}
211
+ onChange={(event) => props.onApi(event.target.value)}
212
+ >
213
+ <option value="">{`${t('browserApi')}:${t('browserAll')}`}</option>
214
+ {protocolOptions().map((id) => (
215
+ <option key={id} value={id}>
216
+ {id}
217
+ </option>
218
+ ))}
219
+ </select>
220
+ </div>
221
+ <div className="lpc-catBar">
222
+ <CheckRow
223
+ prefix="lpc"
224
+ id="lpc-cat-servable"
225
+ label={t('browserOnlyServable')}
226
+ checked={props.servableOnly}
227
+ disabled={props.disabled}
228
+ onEdit={props.onServableOnly}
229
+ />
230
+ <CheckRow
231
+ prefix="lpc"
232
+ id="lpc-cat-reasoning"
233
+ label={t('browserReasoning')}
234
+ checked={props.reasoning}
235
+ disabled={props.disabled}
236
+ onEdit={props.onReasoning}
237
+ />
238
+ <CheckRow
239
+ prefix="lpc"
240
+ id="lpc-cat-image"
241
+ label={t('browserImage')}
242
+ checked={props.image}
243
+ disabled={props.disabled}
244
+ onEdit={props.onImage}
245
+ />
246
+ </div>
247
+ <div className="lpc-catBar">
248
+ <label className="lpc-catLabel" htmlFor="lpc-cat-target">
249
+ {t('browserTarget')}
250
+ </label>
251
+ <select
252
+ id="lpc-cat-target"
253
+ className="lpc-input lpc-select lpc-catSelect"
254
+ value={props.target}
255
+ disabled={props.disabled || props.targets.length === 0}
256
+ onChange={(event) => props.onTarget(event.target.value)}
257
+ >
258
+ {props.targets.length === 0 ? <option value="">-</option> : null}
259
+ {props.targets.map((route) => (
260
+ <option key={route} value={route}>
261
+ {route}
262
+ </option>
263
+ ))}
264
+ </select>
265
+ <span className="lpc-catNote">
266
+ {props.targetApi === undefined
267
+ ? t('browserNoProtocol')
268
+ : `${t('browserTargetProtocol')}:${props.targetApi}`}
269
+ </span>
270
+ </div>
271
+ </>
272
+ )
273
+ }
274
+
275
+ export function CatalogBrowser(props: CatalogBrowserProps): ReactElement {
276
+ const { t } = props
277
+ const [query, setQuery] = useState('')
278
+ const debounced = useDebouncedQuery(query)
279
+ const [provider, setProvider] = useState('')
280
+ const [api, setApi] = useState('')
281
+ const [reasoning, setReasoning] = useState(false)
282
+ const [image, setImage] = useState(false)
283
+ const [servableOnly, setServableOnly] = useState(true)
284
+ const [note, setNote] = useState('')
285
+
286
+ const filters = useMemo(
287
+ () => ({ q: debounced, provider, api, reasoning, image, servableOnly }),
288
+ [debounced, provider, api, reasoning, image, servableOnly],
289
+ )
290
+ const { items, total, servableHidden, loading, error, loadMore } = useCatalogSearch(
291
+ filters,
292
+ props.metaError === undefined,
293
+ )
294
+
295
+ if (props.metaError !== undefined) {
296
+ return <p className="lpc-invalid">{`${t('browserFailed')}${props.metaError}`}</p>
297
+ }
298
+
299
+ const onAdd = (model: WireModelInfo): void => {
300
+ if (props.target === '') return
301
+ const added = props.onAdd(props.target, model)
302
+ setNote(
303
+ added
304
+ ? `${t('browserAddedTo')} ${props.target}:${model.path}`
305
+ : `${t('blockDuplicate')}:${model.path}`,
306
+ )
307
+ }
308
+ const disabled = props.disabled === true
309
+ return (
310
+ <div className="lpc-cat">
311
+ <p className="lpc-hint">{t('browserHint')}</p>
312
+ <BrowserToolbar
313
+ t={t}
314
+ query={query}
315
+ onQuery={setQuery}
316
+ provider={provider}
317
+ onProvider={setProvider}
318
+ api={api}
319
+ onApi={setApi}
320
+ reasoning={reasoning}
321
+ onReasoning={setReasoning}
322
+ image={image}
323
+ onImage={setImage}
324
+ servableOnly={servableOnly}
325
+ onServableOnly={setServableOnly}
326
+ providers={props.providers}
327
+ targets={props.targets}
328
+ target={props.target}
329
+ onTarget={props.onTargetChange}
330
+ targetApi={props.target === '' ? undefined : props.routeApiOf(props.target)}
331
+ disabled={disabled}
332
+ />
333
+ {props.targets.length === 0 ? <p className="lpc-hint">{t('browserNoTarget')}</p> : null}
334
+ <p className="lpc-catSummary">
335
+ {`${t('browserMatched')} ${count(total)}`}
336
+ {servableHidden > 0
337
+ ? ` · ${t('browserHiddenServable')} ${count(servableHidden)} ${t('browserHiddenUnit')}`
338
+ : ''}
339
+ {loading ? ` · ${t('browserLoading')}` : ''}
340
+ </p>
341
+ {error !== '' ? <p className="lpc-invalid">{`${t('browserFailed')}${error}`}</p> : null}
342
+ {!loading && error === '' && items.length === 0 ? (
343
+ <p className="lpc-hint">{t('browserEmpty')}</p>
344
+ ) : null}
345
+ <div className="lpc-catList">
346
+ {items.map((model) => (
347
+ <CatalogRow
348
+ key={model.path}
349
+ t={t}
350
+ model={model}
351
+ disabled={disabled}
352
+ eligibility={addEligibility({
353
+ model,
354
+ provider: props.providerOf(props.target),
355
+ apis: props.apis,
356
+ })}
357
+ onAdd={() => onAdd(model)}
358
+ />
359
+ ))}
360
+ </div>
361
+ {items.length > 0 && items.length < total ? (
362
+ <div className="lpc-catMore">
363
+ <button
364
+ type="button"
365
+ className="lpc-btn lpc-btnGhost lpc-btnSmall"
366
+ disabled={loading}
367
+ onClick={loadMore}
368
+ >
369
+ {t('browserLoadMore')}
370
+ </button>
371
+ </div>
372
+ ) : null}
373
+ {note !== '' ? <p className="lpc-catNote">{note}</p> : null}
374
+ </div>
375
+ )
376
+ }