mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
338 lines
67 KiB
HTML
338 lines
67 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>HomeAgent Dashboard</title>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script>
|
||
<style>
|
||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-card:#1e293b;--bg-input:#0f172a;--bg-hover:rgba(15,23,42,0.25);--text-primary:#e2e8f0;--text-secondary:#94a3b8;--text-muted:#64748b;--border-color:#334155;--accent:#38bdf8;--accent-bg:#1e3a5f;--toast-bg:#166534;--toast-color:#86efac;--toast-error-bg:#7f1d1d;--toast-error-color:#fca5a5;--pre-color:#a5b4fc;--pre-bg:#0f172a;--chat-bg:#0f172a;--msg-user-bg:#1e3a5f;--msg-user-color:#93c5fd;--msg-assistant-bg:#1a3a2a;--msg-assistant-color:#86efac;--msg-system-bg:#3b1a3a;--msg-system-color:#f0abfc;--kv-border:#1e293b;--btn-ghost-border:#334155;--btn-ghost-hover-bg:#1e293b;--save-btn-border:#eab308;--loading-border:#334155;--loading-top:#38bdf8}
|
||
[data-theme=light]{--bg-primary:#f8fafc;--bg-secondary:#ffffff;--bg-card:#ffffff;--bg-input:#f1f5f9;--bg-hover:rgba(241,245,249,0.8);--text-primary:#1e293b;--text-secondary:#64748b;--text-muted:#94a3b8;--border-color:#e2e8f0;--accent:#2563eb;--accent-bg:#dbeafe;--toast-bg:#166534;--toast-color:#86efac;--toast-error-bg:#7f1d1d;--toast-error-color:#fca5a5;--pre-color:#1e293b;--pre-bg:#f1f5f9;--chat-bg:#f1f5f9;--msg-user-bg:#dbeafe;--msg-user-color:#1e40af;--msg-assistant-bg:#dcfce7;--msg-assistant-color:#166534;--msg-system-bg:#f3e8ff;--msg-system-color:#7c3aed;--kv-border:#e2e8f0;--btn-ghost-border:#e2e8f0;--btn-ghost-hover-bg:#f1f5f9;--save-btn-border:#eab308;--loading-border:#e2e8f0;--loading-top:#2563eb}
|
||
*{margin:0;padding:0;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
||
body{background:var(--bg-primary);color:var(--text-primary);min-height:100vh;overflow-x:hidden;transition:background .2s,color .2s}
|
||
nav{background:var(--bg-secondary);padding:0 24px;display:flex;align-items:center;gap:4px;border-bottom:1px solid var(--border-color);height:48px;position:sticky;top:0;z-index:100;transition:background .2s,border .2s}
|
||
nav h1{font-size:16px;font-weight:700;color:var(--accent);margin-right:24px;white-space:nowrap}
|
||
nav a{padding:12px 16px;color:var(--text-secondary);text-decoration:none;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}
|
||
nav a:hover{color:var(--text-primary)}
|
||
nav a.active{color:var(--accent);border-bottom-color:var(--accent)}
|
||
.theme-btn{background:none;border:1px solid var(--border-color);color:var(--text-secondary);cursor:pointer;padding:4px 8px;border-radius:6px;font-size:14px;line-height:1;margin-right:8px;transition:all .15s}
|
||
.theme-btn:hover{color:var(--accent);border-color:var(--accent)}
|
||
.container{padding:20px 24px;max-width:1440px;margin:0 auto}
|
||
.card{background:var(--bg-card);border:1px solid var(--border-color);border-radius:10px;padding:20px;margin-bottom:16px;transition:background .2s,border .2s}
|
||
.card h2{font-size:15px;font-weight:600;margin-bottom:12px;color:var(--text-primary)}
|
||
.card h3{font-size:13px;font-weight:600;color:var(--text-secondary);margin:16px 0 8px}
|
||
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||
.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
||
.stat-value{font-size:26px;font-weight:700;color:var(--accent)}
|
||
.stat-label{font-size:11px;color:var(--text-muted);margin-top:2px}
|
||
.stat-card{padding:16px 20px}
|
||
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
||
.dot-green{background:#22c55e}
|
||
.dot-yellow{background:#eab308}
|
||
.dot-red{background:#ef4444}
|
||
.dot-gray{background:#475569}
|
||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||
th{text-align:left;padding:8px 10px;color:var(--text-muted);font-weight:500;border-bottom:1px solid var(--border-color);font-size:11px;text-transform:uppercase;letter-spacing:.5px}
|
||
td{padding:8px 10px;border-bottom:1px solid var(--kv-border)}
|
||
tr:hover td{background:var(--bg-hover)}
|
||
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500}
|
||
.badge-green{background:#166534;color:#86efac}
|
||
.badge-red{background:#7f1d1d;color:#fca5a5}
|
||
.badge-yellow{background:#713f12;color:#fde68a}
|
||
.badge-blue{background:#1e3a5f;color:#93c5fd}
|
||
.btn{padding:6px 14px;border-radius:6px;border:none;font-size:12px;cursor:pointer;font-weight:500;transition:all .15s}
|
||
.btn-primary{background:var(--accent);color:#fff}
|
||
.btn-primary:hover{filter:brightness(1.1)}
|
||
.btn-danger{background:#dc2626;color:#fff}
|
||
.btn-danger:hover{background:#b91c1c}
|
||
.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)}
|
||
.tab-content{display:none}
|
||
.tab-content.active{display:block}
|
||
input,textarea,select{background:var(--bg-input);border:1px solid var(--border-color);border-radius:6px;padding:8px 12px;color:var(--text-primary);font-size:13px;width:100%;margin-bottom:10px;outline:none;transition:border .15s,background .2s,color .2s}
|
||
input:focus,textarea:focus,select:focus{border-color:var(--accent)}
|
||
textarea{resize:vertical;min-height:80px;font-family:monospace;font-size:12px}
|
||
label{display:block;font-size:11px;color:var(--text-secondary);margin-bottom:3px;font-weight:500}
|
||
pre{background:var(--pre-bg);border-radius:6px;padding:12px;font-size:12px;overflow-x:auto;color:var(--pre-color);font-family:monospace;max-height:400px;overflow-y:auto}
|
||
code{font-family:monospace;font-size:12px;color:var(--pre-color)}
|
||
.settings-layout{display:flex;gap:20px;min-height:60vh}
|
||
.settings-sidebar{width:200px;flex-shrink:0;background:var(--bg-card);border:1px solid var(--border-color);border-radius:10px;padding:8px 0;overflow-y:auto;max-height:70vh}
|
||
.settings-sidebar a{display:block;padding:9px 16px;color:var(--text-secondary);font-size:13px;cursor:pointer;text-decoration:none;border-left:3px solid transparent;transition:all .1s}
|
||
.settings-sidebar a:hover{background:var(--bg-primary);color:var(--text-primary)}
|
||
.settings-sidebar a.active{background:var(--bg-primary);color:var(--accent);border-left-color:var(--accent)}
|
||
.settings-content{flex:1;min-width:0}
|
||
.settings-key{font-family:monospace;font-size:11px;color:var(--text-muted);margin-bottom:2px}
|
||
.reasoning{border-left:2px solid #888;padding-left:12px;margin:8px 0;font-size:12px;color:#999}
|
||
.reasoning-title{cursor:pointer;font-size:11px;color:#666;font-weight:600;user-select:none;margin-bottom:4px}
|
||
.reasoning-body{color:#999;line-height:1.5}
|
||
.reasoning-body p{margin:4px 0}
|
||
.msg-content .text h1,.msg-content .text h2,.msg-content .text h3{font-size:1em;margin:8px 0 4px;color:var(--text-primary)}
|
||
.msg-content .text p{margin:4px 0;line-height:1.5}
|
||
.msg-content .text ul,.msg-content .text ol{padding-left:20px;margin:4px 0}
|
||
.msg-content .text li{margin:2px 0}
|
||
.msg-content .text code{background:var(--pre-bg);padding:1px 4px;border-radius:3px;font-size:11px}
|
||
.msg-content .text pre{background:var(--pre-bg);border-radius:6px;padding:10px;margin:8px 0;overflow-x:auto;font-size:11px;max-height:300px}
|
||
.msg-content .text pre code{background:none;padding:0}
|
||
.msg-content .text blockquote{border-left:3px solid var(--border-color);padding-left:10px;margin:8px 0;color:var(--text-secondary)}
|
||
.msg-content .text table{border-collapse:collapse;margin:8px 0;font-size:12px;width:100%}
|
||
.msg-content .text th,.msg-content .text td{border:1px solid var(--border-color);padding:4px 8px;text-align:left}
|
||
.msg-content .text img{max-width:100%;border-radius:6px}
|
||
.toast{position:fixed;bottom:20px;right:20px;background:var(--toast-bg);color:var(--toast-color);padding:10px 20px;border-radius:8px;font-size:13px;display:none;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,.3)}
|
||
.toast.error{background:var(--toast-error-bg);color:var(--toast-error-color)}
|
||
.empty-state{text-align:center;padding:40px 20px;color:var(--text-muted)}
|
||
.empty-state p{font-size:14px;margin-bottom:8px}
|
||
.empty-state .icon{font-size:36px;margin-bottom:12px;opacity:.5}
|
||
.chat-messages{max-height:500px;overflow-y:auto;margin-bottom:12px;padding:8px;border:1px solid var(--border-color);border-radius:8px;background:var(--chat-bg)}
|
||
.msg{display:flex;gap:10px;margin-bottom:12px;align-items:flex-start}
|
||
.msg-avatar{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0}
|
||
.msg-user .msg-avatar{background:var(--msg-user-bg);color:var(--msg-user-color)}
|
||
.msg-assistant .msg-avatar{background:var(--msg-assistant-bg);color:var(--msg-assistant-color)}
|
||
.msg-system .msg-avatar{background:var(--msg-system-bg);color:var(--msg-system-color)}
|
||
.msg-content{flex:1;min-width:0}
|
||
.msg-content .role{font-size:11px;color:var(--text-muted);margin-bottom:2px}
|
||
.msg-content .text{font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||
.msg-content .text .reasoning{color:var(--text-secondary);font-style:italic;font-size:12px;padding:4px 8px;background:var(--bg-card);border-radius:4px;margin:4px 0;display:block}
|
||
.chat-input-row{display:flex;gap:8px}
|
||
.chat-input-row input{flex:1;margin-bottom:0}
|
||
.chat-input-row button{flex-shrink:0;margin-bottom:0}
|
||
.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}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
.monaco-like{font-family:monospace;font-size:12px;background:var(--bg-input);border:1px solid var(--border-color);border-radius:6px}
|
||
.kv-row{display:flex;padding:6px 0;border-bottom:1px solid var(--kv-border);font-size:13px}
|
||
.kv-row .key{color:var(--text-muted);width:180px;flex-shrink:0}
|
||
.kv-row .val{color:var(--text-primary);word-break:break-all}
|
||
.tool-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;background:var(--accent-bg);color:var(--accent);margin:1px}
|
||
.check-pass{color:#86efac}
|
||
.check-fail{color:#fca5a5}
|
||
.check-skip{color:var(--text-secondary)}
|
||
.memory-graph{width:100%;height:300px;background:var(--bg-input);border-radius:8px;border:1px solid var(--border-color);position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px}
|
||
.health-panel{display:grid;gap:8px}
|
||
.health-item{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--bg-input);border-radius:6px;font-size:13px}
|
||
.health-item .check-name{flex:1}
|
||
.health-item .check-status{font-size:11px;font-weight:500}
|
||
.fade-in{animation:fadeIn .2s ease}
|
||
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
||
#starmap-container{width:100%;height:calc(100vh - 88px);position:relative;overflow:hidden;border-radius:10px;border:1px solid var(--border-color);background:var(--bg-input)}
|
||
#starmap-container canvas{display:block}
|
||
#starmap-stats{position:absolute;top:16px;left:16px;background:rgba(10,10,26,0.85);padding:12px 16px;border-radius:8px;border:1px solid rgba(100,100,255,0.3);font-size:13px;z-index:10;backdrop-filter:blur(10px);color:#ccc}
|
||
#starmap-stats h3{margin-bottom:6px;color:#4488ff;font-size:14px}
|
||
#starmap-stats p{margin:2px 0;color:#888;font-size:12px}
|
||
#starmap-stats span{color:#fff;font-weight:700}
|
||
#starmap-info{position:absolute;top:16px;right:16px;background:rgba(10,10,26,0.9);padding:12px 16px;border-radius:8px;border:1px solid rgba(100,100,255,0.3);font-size:13px;z-index:10;display:none;backdrop-filter:blur(10px);color:#ccc;max-width:260px}
|
||
#starmap-info h3{color:#44ff88;margin-bottom:6px;font-size:14px}
|
||
#starmap-info p{margin:2px 0;color:#888;font-size:12px}
|
||
#starmap-info .label{color:#666}
|
||
#starmap-loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:18px;color:#4488ff;z-index:20}
|
||
.starmap-toggle{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:10}
|
||
.starmap-toggle button{background:rgba(10,10,26,0.85);border:1px solid rgba(100,100,255,0.3);color:#aaa;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px;font-family:inherit;backdrop-filter:blur(10px);transition:all .2s}
|
||
.starmap-toggle button:hover{background:rgba(68,136,255,0.2);color:#fff;border-color:rgba(68,136,255,0.6)}
|
||
.starmap-toggle button.on{background:rgba(68,136,255,0.3);color:#4488ff;border-color:#4488ff}
|
||
#sm-container-chat{height:260px;background:var(--bg-input);border-radius:6px;border:1px solid var(--border-color);overflow:hidden;position:relative}
|
||
#sm-container-chat canvas{display:block}
|
||
.chat-layout{display:flex;gap:16px;min-height:60vh}
|
||
.chat-main{flex:2;min-width:0}
|
||
.chat-sidebar{flex:1;min-width:260px;max-width:360px}
|
||
.sidebar-subnav{display:flex;gap:0;border-bottom:1px solid var(--border-color);margin-bottom:10px}
|
||
.sidebar-subnav span{padding:6px 12px;font-size:12px;cursor:pointer;color:var(--text-muted);border-bottom:2px solid transparent;transition:all .15s}
|
||
.sidebar-subnav span:hover{color:var(--text-primary)}
|
||
.sidebar-subnav span.active{color:var(--accent);border-bottom-color:var(--accent)}
|
||
@media(max-width:900px){.chat-layout{flex-direction:column}.chat-sidebar{max-width:none}}@media(max-width:768px){nav{padding:0 8px;gap:2px}nav h1{font-size:13px;margin-right:8px}nav a{padding:8px 6px;font-size:11px}.container{padding:12px}.card{padding:12px}.grid-2,.grid-3,.grid-4{grid-template-columns:1fr}.stat-value{font-size:20px}.settings-layout{flex-direction:column}.settings-sidebar{width:100%;max-height:200px;display:flex;flex-wrap:wrap;padding:4px;overflow-x:auto}.settings-sidebar a{display:inline-block;padding:6px 12px;border-left:none;border-bottom:2px solid transparent;white-space:nowrap}.settings-sidebar a.active{border-left:none;border-bottom-color:var(--accent)}.kv-row{flex-direction:column;gap:2px}.kv-row .key{width:auto}#starmap-stats{top:8px;left:8px;padding:8px 10px;font-size:11px}#starmap-info{top:8px;right:8px;padding:8px 10px;max-width:180px;font-size:11px}#starmap-container{height:calc(100vh - 48px)}.chat-messages{max-height:300px}.msg-avatar{width:24px;height:24px;font-size:11px}.health-item{flex-wrap:wrap;gap:4px}.health-item .check-name{flex:auto;width:100%}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav><h1>HomeAgent</h1><a class="active" onclick="switchTab('overview')" data-i18n="navOverview">概览</a><a onclick="switchTab('chat')" data-i18n="navChat">对话</a><a onclick="switchTab('plugins')" data-i18n="navPlugins">插件</a><a onclick="switchTab('settings')" data-i18n="navSettings">设置</a><a onclick="switchTab('adapters')" data-i18n="navAdapters">适配器</a><a onclick="switchTab('kernel')" data-i18n="navKernel">内核</a><div style="margin-left:auto;display:flex;align-items:center;gap:4px"><button class="theme-btn" onclick="toggleTheme()" id="theme-btn" title="切换亮色/暗色模式">☀️</button><button class="btn btn-ghost btn-sm" onclick="toggleLang()" id="lang-btn" title="切换语言/Language" style="font-size:13px;min-width:28px">EN</button><button class="btn btn-ghost btn-sm" onclick="logout()" data-i18n="navLogout">退出登录</button></div></nav>
|
||
<div class="container" id="app">
|
||
<div id="tab-overview" class="tab-content active"></div>
|
||
<div id="tab-chat" class="tab-content"></div>
|
||
<div id="tab-plugins" class="tab-content"></div>
|
||
<div id="tab-settings" class="tab-content"></div>
|
||
<div id="tab-adapters" class="tab-content"></div>
|
||
<div id="tab-kernel" class="tab-content"></div>
|
||
</div>
|
||
<div id="toast" class="toast"></div>
|
||
<script>
|
||
let state={status:{},kernel:null,settings:{},meta:{},pluginMeta:{},settingsPlugins:['core'],selectedSection:'core',messages:[],chatLoading:false,healthResult:null,starmapInit:false,starmapLoading:false,starmapData:null,chatHistory:[],terminals:[],cmdHistory:[],eventSource:null,lang:localStorage.getItem('ha-lang')||'zh'};
|
||
function __(zh,en){return state.lang==='en'?en:zh}
|
||
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])});renderAll()}
|
||
window._i18n={navOverview:['概览','Overview'],navChat:['对话','Chat'],navPlugins:['插件','Plugins'],navSettings:['设置','Settings'],navAdapters:['适配器','Adapters'],navKernel:['内核','Kernel'],navLogout:['退出登录','Logout'],themeToggle:['切换亮色/暗色模式','Toggle theme']};
|
||
async function api(p,o){let opts={credentials:'include',headers:{'Content-Type':'application/json',...o?.headers},...o};let r=await fetch('/api/v1'+p,opts);if(r.status===401){location.href='/login';throw new Error('unauthorized')}if(opts.raw)return r;let ct=r.headers.get('content-type')||'';if(ct.includes('json'))return r.json();return r.text()}
|
||
function switchTab(n){document.querySelectorAll('.tab-content').forEach(e=>e.classList.remove('active'));let el=document.getElementById('tab-'+n);if(el)el.classList.add('active');document.querySelectorAll('nav a').forEach(e=>e.classList.remove('active'));document.querySelector('nav a[onclick*="\'+n+\'"]')||document.querySelector(`nav a[onclick*="${n}"]`)?.classList.add('active');renderAll()}
|
||
function toast(m,isError){let t=document.getElementById('toast');t.textContent=m;t.className='toast'+(isError?' error':'');t.style.display='block';setTimeout(()=>t.style.display='none',3000)}
|
||
function setTheme(name){document.documentElement.setAttribute('data-theme',name);localStorage.setItem('ha-theme',name);document.getElementById('theme-btn').textContent=name==='light'?'☀️':'🌙'}
|
||
function toggleTheme(){let cur=document.documentElement.getAttribute('data-theme');setTheme(cur==='light'?'dark':'light')}
|
||
(function(){let saved=localStorage.getItem('ha-theme');if(saved){setTheme(saved)}else{setTheme('dark')}})();
|
||
async function renderAll(){try{let s=await api('/status');state.status=s}catch(e){}try{state.kernel=await api('/kernel')}catch(e){}try{let s=await api('/settings');state.settings=s.settings||{};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){}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()}
|
||
function applyI18n(){var lang=state.lang;var btn=document.getElementById('lang-btn');if(btn)btn.textContent=lang==='zh'?'EN':'中';document.querySelectorAll('[data-i18n]').forEach(function(el){var k=el.getAttribute('data-i18n');var m=window._i18n&&window._i18n[k];if(m)el.textContent=lang==='en'?m[1]:m[0]})}function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||
function timeAgo(t){let s=Math.floor((Date.now()-new Date(t).getTime())/1000);if(s<60)return s+'秒前';let m=Math.floor(s/60);if(m<60)return m+'分钟前';return Math.floor(m/60)+'小时前'}
|
||
|
||
// === Overview ===
|
||
function renderOverview(){let s=state.status||{};let k=state.kernel;let html='<div class="grid-4">'+statCard('运行状态',s.status||'unknown','running')+statCard('运行时间',s.uptime||'-','uptime')+statCard('插件',(k?.plugins||[]).length||0,'plugin')+statCard('版本',s.version||'0.1.0','version')+'</div>';if(k){html+='<div class="grid-2"><div class="card"><h2>LLM 状态</h2><div class="kv-row"><span class="key">Provider</span><span class="val">'+(k.llm?.provider||'未配置')+'</span></div><div class="kv-row"><span class="key">可用源</span><span class="val">'+(k.llm?.sources||0)+'</span></div><div class="kv-row"><span class="key">状态</span><span class="val"><span class="status-dot '+(k.llm?.available?'dot-green':'dot-red')+'"></span>'+(k.llm?.available?'运行中':'不可用')+'</span></div></div><div class="card"><h2>记忆状态</h2><div class="kv-row"><span class="key">图记忆</span><span class="val"><span class="status-dot '+(k.memory?.available?'dot-green':'dot-gray')+'"></span>'+(k.memory?.available?k.memory.entity_count+' 实体, '+k.memory.relation_count+' 关系':'未初始化')+'</span></div><div class="kv-row"><span class="key">文档记忆</span><span class="val">'+(k.documents?.available?k.documents.doc_count+' 文档':'未初始化')+'</span></div><div class="kv-row"><span class="key">文本记忆</span><span class="val">'+(k.text_memory?.available?k.text_memory.file_count+' 文件':'未初始化')+'</span></div><div class="kv-row"><span class="key">知识库</span><span class="val">'+(k.knowledge?.available?k.knowledge.item_count+' 项':'未初始化')+'</span></div></div></div>';}html+='<div class="card"><h2>运行时</h2><div class="grid-3">'+statCard('Goroutines',k?.runtime?.goroutines||'-','')+statCard('内存',k?.runtime?.memory_mb?k.runtime.memory_mb+' MB':'-','')+statCard('Go 版本',k?.runtime?.go_version||'-','')+'</div></div>';document.getElementById('tab-overview').innerHTML=html}
|
||
function statCard(l,v){return '<div class="card stat-card"><div class="stat-value">'+v+'</div><div class="stat-label">'+l+'</div></div>'}
|
||
|
||
// === Chat ===
|
||
function renderChat(){let cont=document.getElementById('tab-chat');let msgs=state.messages;let k=state.kernel;let html='<div class="chat-layout"><div class="chat-main">';html+='<div class="card"><h2>对话</h2><div class="chat-messages" id="chat-msgs">';if(msgs.length===0){html+='<div class="empty-state"><p>开始对话以测试 Agent 回复</p></div>'}else{ msgs.forEach((m,i)=>{let role=m.role||'user';let c=m.content||'';if(role==='assistant'||role==='system'){if(typeof marked!=='undefined'){c=marked.parse(c)}else{c='<pre>'+escHtml(c)+'</pre>'}}else{c=escHtml(c)}let rc=m.reasoning_content?('<div class="reasoning"><div class="reasoning-title" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\';this.textContent=this.textContent===\'收起思考\'?\'展开思考\':\'收起思考\'">收起思考</div><div class="reasoning-body">'+(typeof marked!=='undefined'?marked.parse(m.reasoning_content):escHtml(m.reasoning_content))+'</div></div>'):'';html+='<div class="msg msg-'+role+'"><div class="msg-avatar">'+(role==='user'?'U':role==='assistant'?'A':'S')+'</div><div class="msg-content"><div class="role">'+(role==='user'?'用户':role==='assistant'?'AI':'系统')+'</div>'+rc+'<div class="text">'+c+'</div></div></div>'})}html+='</div><div class="chat-input-row"><input id="chat-input" placeholder="输入消息..." onkeydown="if(event.key==\'Enter\')sendChat()"><button class="btn btn-primary" onclick="sendChat()" id="chat-send-btn">发送</button></div></div>';html+='</div><div class="chat-sidebar"><div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">星图</h2><div id="sm-container-chat" style="height:160px"><p style="color:var(--text-muted);padding:12px;text-align:center;font-size:11px">加载中..</p></div></div><div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">终端 <span id="term-count-badge" class="badge badge-blue">0</span></h2><div id="term-list" style="max-height:160px;overflow-y:auto;font-size:11px"></div></div><div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">命令历史 <span id="cmd-count-badge" class="badge badge-blue">0</span></h2><div id="cmd-list" style="max-height:120px;overflow-y:auto;font-size:11px"></div></div><div class="card" style="padding:12px"><div class="sidebar-subnav"><span class="active" onclick="switchChatSub(\'memory\',this)">记忆</span><span onclick="switchChatSub(\'context\',this)">上下文/span><span onclick="switchChatSub(\'knowledge\',this)">知识</span></div><div id="chat-sub-memory"><div class="kv-row"><span class="key">实体</span><span class="val">'+(k?.memory?.entity_count||'-')+'</span></div><div class="kv-row"><span class="key">关系</span><span class="val">'+(k?.memory?.relation_count||'-')+'</span></div><div style="margin-top:8px"><input id="mem-query" placeholder="关键词查询><button class="btn btn-primary btn-sm" onclick="queryMemoryChat()">查询</button></div><div id="mem-result-chat" style="margin-top:8px;max-height:180px;overflow:auto"></div></div><div id="chat-sub-context" style="display:none"><div style="margin-top:8px"><input id="ctx-query" placeholder="输入当前话题"><button class="btn btn-primary btn-sm" onclick="queryMemoryContext()">获取上下文/button></div><div id="ctx-result" style="margin-top:8px;max-height:200px;overflow:auto"></div></div><div id="chat-sub-knowledge" style="display:none"><div class="kv-row"><span class="key">项目</span><span class="val">'+(k?.knowledge?.item_count||'-')+'</span></div><div style="margin-top:8px"><input id="know-query" placeholder="搜索知识"><button class="btn btn-primary btn-sm" onclick="searchKnowledgeChat()">搜索</button></div><div id="know-result-chat" style="margin-top:8px;max-height:180px;overflow:auto"></div><div style="margin-top:12px;border-top:1px solid var(--border-color);padding-top:8px"><input id="know-name" placeholder="知识鍚嶇О" style="margin-bottom:4px"><textarea id="know-content" placeholder="内容" style="min-height:50px;margin-bottom:4px"></textarea><button class="btn btn-primary btn-sm" onclick="createKnowledgeChat()">创建</button></div></div></div></div></div>';cont.innerHTML=html;let el=document.getElementById('chat-msgs');if(el)el.scrollTop=el.scrollHeight;renderChatStarmap();renderTerminals();renderCmdHistory()}
|
||
function renderChatStarmap(){let cont=document.getElementById('sm-container-chat');if(!cont)return;if(cont.querySelector('canvas')){let rect=cont.getBoundingClientRect();if(starmapRen&&rect.width>0)starmapRen.setSize(rect.width,Math.max(rect.height,250));return}if(state.starmapInit){if(starmapRen){let rect=cont.getBoundingClientRect();if(rect.width>0)starmapRen.setSize(rect.width,Math.max(rect.height,250));cont.appendChild(starmapRen.domElement);starmapRen.domElement.style.display='block'}return}if(state.starmapLoading)return;state.starmapLoading=true;loadChatStarmapData()}
|
||
async function loadChatStarmapData(){try{let resp=await api('/memory/graph');if(!resp||!resp.success||!resp.data){document.getElementById('sm-container-chat').innerHTML='<p style="color:var(--text-muted);padding:20px;text-align:center">无数据</p>';state.starmapInit=true;state.starmapLoading=false;return}let d=resp.data;starmapNodes=d.nodes||[];starmapEdges=d.edges||[];state.starmapInit=true;state.starmapLoading=false;initChatStarmap()}catch(e){document.getElementById('sm-container-chat').innerHTML='<p style="color:var(--text-muted);padding:20px;text-align:center">加载失败</p>';state.starmapInit=true;state.starmapLoading=false}}
|
||
function getStarmapBg(){let isLight=document.documentElement.getAttribute('data-theme')==='light';return isLight?0xf0f4f8:0x0a0a1a}
|
||
function initChatStarmap(){let cont=document.getElementById('sm-container-chat');if(!cont)return;let rect=cont.getBoundingClientRect();let w=Math.max(rect.width||300,100),h=Math.max(rect.height||250,100);if(starmapRen){starmapRen.setSize(w,h);cont.appendChild(starmapRen.domElement);starmapRen.domElement.style.display='block';return}let bg=getStarmapBg();starmapScene=new THREE.Scene();starmapScene.fog=new THREE.FogExp2(bg,0.02);starmapCam=new THREE.PerspectiveCamera(60,w/h,0.1,2000);starmapCam.position.set(0,20,40);starmapRen=new THREE.WebGLRenderer({antialias:true,alpha:true});starmapRen.setSize(w,h);starmapRen.setPixelRatio(Math.min(window.devicePixelRatio,2));starmapRen.setClearColor(bg,1);cont.innerHTML='';cont.appendChild(starmapRen.domElement);starmapCtrl=new THREE.OrbitControls(starmapCam,starmapRen.domElement);starmapCtrl.enableDamping=true;starmapCtrl.dampingFactor=0.05;starmapCtrl.rotateSpeed=0.5;starmapCtrl.zoomSpeed=0.8;let al=new THREE.AmbientLight(0x444466,0.6);starmapScene.add(al);let dl=new THREE.DirectionalLight(0xffffff,0.8);dl.position.set(50,100,50);starmapScene.add(dl);createStarField();buildChatStarmapGraph();starmapRen.domElement.addEventListener('mousemove',onStarmapMove);starmapRen.domElement.addEventListener('click',onStarmapClick);window.addEventListener('resize',onStarmapResize);if(starmapRaf)cancelAnimationFrame(starmapRaf);starmapAnimate()}
|
||
function buildChatStarmapGraph(){starmapNodeMeshes.forEach(function(m){starmapScene.remove(m)});starmapEdgeLines.forEach(function(l){starmapScene.remove(l)});starmapNodeMeshes=[];starmapEdgeLines=[];if(starmapNodes.length===0)return;let nMap={};starmapNodes.forEach(function(n){nMap[n.id]=n});let sorted=[...starmapNodes].sort(function(a,b){return(b.mention_count||0)-(a.mention_count||0)});let mc=sorted.map(function(n){return n.mention_count||0});let maxMc=Math.max(...mc,1),minMc=Math.min(...mc,0),rng=maxMc-minMc||1;let pos={};let baseR=10,maxR=50;let acc=0;sorted.forEach(function(n,i){let m=n.mention_count||0,mn=rng>0?(m-minMc)/rng:0;let rad=baseR+mn*(maxR-baseR);let baseStep=(Math.PI*2)/sorted.length;let step=baseStep+mn*baseStep*2;let angle=acc+step/2;acc+=step;pos[n.id]={x:rad*Math.cos(angle),y:(Math.random()-0.5)*(5+mn*15),z:rad*Math.sin(angle),mn:n,rad}});for(let it=0;it<50;it++){Object.keys(pos).forEach(function(i){Object.keys(pos).forEach(function(j){if(i>=j)return;let a=pos[i],b=pos[j];let dx=a.x-b.x,dy=a.y-b.y,dz=a.z-b.z,d=Math.sqrt(dx*dx+dy*dy+dz*dz)+0.1;let f=(0.5+(a.mn+b.mn)*0.5);if(d<20){let force=(0.08*f)/Math.max(d,0.5);a.x+=dx/d*force;a.y+=dy/d*force;a.z+=dz/d*force;b.x-=dx/d*force;b.y-=dy/d*force;b.z-=dz/d*force}})});starmapEdges.forEach(function(e){let a=pos[e.source_id],b=pos[e.target_id];if(!a||!b)return;let dx=b.x-a.x,dy=b.y-a.y,dz=b.z-a.z,d=Math.sqrt(dx*dx+dy*dy+dz*dz)+0.1;if(d>15){let f=0.05*Math.max(0.3,1-(a.mn+b.mn)*0.3);a.x+=dx/d*f;a.y+=dy/d*f;a.z+=dz/d*f;b.x-=dx/d*f;b.y-=dy/d*f;b.z-=dz/d*f}});Object.keys(pos).forEach(function(i){let p=pos[i];let d=Math.sqrt(p.x*p.x+p.y*p.y+p.z*p.z);let maxA=maxR*1.5;if(d>maxA){let s=maxA/d;p.x*=s;p.y*=s;p.z*=s}})};starmapNodes.forEach(function(n){let p=pos[n.id];if(!p)return;let mn=n.mention_count||0,mnr=rng>0?(mn-minMc)/rng:0;let rad=0.4+mnr*1.5;let col=smTypeColors[n.type]||0xcccccc;let ei=0.3+mnr*0.7;let g=new THREE.SphereGeometry(rad,16,12);let mat=new THREE.MeshPhongMaterial({color:col,emissive:col,emissiveIntensity:ei,shininess:30});let sp=new THREE.Mesh(g,mat);sp.position.set(p.x,p.y,p.z);sp.userData={nodeId:n.id,nodeData:n};let ca=document.createElement('canvas');ca.width=128;ca.height=48;let ctx=ca.getContext('2d');ctx.font='bold 16px Courier New';ctx.fillStyle='#fff';ctx.textAlign='center';ctx.shadowColor='#aaccff';ctx.shadowBlur=6;ctx.fillText((n.name||'').substring(0,10),64,28);let tex=new THREE.CanvasTexture(ca);tex.needsUpdate=true;let lm=new THREE.SpriteMaterial({map:tex,transparent:true,opacity:0.8,depthTest:false,depthWrite:false,blending:THREE.AdditiveBlending});let lb=new THREE.Sprite(lm);lb.scale.set(6,2,1);lb.position.y=rad+1.5;sp.add(lb);starmapScene.add(sp);starmapNodeMeshes.push(sp)});starmapEdges.forEach(function(e){let a=pos[e.source_id],b=pos[e.target_id];if(!a||!b)return;let col=smEdgeColors[e.relation_type]||0x444466;let g=new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(a.x,a.y,a.z),new THREE.Vector3(b.x,b.y,b.z)]);let m=new THREE.LineBasicMaterial({color:col,transparent:true,opacity:0.4});let l=new THREE.Line(g,m);l.userData={edgeId:e.id,edgeData:e};starmapScene.add(l);starmapEdgeLines.push(l)});starmapCam.position.set(35,20,35);starmapCtrl.target.set(0,0,0);starmapCtrl.update()}
|
||
async function sendChat(){let inp=document.getElementById('chat-input');let btn=document.getElementById('chat-send-btn');let text=inp.value.trim();if(!text||state.chatLoading)return;state.messages.push({role:'user',content:text});inp.value='';rerenderChat();state.chatLoading=true;btn.disabled=true;btn.textContent='...';try{let r=await api('/chat',{method:'POST',body:JSON.stringify({message:text})});var last=state.messages[state.messages.length-1];if(last&&last.role==='assistant'&&last._streaming){last.content=r.response||'(无响应)';last.reasoning_content=r.reasoning_content||'';last._final=true;delete last._streaming}else{state.messages.push({role:'assistant',content:r.response||'(无响应)',reasoning_content:r.reasoning_content,_final:true})}rerenderChat()}catch(e){state.messages.push({role:'assistant',content:'错误: '+e.message,_final:true});rerenderChat();toast('请求失败: '+e.message,true)}finally{state.chatLoading=false;btn.disabled=false;btn.textContent='发送'}}
|
||
async function queryMemoryChat(){let q=document.getElementById('mem-query')?.value;let r=document.getElementById('mem-result-chat');if(!r||!q)return;r.innerHTML='<div class="loading"></div>';try{let data=await api('/memory?q='+encodeURIComponent(q)+'&depth=2');r.innerHTML='<pre style="font-size:11px">'+escHtml(JSON.stringify(data,null,2))+'</pre>'}catch(e){r.innerHTML='<p style="color:#fca5a5">查询失败: '+escHtml(e.message)+'</p>'}}
|
||
async function queryMemoryContext(){let q=document.getElementById('ctx-query')?.value;let r=document.getElementById('ctx-result');if(!r)return;r.innerHTML='<div class="loading"></div>';try{let data=await api('/memory/context?q='+encodeURIComponent(q||''));let ctx=data?.context||'无上下文';let summary=data?.summary||'';let entities=data?.entities||[];let tk=data?.token_estimate||0;let html='<div style="font-size:11px">';if(summary)html+='<div class="kv-row"><span class="key">摘要</span><span class="val">'+escHtml(summary)+'</span></div>';html+='<div class="kv-row"><span class="key">Token 预估</span><span class="val">'+tk+'</span></div>';if(entities.length)html+='<div class="kv-row"><span class="key">实体</span><span class="val">'+entities.map(function(e){return escHtml(e.name||e.id||'')}).join(', ')+'</span></div>';html+='<pre style="font-size:11px;margin-top:8px">'+escHtml(ctx)+'</pre></div>';r.innerHTML=html}catch(e){r.innerHTML='<p style="color:#fca5a5">获取失败: '+escHtml(e.message)+'</p>'}}
|
||
async function searchKnowledgeChat(){let q=document.getElementById('know-query')?.value;let r=document.getElementById('know-result-chat');if(!r||!q)return;r.innerHTML='<div class="loading"></div>';try{let data=await api('/knowledge?q='+encodeURIComponent(q));r.innerHTML='<pre style="font-size:11px">'+escHtml(JSON.stringify(data,null,2))+'</pre>'}catch(e){r.innerHTML='<p style="color:#fca5a5">查询失败: '+escHtml(e.message)+'</p>'}}
|
||
async function createKnowledgeChat(){let name=document.getElementById('know-name')?.value;let content=document.getElementById('know-content')?.value;if(!name||!content){toast('名称和内容不能为空',true);return}try{let r=await api('/knowledge',{method:'POST',body:JSON.stringify({name,content})});if(r.status||r.id){toast('知识「'+name+'」已创建');document.getElementById('know-name').value='';document.getElementById('know-content').value=''}else{toast('创建失败',true)}}catch(e){toast('创建失败: '+e.message,true)}}
|
||
|
||
function switchChatSub(tab,el){var cards={'memory':document.getElementById('chat-sub-memory'),'context':document.getElementById('chat-sub-context'),'knowledge':document.getElementById('chat-sub-knowledge')};Object.keys(cards).forEach(function(k){var c=cards[k];if(c)c.style.display=k===tab?'block':'none'});if(el){var parent=el.parentElement;if(parent){Array.from(parent.children).forEach(function(ch){ch.classList.remove('active')});el.classList.add('active')}}if(tab==='context')queryMemoryContext()}
|
||
|
||
async function loadChatHistory(){try{let data=await api('/chat/history');if(data&&data.messages)state.messages=data.messages}catch(e){}}
|
||
|
||
async function loadTerminals(){try{let data=await api('/terminals');if(data&&data.terminals)state.terminals=data.terminals}catch(e){}}
|
||
|
||
async function loadCmdHistory(){try{let data=await api('/cmd/history');if(data&&data.history)state.cmdHistory=data.history}catch(e){}}
|
||
|
||
function renderTerminals(){let r=document.getElementById('term-list');let cnt=document.getElementById('term-count');if(!r)return;let list=state.terminals||[];if(cnt)cnt.textContent=list.length;if(list.length===0){r.innerHTML='<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">暂无终端会话</p>';return}var html='';list.forEach(function(t,i){var detailId='term-detail-'+i;html+='<div style="border:1px solid var(--border-color);border-radius:6px;margin-bottom:4px;font-size:11px">';html+='<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;cursor:pointer;background:var(--bg-hover)" onclick="var d=document.getElementById(\''+detailId+'\');d.style.display=d.style.display===\'none\'?\'block\':\'none\'">';html+='<span style="font-family:monospace;font-size:10px;flex:1">'+escHtml(t.id||'-')+'</span>';html+='<span style="flex:1;color:var(--text-muted)">'+escHtml(t.command||'')+'</span>';html+='<span class="badge '+(t.running?'badge-green':'badge-red')+'">'+(t.running?'运行中':'已关闭')+'</span>';html+='<span style="color:var(--text-muted);font-size:10px">'+escHtml(t.created_at||'')+'</span>';html+='</div>';html+='<div id="'+detailId+'" style="display:none;padding:8px;border-top:1px solid var(--border-color);background:var(--bg-input)">';html+='<div class="kv-row"><span class="key">ID</span><span class="val" style="font-family:monospace">'+escHtml(t.id||'-')+'</span></div>';html+='<div class="kv-row"><span class="key">命令</span><span class="val">'+escHtml(t.command||'-')+'</span></div>';html+='<div class="kv-row"><span class="key">状态</span><span class="val">'+(t.running?'运行中':'已关闭')+'</span></div>';html+='<div class="kv-row"><span class="key">创建时间</span><span class="val">'+escHtml(t.created_at||'-')+'</span></div>';if(t.uptime)html+='<div class="kv-row"><span class="key">运行时长</span><span class="val">'+escHtml(t.uptime)+'</span></div>';if(t.output)html+='<div class="kv-row"><span class="key">输出预览</span><span class="val"><pre style="font-size:10px;margin:0;max-height:120px;overflow:auto">'+escHtml(t.output)+'</pre></span></div>';html+='</div></div>'});r.innerHTML=html}
|
||
|
||
function renderCmdHistory(){let r=document.getElementById('cmd-list');let cnt=document.getElementById('cmd-count');if(!r)return;let list=state.cmdHistory||[];if(cnt)cnt.textContent=list.length;if(list.length===0){r.innerHTML='<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">暂无命令记录</p>';return}var html='<table style="font-size:10px"><tr><th>命令</th><th>状态</th><th>时间</th></tr>';list.slice().reverse().slice(0,50).forEach(function(c){html+='<tr><td style="font-family:monospace;max-width:180px;overflow:hidden;text-overflow:ellipsis">'+escHtml(c.command||'')+'</td><td><span class="badge '+(c.status==='ok'?'badge-green':'badge-red')+'">'+escHtml(c.status||'')+'</span></td><td style="color:var(--text-muted);white-space:nowrap">'+escHtml((c.time||'').substring(0,19))+'</td></tr>'});html+='</table>';r.innerHTML=html}
|
||
|
||
function connectSSE(){if(state.eventSource)state.eventSource.close();var es;try{es=new EventSource('/api/v1/chat/events');state.eventSource=es}catch(ex){}if(!es)return;es.addEventListener('agent_output',function(e){try{var ev=JSON.parse(e.data);var p=ev.payload||{};if(!p.content)return;if(state.messages.length>0&&state.messages[state.messages.length-1].role==='assistant'&&!state.messages[state.messages.length-1]._final){state.messages[state.messages.length-1].content+=p.content;renderChat();renderChatStarmap();return}state.messages.push({role:'assistant',content:p.content,_streaming:true});renderChat();renderChatStarmap()}catch(ex){}});es.addEventListener('reasoning',function(e){try{var ev=JSON.parse(e.data);var p=ev.payload||{};if(p.content&&state.messages.length>0){var last=state.messages[state.messages.length-1];if(last.role==='assistant'){last.reasoning_content=(last.reasoning_content||'')+p.content;renderChat();renderChatStarmap()}}}catch(ex){}});es.onerror=function(){setTimeout(connectSSE,5000)}}
|
||
|
||
// === Plugins ===
|
||
function renderPlugins(){let k=state.kernel;let plugins=k?.plugins||[];let tools=k?.tools||[];let installed=state.installedPlugins||[]
|
||
let html='<div class="card"><h2>安装插件</h2><div style="display:flex;gap:8px;margin-bottom:8px"><input id="plugin-url" placeholder=".hmap 包下载 URL" style="flex:1" onkeydown="if(event.key==\'Enter\')installPlugin()"><button class="btn btn-primary" onclick="installPlugin()">安装</button></div><div><input type="file" id="plugin-file" accept=".hmap" style="display:inline;width:auto" onchange="installPluginFile(this.files[0])"><label for="plugin-file" class="btn btn-ghost" style="cursor:pointer">选择 .hmap 文件上传</label></div></div>'
|
||
html+='<div class="card"><h2>已加载插件 ('+plugins.length+')</h2>';if(plugins.length===0){html+='<div class="empty-state"><p>暂无已加载插件</p></div>'}else{html+='<table><tr><th>名称</th><th>状态</th></tr>';plugins.forEach(p=>{html+='<tr><td>'+escHtml(p.name)+'</td><td><span class="badge badge-green">已加载</span></td></tr>'});html+='</table>'}html+='</div>'
|
||
if(installed.length>0){html+='<div class="card"><h2>已安装外部插件 ('+installed.length+')</h2><table><tr><th>名称</th><th>版本</th><th>描述</th><th>操作</th></tr>';installed.forEach(p=>{html+='<tr><td>'+escHtml(p.name)+'</td><td>'+escHtml(p.version||'-')+'</td><td>'+escHtml((p.description||'').substring(0,50))+'</td><td><button class="btn btn-sm btn-ghost" onclick="showPluginInfo(\''+escHtml(p.name)+'\')">详情</button> <button class="btn btn-sm btn-danger" onclick="removePlugin(\''+escHtml(p.name)+'\')">卸载</button></td></tr>'});html+='</table></div>'}
|
||
if(state.pluginInfo){html+='<div class="card"><h2>插件详情: '+escHtml(state.pluginInfo.name)+'</h2><pre>'+escHtml(JSON.stringify(state.pluginInfo,null,2))+'</pre><button class="btn btn-ghost" onclick="closePluginInfo()">关闭</button></div>'}
|
||
if(tools.length>0){html+='<div class="card"><h2>已注册工具 ('+tools.length+')</h2><div style="display:flex;flex-wrap:wrap;gap:4px">';tools.forEach(t=>{html+='<span class="tool-badge" title="'+escHtml(t.description||'')+'">'+escHtml(t.name)+'</span>'});html+='</div></div>'}
|
||
html+='<div class="card"><h2>系统操作</h2><button class="btn btn-primary" onclick="reloadPlugins()" style="margin-right:8px">重载插件</button><button class="btn btn-ghost" onclick="runHealthcheck()" style="margin-right:8px">健康检查</button></div>'
|
||
html+='<div class="card"><h2>健康检查</h2><div id="health-panel">';if(state.healthResult){html+=renderHealthResult(state.healthResult)}else{html+='<p style="color:var(--text-muted);font-size:13px">点击上方按钮运行</p>'}html+='</div></div>'
|
||
document.getElementById('tab-plugins').innerHTML=html}
|
||
async function loadInstalledPlugins(){try{state.installedPlugins=await api('/plugins')}catch(e){state.installedPlugins=[]}}
|
||
async function installPlugin(){let inp=document.getElementById('plugin-url');let url=inp?.value.trim();if(!url){toast('请输入插件包 URL',true);return}try{let r=await api('/plugins',{method:'POST',body:JSON.stringify({url})});toast('安装结果: '+(r.status||JSON.stringify(r)));if(r.action==='reload_required'){toast('已安装,请点击「重载插件」加载',false)}loadInstalledPlugins();renderPlugins()}catch(e){toast('安装失败: '+e.message,true)}}
|
||
async function installPluginFile(file){if(!file)return;let form=new FormData();form.append('file',file);try{let r=await fetch('/api/v1/plugins',{method:'POST',body:file,headers:{'Content-Type':'application/octet-stream'}});let data=await r.json();toast('上传安装: '+(data.status||JSON.stringify(data)));if(data.action==='reload_required'){toast('已安装,请点击「重载插件」加载',false)}loadInstalledPlugins();renderPlugins()}catch(e){toast('上传失败: '+e.message,true)}}
|
||
async function showPluginInfo(name){try{state.pluginInfo=await api('/plugins/'+encodeURIComponent(name));renderPlugins()}catch(e){toast('获取详情失败: '+e.message,true)}}
|
||
function closePluginInfo(){state.pluginInfo=null;renderPlugins()}
|
||
async function removePlugin(name){if(!confirm('确定卸载插件「'+name+'」?'))return;try{let r=await api('/plugins/'+encodeURIComponent(name),{method:'DELETE'});toast('已卸载: '+(r.status||r.name));if(r.action==='reload_required'){toast('已卸载,请点击「重载插件」生效',false)}loadInstalledPlugins();renderPlugins()}catch(e){toast('卸载失败: '+e.message,true)}}
|
||
async function reloadPlugins(){try{let r=await api('/plugins/reload',{method:'POST'});toast('插件已重载');state.kernel=await api('/kernel');renderPlugins()}catch(e){toast('重载失败: '+e.message,true)}}
|
||
async function runHealthcheck(){let panel=document.getElementById('health-panel');if(!panel)return;panel.innerHTML='<div class="loading" style="margin:12px auto"></div><p style="text-align:center;color:var(--text-muted)">运行中...</p>';try{let r=await api('/kernel');let tools=r?.tools||[];let healthTool=tools.find(t=>t.name==='healthcheck');if(!healthTool){panel.innerHTML='<p style="color:var(--text-secondary)">healthcheck 工具未注册</p>';return}panel.innerHTML='<p style="color:var(--text-secondary)">通过 Agent 对话触发 healthcheck...</p>';let chatR=await api('/chat',{method:'POST',body:JSON.stringify({message:'请运行 healthcheck 工具进行全面健康检查并报告结果'})});panel.innerHTML='<pre>'+escHtml(JSON.stringify(chatR,null,2))+'</pre>'}catch(e){panel.innerHTML='<p style="color:#fca5a5">错误: '+escHtml(e.message)+'</p>';toast('健康检查失败: '+e.message,true)}}
|
||
function renderHealthResult(r){if(!r||!r.checks)return '<p style="color:var(--text-secondary)">暂无健康检查数据</p>';let checks=r.checks||[];let passed=checks.filter(c=>c.pass).length;let failed=checks.filter(c=>!c.pass).length;let html='<div style="margin-bottom:12px;display:flex;gap:16px;align-items:center"><span class="badge badge-green">通过: '+passed+'</span><span class="badge '+(failed>0?'badge-red':'badge-green')+'">失败: '+failed+'</span><span class="badge badge-blue">总计: '+checks.length+'</span></div>';checks.forEach(c=>{let passClass=c.pass?'check-pass':'check-fail';if(c.status==='skip')passClass='check-skip';html+='<div class="health-item"><span class="check-name">'+escHtml(c.name)+'</span><span class="check-status '+passClass+'">'+(c.status||'unknown')+'</span><span style="color:var(--text-muted);font-size:11px">'+escHtml(c.detail||'')+'</span></div>'});return html}
|
||
|
||
// === Kernel ===
|
||
function renderKernel(){let k=state.kernel;if(!k){document.getElementById('tab-kernel').innerHTML='<div class="card"><p style="color:var(--text-muted)">内核未响应</p></div>';return}let html='<div class="card"><h2>运行时</h2><div class="grid-3">'+statCard('Goroutines',k?.runtime?.goroutines||'-','')+statCard('内存',k?.runtime?.memory_mb?k.runtime.memory_mb+' MB':'-','')+statCard('Go 版本',k?.runtime?.go_version||'-','')+'</div></div>';html+='<div class="card"><h2>LLM</h2><div class="kv-row"><span class="key">Provider</span><span class="val">'+(k.llm?.provider||'未配置')+'</span></div><div class="kv-row"><span class="key">可用源</span><span class="val">'+(k.llm?.sources||0)+'</span></div><div class="kv-row"><span class="key">状态</span><span class="val"><span class="status-dot '+(k.llm?.available?'dot-green':'dot-red')+'"></span>'+(k.llm?.available?'运行中':'不可用')+'</span></div></div>';html+='<div class="card"><h2>记忆</h2><div class="kv-row"><span class="key">图记忆</span><span class="val">'+(k.memory?.available?k.memory.entity_count+' 实体, '+k.memory.relation_count+' 关系':'未初始化')+'</span></div><div class="kv-row"><span class="key">文档记忆</span><span class="val">'+(k.documents?.available?k.documents.doc_count+' 文档':'未初始化')+'</span></div><div class="kv-row"><span class="key">文本记忆</span><span class="val">'+(k.text_memory?.available?k.text_memory.file_count+' 文件':'未初始化')+'</span></div><div class="kv-row"><span class="key">知识库</span><span class="val">'+(k.knowledge?.available?k.knowledge.item_count+' 项':'未初始化')+'</span></div></div>';html+='<div class="card"><h2>插件 ('+(k.plugins?.length||0)+')</h2>';if(k.plugins?.length){html+='<div style="display:flex;flex-wrap:wrap;gap:4px">';k.plugins.forEach(p=>{html+='<span class="badge badge-blue">'+escHtml(p.name)+'</span>'});html+='</div>'}else{html+='<p style="color:var(--text-muted)">无</p>'}html+='</div>';document.getElementById('tab-kernel').innerHTML=html}
|
||
// === Star Map ===
|
||
let starmapScene=null,starmapCam=null,starmapRen=null,starmapCtrl=null;
|
||
let starmapNodes=[],starmapEdges=[];
|
||
let starmapNodeMeshes=[],starmapEdgeLines=[],starmapStarField=null;
|
||
let starmapHovered=null,starmapSelected=null,starmapAutoView=true;
|
||
let starmapRaf=null;
|
||
const smTypeColors={person:0x4488ff,task:0xff8844,ai:0xaa44ff,concept:0x44ff88,object:0xff4444};
|
||
const smEdgeColors={'喜欢':0xff6b6b,'学习':0x4ecdc4,'属于':0x45b7d1,'相关':0x96ceb4,'使用':0xfeca57,'创建':0xff9ff3};
|
||
|
||
function createStarField(){
|
||
let c=3000;let p=new Float32Array(c*3),cl=new Float32Array(c*3),s=new Float32Array(c);
|
||
for(let i=0;i<c;i++){let i3=i*3;let r=400+Math.random()*600,th=Math.random()*Math.PI*2,ph=Math.acos(2*Math.random()-1);
|
||
p[i3]=r*Math.sin(ph)*Math.cos(th);p[i3+1]=r*Math.sin(ph)*Math.sin(th);p[i3+2]=r*Math.cos(ph);
|
||
if(Math.random()<0.7){cl[i3]=0.8+Math.random()*0.2;cl[i3+1]=0.8+Math.random()*0.2;cl[i3+2]=1}else{cl[i3]=1;cl[i3+1]=0.9+Math.random()*0.1;cl[i3+2]=0.8+Math.random()*0.2}
|
||
s[i]=0.5+Math.random()*2}
|
||
let g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.BufferAttribute(p,3));g.setAttribute('color',new THREE.BufferAttribute(cl,3));g.setAttribute('size',new THREE.BufferAttribute(s,1));
|
||
let m=new THREE.PointsMaterial({size:1.5,vertexColors:true,transparent:true,opacity:0.8,sizeAttenuation:true});
|
||
starmapStarField=new THREE.Points(g,m);starmapScene.add(starmapStarField)
|
||
}
|
||
|
||
function onStarmapMove(e){
|
||
if(!starmapRen||!starmapCam)return;
|
||
let rect=starmapRen.domElement.getBoundingClientRect();
|
||
let mouse=new THREE.Vector2(((e.clientX-rect.left)/rect.width)*2-1,-((e.clientY-rect.top)/rect.height)*2+1);
|
||
let rc=new THREE.Raycaster();rc.setFromCamera(mouse,starmapCam);
|
||
let hits=rc.intersectObjects(starmapNodeMeshes);
|
||
let infoEl=document.getElementById('starmap-info');
|
||
if(hits.length>0){let n=hits[0].object;
|
||
if(starmapHovered!==n){if(starmapHovered)starmapHovered.scale.set(1,1,1);starmapHovered=n;n.scale.set(1.2,1.2,1.2);
|
||
let nd=n.userData.nodeData;if(infoEl){
|
||
let e1=document.getElementById('sm-info-name');if(e1)e1.textContent=nd.name||'';
|
||
let e2=document.getElementById('sm-info-type');if(e2)e2.textContent=nd.type||'';
|
||
let e3=document.getElementById('sm-info-mentions');if(e3)e3.textContent=(nd.mention_count||0)+'';
|
||
let lk=starmapEdges.filter(e=>e.source_id===nd.id||e.target_id===nd.id).length;
|
||
let e4=document.getElementById('sm-info-links');if(e4)e4.textContent=lk+'';
|
||
infoEl.style.display='block'}}}
|
||
else{if(starmapHovered){starmapHovered.scale.set(1,1,1);starmapHovered=null}if(!starmapSelected&&infoEl)infoEl.style.display='none'}
|
||
}
|
||
|
||
function onStarmapClick(e){
|
||
if(!starmapRen||!starmapCam)return;
|
||
let rect=starmapRen.domElement.getBoundingClientRect();
|
||
let mouse=new THREE.Vector2(((e.clientX-rect.left)/rect.width)*2-1,-((e.clientY-rect.top)/rect.height)*2+1);
|
||
let rc=new THREE.Raycaster();rc.setFromCamera(mouse,starmapCam);
|
||
let hits=rc.intersectObjects(starmapNodeMeshes);
|
||
if(hits.length>0){let n=hits[0].object;
|
||
starmapSelected=(starmapSelected===n)?null:n;
|
||
if(starmapAutoView&&starmapSelected){flyStarmapTo(starmapSelected.userData.nodeId,500)}
|
||
onStarmapMove(e)}
|
||
else{starmapSelected=null}
|
||
}
|
||
|
||
function flyStarmapTo(nodeId,dur){
|
||
if(!starmapAutoView)return;
|
||
let m=starmapNodeMeshes.find(x=>x.userData.nodeId===nodeId);if(!m)return;
|
||
let tp=m.position.clone(),sp=starmapCam.position.clone(),st=starmapCtrl.target.clone();
|
||
let dist=tp.length()+25,ep=new THREE.Vector3(tp.x,tp.y+dist*0.4,tp.z+dist*0.8);
|
||
let t0=Date.now();
|
||
(function lerp(){let t=Math.min((Date.now()-t0)/dur,1),e=1-Math.pow(1-t,3);
|
||
starmapCam.position.lerpVectors(sp,ep,e);starmapCtrl.target.lerpVectors(st,tp,e);if(t<1)requestAnimationFrame(lerp)})()
|
||
}
|
||
|
||
function onStarmapResize(){
|
||
let cont=document.getElementById('starmap-container');if(!cont||!starmapRen||!starmapCam)return;
|
||
let rect=cont.getBoundingClientRect();let w=rect.width||800,h=Math.max(rect.height||500,100);
|
||
if(w>0&&h>0){starmapCam.aspect=w/h;starmapCam.updateProjectionMatrix();starmapRen.setSize(w,h)}
|
||
}
|
||
|
||
function toggleStarmapAuto(){starmapAutoView=!starmapAutoView;let b=document.getElementById('sm-auto-btn');if(b)b.className=starmapAutoView?'on':''}
|
||
|
||
function resetStarmapCamera(){
|
||
let maxD=0;starmapNodeMeshes.forEach(m=>{let d=m.position.length();if(d>maxD)maxD=d});
|
||
let td=Math.min(Math.max(maxD+20,30),150);
|
||
let sp=starmapCam.position.clone(),ep=new THREE.Vector3(td*0.9,td*0.6,td*0.9);
|
||
let st=starmapCtrl.target.clone(),t0=Date.now();
|
||
(function lerp(){let t=Math.min((Date.now()-t0)/400,1),e=1-Math.pow(1-t,3);
|
||
starmapCam.position.lerpVectors(sp,ep,e);starmapCtrl.target.lerpVectors(st,new THREE.Vector3(0,0,0),e);if(t<1)requestAnimationFrame(lerp)})()
|
||
}
|
||
|
||
function starmapAnimate(){
|
||
starmapRaf=requestAnimationFrame(starmapAnimate);
|
||
if(starmapCtrl)starmapCtrl.update();
|
||
if(starmapStarField)starmapStarField.rotation.y+=0.0001;
|
||
if(starmapRen&&starmapScene&&starmapCam)starmapRen.render(starmapScene,starmapCam)
|
||
}
|
||
|
||
// === Settings ===
|
||
function pluginDisplayName(p){if(p==='core')return __('核心','Core');var name=p.replace('plugin.','');var meta=state.pluginMeta&&state.pluginMeta[name];if(meta)return state.lang==='en'?(meta.name_en||name):(meta.name_zh||name);return name}
|
||
function renderSettingsSidebar(){let el=document.querySelector('.settings-sidebar');if(!el)return;el.innerHTML='';state.settingsPlugins.forEach(function(p){let a=document.createElement('a');a.textContent=pluginDisplayName(p);if(p===state.selectedSection)a.className='active';a.onclick=function(){state.selectedSection=p;renderOneSettings()};el.appendChild(a)})}
|
||
function renderOneSettings(){let prefix=state.selectedSection+'.';let allKeys=Object.keys(state.settings||{});let filtered=allKeys.filter(k=>k===prefix.slice(0,-1)||k.startsWith(prefix));filtered.sort();let hideTopLlms=['core.llm.base_url','core.llm.model','core.llm.api_key','core.llm.adapter','core.llm.adapter_path','core.llm.thinking_enabled'];let sourceKeys=filtered.filter(k=>k.startsWith('core.llm.sources.'));let sourceMap={};sourceKeys.forEach(k=>{let parts=k.split('.');let srcName=parts[3];if(!sourceMap[srcName])sourceMap[srcName]={};sourceMap[srcName][k]=true});let mcpServerKeys=filtered.filter(k=>k.startsWith('plugin.mcp.servers.')&&k.split('.').length>=5);let mcpServerMap={};mcpServerKeys.forEach(k=>{let parts=k.split('.');let srvName=parts[3];if(!mcpServerMap[srvName])mcpServerMap[srvName]={};mcpServerMap[srvName][k]=true});let regularKeys=filtered.filter(k=>!k.startsWith('core.llm.sources.')&&hideTopLlms.indexOf(k)===-1&&!k.startsWith('plugin.mcp.servers.')&&k!=='plugin.mcp.servers');let html='<div class="settings-layout"><div class="settings-sidebar"></div><div class="settings-content">';if(regularKeys.length===0&&Object.keys(sourceMap).length===0&&Object.keys(mcpServerMap).length===0&&state.selectedSection!=='plugin.mcp'){html+='<div class="card"><h2>'+escHtml(state.selectedSection)+'</h2><p style="color:var(--text-muted)">暂无设置项</p></div>'}else{regularKeys.forEach(k=>{let v=state.settings[k];let sv=typeof v==='object'?JSON.stringify(v):String(v);let m=state.meta?.[k];let label=m?.display_name||'值';let desc=m?.description||'';let typ=m?.type||'string';let ph=m?.placeholder||'';let opts=m?.options||[];let inpId='inp-'+k.replace(/\./g,'_');let inp='';if(typ==='bool'){let chk=sv==='true'?'checked':'';inp='<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="'+inpId+'" '+chk+' onchange="markDirty(\''+k+'\')" style="width:auto;margin:0"> '+label+'</label>'}else if(typ==='password'){inp='<label>'+label+'</label><input type="password" id="'+inpId+'" value="'+escHtml(sv)+'" placeholder="'+escHtml(ph)+'" onchange="markDirty(\''+k+'\')">'}else if(typ==='text'){inp='<label>'+label+'</label><textarea id="'+inpId+'" onchange="markDirty(\''+k+'\')">'+escHtml(sv)+'</textarea>'}else if(typ==='select'&&opts.length>0){let sel='<label>'+label+'</label><select id="'+inpId+'" onchange="markDirty(\''+k+'\')">';opts.forEach(o=>{sel+='<option value="'+escHtml(o)+'"'+(sv===o?' selected':'')+'>'+escHtml(o)+'</option>'});sel+='</select>';inp=sel}else if(typ==='int'){inp='<label>'+label+'</label><input type="number" id="'+inpId+'" value="'+escHtml(sv)+'" placeholder="'+escHtml(ph)+'" onchange="markDirty(\''+k+'\')">'}else{inp='<label>'+label+'</label><input id="'+inpId+'" value="'+escHtml(sv)+'" placeholder="'+escHtml(ph)+'" onchange="markDirty(\''+k+'\')">'}html+='<div class="card"><button class="btn btn-primary btn-sm save-btn" onclick="saveSetting(\''+k+'\')" style="float:right;margin-top:-4px">保存</button><div class="settings-key">'+escHtml(k)+'</div>'+inp;if(desc){html+='<p style="color:var(--text-muted);font-size:11px;margin-top:-6px">'+escHtml(desc)+'</p>'}html+='</div>'})}if(state.selectedSection==='core'){let srcNames=Object.keys(sourceMap).sort();srcNames.forEach(sn=>{let baseKey='core.llm.sources.'+sn;let srcHtml='<div class="card" style="border-left:3px solid var(--accent)"><h2 style="display:flex;justify-content:space-between;align-items:center"><span>🌐 '+escHtml(sn)+'</span><button class="btn btn-danger btn-sm" onclick="deleteSource(\''+sn+'\')" style="font-size:10px">删除</button></h2>';let fields=[{key:'adapter',label:'适配器'},{key:'base_url',label:'API 地址'},{key:'model',label:'模型'},{key:'api_key',label:'API 密钥',typ:'password'},{key:'thinking_enabled',label:'深度思考',typ:'bool'}];fields.forEach(f=>{let fk=baseKey+'.'+f.key;let fv=state.settings[fk];let fsv=typeof fv==='object'?JSON.stringify(fv):String(fv||'');let fm=state.meta?.[fk];let ftyp=f.typ||fm?.type||'string';let finpId='inp-'+(baseKey+'.'+f.key).replace(/\./g,'_');let finp='';if(ftyp==='bool'){let chk=fsv==='true'?'checked':'';finp='<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:6px"><input type="checkbox" id="'+finpId+'" '+chk+' onchange="markDirty(\''+baseKey+'.'+f.key+'\')" style="width:auto;margin:0"> '+f.label+'</label>'}else if(ftyp==='password'){finp='<label style="font-size:11px;color:var(--text-secondary)">'+f.label+'</label><input type="password" id="'+finpId+'" value="'+escHtml(fsv)+'" placeholder="留空则使用全局密钥" onchange="markDirty(\''+baseKey+'.'+f.key+'\')" style="margin-bottom:6px">'}else if(f.key==='adapter'&&window._adapters&&window._adapters.length){let sel='<label style="font-size:11px;color:var(--text-secondary)">'+f.label+'</label><select id="'+finpId+'" onchange="markDirty(\''+baseKey+'.'+f.key+'\')" style="margin-bottom:6px">';window._adapters.forEach(a=>{sel+='<option value="'+escHtml(a.name)+'" '+(a.name===fsv?'selected':'')+'>'+escHtml(a.name)+'</option>'});sel+='</select>';finp=sel}else{finp='<label style="font-size:11px;color:var(--text-secondary)">'+f.label+'</label><input id="'+finpId+'" value="'+escHtml(fsv)+'" placeholder="输入'+f.label+'" onchange="markDirty(\''+baseKey+'.'+f.key+'\')" style="margin-bottom:6px">'}srcHtml+=finp});srcHtml+='<button class="btn btn-primary btn-sm" onclick="saveSetting(\''+baseKey+'.adapter\');saveSetting(\''+baseKey+'.base_url\');saveSetting(\''+baseKey+'.model\');saveSetting(\''+baseKey+'.api_key\');if(document.getElementById(\''+('inp-'+baseKey+'.thinking_enabled').replace(/\./g,'_')+'\'))saveSetting(\''+baseKey+'.thinking_enabled\');toast(\'源 \\\''+sn+'\\\' 已保存\')" style="margin-top:6px">保存源</button></div>';html+=srcHtml});html+='<div style="margin-top:16px;text-align:center"><button class="btn btn-primary" onclick="showAddSourceDialog()" style="min-width:160px">+ 添加新 LLM 源</button></div>'};if(state.selectedSection==='plugin.mcp'){Object.keys(mcpServerMap).sort().forEach(sn=>{let baseKey='plugin.mcp.servers.'+sn;let cmd=state.settings[baseKey+'.command'],url=state.settings[baseKey+'.url'];if((cmd===undefined||cmd==='<nil>')&&(url===undefined||url==='<nil>'))return;let html2='<div class="card" style="border-left:3px solid var(--accent);margin-top:12px"><h2 style="display:flex;justify-content:space-between;align-items:center"><span>⚙ '+escHtml(sn)+'</span><button class="btn btn-danger btn-sm" onclick="deleteMCPServer(\''+sn+'\')" style="font-size:10px">删除</button></h2>';let fields=[{key:'command',label:'命令'},{key:'url',label:'URL'},{key:'args',label:'参数(JSON)'},{key:'env',label:'环境变量(JSON)'}];fields.forEach(f=>{let fk=baseKey+'.'+f.key;let fv=state.settings[fk];let fsv=typeof fv==='object'?JSON.stringify(fv):String(fv||'');let finpId='inp-'+fk.replace(/\./g,'_');let finp='';if(f.key==='args'||f.key==='env'){finp='<label style="font-size:11px;color:var(--text-secondary)">'+f.label+'</label><textarea id="'+finpId+'" onchange="markDirty(\''+fk+'\')" style="margin-bottom:6px">'+escHtml(fsv)+'</textarea>'}else{finp='<label style="font-size:11px;color:var(--text-secondary)">'+f.label+'</label><input id="'+finpId+'" value="'+escHtml(fsv)+'" placeholder="输入'+f.label+'" onchange="markDirty(\''+fk+'\')" style="margin-bottom:6px">'}html2+=finp});html2+='<button class="btn btn-primary btn-sm" onclick="saveSetting(\''+baseKey+'.command\');saveSetting(\''+baseKey+'.url\');saveSetting(\''+baseKey+'.args\');saveSetting(\''+baseKey+'.env\')" style="margin-top:6px">保存</button></div>';html+=html2});html+='<div style="margin-top:16px;text-align:center"><button class="btn btn-primary" onclick="addMCPSource()" style="min-width:160px">+ 添加 MCP 服务器</button></div>'}html+='</div></div>';document.getElementById('tab-settings').innerHTML=html;renderSettingsSidebar()}
|
||
function markDirty(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(inp)inp.style.borderColor='var(--save-btn-border)'}
|
||
async function saveSetting(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(!inp)return;let val;let m=state.meta?.[k];if(m?.type==='bool'){val=inp.checked?'true':'false'}else if(m?.type==='select'){val=inp.value}else{let raw=inp.value;try{val=JSON.parse(raw)}catch(e){val=raw}}try{let r=await api('/settings',{method:'PUT',body:JSON.stringify({key:k,value:val})});if(r.status==='ok'){inp.style.borderColor='';state.settings[k]=val;toast('已保存: '+k)}else{toast('保存失败: '+(r.error||'unknown'),true)}}catch(e){toast('保存失败: '+e.message,true)}}
|
||
function renderConfigDisabled(){document.getElementById('tab-settings').innerHTML='<div class="card"><h2>设置</h2><p style="color:var(--text-muted)">设置面板已加载</p></div>';renderOneSettings()}
|
||
function showAddSourceDialog(){let name=prompt('输入新 LLM 源名称(如 openai、anthropic):');if(!name||!name.trim())return;name=name.trim().toLowerCase().replace(/[^a-z0-9_]/g,'_');if(!name){toast('名称无效',true);return}let keys=['base_url','model','api_key','adapter','adapter_path','thinking_enabled'];let values={base_url:'https://api.'+name+'.com',model:'',api_key:'',adapter:name,adapter_path:'',thinking_enabled:'false'};let promises=keys.map(f=>api('/settings',{method:'PUT',body:JSON.stringify({key:'core.llm.sources.'+name+'.'+f,value:values[f]})}));Promise.all(promises).then(()=>{toast('源 "'+name+'" 已创建,请配置各项参数');renderAll()}).catch(e=>toast('创建失败: '+e.message,true))}
|
||
async function deleteSource(name){if(!confirm('确认删除源 "'+name+'"?'))return;let base='core.llm.sources.'+name;let fields=['adapter','base_url','model','api_key','thinking_enabled','adapter_path'];try{for(let f of fields){await api('/settings',{method:'PUT',body:JSON.stringify({key:base+'.'+f,value:null})})}toast('源 "'+name+'" 已删除');renderAll()}catch(e){toast('删除失败: '+e.message,true)}}
|
||
function addMCPSource(){let name=prompt('输入新 MCP 服务器名称:');if(!name||!name.trim())return;name=name.trim().toLowerCase().replace(/[^a-z0-9_]/g,'_');if(!name){toast('名称无效',true);return}let fields=['command','url','args','env'];let values={command:'',url:'',args:'[]',env:'[]'};let promises=fields.map(f=>api('/settings',{method:'PUT',body:JSON.stringify({key:'plugin.mcp.servers.'+name+'.'+f,value:values[f]})}));Promise.all(promises).then(()=>{toast('MCP 服务器 "'+name+'" 已创建');renderAll()}).catch(e=>toast('创建失败: '+e.message,true))}
|
||
async function deleteMCPServer(name){if(!confirm('确认删除 MCP 服务器 "'+name+'"?'))return;let fields=['command','url','args','env'];try{for(let f of fields){await api('/settings',{method:'PUT',body:JSON.stringify({key:'plugin.mcp.servers.'+name+'.'+f,value:null})})}toast('MCP 服务器 "'+name+'" 已删除');renderAll()}catch(e){toast('删除失败: '+e.message,true)}}
|
||
|
||
// === Adapters ===
|
||
async function renderAdapters(){let html='<div class="card"><h2>Lua 适配器管理</h2><p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">上传自定义 Lua 适配器脚本以支持新的 LLM 提供商。脚本文件将保存到适配器目录并自动加载到 Lua VM。</p></div>';try{let r=await api('/adapters');let adapters=r.adapters||[];window._adapters=adapters;html+='<div class="card"><h2>已加载的适配器 ('+adapters.length+')</h2>';if(adapters.length===0){html+='<p style="color:var(--text-muted)">暂无适配器</p>'}else{html+='<table><tr><th>名称</th><th>版本</th><th>操作</th></tr>';adapters.forEach(a=>{html+='<tr><td>'+escHtml(a.name)+'</td><td>'+escHtml(a.version||'-')+'</td><td><button class="btn btn-danger btn-sm" onclick="deleteAdapter(\''+escHtml(a.name)+'\')">删除</button></td></tr>'});html+='</table>'}html+='</div>';html+='<div class="card"><h2>上传新适配器</h2><label>适配器名称(不带 .lua)</label><input id="adapter-name" placeholder="如 openai"><label>Lua 脚本代码</label><textarea id="adapter-code" rows="12" placeholder="-- 返回一个适配器表 return { name = "openai", version = "1.0", transform_request = function(raw) ... end, transform_response = function(raw) ... end, }"></textarea><button class="btn btn-primary" onclick="uploadAdapter()">上传</button></div>'}catch(e){html+='<div class="card"><p style="color:var(--text-muted)">加载适配器失败: '+escHtml(e.message)+'</p></div>'}document.getElementById('tab-adapters').innerHTML=html}
|
||
async function uploadAdapter(){let name=document.getElementById('adapter-name')?.value;let code=document.getElementById('adapter-code')?.value;if(!name||!code){toast('名称和代码不能为空',true);return}try{let r=await api('/adapters',{method:'POST',body:JSON.stringify({name,code})});if(r.status==='loaded'){toast('适配器 "'+name+'" 已加载');renderAdapters()}else{toast('上传失败: '+(r.error||'unknown'),true)}}catch(e){toast('上传失败: '+e.message,true)}}
|
||
async function deleteAdapter(name){if(!confirm('确定删除适配器 "'+name+'"?'))return;try{let r=await api('/adapters/'+encodeURIComponent(name),{method:'DELETE'});if(r.status==='deleted'){toast('适配器 "'+name+'" 已删除');renderAdapters()}else{toast('删除失败',true)}}catch(e){toast('删除失败: '+e.message,true)}}
|
||
|
||
// === Init ===
|
||
async function logout(){try{await fetch('/api/v1/logout',{method:'POST',credentials:'include'});}catch(e){}location.href='/login'}
|
||
renderConfigDisabled();
|
||
connectSSE();
|
||
(async function(){await loadChatHistory();renderAll()})();
|
||
setInterval(renderAll,15000);
|
||
</script>
|
||
</body>
|
||
</html>
|