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:
+888
-2
@@ -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("model-search-input");if(s)s.value="";renderFetchedModelList(STATE.fetchedModels,"");" 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("data-model");selectFetchedModel(mn);"' +
|
||||
' style="padding:6px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border);transition:background .12s"' +
|
||||
' onmouseenter="this.style.background="var(--bg3)""' +
|
||||
' onmouseleave="this.style.background=""">' + 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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user