Files
dsh-imagegen/lib/client.js
T

125 lines
15 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 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 };
}});