125 lines
15 KiB
JavaScript
125 lines
15 KiB
JavaScript
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 multipart = data instanceof FormData;
|
||
const response = await fetch(`/api/imagegen/${action}`, { method: data === undefined ? 'GET' : 'POST', credentials: 'same-origin', headers: data === undefined || multipart ? {} : { 'Content-Type': 'application/json' }, body: data === undefined ? undefined : multipart ? data : 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 [image, setImage] = useState(null);
|
||
const [preview, setPreview] = useState('');
|
||
const [fileInputKey, setFileInputKey] = useState(0);
|
||
useEffect(() => {
|
||
if (!image) { setPreview(''); return; }
|
||
const url = URL.createObjectURL(image);
|
||
setPreview(url);
|
||
return () => URL.revokeObjectURL(url);
|
||
}, [image]);
|
||
function selectImage(event) {
|
||
const file = event.target.files?.[0];
|
||
setError(''); setMessage('');
|
||
if (!file) { setImage(null); return; }
|
||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || !file.size || file.size > 20 * 1024 * 1024) {
|
||
setImage(null); event.target.value = '';
|
||
setError('请选择一张 PNG、JPEG 或 WebP 图片,文件大小不超过 20 MiB,且不能为空。');
|
||
return;
|
||
}
|
||
setImage(file);
|
||
}
|
||
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() {
|
||
if (busy || controller.current || !model.trim() || !prompt.trim() || (tab === 'edit' && !image)) return;
|
||
const action = tab === 'edit' ? 'edit' : 'generate';
|
||
await run(action, async () => {
|
||
const ac = new AbortController(); controller.current = ac;
|
||
try {
|
||
const args = { model: model.trim(), prompt, n: Number(count), ...(size.trim() ? { size: size.trim() } : {}), ...(quality ? { quality } : {}) };
|
||
let body = args;
|
||
if (action === 'edit') {
|
||
body = new FormData();
|
||
body.set('image', image, image.name);
|
||
body.set('input', JSON.stringify(args));
|
||
}
|
||
const value = await api(action, body, ac.signal);
|
||
setResults(value);
|
||
setMessage(`已使用 ${value.model} ${action === 'edit' ? '编辑并生成' : '生成'} ${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' }, '配置图片服务,选择模型后生成新图片,或上传图片进行编辑。'),
|
||
h('div', { className: 'ig-tabs', role: 'tablist', 'aria-label': '生图功能' }, ['settings', 'generate', 'edit'].map(id => h('button', { key: id, type: 'button', role: 'tab', 'aria-selected': tab === id, disabled: !!busy, className: tab === id ? 'ig-tab-active' : '', onClick: () => { setTab(id); setMessage(''); setError(''); } }, { settings: '接口设置', generate: '生成图片', edit: '编辑图片' }[id]))),
|
||
tab === 'settings' ? h('div', { className: 'ig-card' },
|
||
h('h3', null, '接口与凭据'),
|
||
h(Field, { label: 'Base URL', hint: '填写服务商提供的 API 根地址,如 https://api.openai.com/v1。' }, 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, tab === 'edit' ? '编辑图片' : '生成图片'),
|
||
tab === 'edit' && h('div', null,
|
||
h(Field, { label: '原图', hint: '选择一张 PNG、JPEG 或 WebP 图片,最大 20 MiB。' }, h('input', { key: fileInputKey, type: 'file', accept: 'image/png,image/jpeg,image/webp', disabled: !!busy, onChange: selectImage })),
|
||
image && h('div', null,
|
||
preview && h('img', { src: preview, alt: '待编辑的原图', style: { display: 'block', maxWidth: '100%', maxHeight: '320px', objectFit: 'contain', borderRadius: '10px' } }),
|
||
h('p', { className: 'ig-muted', style: { overflowWrap: 'anywhere' } }, image.name),
|
||
h('button', { type: 'button', disabled: !!busy, onClick: () => { setImage(null); setFileInputKey(value => value + 1); } }, '移除图片')
|
||
)
|
||
),
|
||
h(Field, { label: '本次使用模型', hint: tab === 'edit' ? '请选择支持图片编辑的模型,也可以填写完整的自定义模型 ID。' : '选择已配置模型,也可以填写完整的自定义模型 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: tab === 'edit' ? '修改要求' : '画面描述' }, h('textarea', { rows: 5, value: prompt, maxLength: 32000, disabled: !!busy, placeholder: tab === 'edit' ? '描述要修改的内容,以及需要保留的人物、构图或细节…' : '描述主体、风格、构图和需要出现的文字…', 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' }, `本次${tab === 'edit' ? '图片与修改要求' : '请求'}发送至 ${config.baseUrl},按服务商规则计费。`),
|
||
h('div', { className: 'ig-row' }, h('button', { className: 'ig-primary', disabled: !!busy || !config.configured || !model.trim() || !prompt.trim() || (tab === 'edit' && !image), onClick: generate }, busy === 'edit' ? '正在编辑…' : busy === 'generate' ? '正在生成…' : tab === 'edit' ? '编辑图片' : '生成图片'), ['generate', 'edit'].includes(busy) && 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 };
|
||
}});
|