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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +281 -0
  3. package/README.md +283 -0
  4. package/assets/kenari-favicon-128.png +0 -0
  5. package/assets/kenari-favicon-256.png +0 -0
  6. package/client/index.js +1998 -0
  7. package/cordis.patch.yml +137 -0
  8. package/lib/billing.d.ts +120 -0
  9. package/lib/billing.d.ts.map +1 -0
  10. package/lib/billing.js +209 -0
  11. package/lib/billing.js.map +1 -0
  12. package/lib/catalog-view.d.ts +78 -0
  13. package/lib/catalog-view.d.ts.map +1 -0
  14. package/lib/catalog-view.js +113 -0
  15. package/lib/catalog-view.js.map +1 -0
  16. package/lib/catalog.d.ts +239 -0
  17. package/lib/catalog.d.ts.map +1 -0
  18. package/lib/catalog.js +445 -0
  19. package/lib/catalog.js.map +1 -0
  20. package/lib/default-route.d.ts +35 -0
  21. package/lib/default-route.d.ts.map +1 -0
  22. package/lib/default-route.js +111 -0
  23. package/lib/default-route.js.map +1 -0
  24. package/lib/errors.d.ts +29 -0
  25. package/lib/errors.d.ts.map +1 -0
  26. package/lib/errors.js +74 -0
  27. package/lib/errors.js.map +1 -0
  28. package/lib/favicon.d.ts +24 -0
  29. package/lib/favicon.d.ts.map +1 -0
  30. package/lib/favicon.js +68 -0
  31. package/lib/favicon.js.map +1 -0
  32. package/lib/http.d.ts +104 -0
  33. package/lib/http.d.ts.map +1 -0
  34. package/lib/http.js +247 -0
  35. package/lib/http.js.map +1 -0
  36. package/lib/index.d.ts +83 -0
  37. package/lib/index.d.ts.map +1 -0
  38. package/lib/index.js +285 -0
  39. package/lib/index.js.map +1 -0
  40. package/lib/llm/adapter.d.ts +78 -0
  41. package/lib/llm/adapter.d.ts.map +1 -0
  42. package/lib/llm/adapter.js +429 -0
  43. package/lib/llm/adapter.js.map +1 -0
  44. package/lib/llm/candidates.d.ts +57 -0
  45. package/lib/llm/candidates.d.ts.map +1 -0
  46. package/lib/llm/candidates.js +86 -0
  47. package/lib/llm/candidates.js.map +1 -0
  48. package/lib/llm/recovery.d.ts +48 -0
  49. package/lib/llm/recovery.d.ts.map +1 -0
  50. package/lib/llm/recovery.js +118 -0
  51. package/lib/llm/recovery.js.map +1 -0
  52. package/lib/llm/retry.d.ts +35 -0
  53. package/lib/llm/retry.d.ts.map +1 -0
  54. package/lib/llm/retry.js +53 -0
  55. package/lib/llm/retry.js.map +1 -0
  56. package/lib/plans.d.ts +59 -0
  57. package/lib/plans.d.ts.map +1 -0
  58. package/lib/plans.js +118 -0
  59. package/lib/plans.js.map +1 -0
  60. package/lib/session-title.d.ts +63 -0
  61. package/lib/session-title.d.ts.map +1 -0
  62. package/lib/session-title.js +280 -0
  63. package/lib/session-title.js.map +1 -0
  64. package/lib/settings.d.ts +35 -0
  65. package/lib/settings.d.ts.map +1 -0
  66. package/lib/settings.js +78 -0
  67. package/lib/settings.js.map +1 -0
  68. package/lib/tools/account.d.ts +11 -0
  69. package/lib/tools/account.d.ts.map +1 -0
  70. package/lib/tools/account.js +132 -0
  71. package/lib/tools/account.js.map +1 -0
  72. package/lib/tools/billing.d.ts +10 -0
  73. package/lib/tools/billing.d.ts.map +1 -0
  74. package/lib/tools/billing.js +100 -0
  75. package/lib/tools/billing.js.map +1 -0
  76. package/lib/tools/count-tokens.d.ts +11 -0
  77. package/lib/tools/count-tokens.d.ts.map +1 -0
  78. package/lib/tools/count-tokens.js +84 -0
  79. package/lib/tools/count-tokens.js.map +1 -0
  80. package/lib/tools/data.d.ts +12 -0
  81. package/lib/tools/data.d.ts.map +1 -0
  82. package/lib/tools/data.js +205 -0
  83. package/lib/tools/data.js.map +1 -0
  84. package/lib/tools/docs.d.ts +13 -0
  85. package/lib/tools/docs.d.ts.map +1 -0
  86. package/lib/tools/docs.js +209 -0
  87. package/lib/tools/docs.js.map +1 -0
  88. package/lib/tools/documents.d.ts +10 -0
  89. package/lib/tools/documents.d.ts.map +1 -0
  90. package/lib/tools/documents.js +118 -0
  91. package/lib/tools/documents.js.map +1 -0
  92. package/lib/tools/media.d.ts +28 -0
  93. package/lib/tools/media.d.ts.map +1 -0
  94. package/lib/tools/media.js +599 -0
  95. package/lib/tools/media.js.map +1 -0
  96. package/lib/tools/shared.d.ts +89 -0
  97. package/lib/tools/shared.d.ts.map +1 -0
  98. package/lib/tools/shared.js +128 -0
  99. package/lib/tools/shared.js.map +1 -0
  100. package/lib/tools/x-search.d.ts +11 -0
  101. package/lib/tools/x-search.d.ts.map +1 -0
  102. package/lib/tools/x-search.js +129 -0
  103. package/lib/tools/x-search.js.map +1 -0
  104. package/lib/web/fallback.d.ts +39 -0
  105. package/lib/web/fallback.d.ts.map +1 -0
  106. package/lib/web/fallback.js +79 -0
  107. package/lib/web/fallback.js.map +1 -0
  108. package/lib/web/fetch.d.ts +19 -0
  109. package/lib/web/fetch.d.ts.map +1 -0
  110. package/lib/web/fetch.js +41 -0
  111. package/lib/web/fetch.js.map +1 -0
  112. package/lib/web/search.d.ts +21 -0
  113. package/lib/web/search.d.ts.map +1 -0
  114. package/lib/web/search.js +47 -0
  115. package/lib/web/search.js.map +1 -0
  116. package/package.json +84 -0
@@ -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
+ })