@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.
- package/lib/client.js +1528 -767
- package/lib/index.d.ts +4 -8
- package/lib/index.js +943 -391
- package/package.json +35 -36
- package/src/catalog/browse.ts +297 -0
- package/src/catalog/builtin.ts +183 -35
- package/src/client/api.ts +96 -33
- package/src/client/card.tsx +183 -133
- package/src/client/client.ts +4 -4
- package/src/client/constants.ts +25 -5
- package/src/client/draft.ts +34 -13
- package/src/client/fields.tsx +18 -3
- package/src/client/i18n.ts +153 -49
- package/src/client/root-fields.tsx +64 -52
- package/src/client/route-fit.ts +117 -0
- package/src/client/styles.ts +40 -10
- package/src/client/views/catalog-browser.tsx +376 -0
- package/src/client/views/catalog-row.tsx +147 -0
- package/src/client/views/compat.tsx +31 -3
- package/src/client/views/models.tsx +189 -193
- package/src/client/views/provider-fields.tsx +2 -2
- package/src/client/views/providers.tsx +51 -11
- package/src/compat.ts +43 -19
- package/src/config-api.ts +65 -37
- package/src/config.ts +20 -28
- package/src/inherit.ts +15 -31
- package/src/kit-meta.ts +156 -0
- package/src/ns.ts +2 -2
- package/src/{compat-gates.ts → official-surface.ts} +60 -16
- package/src/profiles.ts +52 -13
- package/src/resolve-dsh.ts +291 -72
- package/src/service.ts +209 -90
- package/src/catalog/models-dev.ts +0 -261
|
@@ -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
|
+
}
|
package/src/client/styles.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 配置卡片样式:基础规则走 @dsh-plus/shared/client 的 cardCss('lpc'),
|
|
3
|
-
* 本文件补插件特有规则(route/model
|
|
4
|
-
*
|
|
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;
|
|
38
|
-
.lpc-modelTitle{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}
|
|
39
|
-
.lpc-
|
|
40
|
-
.lpc-
|
|
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-
|
|
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-
|
|
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
|
+
}
|