diff --git a/webapp/static/css/app.css b/webapp/static/css/app.css index 22f7886..5880a9c 100644 --- a/webapp/static/css/app.css +++ b/webapp/static/css/app.css @@ -265,3 +265,39 @@ table.group-table td { border-bottom: 1px solid #f1f5f9; font-variant-numeric: t .sidebar { width: 64px; } .brand-sub, .nav-item span:not(.nav-ico), .sidebar-foot span:last-child { display: none; } } + +/* ---------- LLM 配置管理页 ---------- */ +.profile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; } +.profile-card { + background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); + padding: 16px; box-shadow: var(--shadow); +} +.profile-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } +.profile-card-name { font-size: 15px; font-weight: 600; } +.profile-card-actions { display: flex; gap: 6px; } +.profile-card-field { font-size: 12px; color: var(--slate); margin-top: 4px; } +.field-label { font-weight: 600; color: var(--ink); } + +/* Form */ +.profile-form { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; max-width: 560px; } +.form-row { display: flex; flex-direction: column; gap: 4px; } +.form-label { font-size: 13px; font-weight: 600; } +.req { color: var(--bad); } +.form-input { + border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; + font-size: 13px; font-family: inherit; width: 100%; +} +.form-input:focus { outline: none; border-color: var(--petrol); } +.form-input-sm { max-width: 120px; } +.form-actions { display: flex; gap: 10px; align-items: center; margin-top: 4px; } +.form-error { font-size: 12px; color: var(--bad); } +.btn-sm { padding: 4px 10px; font-size: 12px; } +.btn-danger { color: var(--bad); border-color: var(--bad); } +.btn-danger:hover { background: #fee2e2; } + +/* ---------- LLM 角色配置面板 ---------- */ +.llm-assignment-panel { border-left: 3px solid var(--petrol); } +.llm-role-rows { display: flex; flex-direction: column; gap: 10px; } +.llm-role-row { display: flex; align-items: center; gap: 14px; } +.llm-role-label { font-size: 13px; font-weight: 600; min-width: 180px; color: var(--ink); } +.llm-role-select { min-width: 240px; } diff --git a/webapp/static/index.html b/webapp/static/index.html index c270cbb..4899e87 100644 --- a/webapp/static/index.html +++ b/webapp/static/index.html @@ -25,6 +25,9 @@ + + + + + + diff --git a/webapp/static/js/api.js b/webapp/static/js/api.js index 28fcca2..caa4f15 100644 --- a/webapp/static/js/api.js +++ b/webapp/static/js/api.js @@ -43,4 +43,26 @@ const API = { return API.post("/api/evaluations", { scenario_path: scenarioPath }); }, taskStatus(taskId) { return API.get(`/api/evaluations/${encodeURIComponent(taskId)}`); }, + + // LLM Profile API + profiles() { return API.get("/api/llm-profiles"); }, + createProfile(body) { return API.post("/api/llm-profiles", body); }, + updateProfile(id, body) { + return fetch(`/api/llm-profiles/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).then(async r => { + if (!r.ok) { const d = await API._extractError(r); throw new Error(d); } + return r.json(); + }); + }, + deleteProfile(id) { + return fetch(`/api/llm-profiles/${encodeURIComponent(id)}`, { method: "DELETE" }) + .then(async r => { + if (!r.ok) { const d = await API._extractError(r); throw new Error(d); } + return r.json(); + }); + }, + applyProfiles(body) { return API.post("/api/llm-profiles/apply", body); }, }; diff --git a/webapp/static/js/app.js b/webapp/static/js/app.js index e36a66b..854c9f8 100644 --- a/webapp/static/js/app.js +++ b/webapp/static/js/app.js @@ -2,8 +2,8 @@ const App = { currentRunId: null, - views: ["runs", "new", "report"], - titles: { runs: "运行列表", new: "新建评估", report: "报告详情" }, + views: ["runs", "new", "report", "profiles"], + titles: { runs: "运行列表", new: "新建评估", report: "报告详情", profiles: "LLM 配置" }, // 初始化:绑定导航、加载首屏、启动健康检查。 init() { @@ -13,6 +13,7 @@ const App = { document.getElementById("refresh-btn").addEventListener("click", () => App.refreshCurrent()); Runner.init(); + Profiles.init(); App.switchView("runs"); App.checkHealth(); setInterval(App.checkHealth, 15000); @@ -36,6 +37,7 @@ const App = { if (view === "runs") App.loadRuns(); if (view === "new") Runner.loadScenarios(); if (view === "report") Report.render(App.currentRunId); + if (view === "profiles") Profiles.load(); }, // 刷新当前视图的数据。 diff --git a/webapp/static/js/profiles.js b/webapp/static/js/profiles.js new file mode 100644 index 0000000..83e5a53 --- /dev/null +++ b/webapp/static/js/profiles.js @@ -0,0 +1,118 @@ +// profiles.js — LLM 配置管理页面逻辑 + +const Profiles = { + _data: [], + + // 初始化:绑定按钮事件 + init() { + document.getElementById("add-profile-btn").addEventListener("click", () => Profiles.showForm()); + document.getElementById("save-profile-btn").addEventListener("click", () => Profiles.save()); + document.getElementById("cancel-profile-btn").addEventListener("click", () => Profiles.hideForm()); + }, + + // 加载并渲染 Profile 列表 + async load() { + const grid = document.getElementById("profile-cards"); + const empty = document.getElementById("profiles-empty"); + grid.innerHTML = '

加载中…

'; + try { + const data = await API.profiles(); + Profiles._data = data.profiles || []; + grid.innerHTML = ""; + if (Profiles._data.length === 0) { + empty.hidden = false; + } else { + empty.hidden = true; + Profiles._data.forEach(p => grid.appendChild(Profiles.renderCard(p))); + } + } catch (err) { + grid.innerHTML = `

加载失败:${App.escape(err.message)}

`; + } + }, + + // 渲染单个 Profile 卡片 + renderCard(p) { + const card = document.createElement("div"); + card.className = "profile-card"; + card.dataset.id = p.profile_id; + card.innerHTML = ` +
+
${App.escape(p.name)}
+
+ + +
+
+
模型 ${App.escape(p.model)}
+
Base URL ${App.escape(p.base_url)}
+
超时 ${p.timeout_seconds}s
+ `; + card.querySelector("[data-action=edit]").addEventListener("click", () => Profiles.showForm(p)); + card.querySelector("[data-action=delete]").addEventListener("click", () => Profiles.remove(p.profile_id, p.name)); + return card; + }, + + // 显示新建或编辑表单 + showForm(profile = null) { + const panel = document.getElementById("profile-form-panel"); + const title = document.getElementById("profile-form-title"); + panel.hidden = false; + title.textContent = profile ? "编辑 LLM 配置" : "新建 LLM 配置"; + document.getElementById("edit-profile-id").value = profile ? profile.profile_id : ""; + document.getElementById("pf-name").value = profile ? profile.name : ""; + document.getElementById("pf-model").value = profile ? profile.model : ""; + document.getElementById("pf-base-url").value = profile ? profile.base_url : ""; + document.getElementById("pf-api-key").value = profile ? profile.api_key : ""; + document.getElementById("pf-timeout").value = profile ? profile.timeout_seconds : 30; + document.getElementById("profile-form-error").textContent = ""; + panel.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + + hideForm() { + document.getElementById("profile-form-panel").hidden = true; + }, + + // 保存(新建 or 更新) + async save() { + const id = document.getElementById("edit-profile-id").value; + const body = { + name: document.getElementById("pf-name").value.trim(), + model: document.getElementById("pf-model").value.trim(), + base_url: document.getElementById("pf-base-url").value.trim(), + api_key: document.getElementById("pf-api-key").value.trim(), + timeout_seconds: parseInt(document.getElementById("pf-timeout").value, 10) || 30, + }; + const errEl = document.getElementById("profile-form-error"); + if (!body.name || !body.model || !body.base_url || !body.api_key) { + errEl.textContent = "请填写所有必填字段(名称、模型、Base URL、API Key)"; + return; + } + try { + if (id) { + await API.updateProfile(id, body); + } else { + await API.createProfile(body); + } + Profiles.hideForm(); + await Profiles.load(); + } catch (err) { + errEl.textContent = `保存失败:${err.message}`; + } + }, + + // 删除 Profile + async remove(profileId, name) { + if (!confirm(`确认删除配置「${name}」?`)) return; + try { + await API.deleteProfile(profileId); + await Profiles.load(); + } catch (err) { + alert(`删除失败:${err.message}`); + } + }, + + // 获取当前已加载的 profiles(供 runner.js 使用) + getAll() { + return Profiles._data; + }, +};