From 9e4afa3040c0f6df8ca246f16714373c010910d0 Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:14:08 +0800 Subject: [PATCH] =?UTF-8?q?gui:=20=E5=A4=9A=E4=B8=BB=E9=A2=98=E5=A4=96?= =?UTF-8?q?=E8=A7=82(6=E8=89=B2=E6=9D=BF/=E8=83=8C=E6=99=AF=E5=9B=BE?= =?UTF-8?q?=E7=BC=93=E5=AD=98)/=E8=BF=9E=E6=8E=A5=E8=AE=A4=E8=AF=81(?= =?UTF-8?q?=E6=80=BB=E7=BD=91=E5=85=B3=E7=99=BB=E5=BD=95=E7=AA=97=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=8A=93Cookie)/=E4=B8=AD=E8=8B=B1=E5=88=87=E6=8D=A2/?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E7=A6=81=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/gui/main.js | 265 ++++++++++++++++++++++- cmd/gui/preload.js | 7 + cmd/gui/renderer/app.js | 418 ++++++++++++++++++++++++++++++++---- cmd/gui/renderer/index.html | 3 + cmd/gui/renderer/style.css | 202 ++++++++++++++++- 5 files changed, 848 insertions(+), 47 deletions(-) diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 9843a86..abcb3fc 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -3,16 +3,69 @@ const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); const http = require('http'); +const crypto = require('crypto'); +const { pathToFileURL } = require('url'); const CONNECTIONS_FILE = path.join(app.getPath('userData'), 'connections.json'); +const LOG_FILE = path.join(app.getPath('userData'), 'gui.log'); +function log(msg) { + try { fs.appendFileSync(LOG_FILE, new Date().toISOString() + ' ' + msg + '\n'); } catch (e) {} +} +const _consoleLog = console.log, _consoleErr = console.error; +console.log = function () { log(Array.prototype.slice.call(arguments).join(' ')); _consoleLog.apply(null, arguments); }; +console.error = function () { log('ERR ' + Array.prototype.slice.call(arguments).join(' ')); _consoleErr.apply(null, arguments); }; let homedProcess = null; let mainWindow; +let authRule = null; + +function installAuthRule() { + const { session } = require('electron'); + session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { + const h = Object.assign({}, details.requestHeaders); + if (authRule && details.url.indexOf(authRule.url) === 0) { + if (authRule.headers) { + Object.keys(authRule.headers).forEach((k) => { + h[k] = authRule.headers[k]; + }); + } + if (authRule.cookie) { + h['Cookie'] = (h['Cookie'] ? h['Cookie'] + '; ' : '') + authRule.cookie; + } + } + callback({ requestHeaders: h }); + }); + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + const h = Object.assign({}, details.responseHeaders || {}); + if (details.method === 'OPTIONS' || (authRule && details.url.indexOf(authRule.url) === 0)) { + h['Access-Control-Allow-Origin'] = ['*']; + h['Access-Control-Allow-Methods'] = ['GET,POST,PUT,DELETE,OPTIONS']; + h['Access-Control-Allow-Headers'] = ['Content-Type, X-API-Key, Authorization, Cookie']; + h['Access-Control-Max-Age'] = ['86400']; + } + if (details.method === 'OPTIONS') { + callback({ responseHeaders: h, statusLine: 'HTTP/1.1 200 OK' }); + return; + } + callback({ responseHeaders: h }); + }); + session.defaultSession.webRequest.onCompleted((details) => { + if (authRule && details.url.indexOf(authRule.url) === 0 && details.url.indexOf('/api/') !== -1) { + log('[req] ' + details.method + ' ' + details.statusCode + ' ' + details.url.slice(0, 120)); + } + }); + session.defaultSession.webRequest.onErrorOccurred((details) => { + if (authRule && details.url.indexOf(authRule.url) === 0) { + log('[req-err] ' + details.method + ' ' + details.error + ' ' + details.url.slice(0, 120)); + } + }); +} function loadConnections() { try { if (fs.existsSync(CONNECTIONS_FILE)) { - const data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf-8')); + const raw = fs.readFileSync(CONNECTIONS_FILE, 'utf-8').replace(/^\uFEFF/, ''); + const data = JSON.parse(raw); normalizeConnections(data); return data; } @@ -202,6 +255,61 @@ ipcMain.handle('connections:delete', (_, id) => { return data; }); +function downloadTo(src, dest) { + return new Promise((resolve, reject) => { + const mod = src.startsWith('https:') ? require('https') : http; + const req = mod.get(src, { + headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }, + rejectUnauthorized: false, + }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + downloadTo(new URL(res.headers.location, src).toString(), dest).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { res.resume(); reject(new Error('HTTP ' + res.statusCode)); return; } + const out = fs.createWriteStream(dest); + res.pipe(out); + out.on('finish', () => out.close(resolve)); + out.on('error', reject); + res.on('error', reject); + }); + req.on('error', reject); + req.setTimeout(60000, () => req.destroy(new Error('timeout'))); + }); +} + +ipcMain.handle('bg:cache', async (_, { src }) => { + if (!src || typeof src !== 'string') return { ok: false, error: 'no src' }; + const dir = path.join(app.getPath('userData'), 'bg-cache'); + try { + fs.mkdirSync(dir, { recursive: true }); + const hash = crypto.createHash('sha1').update(src).digest('hex').slice(0, 24); + let dest = null; + if (/^https?:\/\//i.test(src)) { + dest = path.join(dir, hash); + if (!fs.existsSync(dest)) { + try { await downloadTo(src, dest); } + catch (e) { return { ok: false, error: '下载失败: ' + e.message, useOriginal: true }; } + } + } else if (/^file:\/\//i.test(src)) { + dest = new URL(src).pathname.replace(/^\/([A-Za-z]:)/, '$1'); + if (!fs.existsSync(dest)) return { ok: false, error: '文件不存在: ' + src }; + } else if (/^data:image\//i.test(src)) { + dest = path.join(dir, hash + '.png'); + if (!fs.existsSync(dest)) fs.writeFileSync(dest, Buffer.from(src.split(',')[1] || '', 'base64')); + } else { + const p = path.resolve(src); + if (!fs.existsSync(p)) return { ok: false, error: '路径不存在: ' + src }; + dest = path.join(dir, hash + (path.extname(p) || '')); + if (!fs.existsSync(dest)) fs.copyFileSync(p, dest); + } + return { ok: true, file: pathToFileURL(dest).href }; + } catch (e) { + return { ok: false, error: e.message }; + } +}); + ipcMain.handle('connections:setCurrent', (_, id) => { const data = loadConnections(); if (data.connections.some(c => c.id === id)) { @@ -211,6 +319,160 @@ ipcMain.handle('connections:setCurrent', (_, id) => { return data; }); +function doWebuiLogin(baseUrl, username, password, extraCookie) { + return new Promise((resolve, reject) => { + const u = new URL(baseUrl + '/api/v1/login'); + const body = JSON.stringify({ username, password }); + const mod = u.protocol === 'https:' ? require('https') : http; + const headers = { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + 'Accept': 'application/json', + }; + if (extraCookie) headers['Cookie'] = extraCookie; + const req = mod.request(u, { + method: 'POST', + headers, + }, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + const sc = res.headers['set-cookie']; + if (!sc) { + reject(new Error('login failed HTTP ' + res.statusCode + ' ' + data.slice(0, 150))); + return; + } + const cookies = Array.isArray(sc) ? sc : [sc]; + let tok = null; + cookies.some((c) => { + const m = /homeagent_session=([^;]+)/.exec(c); + if (m) { tok = m[1]; return true; } + return false; + }); + if (!tok) { + reject(new Error('login ok but no homeagent_session cookie')); + return; + } + resolve(tok); + }); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +ipcMain.on('log:r', (_e, m) => { log('[r] ' + m); }); + +ipcMain.handle('webui:setAuth', async (_, { url, cookie, headers, username, password }) => { + const resp = { ok: true, error: '' }; + let extra = cookie || ''; + try { + if (username && url) { + try { + const tok = await doWebuiLogin(url, username, password, extra); + extra = extra ? extra + '; homeagent_session=' + tok : 'homeagent_session=' + tok; + } catch (e) { + if (extra) { + log('[setAuth] auto-login skipped (gateway cookie mode): ' + e.message.slice(0, 120)); + } else { + throw e; + } + } + } else if (!cookie) { + authRule = { url: '', cookie: '', headers: {} }; + return resp; + } + } catch (e) { + authRule = { url: url || '', cookie: extra || '', headers: headers || {} }; + resp.ok = false; + resp.error = e.message; + log('[setAuth] fail url=' + (url || '') + ' err=' + e.message.slice(0, 150)); + return resp; + } + authRule = { url: url || '', cookie: extra, headers: headers || {} }; + log('[setAuth] ok url=' + (url || '') + ' cookieLen=' + extra.length + ' hasTok=' + (extra.indexOf('homeagent_session') !== -1)); + return resp; +}); + +ipcMain.handle('webui:openLogin', (_, { url, username, password }) => { + if (!url) return { ok: false, error: 'no url' }; + const loginWin = new BrowserWindow({ + width: 980, + height: 740, + parent: mainWindow, + modal: true, + autoHideMenuBar: true, + title: 'HomeAgent - 网关联机登录', + webPreferences: { contextIsolation: true, nodeIntegration: false }, + }); + const u = encodeURIComponent(username || ''); + const p = encodeURIComponent(password || ''); + let autoFills = 0; + const tryAutofill = () => { + if (autoFills > 8) return; + autoFills++; + loginWin.webContents + .executeJavaScript( + '(function(){var pw=document.querySelector("input[type=password]");' + + 'if(!pw)return false;var f=pw.closest("form")||pw.form;if(!f)return false;' + + 'var ins=f.querySelectorAll("input");var filled=false;' + + 'for(var i=0;i { + if (r) log('[openLogin] autofilled gateway login form'); + }) + .catch((e) => log('[openLogin] autofill error: ' + e.message)); + }; + loginWin.webContents.on('did-finish-load', () => { + if (autoFills < 3) tryAutofill(); + }); + loginWin.webContents.on('did-navigate', (_e, u) => { + log('[openLogin] nav: ' + u); + if (autoFills < 6) setTimeout(tryAutofill, 600); + }); + loginWin.webContents.on('did-fail-load', (_e, code, desc, isMain, failedUrl) => { + if (isMain) log('[openLogin] fail-load ' + code + ' ' + desc + ' @ ' + failedUrl); + }); + loginWin.loadURL(url).catch((e) => console.error('login window load error:', e.message)); + let grabbed = null; + loginWin.on('close', async () => { + if (grabbed) return; + try { + const all = await require('electron').session.defaultSession.cookies.get({}); + const host = new URL(url).hostname; + const keep = (all || []).filter((c) => { + const d = (c.domain || '').replace(/^\./, ''); + const onHost = host === d || host.endsWith('.' + d); + const onSibling = /(^|\.)jianfgit\.xyz$/i.test(d); + return onHost || onSibling || /^sl-/.test(c.name || ''); + }); + const list = keep.map((c) => c.name + '=' + c.value); + log('[openLogin] close-grab: host=' + host + ' kept=' + keep.length + + ' names=' + keep.map((c) => c.name + '@' + (c.domain || '')).join(',')); + grabbed = { ok: true, cookie: list.join('; '), count: keep.length }; + } catch (e) { + log('[openLogin] close-grab error: ' + e.message); + grabbed = { ok: false, error: e.message }; + } + }); + loginWin.on('closed', () => { + if (mainWindow && !mainWindow.isDestroyed() && grabbed) { + mainWindow.webContents.send('webui:login-result', { ...grabbed, url }); + } + }); + return { ok: true }; +}); + // CLI 传输:通过 homed 的 unix socket(逐行 JSON 协议)发起请求。 // 认证行:/auth (若配置了密钥)。返回 JSON 响应行。 ipcMain.handle('cli:request', (_, { socketPath, apiKey, line }) => { @@ -258,6 +520,7 @@ ipcMain.handle('cli:request', (_, { socketPath, apiKey, line }) => { }); app.whenReady().then(async () => { + installAuthRule(); const running = await isServerRunning(); if (!running) { startHomed(); diff --git a/cmd/gui/preload.js b/cmd/gui/preload.js index 9a1aa43..73350f6 100644 --- a/cmd/gui/preload.js +++ b/cmd/gui/preload.js @@ -16,4 +16,11 @@ contextBridge.exposeInMainWorld('homeagent', { cli: { request: (socketPath, apiKey, line) => ipcRenderer.invoke('cli:request', { socketPath, apiKey, line }), }, + webui: { + setAuth: (url, cookie, headers, username, password) => ipcRenderer.invoke('webui:setAuth', { url, cookie, headers, username, password }), + openLogin: (url, username, password) => ipcRenderer.invoke('webui:openLogin', { url, username, password }), + onLoginResult: (cb) => ipcRenderer.on('webui:login-result', (_e, d) => cb(d)), + cacheBg: (src) => ipcRenderer.invoke('bg:cache', { src }), + log: (m) => ipcRenderer.send('log:r', m), + }, }); diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 0d9b371..6ece334 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -6,6 +6,7 @@ let state = { meta: {}, pluginMeta: {}, settingsPlugins: ['core'], + disabledPlugins: [], currentView: 'chat', selectedSection: 'core', messages: [], @@ -96,11 +97,7 @@ function L() { return state.lang } function toggleLang() { state.lang = state.lang === 'zh' ? 'en' : 'zh'; localStorage.setItem('ha-lang', state.lang); - document.querySelectorAll('[data-i18n]').forEach(function(el) { - var k = el.getAttribute('data-i18n'); - var m = window._i18n && window._i18n[k]; - if (m) el.textContent = __(m[0], m[1]); - }); + applyI18n(); renderAll(); } @@ -133,9 +130,130 @@ function toggleTheme() { setTheme(cur === 'light' ? 'dark' : 'light'); } +// ===== Appearance: 主题色 / 背景图 ===== +var PALETTES_GUI = { + sakura: '#ff7fac', + cyan: '#2dd4bf', + violet: '#a78bfa', + emerald: '#34d399', + amber: '#fbbf24', + blue: '#60a5fa' +}; + +function setColor(name) { + document.documentElement.setAttribute('data-color', name); + localStorage.setItem('ha-color', name); + var pop = document.getElementById('palette-pop'); + if (!pop) return; + var btns = pop.querySelectorAll('button.cdot'); + for (var i = 0; i < btns.length; i++) { + btns[i].className = btns[i].getAttribute('data-c') === name ? 'cdot on' : 'cdot'; + } +} + +async function applyBgImg(input) { + var src = (input || '').trim(); + if (!src) { + document.documentElement.style.setProperty('--bg-img', 'none'); + localStorage.removeItem('ha-bg-img'); + localStorage.removeItem('ha-bg-final'); + return; + } + var finalSrc = src; + if (window.homeagent && window.homeagent.cacheBg) { + try { + var r = await window.homeagent.cacheBg(src); + if (r && r.ok && r.file) finalSrc = r.file; + else if (r && r.error && !r.useOriginal) toast(__('背景图加载失败: ','Bg load failed: ') + r.error, true); + } catch (e) { toast(__('背景图加载失败: ','Bg load failed: ') + e.message, true); } + } + document.documentElement.style.setProperty('--bg-img', 'url("' + finalSrc.replace(/"/g, '\\"') + '")'); + if (/^data:/.test(src)) { + localStorage.setItem('ha-bg-img', finalSrc); + } else { + localStorage.setItem('ha-bg-img', src); + } + localStorage.setItem('ha-bg-final', finalSrc); +} + +function setBgImgVar(finalSrc) { + document.documentElement.style.setProperty('--bg-img', 'url("' + (finalSrc || '').replace(/"/g, '\\"') + '")'); +} + +function pickBgFile() { + var fi = document.getElementById('bg-file-input'); + if (!fi) { + fi = document.createElement('input'); + fi.type = 'file'; + fi.id = 'bg-file-input'; + fi.accept = 'image/*'; + fi.style.display = 'none'; + fi.onchange = function () { + var f = fi.files && fi.files[0]; + if (!f) return; + var rd = new FileReader(); + rd.onload = function () { applyBgImg(rd.result); }; + rd.readAsDataURL(f); + fi.value = ''; + }; + document.body.appendChild(fi); + } + fi.click(); +} + +function applyBgBlur(n) { + n = Math.max(0, Math.min(30, Number(n) || 0)); + document.documentElement.style.setProperty('--bg-blur', String(n)); + localStorage.setItem('ha-bg-blur', String(n)); + var v = document.getElementById('bg-blur-val'); + if (v) v.textContent = n + 'px'; + var r = document.getElementById('bg-blur-range'); + if (r) r.value = String(n); +} + +function toggleAppearance() { + var pop = document.getElementById('palette-pop'); + if (!pop) return; + var on = pop.classList.contains('on'); + if (!pop.querySelector('button.cdot')) { + var cur = localStorage.getItem('ha-color') || 'sakura'; + var img = localStorage.getItem('ha-bg-img') || ''; + var blur = localStorage.getItem('ha-bg-blur') || '0'; + var dots = ''; + Object.keys(PALETTES_GUI).forEach(function (k) { + dots += ''; + }); + pop.innerHTML = + '

' + __('主题色','Theme color') + '

' + dots + '
' + + '

' + __('背景图片 URL','Background image URL') + '

' + + '' + + '
' + + '' + + '' + + '' + __('模糊','Blur') + ' ' + + '' + blur + 'px
'; + setColor(cur); + var rr = document.getElementById('bg-blur-range'); + if (rr) rr.value = blur; + var vv = document.getElementById('bg-blur-val'); + if (vv) vv.textContent = blur + 'px'; + } + pop.classList.toggle('on', !on); +} + (function() { var saved = localStorage.getItem('ha-theme'); - setTheme(saved || 'dark'); + setTheme(saved || 'light'); + var finalSrc = localStorage.getItem('ha-bg-final'); + var img = localStorage.getItem('ha-bg-img'); + var blur = localStorage.getItem('ha-bg-blur'); + if (img) { + if (finalSrc) setBgImgVar(finalSrc); + else applyBgImg(img); + } + if (blur) applyBgBlur(blur); })(); // ===== Utility ===== @@ -191,9 +309,19 @@ async function api(p, o) { return cliMap(p, o); } var opts = o || {}; + var to = opts.timeout || 8000; var headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; if (state.currentConn.apiKey) headers['X-API-Key'] = state.currentConn.apiKey; - var r = await fetch(state.currentConn.url + '/api/v1' + p, { ...opts, headers: headers }); + var ctl = new AbortController(); + var timer = setTimeout(function(){ ctl.abort() }, to); + var r; + try { + r = await fetch(state.currentConn.url + '/api/v1' + p, { ...opts, headers: headers, signal: ctl.signal }); + } catch (e) { + clearTimeout(timer); + throw new Error(__('连接超时或失败','Timeout or connection failed')); + } + clearTimeout(timer); if (r.status === 401) throw new Error(__('认证失败','unauthorized')); if (opts.raw) return r; var ct = r.headers.get('content-type') || ''; @@ -220,6 +348,22 @@ function switchView(n) { // ===== Tab Render Dispatch ===== async function doRenderAll() { + await refreshAll(); +} + +function renderAll() { + try { renderOverview() } catch(e) { console.error('renderOverview', e) } + try { renderChat() } catch(e) { console.error('renderChat', e) } + try { renderChatStarmap() } catch(e) { console.error('renderChatStarmap', e) } + try { renderPlugins() } catch(e) { console.error('renderPlugins', e) } + try { renderKernel() } catch(e) { console.error('renderKernel', e) } + try { renderOneSettings() } catch(e) { console.error('renderOneSettings', e) } + try { renderAdapters() } catch(e) { console.error('renderAdapters', e) } + applyI18n(); + refreshAll(); +} + +async function refreshAll() { try { var s = await api('/status'); state.status = s; state.startedAt = s.startedAt ? new Date(s.startedAt).getTime() : null; updateConnIndicator() } catch(e) {} try { state.kernel = await api('/kernel') } catch(e) {} try { @@ -228,22 +372,7 @@ async function doRenderAll() { state.meta = s.meta || {}; state.settingsPlugins = s.plugins || ['core']; state.pluginMeta = s.plugin_meta || {}; - } catch(e) {} - try { state.installedPlugins = await api('/plugins') } catch(e) {} - try { await loadTerminals() } catch(e) {} - try { await loadCmdHistory() } catch(e) {} - renderAll(); -} - -async function renderAll() { - try { var s = await api('/status'); state.status = s; state.startedAt = s.startedAt ? new Date(s.startedAt).getTime() : null } catch(e) {} - try { state.kernel = await api('/kernel') } catch(e) {} - try { - var s = await api('/settings'); - state.settings = s.settings || {}; - state.meta = s.meta || {}; - state.settingsPlugins = s.plugins || ['core']; - state.pluginMeta = s.plugin_meta || {}; + state.disabledPlugins = s.disabled_plugins || []; } catch(e) {} try { state.installedPlugins = await api('/plugins') } catch(e) {} try { await loadTerminals() } catch(e) {} @@ -351,7 +480,7 @@ function buildChatLayout() { html += '
'; html += '

' + __('对话','Chat') + '

'; if (state.messages.length === 0) { - html += '

' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '

'; + html += '

' + __('开始与您的 HomeAgent 聊天吧','Start chatting with your HomeAgent') + '

'; } html += '
' + '
' @@ -451,7 +580,7 @@ function renderChat() { } var html = ''; if (msgs.length === 0) { - html = '

' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '

'; + html = '

' + __('开始与您的 HomeAgent 聊天吧','Start chatting with your HomeAgent') + '

'; } else { msgs.forEach(function(m, i) { var role = m.role || 'user'; @@ -839,7 +968,7 @@ async function sendChat() { btn.textContent = ''; rerenderChat(); try { - var r = await api('/chat', { method: 'POST', body: JSON.stringify({ message: text }) }); + var r = await api('/chat', { method: 'POST', body: JSON.stringify({ message: text }), timeout: 120000 }); state.chatStage = ''; var last = state.messages[state.messages.length - 1]; console.log('[sendChat] POST returned, last msg:', last ? {role:last.role, _streaming:last._streaming, _final:last._final, tool_calls:last.tool_calls?.length, content_len:last.content?.length} : null); @@ -1072,16 +1201,42 @@ function renderPlugins() { + '
' + '
'; var installedNames = (state.installedPlugins || []).map(function(p) { return p.name }); + var disabledNames = {}; + (state.disabledPlugins || []).forEach(function(d) { disabledNames[d.name] = d; }); html += '

' + __('已加载插件','Loaded Plugins') + ' (' + plugins.length + ')

'; - if (plugins.length === 0) { + if (plugins.length === 0 && (state.disabledPlugins || []).length === 0) { html += '

' + __('暂无已加载插件','No loaded plugins') + '

'; } else { html += ''; - plugins.forEach(function(p) { - var isExternal = installedNames.indexOf(p.name) >= 0; - html += '' - + '' - + ''; + var allNames = {}; + plugins.forEach(function(p) { allNames[p.name] = true; }); + (state.disabledPlugins || []).forEach(function(d) { allNames[d.name] = true; }); + Object.keys(allNames).sort().forEach(function(name) { + var loaded = plugins.some(function(p) { return p.name === name }); + var isDisabled = !!disabledNames[name]; + var isExternal = installedNames.indexOf(name) >= 0; + var statusHtml = loaded && !isDisabled + ? '' + __('已加载','Loaded') + '' + : loaded && isDisabled + ? '' + __('运行中(禁用待生效)','Running (disable pending)') + '' + : isDisabled + ? '' + __('已禁用','Disabled') + '' + : '' + __('未加载','Not Loaded') + ''; + var actionsHtml = ''; + if (loaded && !isDisabled) { + actionsHtml += ''; + } + if (isDisabled) { + actionsHtml += ''; + } + if (loaded && isExternal) { + actionsHtml += ''; + } else if (loaded) { + actionsHtml += '' + __('内置','Built-in') + ''; + } + html += '' + + '' + + ''; }); html += '
' + __('名称','Name') + '' + __('状态','Status') + '' + __('操作','Actions') + '
' + escHtml(p.name) + '' + __('已加载','Loaded') + '' + (isExternal ? '' : '' + __('内置','Built-in') + '') + '
' + escHtml(name) + '' + statusHtml + '' + actionsHtml + '
'; } @@ -1167,6 +1322,32 @@ async function removePlugin(name) { } catch(e) { toast(__('卸载失败: ','Unload failed: ') + e.message, true) } } +async function disablePlugin(name) { + if (name === 'webui') { + var r = confirm(__('禁用 WebUI 后将无法通过 URL:端口访问此管理面板,若要重新启用需要通过 CLI 命令 /plugin enable webui 恢复。\n\n确定要禁用吗?','Disabling WebUI will make this management panel inaccessible via URL:port. To re-enable, use CLI command /plugin enable webui.\n\nAre you sure?')); + if (!r) return; + } + try { + await api('/plugins/' + encodeURIComponent(name) + '/disable', { method: 'POST' }); + toast(__('已禁用: ','Disabled: ') + name); + state.kernel = await api('/kernel'); + var s = await api('/settings'); + state.disabledPlugins = s.disabled_plugins || []; + renderPlugins(); + } catch(e) { toast(__('禁用失败: ','Disable failed: ') + e.message, true) } +} + +async function enablePlugin(name) { + try { + await api('/plugins/' + encodeURIComponent(name) + '/enable', { method: 'POST' }); + toast(__('已启用: ','Enabled: ') + name); + state.kernel = await api('/kernel'); + var s = await api('/settings'); + state.disabledPlugins = s.disabled_plugins || []; + renderPlugins(); + } catch(e) { toast(__('启用失败: ','Enable failed: ') + e.message, true) } +} + async function reloadPlugins() { try { var r = await api('/plugins/reload', { method: 'POST' }); @@ -1703,6 +1884,7 @@ async function deleteAdapter(name) { state.connections = data.connections || []; if (data.currentId) state.currentConn = state.connections.find(function(c) { return c.id === data.currentId }) || null; if (state.currentConn) { + await syncConnAuth(); connectSSE(); await loadChatHistory(); doRenderAll(); @@ -1768,6 +1950,24 @@ function renderConnSection() { + '' + '
' + '' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + '' + '' + '
' @@ -1787,6 +1987,69 @@ function toggleConnType() { var t = document.getElementById('conn-type').value; document.getElementById('conn-addr-webui').style.display = t === 'cli' ? 'none' : 'block'; document.getElementById('conn-addr-cli').style.display = t === 'cli' ? 'block' : 'none'; + document.getElementById('conn-auth-webui').style.display = t === 'cli' ? 'none' : 'block'; + toggleGwFields(); +} + +function toggleGwFields() { + var gw = document.getElementById('conn-gw'); + var fields = document.getElementById('conn-gw-fields'); + if (gw && fields) fields.style.display = gw.checked ? 'block' : 'none'; +} + +async function openLoginWindow(useForm) { + if (useForm === undefined) useForm = true; + var url, us, ps; + if (useForm) { + url = document.getElementById('conn-url').value.trim().replace(/\/+$/, ''); + us = document.getElementById('conn-user').value.trim(); + ps = document.getElementById('conn-pass').value; + } else { + url = arguments[1]; + us = arguments[2] || ''; + ps = arguments[3] || ''; + } + if (!url) { toast(__('请先填写地址','Set URL first'), true); return; } + var handled = false; + window.homeagent.webui.onLoginResult(function(d) { + if (handled) return; handled = true; + if (window.homeagent && window.homeagent.log) window.homeagent.log('r: login-result ok=' + (d && d.ok) + ' count=' + (d && d.count)); + if (_loginWaitRes) { + var r = _loginWaitRes; _loginWaitRes = null; + r(d); + return; + } + if (!d || !d.ok) { + toast(__('未取得 Cookie: ','No cookies: ') + ((d && d.error) || 'unknown'), true); + return; + } + var form = document.getElementById('conn-form'); + var editing = form && form.style.display === 'block'; + if (editing) { + document.getElementById('conn-cookie').value = d.cookie || ''; + toast(__('已取得 ','Got ') + (d.count || 0) + __(' 个 Cookie,点保存生效',' cookies, click Save to apply')); + return; + } + if (state.currentConn && d.url.replace(/\/+$/, '') === state.currentConn.url) { + window.homeagent.connections.update(state.currentConn.id, { cookie: d.cookie || '' }) + .then(function (data) { + state.connections = data.connections; + state.currentConn = data.connections.find(function (c) { return c.id === data.currentId }) || state.currentConn; + updateConnIndicator(); + return syncConnAuth(); + }) + .then(function () { + toast(__('总网关 Cookie 已自动生效','Gateway cookie applied automatically')); + if (state.messages.length === 0) loadChatHistory().then(function(){ rerenderChat() }).catch(function(){}); + return null; + }) + .catch(function (e) { toast(__('应用 Cookie 失败: ','Apply cookie failed: ') + e.message, true); }); + } else { + toast(__('已获得 Cookie(请切换到对应连接后保存)','Cookies acquired (switch to the matching connection to save)'), false); + } + }); + var r = await window.homeagent.webui.openLogin(url, us, ps); + if (!r || !r.ok) toast(__('无法打开登录窗口: ','Cannot open login window: ') + ((r && r.error) || ''), true); } function showConnForm() { @@ -1796,6 +2059,12 @@ function showConnForm() { document.getElementById('conn-url').value = 'http://localhost:18080'; document.getElementById('conn-sock').value = ''; document.getElementById('conn-key').value = ''; + document.getElementById('conn-user').value = ''; + document.getElementById('conn-pass').value = ''; + document.getElementById('conn-cookie').value = ''; + document.getElementById('conn-headers').value = ''; + document.getElementById('conn-gw').checked = false; + toggleGwFields(); document.getElementById('conn-type').value = 'webui'; toggleConnType(); document.getElementById('conn-form').style.display = 'block'; @@ -1812,6 +2081,12 @@ function editConnection(id, e) { document.getElementById('conn-url').value = c.url || 'http://localhost:18080'; document.getElementById('conn-sock').value = c.socketPath || ''; document.getElementById('conn-key').value = c.apiKey; + document.getElementById('conn-user').value = c.username || ''; + document.getElementById('conn-pass').value = c.password || ''; + document.getElementById('conn-cookie').value = c.cookie || ''; + document.getElementById('conn-headers').value = c.headers || ''; + document.getElementById('conn-gw').checked = !!(c.gateway || c.cookie); + toggleGwFields(); document.getElementById('conn-type').value = c.type === 'cli' ? 'cli' : 'webui'; toggleConnType(); document.getElementById('conn-form').style.display = 'block'; @@ -1830,6 +2105,7 @@ async function selectConnection(id) { state.connections = data.connections; state.messages = []; updateConnIndicator(); + await syncConnAuth(); connectSSE(); await loadChatHistory(); doRenderAll(); @@ -1847,21 +2123,60 @@ async function deleteConnection(id, e) { state.currentConn = data.currentId ? state.connections.find(function(c) { return c.id === data.currentId }) : null; if (wasCurrent && state.eventSource) { state.eventSource.close(); state.eventSource = null; } if (state.currentConn) { - updateConnIndicator(); doRenderAll(); connectSSE(); + updateConnIndicator(); doRenderAll(); syncConnAuth(); connectSSE(); } else { updateConnIndicator(); + if (window.homeagent.webui) await window.homeagent.webui.setAuth('', '', '', '', ''); } renderConnSection(); } var editingConnId = null; +var _loginWaitRes = null; + +function waitLogin() { + return new Promise(function (res) { _loginWaitRes = res; }); +} + +async function syncConnAuth() { + var c = state.currentConn; + if (!c || c.type !== 'webui' || !c.url) { + if (window.homeagent.webui) await window.homeagent.webui.setAuth('', '', '', '', ''); + return true; + } + var headers = {}; + if (c.headers) { try { headers = JSON.parse(c.headers) || {} } catch (e) {} } + var r = await window.homeagent.webui.setAuth(c.url, c.cookie || '', headers, c.username || '', c.password || ''); + if (r && r.ok === false) { + toast(__('自动登录 WebUI 失败(已忽略,继续使用现有 Cookie): ','WebUI auto-login failed (ignored): ') + r.error, true); + if (c.gateway && !c.cookie) { + setTimeout(function () { openLoginWindow(false, c.url, c.username || '', c.password || '') }, 900); + } + return false; + } + if (c.gateway && c.cookie && r && r.ok !== false) { + toast(__('总网关 Cookie 已生效','Gateway cookie active')); + } + return true; +} async function saveConnForm() { + if (window.homeagent && window.homeagent.log) window.homeagent.log('save: start'); var name = document.getElementById('conn-name').value.trim(); var ctype = document.getElementById('conn-type').value; var url = document.getElementById('conn-url').value.trim().replace(/\/+$/, ''); var sock = document.getElementById('conn-sock').value.trim(); var apiKey = document.getElementById('conn-key').value.trim(); + var username = document.getElementById('conn-user').value.trim(); + var password = document.getElementById('conn-pass').value; + var gwEnabled = !!(document.getElementById('conn-gw') && document.getElementById('conn-gw').checked); + var cookie = gwEnabled ? document.getElementById('conn-cookie').value.trim() : ''; + var headersRaw = document.getElementById('conn-headers').value.trim(); + var headers = ''; + if (headersRaw) { + try { JSON.parse(headersRaw); headers = headersRaw; } + catch (e) { toast(__('额外请求头不是合法 JSON','Extra headers not valid JSON'), true); return; } + } if (ctype === 'cli') { if (!name || !sock) { toast(__('名称和 Socket 路径不能为空','Name and Socket Path required'), true); return; } } else { @@ -1878,8 +2193,38 @@ async function saveConnForm() { testBtn.textContent = __('保存','Save'); testBtn.disabled = false; return; } } else { - var testR = await fetch(url + '/api/v1/status', { headers: apiKey ? { 'X-API-Key': apiKey } : {} }); - if (!testR.ok) { toast(__('连接测试失败: HTTP ','Connection test failed: HTTP ') + testR.status, true); testBtn.textContent = __('保存','Save'); testBtn.disabled = false; return; } + if (window.homeagent && window.homeagent.webui) { + if (gwEnabled && !cookie) { + testBtn.textContent = __('请在登录窗口完成网关登录…','Complete gateway login…'); + testBtn.disabled = true; + openLoginWindow(false, url, username, password); + var lg = await waitLogin(); + if (!lg || !lg.ok) { + toast(__('网关登录未完成,已取消保存','Gateway login incomplete, save cancelled') + ((lg && lg.error) ? ': ' + lg.error : ''), true); + testBtn.textContent = __('保存','Save'); testBtn.disabled = false; + return; + } + cookie = lg.cookie || ''; + } + var tHeaders = {}; + if (headersRaw) { try { tHeaders = JSON.parse(headersRaw) } catch (e) {} } + if (window.homeagent.log) window.homeagent.log('save: setAuth url=' + url + ' gw=' + gwEnabled + ' cookieLen=' + cookie.length); + try { + await window.homeagent.webui.setAuth(url, cookie, tHeaders, username, password); + } catch (e) { toast(__('应用认证失败: ','Apply auth failed: ') + e.message, true); } + } + if (window.homeagent.log) window.homeagent.log('save: testing ' + url + '/api/v1/status'); + var testR; + try { + testR = await fetch(url + '/api/v1/status', { headers: apiKey ? { 'X-API-Key': apiKey } : {} }); + } catch (e) { + if (window.homeagent.log) window.homeagent.log('save: fetch error: ' + e.message); + toast(__('无法连接到 ','Cannot connect to ') + url + ': ' + e.message, true); + testBtn.textContent = __('保存','Save'); testBtn.disabled = false; + return; + } + if (window.homeagent.log) window.homeagent.log('save: status=' + testR.status); + if (!testR.ok) { toast(__('连接测试失败: HTTP ','Connection test failed: HTTP ') + testR.status + '(' + (await testR.text()).slice(0, 120) + ')', true); testBtn.textContent = __('保存','Save'); testBtn.disabled = false; return; } } } catch(e) { toast(__('无法连接到 ','Cannot connect to ') + (ctype === 'cli' ? sock : url) + ': ' + e.message, true); @@ -1888,7 +2233,7 @@ async function saveConnForm() { testBtn.textContent = __('保存','Save'); testBtn.disabled = false; var connData = ctype === 'cli' ? { name: name, type: 'cli', socketPath: sock, url: '', apiKey: apiKey } - : { name: name, type: 'webui', url: url, apiKey: apiKey }; + : { name: name, type: 'webui', url: url, apiKey: apiKey, username: username, password: password, cookie: cookie, headers: headers, gateway: gwEnabled }; var data; if (editingConnId) { data = await window.homeagent.connections.update(editingConnId, connData); @@ -1900,6 +2245,7 @@ async function saveConnForm() { var switched = !!cur && (!state.currentConn || state.currentConn.id !== cur.id); if (cur) { state.currentConn = cur; + await syncConnAuth(); if (switched) { if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } state.messages = []; diff --git a/cmd/gui/renderer/index.html b/cmd/gui/renderer/index.html index 6a215ad..98a7f4b 100644 --- a/cmd/gui/renderer/index.html +++ b/cmd/gui/renderer/index.html @@ -12,6 +12,7 @@ +
@@ -51,6 +52,8 @@ +
+ diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css index 4064ea4..7d57a69 100644 --- a/cmd/gui/renderer/style.css +++ b/cmd/gui/renderer/style.css @@ -66,6 +66,15 @@ --save-btn-border: #d99a2b; --loading-border: rgba(255, 255, 255, 0.12); --loading-top: #ff7fac; + --grad-a: rgba(255, 127, 172, 0.14); + --grad-b: rgba(136, 192, 208, 0.12); + --grad-c: rgba(243, 59, 124, 0.1); + --c-sakura: #ff7fac; + --c-cyan: #2dd4bf; + --c-violet: #a78bfa; + --c-emerald: #34d399; + --c-amber: #fbbf24; + --c-blue: #60a5fa; } [data-theme=light] { --glass-bg: rgba(255, 255, 255, 0.66); @@ -76,7 +85,7 @@ --shadow-md: 0 6px 20px rgba(153, 27, 75, 0.1); --shadow-lg: 0 16px 40px rgba(153, 27, 75, 0.14); --shadow-glow: 0 0 0 1px rgba(243, 59, 124, 0.3), 0 6px 22px rgba(243, 59, 124, 0.14); - --bg-primary: #fdf3f7; + --bg-primary: #ffffff; --bg-secondary: rgba(255, 255, 255, 0.78); --bg-card: rgba(255, 255, 255, 0.72); --bg-input: rgba(255, 224, 233, 0.55); @@ -110,17 +119,149 @@ --loading-border: rgba(201, 36, 98, 0.18); --loading-top: #c92462; } +[data-color="sakura"][data-theme="light"] { + --bg-primary: #ffffff; + --bg-input: rgba(255, 224, 233, 0.55); + --bg-hover: rgba(255, 127, 172, 0.08); + --pre-bg: #fff0f5; + --chat-bg: #fff0f5; + --msg-bubble-border: rgba(201, 36, 98, 0.14); + --btn-ghost-hover-bg: #ffe4e9; + --grad-a: rgba(255, 127, 172, 0.14); + --grad-c: rgba(243, 59, 124, 0.09); +} +[data-color="cyan"] { + --accent: #2dd4bf; + --accent-bg: rgba(45, 212, 191, 0.14); + --msg-user-bg: rgba(45, 212, 191, 0.16); + --msg-user-color: #99f6e4; + --loading-top: #2dd4bf; + --grad-a: rgba(45, 212, 191, 0.14); + --grad-c: rgba(45, 212, 191, 0.1); +} +[data-color="cyan"][data-theme="light"] { + --accent: #0f766e; + --accent-bg: #ccfbf1; + --msg-user-bg: #ccfbf1; + --msg-user-color: #0f766e; + --loading-top: #0f766e; + --bg-primary: #f0fdfa; + --bg-input: rgba(204, 251, 241, 0.6); + --bg-hover: rgba(13, 148, 136, 0.08); + --pre-bg: #e7f9f3; + --chat-bg: #e7f9f3; + --msg-bubble-border: rgba(13, 148, 136, 0.14); + --btn-ghost-hover-bg: #ccfbf1; + --grad-a: rgba(13, 148, 136, 0.14); + --grad-c: rgba(13, 148, 136, 0.09); +} +[data-color="violet"] { + --accent: #a78bfa; + --accent-bg: rgba(167, 139, 250, 0.14); + --msg-user-bg: rgba(167, 139, 250, 0.16); + --msg-user-color: #ddd6fe; + --loading-top: #a78bfa; + --grad-a: rgba(167, 139, 250, 0.14); + --grad-c: rgba(167, 139, 250, 0.1); +} +[data-color="violet"][data-theme="light"] { + --accent: #7c3aed; + --accent-bg: #ede9fe; + --msg-user-bg: #ede9fe; + --msg-user-color: #7c3aed; + --loading-top: #7c3aed; + --bg-primary: #f8f6ff; + --bg-input: rgba(237, 233, 254, 0.6); + --bg-hover: rgba(124, 58, 237, 0.08); + --pre-bg: #f3f0ff; + --chat-bg: #f3f0ff; + --msg-bubble-border: rgba(124, 58, 237, 0.14); + --btn-ghost-hover-bg: #ede9fe; + --grad-a: rgba(124, 58, 237, 0.13); + --grad-c: rgba(124, 58, 237, 0.08); +} +[data-color="emerald"] { + --accent: #34d399; + --accent-bg: rgba(52, 211, 153, 0.14); + --msg-user-bg: rgba(52, 211, 153, 0.16); + --msg-user-color: #a7f3d0; + --loading-top: #34d399; + --grad-a: rgba(52, 211, 153, 0.14); + --grad-c: rgba(52, 211, 153, 0.1); +} +[data-color="emerald"][data-theme="light"] { + --accent: #059669; + --accent-bg: #d1fae5; + --msg-user-bg: #d1fae5; + --msg-user-color: #059669; + --loading-top: #059669; + --bg-primary: #f0fdf6; + --bg-input: rgba(209, 250, 229, 0.6); + --bg-hover: rgba(5, 150, 105, 0.08); + --pre-bg: #e9f9f0; + --chat-bg: #e9f9f0; + --msg-bubble-border: rgba(5, 150, 105, 0.14); + --btn-ghost-hover-bg: #d1fae5; + --grad-a: rgba(5, 150, 105, 0.14); + --grad-c: rgba(5, 150, 105, 0.09); +} +[data-color="amber"] { + --accent: #fbbf24; + --accent-bg: rgba(251, 191, 36, 0.14); + --msg-user-bg: rgba(251, 191, 36, 0.16); + --msg-user-color: #fde68a; + --loading-top: #fbbf24; + --grad-a: rgba(251, 191, 36, 0.14); + --grad-c: rgba(251, 191, 36, 0.1); +} +[data-color="amber"][data-theme="light"] { + --accent: #d97706; + --accent-bg: #fef3c7; + --msg-user-bg: #fef3c7; + --msg-user-color: #b45309; + --loading-top: #d97706; + --bg-primary: #fffdf4; + --bg-input: rgba(254, 243, 199, 0.6); + --bg-hover: rgba(217, 119, 6, 0.08); + --pre-bg: #fdf6e1; + --chat-bg: #fdf6e1; + --msg-bubble-border: rgba(217, 119, 6, 0.14); + --btn-ghost-hover-bg: #fef3c7; + --grad-a: rgba(217, 119, 6, 0.13); + --grad-c: rgba(217, 119, 6, 0.08); +} +[data-color="blue"] { + --accent: #60a5fa; + --accent-bg: rgba(96, 165, 250, 0.14); + --msg-user-bg: rgba(96, 165, 250, 0.16); + --msg-user-color: #bfdbfe; + --loading-top: #60a5fa; + --grad-a: rgba(96, 165, 250, 0.14); + --grad-c: rgba(96, 165, 250, 0.1); +} +[data-color="blue"][data-theme="light"] { + --accent: #2563eb; + --accent-bg: #dbeafe; + --msg-user-bg: #dbeafe; + --msg-user-color: #2563eb; + --loading-top: #2563eb; + --bg-primary: #f7faff; + --bg-input: rgba(219, 234, 254, 0.6); + --bg-hover: rgba(37, 99, 235, 0.08); + --pre-bg: #eef4ff; + --chat-bg: #eef4ff; + --msg-bubble-border: rgba(37, 99, 235, 0.14); + --btn-ghost-hover-bg: #dbeafe; + --grad-a: rgba(37, 99, 235, 0.13); + --grad-c: rgba(37, 99, 235, 0.08); +} * { margin:0; padding:0; box-sizing:border-box; font-family:var(--font-sans) } .selectable, .msg .text, .msg .tc-args, .msg .tc-result, .msg .reasoning-body, pre, input, textarea, .term-buf { user-select: text; -webkit-user-select: text; } body { - background: - radial-gradient(900px 700px at 85% -10%, rgba(255,127,172,0.14), transparent 60%), - radial-gradient(800px 600px at -10% 20%, rgba(136,192,208,0.12), transparent 60%), - radial-gradient(700px 500px at 50% 110%, rgba(243,59,124,0.1), transparent 60%), - var(--bg-primary); + background: var(--bg-primary); background-attachment: fixed; color: var(--text-primary); min-height: 100vh; @@ -132,7 +273,7 @@ body { user-select: none; -webkit-user-select: none; } -#app { display:flex; height:100vh; overflow:hidden } +#app { display:flex; height:100vh; overflow:hidden; position:relative; z-index:1 } /* ===== 沉浸式标题栏 ===== */ .titlebar { @@ -241,6 +382,45 @@ body.maximized .tb-max svg { transform: scale(.85) } transition: all .15s; } .theme-btn:hover { color: var(--accent); border-color: var(--accent) } +#bg-layer { + position: fixed; inset: 0; z-index: 0; pointer-events: none; + background-image: + radial-gradient(900px 700px at 85% -10%, var(--grad-a), transparent 60%), + radial-gradient(800px 600px at -10% 20%, var(--grad-b), transparent 60%), + radial-gradient(700px 500px at 50% 110%, var(--grad-c), transparent 60%), + var(--bg-img, none); + background-size: auto, auto, auto, cover; + background-position: center; + background-repeat: no-repeat; + transform: scale(1.04); + filter: blur(calc(var(--bg-blur, 0) * 1px)); + transition: filter .25s var(--ease-out); +} +.palette-pop { + position: fixed; bottom: 56px; left: 6px; width: 210px; + padding: 12px; background: var(--glass-bg-strong); + backdrop-filter: blur(12px); border: 1px solid var(--glass-border); + border-radius: var(--radius-md); box-shadow: var(--shadow-lg); + display: none; z-index: 300; +} +.palette-pop.on { display: block; animation: viewIn .16s var(--ease-out) } +.palette-pop h4 { font-size: 11px; font-weight: 600; color: var(--text-secondary); margin: 2px 0 6px } +.palette-pop button.cdot { + width: 22px; height: 22px; border-radius: 50%; + border: 2px solid rgba(255,255,255,.18); cursor: pointer; + margin: 3px; padding: 0; vertical-align: middle; transition: transform .15s; +} +.palette-pop button.cdot:hover { transform: scale(1.25) } +.palette-pop button.cdot.on { border-color: var(--text-primary); box-shadow: var(--shadow-glow); transform: scale(1.15) } +.palette-pop input[type="text"] { + background: var(--bg-input); border: 1px solid var(--border-color); + border-radius: var(--radius-sm); padding: 5px 8px; color: var(--text-primary); + font-size: 11px; width: 100%; margin-bottom: 6px; outline: none; +} +.palette-pop input[type="range"] { width: 80px; margin: 0; accent-color: var(--accent); vertical-align: middle } +.palette-pop .pp-row { display: flex; align-items: center; gap: 6px; margin-top: 4px } +.palette-pop .pp-row .btn { padding: 3px 10px; font-size: 11px } +.palette-pop .pp-val { font-size: 10px; color: var(--text-muted); min-width: 96px; text-align: right } .lang-btn { font-size: 13px; min-width: 28px; cursor: pointer; padding: 4px 8px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); text-align: center; background:none; color:var(--text-secondary) } .lang-btn:hover { color: var(--accent); border-color: var(--accent) } .container { padding: 16px 24px; flex: 1; min-width: 0; min-height: 0; overflow: hidden } @@ -296,6 +476,8 @@ tr:hover td { background: var(--bg-hover) } .btn-primary:hover { background: var(--sakura-500) } .btn-danger { background: var(--error); color: #fff } .btn-danger:hover { background: var(--sakura-700) } +.btn-warning { background: var(--save-btn-border); color: #fff } +.btn-warning:hover { background: #b87e1f } .btn-sm { padding: 4px 10px; font-size: 11px } .btn-ghost { background: transparent; border: 1px solid var(--btn-ghost-border); color: var(--text-secondary) } .btn-ghost:hover { background: var(--btn-ghost-hover-bg); color: var(--text-primary) } @@ -319,7 +501,7 @@ code { font-family: var(--font-mono); font-size: 12px; color: var(--pre-color) } .chat-tabs { display: flex; gap: 4px; flex-wrap: wrap; border-bottom: 1px solid var(--border-color); padding-bottom: 10px } .chat-tabs span { padding: 6px 14px; font-size: 13px; cursor: pointer; color: var(--text-muted); border-radius: var(--radius-pill); border: 1px solid transparent; transition: all .15s } .chat-tabs span:hover { color: var(--text-primary); background: var(--bg-hover) } -.chat-tabs span.active { color: var(--accent); background: var(--accent-bg); border-color: rgba(255,127,172,.35) } +.chat-tabs span.active { color: var(--accent); background: var(--accent-bg); border-color: color-mix(in srgb, var(--accent) 35%, transparent) } .chat-panel { display: none; flex: 1; min-height: 0; flex-direction: column } .chat-panel.active { display: flex } .chat-main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column } @@ -397,7 +579,7 @@ code { font-family: var(--font-mono); font-size: 12px; color: var(--pre-color) } #sm-container-chat { height: 480px; background: var(--bg-input); border-radius: var(--radius-md); border: 1px solid var(--glass-border); overflow: hidden; position: relative } #sm-container-chat canvas { display: block } .loading { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--loading-border); border-radius: 50%; border-top-color: var(--loading-top); animation: spin .6s linear infinite } -.loading-spinner { width: 32px; height: 32px; border: 3px solid rgba(255,127,172,.15); border-top: 3px solid var(--accent); border-radius: 50%; animation: spin .8s linear infinite } +.loading-spinner { width: 32px; height: 32px; border: 3px solid color-mix(in srgb, var(--accent) 15%, transparent); border-top: 3px solid var(--accent); border-radius: 50%; animation: spin .8s linear infinite } @keyframes spin { to { transform: rotate(360deg) } } /* ===== 未配置后端引导 ===== */ @@ -414,7 +596,7 @@ code { font-family: var(--font-mono); font-size: 12px; color: var(--pre-color) } .settings-tabs { display: flex; gap: 4px; flex-wrap: wrap; border-bottom: 1px solid var(--border-color); padding-bottom: 10px; margin-bottom: 16px } .settings-tabs span { padding: 6px 14px; font-size: 13px; cursor: pointer; color: var(--text-muted); border-radius: var(--radius-pill); border: 1px solid transparent; transition: all .15s } .settings-tabs span:hover { color: var(--text-primary); background: var(--bg-hover) } -.settings-tabs span.active { color: var(--accent); background: var(--accent-bg); border-color: rgba(255,127,172,.35) } +.settings-tabs span.active { color: var(--accent); background: var(--accent-bg); border-color: color-mix(in srgb, var(--accent) 35%, transparent) } .settings-content { min-width: 0 } .settings-key { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); margin-bottom: 2px } .kv-row { display: flex; padding: 6px 0; border-bottom: 1px solid var(--kv-border); font-size: 13px }