fix: Phase 6联调 — 插件管理器端口修正 + 多模型配置系统整合 + 历史消息刷新修复

## 调试日志

### 1. 插件管理器启动失败
- **症状**: DevTools 显示插件管理器一直"已停止",手动启动正常
- **排查**: 对比 process-manager.js 传入的环境变量 vs plugin-manager config.go 读取的变量
- **根因**: config.js 传入 PLUGIN_MANAGER_PORT=8094,但 config.go 读取 os.Getenv("PORT"),env 名不匹配。且 process.env 中 PORT 泄露时被误读为 9090,与 DevTools 端口冲突
- **修复**: config.js 将 PLUGIN_MANAGER_PORT → PORT,使 env 名与代码一致 (c3055f4)

### 2. 历史消息刷新后消失
- **症状**: 浏览器刷新后聊天历史清空
- **排查**: WebSocket history_response handler 中 if (msg.messages) 对空数组 [] 为 truthy
- **根因**: 后端返回空的 history_response (缓存为空) 时,空数组覆盖了 HTTP 已加载的消息
- **修复**: useWebSocket.ts 改为 if (msg.messages && msg.messages.length > 0),空数组走 else-if 分支仅打日志,不覆盖已有消息

### 3. Phase 6 多模型配置系统
- Gateway: ModelsConfigStore (JSON文件持久化) + Admin CRUD API (providers/models/routing)
- ai-core: ModelSelector 支持按 purpose 选择 + fallback_chain,无配置时回退 .env
- DevTools: 模型配置管理面板 (Providers/Models/Routing 三Tab)、在线模型查询代理、路由表单 checkbox 多选、关键词搜索过滤
- .gitignore: models.json + platform_configs.json

