dsh-plugin-model-tuning 2.0.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.
@@ -0,0 +1,24 @@
1
+ # dsh-plugin-model-tuning v2 — 自 wiring + 显式替换宿主自带的标题生成行。
2
+ #
3
+ # 顺序即语义:
4
+ # 1. 先 disable dsh-base 的 `session-title-llm` 行(@deepseek-ai/dsh-session-title-first-prompt-llm)。
5
+ # `ctx.sessionTitle.register()` 只允许一个 provider,第二次注册直接抛错,
6
+ # 所以必须先摘掉原行再插入本行;反过来 boot 会失败。
7
+ # 2. 再 insert 本包(v2 起一个包承担:模型参数页 + 标题路由 + 绘图工具/面板)。
8
+ #
9
+ # config 中标题预算字段与 dsh-base 的 `session-title-llm` 完全一致,因此无论
10
+ # 设置处于默认「继承」还是被卸载回退,标题生成的提示词预算、字节上限、
11
+ # 超时行为都不变。绘图 models/defaultModel 与标题 titleMode/titleProvider/
12
+ # titleModel 是 volatile 字段,热更新即时生效。
13
+ - id: session-title-llm
14
+ disabled: true
15
+
16
+ - insert:
17
+ - id: model-tuning
18
+ name: dsh-plugin-model-tuning
19
+ config:
20
+ targetWords: 5
21
+ targetCjkCharacters: 10
22
+ maxInputBytes: 4096
23
+ maxOutputTokens: 64
24
+ timeoutMs: 60000
package/core.js ADDED
@@ -0,0 +1,218 @@
1
+ /** Images API transport. No credentials or remote response bodies enter errors. */
2
+ export const MAX_IMAGES = 4
3
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024
4
+ const MAX_RESPONSE_BYTES = 112 * 1024 * 1024
5
+ const PARAMS = ['size', 'quality', 'background', 'output_format', 'response_format', 'style']
6
+
7
+ function fail(message) { throw new Error(message) }
8
+ function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
9
+
10
+ export function httpUrl(value, label) {
11
+ let url
12
+ try { url = new URL(value) } catch { fail(`${label} 必须是完整的 HTTP(S) 地址。`) }
13
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || url.search) {
14
+ fail(`${label} 不得包含凭据、查询参数或片段,且必须使用 HTTP(S)。`)
15
+ }
16
+ return url
17
+ }
18
+
19
+ /** Preserve custom prefixes; accept either an API base or a full Images endpoint. */
20
+ export function discoveryUrl(endpoint, modelsEndpoint) {
21
+ const generation = httpUrl(endpoint, '供应商地址')
22
+ if (modelsEndpoint) {
23
+ const listing = httpUrl(modelsEndpoint, '模型列表接口')
24
+ if (listing.origin !== generation.origin) fail('模型列表接口必须与生成接口同源,避免向其他供应商发送凭据。')
25
+ return listing.href
26
+ }
27
+ generation.pathname = generation.pathname.replace(/\/+$/, '').replace(/\/images\/generations$/, '') + '/models'
28
+ return generation.href
29
+ }
30
+
31
+ /** Read a supplier's catalog without saving a draft model or its typed secret. */
32
+ export async function discoverImageModels({ request, resolveKey, signal, fetchImpl = fetch }) {
33
+ if (!object(request) || typeof request.endpoint !== 'string') fail('请先填写供应商地址。')
34
+ if (request.modelsEndpoint !== undefined && typeof request.modelsEndpoint !== 'string') fail('模型列表接口必须是地址。')
35
+ if (request.apiKeyEnv && (typeof request.apiKeyEnv !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(request.apiKeyEnv))) fail('凭据引用必须是有效的环境变量名。')
36
+ if (request.apiKey !== undefined && (typeof request.apiKey !== 'string' || request.apiKey.length > 16384 || /[\r\n]/.test(request.apiKey))) fail('API Key 格式无效。')
37
+ const url = discoveryUrl(request.endpoint, request.modelsEndpoint)
38
+ const controller = new AbortController()
39
+ const timer = setTimeout(() => controller.abort(new Error('获取模型列表超时,请检查服务地址或手动填写模型 ID。')), 20000)
40
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
41
+ try {
42
+ combined.throwIfAborted()
43
+ const key = request.apiKey || (request.apiKeyEnv ? await resolveKey(request.apiKeyEnv) : undefined)
44
+ if (request.apiKeyEnv && !key) fail(`未找到可用凭据 ${request.apiKeyEnv}。请填写 API Key 或选择已有凭据引用。`)
45
+ if (key && (typeof key !== 'string' || /[\r\n]/.test(key))) fail('API Key 格式无效。')
46
+ const response = await checkedFetch(fetchImpl, url, {
47
+ method: 'GET', redirect: 'error', signal: combined,
48
+ headers: { Accept: 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) },
49
+ })
50
+ let payload
51
+ const raw = await readBounded(response, 2 * 1024 * 1024, combined)
52
+ try { payload = JSON.parse(raw.toString('utf8')) } catch { fail('模型列表接口未返回有效 JSON,可在高级设置中指定列表地址或手动填写模型 ID。') }
53
+ if (!Array.isArray(payload?.data)) fail('模型列表应包含 data 数组;当前供应商可能不支持该接口,请手动填写模型 ID。')
54
+ if (payload.data.length > 10000) fail('供应商返回的模型列表过大。')
55
+ const models = new Map()
56
+ for (const item of payload.data) {
57
+ if (!object(item) || typeof item.id !== 'string' || !item.id.trim() || item.id.length > 256) continue
58
+ if (!models.has(item.id)) models.set(item.id, { id: item.id,
59
+ name: typeof item.name === 'string' && item.name.trim() ? item.name.slice(0, 256) : item.id })
60
+ }
61
+ if (payload.data.length && !models.size) fail('供应商列表没有有效的模型 ID。')
62
+ return { models: [...models.values()], truncated: payload.has_more === true }
63
+ } finally { clearTimeout(timer) }
64
+ }
65
+
66
+ /** Validate cross-field settings both at registration and before a paid call. */
67
+ export function validateConfig(config) {
68
+ const ids = new Set()
69
+ for (const entry of config.models ?? []) {
70
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(entry.id)) fail('绘图配置 ID 只能包含字母、数字、下划线和连字符。')
71
+ if (ids.has(entry.id)) fail(`绘图配置 ID 重复:${entry.id}`)
72
+ ids.add(entry.id)
73
+ if (typeof entry.model !== 'string' || !entry.model.trim() || entry.model.length > 256) fail('必须填写绘图模型 ID(最长 256 字符)。')
74
+ if (entry.api !== 'openai-images') fail('当前只支持 openai-images 协议。')
75
+ httpUrl(entry.endpoint, '生成接口')
76
+ if (entry.modelsEndpoint) discoveryUrl(entry.endpoint, entry.modelsEndpoint)
77
+ if (entry.apiKeyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.apiKeyEnv)) fail('凭据引用必须是有效的环境变量名。')
78
+ if (!Number.isInteger(entry.timeoutSeconds) || entry.timeoutSeconds < 10 || entry.timeoutSeconds > 600) fail('超时必须在 10–600 秒之间。')
79
+ for (const field of PARAMS) {
80
+ if (entry[field] !== undefined && (typeof entry[field] !== 'string' || entry[field].length > 128)) fail(`无效的 ${field}。`)
81
+ }
82
+ }
83
+ if (config.defaultModel && !ids.has(config.defaultModel)) fail('默认绘图配置不存在。')
84
+ }
85
+
86
+ export function resolveRequest(config, args) {
87
+ validateConfig(config)
88
+ if (!object(args)) fail('绘图参数必须是对象。')
89
+ const id = args.model ?? config.defaultModel ?? ''
90
+ const entries = config.models ?? []
91
+ const entry = entries.find(item => item.id === id) ?? (!id && entries.length === 1 ? entries[0] : undefined)
92
+ if (!entry) fail('请先在「设置 → 模型调参 → 绘图」添加模型并选择默认项,或用 list_image_models 查看可选 ID。')
93
+ if (typeof args.prompt !== 'string' || !args.prompt.trim() || args.prompt.length > 32000) fail('提示词不能为空,且不能超过 32000 字符。')
94
+ const n = args.n ?? 1
95
+ if (!Number.isInteger(n) || n < 1 || n > MAX_IMAGES) fail(`每次只能生成 1–${MAX_IMAGES} 张图片。`)
96
+ const body = { model: entry.model, prompt: args.prompt, n }
97
+ for (const field of PARAMS) {
98
+ const value = args[field] ?? entry[field]
99
+ if (value !== undefined && value !== '') {
100
+ if (typeof value !== 'string' || value.length > 128) fail(`无效的 ${field}。`)
101
+ body[field] = value
102
+ }
103
+ }
104
+ // Unspecified parameters remain absent; GPT Image does not accept response_format.
105
+ return { entry, body }
106
+ }
107
+
108
+ async function readBounded(response, limit, signal) {
109
+ if (Number(response.headers.get('content-length')) > limit) {
110
+ await response.body?.cancel()
111
+ fail('接口返回内容超过大小限制。')
112
+ }
113
+ if (!response.body) fail('接口返回空内容。')
114
+ const reader = response.body.getReader()
115
+ const chunks = []
116
+ let size = 0
117
+ try {
118
+ while (true) {
119
+ signal.throwIfAborted()
120
+ const { done, value } = await reader.read()
121
+ if (done) break
122
+ size += value.byteLength
123
+ if (size > limit) fail('接口返回内容超过大小限制。')
124
+ chunks.push(value)
125
+ }
126
+ } finally {
127
+ await reader.cancel().catch(() => {}) // Cleanup cannot replace the original transport error.
128
+ reader.releaseLock()
129
+ }
130
+ return Buffer.concat(chunks, size)
131
+ }
132
+
133
+ async function checkedFetch(fetchImpl, url, options) {
134
+ let response
135
+ try { response = await fetchImpl(url, options) } catch (error) {
136
+ if (options.signal.aborted) throw options.signal.reason
137
+ throw new Error('绘图服务连接失败,请检查地址与网络。', { cause: error })
138
+ }
139
+ if (!response.ok) {
140
+ await response.body?.cancel()
141
+ fail(`绘图服务返回 HTTP ${response.status}。请检查接口、凭据、模型权限和参数;未自动重试。`)
142
+ }
143
+ return response
144
+ }
145
+
146
+ /** Detect the bytes, rather than trusting response headers or a configured format. */
147
+ export function imageType(data) {
148
+ if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png'
149
+ if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return 'image/jpeg'
150
+ if (data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
151
+ if (['GIF87a', 'GIF89a'].includes(data.toString('ascii', 0, 6))) return 'image/gif'
152
+ fail('接口返回的内容不是支持的 PNG、JPEG、WebP 或 GIF 图片。')
153
+ }
154
+
155
+ async function imageBytes(item, entry, fetchImpl, signal) {
156
+ if (typeof item.b64_json === 'string' && item.b64_json) {
157
+ const encoded = item.b64_json
158
+ if (encoded.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4 || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) fail('图片 Base64 无效或超过 20 MiB。')
159
+ const bytes = Buffer.from(encoded, 'base64')
160
+ if (bytes.toString('base64') !== encoded) fail('图片 Base64 编码无效。')
161
+ if (bytes.length > MAX_IMAGE_BYTES) fail('图片超过 20 MiB。')
162
+ return bytes
163
+ }
164
+ if (typeof item.url !== 'string') fail('接口没有返回 b64_json 或图片 URL。')
165
+ let url
166
+ try { url = new URL(item.url) } catch { fail('接口返回无效图片 URL。') }
167
+ // Any host is accepted: a gateway may host results on a CDN, and pinning an
168
+ // origin list here only broke that. `redirect: 'error'` below still refuses a
169
+ // response that tries to bounce the download somewhere else, and the scheme,
170
+ // credential, and fragment checks keep the fetch to a plain public URL.
171
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) {
172
+ fail('图片 URL 必须是公开的 HTTP(S) 地址。')
173
+ }
174
+ const response = await checkedFetch(fetchImpl, url, { signal, redirect: 'error' })
175
+ return readBounded(response, MAX_IMAGE_BYTES, signal)
176
+ }
177
+
178
+ /** One POST only: transport retries can create and bill for duplicate images. */
179
+ export async function generateImages({ config, args, resolveKey, attachments, signal, fetchImpl = fetch }) {
180
+ const { entry, body } = resolveRequest(config, args)
181
+ const controller = new AbortController()
182
+ const timer = setTimeout(() => controller.abort(new Error('绘图请求超时;服务端可能仍在生成,请勿立即重复提交。')), entry.timeoutSeconds * 1000)
183
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
184
+ try {
185
+ combined.throwIfAborted()
186
+ const key = entry.apiKeyEnv ? await resolveKey(entry.apiKeyEnv) : undefined
187
+ if (entry.apiKeyEnv && (!key || /[\r\n]/.test(key))) fail(`未找到可用凭据 ${entry.apiKeyEnv}。请在设置中保存 API Key。`)
188
+ const response = await checkedFetch(fetchImpl, entry.endpoint, {
189
+ method: 'POST', redirect: 'error', signal: combined,
190
+ headers: { 'Content-Type': 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) },
191
+ body: JSON.stringify(body),
192
+ })
193
+ const raw = await readBounded(response, MAX_RESPONSE_BYTES, combined)
194
+ let payload
195
+ try { payload = JSON.parse(raw.toString('utf8')) } catch { fail('绘图接口未返回有效 JSON;请确认使用的是 Images API。') }
196
+ if (!Array.isArray(payload?.data) || payload.data.length === 0 || payload.data.length > body.n) fail('绘图响应没有图片,或返回数量超过请求数量。')
197
+ const inputs = []
198
+ for (const item of payload.data) {
199
+ if (!object(item)) fail('绘图响应中存在无效的图片条目。')
200
+ combined.throwIfAborted()
201
+ const data = await imageBytes(item, entry, fetchImpl, combined)
202
+ const mediaType = imageType(data)
203
+ inputs.push({ data, mediaType, name: `generated-${inputs.length + 1}.${mediaType.split('/')[1]}` })
204
+ }
205
+ combined.throwIfAborted()
206
+ // AttachmentStore validates the entire batch before writing any previews.
207
+ const previews = await attachments.saveImages(inputs)
208
+ const images = []
209
+ for (let i = 0; i < inputs.length; i++) {
210
+ combined.throwIfAborted()
211
+ const original = await attachments.saveFile({ data: inputs[i].data, name: inputs[i].name })
212
+ const path = attachments.fileHostPath(original)
213
+ images.push({ preview: previews[i], original, mediaType: inputs[i].mediaType, ...(path ? { path } : {}),
214
+ ...(typeof payload.data[i].revised_prompt === 'string' ? { revisedPrompt: payload.data[i].revised_prompt.slice(0, 32000) } : {}) })
215
+ }
216
+ return { model: entry.id, prompt: args.prompt, images }
217
+ } finally { clearTimeout(timer) }
218
+ }
package/index.js ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Host half of `dsh-plugin-model-tuning` (v2: merged with the former
3
+ * `dsh-plugin-title-model` and `dsh-plugin-image-generation`).
4
+ *
5
+ * Three responsibilities, one entry:
6
+ *
7
+ * 1. Session-title routing (ex dsh-plugin-title-model). This plugin REPLACES
8
+ * the deployment's shipped title provider row
9
+ * (`@deepseek-ai/dsh-session-title-first-prompt-llm`) through the bundle
10
+ * patch in `cordis.patch.yml`. It is still exactly one provider:
11
+ * `ctx.sessionTitle.register()` throws on a second registration, so a
12
+ * competing generator cannot coexist. Everything except the route is
13
+ * delegated to the public shared policy `generateSessionTitleWithLlm` from
14
+ * `@deepseek-ai/dsh-session-title-llm`, resolved from the RUNNING
15
+ * deployment (see hostRequire) — never from this package's directory, which
16
+ * for a linked workspace package would risk a second copy of the host's
17
+ * shared module graph.
18
+ *
19
+ * 2. Image generation (ex dsh-plugin-image-generation): the `generate_image`
20
+ * and `list_image_models` tools plus the `imageGeneration` Remote service
21
+ * that the 直接绘图 panel drives. Transport lives in `./core.js`.
22
+ *
23
+ * 3. Nothing else: the settings surface (模型参数 / 用途 / 绘图 tabs) lives in
24
+ * the browser implementation exported from `./client`.
25
+ *
26
+ * @module dsh-plugin-model-tuning
27
+ */
28
+ import { randomUUID } from 'node:crypto'
29
+ import { pathToFileURL } from 'node:url'
30
+ import { createRequire } from 'node:module'
31
+ import z from '@deepseek-ai/schemastery'
32
+ import { defineTool } from '@deepseek-ai/dsh-tools'
33
+ import { TypertRemoteService, RemoteError } from '@deepseek-ai/dsh-typert-protocol'
34
+ import { discoverImageModels, generateImages, resolveRequest, validateConfig } from './core.js'
35
+
36
+ export const name = 'dsh-plugin-model-tuning'
37
+
38
+ /** Services this plugin reads. */
39
+ export const inject = ['tools', 'attachments', 'sessionTitle', 'llm']
40
+
41
+ /** Settings namespace owned by this plugin (one entry, one schema). */
42
+ export const NS = 'model-tuning'
43
+
44
+ /**
45
+ * Provider identity recorded with generated titles. Kept equal to the row this
46
+ * plugin replaces so durable logs and the folded title source stay unchanged.
47
+ */
48
+ const TITLE_PROVIDER_ID = 'session-title-llm'
49
+
50
+ /**
51
+ * Unified Config: image-generation fields (models / defaultModel) plus the
52
+ * title route fields (titleMode / titleProvider / titleModel), all volatile.
53
+ *
54
+ * The title budget fields mirror the `session-title-llm` config in
55
+ * `@deepseek-ai/dsh-base` so that removing this bundle's `config:` block
56
+ * cannot change title behaviour. Title fields are prefixed `title` so the
57
+ * pre-merge `title-model` section could be migrated mechanically; the former
58
+ * `image-generation` section's `models`/`defaultModel` keys are kept verbatim.
59
+ */
60
+ const imageModelSchema = z.object({
61
+ id: z.string().required(), name: z.string(), model: z.string().required(),
62
+ api: z.union(['openai-images']).default('openai-images'),
63
+ endpoint: z.string().default('https://api.openai.com/v1/images/generations'), apiKeyEnv: z.string().role('credential-ref'),
64
+ modelsEndpoint: z.string(),
65
+ // Retained so sections written by earlier versions still parse. Results
66
+ // returned as URLs are downloaded from whatever host the gateway names; the
67
+ // scheme and redirect checks in core.js are what bound that fetch now.
68
+ downloadOrigins: z.array(z.string()).default([]),
69
+ timeoutSeconds: z.number().step(1).min(10).max(600).default(300),
70
+ size: z.string(), quality: z.string(), background: z.string(),
71
+ output_format: z.string(), response_format: z.string(), style: z.string(),
72
+ })
73
+
74
+ export const Config = z.object({
75
+ // ── 绘图(ex image-generation;键名原样保留,旧数据可直接搬入) ──
76
+ models: z.array(imageModelSchema).default([]).volatile(),
77
+ defaultModel: z.string().volatile(),
78
+ // ── 标题生成(ex title-model;title 前缀迁移自 mode/provider/model) ──
79
+ titleMode: z.union([z.const('inherit'), z.const('custom')]).default('inherit').volatile(),
80
+ titleProvider: z.string().volatile(),
81
+ titleModel: z.string().volatile(),
82
+ // ── 标题请求预算(用户可在「用途」页改;默认值与 dsh-base 的 session-title-llm 行一致) ──
83
+ targetWords: z.number().step(1).min(1).default(5).volatile(),
84
+ targetCjkCharacters: z.number().step(1).min(1).default(10).volatile(),
85
+ maxInputBytes: z.number().step(1).min(1).default(4096).volatile(),
86
+ maxOutputTokens: z.number().step(1).min(1).default(64).volatile(),
87
+ timeoutMs: z.number().step(1).min(1).default(60000).volatile(),
88
+ })
89
+
90
+ /** Reject a custom title mode that cannot name both halves of a route. */
91
+ export function validateTitleModel(value) {
92
+ const provider = value?.titleProvider
93
+ const model = value?.titleModel
94
+ const hasProvider = provider !== undefined && provider !== null && provider !== ''
95
+ const hasModel = model !== undefined && model !== null && model !== ''
96
+ if (hasProvider !== hasModel) throw new Error('model-tuning: titleProvider 与 titleModel 必须同时填写或同时留空')
97
+ if (value?.titleMode === 'custom' && !hasProvider) throw new Error('model-tuning: titleMode 为 custom 时必须同时选择 provider 与 model')
98
+ }
99
+
100
+ /**
101
+ * Resolve the exact route recorded for one generation request.
102
+ * @param settings - resolved title fields (`titleMode`/`titleProvider`/`titleModel`).
103
+ * @param request - service-owned request carrying the session's logged route.
104
+ * @returns the provider/model pair to dispatch.
105
+ */
106
+ export function resolveTitleRoute(settings, request) {
107
+ validateTitleModel(settings)
108
+ if (settings?.titleMode === 'custom') {
109
+ return { provider: settings.titleProvider, model: settings.titleModel }
110
+ }
111
+ if (request?.route === undefined) {
112
+ throw new Error('session-title-llm: no logged request route is available; configure provider and model together')
113
+ }
114
+ return { provider: request.route.provider, model: request.route.model }
115
+ }
116
+
117
+ /** The shared title policy module, loaded once per process. */
118
+ const HELPER_PACKAGE = '@deepseek-ai/dsh-session-title-llm'
119
+
120
+ /**
121
+ * Resolve the shared title policy.
122
+ *
123
+ * A plain `import '@deepseek-ai/dsh-session-title-llm'` would have to be
124
+ * resolvable from THIS package's directory, which for a linked workspace package
125
+ * means risking another installation of the host's shared module graph. The
126
+ * helper therefore resolves from the deployment that loads this plugin.
127
+ */
128
+ function hostRequire() {
129
+ const anchors = [process.argv[1], process.execPath, import.meta.url]
130
+ for (const anchor of anchors) {
131
+ if (anchor === undefined) continue
132
+ try {
133
+ const require = createRequire(anchor)
134
+ require.resolve(HELPER_PACKAGE)
135
+ return require
136
+ } catch {
137
+ // Try the next anchor: a test or tool process may not sit inside the deployment.
138
+ }
139
+ }
140
+ return undefined
141
+ }
142
+
143
+ let helperPromise
144
+ function loadTitleHelper() {
145
+ helperPromise ??= (async () => {
146
+ const require = hostRequire()
147
+ if (require === undefined) {
148
+ throw new Error(
149
+ `model-tuning: 无法从宿主解析 ${HELPER_PACKAGE};本插件必须与提供该包的部署一起运行。`,
150
+ )
151
+ }
152
+ const loaded = await import(pathToFileURL(require.resolve(HELPER_PACKAGE)).href)
153
+ const generate = loaded.generateSessionTitleWithLlm
154
+ if (typeof generate !== 'function') {
155
+ throw new Error(`model-tuning: ${HELPER_PACKAGE} 未导出 generateSessionTitleWithLlm`)
156
+ }
157
+ return generate
158
+ })()
159
+ return helperPromise
160
+ }
161
+
162
+ /**
163
+ * Read the live title budget.
164
+ *
165
+ * Every field is user-editable from the 用途 tab, so each generation re-reads
166
+ * them instead of freezing the apply-time values. A reasoning model that only
167
+ * starts answering after a long think needs a larger `maxOutputTokens` here;
168
+ * that is the user's call, not a constant in this file.
169
+ * @param config - the plugin's live Config.
170
+ * @returns the prompt/byte/token/timeout policy for one title request.
171
+ */
172
+ export function titleBudgetOf(config) {
173
+ const policy = {
174
+ targetWords: config.targetWords.get(),
175
+ targetCjkCharacters: config.targetCjkCharacters.get(),
176
+ maxInputBytes: config.maxInputBytes.get(),
177
+ maxOutputTokens: config.maxOutputTokens.get(),
178
+ timeoutMs: config.timeoutMs.get(),
179
+ }
180
+ for (const [key, value] of Object.entries(policy)) {
181
+ if (!Number.isInteger(value) || value < 1) throw new Error(`model-tuning: ${key} 必须是正整数`)
182
+ }
183
+ return policy
184
+ }
185
+
186
+ /**
187
+ * Register the settings-owned title provider.
188
+ * @param ctx - context exposing session-title and LLM services.
189
+ * @param config - the live Config (route and budget are re-read per generation).
190
+ */
191
+ export async function registerTitleProvider(ctx, config) {
192
+ const generateSessionTitleWithLlm = await loadTitleHelper()
193
+ validateTitleModel({ titleMode: config.titleMode.get(), titleProvider: config.titleProvider.get(), titleModel: config.titleModel.get() })
194
+ titleBudgetOf(config)
195
+ ctx.sessionTitle.register({
196
+ id: TITLE_PROVIDER_ID,
197
+ automatic: 'first-prompt',
198
+ async generate(request) {
199
+ const route = resolveTitleRoute({
200
+ titleMode: config.titleMode.get(), titleProvider: config.titleProvider.get(), titleModel: config.titleModel.get(),
201
+ }, request)
202
+ const selected = request.messages.slice(0, 1)
203
+ return await generateSessionTitleWithLlm(ctx, titleBudgetOf(config), { ...request, route }, selected, TITLE_PROVIDER_ID)
204
+ },
205
+ })
206
+ }
207
+
208
+ // ── 绘图:工具 + Remote 服务(ex dsh-plugin-image-generation/index.js) ──
209
+
210
+ const IMAGE_PARAMETERS = {
211
+ model: { type: 'string', description: 'Configuration ID from list_image_models, not the upstream model ID. Omit to use the configured default.' },
212
+ prompt: { type: 'string', required: true, description: 'Full self-contained description of the image to generate. Chat history is not sent.' },
213
+ n: { type: 'integer', description: 'Number of images, 1–4. Defaults to 1; the upstream model may support only 1.' },
214
+ size: { type: 'string', description: 'Optional model-supported size, e.g. 1024x1024.' },
215
+ quality: { type: 'string', description: 'Optional quality supported by this model.' },
216
+ }
217
+
218
+ export function resultText(value) {
219
+ return `Generated ${value.images.length} image(s) with ${value.model}. Original files are retained without resizing.\n`
220
+ + value.images.map((image, i) => `${i + 1}. ${image.path ?? `attachment:${image.original.attachmentId}`} (${image.original.bytes} bytes)`
221
+ + (image.revisedPrompt ? `\nRevised prompt: ${image.revisedPrompt}` : '')).join('\n')
222
+ + (value.includeImages ? '' : '\nImages were not added to model context because the selected model does not declare image input. Do not claim to have inspected their contents.')
223
+ }
224
+
225
+ /** Jobs are bounded, process-local tickets. Attachment bytes themselves are durable. */
226
+ export class ImageGenerationService extends TypertRemoteService {
227
+ constructor(ctx, current) {
228
+ super(ctx, 'imageGeneration')
229
+ this.current = current
230
+ this.jobs = new Map()
231
+ this.lifetime = new AbortController()
232
+ ctx.effect(() => () => {
233
+ this.lifetime.abort(new Error('绘图插件已卸载。'))
234
+ this.jobs.clear()
235
+ })
236
+ }
237
+
238
+ async run(args, signal) {
239
+ return generateImages({
240
+ config: this.current(), args, attachments: this.ctx.attachments,
241
+ signal: signal ? AbortSignal.any([signal, this.lifetime.signal]) : this.lifetime.signal,
242
+ resolveKey: async ref => {
243
+ const credentials = this.ctx.get('credentials')
244
+ return credentials ? (await credentials.resolve(ref))?.value : process.env[ref]
245
+ },
246
+ })
247
+ }
248
+
249
+ async discover(request) {
250
+ try {
251
+ return await discoverImageModels({ request, signal: this.lifetime.signal,
252
+ resolveKey: async ref => {
253
+ const credentials = this.ctx.get('credentials')
254
+ return credentials ? (await credentials.resolve(ref))?.value : process.env[ref]
255
+ },
256
+ })
257
+ } catch (error) { throw new RemoteError('image-generation/discovery', error.message) }
258
+ }
259
+
260
+ start(request) {
261
+ try { resolveRequest(this.current(), request) } catch (error) { throw new RemoteError('image-generation/invalid', error.message) }
262
+ // Retain completed tickets for one hour, and cap memory even if a tab disappears.
263
+ for (const [id, job] of this.jobs) if (job.status !== 'running' && Date.now() - job.created > 3600000) this.jobs.delete(id)
264
+ if ([...this.jobs.values()].filter(job => job.status === 'running').length >= 2) throw new RemoteError('image-generation/busy', '已有两个绘图任务正在运行。')
265
+ if (this.jobs.size >= 32) {
266
+ const oldest = [...this.jobs].find(([, job]) => job.status !== 'running')
267
+ if (oldest) this.jobs.delete(oldest[0])
268
+ }
269
+ const id = randomUUID()
270
+ const controller = new AbortController()
271
+ const job = { status: 'running', created: Date.now(), controller }
272
+ this.jobs.set(id, job)
273
+ void this.run(request, controller.signal).then(result => {
274
+ job.result = result
275
+ job.status = 'done'
276
+ }, error => {
277
+ job.error = controller.signal.aborted ? '已停止等待;服务端可能仍在生成。' : error.message
278
+ job.status = 'error'
279
+ })
280
+ return { jobId: id }
281
+ }
282
+
283
+ status(jobId) {
284
+ const job = this.jobs.get(jobId)
285
+ if (!job) throw new RemoteError('image-generation/missing', '绘图任务已过期或宿主已重启。')
286
+ return { status: job.status, ...(job.result ? { result: job.result } : {}), ...(job.error ? { error: job.error } : {}) }
287
+ }
288
+
289
+ cancel(jobId) {
290
+ const job = this.jobs.get(jobId)
291
+ if (!job) throw new RemoteError('image-generation/missing', '绘图任务不存在。')
292
+ if (job.status === 'running') job.controller.abort(new Error('绘图已取消。'))
293
+ return { cancelled: job.status === 'running' }
294
+ }
295
+
296
+ async image(jobId, imageIndex, original) {
297
+ const image = this.jobs.get(jobId)?.result?.images[imageIndex]
298
+ if (!Number.isInteger(imageIndex) || !image) throw new RemoteError('image-generation/missing', '图片不存在。')
299
+ if (original === true) {
300
+ const chunks = []
301
+ for await (const chunk of this.ctx.attachments.readFileStream(image.original, this.lifetime.signal)) chunks.push(chunk)
302
+ return { data: Buffer.concat(chunks).toString('base64'), mediaType: image.mediaType, name: image.original.name }
303
+ }
304
+ const stored = await this.ctx.attachments.readImage(image.preview)
305
+ return { data: Buffer.from(stored.data).toString('base64'), mediaType: stored.ref.mediaType, name: image.preview.name ?? 'preview.png' }
306
+ }
307
+ }
308
+
309
+ Object.defineProperty(ImageGenerationService.prototype, '@deepseek-ai/dsh-typert-protocol/remote-methods', {
310
+ value: Object.freeze({ version: 1, methods: Object.freeze(['start', 'status', 'cancel', 'image', 'discover'].map(method =>
311
+ Object.freeze({ method, invocation: Object.freeze({ kind: 'direct' }) }))) }),
312
+ })
313
+
314
+ export function registerImageTools(ctx, config) {
315
+ const current = () => {
316
+ const value = { models: config.models.get(), defaultModel: config.defaultModel.get() }
317
+ validateConfig(value)
318
+ return value
319
+ }
320
+ current()
321
+ const service = new ImageGenerationService(ctx, current)
322
+ const register = definition => ctx.effect(() => ctx.tools.register(defineTool(definition)), `image-generation: ${definition.name}`)
323
+ register({
324
+ name: 'list_image_models',
325
+ description: 'List configured image-generation models and their supported protocol/default parameters. These models are separate from chat models.',
326
+ parameters: {},
327
+ output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
328
+ isConcurrencySafe: () => true,
329
+ async execute() {
330
+ const value = current()
331
+ return { defaultModel: value.defaultModel ?? '', models: value.models.map(({ id, name, model, api, size, quality }) =>
332
+ ({ id, name: name || id, model, api, ...(size ? { size } : {}), ...(quality ? { quality } : {}) })) }
333
+ },
334
+ })
335
+ register({
336
+ name: 'generate_image',
337
+ description: 'Generate images with a separately configured Images API. Call list_image_models first if needed. This sends the prompt to the image provider and may incur charges. Results include original file paths and previews when the current chat model supports images. No automatic retries; a timeout may still have generated an image remotely. This tool generates new images only; it cannot edit or read reference images.',
338
+ parameters: IMAGE_PARAMETERS,
339
+ output: {
340
+ schema: { type: 'json' },
341
+ render: (_args, value) => [{ type: 'text', text: resultText(value) },
342
+ ...(value.includeImages ? value.images.map(image => ({ type: 'image', attachment: image.preview })) : [])],
343
+ },
344
+ isConcurrencySafe: () => false,
345
+ timeoutMs: 610000,
346
+ async execute(args, exec) {
347
+ // Check capability before the paid request; never append images a text-only
348
+ // route would reject on the next agent step.
349
+ const selection = exec.agent?.session?.requestHeader()?.config
350
+ const llm = ctx.get('llm')
351
+ const info = selection && llm ? await llm.resolveModelInfo(selection.provider, selection.model, exec.signal) : undefined
352
+ const includeImages = info?.inputModalities?.includes('image') === true
353
+ return { ...await service.run(args, exec.signal), includeImages }
354
+ },
355
+ })
356
+ }
357
+
358
+ /**
359
+ * Register everything: title provider, image tools, and the Remote service.
360
+ * @param ctx - context exposing tools, attachments, sessionTitle and llm.
361
+ * @param config - the unified Config described above.
362
+ */
363
+ export async function apply(ctx, config) {
364
+ await registerTitleProvider(ctx, config)
365
+ registerImageTools(ctx, config)
366
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "dsh-plugin-model-tuning",
3
+ "version": "2.0.1",
4
+ "license": "MIT",
5
+ "keywords": ["dsh-plugin", "deepseek-harness", "dsh", "model", "settings", "reasoning", "context", "session-title", "image-generation"],
6
+ "publishConfig": { "registry": "https://registry.npmjs.org" },
7
+ "repository": { "type": "git", "url": "git+https://github.com/tofu-zyc/dsh-plugin-tofu-box.git", "directory": "dsh-plugin-model-tuning" },
8
+ "bugs": { "url": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/issues" },
9
+ "homepage": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/tree/main/dsh-plugin-model-tuning",
10
+ "description": "「设置 → 模型调参」页:模型参数(推理档位/上下文/最大输出/输入模态)、「用途」页(标题生成独立路由,替换宿主标题行)、「绘图」页(绘图模型配置 + generate_image 工具 + 直接绘图面板)。v2 合并了原 dsh-plugin-title-model 与 dsh-plugin-image-generation。",
11
+ "type": "module",
12
+ "main": "index.js",
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./core": "./core.js",
16
+ "./client": "./client.js",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "dsh": {
20
+ "bundle": {
21
+ "patch": "./cordis.patch.yml"
22
+ },
23
+ "client": {
24
+ "platform": "web",
25
+ "inject": [
26
+ "@deepseek-ai/dsh-client-ui-settings",
27
+ "@deepseek-ai/dsh-client-ui-tool",
28
+ "@deepseek-ai/dsh-api-remotes"
29
+ ]
30
+ }
31
+ },
32
+ "peerDependencies": {
33
+ "@deepseek-ai/dsh-tools": ">=0.1.6-alpha.1",
34
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.6-alpha.1",
35
+ "@deepseek-ai/schemastery": "^3.18.2",
36
+ "@deepseek-ai/dsh-session-title-llm": ">=0.1.6-alpha.2"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "@deepseek-ai/dsh-session-title-llm": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "scripts": {
44
+ "test": "node --test tests/*.test.mjs"
45
+ },
46
+ "files": [
47
+ "client.js",
48
+ "index.js",
49
+ "core.js",
50
+ "cordis.patch.yml",
51
+ "README.md"
52
+ ]
53
+ }