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 }; }});