Files
dsh-imagegen/lib/client.js
T

78 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}});