feat: 发布 DSH 生图插件与中文安装使用文档

This commit is contained in:
cneicy committed 2026-10-05 00:21:57 +08:00
commit 108e957c64
25 files changed
+4138

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
window.__ModuleLoader__.load({ id: 'dsh-imagegen-local', factory(require) {
const React = require('react');
const h = React.createElement;
const { useState, useEffect } = React;
const style = `.ig-root{color:var(--dsw-alias-label-primary);font-size:14px;max-width:920px;margin:0 auto;padding:8px 4px 28px}.ig-root *{box-sizing:border-box}.ig-root h2{font-size:21px;margin:0 0 8px}.ig-root h3{font-size:16px;margin:0 0 14px}.ig-muted{color:var(--dsw-alias-label-secondary);line-height:1.6}.ig-card{border:1px solid var(--dsw-alias-border-l1);border-radius:16px;padding:20px;margin-top:20px;background:var(--dsw-alias-bg-layer-1)}.ig-field{display:flex;flex-direction:column;gap:7px;margin:14px 0;min-width:0}.ig-field span{font-weight:500}.ig-root input,.ig-root textarea,.ig-root select{width:100%;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:9px;padding:10px 12px;font:inherit}.ig-root input:focus,.ig-root textarea:focus,.ig-root select:focus{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.ig-root textarea{resize:vertical}.ig-row{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.ig-row>.ig-field{flex:1 1 160px}.ig-root button,.ig-link{border:1px solid var(--dsw-alias-border-l2);border-radius:9px;padding:9px 15px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit}.ig-root button:disabled{opacity:.5;cursor:default}.ig-root .ig-primary{background:var(--dsw-alias-brand-primary);color:#fff;border-color:transparent}.ig-message{margin-top:14px;white-space:pre-wrap;overflow-wrap:anywhere}.ig-error{color:var(--dsw-alias-state-error-primary)}.ig-success{color:var(--dsw-alias-state-success-primary)}.ig-gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-top:20px}.ig-gallery img{display:block;width:100%;height:auto;max-height:500px;object-fit:contain;border-radius:10px;background:var(--dsw-alias-bg-base)}.ig-gallery a{color:var(--dsw-alias-brand-primary);display:inline-block;margin-top:10px}.ig-gallery small{display:block;overflow-wrap:anywhere;margin-top:8px}.ig-check{display:flex;align-items:center;gap:8px}.ig-check input{width:auto}.ig-status{border-radius:20px;padding:4px 10px;background:var(--dsw-alias-bg-layer-2);font-size:12px}.ig-tabs{display:flex;gap:8px;margin:20px 0}.ig-root .ig-tab-active{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}.ig-root details{margin-top:12px}.ig-root summary{cursor:pointer;color:var(--dsw-alias-label-secondary)}`;
async function api(action, data, signal) {
const response = await fetch(`/api/imagegen/${action}`, { method: data === undefined ? 'GET' : 'POST', credentials: 'same-origin', headers: data === undefined ? {} : { 'Content-Type': 'application/json' }, body: data === undefined ? undefined : JSON.stringify(data), signal });
const value = await response.json();
if (!response.ok) throw new Error(value.error || `请求失败(${response.status})`);
return value;
}
function Field({ label, children, hint }) { return h('label', { className: 'ig-field' }, h('span', null, label), children, hint && h('small', { className: 'ig-muted' }, hint)); }
const option = (value, label) => h('option', { key: value, value }, label || value);
function Panel() {
const [tab, setTab] = useState('settings');
const [config, setConfig] = useState(null);
const [baseUrl, setBaseUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [clearKey, setClearKey] = useState(false);
const [models, setModels] = useState('');
const [defaultModel, setDefault] = useState('');
const [model, setModel] = useState('');
const [prompt, setPrompt] = useState('');
const [size, setSize] = useState('');
const [quality, setQuality] = useState('');
const [count, setCount] = useState('1');
const [busy, setBusy] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [results, setResults] = useState(null);
const [controller] = useState(() => ({ current: null }));
function load(value) { setConfig(value); setBaseUrl(value.baseUrl); setModels(value.models.join('\n')); setDefault(value.defaultModel); setModel(current => current || value.defaultModel); }
useEffect(() => { const ac = new AbortController(); api('settings', undefined, ac.signal).then(load).catch(e => { if (!ac.signal.aborted) setError(e.message); }); return () => { ac.abort(); controller.current?.abort(); }; }, []);
const modelList = [...new Set(models.split(/\r?\n/).map(m => m.trim()).filter(Boolean))];
async function run(label, operation) { setBusy(label); setError(''); setMessage(''); try { await operation(); } catch (e) { setError(e.name === 'AbortError' ? '已停止等待;服务端可能已经接收请求,请勿立即重复生成。' : e.message); } finally { setBusy(''); } }
const changed = config && (baseUrl !== config.baseUrl || models !== config.models.join('\n') || defaultModel !== config.defaultModel || apiKey || clearKey);
async function save() { await run('save', async () => { const value = await api('settings', { baseUrl, models: modelList, defaultModel, ...(apiKey ? { apiKey } : {}), clearKey }); load(value); setApiKey(''); setClearKey(false); setMessage('设置已保存,对话生图立即使用这些配置。'); }); }
async function discover() { await run('models', async () => { const value = await api('models', {}); setModels([...new Set([...modelList, ...value.models])].join('\n')); setMessage(`已读取 ${value.models.length} 个模型。列表可能包含聊天模型,请保留支持生图的模型后保存。`); }); }
async function generate() { await run('generate', async () => { const ac = new AbortController(); controller.current = ac; try { const value = await api('generate', { model: model.trim(), prompt, n: Number(count), ...(size.trim() ? { size: size.trim() } : {}), ...(quality ? { quality } : {}) }, ac.signal); setResults(value); setMessage(`已使用 ${value.model} 生成 ${value.images.length} 张图片。`); } finally { controller.current = null; } }); }
if (!config) return h('div', { className: 'ig-root' }, h('h2', null, '生图'), h('p', { role: error ? 'alert' : 'status' }, error || '正在读取生图设置…'));
return h('div', { className: 'ig-root' },
h('div', { className: 'ig-row' }, h('h2', null, '生图'), h('span', { className: 'ig-status' }, config.configured ? '接口已配置' : '待配置')),
h('p', { className: 'ig-muted' }, '配置兼容 OpenAI Images API 的服务,然后在对话中指定模型生图,或在这里直接生成。'),
h('div', { className: 'ig-tabs' }, ['settings', 'generate'].map(id => h('button', { key: id, type: 'button', className: tab === id ? 'ig-tab-active' : '', onClick: () => { setTab(id); setMessage(''); setError(''); } }, id === 'settings' ? '接口设置' : '生成图片'))),
tab === 'settings' ? h('div', { className: 'ig-card' },
h('h3', null, '接口与凭据'),
h(Field, { label: 'Base URL', hint: '填写 API 根地址,如 https://api.openai.com/v1;插件会追加 /images/generations。' }, h('input', { value: baseUrl, onChange: e => setBaseUrl(e.target.value), placeholder: 'https://api.openai.com/v1', spellCheck: false, disabled: !!busy })),
h(Field, { label: 'API Key', hint: baseUrl === config.baseUrl && config.configured ? '已保存。留空保持当前密钥;密钥不会回显。' : '为这个 Base URL 输入密钥。不同地址的密钥分别保存。' }, h('input', { type: 'password', value: apiKey, onChange: e => { setApiKey(e.target.value); setClearKey(false); }, autoComplete: 'new-password', placeholder: '输入 API Key', disabled: !!busy })),
h('label', { className: 'ig-check ig-muted' }, h('input', { type: 'checkbox', checked: clearKey, disabled: !!busy, onChange: e => { setClearKey(e.target.checked); if (e.target.checked) setApiKey(''); } }), '清除这个地址已保存的密钥'),
h(Field, { label: '可选模型', hint: '每行一个精确模型 ID。支持手动填写第三方模型;模型是否可用由服务商决定。' }, h('textarea', { value: models, rows: 4, spellCheck: false, disabled: !!busy, onChange: e => setModels(e.target.value) })),
h(Field, { label: '对话默认模型', hint: '这是你的显式选择。未选择时,助手会先询问模型;对话中指定的模型优先。' }, h('select', { value: defaultModel, disabled: !!busy, onChange: e => setDefault(e.target.value) }, option('', '每次在对话中指定'), modelList.map(m => option(m)))),
h('div', { className: 'ig-row' }, h('button', { className: 'ig-primary', disabled: !!busy, onClick: save }, busy === 'save' ? '保存中…' : '保存设置'), h('button', { disabled: !!busy || !!changed || !config.configured, onClick: discover }, busy === 'models' ? '读取中…' : '从接口读取模型')),
h('p', { className: 'ig-muted' }, '先保存接口设置,再读取模型。部分服务不提供模型列表,可以直接手动填写。')
) : h('div', { className: 'ig-card' },
h('h3', null, '生成图片'),
h(Field, { label: '本次生成模型', hint: '选择已配置模型,也可以填写完整的自定义模型 ID。' }, h('input', { list: 'ig-model-options', value: model, disabled: !!busy, onChange: e => setModel(e.target.value), placeholder: '选择或输入模型' }), h('datalist', { id: 'ig-model-options' }, config.models.map(m => option(m)))),
h(Field, { label: '画面描述' }, h('textarea', { rows: 5, value: prompt, maxLength: 32000, disabled: !!busy, placeholder: '描述主体、风格、构图和需要出现的文字…', onChange: e => setPrompt(e.target.value) })),
h('details', null, h('summary', null, '尺寸、质量与数量'), h('div', { className: 'ig-row' },
h(Field, { label: '尺寸', hint: '留空使用接口默认' }, h('input', { value: size, placeholder: '如 1024x1024', disabled: !!busy, onChange: e => setSize(e.target.value) })),
h(Field, { label: '质量' }, h('select', { value: quality, disabled: !!busy, onChange: e => setQuality(e.target.value) }, option('', '接口默认'), ['auto','low','medium','high','standard','hd'].map(m => option(m)))),
h(Field, { label: '数量' }, h('select', { value: count, disabled: !!busy, onChange: e => setCount(e.target.value) }, ['1','2','3','4'].map(m => option(m))))
)),
h('p', { className: 'ig-muted' }, `本次请求发送至 ${config.baseUrl},按服务商规则计费。`),
h('div', { className: 'ig-row' }, h('button', { className: 'ig-primary', disabled: !!busy || !config.configured || !model.trim() || !prompt.trim(), onClick: generate }, busy === 'generate' ? '正在生成…' : '生成图片'), busy === 'generate' && h('button', { onClick: () => controller.current?.abort() }, '停止等待')),
!config.configured && h('p', { className: 'ig-muted' }, '请先在接口设置中保存 API Key。')
),
error && h('div', { role: 'alert', className: 'ig-message ig-error' }, error),
message && h('div', { role: 'status', className: 'ig-message ig-success' }, message),
results && h('section', null, h('p', { className: 'ig-muted' }, `最近生成 · ${results.model}`), h('div', { className: 'ig-gallery' }, results.images.map(image => h('div', { key: image.filename }, h('img', { src: image.url, alt: results.prompt }), h('a', { href: image.url, download: image.filename }, '下载原图'), h('small', { className: 'ig-muted' }, image.path)))))
);
}
function apply(ctx) {
ctx.effect(() => { const element = document.createElement('style'); element.textContent = style; document.head.appendChild(element); return () => element.remove(); });
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'imagegen-local', order: 30, label: '生图' }, Panel));
}
return { name: 'imagegen-local-client', inject: ['slots'], apply };
}});
+169
View File
@@ -0,0 +1,169 @@
const MAX_BYTES = 64 * 1024 * 1024;
const TIMEOUT_MS = 180_000;
const QUALITY = new Set(['auto', 'low', 'medium', 'high', 'standard', 'hd']);
const BACKGROUND = new Set(['auto', 'transparent', 'opaque']);
const OUTPUT_FORMAT = new Set(['png', 'jpeg', 'webp']);
export function validateBaseUrl(value) {
if (typeof value !== 'string' || !value.trim()) throw new Error('Base URL is required.');
let url;
try { url = new URL(value.trim()); } catch { throw new Error('Base URL must be an absolute HTTP(S) URL.'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || /[?#]/.test(value)) {
throw new Error('Base URL must use HTTP(S) without credentials, query, or fragment.');
}
return url.href.replace(/\/+$/, '');
}
export function validateGeneration(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Generation input must be an object.');
const result = {};
for (const [key, maximum] of [['model', 200], ['prompt', 32000]]) {
if (typeof input[key] !== 'string' || !input[key].trim() || input[key].length > maximum) {
throw new Error(`${key} must be a nonempty string of at most ${maximum} characters.`);
}
result[key] = input[key];
}
result.n = input.n === undefined ? 1 : input.n;
if (!Number.isInteger(result.n) || result.n < 1 || result.n > 4) throw new Error('n must be an integer between 1 and 4.');
if (input.size !== undefined) {
if (typeof input.size !== 'string' || !/^(?:auto|[0-9]+x[0-9]+)$/.test(input.size)) throw new Error('size must be auto or WIDTHxHEIGHT.');
result.size = input.size;
}
for (const [key, values] of [['quality', QUALITY], ['background', BACKGROUND], ['output_format', OUTPUT_FORMAT]]) {
if (input[key] !== undefined) {
if (!values.has(input[key])) throw new Error(`Invalid ${key}.`);
result[key] = input[key];
}
}
return result;
}
function apiHeaders(apiKey) {
if (typeof apiKey !== 'string' || !apiKey.trim() || /[\r\n]/.test(apiKey)) throw new Error('A valid API key is required.');
return { Authorization: `Bearer ${apiKey}` };
}
async function operation(signal, work) {
const controller = new AbortController();
let timedOut = false;
const abort = () => controller.abort();
if (signal?.aborted) abort();
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, TIMEOUT_MS);
timer.unref?.();
try {
return await work(controller.signal);
} catch (error) {
if (controller.signal.aborted) throw new Error(timedOut ? 'Request timed out after 180 seconds.' : 'Request aborted.');
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
}
async function fetchBytes(url, options, label, budget) {
let response;
try { response = await fetch(url, { ...options, redirect: 'error' }); }
catch { throw new Error(`${label} network request failed.`); }
if (!response.ok) {
try { await response.body?.cancel(); } catch { /* Do not expose provider errors. */ }
throw new Error(`${label} failed with HTTP ${response.status}.`);
}
const declared = response.headers.get('content-length');
if (declared && Number(declared) > budget.remaining) {
try { await response.body?.cancel(); } catch { /* Best effort cancellation. */ }
throw new Error(`${label} exceeds the 64 MB response limit.`);
}
if (!response.body) throw new Error(`${label} returned an empty response.`);
const reader = response.body.getReader();
const chunks = [];
let length = 0;
try {
while (true) {
let part;
try { part = await reader.read(); } catch { throw new Error(`${label} response download failed.`); }
if (part.done) break;
length += part.value.byteLength;
budget.remaining -= part.value.byteLength;
if (budget.remaining < 0) {
try { await reader.cancel(); } catch { /* Best effort cancellation. */ }
throw new Error(`${label} exceeds the 64 MB response limit.`);
}
chunks.push(Buffer.from(part.value));
}
} finally { reader.releaseLock(); }
return Buffer.concat(chunks, length);
}
async function fetchJson(url, options, budget) {
const bytes = await fetchBytes(url, options, 'API request', budget);
try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
catch { throw new Error('API returned malformed JSON.'); }
}
function decodeBase64(value) {
if (typeof value !== 'string' || !value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
throw new Error('API returned invalid base64 image data.');
}
const bytes = Buffer.from(value, 'base64');
if (bytes.toString('base64') !== value) throw new Error('API returned invalid base64 image data.');
return bytes;
}
function mediaType(bytes) {
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png';
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg';
if (bytes.length >= 6 && ['GIF87a', 'GIF89a'].includes(bytes.toString('latin1', 0, 6))) return 'image/gif';
if (bytes.length >= 12 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
throw new Error('Image bytes are not a supported PNG, JPEG, WebP, or GIF image.');
}
function imageUrl(value) {
let url;
try { url = new URL(value); } catch { throw new Error('API returned an invalid image URL.'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('API returned an invalid image URL.');
return url.href;
}
export async function listModels({ baseUrl, apiKey, signal }) {
const endpoint = `${validateBaseUrl(baseUrl)}/models`;
const headers = apiHeaders(apiKey);
return operation(signal, async combinedSignal => {
const json = await fetchJson(endpoint, { headers, signal: combinedSignal }, { remaining: MAX_BYTES });
if (!json || !Array.isArray(json.data) || json.data.some(item => !item || typeof item.id !== 'string' || !item.id.trim())) {
throw new Error('API returned a malformed model list.');
}
return json.data.map(item => item.id);
});
}
export async function generateImages({ baseUrl, apiKey, input, signal }) {
const endpoint = `${validateBaseUrl(baseUrl)}/images/generations`;
const generation = validateGeneration(input);
const headers = { ...apiHeaders(apiKey), 'Content-Type': 'application/json' };
const body = JSON.stringify(generation);
if (Buffer.byteLength(body) > MAX_BYTES) throw new Error('Request exceeds the 64 MB request limit.');
return operation(signal, async combinedSignal => {
const budget = { remaining: MAX_BYTES };
const json = await fetchJson(endpoint, { method: 'POST', headers, body, signal: combinedSignal }, budget);
if (!json || !Array.isArray(json.data) || !json.data.length) throw new Error('API returned a malformed image response.');
const images = [];
let decodedBytes = 0;
for (const item of json.data) {
if (!item || typeof item !== 'object' || (item.revised_prompt !== undefined && typeof item.revised_prompt !== 'string')) {
throw new Error('API returned a malformed image entry.');
}
let bytes;
if (item.b64_json !== undefined) bytes = decodeBase64(item.b64_json);
else if (typeof item.url === 'string') bytes = await fetchBytes(imageUrl(item.url), { signal: combinedSignal }, 'Image download', budget);
else throw new Error('API returned an image without base64 data or a URL.');
decodedBytes += bytes.length;
if (decodedBytes > MAX_BYTES) throw new Error('Images exceed the 64 MB response limit.');
const image = { data: bytes, mediaType: mediaType(bytes) };
if (item.revised_prompt !== undefined) image.revisedPrompt = item.revised_prompt;
images.push(image);
}
return { model: generation.model, prompt: generation.prompt, images };
});
}
+137
View File
@@ -0,0 +1,137 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash, randomUUID } from 'node:crypto';
import { validateBaseUrl, validateGeneration, generateImages, listModels } from './engine.mjs';
export const name = 'imagegen-local';
export const inject = ['connection', 'tools', 'credentials', 'attachments', 'skills'];
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const dataRoot = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'imagegen');
const settingsPath = join(dataRoot, 'settings.json');
const outputs = join(dataRoot, 'outputs');
const initial = { baseUrl: 'https://api.openai.com/v1', models: ['gpt-image-2', 'gpt-image-1.5'], defaultModel: '' };
const keyFor = base => `dsh-imagegen:${createHash('sha256').update(base).digest('hex')}`;
const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } });
const schema = { type: 'object', additionalProperties: true };
const text = value => ({ type: 'text', text: JSON.stringify(value, null, 2) });
export async function apply(ctx) {
let config = { ...initial };
try { config = validateSettings(JSON.parse(await readFile(settingsPath, 'utf8'))); }
catch (error) { if (error.code !== 'ENOENT') throw new Error('生图配置无法读取,请检查 .dsh/imagegen/settings.json。'); }
let queue = Promise.resolve();
const lifetime = new AbortController();
ctx.on('dispose', () => lifetime.abort());
const signalFor = signal => signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
const readKey = async base => (await ctx.credentials.readRecord(keyFor(base)))?.key;
const view = async () => ({ ...config, configured: Boolean(await readKey(config.baseUrl)), outputDirectory: outputs });
async function save(input) {
const next = validateSettings(input);
if (input.apiKey !== undefined && (typeof input.apiKey !== 'string' || input.apiKey.length > 16384)) throw new Error('API Key 格式无效。');
if (input.clearKey && input.apiKey?.trim()) throw new Error('不能同时清除和保存 API Key。');
if (input.apiKey?.trim()) await ctx.credentials.modifyRecord(keyFor(next.baseUrl), async () => ({ kind: 'api-key', key: input.apiKey.trim() }));
if (input.clearKey === true) await ctx.credentials.deleteRecord(keyFor(next.baseUrl));
await mkdir(dataRoot, { recursive: true });
const temp = `${settingsPath}.${randomUUID()}.tmp`;
await writeFile(temp, JSON.stringify(next, null, 2), { mode: 0o600, flag: 'wx' });
await rename(temp, settingsPath);
config = next;
return view();
}
async function generate(input, signal) {
const args = validateGeneration(input);
const snapshot = { ...config };
const apiKey = await readKey(snapshot.baseUrl);
if (!apiKey) throw new Error('请先在 设置 → 生图 中为当前 Base URL 保存 API Key。');
const result = await generateImages({ baseUrl: snapshot.baseUrl, apiKey, input: args, signal: signalFor(signal) });
await mkdir(outputs, { recursive: true });
const images = [];
for (const item of result.images) {
const extension = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }[item.mediaType];
const filename = `${Date.now()}-${randomUUID()}.${extension}`;
const path = join(outputs, filename);
await writeFile(path, item.data, { flag: 'wx' });
// Keep original bytes on disk even when DSH normalizes the inline preview.
const attachment = await ctx.attachments.saveImage({ data: item.data, mediaType: item.mediaType, name: filename });
images.push({ path, filename, url: `/api/imagegen/file?name=${encodeURIComponent(filename)}`, attachment, ...(item.revisedPrompt ? { revisedPrompt: item.revisedPrompt } : {}) });
}
return { model: args.model, prompt: args.prompt, images };
}
function route(path, methods, handler) {
ctx.connection.fetch.register({ path, methods, requestBody: 'buffered', fetch: async request => {
try { return await handler(request); }
catch (error) {
// Never include arbitrary provider responses or request headers in a browser error.
const message = String(error.message || '生图请求失败。');
return json({ error: message }, error.name === 'AbortError' ? 499 : 400);
}
} });
}
async function body(request) {
const raw = await request.text();
if (raw.length > 100000) throw new Error('请求内容过大。');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('请求格式无效。');
return parsed;
}
route('/api/imagegen/settings', ['GET', 'POST'], async request => {
if (request.method === 'GET') return json(await view());
const input = await body(request);
const operation = queue.then(() => save(input));
queue = operation.catch(() => {});
return json(await operation);
});
route('/api/imagegen/models', ['POST'], async request => {
const snapshot = { ...config };
const apiKey = await readKey(snapshot.baseUrl);
if (!apiKey) throw new Error('请先保存 Base URL 和 API Key。');
return json({ models: await listModels({ baseUrl: snapshot.baseUrl, apiKey, signal: signalFor(request.signal) }) });
});
route('/api/imagegen/generate', ['POST'], async request => json(await generate(await body(request), request.signal)));
route('/api/imagegen/file', ['GET'], async request => {
const filename = new URL(request.url).searchParams.get('name') || '';
if (!/^\d+-[0-9a-f-]{36}\.(png|jpg|webp|gif)$/.test(filename)) return json({ error: '图片不存在。' }, 404);
let bytes;
try { bytes = await readFile(join(outputs, filename)); } catch (e) { if (e.code === 'ENOENT') return json({ error: '图片不存在。' }, 404); throw e; }
const ext = filename.split('.').pop();
return new Response(bytes, { headers: { 'Content-Type': { png: 'image/png', jpg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }[ext], 'Cache-Control': 'private, max-age=31536000, immutable', 'X-Content-Type-Options': 'nosniff', 'Content-Disposition': `inline; filename="${filename}"` } });
});
ctx.tools.register({
name: 'imagegen_status',
description: 'Read image generation configuration (never reveals API keys). Returns Base URL, configured status, model choices and the user-selected default. Use before imagegen_generate; if the user has not specified a model and defaultModel is empty, ask them to choose.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
output: { schema, render: (_, value) => [text(value)] },
execute: () => view(),
isConcurrencySafe: () => true,
});
ctx.tools.register({
name: 'imagegen_generate',
description: 'Generate images using the configured Images API. Requires an exact model explicitly chosen by the user in chat or Settings → 生图 (query imagegen_status for their default). Never silently switch models. Returns saved original file paths and inline image attachments. API calls may incur charges. Text-to-image only; no reference-image editing. Reuse dsh-imagegen skill prompt guidance.',
parameters: { type: 'object', properties: {
model: { type: 'string', description: 'Exact user-selected model ID. Required; no automatic fallback.' },
prompt: { type: 'string', description: 'Image prompt, preserving requested text and constraints.' },
n: { type: 'integer', description: 'Number of variations of this prompt (1–4), default 1.' },
size: { type: 'string', description: 'Optional auto or WIDTHxHEIGHT, if supported by this model.' },
quality: { type: 'string', enum: ['auto', 'low', 'medium', 'high', 'standard', 'hd'] },
background: { type: 'string', enum: ['auto', 'transparent', 'opaque'] },
output_format: { type: 'string', enum: ['png', 'jpeg', 'webp'] },
}, required: ['model', 'prompt'], additionalProperties: false },
timeoutMs: 240000,
output: { schema, render: (_, value) => [text({ model: value.model, prompt: value.prompt, images: value.images.map(({ path, revisedPrompt }) => ({ path, revisedPrompt })) }), ...value.images.map(image => ({ type: 'image', attachment: image.attachment }))] },
execute: (args, exec) => generate(args, exec.signal),
presentCall: args => ({ card: 'generic', title: `生图 · ${args.model}`, kind: 'fetch', rawInput: args }),
});
ctx.skills.register({ name: 'dsh-imagegen', description: '使用生图插件生成图片,复用 imagegen 提示词规范,支持自定义 API 与显式模型选择。', source: 'runtime', path: join(root, 'skills', 'SKILL.md'), resourceBase: { kind: 'directory', path: join(root, 'skills') }, content: await readFile(join(root, 'skills', 'SKILL.md'), 'utf8') });
}
export function validateSettings(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('生图设置格式无效。');
const baseUrl = validateBaseUrl(input.baseUrl);
if (!Array.isArray(input.models) || input.models.length > 200 || input.models.some(m => typeof m !== 'string' || !m.trim() || m.length > 200)) throw new Error('模型列表无效,请每行填写一个模型 ID(最多 200 个)。');
const models = [...new Set(input.models.map(m => m.trim()))];
const defaultModel = typeof input.defaultModel === 'string' ? input.defaultModel.trim() : '';
if (defaultModel && !models.includes(defaultModel)) throw new Error('默认模型必须位于模型列表中。');
return { baseUrl, models, defaultModel };
}