### 4. 多端客户端追踪
- Hub 新增 knownClients 映射 (clientID → KnownClient),在线/离线状态追踪
- 客户端备注持久化到 PostgreSQL
- DevTools 客户端管理面板

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 21:23:10 +08:00
parent 965cce7192
commit 0717928496
29 changed files with 3177 additions and 137 deletions
+888 -2
View File
@@ -684,6 +684,16 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<button class="nav-item" data-panel="timeline">
<span class="nav-icon">⏱️</span><span class="nav-label">记忆时间线</span>
</button>
<button class="nav-item" data-panel="chatPlatforms">
<span class="nav-icon">💬</span><span class="nav-label">第三方聊天</span>
</button>
<button class="nav-item" data-panel="clients">
<span class="nav-icon">📱</span><span class="nav-label">客户端管理</span>
<span class="nav-badge" id="clients-badge" style="display:none">0</span>
</button>
<button class="nav-item" data-panel="modelConfig">
<span class="nav-icon">🤖</span><span class="nav-label">模型配置</span>
</button>
</nav>
<div class="sidebar-footer">
<span id="ws-dot" class="disconnected"></span>
@@ -720,6 +730,11 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<div class="panel" id="panel-thinking"></div>
<!-- 记忆时间线 -->
<div class="panel" id="panel-timeline"></div>
<!-- 第三方聊天 -->
<div class="panel" id="panel-chatPlatforms"></div>
<!-- 客户端管理 -->
<div class="panel" id="panel-clients"></div>
<div class="panel" id="panel-modelConfig"></div>
</div>
</div>
@@ -772,8 +787,19 @@ const STATE = {
timelineFilterType: 'all',
timelineAutoRefresh: null,
timelineLimit: 100,
// 第三方聊天
chatConfigsAutoRefresh: null,
chatConfigs: [],
chatActivePlatform: null,
chatLogLimit: 100,
// 自主思考面板:记录展开的日志 ID
expandedThinkingLogs: {},
// 模型配置
modelConfigTab: 'providers',
modelConfigProviders: [],
modelConfigModels: [],
modelConfigRouting: [],
fetchedModels: [],
expandedThinkingLogs: {},
};
// ========== WebSocket ==========
@@ -968,6 +994,11 @@ document.getElementById('toggle-sidebar').addEventListener('click', () => {
function switchPanel(name) {
STATE.activePanel = name;
// Update URL hash (without triggering hashchange).
if (location.hash !== '#' + name) {
history.replaceState(null, '', '#' + name);
}
// 更新侧边栏
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`.nav-item[data-panel="${name}"]`);
@@ -978,6 +1009,9 @@ function switchPanel(name) {
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
services: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
toolCalls: '🔧 工具调用记录', stt: '🎤 语音识别日志', thinking: '💭 自主思考', timeline: '⏱️ 记忆时间线',
chatPlatforms: '💬 第三方聊天配置与消息日志',
clients: '📱 客户端管理',
modelConfig: '🤖 模型配置管理',
};
document.getElementById('panel-title').textContent = titles[name] || name;
@@ -1001,6 +1035,9 @@ function switchPanel(name) {
case 'stt': renderSTTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'timeline': renderTimelinePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); startTimelineAutoRefresh(); break;
case 'chatPlatforms': renderChatPlatformsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); startChatAutoRefresh(); break;
case 'clients': renderClientsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'modelConfig': renderModelConfigPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
}
}
@@ -3439,13 +3476,862 @@ function toggleTimelineAutoRefresh(on) {
}
}
// ========== 面板: 第三方聊天配置 ==========
var PLATFORM_FIELDS = {
qq: [{ key: 'bot_port', label: 'Bot WebSocket 端口', placeholder: '8096' }],
telegram: [
{ key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' },
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://your-domain.com' }
],
webhook: [
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://hook.example.com/chat' },
{ key: 'secret', label: 'Secret Token', placeholder: '(可选)' }
],
wechat: [
{ key: 'corp_id', label: '企业ID (Corp ID)', placeholder: 'ww...' },
{ key: 'corp_secret', label: '应用Secret', placeholder: '' },
{ key: 'agent_id', label: 'Agent ID', placeholder: '1000001' },
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://...' }
],
feishu: [
{ key: 'app_id', label: 'App ID', placeholder: 'cli_...' },
{ key: 'app_secret', label: 'App Secret', placeholder: '' },
{ key: 'verification_token', label: 'Verification Token', placeholder: '' },
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://...' }
],
discord: [
{ key: 'bot_token', label: 'Bot Token', placeholder: 'MT...' },
{ key: 'application_id', label: 'Application ID', placeholder: '123456789...' }
]
};
var PLATFORM_ICONS = { qq: '🐧', telegram: '✈️', webhook: '🪝', wechat: '💚', feishu: '🕊️', discord: '🎮' };
var PLATFORM_LABELS = { qq: 'QQ', telegram: 'Telegram', webhook: 'Webhook', wechat: 'WeChat', feishu: 'Feishu', discord: 'Discord' };
function startChatAutoRefresh() {
stopChatAutoRefresh();
STATE.chatConfigsAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'chatPlatforms') {
loadChatConfigs();
if (STATE.chatActivePlatform) refreshChatLogs(STATE.chatActivePlatform);
}
}, 10000);
}
function stopChatAutoRefresh() {
if (STATE.chatConfigsAutoRefresh) { clearInterval(STATE.chatConfigsAutoRefresh); STATE.chatConfigsAutoRefresh = null; }
}
function renderChatPlatformsPanel() {
if (STATE.chatActivePlatform) { renderChatPlatformDetail(STATE.chatActivePlatform); return; }
var panel = document.getElementById('panel-chatPlatforms');
panel.innerHTML = '<div class="card"><div class="card-header"><span class="card-title">🔗 平台配置列表</span>' +
'<button class="btn btn-sm btn-accent" onclick="showChatConfigForm()"> 添加配置</button></div>' +
'<div class="table-wrap"><table id="chat-configs-table"><thead><tr>' +
'<th>平台</th><th>启用</th><th>连接</th><th>关键配置</th><th>更新时间</th><th>操作</th>' +
'</tr></thead><tbody id="chat-configs-tbody">' +
'<tr><td colspan="6"><div class="empty-state"><div class="icon">💬</div>加载中...</div></td></tr></tbody></table></div></div>';
document.getElementById('panel-actions').innerHTML = '<button class="btn btn-sm" onclick="refreshChatConfigs()">🔄 刷新</button>';
loadChatConfigs();
}
async function loadChatConfigs() {
var data = await api('/api/chat-platforms/configs');
var tbody = document.getElementById('chat-configs-tbody');
if (!tbody) return;
if (data.error) { tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div></td></tr>'; return; }
STATE.chatConfigs = data.configs || [];
renderChatConfigsTable();
}
function renderChatConfigsTable() {
var tbody = document.getElementById('chat-configs-tbody');
if (!tbody) return;
var configs = STATE.chatConfigs;
if (configs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="icon">💬</div>暂无配置,点击「添加配置」创建</div></td></tr>';
return;
}
tbody.innerHTML = configs.map(function(c) {
var icon = PLATFORM_ICONS[c.name] || '🔗';
var label = c.label || PLATFORM_LABELS[c.name] || c.name;
var connBadge = c.connected ? '<span class="badge badge-running">已连接</span>' : '<span class="badge badge-stopped">未连接</span>';
var enabledBadge = c.enabled !== false ? '<span class="badge badge-running">启用</span>' : '<span class="badge badge-stopped">禁用</span>';
var keys = (c.fields && Object.keys(c.fields).length > 0)
? Object.keys(c.fields).map(function(k) { return k + '=' + (c.fields[k] ? '***' : '(空)'); }).join(', ')
: '—';
var updated = c.updated_at ? timeAgo(c.updated_at) : '—';
return '<tr>' +
'<td><strong>' + icon + ' ' + escHtml(label) + '</strong></td>' +
'<td>' + enabledBadge + '</td>' +
'<td>' + connBadge + '</td>' +
'<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(keys) + '</td>' +
'<td>' + updated + '</td>' +
'<td><div class="btn-group">' +
'<button class="btn btn-xs" onclick="editChatConfig(\'' + escHtml(c.name) + '\')">✏️ 编辑</button>' +
'<button class="btn btn-xs btn-red" onclick="deleteChatConfig(\'' + escHtml(c.name) + '\')">🗑</button>' +
'</div></td></tr>';
}).join('');
}
function refreshChatConfigs() { loadChatConfigs(); }
function showChatConfigForm() {
var panel = document.getElementById('panel-chatPlatforms');
var options = ['qq', 'telegram', 'webhook', 'wechat', 'feishu', 'discord'];
panel.innerHTML = '<div class="card"><div class="card-header"><span class="card-title"> 选择要配置的平台</span>' +
'<button class="btn btn-sm" onclick="STATE.chatActivePlatform=null;renderChatPlatformsPanel();">← 取消</button></div>' +
'<div class="cards-grid cards-3">' +
options.map(function(p) {
return '<div class="card" style="cursor:pointer;text-align:center;padding:20px" onclick="startNewConfig(\'' + p + '\')">' +
'<div style="font-size:32px;margin-bottom:8px">' + (PLATFORM_ICONS[p] || '🔗') + '</div>' +
'<div style="font-weight:600">' + (PLATFORM_LABELS[p] || p) + '</div></div>';
}).join('') + '</div></div>';
document.getElementById('panel-actions').innerHTML = '';
}
function startNewConfig(name) { STATE.chatActivePlatform = name; renderChatPlatformsPanel(); }
function editChatConfig(name) {
if (!STATE.chatConfigs.some(function(c) { return c.name === name; })) {
STATE.chatActivePlatform = name;
renderChatPlatformsPanel();
} else {
STATE.chatActivePlatform = name;
renderChatPlatformsPanel();
}
}
function renderChatPlatformDetail(name) {
var cfg = null;
for (var i = 0; i < STATE.chatConfigs.length; i++) {
if (STATE.chatConfigs[i].name === name) { cfg = STATE.chatConfigs[i]; break; }
}
var icon = PLATFORM_ICONS[name] || '🔗';
var panel = document.getElementById('panel-chatPlatforms');
panel.innerHTML =
'<div style="margin-bottom:14px"><button class="btn btn-sm" onclick="STATE.chatActivePlatform=null;renderChatPlatformsPanel();">← 返回列表</button></div>' +
'<div class="card"><div class="card-header"><span class="card-title">' + icon + ' ' + escHtml(name) + ' 配置</span><span id="cfg-save-status"></span></div>' +
'<div class="card-body" id="chat-config-form"></div></div>' +
'<div class="card" style="margin-top:14px"><div class="card-header"><span class="card-title">📋 消息日志 (最近 ' + STATE.chatLogLimit + ' 条)</span>' +
'<div class="btn-group">' +
'<button class="btn btn-xs" onclick="refreshChatLogs(\'' + escHtml(name) + '\')">🔄 刷新</button>' +
'<select id="chat-log-limit" onchange="STATE.chatLogLimit=parseInt(this.value);refreshChatLogs(\'' + escHtml(name) + '\')" ' +
'style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' +
'<option value="50">50条</option><option value="100" selected>100条</option><option value="200">200条</option><option value="500">500条</option></select></div></div>' +
'<div id="chat-log-container" style="max-height:400px;overflow-y:auto;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px">' +
'<div class="empty-state"><div class="icon">📝</div>加载中...</div></div></div>';
document.getElementById('panel-actions').innerHTML = '';
renderChatConfigForm(name, cfg);
refreshChatLogs(name);
}
function renderChatConfigForm(name, cfg) {
var fields = PLATFORM_FIELDS[name] || [];
var container = document.getElementById('chat-config-form');
if (!container) return;
var currentFields = (cfg && cfg.fields) || {};
var enabled = cfg ? (cfg.enabled !== false) : true;
var fieldsHTML = fields.map(function(f) {
var val = currentFields[f.key] || '';
return '<div class="form-group"><label>' + escHtml(f.label) + '</label>' +
'<input type="text" id="cfg-field-' + escHtml(f.key) + '" value="' + escHtml(val) + '" placeholder="' + escHtml(f.placeholder || '') + '"></div>';
}).join('');
container.innerHTML =
'<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer">' +
'<input type="checkbox" id="cfg-field-enabled" ' + (enabled ? 'checked' : '') + ' style="width:auto"><span>启用此平台</span></label></div>' +
fieldsHTML +
'<div class="form-group"><label>显示名称</label>' +
'<input type="text" id="cfg-field-label" value="' + escHtml((cfg && cfg.label) || '') + '" placeholder="' + escHtml(name) + '"></div>' +
'<div class="btn-group" style="margin-top:12px"><button class="btn btn-sm btn-accent" onclick="saveChatConfig(\'' + escHtml(name) + '\')">💾 保存配置</button></div>';
}
async function saveChatConfig(name) {
var fields = {};
var fieldDefs = PLATFORM_FIELDS[name] || [];
fieldDefs.forEach(function(f) {
var el = document.getElementById('cfg-field-' + f.key);
if (el) fields[f.key] = el.value;
});
var enabledEl = document.getElementById('cfg-field-enabled');
var enabled = enabledEl ? enabledEl.checked : true;
var labelEl = document.getElementById('cfg-field-label');
var label = labelEl ? labelEl.value : '';
var data = await api('/api/chat-platforms/configs/' + encodeURIComponent(name), {
method: 'POST',
body: JSON.stringify({ name: name, enabled: enabled, label: label, fields: fields })
});
if (data.error) { showToast('保存失败: ' + data.error, 'error'); return; }
showToast('配置已保存 (需重启平台桥接服务生效)', 'success');
await loadChatConfigs();
renderChatPlatformDetail(name);
}
async function deleteChatConfig(name) {
if (!confirm('确认删除 ' + name + ' 的配置?')) return;
var data = await api('/api/chat-platforms/configs/' + encodeURIComponent(name), { method: 'DELETE' });
if (data.error) { showToast('删除失败: ' + data.error, 'error'); return; }
showToast('配置已删除', 'success');
STATE.chatActivePlatform = null;
await loadChatConfigs();
renderChatPlatformsPanel();
}
async function refreshChatLogs(name) {
var limit = STATE.chatLogLimit || 100;
var data = await api('/api/chat-platforms/logs/' + encodeURIComponent(name) + '?limit=' + limit);
var container = document.getElementById('chat-log-container');
if (!container) return;
if (data.error) { container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>'; return; }
var logs = data.logs || [];
STATE.chatLogs = STATE.chatLogs || {};
STATE.chatLogs[name] = logs;
if (logs.length === 0) { container.innerHTML = '<div class="empty-state"><div class="icon">📝</div>暂无消息日志</div>'; return; }
container.innerHTML = logs.map(function(l) {
var arrow = l.direction === 'incoming' ? '← 收到' : '→ 发送';
var color = l.direction === 'incoming' ? 'var(--blue)' : 'var(--green)';
var time = new Date(l.timestamp).toLocaleString('zh-CN', { hour12: false });
var content = (l.content || '').length > 300 ? (l.content || '').substring(0, 297) + '...' : (l.content || '');
return '<div style="padding:6px 10px;border-bottom:1px solid var(--border);font-size:12px">' +
'<span style="color:' + color + ';font-weight:600">' + arrow + '</span> ' +
'<span style="color:var(--text3)">' + time + '</span> ' +
'<span style="color:var(--text2)">[' + escHtml(l.sender_name || l.sender_id || '-') + ']</span> ' +
'<span>' + escHtml(content) + '</span>' +
(l.error ? ' <span style="color:var(--red)">⚠ ' + escHtml(l.error) + '</span>' : '') +
'</div>';
}).join('');
}
// ========== 模型配置管理面板 ==========
function renderModelConfigPanel() {
var panel = document.getElementById('panel-modelConfig');
var activeTab = STATE.modelConfigTab || 'providers';
var tabs = [
{ id: 'providers', label: '🔌 模型提供商' },
{ id: 'models', label: '🧠 模型定义' },
{ id: 'routing', label: '🔀 用途路由' },
];
var tabBar = '<div class="tab-bar" style="margin-bottom:14px;display:flex;gap:6px;flex-wrap:wrap">' +
tabs.map(function(t) {
return '<button class="btn btn-sm' + (activeTab === t.id ? ' btn-accent' : '') +
'" onclick="STATE.modelConfigTab=\'' + t.id + '\';renderModelConfigPanel();">' + t.label + '</button>';
}).join('') + '</div>';
panel.innerHTML = tabBar + '<div id="model-config-content" class="card"><div class="card-body">加载中...</div></div>';
document.getElementById('panel-actions').innerHTML = '';
switch (activeTab) {
case 'providers': renderProvidersTab(); break;
case 'models': renderModelsTab(); break;
case 'routing': renderRoutingTab(); break;
}
}
// ---- Providers tab ----
async function renderProvidersTab() {
var container = document.getElementById('model-config-content');
var data = await api('/api/model-config/providers');
STATE.modelConfigProviders = data.providers || [];
var rows = STATE.modelConfigProviders.length === 0
? '<tr><td colspan="5"><div class="empty-state"><div class="icon">🔌</div>暂无模型提供商,请添加</div></td></tr>'
: STATE.modelConfigProviders.map(function(p) {
var updated = p.updated_at ? timeAgo(p.updated_at) : '—';
return '<tr>' +
'<td><strong>' + escHtml(p.name) + '</strong></td>' +
'<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(p.base_url) + '</td>' +
'<td>' + (p.timeout_sec || '—') + 's</td>' +
'<td>' + updated + '</td>' +
'<td><div class="btn-group">' +
'<button class="btn btn-xs" onclick="showProviderForm(\'' + escHtml(p.name) + '\')">✏️</button>' +
'<button class="btn btn-xs btn-red" onclick="deleteModelProvider(\'' + escHtml(p.name) + '\')">🗑</button>' +
'</div></td></tr>';
}).join('');
container.innerHTML =
'<div class="card-header"><span class="card-title">🔌 模型提供商</span>' +
'<button class="btn btn-sm btn-accent" onclick="showProviderForm()"> 添加</button></div>' +
'<div class="table-wrap"><table><thead><tr>' +
'<th>名称</th><th>Base URL</th><th>超时</th><th>更新时间</th><th>操作</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
var PROVIDER_TEMPLATES = [
{ name: 'deepseek', label: 'DeepSeek', base_url: 'https://api.deepseek.com/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.deepseek.com/models' },
{ name: 'dashscope', label: '阿里百炼 (DashScope)', base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/models' },
{ name: 'zhipu', label: '智谱 AI (GLM)', base_url: 'https://open.bigmodel.cn/api/paas/v4', timeout_sec: 120, max_retries: 3, models_url: 'https://open.bigmodel.cn/api/paas/v4/models' },
{ name: 'moonshot', label: 'Moonshot (Kimi)', base_url: 'https://api.moonshot.cn/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.moonshot.cn/v1/models' },
{ name: 'siliconflow', label: '硅基流动 (SiliconFlow)', base_url: 'https://api.siliconflow.cn/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.siliconflow.cn/v1/models' },
{ name: 'lingyi', label: '零一万物', base_url: 'https://api.lingyiwanwu.com/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.lingyiwanwu.com/v1/models' },
{ name: 'qianfan', label: '百度千帆', base_url: 'https://qianfan.baidubce.com/v2', timeout_sec: 120, max_retries: 3, models_url: 'https://qianfan.baidubce.com/v2/models' },
{ name: 'xfyun', label: '讯飞星火', base_url: 'https://spark-api-open.xf-yun.com/v1', timeout_sec: 120, max_retries: 3, models_url: '' },
{ name: 'minimax', label: 'MiniMax', base_url: 'https://api.minimax.chat/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.minimax.chat/v1/models' },
{ name: 'openai', label: 'OpenAI', base_url: 'https://api.openai.com/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.openai.com/v1/models' },
{ name: 'custom', label: '💡 自定义...', base_url: '', timeout_sec: 120, max_retries: 3, models_url: '' },
];
var MODEL_TEMPLATES = {
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
dashscope: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-max-longcontext', 'qwen-vl-plus', 'qwen-coder-turbo'],
zhipu: ['glm-4-flash', 'glm-4-plus', 'glm-4-long', 'glm-4v-flash', 'glm-4-air'],
moonshot: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
siliconflow: ['Qwen/Qwen3-235B-A22B', 'Qwen/Qwen2.5-72B-Instruct', 'Qwen/Qwen2.5-7B-Instruct', 'deepseek-ai/DeepSeek-V3', 'Pro/THUDM/glm-4-9b-chat'],
lingyi: ['yi-large', 'yi-medium', 'yi-lightning', 'yi-vision'],
qianfan: ['ernie-speed-128k', 'ernie-4.0-8k', 'ernie-3.5-8k', 'ernie-speed-pro-128k'],
xfyun: ['spark-lite', 'spark-pro-128k', 'spark-max', 'spark-4.0-ultra'],
minimax: ['abab6.5s-chat', 'abab6.5-chat'],
openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini'],
};
function showProviderForm(name) {
var existing = null;
if (name) {
for (var i = 0; i < STATE.modelConfigProviders.length; i++) {
if (STATE.modelConfigProviders[i].name === name) { existing = STATE.modelConfigProviders[i]; break; }
}
}
var isEdit = !!existing;
var formTitle = isEdit ? '✏️ 编辑 ' + escHtml(name) : ' 添加模型提供商';
var defaults = existing || { name: '', base_url: 'https://api.deepseek.com/v1', api_key: '', timeout_sec: 120, max_retries: 3 };
var templateOptions = PROVIDER_TEMPLATES.map(function(t) {
return '<option value="' + t.name + '" data-url="' + escHtml(t.base_url) + '" data-timeout="' + t.timeout_sec + '" data-retries="' + t.max_retries + '">' + escHtml(t.label) + '</option>';
}).join('');
var container = document.getElementById('model-config-content');
container.innerHTML =
'<div class="card-header"><span class="card-title">' + formTitle + '</span>' +
'<button class="btn btn-sm" onclick="renderProvidersTab()">← 返回</button></div>' +
'<div class="card-body"><form onsubmit="event.preventDefault();saveProviderForm(\'' + escHtml(name || '') + '\');">' +
(isEdit ? '' :
'<div class="form-row"><label>📋 快速模板</label>' +
'<select id="prov-template" class="input" onchange="applyProviderTemplate(this.value)" style="background:var(--bg3)">' +
'<option value="">-- 选择提供商模板自动填充 --</option>' + templateOptions + '</select></div>') +
'<div class="form-row"><label>Provider 名称 ' + (isEdit ? '' : '<span style="color:var(--red)">*</span>') + '</label>' +
'<input id="prov-name" class="input" value="' + escHtml(defaults.name) + '" ' + (isEdit ? 'readonly' : 'placeholder="如 deepseek, openai"') + ' required></div>' +
'<div class="form-row"><label>Base URL <span style="color:var(--red)">*</span></label>' +
'<input id="prov-url" class="input" value="' + escHtml(defaults.base_url) + '" placeholder="https://api.deepseek.com/v1" required></div>' +
'<div class="form-row"><label>API Key</label>' +
'<input id="prov-key" class="input" type="password" value="' + escHtml(defaults.api_key || '') + '" placeholder="sk-xxx"></div>' +
'<div class="form-row" style="display:flex;gap:12px"><div style="flex:1"><label>超时 (秒)</label>' +
'<input id="prov-timeout" class="input" type="number" value="' + (defaults.timeout_sec || 120) + '"></div>' +
'<div style="flex:1"><label>最大重试</label>' +
'<input id="prov-retries" class="input" type="number" value="' + (defaults.max_retries || 3) + '"></div></div>' +
'<div style="margin-top:14px"><button type="submit" class="btn btn-accent">💾 保存</button></div>' +
'</form></div>';
}
function applyProviderTemplate(templateName) {
if (!templateName || templateName === 'custom') return;
var sel = document.getElementById('prov-template');
var opt = sel.options[sel.selectedIndex];
document.getElementById('prov-name').value = opt.value;
document.getElementById('prov-url').value = opt.getAttribute('data-url') || '';
var timeout = parseInt(opt.getAttribute('data-timeout')) || 120;
var retries = parseInt(opt.getAttribute('data-retries')) || 3;
var timeoutEl = document.getElementById('prov-timeout');
var retriesEl = document.getElementById('prov-retries');
if (timeoutEl && !timeoutEl.value) timeoutEl.value = timeout;
if (retriesEl && !retriesEl.value) retriesEl.value = retries;
}
function updateModelTemplateOptions() {
var provider = document.getElementById('model-provider').value;
var area = document.getElementById('model-template-area');
if (!area) return;
STATE.fetchedModels = [];
var models = MODEL_TEMPLATES[provider] || [];
area.innerHTML =
'<select id="model-template" class="input" onchange="applyModelTemplate(this.value)" style="background:var(--bg3)">' +
'<option value="">-- 选择模型模板 / 查询获取 --</option>' +
models.map(function(m) { return '<option value="' + escHtml(m) + '">' + escHtml(m) + '</option>'; }).join('') +
'</select>';
var btn = document.getElementById('btn-fetch-models');
if (!btn) return;
var tmpl = null;
for (var i = 0; i < PROVIDER_TEMPLATES.length; i++) {
if (PROVIDER_TEMPLATES[i].name === provider) { tmpl = PROVIDER_TEMPLATES[i]; break; }
}
btn.disabled = !(tmpl && tmpl.models_url);
}
function applyModelTemplate(modelName) {
if (!modelName) return;
document.getElementById('model-name').value = modelName;
var idEl = document.getElementById('model-id');
if (idEl && !idEl.value) idEl.value = modelName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
}
async function fetchProviderModels() {
var provider = document.getElementById('model-provider').value;
if (!provider) { alert('请先选择 Provider'); return; }
var tmpl = null;
for (var i = 0; i < PROVIDER_TEMPLATES.length; i++) {
if (PROVIDER_TEMPLATES[i].name === provider) { tmpl = PROVIDER_TEMPLATES[i]; break; }
}
if (!tmpl || !tmpl.models_url) { alert('该 Provider 不支持在线查询模型列表(讯飞星火等使用非标准接口)'); return; }
var btn = document.getElementById('btn-fetch-models');
if (btn) { btn.disabled = true; btn.textContent = '⏳ 查询中...'; }
try {
var result = await api('/api/model-config/fetch-models/' + encodeURIComponent(provider) + '?url=' + encodeURIComponent(tmpl.models_url));
if (result.error) { alert('查询失败: ' + result.error + (result.body ? '\n' + result.body.substring(0, 200) : '')); return; }
var models = result.models || [];
if (models.length === 0) { alert('该 Provider 未返回任何模型'); return; }
STATE.fetchedModels = models;
renderFetchedModelList(models, '');
} catch(e) {
alert('查询模型列表出错: ' + e.message);
} finally {
if (btn) { btn.disabled = false; btn.textContent = '🔍 查询'; }
}
}
function renderFetchedModelList(models, filter) {
var area = document.getElementById('model-template-area');
if (!area) return;
var filterLower = (filter || '').toLowerCase();
var filtered = filterLower ? models.filter(function(m) { return m.toLowerCase().indexOf(filterLower) >= 0; }) : models;
var countInfo = filterLower ? '\uff08' + filtered.length + '/' + models.length + '\uff09' : '\uff08共 ' + models.length + ' \u4e2a\uff09';
var html = '<div style="display:flex;gap:8px;margin-bottom:8px">' +
'<input id="model-search-input" class="input" type="text" placeholder="\U0001f50d \u641c\u7d22\u6a21\u578b\u540d\u79f0...' + countInfo + '" value="' + escHtml(filter) + '"' +
' oninput="renderFetchedModelList(STATE.fetchedModels, this.value)" style="flex:1;background:var(--bg);font-size:12px">' +
'<button type="button" class="btn btn-xs" onclick="var s=document.getElementById(&quot;model-search-input&quot;);if(s)s.value=&quot;&quot;;renderFetchedModelList(STATE.fetchedModels,&quot;&quot;);" title="\u6e05\u9664\u641c\u7d22">\u2715</button></div>' +
'<div id="fetched-model-list" style="max-height:220px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg)">';
if (filtered.length === 0) {
html += '<div class="empty-state" style="padding:12px"><div class="icon">\U0001f50d</div>\u65e0\u5339\u914d\u6a21\u578b</div>';
} else {
html += filtered.map(function(m) {
return '<div class="fetched-model-item"' +
' data-model="' + escHtml(m) + '"' +
' onclick="var mn=this.getAttribute(&quot;data-model&quot;);selectFetchedModel(mn);"' +
' style="padding:6px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border);transition:background .12s"' +
' onmouseenter="this.style.background=&quot;var(--bg3)&quot;"' +
' onmouseleave="this.style.background=&quot;&quot;">' + escHtml(m) + '</div>';
}).join('');
}
html += '</div>';
area.innerHTML = html;
}
function selectFetchedModel(modelName) {
document.getElementById('model-name').value = modelName;
var idEl = document.getElementById('model-id');
if (idEl && !idEl.value) idEl.value = modelName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
var items = document.querySelectorAll('.fetched-model-item');
for (var i = 0; i < items.length; i++) {
items[i].style.background = (items[i].textContent === modelName) ? 'var(--accent-bg)' : '';
}
}
function resetModelTemplateArea() {
var area = document.getElementById('model-template-area');
if (!area) return;
STATE.fetchedModels = [];
var provider = document.getElementById('model-provider').value;
var models = MODEL_TEMPLATES[provider] || [];
area.innerHTML =
'<select id="model-template" class="input" onchange="applyModelTemplate(this.value)" style="background:var(--bg3)">' +
'<option value="">-- 选择模型模板 / 查询获取 --</option>' +
models.map(function(m) { return '<option value="' + escHtml(m) + '">' + escHtml(m) + '</option>'; }).join('') +
'</select>';
}
async function saveProviderForm(name) {
var data = {
name: document.getElementById('prov-name').value.trim(),
base_url: document.getElementById('prov-url').value.trim(),
api_key: document.getElementById('prov-key').value,
timeout_sec: parseInt(document.getElementById('prov-timeout').value) || 120,
max_retries: parseInt(document.getElementById('prov-retries').value) || 3,
};
var saveName = name || data.name;
if (!saveName || !data.base_url) { alert('名称和 Base URL 为必填项'); return; }
var result = await api('/api/model-config/providers/' + encodeURIComponent(saveName), { method: 'POST', body: JSON.stringify(data) });
if (result.error) { alert('保存失败: ' + result.error); return; }
STATE.modelConfigTab = 'providers';
renderModelConfigPanel();
}
async function deleteModelProvider(name) {
if (!confirm('确定删除 Provider "' + name + '"?\n注意:关联的模型和路由也会受影响。')) return;
var result = await api('/api/model-config/providers/' + encodeURIComponent(name), { method: 'DELETE' });
if (result.error) { alert('删除失败: ' + result.error); return; }
renderProvidersTab();
}
// ---- Models tab ----
async function renderModelsTab() {
var container = document.getElementById('model-config-content');
var data = await api('/api/model-config/models');
STATE.modelConfigModels = data.models || [];
var rows = STATE.modelConfigModels.length === 0
? '<tr><td colspan="6"><div class="empty-state"><div class="icon">🧠</div>暂无模型定义,请添加</div></td></tr>'
: STATE.modelConfigModels.map(function(m) {
var enabledBadge = m.enabled !== false ? '<span class="badge badge-running">启用</span>' : '<span class="badge badge-stopped">禁用</span>';
var tags = (m.tags && m.tags.length > 0) ? m.tags.join(', ') : '—';
var updated = m.updated_at ? timeAgo(m.updated_at) : '—';
return '<tr>' +
'<td><strong>' + escHtml(m.id) + '</strong></td>' +
'<td>' + escHtml(m.name) + '</td>' +
'<td>' + escHtml(m.provider) + '</td>' +
'<td>' + enabledBadge + '</td>' +
'<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(tags) + '</td>' +
'<td><div class="btn-group">' +
'<button class="btn btn-xs" onclick="showModelForm(\'' + escHtml(m.id) + '\')">✏️</button>' +
'<button class="btn btn-xs btn-red" onclick="deleteModelConfig(\'' + escHtml(m.id) + '\')">🗑</button>' +
'</div></td></tr>';
}).join('');
container.innerHTML =
'<div class="card-header"><span class="card-title">🧠 模型定义</span>' +
'<button class="btn btn-sm btn-accent" onclick="showModelForm()"> 添加</button></div>' +
'<div class="table-wrap"><table><thead><tr>' +
'<th>ID</th><th>模型名</th><th>Provider</th><th>状态</th><th>标签</th><th>操作</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function showModelForm(id) {
var existing = null;
if (id) {
for (var i = 0; i < STATE.modelConfigModels.length; i++) {
if (STATE.modelConfigModels[i].id === id) { existing = STATE.modelConfigModels[i]; break; }
}
}
var isEdit = !!existing;
var formTitle = isEdit ? '✏️ 编辑模型 ' + escHtml(id) : ' 添加模型';
var defaults = existing || { id: '', name: '', provider: '', description: '', priority: 0, tags: [], params: {}, enabled: true };
var tagsStr = (defaults.tags && defaults.tags.length > 0) ? defaults.tags.join(', ') : '';
var paramsStr = JSON.stringify(defaults.params || {}, null, 2);
var container = document.getElementById('model-config-content');
container.innerHTML =
'<div class="card-header"><span class="card-title">' + formTitle + '</span>' +
'<button class="btn btn-sm" onclick="renderModelsTab()">← 返回</button></div>' +
'<div class="card-body"><form onsubmit="event.preventDefault();saveModelForm(\'' + escHtml(id || '') + '\');">' +
'<div class="form-row"><label>模型 ID ' + (isEdit ? '' : '<span style="color:var(--red)">*</span>') + '</label>' +
'<input id="model-id" class="input" value="' + escHtml(defaults.id) + '" ' + (isEdit ? 'readonly' : 'placeholder="如 primary_chat"') + ' required></div>' +
'<div class="form-row"><label>模型名称 <span style="color:var(--red)">*</span></label>' +
'<input id="model-name" class="input" value="' + escHtml(defaults.name) + '" placeholder="deepseek-v4-flash" required></div>' +
'<div class="form-row"><label>Provider <span style="color:var(--red)">*</span></label>' +
'<select id="model-provider" class="input" required onchange="updateModelTemplateOptions()"><option value="">-- 选择 Provider --</option>' +
STATE.modelConfigProviders.map(function(p) {
return '<option value="' + escHtml(p.name) + '"' + (defaults.provider === p.name ? ' selected' : '') + '>' + escHtml(p.name) + '</option>';
}).join('') + '</select></div>' +
(isEdit ? '' :
'<div class="form-row"><label>📋 快速模板</label>' +
'<div style="display:flex;gap:8px">' +
'<div id="model-template-area" style="flex:1"><select id="model-template" class="input" onchange="applyModelTemplate(this.value)" style="background:var(--bg3)">' +
'<option value="">-- 选择模型模板 / 查询获取 --</option></select></div>' +
'<button type="button" class="btn btn-sm" id="btn-fetch-models" onclick="fetchProviderModels()" style="white-space:nowrap" disabled>🔍 查询</button></div>' +
'<div style="font-size:11px;color:var(--text3);margin-top:4px">选择 Provider 后可用模板或点击查询在线获取模型列表</div></div>') +
'<div class="form-row"><label>描述</label>' +
'<input id="model-desc" class="input" value="' + escHtml(defaults.description || '') + '" placeholder="用于日常对话的模型"></div>' +
'<div class="form-row" style="display:flex;gap:12px"><div style="flex:1"><label>优先级</label>' +
'<input id="model-priority" class="input" type="number" value="' + (defaults.priority || 0) + '"></div>' +
'<div style="flex:1;display:flex;align-items:flex-end;padding-bottom:4px"><label style="display:flex;align-items:center;gap:6px;cursor:pointer">' +
'<input type="checkbox" id="model-enabled"' + (defaults.enabled !== false ? ' checked' : '') + '> 启用</label></div></div>' +
'<div class="form-row"><label>标签 (逗号分隔)</label>' +
'<input id="model-tags" class="input" value="' + escHtml(tagsStr) + '" placeholder="chat, fast"></div>' +
'<div class="form-row"><label>模型参数 (JSON)</label>' +
'<textarea id="model-params" class="input" rows="3" style="font-family:monospace;font-size:12px">' + escHtml(paramsStr) + '</textarea></div>' +
'<div style="margin-top:14px"><button type="submit" class="btn btn-accent">💾 保存</button></div>' +
'</form></div>';
}
async function saveModelForm(id) {
var tagsStr = document.getElementById('model-tags').value.trim();
var tags = tagsStr ? tagsStr.split(',').map(function(t) { return t.trim(); }).filter(Boolean) : [];
var paramsStr = document.getElementById('model-params').value.trim();
var params = {};
try { if (paramsStr) params = JSON.parse(paramsStr); } catch(e) { alert('模型参数 JSON 格式错误: ' + e.message); return; }
var data = {
id: document.getElementById('model-id').value.trim(),
name: document.getElementById('model-name').value.trim(),
provider: document.getElementById('model-provider').value,
description: document.getElementById('model-desc').value.trim(),
priority: parseInt(document.getElementById('model-priority').value) || 0,
enabled: document.getElementById('model-enabled').checked,
tags: tags,
params: params,
};
var saveId = id || data.id;
if (!saveId || !data.name || !data.provider) { alert('模型 ID、名称和 Provider 为必填项'); return; }
var result = await api('/api/model-config/models/' + encodeURIComponent(saveId), { method: 'POST', body: JSON.stringify(data) });
if (result.error) { alert('保存失败: ' + result.error); return; }
STATE.modelConfigTab = 'models';
renderModelConfigPanel();
}
async function deleteModelConfig(id) {
if (!confirm('确定删除模型 "' + id + '"?')) return;
var result = await api('/api/model-config/models/' + encodeURIComponent(id), { method: 'DELETE' });
if (result.error) { alert('删除失败: ' + result.error); return; }
renderModelsTab();
}
// ---- Routing tab ----
async function renderRoutingTab() {
var container = document.getElementById('model-config-content');
var data = await api('/api/model-config/routing');
STATE.modelConfigRouting = data.routing || [];
var rows = STATE.modelConfigRouting.length === 0
? '<tr><td colspan="4"><div class="empty-state"><div class="icon">🔀</div>暂无路由规则,请添加</div></td></tr>'
: STATE.modelConfigRouting.map(function(r) {
var chain = (r.fallback_chain && r.fallback_chain.length > 0) ? r.fallback_chain.join(' → ') : '—';
var requiredBadge = r.required ? '<span class="badge badge-running">必需</span>' : '<span class="badge badge-stopped">可选</span>';
return '<tr>' +
'<td><strong>' + escHtml(r.purpose) + '</strong></td>' +
'<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(chain) + '</td>' +
'<td>' + requiredBadge + '</td>' +
'<td><div class="btn-group">' +
'<button class="btn btn-xs" onclick="showRoutingForm(\'' + escHtml(r.purpose) + '\')">✏️</button>' +
'<button class="btn btn-xs btn-red" onclick="deleteRoutingRule(\'' + escHtml(r.purpose) + '\')">🗑</button>' +
'</div></td></tr>';
}).join('');
container.innerHTML =
'<div class="card-header"><span class="card-title">🔀 用途路由</span>' +
'<button class="btn btn-sm btn-accent" onclick="showRoutingForm()"> 添加</button></div>' +
'<div class="table-wrap"><table><thead><tr>' +
'<th>用途</th><th>回退链</th><th>必需性</th><th>操作</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function showRoutingForm(purpose) {
var existing = null;
if (purpose) {
for (var i = 0; i < STATE.modelConfigRouting.length; i++) {
if (STATE.modelConfigRouting[i].purpose === purpose) { existing = STATE.modelConfigRouting[i]; break; }
}
}
var isEdit = !!existing;
var formTitle = isEdit ? '✏️ 编辑路由 ' + escHtml(purpose) : ' 添加路由';
var defaults = existing || { purpose: '', fallback_chain: [], required: false };
var existingChain = defaults.fallback_chain || [];
var models = STATE.modelConfigModels;
var modelCheckboxes = '';
if (models.length === 0) {
modelCheckboxes = '<div class="empty-state" style="padding:16px"><div class="icon">🧠</div>暂无模型定义,请先在「模型定义」标签中添加模型</div>';
} else {
modelCheckboxes = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:6px;max-height:260px;overflow-y:auto;padding:4px 0">' +
models.map(function(m) {
var checked = existingChain.indexOf(m.id) >= 0 ? ' checked' : '';
var providerLabel = m.provider ? ' <span style="color:var(--text3);font-size:11px">(' + escHtml(m.provider) + ')</span>' : '';
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg3);border-radius:var(--radius-sm);cursor:pointer;font-size:12px;transition:background .15s" onmouseenter="this.style.background=\'var(--bg4)\'" onmouseleave="this.style.background=\'var(--bg3)\'">' +
'<input type="checkbox" name="routing-model" value="' + escHtml(m.id) + '"' + checked + ' style="accent-color:var(--accent)">' +
'<span style="flex:1"><strong>' + escHtml(m.name || m.id) + '</strong>' + providerLabel + '</span>' +
'</label>';
}).join('') + '</div>';
}
var container = document.getElementById('model-config-content');
container.innerHTML =
'<div class="card-header"><span class="card-title">' + formTitle + '</span>' +
'<button class="btn btn-sm" onclick="renderRoutingTab()">← 返回</button></div>' +
'<div class="card-body"><form onsubmit="event.preventDefault();saveRoutingForm(\'' + escHtml(purpose || '') + '\');">' +
'<div class="form-row"><label>用途 ID ' + (isEdit ? '' : '<span style="color:var(--red)">*</span>') + '</label>' +
'<select id="routing-purpose" class="input" ' + (isEdit ? 'disabled' : 'required') + '>' +
'<option value="">-- 选择用途 --</option>' +
'<option value="chat"' + (defaults.purpose === 'chat' ? ' selected' : '') + '>chat (日常对话)</option>' +
'<option value="deep_thinking"' + (defaults.purpose === 'deep_thinking' ? ' selected' : '') + '>deep_thinking (深度思考)</option>' +
'<option value="intent_analysis"' + (defaults.purpose === 'intent_analysis' ? ' selected' : '') + '>intent_analysis (意图分析)</option>' +
'<option value="tool_calling"' + (defaults.purpose === 'tool_calling' ? ' selected' : '') + '>tool_calling (工具调用)</option>' +
'<option value="memory_extraction"' + (defaults.purpose === 'memory_extraction' ? ' selected' : '') + '>memory_extraction (记忆提取)</option>' +
'</select></div>' +
'<div class="form-row"><label>回退模型链 <span style="color:var(--text2);font-weight:400">(勾选即加入,顺序=表格显示顺序)</span></label>' +
(models.length > 0 ? '<div class="btn-group" style="margin-bottom:8px">' +
'<button type="button" class="btn btn-xs" onclick="var cbs=document.querySelectorAll(\'input[name=routing-model]\');cbs.forEach(function(c){c.checked=true})">全选</button>' +
'<button type="button" class="btn btn-xs" onclick="var cbs=document.querySelectorAll(\'input[name=routing-model]\');cbs.forEach(function(c){c.checked=false})">取消全选</button>' +
'</div>' : '') +
modelCheckboxes + '</div>' +
'<div class="form-row"><label style="display:flex;align-items:center;gap:6px;cursor:pointer">' +
'<input type="checkbox" id="routing-required"' + (defaults.required ? ' checked' : '') + '> 必需 (所有模型不可用时返回错误,而非回退到 .env)</label></div>' +
'<div style="margin-top:14px"><button type="submit" class="btn btn-accent">💾 保存</button></div>' +
'</form></div>';
}
async function saveRoutingForm(purpose) {
// 收集所有勾选的模型 (按 DOM 顺序 = 表格显示顺序)
var checkedCbs = document.querySelectorAll('input[name="routing-model"]:checked');
var chain = [];
for (var i = 0; i < checkedCbs.length; i++) {
chain.push(checkedCbs[i].value);
}
var data = {
purpose: purpose || document.getElementById('routing-purpose').value,
fallback_chain: chain,
required: document.getElementById('routing-required').checked,
};
if (!data.purpose) { alert('请选择用途'); return; }
if (chain.length === 0) { alert('回退模型链不能为空'); return; }
var result = await api('/api/model-config/routing/' + encodeURIComponent(data.purpose), { method: 'POST', body: JSON.stringify(data) });
if (result.error) { alert('保存失败: ' + result.error); return; }
STATE.modelConfigTab = 'routing';
renderModelConfigPanel();
}
async function deleteRoutingRule(purpose) {
if (!confirm('确定删除路由 "' + purpose + '"?')) return;
var result = await api('/api/model-config/routing/' + encodeURIComponent(purpose), { method: 'DELETE' });
if (result.error) { alert('删除失败: ' + result.error); return; }
renderRoutingTab();
}
// ========== 客户端管理面板 ==========
function renderClientsPanel() {
var panel = document.getElementById('panel-clients');
panel.innerHTML =
'<div class="card">' +
'<div class="card-header">' +
'<span class="card-title">📱 已连接设备</span>' +
'<div class="btn-group">' +
'<button class="btn btn-sm" onclick="loadClients()">🔄 刷新</button>' +
'</div>' +
'</div>' +
'<div id="clients-online"></div>' +
'</div>' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">📋 历史设备</span></div>' +
'<div id="clients-offline"></div>' +
'</div>' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">💡 跨端说明</span></div>' +
'<div style="font-size:12px;color:var(--text2);line-height:1.8">' +
'<p>每个浏览器/设备首次连接时会分配唯一的 <b>Client ID</b>(存储在浏览器 localStorage)。</p>' +
'<p>后续所有消息都会携带此 ID,昔涟可据此判断用户当前使用的设备。</p>' +
'<p><b>设备名称</b> 自动从 User-Agent 推断,你也可以在下方为设备添加备注。</p>' +
'<p>在线设备 = 当前 WebSocket 连接已建立;离线设备 = 曾连接过但当前断开。</p>' +
'</div>' +
'</div>';
loadClients();
}
async function loadClients() {
var data = await api('/api/clients');
var onlineDiv = document.getElementById('clients-online');
var offlineDiv = document.getElementById('clients-offline');
if (!onlineDiv || !offlineDiv) return;
if (data.error) {
onlineDiv.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '<br><span style="font-size:11px">Gateway 服务可能未启动</span></div>';
offlineDiv.innerHTML = '';
return;
}
var clients = data.clients || [];
var onlineClients = clients.filter(function(c) { return c.online; });
var offlineClients = clients.filter(function(c) { return !c.online; });
// Update badge
var badge = document.getElementById('clients-badge');
if (badge) {
var onlineCount = onlineClients.length;
if (onlineCount > 0) {
badge.textContent = onlineCount;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
}
if (clients.length === 0) {
onlineDiv.innerHTML = '<div class="empty-state"><div class="icon">📱</div>暂无已连接的客户端</div>';
offlineDiv.innerHTML = '<div class="empty-state"><div class="icon">📋</div>暂无历史设备记录</div>';
return;
}
onlineDiv.innerHTML = onlineClients.length > 0
? '<div class="table-wrap"><table>' +
'<thead><tr><th>状态</th><th>设备名</th><th>Client ID</th><th>备注</th><th>首次连接</th><th>最后活跃</th><th>操作</th></tr></thead>' +
'<tbody>' + onlineClients.map(function(c) { return clientRowHTML(c); }).join('') + '</tbody>' +
'</table></div>'
: '<div class="empty-state"><div class="icon">📱</div>暂无在线客户端</div>';
offlineDiv.innerHTML = offlineClients.length > 0
? '<div class="table-wrap"><table>' +
'<thead><tr><th>状态</th><th>设备名</th><th>Client ID</th><th>备注</th><th>首次连接</th><th>最后活跃</th><th>操作</th></tr></thead>' +
'<tbody>' + offlineClients.map(function(c) { return clientRowHTML(c); }).join('') + '</tbody>' +
'</table></div>'
: '<div class="empty-state"><div class="icon">📋</div>暂无历史离线设备</div>';
}
function clientRowHTML(c) {
var statusClass = c.online ? 'badge-running' : 'badge-stopped';
var statusText = c.online ? '在线' : '离线';
return '<tr>' +
'<td><span class="badge ' + statusClass + '">' + statusText + '</span></td>' +
'<td><strong>' + escHtml(c.device_name || '未知设备') + '</strong></td>' +
'<td style="font-family:\'JetBrains Mono\',monospace;font-size:11px;color:var(--text2)">' + escHtml(c.client_id || '') + '</td>' +
'<td>' +
'<span id="client-note-' + escHtml(c.client_id || '') + '" style="cursor:pointer;color:var(--accent)" title="点击编辑备注" onclick="editClientNote(\'' + escHtml(c.client_id || '') + '\')">' +
(c.note ? escHtml(c.note) : '<span style="color:var(--text3);font-style:italic">点击添加备注</span>') +
'</span>' +
'</td>' +
'<td style="font-size:11px;color:var(--text3)">' + formatTime(c.first_seen_at) + '</td>' +
'<td style="font-size:11px;color:var(--text3)">' + formatTime(c.last_seen_at) + '</td>' +
'<td><button class="btn btn-xs" onclick="editClientNote(\'' + escHtml(c.client_id || '') + '\')">✏️ 备注</button></td>' +
'</tr>';
}
async function editClientNote(clientID) {
var currentNote = document.getElementById('client-note-' + clientID);
var oldNote = (currentNote && currentNote.textContent !== '点击添加备注') ? currentNote.textContent : '';
var note = prompt('为设备 ' + clientID + ' 输入备注:', oldNote);
if (note === null) return; // cancelled
var resp = await api('/api/clients/' + encodeURIComponent(clientID) + '/note', {
method: 'PUT',
body: JSON.stringify({ note: note }),
});
if (resp.error) {
showToast('更新备注失败: ' + resp.error, 'error');
} else {
showToast('备注已更新', 'success');
loadClients();
}
}
</script>
<script src="iot-panel.js"></script>
<script>
// ========== 初始化 ==========
// Listen for browser back/forward navigation.
window.addEventListener('hashchange', function() {
var hash = location.hash.replace('#', '');
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig'];
if (hash && validPanels.indexOf(hash) >= 0 && hash !== STATE.activePanel) {
switchPanel(hash);
}
});
connectWS();
refreshStatus();
renderDashboard();
// Restore last panel from URL hash, or default to dashboard.
var initHash = location.hash.replace('#', '');
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig'];
if (initHash && validPanels.indexOf(initHash) >= 0) {
switchPanel(initHash);
} else {
switchPanel('dashboard');
location.hash = '#dashboard';
}
// 全局状态定时刷新
STATE.statusInterval = setInterval(refreshStatus, 5000);
+1 -1
View File
@@ -160,7 +160,7 @@ export const SERVICES = {
cwd: path.join(ROOT, 'backend/plugin-manager'),
command: './main',
env: {
PLUGIN_MANAGER_PORT: '8094',
PORT: '8094',
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
},
healthUrl: 'http://localhost:8094/api/v1/health',
+177
View File
@@ -21,6 +21,7 @@ import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, TOOL_ENGINE_UR
const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8091';
const VOICE_SERVICE_URL = process.env.VOICE_SERVICE_URL || 'http://localhost:8093';
const PLATFORM_BRIDGE_URL = process.env.PLATFORM_BRIDGE_URL || 'http://localhost:8095';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -596,6 +597,182 @@ async function proxyToToolEngine(path, opts = {}) {
}
}
// ---- 第三方聊天平台配置代理 (转发到 platform-bridge) ----
/**
* 代理请求到 Platform-Bridge
* @param {string} path - Platform-Bridge API 路径
* @param {object} opts - fetch 选项
*/
async function proxyToPlatformBridge(path, opts = {}) {
const url = `${PLATFORM_BRIDGE_URL}${path}`;
const logPrefix = `[PlatformBridge代理]`;
try {
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
const resp = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...opts.headers },
signal: AbortSignal.timeout(10000),
});
const body = await resp.json().catch(() => null);
if (!resp.ok) {
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
}
return { status: resp.status, body };
} catch (err) {
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
return {
status: 502,
body: {
error: `Platform-Bridge 不可达: ${err.message}`,
errorType: isConnRefused ? 'bridge_not_running' : 'bridge_unreachable',
hint: isConnRefused
? 'Platform-Bridge 服务未启动,请先在「服务管理」面板中启动该服务'
: 'Platform-Bridge 服务无响应,请检查网络连接和服务状态',
},
};
}
}
// GET /api/chat-platforms/configs — 列出所有平台配置
app.get('/api/chat-platforms/configs', async (_req, res) => {
const result = await proxyToPlatformBridge('/api/v1/configs');
res.status(result.status).json(result.body);
});
// GET /api/chat-platforms/configs/:name — 获取单个配置
app.get('/api/chat-platforms/configs/:name', async (req, res) => {
const result = await proxyToPlatformBridge(`/api/v1/configs/${req.params.name}`);
res.status(result.status).json(result.body);
});
// POST /api/chat-platforms/configs/:name — 创建或更新配置
app.post('/api/chat-platforms/configs/:name', async (req, res) => {
const result = await proxyToPlatformBridge(`/api/v1/configs/${req.params.name}`, {
method: 'POST',
body: JSON.stringify(req.body),
});
res.status(result.status).json(result.body);
});
// DELETE /api/chat-platforms/configs/:name — 删除配置
app.delete('/api/chat-platforms/configs/:name', async (req, res) => {
const result = await proxyToPlatformBridge(`/api/v1/configs/${req.params.name}`, {
method: 'DELETE',
});
res.status(result.status).json(result.body);
});
// GET /api/chat-platforms/logs/:name — 获取消息日志
app.get('/api/chat-platforms/logs/:name', async (req, res) => {
const limit = req.query.limit || '100';
const result = await proxyToPlatformBridge(`/api/v1/logs/${req.params.name}?limit=${limit}`);
res.status(result.status).json(result.body);
});
// ---- 多端客户端管理代理 (转发到 Gateway) ----
// GET /api/clients — 获取已知客户端列表
app.get('/api/clients', async (req, res) => {
const userID = req.query.user_id || 'admin';
const result = await proxyToGateway(`/api/v1/admin/clients?user_id=${encodeURIComponent(userID)}`);
res.status(result.status).json(result.body);
});
// PUT /api/clients/:id/note — 更新客户端备注
app.put('/api/clients/:id/note', async (req, res) => {
const { note } = req.body;
const result = await proxyToGateway(`/api/v1/admin/clients/${req.params.id}/note`, {
method: 'PUT',
body: JSON.stringify({ note }),
});
res.status(result.status).json(result.body);
});
// ---- 模型配置管理代理 (转发到 Gateway admin) ----
// Providers
app.get('/api/model-config/providers', async (_req, res) => {
const result = await proxyToGateway('/api/v1/admin/models/providers');
res.status(result.status).json(result.body);
});
app.get('/api/model-config/providers/:name', async (req, res) => {
const result = await proxyToGateway(`/api/v1/admin/models/providers/${req.params.name}`);
res.status(result.status).json(result.body);
});
app.post('/api/model-config/providers/:name', async (req, res) => {
const result = await proxyToGateway(`/api/v1/admin/models/providers/${req.params.name}`, {
method: 'POST', body: JSON.stringify(req.body),
});
res.status(result.status).json(result.body);
});
app.delete('/api/model-config/providers/:name', async (req, res) => {
const result = await proxyToGateway(`/api/v1/admin/models/providers/${req.params.name}`, {
method: 'DELETE',
});
res.status(result.status).json(result.body);
});
// Models
app.get('/api/model-config/models', async (_req, res) => {
const result = await proxyToGateway('/api/v1/admin/models/models');
res.status(result.status).json(result.body);
});
app.get('/api/model-config/models/:id', async (req, res) => {
const result = await proxyToGateway(`/api/v1/admin/models/models/${req.params.id}`);
res.status(result.status).json(result.body);
});
app.post('/api/model-config/models/:id', async (req, res) => {
const result = await proxyToGateway(`/api/v1/admin/models/models/${req.params.id}`, {
method: 'POST', body: JSON.stringify(req.body),
});
res.status(result.status).json(result.body);
});
app.delete('/api/model-config/models/:id', async (req, res) => {
const result = await proxyToGateway(`/api/v1/admin/models/models/${req.params.id}`, {
method: 'DELETE',
});
res.status(result.status).json(result.body);
});
// Routing
app.get('/api/model-config/routing', async (_req, res) => {
const result = await proxyToGateway('/api/v1/admin/models/routing');
res.status(result.status).json(result.body);
});
app.get('/api/model-config/routing/:purpose', async (req, res) => {
const result = await proxyToGateway(`/api/v1/admin/models/routing/${req.params.purpose}`);
res.status(result.status).json(result.body);
});
app.post('/api/model-config/routing/:purpose', async (req, res) => {
const result = await proxyToGateway(`/api/v1/admin/models/routing/${req.params.purpose}`, {
method: 'POST', body: JSON.stringify(req.body),
});
res.status(result.status).json(result.body);
});
app.delete('/api/model-config/routing/:purpose', async (req, res) => {
const result = await proxyToGateway(`/api/v1/admin/models/routing/${req.params.purpose}`, {
method: 'DELETE',
});
res.status(result.status).json(result.body);
});
// Health check
app.post('/api/model-config/health-check', async (req, res) => {
const result = await proxyToGateway('/api/v1/admin/models/health-check', {
method: 'POST', body: JSON.stringify(req.body),
});
res.status(result.status).json(result.body);
});
// GET /api/model-config/fetch-models/:name?url=... — 代理查询 Provider 模型列表
app.get('/api/model-config/fetch-models/:name', async (req, res) => {
const urlParam = req.query.url ? '?url=' + encodeURIComponent(req.query.url) : '';
const result = await proxyToGateway('/api/v1/admin/models/fetch-models/' + encodeURIComponent(req.params.name) + urlParam);
res.status(result.status).json(result.body);
});
// GET /api/tool-calls — 查询工具调用记录
app.get('/api/tool-calls', async (req, res) => {
const { tool_name, page, limit } = req.query;