dsh-kenari-plugin 0.1.1
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/LICENSE +21 -0
- package/README.en.md +281 -0
- package/README.md +283 -0
- package/assets/kenari-favicon-128.png +0 -0
- package/assets/kenari-favicon-256.png +0 -0
- package/client/index.js +1998 -0
- package/cordis.patch.yml +137 -0
- package/lib/billing.d.ts +120 -0
- package/lib/billing.d.ts.map +1 -0
- package/lib/billing.js +209 -0
- package/lib/billing.js.map +1 -0
- package/lib/catalog-view.d.ts +78 -0
- package/lib/catalog-view.d.ts.map +1 -0
- package/lib/catalog-view.js +113 -0
- package/lib/catalog-view.js.map +1 -0
- package/lib/catalog.d.ts +239 -0
- package/lib/catalog.d.ts.map +1 -0
- package/lib/catalog.js +445 -0
- package/lib/catalog.js.map +1 -0
- package/lib/default-route.d.ts +35 -0
- package/lib/default-route.d.ts.map +1 -0
- package/lib/default-route.js +111 -0
- package/lib/default-route.js.map +1 -0
- package/lib/errors.d.ts +29 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +74 -0
- package/lib/errors.js.map +1 -0
- package/lib/favicon.d.ts +24 -0
- package/lib/favicon.d.ts.map +1 -0
- package/lib/favicon.js +68 -0
- package/lib/favicon.js.map +1 -0
- package/lib/http.d.ts +104 -0
- package/lib/http.d.ts.map +1 -0
- package/lib/http.js +247 -0
- package/lib/http.js.map +1 -0
- package/lib/index.d.ts +83 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +285 -0
- package/lib/index.js.map +1 -0
- package/lib/llm/adapter.d.ts +78 -0
- package/lib/llm/adapter.d.ts.map +1 -0
- package/lib/llm/adapter.js +429 -0
- package/lib/llm/adapter.js.map +1 -0
- package/lib/llm/candidates.d.ts +57 -0
- package/lib/llm/candidates.d.ts.map +1 -0
- package/lib/llm/candidates.js +86 -0
- package/lib/llm/candidates.js.map +1 -0
- package/lib/llm/recovery.d.ts +48 -0
- package/lib/llm/recovery.d.ts.map +1 -0
- package/lib/llm/recovery.js +118 -0
- package/lib/llm/recovery.js.map +1 -0
- package/lib/llm/retry.d.ts +35 -0
- package/lib/llm/retry.d.ts.map +1 -0
- package/lib/llm/retry.js +53 -0
- package/lib/llm/retry.js.map +1 -0
- package/lib/plans.d.ts +59 -0
- package/lib/plans.d.ts.map +1 -0
- package/lib/plans.js +118 -0
- package/lib/plans.js.map +1 -0
- package/lib/session-title.d.ts +63 -0
- package/lib/session-title.d.ts.map +1 -0
- package/lib/session-title.js +280 -0
- package/lib/session-title.js.map +1 -0
- package/lib/settings.d.ts +35 -0
- package/lib/settings.d.ts.map +1 -0
- package/lib/settings.js +78 -0
- package/lib/settings.js.map +1 -0
- package/lib/tools/account.d.ts +11 -0
- package/lib/tools/account.d.ts.map +1 -0
- package/lib/tools/account.js +132 -0
- package/lib/tools/account.js.map +1 -0
- package/lib/tools/billing.d.ts +10 -0
- package/lib/tools/billing.d.ts.map +1 -0
- package/lib/tools/billing.js +100 -0
- package/lib/tools/billing.js.map +1 -0
- package/lib/tools/count-tokens.d.ts +11 -0
- package/lib/tools/count-tokens.d.ts.map +1 -0
- package/lib/tools/count-tokens.js +84 -0
- package/lib/tools/count-tokens.js.map +1 -0
- package/lib/tools/data.d.ts +12 -0
- package/lib/tools/data.d.ts.map +1 -0
- package/lib/tools/data.js +205 -0
- package/lib/tools/data.js.map +1 -0
- package/lib/tools/docs.d.ts +13 -0
- package/lib/tools/docs.d.ts.map +1 -0
- package/lib/tools/docs.js +209 -0
- package/lib/tools/docs.js.map +1 -0
- package/lib/tools/documents.d.ts +10 -0
- package/lib/tools/documents.d.ts.map +1 -0
- package/lib/tools/documents.js +118 -0
- package/lib/tools/documents.js.map +1 -0
- package/lib/tools/media.d.ts +28 -0
- package/lib/tools/media.d.ts.map +1 -0
- package/lib/tools/media.js +599 -0
- package/lib/tools/media.js.map +1 -0
- package/lib/tools/shared.d.ts +89 -0
- package/lib/tools/shared.d.ts.map +1 -0
- package/lib/tools/shared.js +128 -0
- package/lib/tools/shared.js.map +1 -0
- package/lib/tools/x-search.d.ts +11 -0
- package/lib/tools/x-search.d.ts.map +1 -0
- package/lib/tools/x-search.js +129 -0
- package/lib/tools/x-search.js.map +1 -0
- package/lib/web/fallback.d.ts +39 -0
- package/lib/web/fallback.d.ts.map +1 -0
- package/lib/web/fallback.js +79 -0
- package/lib/web/fallback.js.map +1 -0
- package/lib/web/fetch.d.ts +19 -0
- package/lib/web/fetch.d.ts.map +1 -0
- package/lib/web/fetch.js +41 -0
- package/lib/web/fetch.js.map +1 -0
- package/lib/web/search.d.ts +21 -0
- package/lib/web/search.d.ts.map +1 -0
- package/lib/web/search.js +47 -0
- package/lib/web/search.js.map +1 -0
- package/package.json +84 -0
package/client/index.js
ADDED
|
@@ -0,0 +1,1998 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kenari settings section — browser half.
|
|
3
|
+
*
|
|
4
|
+
* This file is checked in already wrapped in the client module loader's
|
|
5
|
+
* factory format instead of being produced by a bundler: this package builds
|
|
6
|
+
* with plain `tsc`, and the loader only requires that
|
|
7
|
+
* `exports["./client"]` be a file that calls `window.__ModuleLoader__.load`.
|
|
8
|
+
* `scripts/check-client.mjs` runs on every build and executes this factory
|
|
9
|
+
* against a stub module table, so a change that breaks the format, the module
|
|
10
|
+
* requests, or a render path fails the build rather than the browser.
|
|
11
|
+
*
|
|
12
|
+
* Modules come from dsh's frozen platform table only: `react` plus
|
|
13
|
+
* `@deepseek-ai/dsh-client-ui-primitives` (the shell seeds both, so neither
|
|
14
|
+
* needs a `dsh.client.external` declaration). Using dsh's own primitives is
|
|
15
|
+
* what lets the picker below BE the Models page's dialog — same Modal chrome,
|
|
16
|
+
* same Button/Pill/Tag tokens — rather than a look-alike beside it.
|
|
17
|
+
*
|
|
18
|
+
* What deliberately is NOT here: wallet balance, spend totals, and live
|
|
19
|
+
* catalog prices. Those live behind the Host on endpoints with no Remote
|
|
20
|
+
* namespace, and this plugin does not add one — inventing a new Host API
|
|
21
|
+
* package would mean touching dsh beyond a bundle. The card therefore points
|
|
22
|
+
* at the tools that do have them (`kenari_billing`, `kenari_balance`,
|
|
23
|
+
* `kenari_list_models`) instead of showing a number it cannot refresh.
|
|
24
|
+
*
|
|
25
|
+
* The catalog itself (capability tags, plan coverage, every filter's facts)
|
|
26
|
+
* is computed Host-side and read through the plugin's own same-origin Fetch
|
|
27
|
+
* route: dsh's Connection service lets a Host plugin register `/api/...`
|
|
28
|
+
* routes, so the browser reads `/api/kenari.models` with the same session that
|
|
29
|
+
* already authenticates the Remote calls. The browser never parses Kenari's
|
|
30
|
+
* payloads itself, so the tags here and `kenari_list_models` cannot disagree.
|
|
31
|
+
*
|
|
32
|
+
* Entry points: the Kenari card's 模型目录 section renders two buttons that both
|
|
33
|
+
* mean "put a model in this route" — 添加模型 (native: append a blank row to type
|
|
34
|
+
* into) and 获取可用模型 (native: ask the provider what it has). For Kenari the
|
|
35
|
+
* catalog IS the source of truth, so both open this plugin's picker instead of
|
|
36
|
+
* two divergent native paths. dsh renders those buttons itself and offers no
|
|
37
|
+
* slot inside its dialog — adding fields there would mean editing dsh, which
|
|
38
|
+
* this plugin never does. What a plugin CAN own is the entry point, so
|
|
39
|
+
* `installEntryTakeover` recognizes those two labels in the card this bundle's
|
|
40
|
+
* slot renders into. Every branch fails open, so the native flow is still there
|
|
41
|
+
* wherever the takeover does not apply.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
window.__ModuleLoader__.load({
|
|
45
|
+
id: 'dsh-kenari-plugin',
|
|
46
|
+
factory: (require) => {
|
|
47
|
+
var module = { exports: {} }
|
|
48
|
+
var exports = module.exports
|
|
49
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
50
|
+
|
|
51
|
+
const React = require('react')
|
|
52
|
+
const { Modal, Button, Tag, Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
53
|
+
|
|
54
|
+
/** Must match the Host-side `KENARI_SETTINGS_NAMESPACE`. */
|
|
55
|
+
const NS = 'kenari'
|
|
56
|
+
|
|
57
|
+
/** Must match the Host-side `KENARI_MODEL_VIEW_PATH` (the Connection Fetch route). */
|
|
58
|
+
const MODEL_VIEW_PATH = '/api/kenari.models'
|
|
59
|
+
|
|
60
|
+
/** The pi-ai settings namespace that owns the Kenari route on the Models page. */
|
|
61
|
+
const PI_AI_NS = 'llm-pi-ai'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Capability filter dimensions. The Host view carries the same list in
|
|
65
|
+
* `tags`, which is authoritative — this is only the fallback used before
|
|
66
|
+
* the first response arrives.
|
|
67
|
+
*/
|
|
68
|
+
const CAPABILITY_TAGS = ['image', 'audio', 'video', 'pdf', 'embedding']
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* This bundle's own copy, in the two locales dsh ships.
|
|
72
|
+
*
|
|
73
|
+
* `zh` is the key-set source of truth and `en` must cover every key — dsh's
|
|
74
|
+
* own convention, and the build gate asserts it. Nothing user-visible is
|
|
75
|
+
* inlined in a component: a locale switch re-renders every slot outlet (the
|
|
76
|
+
* renderer subscribes each one to the locale revision), so a stray literal
|
|
77
|
+
* would be the one string that never follows the switch.
|
|
78
|
+
*
|
|
79
|
+
* Wording rules this copy follows, matching dsh's own settings pages: short
|
|
80
|
+
* noun phrases for headings, full sentences for explanations, no jargon a
|
|
81
|
+
* user would have to look up — no config-file keys, no schema terms, no
|
|
82
|
+
* "Host endpoint"/"namespace" talk. Someone who only wants Kenari as a model
|
|
83
|
+
* provider should be able to read every line here.
|
|
84
|
+
*/
|
|
85
|
+
const LOCALES = {
|
|
86
|
+
zh: {
|
|
87
|
+
nav: 'Kenari',
|
|
88
|
+
'page.title': 'Kenari',
|
|
89
|
+
'page.lead': '把 kenari.id 接成 dsh 的会话模型、网页搜索与抓取,以及一组 REST 工具。',
|
|
90
|
+
|
|
91
|
+
'connection.title': '密钥',
|
|
92
|
+
'connection.keyStatus': '密钥状态',
|
|
93
|
+
'connection.notice': '密钥本身在「模型」页的 Kenari 卡片里填。这里只显示它的状态和它存的名字,不会显示密钥本身。',
|
|
94
|
+
'connection.status.loading': '正在读取设置…',
|
|
95
|
+
'connection.status.unavailable': '当前部署的设置不可保存,改动只能在配置文件里做。',
|
|
96
|
+
|
|
97
|
+
'key.configured': '已配置',
|
|
98
|
+
'key.missing': '未配置',
|
|
99
|
+
'key.source': '来源 {source}',
|
|
100
|
+
'key.editable': '可修改',
|
|
101
|
+
'key.locked': '由启动环境提供,不可改',
|
|
102
|
+
'key.checking': '查询中…',
|
|
103
|
+
'key.checkFailed': '状态查询失败:{message}',
|
|
104
|
+
'key.missingRef': '没有这个引用',
|
|
105
|
+
|
|
106
|
+
'models.title': '模型',
|
|
107
|
+
'models.loading': '正在读取模型…',
|
|
108
|
+
'models.error': '模型列表读取失败:{message}',
|
|
109
|
+
'models.empty': '这个路由还没有模型。可以到「模型」页的 Kenari 卡片里添加,或重启 dsh 让内置模型生效。',
|
|
110
|
+
'models.summary': '路由 {route} 提供 {count} 个模型。',
|
|
111
|
+
'models.expandAll': '展开全部 {count} 个',
|
|
112
|
+
'models.routeEmpty': '空目录',
|
|
113
|
+
'models.noRoute': '没有可用的 Kenari 模型路由({failures})',
|
|
114
|
+
'ui.expand': '展开',
|
|
115
|
+
'ui.collapse': '收起',
|
|
116
|
+
'models.hint': '这里列出当前路由可用的模型。价格和完整目录(含向量、重排这类非会话模型)要看的话,在对话里问一句就行,比如「列出 Kenari 的模型和价格」。',
|
|
117
|
+
'models.colId': '模型 ID',
|
|
118
|
+
'models.colName': '显示名称',
|
|
119
|
+
'models.colContext': '上下文',
|
|
120
|
+
|
|
121
|
+
'sessionTitle.title': '会话标题',
|
|
122
|
+
'sessionTitle.prefix': '新会话标题加时间前缀',
|
|
123
|
+
'sessionTitle.prefix.hint': '开启后新会话的标题会带上创建时间,方便按时间排序;已有会话不受影响。',
|
|
124
|
+
|
|
125
|
+
'advanced.title': '高级设置',
|
|
126
|
+
'advanced.hint': '这些参数多数只需要设置一次,改完下一次操作即生效。',
|
|
127
|
+
|
|
128
|
+
'field.baseURL': 'API 地址',
|
|
129
|
+
'field.baseURL.hint': '插件调用 Kenari API 用的地址,默认 https://kenari.id;写成 …/v1 也可以,插件自己归一。会话模型用的是「模型」页那张卡里的地址,两者互不影响。',
|
|
130
|
+
'field.apiKeyEnv': '密钥引用名',
|
|
131
|
+
'field.apiKeyEnv.hint': '密钥保存在这个名字下,与「模型」页那张卡是同一个。改它会让会话模型和工具指向不同的密钥,所以这里只读。',
|
|
132
|
+
'field.timeoutMs': '请求超时(毫秒)',
|
|
133
|
+
'field.generationTimeoutMs': '生成类超时(毫秒)',
|
|
134
|
+
'field.generationTimeoutMs.hint': '图像、语音、OCR 这类按次计费的调用。超时不会自动重试,避免重复扣费。',
|
|
135
|
+
'field.maxRetries': '失败重试次数',
|
|
136
|
+
'field.maxRetries.hint': '只对限流、超时和网络错误生效。',
|
|
137
|
+
'field.catalogCacheTtlMs': '模型目录缓存(毫秒)',
|
|
138
|
+
'field.docsCacheTtlMs': '文档缓存(毫秒)',
|
|
139
|
+
'field.balanceCacheTtlMs': '余额缓存(毫秒)',
|
|
140
|
+
'field.lowBalanceAlertRp': '余额提醒阈值(Rp)',
|
|
141
|
+
'field.lowBalanceAlertRp.hint': '余额低于这个数时,每次花费后会提醒一次;填 0 关闭。',
|
|
142
|
+
'field.sessionTitlePrefix': '前缀格式',
|
|
143
|
+
'field.sessionTitlePrefix.hint': '默认 yyyyMMddHHmmss-。yyyy 年、MM 月、dd 日、HH 时、mm 分、ss 秒,其余字符原样保留。',
|
|
144
|
+
'field.sessionTitleMaxBytes': '标题长度上限(字节)',
|
|
145
|
+
'field.sessionTitleMaxBytes.hint': '前缀加正文的总长度,超出会被截断,默认 96。',
|
|
146
|
+
'field.overridden': '已自定义',
|
|
147
|
+
'field.saved': '已保存',
|
|
148
|
+
'field.invalidNumber': '请填数字',
|
|
149
|
+
'field.on': '已开启',
|
|
150
|
+
'field.off': '已关闭',
|
|
151
|
+
|
|
152
|
+
'restart.title': '重启后生效',
|
|
153
|
+
'restart.hint': '这些开关在插件加载时决定,改完要重启 dsh 才会生效。它们保存在设置里,可以用右上角的「打开配置文件」编辑。',
|
|
154
|
+
'restart.search': '网页搜索',
|
|
155
|
+
'restart.fetch': '网页抓取',
|
|
156
|
+
'restart.fallback': '搜索或抓取失败时改用 dsh 自带方式',
|
|
157
|
+
'restart.tools': 'REST 工具',
|
|
158
|
+
'restart.budget': '会话预算封顶(Rp)',
|
|
159
|
+
'restart.budget.hint': '填 0 表示不封顶。',
|
|
160
|
+
'restart.nativeAdapter': '使用插件自带的模型适配器',
|
|
161
|
+
'restart.nativeProviderId': '适配器的路由名',
|
|
162
|
+
|
|
163
|
+
'usage.title': '余额与用量',
|
|
164
|
+
'usage.hint': '这些数字随用量变化,所以这里不显示会过期的快照。在对话里问一句,dsh 会替你查最新值:',
|
|
165
|
+
'usage.what': '想看什么',
|
|
166
|
+
'usage.ask': '在对话里这么问',
|
|
167
|
+
'usage.balance': '账户余额与近期用量',
|
|
168
|
+
'usage.balance.ask': '我的 Kenari 余额还剩多少?',
|
|
169
|
+
'usage.billing': '本次会话的花费与预算',
|
|
170
|
+
'usage.billing.ask': '这个会话花了多少钱,还在预算内吗?',
|
|
171
|
+
'usage.catalog': '模型目录、上下文长度与实时价格',
|
|
172
|
+
'usage.catalog.ask': '列出 Kenari 支持图片的模型和它们的价格',
|
|
173
|
+
'usage.tokens': '输入 token 与窗口占用',
|
|
174
|
+
'usage.tokens.ask': '这个会话的上下文用了多少?',
|
|
175
|
+
|
|
176
|
+
'catalog.title': '选择要添加的模型',
|
|
177
|
+
'catalog.description': '按能力与套餐筛选,勾选后加入 kenari 路由。',
|
|
178
|
+
'catalog.close': '关闭',
|
|
179
|
+
'catalog.cancel': '取消',
|
|
180
|
+
'catalog.searchPlaceholder': '搜索 id / 名称',
|
|
181
|
+
'catalog.searchLabel': '搜索模型',
|
|
182
|
+
'catalog.clear': '清除筛选',
|
|
183
|
+
'catalog.selectAll': '全选可见',
|
|
184
|
+
'catalog.clearAll': '取消全选',
|
|
185
|
+
'catalog.loading': '正在读取模型目录…',
|
|
186
|
+
'catalog.error': '模型目录读取失败:{message}',
|
|
187
|
+
'catalog.fallback': '改用 dsh 自带对话框',
|
|
188
|
+
'catalog.readonly': '设置只读,不能写入。',
|
|
189
|
+
'catalog.settingsReadOnly': '设置只读,改动只能在配置文件里做。',
|
|
190
|
+
'catalog.noNamespace': '设置里没有 {ns} 这一节,先让插件把预设写进去。',
|
|
191
|
+
'catalog.plansError': '套餐表读取失败,「套餐内」标签与筛选这次不可用:{message}',
|
|
192
|
+
'catalog.empty': '当前筛选下没有模型。',
|
|
193
|
+
'catalog.summary': '显示 {visible} / {total} 个,待加入 {addable} 个。「套餐内」表示这个付费模型由订阅套餐覆盖,否则走余额。',
|
|
194
|
+
'catalog.footerNote': '加入后立即生效',
|
|
195
|
+
'catalog.submit': '添加所选({count})',
|
|
196
|
+
'catalog.saving': '正在加入…',
|
|
197
|
+
'catalog.added': '已加入 {count} 个模型。模型页的列表要重新展开「编辑」才会刷新。',
|
|
198
|
+
'catalog.chatOnly': '(不支持会话)',
|
|
199
|
+
'catalog.inRoute': '已在路由',
|
|
200
|
+
|
|
201
|
+
'filter.plan': '套餐内',
|
|
202
|
+
'filter.free': '免费',
|
|
203
|
+
'tag.image': '图片',
|
|
204
|
+
'tag.audio': '音频',
|
|
205
|
+
'tag.video': '视频',
|
|
206
|
+
'tag.pdf': 'PDF',
|
|
207
|
+
'tag.embedding': '向量',
|
|
208
|
+
},
|
|
209
|
+
en: {
|
|
210
|
+
nav: 'Kenari',
|
|
211
|
+
'page.title': 'Kenari',
|
|
212
|
+
'page.lead': 'Kenari (kenari.id) as a session model provider, a web search and fetch backend, and a set of REST tools.',
|
|
213
|
+
|
|
214
|
+
'connection.title': 'API key',
|
|
215
|
+
'connection.keyStatus': 'Key status',
|
|
216
|
+
'connection.notice': 'The key itself is entered on the Models page, in the Kenari card. This panel only reports its state and the name it is stored under, never the key itself.',
|
|
217
|
+
'connection.status.loading': 'Reading settings…',
|
|
218
|
+
'connection.status.unavailable': 'Settings cannot be saved in this deployment; edit the configuration file instead.',
|
|
219
|
+
|
|
220
|
+
'key.configured': 'Configured',
|
|
221
|
+
'key.missing': 'Missing',
|
|
222
|
+
'key.source': 'From {source}',
|
|
223
|
+
'key.editable': 'Editable',
|
|
224
|
+
'key.locked': 'Provided by the launch environment, cannot be changed',
|
|
225
|
+
'key.checking': 'Checking…',
|
|
226
|
+
'key.checkFailed': 'Could not read the key status: {message}',
|
|
227
|
+
'key.missingRef': 'No such reference',
|
|
228
|
+
|
|
229
|
+
'models.title': 'Models',
|
|
230
|
+
'models.loading': 'Reading models…',
|
|
231
|
+
'models.error': 'Could not read the model list: {message}',
|
|
232
|
+
'models.empty': 'This route has no models yet. Add some on the Models page under the Kenari card, or restart dsh to load the built-in ones.',
|
|
233
|
+
'models.summary': 'Route {route} offers {count} models.',
|
|
234
|
+
'models.expandAll': 'Show all {count}',
|
|
235
|
+
'models.routeEmpty': 'empty catalog',
|
|
236
|
+
'models.noRoute': 'No usable Kenari model route ({failures})',
|
|
237
|
+
'ui.expand': 'Show',
|
|
238
|
+
'ui.collapse': 'Hide',
|
|
239
|
+
'models.hint': 'These are the models this route can use right now. To see pricing and the full catalog — including embedding and rerank models — ask in the chat, for example "list the Kenari models and their prices".',
|
|
240
|
+
'models.colId': 'Model ID',
|
|
241
|
+
'models.colName': 'Display name',
|
|
242
|
+
'models.colContext': 'Context',
|
|
243
|
+
|
|
244
|
+
'sessionTitle.title': 'Session titles',
|
|
245
|
+
'sessionTitle.prefix': 'Prefix new session titles with the time',
|
|
246
|
+
'sessionTitle.prefix.hint': 'New sessions get their creation time in the title, which keeps them sorted by time. Existing sessions are left alone.',
|
|
247
|
+
|
|
248
|
+
'advanced.title': 'Advanced',
|
|
249
|
+
'advanced.hint': 'Most of these are set once. Changes apply to the next operation.',
|
|
250
|
+
|
|
251
|
+
'field.baseURL': 'API address',
|
|
252
|
+
'field.baseURL.hint': 'The address the plugin calls for Kenari API requests. https://kenari.id by default; a trailing /v1 is accepted and normalized. Session models use the address on the Models page card instead — the two are independent.',
|
|
253
|
+
'field.apiKeyEnv': 'Key reference',
|
|
254
|
+
'field.apiKeyEnv.hint': 'The name the key is stored under, the same one the Models page card uses. Changing it would point the session models and the tools at different keys, so it is read-only here.',
|
|
255
|
+
'field.timeoutMs': 'Request timeout (ms)',
|
|
256
|
+
'field.generationTimeoutMs': 'Generation timeout (ms)',
|
|
257
|
+
'field.generationTimeoutMs.hint': 'For per-call billed work such as images, speech and OCR. A timeout is never retried, so a slow call cannot be paid for twice.',
|
|
258
|
+
'field.maxRetries': 'Retries on failure',
|
|
259
|
+
'field.maxRetries.hint': 'Applies to rate limits, timeouts and network errors only.',
|
|
260
|
+
'field.catalogCacheTtlMs': 'Model catalog cache (ms)',
|
|
261
|
+
'field.docsCacheTtlMs': 'Documentation cache (ms)',
|
|
262
|
+
'field.balanceCacheTtlMs': 'Balance cache (ms)',
|
|
263
|
+
'field.lowBalanceAlertRp': 'Low balance alert (Rp)',
|
|
264
|
+
'field.lowBalanceAlertRp.hint': 'After a billed call, warn once when the balance falls below this. Set 0 to turn the warning off.',
|
|
265
|
+
'field.sessionTitlePrefix': 'Prefix format',
|
|
266
|
+
'field.sessionTitlePrefix.hint': 'yyyyMMddHHmmss- by default. yyyy year, MM month, dd day, HH hour, mm minute, ss second; anything else is kept as typed.',
|
|
267
|
+
'field.sessionTitleMaxBytes': 'Title length cap (bytes)',
|
|
268
|
+
'field.sessionTitleMaxBytes.hint': 'Prefix plus title, in bytes. Anything longer is truncated. 96 by default.',
|
|
269
|
+
'field.overridden': 'Customized',
|
|
270
|
+
'field.saved': 'Saved',
|
|
271
|
+
'field.invalidNumber': 'Enter a number',
|
|
272
|
+
'field.on': 'On',
|
|
273
|
+
'field.off': 'Off',
|
|
274
|
+
|
|
275
|
+
'restart.title': 'Applies after a restart',
|
|
276
|
+
'restart.hint': 'These switches are decided when the plugin loads, so changing them needs a dsh restart. They live in your settings, editable through "Open configuration file" at the top right.',
|
|
277
|
+
'restart.search': 'Web search',
|
|
278
|
+
'restart.fetch': 'Web fetch',
|
|
279
|
+
'restart.fallback': 'Fall back to the built-in way when search or fetch fails',
|
|
280
|
+
'restart.tools': 'REST tools',
|
|
281
|
+
'restart.budget': 'Session budget cap (Rp)',
|
|
282
|
+
'restart.budget.hint': 'Set 0 for no cap.',
|
|
283
|
+
'restart.nativeAdapter': 'Use the bundled model adapter',
|
|
284
|
+
'restart.nativeProviderId': 'Adapter route name',
|
|
285
|
+
|
|
286
|
+
'usage.title': 'Balance and usage',
|
|
287
|
+
'usage.hint': 'These numbers move with usage, so no snapshot is shown here that would go stale. Ask in the chat and dsh looks up the current value:',
|
|
288
|
+
'usage.what': 'What you want',
|
|
289
|
+
'usage.ask': 'Ask in the chat',
|
|
290
|
+
'usage.balance': 'Account balance and recent usage',
|
|
291
|
+
'usage.balance.ask': 'How much Kenari balance do I have left?',
|
|
292
|
+
'usage.billing': 'Spend and budget for this session',
|
|
293
|
+
'usage.billing.ask': 'How much has this session cost, and am I still within budget?',
|
|
294
|
+
'usage.catalog': 'Model catalog, context lengths and live pricing',
|
|
295
|
+
'usage.catalog.ask': 'List the Kenari models that take images, with their prices',
|
|
296
|
+
'usage.tokens': 'Input tokens and context usage',
|
|
297
|
+
'usage.tokens.ask': 'How much of my context window is this session using?',
|
|
298
|
+
|
|
299
|
+
'catalog.title': 'Choose models to add',
|
|
300
|
+
'catalog.description': 'Filter by capability and plan, then pick the ones to write into the kenari route.',
|
|
301
|
+
'catalog.close': 'Close',
|
|
302
|
+
'catalog.cancel': 'Cancel',
|
|
303
|
+
'catalog.searchPlaceholder': 'Search id or name',
|
|
304
|
+
'catalog.searchLabel': 'Search models',
|
|
305
|
+
'catalog.clear': 'Clear filters',
|
|
306
|
+
'catalog.selectAll': 'Select all shown',
|
|
307
|
+
'catalog.clearAll': 'Clear selection',
|
|
308
|
+
'catalog.loading': 'Reading the model catalog…',
|
|
309
|
+
'catalog.error': 'Could not read the model catalog: {message}',
|
|
310
|
+
'catalog.fallback': 'Use the built-in dialog instead',
|
|
311
|
+
'catalog.readonly': 'Settings are read-only; nothing can be written.',
|
|
312
|
+
'catalog.settingsReadOnly': 'Settings are read-only; changes have to go in the configuration file.',
|
|
313
|
+
'catalog.noNamespace': 'Your settings have no {ns} section yet. Let the plugin write its preset first.',
|
|
314
|
+
'catalog.plansError': 'Could not read the plan table, so the "In plan" tag and filter are unavailable this time: {message}',
|
|
315
|
+
'catalog.empty': 'No model matches these filters.',
|
|
316
|
+
'catalog.summary': 'Showing {visible} of {total}, {addable} ready to add. "In plan" means a subscription covers this paid model; otherwise it is billed to your balance.',
|
|
317
|
+
'catalog.footerNote': 'Applies immediately',
|
|
318
|
+
'catalog.submit': 'Add selected ({count})',
|
|
319
|
+
'catalog.saving': 'Adding…',
|
|
320
|
+
'catalog.added': 'Added {count} models. The list on the Models page refreshes once you reopen Edit.',
|
|
321
|
+
'catalog.chatOnly': '(not a chat model)',
|
|
322
|
+
'catalog.inRoute': 'Already in the route',
|
|
323
|
+
|
|
324
|
+
'filter.plan': 'In plan',
|
|
325
|
+
'filter.free': 'Free',
|
|
326
|
+
'tag.image': 'image',
|
|
327
|
+
'tag.audio': 'audio',
|
|
328
|
+
'tag.video': 'video',
|
|
329
|
+
'tag.pdf': 'PDF',
|
|
330
|
+
'tag.embedding': 'embedding',
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The translator, bound once per activation.
|
|
336
|
+
*
|
|
337
|
+
* Bound from the locale service rather than taken from props: `bind` returns
|
|
338
|
+
* an identity-stable function that reads the active locale at call time, so
|
|
339
|
+
* this bundle never has to thread `t` through six components. The outlets
|
|
340
|
+
* re-render on every locale switch (the renderer subscribes each one to the
|
|
341
|
+
* locale revision), and that re-render is what re-reads these strings.
|
|
342
|
+
*/
|
|
343
|
+
let t = (key) => key
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Marks the Kenari card in the DOM. The Models-page component renders it
|
|
347
|
+
* into the same card element dsh renders that card's editor into, which is
|
|
348
|
+
* what lets the click takeover tell "the 模型目录 buttons on the Kenari
|
|
349
|
+
* card" from the identical buttons every other provider's editor renders.
|
|
350
|
+
*/
|
|
351
|
+
const MARKER_ATTR = 'data-kenari-model-picker'
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The labels of the two buttons in that card's 模型目录 section, in the
|
|
355
|
+
* locales this build ships. Text is the only stable identity those buttons
|
|
356
|
+
* have: their class is a CSS-module hash, and their position among their
|
|
357
|
+
* siblings varies with whether the 重置模型目录 link is rendered beside
|
|
358
|
+
* them. 添加模型 appends a blank row natively and 获取可用模型 fetches;
|
|
359
|
+
* both are taken over because for Kenari the catalog decides what exists.
|
|
360
|
+
*/
|
|
361
|
+
const TAKEOVER_LABELS = ['添加模型', 'Add model', '获取可用模型', 'Fetch available models']
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Whether a button's label (already trimmed) belongs to this takeover. An
|
|
365
|
+
* exact match, never a prefix: the Models page carries other 添加… buttons
|
|
366
|
+
* whose native meaning has nothing to do with this card's model list.
|
|
367
|
+
*/
|
|
368
|
+
function isTakeoverLabel(label) {
|
|
369
|
+
return TAKEOVER_LABELS.indexOf(label) !== -1
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The picker's own width.
|
|
374
|
+
*
|
|
375
|
+
* `Modal`'s card is `width: min(380px, 100%)` — sized for dsh's own list of
|
|
376
|
+
* bare ids — while a row here carries an id plus up to five tags, and the
|
|
377
|
+
* toolbar seven filter chips. `Modal` accepts only a class (its `className`
|
|
378
|
+
* merges into the base card class and no style prop is forwarded), so the
|
|
379
|
+
* width rides an injected rule; the `[role="dialog"]` qualifier is what beats
|
|
380
|
+
* the base rule's specificity without `!important`.
|
|
381
|
+
*/
|
|
382
|
+
const DIALOG_CLASS = 'kenari-catalog-dialog'
|
|
383
|
+
|
|
384
|
+
/** Inject the picker's width rule once, at activation, so the first paint already has it. */
|
|
385
|
+
function ensureDialogWidth() {
|
|
386
|
+
if (document.querySelector(`style[data-${DIALOG_CLASS}]`) !== null) return
|
|
387
|
+
const style = document.createElement('style')
|
|
388
|
+
style.setAttribute(`data-${DIALOG_CLASS}`, '')
|
|
389
|
+
style.textContent = `.${DIALOG_CLASS}[role="dialog"]{width:min(820px,92vw);max-width:92vw}`
|
|
390
|
+
document.head.appendChild(style)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Set-once and diagnostic parameters; folded away until asked for.
|
|
396
|
+
*
|
|
397
|
+
* `baseURL` lives here rather than in a user-facing group because it is not
|
|
398
|
+
* a user setting: the address defaults to Kenari's one public endpoint. It
|
|
399
|
+
* used to sit in an open 「连接」 group beside the Models page's own Kenari
|
|
400
|
+
* card, which showed a *different* address (the model wire endpoint) — two
|
|
401
|
+
* panels that looked like one thing, in two places, disagreeing.
|
|
402
|
+
*/
|
|
403
|
+
const ADVANCED_FIELDS = [
|
|
404
|
+
{ field: 'baseURL', labelKey: 'field.baseURL', hintKey: 'field.baseURL.hint', kind: 'string' },
|
|
405
|
+
{ field: 'timeoutMs', labelKey: 'field.timeoutMs', kind: 'number' },
|
|
406
|
+
{ field: 'generationTimeoutMs', labelKey: 'field.generationTimeoutMs', hintKey: 'field.generationTimeoutMs.hint', kind: 'number' },
|
|
407
|
+
{ field: 'maxRetries', labelKey: 'field.maxRetries', hintKey: 'field.maxRetries.hint', kind: 'number' },
|
|
408
|
+
{ field: 'catalogCacheTtlMs', labelKey: 'field.catalogCacheTtlMs', kind: 'number' },
|
|
409
|
+
{ field: 'docsCacheTtlMs', labelKey: 'field.docsCacheTtlMs', kind: 'number' },
|
|
410
|
+
{ field: 'balanceCacheTtlMs', labelKey: 'field.balanceCacheTtlMs', kind: 'number' },
|
|
411
|
+
{ field: 'lowBalanceAlertRp', labelKey: 'field.lowBalanceAlertRp', hintKey: 'field.lowBalanceAlertRp.hint', kind: 'number' },
|
|
412
|
+
]
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The name the key is stored under — read-only, and shown in the 密钥 group
|
|
416
|
+
* beside the key's status rather than behind a fold.
|
|
417
|
+
*
|
|
418
|
+
* It belongs with the key, not with the set-once parameters: "which key is
|
|
419
|
+
* this page talking about" and "which name is it stored under" are one
|
|
420
|
+
* question, and splitting them put half the answer in a collapsed group.
|
|
421
|
+
*
|
|
422
|
+
* Read-only because renaming it would leave the model route resolving the
|
|
423
|
+
* old reference (sessions fail with MISSING_CREDENTIAL) while the plugin's
|
|
424
|
+
* tools keep working — a split that is invisible until a session breaks.
|
|
425
|
+
*/
|
|
426
|
+
const KEY_REFERENCE_FIELD = { field: 'apiKeyEnv', labelKey: 'field.apiKeyEnv', hintKey: 'field.apiKeyEnv.hint', kind: 'readonly' }
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The session-title settings, all of them, in one group that is never folded.
|
|
430
|
+
*
|
|
431
|
+
* The switch, the format and the length cap are one feature: the switch says
|
|
432
|
+
* whether a prefix is written at all, and the other two say what it looks
|
|
433
|
+
* like. Splitting the format and the cap off into 高级设置 made the reader
|
|
434
|
+
* hunt in a second place for the other half of the thing they were editing.
|
|
435
|
+
*
|
|
436
|
+
* `sessionTitlePrefix` is part of the title text itself — dsh's title event
|
|
437
|
+
* carries no separate prefix field — so these fields only decide what gets
|
|
438
|
+
* written when a title is generated. They are not a display filter and they
|
|
439
|
+
* do not reach back into existing sessions.
|
|
440
|
+
*/
|
|
441
|
+
const SESSION_TITLE_FIELDS = [
|
|
442
|
+
{ field: 'sessionTitlePrefixEnabled', labelKey: 'sessionTitle.prefix', hintKey: 'sessionTitle.prefix.hint', kind: 'boolean' },
|
|
443
|
+
{ field: 'sessionTitlePrefix', labelKey: 'field.sessionTitlePrefix', hintKey: 'field.sessionTitlePrefix.hint', kind: 'string' },
|
|
444
|
+
{ field: 'sessionTitleMaxBytes', labelKey: 'field.sessionTitleMaxBytes', hintKey: 'field.sessionTitleMaxBytes.hint', kind: 'number' },
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The switches that are decided when the plugin loads. They are read-only
|
|
449
|
+
* here on purpose: a switch that does nothing until dsh restarts invites
|
|
450
|
+
* "I turned it off and nothing happened", so this block states the values
|
|
451
|
+
* and where to change them rather than offering a control that lies.
|
|
452
|
+
*/
|
|
453
|
+
const RESTART_FIELDS = [
|
|
454
|
+
{ field: 'searchEnabled', labelKey: 'restart.search' },
|
|
455
|
+
{ field: 'fetchEnabled', labelKey: 'restart.fetch' },
|
|
456
|
+
{ field: 'fallbackEnabled', labelKey: 'restart.fallback' },
|
|
457
|
+
{ field: 'toolsEnabled', labelKey: 'restart.tools' },
|
|
458
|
+
{ field: 'budgetCapRp', labelKey: 'restart.budget', hintKey: 'restart.budget.hint' },
|
|
459
|
+
{ field: 'nativeAdapterEnabled', labelKey: 'restart.nativeAdapter' },
|
|
460
|
+
{ field: 'nativeProviderId', labelKey: 'restart.nativeProviderId' },
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* What this page deliberately does not show, and how to ask for it instead.
|
|
465
|
+
*
|
|
466
|
+
* The second element is the question a reader can actually type. It replaced
|
|
467
|
+
* the tool name that used to sit there: the table was legible and useless,
|
|
468
|
+
* because a tool name is not something a user can call — the agent is. Both
|
|
469
|
+
* elements are locale keys, so the examples follow a language switch.
|
|
470
|
+
*/
|
|
471
|
+
const TOOL_ONLY_FACTS = [
|
|
472
|
+
{ what: 'usage.balance', ask: 'usage.balance.ask' },
|
|
473
|
+
{ what: 'usage.billing', ask: 'usage.billing.ask' },
|
|
474
|
+
{ what: 'usage.catalog', ask: 'usage.catalog.ask' },
|
|
475
|
+
{ what: 'usage.tokens', ask: 'usage.tokens.ask' },
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Every spec whose label and hint are locale keys. The build gate resolves
|
|
480
|
+
* each one against both dictionaries, which is the only way a renamed or
|
|
481
|
+
* misspelled key fails at build time instead of rendering as the raw key
|
|
482
|
+
* `field.timeoutMs` on the page.
|
|
483
|
+
*/
|
|
484
|
+
const LOCALE_SPECS = ADVANCED_FIELDS.concat([KEY_REFERENCE_FIELD], SESSION_TITLE_FIELDS, RESTART_FIELDS)
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The selection box every row starts with, in both of its forms: a real
|
|
488
|
+
* checkbox on a row you can add, and the ✓ marker on a row already in the
|
|
489
|
+
* route. A native checkbox carries browser margin and its own intrinsic
|
|
490
|
+
* size, so a plain span around the ✓ lined up with nothing — both forms get
|
|
491
|
+
* this exact box, which is what keeps the ✓ and the checkboxes in one column.
|
|
492
|
+
*/
|
|
493
|
+
const PICK_BOX = { width: '14px', height: '14px', flexShrink: 0, margin: 0, boxSizing: 'border-box' }
|
|
494
|
+
|
|
495
|
+
/** The one monospace stack this page uses, for the things that are ids. */
|
|
496
|
+
const MONO_FONT = 'ui-monospace, SFMono-Regular, Menlo, monospace'
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The one chip shape this page uses — the key-status badges and every
|
|
500
|
+
* read-only value wear it, so "value" is a shape a reader learns once. Only
|
|
501
|
+
* the shape is shared: the value chip is set a step larger and at full
|
|
502
|
+
* strength, because it carries the payload while the label is its caption.
|
|
503
|
+
*/
|
|
504
|
+
const CHIP = { display: 'inline-block', padding: '1px 7px', borderRadius: '999px', border: '1px solid rgba(127,127,127,0.4)' }
|
|
505
|
+
|
|
506
|
+
const styles = {
|
|
507
|
+
root: { display: 'flex', flexDirection: 'column', gap: '18px', fontSize: '13px', lineHeight: 1.6 },
|
|
508
|
+
title: { margin: 0, fontSize: '15px', fontWeight: 600 },
|
|
509
|
+
lead: { margin: 0, opacity: 0.75 },
|
|
510
|
+
block: { border: '1px solid rgba(127,127,127,0.28)', borderRadius: '8px', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '10px' },
|
|
511
|
+
blockTitle: { margin: 0, fontSize: '13px', fontWeight: 600 },
|
|
512
|
+
// A foldable block head: the whole row is the hit target, and the state
|
|
513
|
+
// word sits at the far end so it can be found without hunting for a caret.
|
|
514
|
+
disclosure: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', width: '100%', padding: 0, border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', textAlign: 'left' },
|
|
515
|
+
disclosureMark: { opacity: 0.6, fontSize: '12px', whiteSpace: 'nowrap' },
|
|
516
|
+
row: { display: 'grid', gridTemplateColumns: 'minmax(160px, 260px) 1fr', gap: '8px 12px', alignItems: 'center' },
|
|
517
|
+
label: { opacity: 0.85 },
|
|
518
|
+
hint: { gridColumn: '2 / 3', opacity: 0.6, fontSize: '12px', marginTop: '-4px' },
|
|
519
|
+
input: { width: '100%', boxSizing: 'border-box', padding: '4px 8px', border: '1px solid rgba(127,127,127,0.4)', borderRadius: '6px', background: 'transparent', color: 'inherit', font: 'inherit' },
|
|
520
|
+
checkboxRow: { display: 'flex', alignItems: 'center', gap: '8px' },
|
|
521
|
+
pickBox: PICK_BOX,
|
|
522
|
+
pickMark: { ...PICK_BOX, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px', lineHeight: 1, opacity: 0.75 },
|
|
523
|
+
mono: { fontFamily: MONO_FONT },
|
|
524
|
+
badge: { ...CHIP, fontSize: '11px', opacity: 0.85 },
|
|
525
|
+
// A read-only value, deliberately NOT the label's plain text: the two used
|
|
526
|
+
// to sit one above the other in the same 13px tone, so "网页搜索" and
|
|
527
|
+
// "已开启" read as two labels. The chip gives the value its own shape, and
|
|
528
|
+
// the monospace keeps `KENARI_API_KEY` / `kenari-direct` legible as ids.
|
|
529
|
+
value: { ...CHIP, fontSize: '12px', fontFamily: MONO_FONT },
|
|
530
|
+
table: { width: '100%', borderCollapse: 'collapse' },
|
|
531
|
+
cell: { textAlign: 'left', padding: '3px 6px', borderBottom: '1px solid rgba(127,127,127,0.18)', verticalAlign: 'top' },
|
|
532
|
+
th: { fontWeight: 600, opacity: 0.6, fontSize: '12px' },
|
|
533
|
+
notice: { margin: 0, opacity: 0.7, fontSize: '12px' },
|
|
534
|
+
error: { margin: 0, color: '#c0392b', fontSize: '12px' },
|
|
535
|
+
toolbar: { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' },
|
|
536
|
+
filterRow: { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' },
|
|
537
|
+
// Filter chips are buttons, but they wear the tag vocabulary: a capsule
|
|
538
|
+
// outlined in BOTH states, where selecting only fills it and raises the
|
|
539
|
+
// weight. dsh's `Pill` is the wrong atom here — it drops the outline when
|
|
540
|
+
// idle, and its active fill is nearly the dialog's own background, so a
|
|
541
|
+
// selected filter read as plain text instead of as a chip. Tokens keep
|
|
542
|
+
// both themes honest.
|
|
543
|
+
filterChip: { display: 'inline-flex', alignItems: 'center', height: '24px', padding: '0 10px', borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l4)', background: 'transparent', color: 'var(--dsw-alias-label-secondary)', font: 'inherit', fontSize: '12px', cursor: 'pointer' },
|
|
544
|
+
filterChipOn: { display: 'inline-flex', alignItems: 'center', height: '24px', padding: '0 10px', borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l4)', background: 'var(--dsw-alias-bg-module-platform)', color: 'var(--dsw-alias-label-primary)', font: 'inherit', fontSize: '12px', fontWeight: 600, cursor: 'pointer' },
|
|
545
|
+
// The search box is the one control dsh's Input atom cannot carry here:
|
|
546
|
+
// that atom wraps the field in a fixed-height inline-flex box whose width
|
|
547
|
+
// comes from a class this bundle cannot pass, so the field would size to
|
|
548
|
+
// its placeholder. Styling the bare input with dsh's own tokens keeps the
|
|
549
|
+
// look while leaving `flex` — the thing this layout needs — to inline style.
|
|
550
|
+
search: { flex: '1 1 200px', minWidth: '140px', boxSizing: 'border-box', height: '32px', padding: '0 8px', border: '0.5px solid var(--dsw-alias-border-l4)', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)', font: 'inherit', fontSize: '14px' },
|
|
551
|
+
// The list scrolls inside the dialog: the picker must never push its own
|
|
552
|
+
// footer off screen, because that footer is where 添加所选 lives.
|
|
553
|
+
// Rows breathe: a row is a hit target and a fact set at once, and at the
|
|
554
|
+
// density of a pure data grid the tags above and below each other read as
|
|
555
|
+
// one block. The inner gap separates wrapped tag lines specifically.
|
|
556
|
+
list: { maxHeight: 'min(52vh, 420px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '4px', border: '0.5px solid var(--dsw-alias-border-l4)', borderRadius: '8px', padding: '8px' },
|
|
557
|
+
// The row is one non-wrapping band: checkbox, id, then the tag column.
|
|
558
|
+
// `alignItems: center` therefore centers the id against the tag block,
|
|
559
|
+
// and the tags wrap inside their own column instead of restarting at the
|
|
560
|
+
// row's left edge.
|
|
561
|
+
item: { display: 'flex', flexWrap: 'nowrap', gap: '10px', alignItems: 'center', padding: '7px 8px', cursor: 'default' },
|
|
562
|
+
itemId: { fontFamily: MONO_FONT, whiteSpace: 'nowrap', flexShrink: 0 },
|
|
563
|
+
tagColumn: { display: 'flex', flexWrap: 'wrap', gap: '4px 8px', alignItems: 'center', flex: '1 1 auto', minWidth: 0 },
|
|
564
|
+
// The body stacks four things that answer different questions (what can I
|
|
565
|
+
// filter, how many are showing, the rows, what just happened); a gap keeps
|
|
566
|
+
// them from reading as one paragraph of controls.
|
|
567
|
+
body: { display: 'flex', flexDirection: 'column', gap: '14px' },
|
|
568
|
+
dim: { opacity: 0.45, fontSize: '12px', whiteSpace: 'nowrap', flexShrink: 0 },
|
|
569
|
+
footerNote: { marginRight: 'auto', opacity: 0.7, fontSize: '12px' },
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Subscribe to one settings scope snapshot (stable handles for React). */
|
|
573
|
+
function useScopeSnapshot(scope) {
|
|
574
|
+
const subscribe = React.useCallback((onChange) => scope.subscribe(onChange), [scope])
|
|
575
|
+
const getSnapshot = React.useCallback(() => scope.getSnapshot(), [scope])
|
|
576
|
+
return React.useSyncExternalStore(subscribe, getSnapshot)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* One editable scalar. The draft is local until commit (blur or Enter), so a
|
|
581
|
+
* half-typed number never lands in the settings document.
|
|
582
|
+
*/
|
|
583
|
+
/**
|
|
584
|
+
* One live field. The Host reads these live, so an edit applies at the next
|
|
585
|
+
* operation; the badge says saved and promises nothing about timing.
|
|
586
|
+
*/
|
|
587
|
+
function LiveField(props) {
|
|
588
|
+
const { spec, committed, overridden, writable, onWrite } = props
|
|
589
|
+
const label = t(spec.labelKey)
|
|
590
|
+
const hint = spec.hintKey === undefined ? undefined : t(spec.hintKey)
|
|
591
|
+
const [draft, setDraft] = React.useState(String(committed ?? ''))
|
|
592
|
+
const [status, setStatus] = React.useState('idle')
|
|
593
|
+
React.useEffect(() => {
|
|
594
|
+
setDraft(String(committed ?? ''))
|
|
595
|
+
}, [committed])
|
|
596
|
+
|
|
597
|
+
// A boolean is a switch, so the click IS the write and what it writes is
|
|
598
|
+
// the requested state — there is no draft, and therefore no blur or Enter
|
|
599
|
+
// step to reach for.
|
|
600
|
+
if (spec.kind === 'boolean') {
|
|
601
|
+
const checked = committed === true
|
|
602
|
+
const toggle = (next) => {
|
|
603
|
+
if (next === checked) return
|
|
604
|
+
setStatus('saving')
|
|
605
|
+
onWrite(spec.field, next).then(
|
|
606
|
+
() => setStatus('saved'),
|
|
607
|
+
(err) => setStatus(`error:${String(err && err.message ? err.message : err)}`),
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
return React.createElement(
|
|
611
|
+
React.Fragment,
|
|
612
|
+
null,
|
|
613
|
+
React.createElement(
|
|
614
|
+
'div',
|
|
615
|
+
{ style: styles.label },
|
|
616
|
+
label,
|
|
617
|
+
overridden ? React.createElement('span', { style: { ...styles.badge, marginLeft: '8px' } }, t('field.overridden')) : null,
|
|
618
|
+
status === 'saved' ? React.createElement('span', { style: { ...styles.badge, marginLeft: '8px' } }, t('field.saved')) : null,
|
|
619
|
+
typeof status === 'string' && status.startsWith('error:') ? React.createElement('span', { style: { ...styles.error, marginLeft: '8px' } }, status.slice(6)) : null,
|
|
620
|
+
),
|
|
621
|
+
React.createElement(
|
|
622
|
+
'div',
|
|
623
|
+
{ style: styles.checkboxRow },
|
|
624
|
+
React.createElement(Switch, {
|
|
625
|
+
checked,
|
|
626
|
+
label,
|
|
627
|
+
disabled: !writable || status === 'saving',
|
|
628
|
+
onChange: toggle,
|
|
629
|
+
}),
|
|
630
|
+
React.createElement('span', { style: styles.dim }, checked ? t('field.on') : t('field.off')),
|
|
631
|
+
),
|
|
632
|
+
hint === undefined ? null : React.createElement('div', { style: styles.hint }, hint),
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const commit = () => {
|
|
637
|
+
const next = spec.kind === 'number' ? Number(draft) : draft
|
|
638
|
+
if (spec.kind === 'number' && !Number.isFinite(next)) {
|
|
639
|
+
setStatus('invalid')
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
if (next === committed) {
|
|
643
|
+
setStatus('idle')
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
setStatus('saving')
|
|
647
|
+
onWrite(spec.field, next).then(
|
|
648
|
+
() => setStatus('saved'),
|
|
649
|
+
(err) => setStatus(`error:${String(err && err.message ? err.message : err)}`),
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return React.createElement(
|
|
654
|
+
React.Fragment,
|
|
655
|
+
null,
|
|
656
|
+
React.createElement(
|
|
657
|
+
'div',
|
|
658
|
+
{ style: styles.label },
|
|
659
|
+
label,
|
|
660
|
+
overridden ? React.createElement('span', { style: { ...styles.badge, marginLeft: '8px' } }, t('field.overridden')) : null,
|
|
661
|
+
status === 'saved' ? React.createElement('span', { style: { ...styles.badge, marginLeft: '8px' } }, t('field.saved')) : null,
|
|
662
|
+
status === 'invalid' ? React.createElement('span', { style: { ...styles.error, marginLeft: '8px' } }, t('field.invalidNumber')) : null,
|
|
663
|
+
typeof status === 'string' && status.startsWith('error:') ? React.createElement('span', { style: { ...styles.error, marginLeft: '8px' } }, status.slice(6)) : null,
|
|
664
|
+
),
|
|
665
|
+
React.createElement('input', {
|
|
666
|
+
style: styles.input,
|
|
667
|
+
type: spec.kind === 'number' ? 'number' : 'text',
|
|
668
|
+
value: draft,
|
|
669
|
+
disabled: !writable || status === 'saving',
|
|
670
|
+
onChange: (event) => setDraft(event.target.value),
|
|
671
|
+
onBlur: commit,
|
|
672
|
+
onKeyDown: (event) => {
|
|
673
|
+
if (event.key === 'Enter') commit()
|
|
674
|
+
},
|
|
675
|
+
}),
|
|
676
|
+
hint === undefined ? null : React.createElement('div', { style: styles.hint }, hint),
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* One read-only value with its label: a load-time switch, or a setting that
|
|
682
|
+
* is deliberately not editable from this page.
|
|
683
|
+
*
|
|
684
|
+
* Label left, value right, on one line — the same two-column grid the key
|
|
685
|
+
* status row above uses. The value wears a pill (see `styles.value`), which
|
|
686
|
+
* is the whole point: stacked as two plain lines the label and the value
|
|
687
|
+
* were indistinguishable, and a value that does not look like a value reads
|
|
688
|
+
* as a second label.
|
|
689
|
+
*
|
|
690
|
+
* No per-row "restart required" badge — the block states that once, and seven
|
|
691
|
+
* identical badges only train the reader to skip them. A boolean reads as
|
|
692
|
+
* on/off rather than as `true`/`false`, because nothing here is a config file.
|
|
693
|
+
*/
|
|
694
|
+
function ReadOnlyRow(props) {
|
|
695
|
+
const { spec, value } = props
|
|
696
|
+
const shown = typeof value === 'boolean' ? (value ? t('field.on') : t('field.off')) : String(value)
|
|
697
|
+
return React.createElement(
|
|
698
|
+
'div',
|
|
699
|
+
{ style: styles.row },
|
|
700
|
+
React.createElement('div', { style: styles.label }, t(spec.labelKey)),
|
|
701
|
+
React.createElement('div', null, React.createElement('span', { style: styles.value }, shown)),
|
|
702
|
+
spec.hintKey === undefined ? null : React.createElement('div', { style: styles.hint }, t(spec.hintKey)),
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* A block whose body folds away, used for the parameters most readers never
|
|
708
|
+
* touch. The whole heading row is the hit target — a fold that can only be
|
|
709
|
+
* opened by hitting a 12px caret is a fold nobody opens — and `aria-expanded`
|
|
710
|
+
* is what makes that row read as a control rather than as a label.
|
|
711
|
+
*/
|
|
712
|
+
function Disclosure(props) {
|
|
713
|
+
const { title, hint, children, defaultOpen } = props
|
|
714
|
+
const [open, setOpen] = React.useState(defaultOpen === true)
|
|
715
|
+
return React.createElement(
|
|
716
|
+
'div',
|
|
717
|
+
{ style: styles.block },
|
|
718
|
+
React.createElement(
|
|
719
|
+
'button',
|
|
720
|
+
{
|
|
721
|
+
type: 'button',
|
|
722
|
+
style: styles.disclosure,
|
|
723
|
+
'aria-expanded': open,
|
|
724
|
+
onClick: () => {
|
|
725
|
+
setOpen(!open)
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
React.createElement('span', { style: styles.blockTitle }, title),
|
|
729
|
+
React.createElement('span', { style: styles.disclosureMark }, open ? t('ui.collapse') : t('ui.expand')),
|
|
730
|
+
),
|
|
731
|
+
hint === undefined ? null : React.createElement('p', { style: styles.notice }, hint),
|
|
732
|
+
open ? children : null,
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Read one nested settings value without inventing defaults: a missing
|
|
738
|
+
* ancestor answers `undefined`, which is what tells "the user layer does
|
|
739
|
+
* not own this array" from "it owns an empty one".
|
|
740
|
+
*/
|
|
741
|
+
function pathGet(root, path) {
|
|
742
|
+
let node = root
|
|
743
|
+
for (const segment of path) {
|
|
744
|
+
if (typeof node !== 'object' || node === null) return undefined
|
|
745
|
+
node = node[segment]
|
|
746
|
+
}
|
|
747
|
+
return node
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The models one provider profile currently serves, as the settings
|
|
752
|
+
* document resolves them: the user layer when it owns the array (an
|
|
753
|
+
* explicit list replaces the shipped catalog), else the composition layer.
|
|
754
|
+
*/
|
|
755
|
+
function routeModelsOf(namespaceView, provider) {
|
|
756
|
+
if (namespaceView === undefined) return []
|
|
757
|
+
const path = ['providers', provider, 'models']
|
|
758
|
+
const owned = pathGet(namespaceView.user, path)
|
|
759
|
+
if (Array.isArray(owned)) return owned
|
|
760
|
+
const inherited = pathGet(namespaceView.base, path)
|
|
761
|
+
return Array.isArray(inherited) ? inherited : []
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Whether the subscription tag applies to one model.
|
|
766
|
+
*
|
|
767
|
+
* Free models are excluded on purpose: they cost nothing whether or not a
|
|
768
|
+
* plan covers them, so "covered by a subscription" is noise on their row.
|
|
769
|
+
* The filter and the row tag both read this one function, so a filtered
|
|
770
|
+
* list never shows a row the tag contradicts.
|
|
771
|
+
*/
|
|
772
|
+
function planCovered(model) {
|
|
773
|
+
return model.free !== true && (model.plans || []).length > 0
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* One model row's filter decision: every active dimension must match
|
|
778
|
+
* (capabilities are ANDed too). `plan` is a yes/no dimension — "is a
|
|
779
|
+
* subscription covering this paid model" — not a pick-one-of-N tier selector.
|
|
780
|
+
*/
|
|
781
|
+
function matchesFilters(model, query, flags) {
|
|
782
|
+
if (flags.plan === true && !planCovered(model)) return false
|
|
783
|
+
if (flags.free === true && model.free !== true) return false
|
|
784
|
+
for (const tag of CAPABILITY_TAGS) {
|
|
785
|
+
if (flags[tag] === true && (model.tags || []).indexOf(tag) === -1) return false
|
|
786
|
+
}
|
|
787
|
+
if (query !== '') {
|
|
788
|
+
const needle = query.toLowerCase()
|
|
789
|
+
const haystack = `${model.id} ${model.name || ''} ${model.ownedBy || ''}`.toLowerCase()
|
|
790
|
+
if (haystack.indexOf(needle) === -1) return false
|
|
791
|
+
}
|
|
792
|
+
return true
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* 套餐内 and 免费 are one choice, not two.
|
|
797
|
+
*
|
|
798
|
+
* A free model is never plan-covered (`planCovered` excludes it on purpose),
|
|
799
|
+
* so the two dimensions can never both match — selecting both could only ever
|
|
800
|
+
* show an empty list. Picking one therefore releases the other, which is also
|
|
801
|
+
* what the reader means by them: "show me what my plan pays for" and "show me
|
|
802
|
+
* what costs nothing" are answers to the same question. Capability tags stay
|
|
803
|
+
* multi-select (they are ANDed).
|
|
804
|
+
*/
|
|
805
|
+
const EXCLUSIVE_FILTERS = { plan: 'free', free: 'plan' }
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* `embedding` stands alone.
|
|
809
|
+
*
|
|
810
|
+
* It does not describe a capability of a chat model — it is the marker of a
|
|
811
|
+
* different population (the `?modality=embedding` catalog: embedding models,
|
|
812
|
+
* which have no chat endpoint). Every other dimension describes chat models,
|
|
813
|
+
* so ANDing this one with any of them can only ever match nothing, exactly
|
|
814
|
+
* like the exclusive pair above. Picking it clears everything; picking
|
|
815
|
+
* anything else clears it.
|
|
816
|
+
*/
|
|
817
|
+
const SOLO_FILTERS = ['embedding']
|
|
818
|
+
|
|
819
|
+
/** Toggle one filter dimension, enforcing the exclusive and solo rules above. */
|
|
820
|
+
function toggleFilter(flags, tag) {
|
|
821
|
+
const next = { ...flags }
|
|
822
|
+
if (next[tag] === true) {
|
|
823
|
+
delete next[tag]
|
|
824
|
+
return next
|
|
825
|
+
}
|
|
826
|
+
if (SOLO_FILTERS.indexOf(tag) !== -1) return { [tag]: true }
|
|
827
|
+
for (const solo of SOLO_FILTERS) delete next[solo]
|
|
828
|
+
next[tag] = true
|
|
829
|
+
const released = EXCLUSIVE_FILTERS[tag]
|
|
830
|
+
if (released !== undefined) delete next[released]
|
|
831
|
+
return next
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* One render's worth of derived catalog facts, in one pure function so the
|
|
836
|
+
* filter/count logic is testable without React (the build gate does that).
|
|
837
|
+
* `known` is "already in the route's model array", which is what turns a
|
|
838
|
+
* checkbox into a ✓ and keeps an existing entry out of the write.
|
|
839
|
+
*/
|
|
840
|
+
function derivePanel(view, routeState, query, flags, picked) {
|
|
841
|
+
const tags = Array.isArray(view.tags) && view.tags.length > 0 ? view.tags : CAPABILITY_TAGS
|
|
842
|
+
const models = Array.isArray(view.models) ? view.models : []
|
|
843
|
+
const known = {}
|
|
844
|
+
for (const id of (routeState && routeState.modelIds) || []) known[id] = true
|
|
845
|
+
const visible = models.filter((model) => matchesFilters(model, query.trim(), flags))
|
|
846
|
+
const visibleIds = visible.map((model) => model.id)
|
|
847
|
+
const addable = visible.filter((model) => known[model.id] !== true && picked.indexOf(model.id) !== -1)
|
|
848
|
+
const allVisiblePicked = visibleIds.length > 0 && visibleIds.every((id) => picked.indexOf(id) !== -1)
|
|
849
|
+
return { tags, models, known, visible, visibleIds, addable, allVisiblePicked }
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* One Kenari route exists at a time, so the takeover and the mounted card
|
|
854
|
+
* need no wiring: a module-level channel carries the click that opened the
|
|
855
|
+
* picker (with the native button, for the fail-safe) to the component that
|
|
856
|
+
* renders it. `mounted` is the takeover's guard — no listener means no
|
|
857
|
+
* Kenari card on this page, and the native button is left alone.
|
|
858
|
+
*/
|
|
859
|
+
function createOpenChannel() {
|
|
860
|
+
const listeners = new Set()
|
|
861
|
+
return {
|
|
862
|
+
open(button) {
|
|
863
|
+
for (const listener of [...listeners]) listener(button)
|
|
864
|
+
},
|
|
865
|
+
subscribe(listener) {
|
|
866
|
+
listeners.add(listener)
|
|
867
|
+
return () => {
|
|
868
|
+
listeners.delete(listener)
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
get mounted() {
|
|
872
|
+
return listeners.size > 0
|
|
873
|
+
},
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const pickerChannel = createOpenChannel()
|
|
877
|
+
|
|
878
|
+
/** Set while this plugin re-clicks the native button itself, so that click cannot loop back here. */
|
|
879
|
+
let takeoverBypassed = false
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* The provider card this bundle's marker identifies: the marker's nearest
|
|
883
|
+
* list item, which is the element dsh renders one provider's card as.
|
|
884
|
+
*/
|
|
885
|
+
function markedCard() {
|
|
886
|
+
const marker = document.querySelector(`[${MARKER_ATTR}]`)
|
|
887
|
+
if (marker === null || marker === undefined) return null
|
|
888
|
+
const card = typeof marker.closest === 'function' ? marker.closest('li') : null
|
|
889
|
+
return card === null ? marker.parentElement : card
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The card containing `start`, when that card is this bundle's; else null.
|
|
894
|
+
*
|
|
895
|
+
* The test is "does the marked card contain the button", not "is any
|
|
896
|
+
* ancestor of the button marked": the `<ul>` that holds every provider card
|
|
897
|
+
* also contains the marker, so walking up from the button until the marker
|
|
898
|
+
* is found claims a sibling provider's identically labelled button. The
|
|
899
|
+
* failure that guards against is silent — the wrong card's 添加模型 would
|
|
900
|
+
* open this picker instead of adding its own row — so the scope is asked
|
|
901
|
+
* from the marker's side, where it is exact.
|
|
902
|
+
*/
|
|
903
|
+
function cardWithMarker(start) {
|
|
904
|
+
const card = markedCard()
|
|
905
|
+
if (card === null || typeof card.contains !== 'function') return null
|
|
906
|
+
return card.contains(start) ? card : null
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Take over the Kenari card's 模型目录 buttons.
|
|
911
|
+
*
|
|
912
|
+
* A capture-phase listener on the document runs before React's
|
|
913
|
+
* root-container listener, so `stopPropagation` here keeps dsh's own click
|
|
914
|
+
* handler from ever seeing the event. The match is deliberately narrow: the
|
|
915
|
+
* label is one of dsh's own, and the button must sit in a card containing
|
|
916
|
+
* this bundle's marker. Everything else — another provider's identical
|
|
917
|
+
* button, a click inside this plugin's own dialog (which is portaled to
|
|
918
|
+
* `body`, so no marker is above it), a click this plugin originated —
|
|
919
|
+
* propagates untouched, which is what keeps the native dialog reachable.
|
|
920
|
+
*
|
|
921
|
+
* A `disabled` button never dispatches a click at all, so the native
|
|
922
|
+
* gates (adding while the editor is busy, fetching without a base URL) hold
|
|
923
|
+
* here too without this function re-reading any of them.
|
|
924
|
+
*/
|
|
925
|
+
function installEntryTakeover() {
|
|
926
|
+
const onClickCapture = (event) => {
|
|
927
|
+
if (takeoverBypassed || !pickerChannel.mounted) return
|
|
928
|
+
const target = event.target
|
|
929
|
+
if (target === null || target === undefined || typeof target.closest !== 'function') return
|
|
930
|
+
const button = target.closest('button')
|
|
931
|
+
if (button === null) return
|
|
932
|
+
const label = typeof button.textContent === 'string' ? button.textContent.trim() : ''
|
|
933
|
+
if (!isTakeoverLabel(label)) return
|
|
934
|
+
if (cardWithMarker(button) === null) return
|
|
935
|
+
event.preventDefault()
|
|
936
|
+
event.stopPropagation()
|
|
937
|
+
pickerChannel.open(button)
|
|
938
|
+
}
|
|
939
|
+
document.addEventListener('click', onClickCapture, true)
|
|
940
|
+
return () => {
|
|
941
|
+
document.removeEventListener('click', onClickCapture, true)
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* The Kenari glyph on the settings panel's own nav rail.
|
|
947
|
+
*
|
|
948
|
+
* dsh's shell picks that glyph from a hardcoded id switch (`navIcon(row.id)`:
|
|
949
|
+
* models / agent-presets / plugins, else the settings gear) and a section's
|
|
950
|
+
* registration carries only `id`/`order`/`label` — there is no icon field to
|
|
951
|
+
* fill in. What a plugin CAN own is the DOM, and the row for our own section
|
|
952
|
+
* is the one nav button whose label is our own label. So the swap is done
|
|
953
|
+
* here, and the image comes from this plugin's same-origin route
|
|
954
|
+
* (`/api/kenari.favicon`), the same way the catalog view does.
|
|
955
|
+
*
|
|
956
|
+
* The glyph is NOT replaced: dsh's `<svg>` stays exactly where React put it
|
|
957
|
+
* and the injected rule only hides it. React deletes the whole panel subtree
|
|
958
|
+
* when settings closes and calls `removeChild` for every host node it
|
|
959
|
+
* rendered, so a node this bundle had detached would throw there — an
|
|
960
|
+
* `Uncaught NotFoundError` on every close, from code that looks fine.
|
|
961
|
+
*
|
|
962
|
+
* Fail-open: the attribute is set only after the image has actually loaded,
|
|
963
|
+
* so a 404 (a deploy whose package omits `assets/`) leaves dsh's gear alone
|
|
964
|
+
* instead of leaving an empty 16px hole.
|
|
965
|
+
*/
|
|
966
|
+
const NAV_ICON_ATTR = 'data-kenari-nav-icon'
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* The injected `<style>`'s own marker. Deliberately NOT `NAV_ICON_ATTR`: the
|
|
970
|
+
* two would otherwise be indistinguishable to `[data-kenari-nav-icon]`, and
|
|
971
|
+
* the row lookup would have to care which of the two it had matched.
|
|
972
|
+
*/
|
|
973
|
+
const NAV_ICON_STYLE_ATTR = 'data-kenari-nav-icon-style'
|
|
974
|
+
|
|
975
|
+
/** Where the browser fetches the glyph; must match `KENARI_FAVICON_PATH`. */
|
|
976
|
+
const NAV_ICON_URL = '/api/kenari.favicon'
|
|
977
|
+
|
|
978
|
+
/** Must match the `label` this bundle registers for its settings section. */
|
|
979
|
+
const SETTINGS_SECTION_LABEL = 'Kenari'
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Scope for the row lookup. The settings panel is the only dialog on the
|
|
983
|
+
* page that renders a nav rail, and "Kenari" is also painted well outside
|
|
984
|
+
* it (the Models page's route row, the composer's model menu), so the match
|
|
985
|
+
* is confined to a `<nav>` inside a `[role="dialog"]` rather than to any
|
|
986
|
+
* button anywhere.
|
|
987
|
+
*/
|
|
988
|
+
const NAV_ROW_SELECTOR = '[role="dialog"] nav button'
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* The settings nav rail's button for one section label, or null.
|
|
992
|
+
*
|
|
993
|
+
* Text is the only stable identity a nav row has — its class is a CSS-module
|
|
994
|
+
* hash and its position moves with the registered order — which is the same
|
|
995
|
+
* reason the 模型目录 takeover matches labels.
|
|
996
|
+
*/
|
|
997
|
+
function settingsNavButton(root, label) {
|
|
998
|
+
const buttons = root.querySelectorAll(NAV_ROW_SELECTOR)
|
|
999
|
+
for (let index = 0; index < buttons.length; index += 1) {
|
|
1000
|
+
const text = typeof buttons[index].textContent === 'string' ? buttons[index].textContent.trim() : ''
|
|
1001
|
+
if (text === label) return buttons[index]
|
|
1002
|
+
}
|
|
1003
|
+
return null
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** The rule that swaps dsh's nav glyph for this plugin's icon. */
|
|
1007
|
+
function navIconRule() {
|
|
1008
|
+
return `[${NAV_ICON_ATTR}] svg{display:none}`
|
|
1009
|
+
+ `[${NAV_ICON_ATTR}]::before{content:"";flex:none;width:16px;height:16px;border-radius:4px;`
|
|
1010
|
+
+ `background:url("${NAV_ICON_URL}") center/contain no-repeat}`
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Inject the nav-icon rule once, so the first paint of the panel already has it. */
|
|
1014
|
+
function ensureNavIconStyle() {
|
|
1015
|
+
if (document.querySelector(`style[${NAV_ICON_STYLE_ATTR}]`) !== null) return
|
|
1016
|
+
const style = document.createElement('style')
|
|
1017
|
+
style.setAttribute(NAV_ICON_STYLE_ATTR, '')
|
|
1018
|
+
style.textContent = navIconRule()
|
|
1019
|
+
document.head.appendChild(style)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Mark the nav row owning one section label, if it is on the page.
|
|
1024
|
+
*
|
|
1025
|
+
* The `::before` icon is 16px and `flex: none`, and the svg it hides is
|
|
1026
|
+
* 16px and `flex: none` too, so the row's geometry (icon, 8px gap, label)
|
|
1027
|
+
* is byte-for-byte what dsh laid out.
|
|
1028
|
+
*/
|
|
1029
|
+
function markNavRow(root, label) {
|
|
1030
|
+
const button = settingsNavButton(root, label)
|
|
1031
|
+
if (button === null) return null
|
|
1032
|
+
button.setAttribute(NAV_ICON_ATTR, '')
|
|
1033
|
+
return button
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Whether the route actually serves the image; the licence to hide dsh's glyph. */
|
|
1037
|
+
let navIconReady = false
|
|
1038
|
+
|
|
1039
|
+
/** The row already marked, while it is still connected — the lookup's short-circuit. */
|
|
1040
|
+
let navRow = null
|
|
1041
|
+
|
|
1042
|
+
function patchSettingsNavIcon(root) {
|
|
1043
|
+
if (!navIconReady) return false
|
|
1044
|
+
if (navRow !== null && navRow.isConnected === true) return true
|
|
1045
|
+
navRow = markNavRow(root, SETTINGS_SECTION_LABEL)
|
|
1046
|
+
return navRow !== null
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Put the icon on the nav rail.
|
|
1051
|
+
*
|
|
1052
|
+
* The panel mounts only while settings is open, so the row is found by
|
|
1053
|
+
* watching for the panel's own insertion rather than by polling: the
|
|
1054
|
+
* document-wide lookup runs only for a mutation that could have added a
|
|
1055
|
+
* dialog, and short-circuits on the remembered row while it is connected.
|
|
1056
|
+
* Plain chat streaming — every other mutation in this app — never reaches
|
|
1057
|
+
* the lookup at all.
|
|
1058
|
+
*/
|
|
1059
|
+
function installSettingsNavIcon() {
|
|
1060
|
+
ensureNavIconStyle()
|
|
1061
|
+
// The probe is what licenses hiding the gear: `onload` proves the route
|
|
1062
|
+
// answers, `onerror` silently leaves dsh's own glyph in place.
|
|
1063
|
+
const probe = document.createElement('img')
|
|
1064
|
+
probe.onload = () => {
|
|
1065
|
+
navIconReady = true
|
|
1066
|
+
patchSettingsNavIcon(document)
|
|
1067
|
+
}
|
|
1068
|
+
probe.onerror = () => {}
|
|
1069
|
+
probe.src = NAV_ICON_URL
|
|
1070
|
+
|
|
1071
|
+
const observer = new MutationObserver((records) => {
|
|
1072
|
+
for (const record of records) {
|
|
1073
|
+
for (const node of record.addedNodes) {
|
|
1074
|
+
if (node.nodeType !== 1) continue
|
|
1075
|
+
// The panel's root is the overlay, not the dialog itself, so both
|
|
1076
|
+
// the node and its subtree are asked.
|
|
1077
|
+
if (node.matches('[role="dialog"]') || node.querySelector('[role="dialog"]') !== null) {
|
|
1078
|
+
patchSettingsNavIcon(document)
|
|
1079
|
+
return
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
})
|
|
1084
|
+
if (document.body !== null && document.body !== undefined) {
|
|
1085
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
1086
|
+
}
|
|
1087
|
+
return () => observer.disconnect()
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Everything the catalog shows and does, in one hook feeding the dialog's
|
|
1092
|
+
* every part, so the toolbar, the summary and the write all agree on what is
|
|
1093
|
+
* currently filtered.
|
|
1094
|
+
*
|
|
1095
|
+
* There is one dialog. The Kenari settings page used to open a second,
|
|
1096
|
+
* read-only copy of it; that page now points at the Models page instead,
|
|
1097
|
+
* because a catalog browser that cannot add anything just duplicated it.
|
|
1098
|
+
*/
|
|
1099
|
+
function useCatalogPanel(props) {
|
|
1100
|
+
const { routeNs, routeProvider, loadPanel, addModels } = props
|
|
1101
|
+
const [state, setState] = React.useState({ status: 'loading' })
|
|
1102
|
+
const [reloads, setReloads] = React.useState(0)
|
|
1103
|
+
const [query, setQuery] = React.useState('')
|
|
1104
|
+
const [flags, setFlags] = React.useState({})
|
|
1105
|
+
const [picked, setPicked] = React.useState([])
|
|
1106
|
+
const [write, setWrite] = React.useState({ status: 'idle' })
|
|
1107
|
+
|
|
1108
|
+
// The target route travels as two strings, never as an object: a fresh
|
|
1109
|
+
// object in the dependency array would re-run this effect on every
|
|
1110
|
+
// render, and the state it sets re-renders — an endless fetch loop.
|
|
1111
|
+
React.useEffect(() => {
|
|
1112
|
+
let live = true
|
|
1113
|
+
const route = routeNs !== undefined && routeProvider !== undefined
|
|
1114
|
+
? { settingsNs: routeNs, provider: routeProvider }
|
|
1115
|
+
: undefined
|
|
1116
|
+
setState({ status: 'loading' })
|
|
1117
|
+
loadPanel(route).then(
|
|
1118
|
+
(answer) => {
|
|
1119
|
+
if (live) setState({ status: 'ready', panel: answer })
|
|
1120
|
+
},
|
|
1121
|
+
(err) => {
|
|
1122
|
+
if (live) setState({ status: 'error', message: String(err && err.message ? err.message : err) })
|
|
1123
|
+
},
|
|
1124
|
+
)
|
|
1125
|
+
return () => {
|
|
1126
|
+
live = false
|
|
1127
|
+
}
|
|
1128
|
+
}, [loadPanel, reloads, routeNs, routeProvider])
|
|
1129
|
+
|
|
1130
|
+
const view = state.status === 'ready' ? state.panel.view : {}
|
|
1131
|
+
const routeState = state.status === 'ready' ? state.panel.route : undefined
|
|
1132
|
+
const derived = derivePanel(view, routeState, query, flags, picked)
|
|
1133
|
+
|
|
1134
|
+
const toggleFlag = (tag) => {
|
|
1135
|
+
setFlags((current) => toggleFilter(current, tag))
|
|
1136
|
+
}
|
|
1137
|
+
const togglePick = (id) => {
|
|
1138
|
+
setPicked((current) => current.indexOf(id) === -1
|
|
1139
|
+
? current.concat([id])
|
|
1140
|
+
: current.filter((entry) => entry !== id))
|
|
1141
|
+
}
|
|
1142
|
+
const toggleVisible = () => {
|
|
1143
|
+
setPicked((current) => derived.allVisiblePicked
|
|
1144
|
+
? current.filter((id) => derived.visibleIds.indexOf(id) === -1)
|
|
1145
|
+
: current.concat(derived.visibleIds.filter((id) => current.indexOf(id) === -1)))
|
|
1146
|
+
}
|
|
1147
|
+
const clearFilters = () => {
|
|
1148
|
+
setQuery('')
|
|
1149
|
+
setFlags({})
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Write the picked-and-not-yet-in-route models into the route, then
|
|
1154
|
+
* re-read so the ✓ marks and the count come from the document rather than
|
|
1155
|
+
* from what this function assumed it wrote.
|
|
1156
|
+
*/
|
|
1157
|
+
const submit = () => {
|
|
1158
|
+
const profiles = derived.addable.map((model) => model.profile)
|
|
1159
|
+
if (profiles.length === 0 || routeNs === undefined || routeProvider === undefined) return
|
|
1160
|
+
setWrite({ status: 'saving' })
|
|
1161
|
+
addModels({ settingsNs: routeNs, provider: routeProvider }, profiles).then(
|
|
1162
|
+
(result) => {
|
|
1163
|
+
setWrite(result.ok === true
|
|
1164
|
+
? { status: 'added', message: t('catalog.added', { count: profiles.length }) }
|
|
1165
|
+
: { status: 'error', message: result.message })
|
|
1166
|
+
if (result.ok === true) {
|
|
1167
|
+
setPicked([])
|
|
1168
|
+
setReloads((current) => current + 1)
|
|
1169
|
+
}
|
|
1170
|
+
},
|
|
1171
|
+
(err) => setWrite({ status: 'error', message: String(err && err.message ? err.message : err) }),
|
|
1172
|
+
)
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
return { state, view, routeState, derived, query, setQuery, flags, picked, write, toggleFlag, togglePick, toggleVisible, clearFilters, submit }
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* How a capability id reads to a person. The ids stay the host's vocabulary
|
|
1180
|
+
* — they are what `kenari_list_models` reports and what the filter matches —
|
|
1181
|
+
* so only the display is translated. An id with no translation prints as
|
|
1182
|
+
* itself rather than as a missing-key artifact, which is what lets the host
|
|
1183
|
+
* add a capability without this bundle shipping a new string.
|
|
1184
|
+
*/
|
|
1185
|
+
function tagLabel(tag) {
|
|
1186
|
+
const key = `tag.${tag}`
|
|
1187
|
+
const label = t(key)
|
|
1188
|
+
return label === key ? tag : label
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** The search box, the filter chips, and the select-all control. */
|
|
1192
|
+
function CatalogToolbar(props) {
|
|
1193
|
+
const { panel } = props
|
|
1194
|
+
// Every dimension is one chip of the same kind: plan, free, then the
|
|
1195
|
+
// capability tags. No plan NAMES appear anywhere — a reader filtering by
|
|
1196
|
+
// subscription wants "covered by a plan", not a pick-one-of-five tier.
|
|
1197
|
+
// 套餐内 and 免费 are one choice rather than two (see `toggleFilter`).
|
|
1198
|
+
const chips = ['plan', 'free'].concat(panel.derived.tags).map((tag) => React.createElement(
|
|
1199
|
+
'button',
|
|
1200
|
+
{
|
|
1201
|
+
key: tag,
|
|
1202
|
+
type: 'button',
|
|
1203
|
+
style: panel.flags[tag] === true ? styles.filterChipOn : styles.filterChip,
|
|
1204
|
+
'aria-pressed': panel.flags[tag] === true,
|
|
1205
|
+
onClick: () => {
|
|
1206
|
+
panel.toggleFlag(tag)
|
|
1207
|
+
},
|
|
1208
|
+
},
|
|
1209
|
+
tag === 'plan' ? t('filter.plan') : tag === 'free' ? t('filter.free') : tagLabel(tag),
|
|
1210
|
+
))
|
|
1211
|
+
return React.createElement(
|
|
1212
|
+
'div',
|
|
1213
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
|
|
1214
|
+
React.createElement(
|
|
1215
|
+
'div',
|
|
1216
|
+
{ style: styles.toolbar },
|
|
1217
|
+
React.createElement('input', {
|
|
1218
|
+
style: styles.search,
|
|
1219
|
+
type: 'search',
|
|
1220
|
+
value: panel.query,
|
|
1221
|
+
placeholder: t('catalog.searchPlaceholder'),
|
|
1222
|
+
'aria-label': t('catalog.searchLabel'),
|
|
1223
|
+
onChange: (event) => {
|
|
1224
|
+
panel.setQuery(event.target.value)
|
|
1225
|
+
},
|
|
1226
|
+
}),
|
|
1227
|
+
React.createElement(
|
|
1228
|
+
Button,
|
|
1229
|
+
{ variant: 'ghost', size: 'sm', disabled: panel.derived.visible.length === 0, onClick: panel.toggleVisible },
|
|
1230
|
+
panel.derived.allVisiblePicked ? t('catalog.clearAll') : t('catalog.selectAll'),
|
|
1231
|
+
),
|
|
1232
|
+
React.createElement(Button, { variant: 'ghost', size: 'sm', onClick: panel.clearFilters }, t('catalog.clear')),
|
|
1233
|
+
),
|
|
1234
|
+
React.createElement('div', { style: styles.filterRow }, chips),
|
|
1235
|
+
)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** What the current filter is showing, and what the one non-obvious tag means. */
|
|
1239
|
+
function CatalogSummary(props) {
|
|
1240
|
+
const { derived } = props
|
|
1241
|
+
return React.createElement(
|
|
1242
|
+
'p',
|
|
1243
|
+
{ style: styles.notice },
|
|
1244
|
+
t('catalog.summary', {
|
|
1245
|
+
visible: derived.visible.length,
|
|
1246
|
+
total: derived.models.length,
|
|
1247
|
+
addable: derived.addable.length,
|
|
1248
|
+
}),
|
|
1249
|
+
)
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** The model rows: pick box, id, then the tag column. */
|
|
1253
|
+
function CatalogRows(props) {
|
|
1254
|
+
const { derived, picked, togglePick } = props
|
|
1255
|
+
if (derived.visible.length === 0) {
|
|
1256
|
+
return React.createElement('p', { style: styles.notice }, t('catalog.empty'))
|
|
1257
|
+
}
|
|
1258
|
+
return React.createElement(
|
|
1259
|
+
'div',
|
|
1260
|
+
{ style: styles.list },
|
|
1261
|
+
derived.visible.map((model) => React.createElement(
|
|
1262
|
+
'label',
|
|
1263
|
+
{ key: model.id, style: styles.item },
|
|
1264
|
+
// A model already in the route cannot be picked again, so its cell is
|
|
1265
|
+
// a ✓ in the very box the checkboxes occupy: the column keeps one
|
|
1266
|
+
// width and the row still reports what it is.
|
|
1267
|
+
derived.known[model.id] !== true
|
|
1268
|
+
? React.createElement('input', {
|
|
1269
|
+
type: 'checkbox',
|
|
1270
|
+
style: styles.pickBox,
|
|
1271
|
+
checked: picked.indexOf(model.id) !== -1,
|
|
1272
|
+
onChange: () => {
|
|
1273
|
+
togglePick(model.id)
|
|
1274
|
+
},
|
|
1275
|
+
})
|
|
1276
|
+
: React.createElement('span', { style: styles.pickMark }, '✓'),
|
|
1277
|
+
// The id alone: it is the exact string a request and the route entry
|
|
1278
|
+
// use, and the vendor's display name beside it only made every row
|
|
1279
|
+
// wider without adding anything a reader acts on. The name still
|
|
1280
|
+
// travels in the payload (search matches it, and it is what gets
|
|
1281
|
+
// written into the route's model entry).
|
|
1282
|
+
React.createElement('code', { style: styles.itemId }, model.id),
|
|
1283
|
+
// The tags are their OWN wrapping column, not siblings of the id in
|
|
1284
|
+
// one wrapping row: as siblings, a wrapped line restarts at the row's
|
|
1285
|
+
// left edge, which reads as a stray line rather than as the row's
|
|
1286
|
+
// tags. In a column they wrap in place, and the id centers against
|
|
1287
|
+
// the block. Every tag uses dsh's one `outline` tone, so the facts
|
|
1288
|
+
// read as a set rather than one badge shouting louder than the rest.
|
|
1289
|
+
React.createElement(
|
|
1290
|
+
'span',
|
|
1291
|
+
{ style: styles.tagColumn },
|
|
1292
|
+
// No 免费 tag: a free model says so in its own id (`...:free`), so
|
|
1293
|
+
// the badge only repeated what the row already showed. The 免费
|
|
1294
|
+
// FILTER stays, and it still reads the payload's `free` field
|
|
1295
|
+
// rather than the suffix — if Kenari ever marks a model free
|
|
1296
|
+
// without renaming it, filtering keeps working.
|
|
1297
|
+
(model.tags || []).map((tag) => React.createElement(Tag, { key: tag, tone: 'neutral' }, tagLabel(tag))),
|
|
1298
|
+
// One boolean tag, never one badge per plan: the only question a
|
|
1299
|
+
// row answers is "does a subscription cover this PAID model", and
|
|
1300
|
+
// the tier that happens to cover it is not the reader's business.
|
|
1301
|
+
planCovered(model) ? React.createElement(Tag, { tone: 'neutral' }, t('filter.plan')) : null,
|
|
1302
|
+
model.chatCapable === false ? React.createElement('span', { style: styles.dim }, t('catalog.chatOnly')) : null,
|
|
1303
|
+
derived.known[model.id] === true ? React.createElement('span', { style: styles.dim }, t('catalog.inRoute')) : null,
|
|
1304
|
+
),
|
|
1305
|
+
)),
|
|
1306
|
+
)
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* The catalog as dsh's own dialog: same Modal chrome, same Button/Pill/Tag
|
|
1311
|
+
* tokens as the dialog dsh's own 获取可用模型 opens, with the capability
|
|
1312
|
+
* and plan dimensions added into that one surface.
|
|
1313
|
+
*
|
|
1314
|
+
* It is mounted only while open (see the two call sites), so a closed dialog
|
|
1315
|
+
* costs no request and every opening starts from a fresh query and pick set.
|
|
1316
|
+
*/
|
|
1317
|
+
function ModelCatalogModal(props) {
|
|
1318
|
+
const { onClose, nativeButton, loadPanel, addModels, routeNs, routeProvider } = props
|
|
1319
|
+
const panel = useCatalogPanel({ routeNs, routeProvider, loadPanel, addModels })
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* The way back out: close this dialog, then re-click the button dsh
|
|
1323
|
+
* rendered, with the takeover bypassed for that one click so the native
|
|
1324
|
+
* flow runs. Only offered when this plugin's own view failed to load.
|
|
1325
|
+
*/
|
|
1326
|
+
const fallbackToNative = () => {
|
|
1327
|
+
onClose()
|
|
1328
|
+
if (nativeButton !== null && nativeButton !== undefined && typeof nativeButton.click === 'function') {
|
|
1329
|
+
takeoverBypassed = true
|
|
1330
|
+
try {
|
|
1331
|
+
nativeButton.click()
|
|
1332
|
+
} finally {
|
|
1333
|
+
setTimeout(() => {
|
|
1334
|
+
takeoverBypassed = false
|
|
1335
|
+
}, 0)
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const footer = React.createElement(
|
|
1341
|
+
React.Fragment,
|
|
1342
|
+
null,
|
|
1343
|
+
React.createElement('span', { style: styles.footerNote }, t('catalog.footerNote')),
|
|
1344
|
+
React.createElement(Button, { variant: 'outline', onClick: onClose }, t('catalog.cancel')),
|
|
1345
|
+
React.createElement(
|
|
1346
|
+
Button,
|
|
1347
|
+
{
|
|
1348
|
+
variant: 'primary',
|
|
1349
|
+
disabled: panel.derived.addable.length === 0 || panel.write.status === 'saving',
|
|
1350
|
+
onClick: panel.submit,
|
|
1351
|
+
},
|
|
1352
|
+
panel.write.status === 'saving'
|
|
1353
|
+
? t('catalog.saving')
|
|
1354
|
+
: t('catalog.submit', { count: panel.derived.addable.length }),
|
|
1355
|
+
),
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
const plansError = viewPlansError(panel)
|
|
1359
|
+
const body = panel.state.status === 'loading'
|
|
1360
|
+
? React.createElement('p', { style: styles.notice }, t('catalog.loading'))
|
|
1361
|
+
: panel.state.status === 'error'
|
|
1362
|
+
? React.createElement(
|
|
1363
|
+
React.Fragment,
|
|
1364
|
+
null,
|
|
1365
|
+
React.createElement('p', { style: styles.error }, t('catalog.error', { message: panel.state.message })),
|
|
1366
|
+
nativeButton !== undefined
|
|
1367
|
+
? React.createElement('p', { style: { margin: 0 } }, React.createElement(
|
|
1368
|
+
Button,
|
|
1369
|
+
{ variant: 'outline', size: 'sm', onClick: fallbackToNative },
|
|
1370
|
+
t('catalog.fallback'),
|
|
1371
|
+
))
|
|
1372
|
+
: null,
|
|
1373
|
+
)
|
|
1374
|
+
: React.createElement(
|
|
1375
|
+
'div',
|
|
1376
|
+
{ style: styles.body },
|
|
1377
|
+
React.createElement(CatalogToolbar, { panel }),
|
|
1378
|
+
React.createElement(CatalogSummary, { derived: panel.derived }),
|
|
1379
|
+
plansError !== undefined ? React.createElement('p', { style: styles.error }, plansError) : null,
|
|
1380
|
+
React.createElement(CatalogRows, { derived: panel.derived, picked: panel.picked, togglePick: panel.togglePick }),
|
|
1381
|
+
panel.routeState !== undefined && panel.routeState.writable === false
|
|
1382
|
+
? React.createElement('p', { style: styles.notice }, t('catalog.readonly'))
|
|
1383
|
+
: null,
|
|
1384
|
+
panel.write.status === 'added' ? React.createElement('p', { style: styles.notice }, panel.write.message) : null,
|
|
1385
|
+
panel.write.status === 'error' ? React.createElement('p', { style: styles.error }, panel.write.message) : null,
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
return React.createElement(
|
|
1389
|
+
Modal,
|
|
1390
|
+
{
|
|
1391
|
+
open: true,
|
|
1392
|
+
onClose,
|
|
1393
|
+
className: DIALOG_CLASS,
|
|
1394
|
+
title: t('catalog.title'),
|
|
1395
|
+
closeLabel: t('catalog.close'),
|
|
1396
|
+
description: t('catalog.description'),
|
|
1397
|
+
footer,
|
|
1398
|
+
},
|
|
1399
|
+
body,
|
|
1400
|
+
)
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** The plan-table failure sentence, or `undefined` when the table loaded. */
|
|
1404
|
+
function viewPlansError(panel) {
|
|
1405
|
+
const plansError = panel.view.plansError
|
|
1406
|
+
return plansError === undefined
|
|
1407
|
+
? undefined
|
|
1408
|
+
: t('catalog.plansError', { message: plansError })
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* The Models-page seat. dsh dispatches `settings.models.provider-card` for
|
|
1413
|
+
* every route the pi-ai namespace owns, into that route's own card, so the
|
|
1414
|
+
* hidden marker it renders here is how the takeover recognizes the card —
|
|
1415
|
+
* and the reason another pi-ai route renders nothing at all.
|
|
1416
|
+
*/
|
|
1417
|
+
function ModelPickerHost(props) {
|
|
1418
|
+
const [request, setRequest] = React.useState(null)
|
|
1419
|
+
React.useEffect(() => pickerChannel.subscribe((button) => {
|
|
1420
|
+
setRequest({ button })
|
|
1421
|
+
}), [])
|
|
1422
|
+
// The seat dispatches for every route the pi-ai namespace owns; this
|
|
1423
|
+
// picker is about Kenari, so another pi-ai route renders nothing — not
|
|
1424
|
+
// even the marker, which is what keeps the takeover from claiming that
|
|
1425
|
+
// route's otherwise identical button.
|
|
1426
|
+
if (props.provider === undefined || props.provider.provider !== 'kenari') return null
|
|
1427
|
+
return React.createElement(
|
|
1428
|
+
React.Fragment,
|
|
1429
|
+
null,
|
|
1430
|
+
React.createElement('span', { [MARKER_ATTR]: 'kenari', style: { display: 'none' } }),
|
|
1431
|
+
request === null
|
|
1432
|
+
? null
|
|
1433
|
+
: React.createElement(ModelCatalogModal, {
|
|
1434
|
+
nativeButton: request.button,
|
|
1435
|
+
onClose: () => {
|
|
1436
|
+
setRequest(null)
|
|
1437
|
+
},
|
|
1438
|
+
routeNs: PI_AI_NS,
|
|
1439
|
+
routeProvider: props.provider.provider,
|
|
1440
|
+
loadPanel: props.loadPanel,
|
|
1441
|
+
addModels: props.addModels,
|
|
1442
|
+
}),
|
|
1443
|
+
)
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/** Adapter-discovered models for this route: names and capacities, no prices. */
|
|
1447
|
+
/** How many models a collapsed list shows before it offers to open up. */
|
|
1448
|
+
const MODEL_PREVIEW_COUNT = 3
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* A context window as a reader-facing size: 1048576 reads as "1M". Nobody
|
|
1452
|
+
* compares exact token counts here; the magnitude is the fact being checked.
|
|
1453
|
+
*/
|
|
1454
|
+
function formatContextWindow(tokens) {
|
|
1455
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens <= 0) return undefined
|
|
1456
|
+
if (tokens >= 1_000_000) return `${String(Math.round(tokens / 100_000) / 10)}M`
|
|
1457
|
+
if (tokens >= 1_000) return `${String(Math.round(tokens / 1_000))}K`
|
|
1458
|
+
return String(tokens)
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* Which rows a list shows. Pulled out of the component so the build gate can
|
|
1463
|
+
* drive it directly: "three, then all when asked" is exactly the kind of rule
|
|
1464
|
+
* that stays quietly wrong when the only way to reach it is a click.
|
|
1465
|
+
*/
|
|
1466
|
+
function visibleModels(models, expanded) {
|
|
1467
|
+
return expanded ? models : models.slice(0, MODEL_PREVIEW_COUNT)
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Rows carrying a name a reader can use, given the catalog's display names.
|
|
1472
|
+
*
|
|
1473
|
+
* The discovery listing is not a source of names: dsh fills `name` with the
|
|
1474
|
+
* raw id when the endpoint publishes none (`llm-pi-ai/src/discovery.ts`), and
|
|
1475
|
+
* Kenari publishes a name for only 8 of its 80-odd models. So a row printed
|
|
1476
|
+
* the id twice — once as the id, once as the name — and the second copy said
|
|
1477
|
+
* nothing the first had not. The plugin's own view derives a display name for
|
|
1478
|
+
* every model (`Agnes 2.0 Flash`), which is also the string written into the
|
|
1479
|
+
* route profile, so the list reads it from there.
|
|
1480
|
+
*
|
|
1481
|
+
* When nothing resolves and the listing's name is just the id, the cell is
|
|
1482
|
+
* left empty rather than reprinting it: the id column already carries it.
|
|
1483
|
+
*/
|
|
1484
|
+
function withDisplayNames(models, names) {
|
|
1485
|
+
return models.map((model) => {
|
|
1486
|
+
const resolved = names[model.id]
|
|
1487
|
+
if (typeof resolved === 'string' && resolved.length > 0 && resolved !== model.id) {
|
|
1488
|
+
return { ...model, name: resolved }
|
|
1489
|
+
}
|
|
1490
|
+
const listed = typeof model.name === 'string' ? model.name : ''
|
|
1491
|
+
return { ...model, name: listed === model.id ? '' : listed }
|
|
1492
|
+
})
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* The models the current route can use.
|
|
1497
|
+
*
|
|
1498
|
+
* The provider reports its whole catalog — dozens of rows — and a reader who
|
|
1499
|
+
* came here to check one thing should not have to scroll past all of them. So
|
|
1500
|
+
* a collapsed list shows the first few, says how many there are in total, and
|
|
1501
|
+
* opens on request.
|
|
1502
|
+
*/
|
|
1503
|
+
function ModelList(props) {
|
|
1504
|
+
const { loadModels } = props
|
|
1505
|
+
const [state, setState] = React.useState({ status: 'loading' })
|
|
1506
|
+
const [expanded, setExpanded] = React.useState(false)
|
|
1507
|
+
React.useEffect(() => {
|
|
1508
|
+
let live = true
|
|
1509
|
+
loadModels().then(
|
|
1510
|
+
(answer) => {
|
|
1511
|
+
if (live) setState({ status: 'ready', ...answer })
|
|
1512
|
+
},
|
|
1513
|
+
(err) => {
|
|
1514
|
+
if (live) setState({ status: 'error', message: String(err && err.message ? err.message : err) })
|
|
1515
|
+
},
|
|
1516
|
+
)
|
|
1517
|
+
return () => {
|
|
1518
|
+
live = false
|
|
1519
|
+
}
|
|
1520
|
+
}, [loadModels])
|
|
1521
|
+
|
|
1522
|
+
if (state.status === 'loading') return React.createElement('p', { style: styles.notice }, t('models.loading'))
|
|
1523
|
+
if (state.status === 'error') {
|
|
1524
|
+
return React.createElement('p', { style: styles.error }, t('models.error', { message: state.message }))
|
|
1525
|
+
}
|
|
1526
|
+
if (state.models.length === 0) {
|
|
1527
|
+
return React.createElement('p', { style: styles.notice }, t('models.empty'))
|
|
1528
|
+
}
|
|
1529
|
+
return React.createElement(
|
|
1530
|
+
React.Fragment,
|
|
1531
|
+
null,
|
|
1532
|
+
React.createElement('p', { style: styles.notice }, t('models.summary', { route: state.route, count: state.models.length })),
|
|
1533
|
+
React.createElement(
|
|
1534
|
+
'table',
|
|
1535
|
+
{ style: styles.table },
|
|
1536
|
+
// Headed, unlike the usage table's first version: three unlabelled
|
|
1537
|
+
// columns where two of them printed the same string left the reader to
|
|
1538
|
+
// guess which was which. dsh's own vocabulary names them.
|
|
1539
|
+
React.createElement(
|
|
1540
|
+
'thead',
|
|
1541
|
+
null,
|
|
1542
|
+
React.createElement(
|
|
1543
|
+
'tr',
|
|
1544
|
+
null,
|
|
1545
|
+
React.createElement('th', { style: { ...styles.cell, ...styles.th } }, t('models.colId')),
|
|
1546
|
+
React.createElement('th', { style: { ...styles.cell, ...styles.th } }, t('models.colName')),
|
|
1547
|
+
React.createElement('th', { style: { ...styles.cell, ...styles.th } }, t('models.colContext')),
|
|
1548
|
+
),
|
|
1549
|
+
),
|
|
1550
|
+
React.createElement(
|
|
1551
|
+
'tbody',
|
|
1552
|
+
null,
|
|
1553
|
+
visibleModels(state.models, expanded).map((model) => {
|
|
1554
|
+
const context = formatContextWindow(model.contextWindow)
|
|
1555
|
+
return React.createElement(
|
|
1556
|
+
'tr',
|
|
1557
|
+
{ key: model.id },
|
|
1558
|
+
React.createElement('td', { style: styles.cell }, React.createElement('code', { style: styles.mono }, model.id)),
|
|
1559
|
+
React.createElement('td', { style: styles.cell }, model.name || ''),
|
|
1560
|
+
// The header carries the word, so the cell carries only the
|
|
1561
|
+
// magnitude: "Context window | 872K", not "Context window | 872K context".
|
|
1562
|
+
React.createElement('td', { style: { ...styles.cell, whiteSpace: 'nowrap', opacity: 0.7 } }, context === undefined ? '' : context),
|
|
1563
|
+
)
|
|
1564
|
+
}),
|
|
1565
|
+
),
|
|
1566
|
+
),
|
|
1567
|
+
state.models.length > MODEL_PREVIEW_COUNT
|
|
1568
|
+
? React.createElement(
|
|
1569
|
+
Button,
|
|
1570
|
+
{
|
|
1571
|
+
variant: 'ghost',
|
|
1572
|
+
size: 'sm',
|
|
1573
|
+
'aria-expanded': expanded,
|
|
1574
|
+
onClick: () => {
|
|
1575
|
+
setExpanded(!expanded)
|
|
1576
|
+
},
|
|
1577
|
+
},
|
|
1578
|
+
expanded ? t('ui.collapse') : t('models.expandAll', { count: state.models.length }),
|
|
1579
|
+
)
|
|
1580
|
+
: null,
|
|
1581
|
+
)
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Credential status read through the settings-controller Remote namespace:
|
|
1586
|
+
* `describe()` returns `{configured, source, writable}` and never a value, so
|
|
1587
|
+
* this row can state the key's health without a code path that could leak it.
|
|
1588
|
+
*/
|
|
1589
|
+
function KeyStatus(props) {
|
|
1590
|
+
const { describeKey, keyRef } = props
|
|
1591
|
+
const [state, setState] = React.useState({ status: 'loading' })
|
|
1592
|
+
React.useEffect(() => {
|
|
1593
|
+
let live = true
|
|
1594
|
+
describeKey(keyRef).then(
|
|
1595
|
+
(info) => {
|
|
1596
|
+
if (live) setState({ status: 'ready', info })
|
|
1597
|
+
},
|
|
1598
|
+
(err) => {
|
|
1599
|
+
if (live) setState({ status: 'error', message: String(err && err.message ? err.message : err) })
|
|
1600
|
+
},
|
|
1601
|
+
)
|
|
1602
|
+
return () => {
|
|
1603
|
+
live = false
|
|
1604
|
+
}
|
|
1605
|
+
}, [describeKey, keyRef])
|
|
1606
|
+
|
|
1607
|
+
if (state.status === 'loading') return React.createElement('span', { style: styles.notice }, t('key.checking'))
|
|
1608
|
+
if (state.status === 'error') return React.createElement('span', { style: styles.error }, t('key.checkFailed', { message: state.message }))
|
|
1609
|
+
const info = state.info
|
|
1610
|
+
if (info === undefined) return React.createElement('span', { style: styles.error }, t('key.missingRef'))
|
|
1611
|
+
return React.createElement(
|
|
1612
|
+
'span',
|
|
1613
|
+
{ style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' } },
|
|
1614
|
+
React.createElement('span', { style: styles.badge }, info.configured ? t('key.configured') : t('key.missing')),
|
|
1615
|
+
info.source ? React.createElement('span', { style: styles.badge }, t('key.source', { source: info.source })) : null,
|
|
1616
|
+
React.createElement('span', { style: styles.badge }, info.writable ? t('key.editable') : t('key.locked')),
|
|
1617
|
+
)
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* The Kenari settings page.
|
|
1622
|
+
*
|
|
1623
|
+
* Ordered by what a reader arrives for: is it connected (key status and the
|
|
1624
|
+
* name it is stored under), see what it serves, decide about session titles
|
|
1625
|
+
* — and then, behind folds, the two groups most readers never open. Nothing
|
|
1626
|
+
* here names a config key, a schema field or an endpoint: the file-level
|
|
1627
|
+
* facts live in the documentation.
|
|
1628
|
+
*/
|
|
1629
|
+
function KenariSection(props) {
|
|
1630
|
+
const { scope, loadModels, describeKey } = props
|
|
1631
|
+
const snapshot = useScopeSnapshot(scope)
|
|
1632
|
+
const value = snapshot.value || {}
|
|
1633
|
+
// A field's PRESENCE in the raw user layer is what marks it overridden —
|
|
1634
|
+
// an override equal to the composition default is still an override.
|
|
1635
|
+
const user = snapshot.user || {}
|
|
1636
|
+
const writable = snapshot.writable === true
|
|
1637
|
+
|
|
1638
|
+
const write = (field, next) => scope.set(field, next)
|
|
1639
|
+
|
|
1640
|
+
// Only speak up when there is something to say. "Settings are in sync" on
|
|
1641
|
+
// a page that is always in sync is one more line to read and skip.
|
|
1642
|
+
const statusNotice = snapshot.status === 'ready'
|
|
1643
|
+
? undefined
|
|
1644
|
+
: snapshot.status === 'loading' ? t('connection.status.loading') : t('connection.status.unavailable')
|
|
1645
|
+
|
|
1646
|
+
// The untouched default is the reference the Host would resolve anyway, so
|
|
1647
|
+
// the key-status row reports on the key actually in effect.
|
|
1648
|
+
const keyRef = value.apiKeyEnv || 'KENARI_API_KEY'
|
|
1649
|
+
|
|
1650
|
+
const liveField = (spec) => (spec.kind === 'readonly'
|
|
1651
|
+
? React.createElement(ReadOnlyRow, { key: spec.field, spec, value: value[spec.field] })
|
|
1652
|
+
: React.createElement(LiveField, {
|
|
1653
|
+
key: spec.field,
|
|
1654
|
+
spec,
|
|
1655
|
+
committed: value[spec.field],
|
|
1656
|
+
overridden: Object.prototype.hasOwnProperty.call(user, spec.field),
|
|
1657
|
+
writable,
|
|
1658
|
+
onWrite: write,
|
|
1659
|
+
}))
|
|
1660
|
+
|
|
1661
|
+
return React.createElement(
|
|
1662
|
+
'div',
|
|
1663
|
+
{ style: styles.root },
|
|
1664
|
+
React.createElement('h3', { style: styles.title }, t('page.title')),
|
|
1665
|
+
React.createElement('p', { style: styles.lead }, t('page.lead')),
|
|
1666
|
+
|
|
1667
|
+
// A status card, not a settings card: it answers "does Kenari work, and
|
|
1668
|
+
// if not, where do I fix it" — the key itself is entered on the model
|
|
1669
|
+
// route's own card, where dsh's password input lives.
|
|
1670
|
+
React.createElement(
|
|
1671
|
+
'div',
|
|
1672
|
+
{ style: styles.block },
|
|
1673
|
+
React.createElement('h4', { style: styles.blockTitle }, t('connection.title')),
|
|
1674
|
+
React.createElement(
|
|
1675
|
+
'div',
|
|
1676
|
+
{ style: styles.row },
|
|
1677
|
+
React.createElement('div', { style: styles.label }, t('connection.keyStatus')),
|
|
1678
|
+
React.createElement(KeyStatus, { describeKey, keyRef }),
|
|
1679
|
+
),
|
|
1680
|
+
// The name the key is stored under sits with the key, not behind the
|
|
1681
|
+
// 高级设置 fold: it answers the same question the status badges do.
|
|
1682
|
+
liveField(KEY_REFERENCE_FIELD),
|
|
1683
|
+
React.createElement('p', { style: styles.notice }, t('connection.notice')),
|
|
1684
|
+
statusNotice === undefined ? null : React.createElement('p', { style: styles.notice }, statusNotice),
|
|
1685
|
+
),
|
|
1686
|
+
|
|
1687
|
+
React.createElement(
|
|
1688
|
+
'div',
|
|
1689
|
+
{ style: styles.block },
|
|
1690
|
+
React.createElement('h4', { style: styles.blockTitle }, t('models.title')),
|
|
1691
|
+
React.createElement(ModelList, { loadModels }),
|
|
1692
|
+
React.createElement('p', { style: styles.notice }, t('models.hint')),
|
|
1693
|
+
),
|
|
1694
|
+
|
|
1695
|
+
React.createElement(
|
|
1696
|
+
'div',
|
|
1697
|
+
{ style: styles.block },
|
|
1698
|
+
React.createElement('h4', { style: styles.blockTitle }, t('sessionTitle.title')),
|
|
1699
|
+
React.createElement(
|
|
1700
|
+
'div',
|
|
1701
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
|
|
1702
|
+
SESSION_TITLE_FIELDS.map(liveField),
|
|
1703
|
+
),
|
|
1704
|
+
),
|
|
1705
|
+
|
|
1706
|
+
React.createElement(
|
|
1707
|
+
Disclosure,
|
|
1708
|
+
{ title: t('advanced.title'), hint: t('advanced.hint') },
|
|
1709
|
+
React.createElement(
|
|
1710
|
+
'div',
|
|
1711
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '10px' } },
|
|
1712
|
+
ADVANCED_FIELDS.map(liveField),
|
|
1713
|
+
),
|
|
1714
|
+
),
|
|
1715
|
+
|
|
1716
|
+
React.createElement(
|
|
1717
|
+
Disclosure,
|
|
1718
|
+
{ title: t('restart.title'), hint: t('restart.hint') },
|
|
1719
|
+
React.createElement(
|
|
1720
|
+
'div',
|
|
1721
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '10px' } },
|
|
1722
|
+
RESTART_FIELDS.map((spec) => React.createElement(ReadOnlyRow, { key: spec.field, spec, value: value[spec.field] })),
|
|
1723
|
+
),
|
|
1724
|
+
),
|
|
1725
|
+
|
|
1726
|
+
React.createElement(
|
|
1727
|
+
'div',
|
|
1728
|
+
{ style: styles.block },
|
|
1729
|
+
React.createElement('h4', { style: styles.blockTitle }, t('usage.title')),
|
|
1730
|
+
React.createElement('p', { style: styles.notice }, t('usage.hint')),
|
|
1731
|
+
React.createElement(
|
|
1732
|
+
'table',
|
|
1733
|
+
{ style: styles.table },
|
|
1734
|
+
React.createElement(
|
|
1735
|
+
'thead',
|
|
1736
|
+
null,
|
|
1737
|
+
React.createElement(
|
|
1738
|
+
'tr',
|
|
1739
|
+
null,
|
|
1740
|
+
React.createElement('th', { style: { ...styles.cell, ...styles.th } }, t('usage.what')),
|
|
1741
|
+
React.createElement('th', { style: { ...styles.cell, ...styles.th } }, t('usage.ask')),
|
|
1742
|
+
),
|
|
1743
|
+
),
|
|
1744
|
+
React.createElement(
|
|
1745
|
+
'tbody',
|
|
1746
|
+
null,
|
|
1747
|
+
TOOL_ONLY_FACTS.map((fact) =>
|
|
1748
|
+
React.createElement(
|
|
1749
|
+
'tr',
|
|
1750
|
+
{ key: fact.what },
|
|
1751
|
+
React.createElement('td', { style: styles.cell }, t(fact.what)),
|
|
1752
|
+
// The right-hand cell is the thing to say out loud, so it is
|
|
1753
|
+
// the example question rather than a tool name: a tool name is
|
|
1754
|
+
// not something a reader can call — the agent is.
|
|
1755
|
+
React.createElement('td', { style: styles.cell }, t(fact.ask)),
|
|
1756
|
+
),
|
|
1757
|
+
),
|
|
1758
|
+
),
|
|
1759
|
+
),
|
|
1760
|
+
),
|
|
1761
|
+
)
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Services this bundle needs: the slot ledger, the Remotes, our settings
|
|
1766
|
+
* scope, and the locale service that answers this bundle's own copy.
|
|
1767
|
+
*/
|
|
1768
|
+
const inject = ['slots', 'locale', 'remote', 'remote.llm', 'remote.credentials', 'remote.settings', 'settingsScope']
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Register the Kenari settings page once the shell has declared
|
|
1772
|
+
* `settings.section` (a registration before that declaration throws).
|
|
1773
|
+
*/
|
|
1774
|
+
function apply(ctx) {
|
|
1775
|
+
const scope = ctx.settingsScope.bind({ namespace: NS })
|
|
1776
|
+
|
|
1777
|
+
// Bind the translator before anything can render: the components below
|
|
1778
|
+
// call it, and every one of them is reachable from here on.
|
|
1779
|
+
t = ctx.locale.bind(NS)
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* The catalog's display names, keyed by id, read from this plugin's own
|
|
1783
|
+
* same-origin view. Best-effort on purpose: a name is a nicety and the id
|
|
1784
|
+
* is the fact, so a view that will not load must not take the list with it.
|
|
1785
|
+
*/
|
|
1786
|
+
const displayNames = async () => {
|
|
1787
|
+
try {
|
|
1788
|
+
const view = await loadModelView()
|
|
1789
|
+
const names = {}
|
|
1790
|
+
for (const model of view.models || []) {
|
|
1791
|
+
if (typeof model.id === 'string' && typeof model.name === 'string') names[model.id] = model.name
|
|
1792
|
+
}
|
|
1793
|
+
return names
|
|
1794
|
+
} catch {
|
|
1795
|
+
return {}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* Model discovery is per owning settings namespace, and a route pi-ai does
|
|
1801
|
+
* not ship also needs the endpoint to ask: `llm-pi-ai` owns the shipped
|
|
1802
|
+
* preset route, while the plugin's own adapter (phase 5) owns its route
|
|
1803
|
+
* under this plugin's namespace. Try the shipped route first so the card
|
|
1804
|
+
* works out of the box, then the native one.
|
|
1805
|
+
*
|
|
1806
|
+
* The listing carries no usable names (see `withDisplayNames`), so the
|
|
1807
|
+
* rows are joined with this plugin's own view before they are returned.
|
|
1808
|
+
*/
|
|
1809
|
+
const loadModels = async () => {
|
|
1810
|
+
const settings = scope.getSnapshot().value || {}
|
|
1811
|
+
const providerId = settings.nativeProviderId || 'kenari-direct'
|
|
1812
|
+
// pi-ai wants the API base ([OI] shape, /v1 included); our REST base is
|
|
1813
|
+
// the bare host, so normalize the same way the Host client does.
|
|
1814
|
+
const restBase = String(settings.baseURL || 'https://kenari.id').replace(/\/+$/, '')
|
|
1815
|
+
const apiBase = restBase.endsWith('/v1') ? restBase : `${restBase}/v1`
|
|
1816
|
+
const candidates = [
|
|
1817
|
+
{ ns: 'llm-pi-ai', request: { provider: 'kenari', baseURL: apiBase, api: 'openai-completions' } },
|
|
1818
|
+
{ ns: NS, request: { provider: providerId } },
|
|
1819
|
+
]
|
|
1820
|
+
const failures = []
|
|
1821
|
+
for (const candidate of candidates) {
|
|
1822
|
+
const result = await ctx.remote.llm.discoverModels(candidate.ns, candidate.request)
|
|
1823
|
+
if (result.ok && result.value.length > 0) {
|
|
1824
|
+
return { route: candidate.request.provider, models: withDisplayNames(result.value, await displayNames()) }
|
|
1825
|
+
}
|
|
1826
|
+
failures.push(`${candidate.request.provider}: ${result.ok ? t('models.routeEmpty') : result.error.code}`)
|
|
1827
|
+
}
|
|
1828
|
+
throw new Error(t('models.noRoute', { failures: failures.join('; ') }))
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/** `describe()` is the only key reader: it answers state, never the value. */
|
|
1832
|
+
const describeKey = async (keyRef) => {
|
|
1833
|
+
const result = await ctx.remote.credentials.describe([keyRef])
|
|
1834
|
+
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
|
1835
|
+
return result.value[keyRef]
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* The Host-computed model view, read through the plugin's own same-origin
|
|
1840
|
+
* Fetch route. Same-origin means the browser session that already
|
|
1841
|
+
* authenticates Remote calls carries this too — no CORS, no new Host API
|
|
1842
|
+
* package, and the payload (tags, plan coverage) is built once, Host-side.
|
|
1843
|
+
*/
|
|
1844
|
+
const loadModelView = async () => {
|
|
1845
|
+
const response = await fetch(MODEL_VIEW_PATH, { headers: { accept: 'application/json' } })
|
|
1846
|
+
if (!response.ok) {
|
|
1847
|
+
const detail = await response.json().catch(() => undefined)
|
|
1848
|
+
const message = detail && detail.error ? detail.error : `HTTP ${response.status}`
|
|
1849
|
+
throw new Error(message)
|
|
1850
|
+
}
|
|
1851
|
+
return response.json()
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
/** The pi-ai namespace view, or undefined when it is not mounted. */
|
|
1855
|
+
const readPiAi = async () => {
|
|
1856
|
+
const described = await ctx.remote.settings.describe()
|
|
1857
|
+
if (!described.ok) return { failure: `${described.error.code}: ${described.error.message}` }
|
|
1858
|
+
const namespaces = described.value.namespaces || []
|
|
1859
|
+
const view = namespaces.find((entry) => entry.ns === PI_AI_NS)
|
|
1860
|
+
return { view, writable: described.value.writable === true }
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/** Panel data: the catalog view, plus (when the route is addressable) what it already serves. */
|
|
1864
|
+
const loadPanel = async (route) => {
|
|
1865
|
+
const view = await loadModelView()
|
|
1866
|
+
if (route === undefined) return { view }
|
|
1867
|
+
const read = await readPiAi()
|
|
1868
|
+
if (read.failure !== undefined || read.view === undefined) return { view }
|
|
1869
|
+
return {
|
|
1870
|
+
view,
|
|
1871
|
+
route: {
|
|
1872
|
+
modelIds: routeModelsOf(read.view, route.provider).map((model) => model && model.id).filter((id) => typeof id === 'string'),
|
|
1873
|
+
writable: read.writable,
|
|
1874
|
+
},
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/**
|
|
1879
|
+
* Append the selected profiles to the route's model array.
|
|
1880
|
+
*
|
|
1881
|
+
* A stored array REPLACES the shipped one, so the write must carry the
|
|
1882
|
+
* list that is currently in effect (user layer when it owns the array,
|
|
1883
|
+
* else the composition layer the patch pinned) and only append. Without
|
|
1884
|
+
* that, adding one paid model would silently drop the free presets.
|
|
1885
|
+
*/
|
|
1886
|
+
const addModels = async (route, profiles) => {
|
|
1887
|
+
const read = await readPiAi()
|
|
1888
|
+
if (read.failure !== undefined) return { ok: false, message: read.failure }
|
|
1889
|
+
if (read.view === undefined) return { ok: false, message: t('catalog.noNamespace', { ns: PI_AI_NS }) }
|
|
1890
|
+
if (read.writable !== true) return { ok: false, message: t('catalog.settingsReadOnly') }
|
|
1891
|
+
const existing = routeModelsOf(read.view, route.provider)
|
|
1892
|
+
const byId = new Map()
|
|
1893
|
+
for (const model of existing) {
|
|
1894
|
+
if (model && typeof model.id === 'string') byId.set(model.id, model)
|
|
1895
|
+
}
|
|
1896
|
+
for (const profile of profiles) if (!byId.has(profile.id)) byId.set(profile.id, profile)
|
|
1897
|
+
const written = await ctx.remote.settings.mutate(
|
|
1898
|
+
route.settingsNs,
|
|
1899
|
+
[{ op: 'set', path: ['providers', route.provider, 'models'], value: [...byId.values()] }],
|
|
1900
|
+
read.view.revision,
|
|
1901
|
+
)
|
|
1902
|
+
if (!written.ok) return { ok: false, message: `${written.error.code}: ${written.error.message}` }
|
|
1903
|
+
return { ok: true }
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
const injected = () => ({ scope, loadModels, describeKey })
|
|
1907
|
+
ctx.slots.inject('settings.section', () =>
|
|
1908
|
+
ctx.slots.register(
|
|
1909
|
+
// `locale: NS` is what mints the props `t` seat and makes a missing
|
|
1910
|
+
// locale face fail loudly instead of silently painting raw keys. This
|
|
1911
|
+
// bundle reads its copy through the module-bound translator instead,
|
|
1912
|
+
// which reads the active locale at call time — the outlet's own
|
|
1913
|
+
// locale-revision subscription is what re-renders the switch.
|
|
1914
|
+
{ name: 'settings.section', id: NS, order: 30, label: () => t('nav'), locale: NS, inject: injected },
|
|
1915
|
+
KenariSection,
|
|
1916
|
+
),
|
|
1917
|
+
)
|
|
1918
|
+
|
|
1919
|
+
// The Models-page seat: dsh declares this slot for exactly this purpose
|
|
1920
|
+
// ("a plugin distributed outside this repository adds UI to the Models
|
|
1921
|
+
// settings section without editing it"), keyed by the owning settings
|
|
1922
|
+
// namespace. The component narrows it to the Kenari route and doubles as
|
|
1923
|
+
// the anchor and renderer for the 模型目录 takeover below.
|
|
1924
|
+
ctx.slots.inject('settings.models.provider-card', () =>
|
|
1925
|
+
ctx.slots.register(
|
|
1926
|
+
{
|
|
1927
|
+
name: 'settings.models.provider-card',
|
|
1928
|
+
key: PI_AI_NS,
|
|
1929
|
+
locale: NS,
|
|
1930
|
+
inject: () => ({ loadPanel, addModels }),
|
|
1931
|
+
},
|
|
1932
|
+
ModelPickerHost,
|
|
1933
|
+
),
|
|
1934
|
+
)
|
|
1935
|
+
|
|
1936
|
+
// The dictionary for this bundle's own copy. Registered as an effect so a
|
|
1937
|
+
// reload cannot leave a stale table behind, exactly like dsh's own plugins.
|
|
1938
|
+
ctx.effect(() => ctx.locale.register(NS, LOCALES), 'kenari: 界面文案字典')
|
|
1939
|
+
|
|
1940
|
+
// One document-level listener, torn down with this fiber. It is inert
|
|
1941
|
+
// until the Kenari card mounts, because a click only means anything when
|
|
1942
|
+
// this plugin's dialog can answer it.
|
|
1943
|
+
ctx.effect(() => installEntryTakeover(), 'kenari: 模型目录入口接管')
|
|
1944
|
+
ensureDialogWidth()
|
|
1945
|
+
|
|
1946
|
+
// The settings nav glyph. dsh picks its own by section id, so this is the
|
|
1947
|
+
// only way a plugin can carry its own mark into that rail.
|
|
1948
|
+
ctx.effect(() => installSettingsNavIcon(), 'kenari: 设置导航图标')
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
exports.NS = NS
|
|
1952
|
+
exports.inject = inject
|
|
1953
|
+
exports.apply = apply
|
|
1954
|
+
// The build gate (scripts/check-client.mjs) drives the pure logic directly:
|
|
1955
|
+
// filters, plan coverage, and the route read/write shapes are where a
|
|
1956
|
+
// regression is silent in the browser.
|
|
1957
|
+
exports.__internals = {
|
|
1958
|
+
CAPABILITY_TAGS,
|
|
1959
|
+
TAKEOVER_LABELS,
|
|
1960
|
+
isTakeoverLabel,
|
|
1961
|
+
MARKER_ATTR,
|
|
1962
|
+
pathGet,
|
|
1963
|
+
routeModelsOf,
|
|
1964
|
+
planCovered,
|
|
1965
|
+
matchesFilters,
|
|
1966
|
+
toggleFilter,
|
|
1967
|
+
derivePanel,
|
|
1968
|
+
markedCard,
|
|
1969
|
+
cardWithMarker,
|
|
1970
|
+
ModelCatalogModal,
|
|
1971
|
+
// Localization and the folded groups: a key with no translation, or a
|
|
1972
|
+
// "collapsed" block that opens by default, is invisible until a user
|
|
1973
|
+
// notices. The gate asserts both.
|
|
1974
|
+
LOCALES,
|
|
1975
|
+
LOCALE_NS: NS,
|
|
1976
|
+
LOCALE_SPECS,
|
|
1977
|
+
ADVANCED_FIELDS,
|
|
1978
|
+
KEY_REFERENCE_FIELD,
|
|
1979
|
+
TOOL_ONLY_FACTS,
|
|
1980
|
+
MODEL_PREVIEW_COUNT,
|
|
1981
|
+
visibleModels,
|
|
1982
|
+
withDisplayNames,
|
|
1983
|
+
formatContextWindow,
|
|
1984
|
+
tagLabel,
|
|
1985
|
+
Disclosure,
|
|
1986
|
+
ReadOnlyRow,
|
|
1987
|
+
NAV_ICON_ATTR,
|
|
1988
|
+
NAV_ICON_STYLE_ATTR,
|
|
1989
|
+
NAV_ICON_URL,
|
|
1990
|
+
NAV_ROW_SELECTOR,
|
|
1991
|
+
SETTINGS_SECTION_LABEL,
|
|
1992
|
+
navIconRule,
|
|
1993
|
+
settingsNavButton,
|
|
1994
|
+
markNavRow,
|
|
1995
|
+
}
|
|
1996
|
+
return module.exports
|
|
1997
|
+
},
|
|
1998
|
+
})
|