@maci0/dsh-chatjimmy 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +145 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/cordis.patch.yml +13 -0
- package/icon.svg +6 -0
- package/lib/adapter.js +421 -0
- package/lib/client.js +398 -0
- package/lib/host.js +21 -0
- package/lib/index.js +172 -0
- package/lib/protocol.js +172 -0
- package/lib/types/adapter.d.ts +78 -0
- package/lib/types/host.d.ts +177 -0
- package/lib/types/index.d.ts +125 -0
- package/lib/types/protocol.d.ts +101 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +95 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-chatjimmy: browser half.
|
|
3
|
+
*
|
|
4
|
+
* One surface: the ChatJimmy card on the Plugins page, keyed on the
|
|
5
|
+
* `chatjimmy` settings namespace the host half declares as its row id. Every
|
|
6
|
+
* field of the row that the host marks `volatile()` is editable here, and the
|
|
7
|
+
* adapter resolves the row per read, so a save lands on the next request
|
|
8
|
+
* without remounting the provider route.
|
|
9
|
+
*
|
|
10
|
+
* The form stages edits locally and writes them in one `scope.mutate` call:
|
|
11
|
+
* a half-typed base URL must not reach the settings document field by field.
|
|
12
|
+
* Chrome is a stylesheet, not inline style objects: the module system claims
|
|
13
|
+
* every `<style>` tag a factory appends while it materializes and removes it
|
|
14
|
+
* when the package unloads. Classes are `cj-`-prefixed because that sheet
|
|
15
|
+
* lands in the page's own document.
|
|
16
|
+
*
|
|
17
|
+
* This file is plain JavaScript on purpose. The client module system serves a
|
|
18
|
+
* package's `exports["./client"]` artifact as a lazy-CJS factory registered on
|
|
19
|
+
* `window.__ModuleLoader__`, and that is the whole format: an out-of-tree
|
|
20
|
+
* plugin can author it directly instead of reproducing the repository's tsdown
|
|
21
|
+
* client preset. `react` is provided by the module system; nothing else is
|
|
22
|
+
* required here.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
window.__ModuleLoader__.load({
|
|
26
|
+
id: '@maci0/dsh-chatjimmy',
|
|
27
|
+
|
|
28
|
+
factory: (require) => {
|
|
29
|
+
var module = { exports: {} }
|
|
30
|
+
var exports = module.exports
|
|
31
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
32
|
+
|
|
33
|
+
const React = require('react')
|
|
34
|
+
|
|
35
|
+
/** Settings namespace shared with the host half; also this card's slot key. */
|
|
36
|
+
const NAMESPACE = 'chatjimmy'
|
|
37
|
+
|
|
38
|
+
/** Locale namespace for this plugin's copy. */
|
|
39
|
+
const LOCALE_NS = 'chatjimmy'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every editable row field, in display order. `integer` fields are sent as
|
|
43
|
+
* numbers and must be positive integers; the host schema bounds each one,
|
|
44
|
+
* and the card refuses what it can see before the write.
|
|
45
|
+
*/
|
|
46
|
+
const FIELDS = [
|
|
47
|
+
{ key: 'baseUrl', kind: 'text', label: 'labelBaseUrl', hint: 'hintBaseUrl' },
|
|
48
|
+
{ key: 'model', kind: 'text', label: 'labelModel', hint: 'hintModel' },
|
|
49
|
+
{ key: 'topK', kind: 'integer', label: 'labelTopK', hint: 'hintTopK' },
|
|
50
|
+
{ key: 'contextWindow', kind: 'integer', label: 'labelContextWindow', hint: 'hintContextWindow' },
|
|
51
|
+
{ key: 'streamIdleTimeoutMs', kind: 'integer', label: 'labelStreamIdleTimeoutMs', hint: 'hintStreamIdleTimeoutMs' },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/** Every class is `cj-`-prefixed: the sheet lands in the page's own document. */
|
|
55
|
+
const CSS = [
|
|
56
|
+
'.cj-page{display:flex;flex-direction:column;gap:12px}',
|
|
57
|
+
'.cj-field{display:flex;flex-direction:column;gap:4px}',
|
|
58
|
+
'.cj-label{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}',
|
|
59
|
+
'.cj-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
|
|
60
|
+
'.cj-input{font:inherit;font-size:13px;line-height:1.5;padding:5px 12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;width:100%;box-sizing:border-box}',
|
|
61
|
+
'.cj-input:disabled{cursor:default;opacity:.5}',
|
|
62
|
+
'.cj-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}',
|
|
63
|
+
'.cj-button{appearance:none;font:inherit;font-size:13px;line-height:1.5;padding:5px 14px;cursor:pointer;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
|
|
64
|
+
'.cj-button:disabled{cursor:default;opacity:.5}',
|
|
65
|
+
'.cj-button-quiet{padding:3px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
66
|
+
'.cj-status{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
|
|
67
|
+
'.cj-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}',
|
|
68
|
+
].join('')
|
|
69
|
+
|
|
70
|
+
// Appended while the factory materializes: the module system claims the tag
|
|
71
|
+
// for this package and disposes it on unload. Guarded because the node unit
|
|
72
|
+
// tests evaluate this file without a DOM.
|
|
73
|
+
if (typeof document !== 'undefined') {
|
|
74
|
+
const style = document.createElement('style')
|
|
75
|
+
style.textContent = CSS
|
|
76
|
+
document.head.append(style)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Plugin version, shown in the card footer. Kept in lockstep with package.json. */
|
|
80
|
+
const VERSION = '0.11.4'
|
|
81
|
+
|
|
82
|
+
const en = {
|
|
83
|
+
title: 'ChatJimmy',
|
|
84
|
+
summary: 'ChatJimmy text provider: model {model} at {baseUrl}.',
|
|
85
|
+
summaryEmpty: 'ChatJimmy text provider (not configured yet).',
|
|
86
|
+
description: 'ChatJimmy text provider: {model}',
|
|
87
|
+
labelBaseUrl: 'Base URL',
|
|
88
|
+
labelModel: 'Model',
|
|
89
|
+
labelTopK: 'topK',
|
|
90
|
+
labelContextWindow: 'Context window (tokens)',
|
|
91
|
+
labelStreamIdleTimeoutMs: 'Stream idle timeout (ms)',
|
|
92
|
+
hintBaseUrl: 'Deployment origin, no trailing slash needed.',
|
|
93
|
+
hintModel: 'Model id sent as chatOptions.selectedModel.',
|
|
94
|
+
hintTopK: 'Sampling breadth forwarded to the service. The site itself sends 8.',
|
|
95
|
+
hintContextWindow: 'Prompt + completion tokens; the service enforces 6144.',
|
|
96
|
+
hintStreamIdleTimeoutMs: 'A stream silent for this long ends with the TIMEOUT failure.',
|
|
97
|
+
overridden: 'overridden',
|
|
98
|
+
save: 'Save',
|
|
99
|
+
saving: 'Saving…',
|
|
100
|
+
saved: 'Saved. The next request uses these values.',
|
|
101
|
+
reset: 'Reset to defaults',
|
|
102
|
+
persists: 'Stored in your profile; the adapter reads the row on every request.',
|
|
103
|
+
readOnly: 'Read-only: this deployment does not persist settings.',
|
|
104
|
+
baseUrlInvalid: 'Base URL must be an absolute http(s) URL.',
|
|
105
|
+
modelInvalid: 'Model must be a non-empty id.',
|
|
106
|
+
numberInvalid: 'Must be a positive whole number.',
|
|
107
|
+
rejected: 'The Host refused the write; the previous values are still in effect.',
|
|
108
|
+
failed: 'Could not save: {message}',
|
|
109
|
+
version: 'v{version}',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const zh = {
|
|
113
|
+
title: 'ChatJimmy',
|
|
114
|
+
summary: 'ChatJimmy 文本提供方:模型 {model},地址 {baseUrl}。',
|
|
115
|
+
summaryEmpty: 'ChatJimmy 文本提供方(尚未配置)。',
|
|
116
|
+
description: 'ChatJimmy 文本提供方:{model}',
|
|
117
|
+
labelBaseUrl: '基础地址',
|
|
118
|
+
labelModel: '模型',
|
|
119
|
+
labelTopK: 'topK',
|
|
120
|
+
labelContextWindow: '上下文窗口(token)',
|
|
121
|
+
labelStreamIdleTimeoutMs: '流空闲超时(毫秒)',
|
|
122
|
+
hintBaseUrl: '部署地址,结尾的斜杠可省略。',
|
|
123
|
+
hintModel: '作为 chatOptions.selectedModel 发送的模型 id。',
|
|
124
|
+
hintTopK: '转发给服务的采样广度。站点自身发送 8。',
|
|
125
|
+
hintContextWindow: '提示词与回复合计的 token 数;服务上限为 6144。',
|
|
126
|
+
hintStreamIdleTimeoutMs: '流在该时长内无输出即以 TIMEOUT 结束。',
|
|
127
|
+
overridden: '已覆盖',
|
|
128
|
+
save: '保存',
|
|
129
|
+
saving: '保存中…',
|
|
130
|
+
saved: '已保存。下一个请求将使用这些值。',
|
|
131
|
+
reset: '恢复默认',
|
|
132
|
+
persists: '保存在你的配置中;适配器每次请求都会读取该行。',
|
|
133
|
+
readOnly: '只读:此部署不持久化设置。',
|
|
134
|
+
baseUrlInvalid: '基础地址必须是绝对的 http(s) URL。',
|
|
135
|
+
modelInvalid: '模型必须是非空 id。',
|
|
136
|
+
numberInvalid: '必须是正整数。',
|
|
137
|
+
rejected: 'Host 拒绝了写入;原先的值仍然有效。',
|
|
138
|
+
failed: '保存失败:{message}',
|
|
139
|
+
version: 'v{version}',
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Bind one settings scope to a React subscription.
|
|
144
|
+
* @param scope - the scope bound to the chatjimmy settings namespace.
|
|
145
|
+
* @returns a hook reading that scope's current snapshot.
|
|
146
|
+
*/
|
|
147
|
+
function useScope(scope) {
|
|
148
|
+
const subscribe = (listener) => scope.subscribe(listener)
|
|
149
|
+
const getSnapshot = () => scope.getSnapshot()
|
|
150
|
+
return () => React.useSyncExternalStore(subscribe, getSnapshot)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read a snapshot's resolved row. A namespace this deployment does not
|
|
155
|
+
* serve reports no row, which the card renders as nothing at all.
|
|
156
|
+
* @param snapshot - the settings scope snapshot.
|
|
157
|
+
* @returns the resolved row, or `undefined` when unreadable.
|
|
158
|
+
*/
|
|
159
|
+
function rowOf(snapshot) {
|
|
160
|
+
if (snapshot.status !== 'ready') return undefined
|
|
161
|
+
return snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One editable field: label, hint, input, and the override marker. */
|
|
165
|
+
function Field(props) {
|
|
166
|
+
const { t, field, value, overridden, disabled, onChange } = props
|
|
167
|
+
return React.createElement(
|
|
168
|
+
'div',
|
|
169
|
+
{ className: 'cj-field' },
|
|
170
|
+
React.createElement(
|
|
171
|
+
'div',
|
|
172
|
+
{ className: 'cj-row' },
|
|
173
|
+
React.createElement('span', { className: 'cj-label' }, t(field.label)),
|
|
174
|
+
overridden === true
|
|
175
|
+
? React.createElement('span', { className: 'cj-hint' }, `(${t('overridden')})`)
|
|
176
|
+
: null,
|
|
177
|
+
),
|
|
178
|
+
React.createElement('input', {
|
|
179
|
+
className: 'cj-input',
|
|
180
|
+
type: field.kind === 'integer' ? 'number' : 'text',
|
|
181
|
+
value: value,
|
|
182
|
+
disabled,
|
|
183
|
+
'aria-label': t(field.label),
|
|
184
|
+
onChange: (event) => { onChange(field.key, event.target.value) },
|
|
185
|
+
}),
|
|
186
|
+
React.createElement('span', { className: 'cj-hint' }, t(field.hint)),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build the card component over one bound settings scope.
|
|
192
|
+
* @param scope - the scope bound to the chatjimmy settings namespace.
|
|
193
|
+
* @param t - translate function bound to this plugin's locale namespace.
|
|
194
|
+
* @returns the component the slot renders.
|
|
195
|
+
*/
|
|
196
|
+
function createCard(scope, t) {
|
|
197
|
+
const useChatJimmy = useScope(scope)
|
|
198
|
+
|
|
199
|
+
return function ChatJimmyCard(props) {
|
|
200
|
+
const snapshot = useChatJimmy()
|
|
201
|
+
const [draft, setDraft] = React.useState(null)
|
|
202
|
+
const [error, setError] = React.useState(null)
|
|
203
|
+
const [status, setStatus] = React.useState(null)
|
|
204
|
+
const [pending, setPending] = React.useState(false)
|
|
205
|
+
|
|
206
|
+
const row = rowOf(snapshot)
|
|
207
|
+
// A namespace this deployment does not serve renders no trace of itself.
|
|
208
|
+
if (row === undefined) return null
|
|
209
|
+
|
|
210
|
+
if (props != null && props.view === 'summary') {
|
|
211
|
+
return row.model === undefined
|
|
212
|
+
? t('summaryEmpty')
|
|
213
|
+
: t('summary', { model: String(row.model), baseUrl: String(row.baseUrl) })
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const disabled = !snapshot.writable || pending
|
|
217
|
+
const shown = draft ?? row
|
|
218
|
+
const user = snapshot.user !== null && typeof snapshot.user === 'object' ? snapshot.user : {}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Validate and collect the staged edits.
|
|
222
|
+
* @returns an error key, or the ordered path operations to write.
|
|
223
|
+
*/
|
|
224
|
+
const collect = () => {
|
|
225
|
+
const ops = []
|
|
226
|
+
for (const field of FIELDS) {
|
|
227
|
+
const value = shown[field.key]
|
|
228
|
+
const original = row[field.key]
|
|
229
|
+
if (String(value) === String(original)) continue
|
|
230
|
+
if (field.kind === 'integer') {
|
|
231
|
+
const parsed = Number(value)
|
|
232
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return { error: 'numberInvalid' }
|
|
233
|
+
ops.push({ op: 'set', path: [field.key], value: parsed })
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
const text = String(value).trim()
|
|
237
|
+
if (field.key === 'model') {
|
|
238
|
+
if (text === '') return { error: 'modelInvalid' }
|
|
239
|
+
ops.push({ op: 'set', path: [field.key], value: text })
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
if (text === '') return { error: 'baseUrlInvalid' }
|
|
243
|
+
// A scheme prefix is not a URL: the host resolver parses with
|
|
244
|
+
// `new URL` and refuses a root carrying credentials, a query, or a
|
|
245
|
+
// fragment, so the card rejects exactly what that throws on.
|
|
246
|
+
let parsedUrl
|
|
247
|
+
try {
|
|
248
|
+
parsedUrl = new URL(text)
|
|
249
|
+
} catch {
|
|
250
|
+
return { error: 'baseUrlInvalid' }
|
|
251
|
+
}
|
|
252
|
+
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
|
|
253
|
+
return { error: 'baseUrlInvalid' }
|
|
254
|
+
}
|
|
255
|
+
// `resolveConfig` refuses the same four cases: the adapter appends
|
|
256
|
+
// `/api/chat` to this root, so credentials (which `fetch` rejects
|
|
257
|
+
// outright), a query, or a fragment can never address the endpoint.
|
|
258
|
+
if (parsedUrl.username.length > 0 || parsedUrl.password.length > 0
|
|
259
|
+
|| parsedUrl.search.length > 0 || parsedUrl.hash.length > 0) {
|
|
260
|
+
return { error: 'baseUrlInvalid' }
|
|
261
|
+
}
|
|
262
|
+
ops.push({ op: 'set', path: [field.key], value: text })
|
|
263
|
+
}
|
|
264
|
+
return { ops }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const write = (ops) => new Promise((resolve) => { resolve(scope.mutate(ops)) })
|
|
268
|
+
|
|
269
|
+
const save = () => {
|
|
270
|
+
if (disabled) return
|
|
271
|
+
setError(null)
|
|
272
|
+
const result = collect()
|
|
273
|
+
if (result.error !== undefined) {
|
|
274
|
+
setError(t(result.error))
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
if (result.ops.length === 0) {
|
|
278
|
+
// Nothing moved: an edit that landed back on the stored value is
|
|
279
|
+
// not a change, and the row already holds it.
|
|
280
|
+
setStatus(null)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
setStatus(t('saving'))
|
|
284
|
+
setPending(true)
|
|
285
|
+
write(result.ops)
|
|
286
|
+
.then((accepted) => {
|
|
287
|
+
if (accepted === false) {
|
|
288
|
+
setError(t('rejected'))
|
|
289
|
+
setStatus(null)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
setDraft(null)
|
|
293
|
+
setStatus(t('saved'))
|
|
294
|
+
})
|
|
295
|
+
.catch((cause) => {
|
|
296
|
+
setStatus(null)
|
|
297
|
+
setError(t('failed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
298
|
+
})
|
|
299
|
+
.finally(() => { setPending(false) })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const reset = () => {
|
|
303
|
+
if (disabled) return
|
|
304
|
+
setDraft(null)
|
|
305
|
+
setStatus(null)
|
|
306
|
+
setError(null)
|
|
307
|
+
const ops = Object.keys(user)
|
|
308
|
+
.filter((key) => FIELDS.some((field) => field.key === key))
|
|
309
|
+
.map((key) => ({ op: 'unset', path: [key] }))
|
|
310
|
+
if (ops.length === 0) return
|
|
311
|
+
setPending(true)
|
|
312
|
+
write(ops)
|
|
313
|
+
.then((accepted) => {
|
|
314
|
+
// A refused reset is as visible as a refused save: silence here
|
|
315
|
+
// reads as "reset worked" while the override is still in effect.
|
|
316
|
+
if (accepted === false) setError(t('rejected'))
|
|
317
|
+
})
|
|
318
|
+
.catch((cause) => {
|
|
319
|
+
setError(t('failed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
320
|
+
})
|
|
321
|
+
.finally(() => { setPending(false) })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const overridden = Object.keys(user).some((key) => FIELDS.some((field) => field.key === key))
|
|
325
|
+
|
|
326
|
+
return React.createElement(
|
|
327
|
+
'div',
|
|
328
|
+
{ className: 'cj-page' },
|
|
329
|
+
...FIELDS.map((field) => React.createElement(Field, {
|
|
330
|
+
key: field.key,
|
|
331
|
+
t,
|
|
332
|
+
field,
|
|
333
|
+
value: String(shown[field.key] ?? ''),
|
|
334
|
+
overridden: Object.hasOwn(user, field.key),
|
|
335
|
+
disabled,
|
|
336
|
+
onChange: (key, value) => {
|
|
337
|
+
setStatus(null)
|
|
338
|
+
setDraft({ ...shown, [key]: value })
|
|
339
|
+
},
|
|
340
|
+
})),
|
|
341
|
+
React.createElement(
|
|
342
|
+
'div',
|
|
343
|
+
{ className: 'cj-row' },
|
|
344
|
+
React.createElement(
|
|
345
|
+
'button',
|
|
346
|
+
{ type: 'button', className: 'cj-button', disabled, onClick: save },
|
|
347
|
+
t('save'),
|
|
348
|
+
),
|
|
349
|
+
overridden
|
|
350
|
+
? React.createElement(
|
|
351
|
+
'button',
|
|
352
|
+
{ type: 'button', className: 'cj-button cj-button-quiet', disabled, onClick: reset },
|
|
353
|
+
t('reset'),
|
|
354
|
+
)
|
|
355
|
+
: null,
|
|
356
|
+
),
|
|
357
|
+
React.createElement(
|
|
358
|
+
'div',
|
|
359
|
+
{ className: 'cj-status' },
|
|
360
|
+
status ?? (snapshot.writable ? t('persists') : t('readOnly')),
|
|
361
|
+
' ',
|
|
362
|
+
t('version', { version: VERSION }),
|
|
363
|
+
),
|
|
364
|
+
error === null ? null : React.createElement('div', { className: 'cj-error' }, error),
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Mount the browser surface: the ChatJimmy card on the Plugins page.
|
|
371
|
+
* @param ctx - the browser plugin context.
|
|
372
|
+
*/
|
|
373
|
+
function apply(ctx) {
|
|
374
|
+
const t = ctx.locale.bind(LOCALE_NS)
|
|
375
|
+
ctx.effect(
|
|
376
|
+
() => ctx.locale.register(LOCALE_NS, { en, zh }),
|
|
377
|
+
'dsh-chatjimmy: locale dictionary',
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
const scope = ctx.configForms.get(NAMESPACE)
|
|
381
|
+
const Card = createCard(scope, t)
|
|
382
|
+
|
|
383
|
+
// The owner declares its own slot; injecting waits for it to exist, so
|
|
384
|
+
// this registration does not depend on plugin load order. The card takes
|
|
385
|
+
// no injected props (it closes over its own bound scope), so the entry
|
|
386
|
+
// declares the documented `locale` namespace and no `inject`.
|
|
387
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
388
|
+
name: 'plugins.row.config',
|
|
389
|
+
key: '@maci0/dsh-chatjimmy#chatjimmy',
|
|
390
|
+
locale: LOCALE_NS,
|
|
391
|
+
}, Card))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
exports.apply = apply
|
|
395
|
+
exports.inject = ['slots', 'configForms', 'locale']
|
|
396
|
+
return module.exports
|
|
397
|
+
},
|
|
398
|
+
})
|
package/lib/host.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the DeepSeek Harness host surface this plugin uses, declared
|
|
3
|
+
* structurally.
|
|
4
|
+
*
|
|
5
|
+
* Like the other plugins under `~/dsh-plugins`, this package declares the host
|
|
6
|
+
* surface it consumes rather than depending on the harness's own classes: its
|
|
7
|
+
* one runtime `@deepseek-ai/*` dependency is `@deepseek-ai/dsh-llm`'s pure
|
|
8
|
+
* `attributionHeaders()` / `resolveRetryPolicy()` helpers. The harness reaches
|
|
9
|
+
* its adapters through plain method calls on the registered object (there is
|
|
10
|
+
* no `instanceof LlmAdapter` check anywhere in `LlmRuntime`), so a duck-typed
|
|
11
|
+
* adapter is a supported shape, not a workaround.
|
|
12
|
+
*
|
|
13
|
+
* Each declaration here is deliberately narrowed to what this adapter reads or
|
|
14
|
+
* emits. Mirroring a foreign API in full is not documentation: a field we never
|
|
15
|
+
* touch is a field whose absence goes unnoticed, which is how the missing
|
|
16
|
+
* `providerRetryPolicy` reached an integration test instead of a compiler.
|
|
17
|
+
* Widen a declaration when the adapter starts using it, not before.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-chatjimmy/host
|
|
20
|
+
*/
|
|
21
|
+
export {};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-chatjimmy: use the chatjimmy.ai model inside DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* One capability: an `ctx.llm` provider adapter for the reconstructed chat API
|
|
5
|
+
* (see `API.md`). Registering it makes the route selectable in the Web client's
|
|
6
|
+
* model picker, because `buildModelCatalog()` enumerates `ctx.llm.listProviders()`
|
|
7
|
+
* and asks each adapter for `listModels()` / `resolveModel()`.
|
|
8
|
+
*
|
|
9
|
+
* See README.md for the known limits: the service has no tool-calling, no
|
|
10
|
+
* image input, and a 6144-token total context.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-chatjimmy
|
|
13
|
+
*/
|
|
14
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
15
|
+
import { RetryPolicySchema } from '@deepseek-ai/dsh-llm';
|
|
16
|
+
import { ChatJimmyAdapter } from './adapter.js';
|
|
17
|
+
/** Plugin name as it appears in the loader. */
|
|
18
|
+
export const name = 'chatjimmy';
|
|
19
|
+
/** The `ctx.llm` provider route this adapter owns. */
|
|
20
|
+
export const PROVIDER = 'chatjimmy';
|
|
21
|
+
/** The one service this plugin needs mounted. */
|
|
22
|
+
export const inject = ['llm'];
|
|
23
|
+
/**
|
|
24
|
+
* Total context the backend enforces, in tokens, measured empirically:
|
|
25
|
+
* a request whose reported `prefill_tokens` reached 6141 with 2 output tokens
|
|
26
|
+
* succeeded, while one needing 6143 + 2 returned a zero-byte body. The site's
|
|
27
|
+
* own client caps at the same number (`NEXT_PUBLIC_TOKEN_LIMIT`, default 6144).
|
|
28
|
+
*/
|
|
29
|
+
const CONTEXT_WINDOW = 6144;
|
|
30
|
+
/** Model the service serves. `/api/models` advertises exactly this one id. */
|
|
31
|
+
const DEFAULT_MODEL = 'llama3.1-8B';
|
|
32
|
+
/** Base URL of the deployment. */
|
|
33
|
+
const DEFAULT_BASE_URL = 'https://chatjimmy.ai';
|
|
34
|
+
/** Per-read stream idle watchdog default, matching the shipped remote adapters. */
|
|
35
|
+
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000;
|
|
36
|
+
/** `setTimeout`'s maximum delay; a larger configured timeout is rejected at load. */
|
|
37
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
38
|
+
/**
|
|
39
|
+
* Field defaults and bounds with no volatility wrapper.
|
|
40
|
+
*
|
|
41
|
+
* {@link resolveConfig} parses a plain row through this schema, so its output
|
|
42
|
+
* is plain values; the loader-facing {@link Config} below is the same shape
|
|
43
|
+
* with every editable field made `volatile()`, which is what hands the plugin
|
|
44
|
+
* a live reference. The pair is asserted equal in the suite.
|
|
45
|
+
*/
|
|
46
|
+
const ValueSchema = Schema.object({
|
|
47
|
+
baseUrl: Schema.string().default(DEFAULT_BASE_URL),
|
|
48
|
+
model: Schema.string().default(DEFAULT_MODEL),
|
|
49
|
+
topK: Schema.number().step(1).min(1).default(8),
|
|
50
|
+
contextWindow: Schema.number().step(1).min(1).default(CONTEXT_WINDOW),
|
|
51
|
+
streamIdleTimeoutMs: Schema.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS)
|
|
52
|
+
.default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
53
|
+
retryPolicy: RetryPolicySchema,
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* Row schema as Cordis resolves it: defaults live here, so a deployment only
|
|
57
|
+
* states what it changes.
|
|
58
|
+
*
|
|
59
|
+
* Every field a user may edit is `volatile()`: the settings document accepts
|
|
60
|
+
* only volatile paths, and the browser half's card edits exactly these. The
|
|
61
|
+
* adapter resolves the row per read, so an edit lands on the next request
|
|
62
|
+
* instead of waiting for a remount. `retryPolicy` stays ordinary
|
|
63
|
+
* configuration: patch-only, as its docs say.
|
|
64
|
+
*/
|
|
65
|
+
export const Config = Schema.object({
|
|
66
|
+
baseUrl: Schema.string().default(DEFAULT_BASE_URL).volatile(),
|
|
67
|
+
model: Schema.string().default(DEFAULT_MODEL).volatile(),
|
|
68
|
+
topK: Schema.number().step(1).min(1).default(8).volatile(),
|
|
69
|
+
contextWindow: Schema.number().step(1).min(1).default(CONTEXT_WINDOW).volatile(),
|
|
70
|
+
streamIdleTimeoutMs: Schema.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS)
|
|
71
|
+
.default(DEFAULT_STREAM_IDLE_TIMEOUT_MS).volatile(),
|
|
72
|
+
retryPolicy: RetryPolicySchema,
|
|
73
|
+
});
|
|
74
|
+
/**
|
|
75
|
+
* Validate and normalize one configuration row.
|
|
76
|
+
*
|
|
77
|
+
* The row is fed back through the exported `Config` schema, which is the one
|
|
78
|
+
* source of the defaults and the numeric bounds. Cordis already ran the same
|
|
79
|
+
* schema before `apply`, so this only makes `resolveConfig` usable on its own.
|
|
80
|
+
* What the schema cannot express is checked here: invalid values throw rather
|
|
81
|
+
* than being silently defaulted, because a typo'd base URL would otherwise
|
|
82
|
+
* present as an opaque transport failure on the first message.
|
|
83
|
+
*
|
|
84
|
+
* @param config - raw row configuration.
|
|
85
|
+
* @returns the resolved adapter configuration.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveConfig(config = {}) {
|
|
88
|
+
const resolved = ValueSchema(config);
|
|
89
|
+
const baseUrl = resolved.baseUrl.replace(/\/+$/, '');
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = new URL(baseUrl);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error(`chatjimmy: baseUrl "${resolved.baseUrl}" is not a valid absolute URL`);
|
|
96
|
+
}
|
|
97
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
98
|
+
throw new Error(`chatjimmy: baseUrl "${resolved.baseUrl}" must use http or https`);
|
|
99
|
+
}
|
|
100
|
+
// The adapter appends `/api/chat` to this root. Credentials make `fetch`
|
|
101
|
+
// refuse the URL (and hide the real host behind a look-alike userinfo), and a
|
|
102
|
+
// query or fragment swallows the appended path, so no such URL can ever
|
|
103
|
+
// address the documented endpoint. Same rule as the harness's own providers.
|
|
104
|
+
if (parsed.username.length > 0 || parsed.password.length > 0 || parsed.search.length > 0 || parsed.hash.length > 0) {
|
|
105
|
+
throw new Error(`chatjimmy: baseUrl "${resolved.baseUrl}" must be an HTTP(S) root without credentials, query, or fragment`);
|
|
106
|
+
}
|
|
107
|
+
// Any string is a valid model id to the schema, but the empty one is not a model.
|
|
108
|
+
if (resolved.model.trim().length === 0)
|
|
109
|
+
throw new Error('chatjimmy: model must be a non-empty string');
|
|
110
|
+
// `min`/`max` cannot see NaN (every comparison against it is false, so NaN
|
|
111
|
+
// passes both), and schemastery has no refinement hook. One explicit check.
|
|
112
|
+
for (const field of ['topK', 'contextWindow', 'streamIdleTimeoutMs']) {
|
|
113
|
+
if (!Number.isFinite(resolved[field])) {
|
|
114
|
+
throw new Error(`chatjimmy: ${field} must be a finite number, got ${String(resolved[field])}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { ...resolved, baseUrl };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Read the live row out of the references the loader resolved.
|
|
121
|
+
*
|
|
122
|
+
* Every editable field arrives as a `Volatile<T>`; this is the one place that
|
|
123
|
+
* turns them back into the plain values the schema and the adapter understand,
|
|
124
|
+
* so a caller cannot forget one.
|
|
125
|
+
*
|
|
126
|
+
* @param config - the resolved row.
|
|
127
|
+
* @returns plain row values, with absent references left undefined.
|
|
128
|
+
*/
|
|
129
|
+
export function liveOptions(config) {
|
|
130
|
+
return {
|
|
131
|
+
baseUrl: config.baseUrl.get(),
|
|
132
|
+
model: config.model.get(),
|
|
133
|
+
topK: config.topK.get(),
|
|
134
|
+
contextWindow: config.contextWindow.get(),
|
|
135
|
+
streamIdleTimeoutMs: config.streamIdleTimeoutMs.get(),
|
|
136
|
+
retryPolicy: config.retryPolicy,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Mount the adapter.
|
|
141
|
+
*
|
|
142
|
+
* The adapter is handed the resolver itself, not one resolved row: every
|
|
143
|
+
* configurable field is `volatile()`, so a settings write from the Plugins
|
|
144
|
+
* card changes what the next request uses without remounting the provider
|
|
145
|
+
* route (which would drop the model picker's selection). The row is resolved
|
|
146
|
+
* once here anyway, so an unusable one still fails at mount the way a
|
|
147
|
+
* non-volatile row would.
|
|
148
|
+
*
|
|
149
|
+
* @param ctx - host context; `ctx.llm` must be mounted (`inject` guarantees it).
|
|
150
|
+
* @param config - this plugin's row configuration.
|
|
151
|
+
*/
|
|
152
|
+
export function apply(ctx, config) {
|
|
153
|
+
const live = () => resolveConfig(liveOptions(config));
|
|
154
|
+
const resolved = live();
|
|
155
|
+
ctx.llm.registerAdapter([PROVIDER], new ChatJimmyAdapter(live));
|
|
156
|
+
ctx.logger.info(`chatjimmy: provider "${PROVIDER}" registered for model "${resolved.model}" at ${resolved.baseUrl}`
|
|
157
|
+
+ ` (text only, ${resolved.contextWindow}-token total context)`);
|
|
158
|
+
// Report edits rather than swallowing them: a card write that made the row
|
|
159
|
+
// unusable has to be visible somewhere, and this is the only surface the
|
|
160
|
+
// host owns. The value stays on the row, so the request that needs it will
|
|
161
|
+
// raise the same error with the same message.
|
|
162
|
+
ctx.on('loader/volatile-update', () => {
|
|
163
|
+
try {
|
|
164
|
+
const next = live();
|
|
165
|
+
ctx.logger.info(`chatjimmy: configuration updated: model "${next.model}" at ${next.baseUrl}`
|
|
166
|
+
+ `, topK ${String(next.topK)}, ${next.contextWindow}-token context`);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
ctx.logger.warn(`chatjimmy: ${error instanceof Error ? error.message : String(error)}`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|