auto-save 2026-05-11 16:59 (~3)

This commit is contained in:
2026-05-11 16:59:20 +08:00
parent 48474dc4d6
commit 7a150ae74c
3 changed files with 621 additions and 54 deletions

View File

@@ -11,6 +11,7 @@ const LS_CUSTOM_SKILLS = "hermes-ui-custom-skills-v1";
const LS_FLOWS = "hermes-ui-flows-v1";
const LS_TAB = "hermes-ui-active-tab-v1";
const LS_WEEKLY_REPORTS = "hermes-ui-weekly-reports-v1";
const UI_CONFIG_ENDPOINT = "/feishu/ui-config";
const DEFAULT_MODEL_ID = "google/gemini-3.1-pro-preview";
const LEGACY_DEFAULT_MODEL_ID = "gemini-3-pro-preview";
@@ -30,6 +31,10 @@ const state = {
// 智能体
agents: {}, // {id: {id, emoji, name, desc, model, systemPrompt, createdAt}}
editingAgentId: null,
modelProfiles: [],
editingModelProfileId: null,
sharedConfigLoaded: false,
sharedConfigAvailable: false,
// 自定义 skill
customSkills: {}, // {id: {id, emoji, name, prompt, custom: true}}
@@ -54,22 +59,65 @@ const state = {
};
// ---------- 入口 ----------
function showBootIssue(error, source = "运行错误") {
const message = error?.message || String(error || "未知错误");
const detail = error?.stack || message;
const render = () => {
if (!document.body) return;
let box = document.getElementById("bootIssue");
if (!box) {
box = document.createElement("div");
box.id = "bootIssue";
box.className = "boot-issue";
document.body.appendChild(box);
}
box.innerHTML = `
<div class="boot-issue-title">${escapeHTML(source)}</div>
<div class="boot-issue-msg">${escapeHTML(message)}</div>
<button type="button" onclick="this.closest('.boot-issue')?.remove()">关闭</button>
<details><summary>详情</summary><pre>${escapeHTML(detail)}</pre></details>
`;
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", render, { once: true });
} else {
render();
}
}
function safeBoot(label, fn) {
try {
return fn();
} catch (error) {
console.error("[boot]", label, error);
showBootIssue(error, label);
return undefined;
}
}
window.addEventListener("error", (event) => showBootIssue(event.error || event.message, "页面脚本错误"));
window.addEventListener("unhandledrejection", (event) => showBootIssue(event.reason, "异步请求错误"));
document.addEventListener("DOMContentLoaded", () => {
loadTheme();
loadSettings();
loadCustomSkills();
loadFlows();
loadAgents();
loadConversations();
loadWeeklyReports();
bindTabs();
bindChat();
bindSearch();
bindStudio();
renderSidebar();
renderChat();
renderAgents();
restoreActiveTab();
safeBoot("加载主题", loadTheme);
safeBoot("加载本地设置", loadSettings);
safeBoot("加载自定义技能", loadCustomSkills);
safeBoot("加载本地编排", loadFlows);
safeBoot("加载本地智能体", loadAgents);
safeBoot("加载本地对话", loadConversations);
safeBoot("加载周报记录", loadWeeklyReports);
safeBoot("绑定导航", bindTabs);
safeBoot("绑定对话", bindChat);
safeBoot("绑定搜索", bindSearch);
safeBoot("绑定 Skill Studio", bindStudio);
safeBoot("渲染侧栏", renderSidebar);
safeBoot("渲染对话", renderChat);
safeBoot("渲染智能体", renderAgents);
safeBoot("恢复页面", restoreActiveTab);
refreshUiConfig({ migrateLocalAgents: true }).catch((error) => {
console.warn("[ui-config] fallback to local config", error);
setSharedConfigStatus("共享配置读取失败,本机仍可临时使用: " + (error.message || error), true);
});
pingBackend();
fetchIP();
setInterval(pingBackend, 30000);
@@ -178,6 +226,135 @@ function syncModelPick(modelValue, providerValue = "") {
updateModelDisplay(model, providerValue);
}
function makeId(prefix) {
return prefix + "_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
function normalizeModelProfile(raw = {}) {
const model = (raw.model || raw.default || "").trim();
return {
id: raw.id || makeId("model"),
name: (raw.name || model || "模型接入").trim(),
provider: (raw.provider || "openrouter").trim(),
model,
baseUrl: (raw.baseUrl || raw.base_url || "").trim(),
apiKeyRef: (raw.apiKeyRef || raw.api_key_ref || "").trim(),
enabled: raw.enabled !== false,
isDefault: !!raw.isDefault,
createdAt: Number(raw.createdAt || Date.now()),
updatedAt: Number(raw.updatedAt || Date.now()),
};
}
function defaultModelProfile() {
const model = state.model || DEFAULT_MODEL_ID;
return normalizeModelProfile({
id: "runtime-default",
name: "线上默认模型",
provider: "openrouter",
model,
apiKeyRef: "服务器环境变量",
enabled: true,
isDefault: true,
});
}
function normalizeModelProfiles(list) {
const seen = new Set();
const profiles = (Array.isArray(list) ? list : [])
.map(normalizeModelProfile)
.filter(profile => profile.model && !seen.has(profile.id) && seen.add(profile.id));
if (!profiles.length) profiles.push(defaultModelProfile());
if (!profiles.some(profile => profile.isDefault)) profiles[0].isDefault = true;
let defaultSeen = false;
for (const profile of profiles) {
if (profile.isDefault && !defaultSeen) defaultSeen = true;
else profile.isDefault = false;
}
return profiles;
}
function activeModelProfile() {
return state.modelProfiles.find(profile => profile.isDefault && profile.enabled)
|| state.modelProfiles.find(profile => profile.enabled)
|| state.modelProfiles[0]
|| defaultModelProfile();
}
function modelProfileById(id) {
return state.modelProfiles.find(profile => profile.id === id) || null;
}
function modelForAgent(agent) {
const profile = modelProfileById(agent?.modelProfileId);
return profile?.model || agent?.model || state.model || DEFAULT_MODEL_ID;
}
function modelLabelForAgent(agent) {
const profile = modelProfileById(agent?.modelProfileId);
if (profile) return profile.name + " · " + profile.model;
return agent?.model || state.model || DEFAULT_MODEL_ID;
}
function syncModelOptionsFromProfiles() {
for (const profile of state.modelProfiles) {
ensureModelChoice(profile.model, profile.name || profile.model);
}
const active = activeModelProfile();
if (active?.model) {
syncModelPick(active.model, active.provider);
}
}
function upsertRuntimeModelProfile(model) {
if (!model?.default && !model?.model) return;
const modelId = model.default || model.model;
const existing = state.modelProfiles.find(profile => profile.isDefault)
|| state.modelProfiles.find(profile => profile.id === "runtime-default");
const next = normalizeModelProfile({
...(existing || {}),
id: existing?.id || "runtime-default",
name: existing?.name || "线上默认模型",
provider: model.provider || existing?.provider || "openrouter",
model: modelId,
baseUrl: model.base_url || model.baseUrl || existing?.baseUrl || "",
apiKeyRef: existing?.apiKeyRef || "服务器环境变量",
enabled: true,
isDefault: true,
updatedAt: Date.now(),
});
state.modelProfiles = normalizeModelProfiles([
next,
...state.modelProfiles.filter(profile => profile.id !== next.id).map(profile => ({ ...profile, isDefault: false })),
]);
syncModelOptionsFromProfiles();
renderModelProfiles();
}
function renderAgentModelProfileOptions(selectedId = "") {
const select = document.getElementById("agentModelProfile");
if (!select) return;
const profiles = normalizeModelProfiles(state.modelProfiles);
select.innerHTML = '<option value="">跟随对话默认 / 仅使用模型 ID</option>';
for (const profile of profiles) {
const option = document.createElement("option");
option.value = profile.id;
option.textContent = `${profile.name} · ${profile.model}`;
select.appendChild(option);
}
select.value = selectedId && profiles.some(profile => profile.id === selectedId) ? selectedId : "";
}
function applyAgentModelProfileSelection() {
const select = document.getElementById("agentModelProfile");
const input = document.getElementById("agentModel");
const profile = modelProfileById(select?.value || "");
if (profile?.model && input) {
input.value = profile.model;
ensureModelChoice(profile.model, profile.name || profile.model);
}
}
// ---------- 会话持久化 ----------
function loadConversations() {
try {
@@ -679,8 +856,12 @@ async function refreshHermesConfig(force = false) {
},
mcp_servers_yaml: config.mcp_servers_yaml || "",
};
if (model.default) syncModelPick(model.default, model.provider || "");
else updateModelDisplay(state.model, model.provider || "");
if (model.default) {
syncModelPick(model.default, model.provider || "");
if (!state.modelProfiles.length) upsertRuntimeModelProfile(model);
} else {
updateModelDisplay(state.model, model.provider || "");
}
_hermesConfigLoaded = true;
const suffix = config.lxc ? " · " + config.lxc : "";
setHermesModelStatus("已读取模型配置" + suffix);
@@ -723,6 +904,128 @@ async function postHermesRuntimeConfig(payload) {
return data.config || {};
}
function setSharedConfigStatus(text, isError = false) {
setSettingsStatus("sharedConfigStatus", text, isError);
}
function agentsForSharedConfig() {
return sortedAgents().map(agent => ({
id: agent.id,
emoji: agent.emoji || "🤖",
name: agent.name || "",
desc: agent.desc || "",
model: agent.model || "",
modelProfileId: agent.modelProfileId || "",
systemPrompt: agent.systemPrompt || "",
skills: Array.isArray(agent.skills) ? agent.skills : [],
stages: agent.stages || null,
createdAt: agent.createdAt || Date.now(),
updatedAt: agent.updatedAt || Date.now(),
}));
}
function applySharedAgents(agents) {
if (!Array.isArray(agents) || !agents.length) return false;
const next = {};
for (const agent of agents) {
if (!agent?.id || !agent?.name || !agent?.systemPrompt) continue;
next[agent.id] = {
id: agent.id,
emoji: agent.emoji || "🤖",
name: agent.name,
desc: agent.desc || "",
model: agent.model || "",
modelProfileId: agent.modelProfileId || "",
systemPrompt: agent.systemPrompt,
skills: Array.isArray(agent.skills) ? agent.skills : [],
stages: agent.stages || null,
createdAt: agent.createdAt || Date.now(),
updatedAt: agent.updatedAt || Date.now(),
};
}
if (!Object.keys(next).length) return false;
state.agents = next;
saveAgents();
renderAgents();
return true;
}
async function fetchUiConfig() {
const res = await fetch(UI_CONFIG_ENDPOINT, {
credentials: "same-origin",
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status));
return data.config || {};
}
async function saveSharedConfig(options = {}) {
const config = {
version: 1,
modelProfiles: normalizeModelProfiles(state.modelProfiles),
agents: agentsForSharedConfig(),
};
const res = await fetch(UI_CONFIG_ENDPOINT, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status));
const saved = data.config || config;
state.modelProfiles = normalizeModelProfiles(saved.modelProfiles);
state.sharedConfigLoaded = true;
state.sharedConfigAvailable = true;
syncModelOptionsFromProfiles();
renderModelProfiles();
renderAgentModelProfileOptions(document.getElementById("agentModelProfile")?.value || "");
if (!options.silent) toast("共享配置已保存");
setSharedConfigStatus("共享配置已保存到服务器");
return saved;
}
async function persistAgents(options = {}) {
saveAgents();
renderAgents();
if (!state.sharedConfigAvailable) {
if (!options.silent) toast("已保存到本机;共享配置暂不可用");
return;
}
try {
await saveSharedConfig({ silent: true });
if (!options.silent) toast("智能体已保存到服务器");
} catch (error) {
setSharedConfigStatus("智能体共享保存失败: " + (error.message || error), true);
if (!options.silent) toast("共享保存失败,已保留在本机");
}
}
async function refreshUiConfig(options = {}) {
try {
const config = await fetchUiConfig();
const profiles = normalizeModelProfiles(config.modelProfiles);
state.modelProfiles = profiles;
state.sharedConfigLoaded = true;
state.sharedConfigAvailable = true;
syncModelOptionsFromProfiles();
renderModelProfiles();
renderAgentModelProfileOptions(document.getElementById("agentModelProfile")?.value || "");
const hadServerAgents = applySharedAgents(config.agents);
if (!hadServerAgents && options.migrateLocalAgents && Object.keys(state.agents || {}).length) {
await saveSharedConfig({ silent: true });
setSharedConfigStatus("已把本机智能体迁移到服务器共享配置");
} else {
setSharedConfigStatus("已读取服务器共享配置" + (config.lxc ? " · " + config.lxc : ""));
}
} catch (error) {
state.sharedConfigAvailable = false;
throw error;
}
}
async function saveModelConfig() {
const model = readModelConfigFields();
if (!model.default) {
@@ -754,6 +1057,10 @@ async function saveModelConfig() {
mcp_servers_yaml: _hermesConfigSnapshot ? _hermesConfigSnapshot.mcp_servers_yaml : (saved.mcp_servers_yaml || ""),
};
_hermesConfigLoaded = false;
upsertRuntimeModelProfile(savedModel.default ? savedModel : model);
saveSharedConfig({ silent: true }).catch((error) => {
setSharedConfigStatus("模型 Profile 共享保存失败: " + (error.message || error), true);
});
setHermesModelStatus("模型配置已保存并重启 · 备份 " + (saved.backup || "已创建"));
toast("AI 模型接入配置已生效");
setTimeout(() => {
@@ -773,11 +1080,6 @@ async function saveModelConfig() {
async function saveMcpConfig() {
const mcpServersYaml = document.getElementById("mcpServersYaml")?.value || "";
const model = snapshotModelOrFields();
if (!model.default) {
toast("先读取或填写默认模型");
return;
}
if (!confirm("保存 MCP 工具接入配置后会重启线上 Hermes agent当前正在生成的任务可能中断。继续吗")) return;
const btn = document.getElementById("hermesMcpSaveBtn");
const oldHTML = btn?.innerHTML;
@@ -788,11 +1090,10 @@ async function saveMcpConfig() {
setHermesMcpStatus("正在写入 mcp_servers 配置并重启 Hermes agent...");
try {
const saved = await postHermesRuntimeConfig({
model,
mcp_servers_yaml: mcpServersYaml,
restart: true,
});
const savedModel = saved.model || model;
const savedModel = saved.model || snapshotModelOrFields();
if (savedModel.default) syncModelPick(savedModel.default, savedModel.provider || model.provider || "");
document.getElementById("mcpServersYaml").value = saved.mcp_servers_yaml || "";
_hermesConfigSnapshot = {
@@ -905,7 +1206,7 @@ async function sendMessage(text) {
if (useAgent) {
const sys = composeSystemPrompt(useAgent);
if (sys) msgsForApi = [{ role: "system", content: sys }, ...msgsForApi];
modelForApi = useAgent.model || state.model;
modelForApi = modelForAgent(useAgent);
}
// 本次使用完清掉 pendingAgent
@@ -2072,6 +2373,7 @@ function loadAgents() {
migrated = true;
}
ensureModelChoice(agent.model || DEFAULT_MODEL_ID);
if (!("modelProfileId" in agent)) agent.modelProfileId = "";
}
if (migrated) saveAgents();
}
@@ -2112,7 +2414,7 @@ function renderAgents() {
`;
card.querySelector(".agent-avatar").textContent = a.emoji || "🤖";
card.querySelector(".agent-name").textContent = a.name;
card.querySelector(".agent-model").textContent = a.model;
card.querySelector(".agent-model").textContent = modelLabelForAgent(a);
card.querySelector(".agent-desc").textContent = a.desc || "(无简介)";
const sEl = card.querySelector(".agent-skills");
const skills = (a.skills || []).map(skillById).filter(Boolean);
@@ -2321,6 +2623,7 @@ function openAgentModal(id) {
document.getElementById("agentDesc").value = a.desc || "";
ensureModelChoice(a.model || DEFAULT_MODEL_ID);
document.getElementById("agentModel").value = a.model || DEFAULT_MODEL_ID;
renderAgentModelProfileOptions(a.modelProfileId || "");
document.getElementById("agentPrompt").value = a.systemPrompt || "";
renderSkillsPicker(a.skills || []);
} else {
@@ -2329,6 +2632,7 @@ function openAgentModal(id) {
document.getElementById("agentName").value = "";
document.getElementById("agentDesc").value = "";
document.getElementById("agentModel").value = state.model || DEFAULT_MODEL_ID;
renderAgentModelProfileOptions(activeModelProfile()?.id || "");
document.getElementById("agentPrompt").value = "";
renderSkillsPicker([]);
}
@@ -2344,6 +2648,7 @@ function saveAgent() {
const name = document.getElementById("agentName").value.trim();
const desc = document.getElementById("agentDesc").value.trim();
const model = document.getElementById("agentModel").value.trim();
const modelProfileId = document.getElementById("agentModelProfile")?.value || "";
const systemPrompt = document.getElementById("agentPrompt").value.trim();
if (!name) { toast("请填写名称"); return; }
if (!model) { toast("请填写模型 ID"); return; }
@@ -2353,21 +2658,29 @@ function saveAgent() {
const skills = readSkillsPicker();
if (state.editingAgentId && state.agents[state.editingAgentId]) {
Object.assign(state.agents[state.editingAgentId], { emoji, name, desc, model, systemPrompt, skills });
Object.assign(state.agents[state.editingAgentId], {
emoji,
name,
desc,
model,
modelProfileId,
systemPrompt,
skills,
updatedAt: Date.now(),
});
} else {
const id = "a_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
state.agents[id] = { id, emoji, name, desc, model, systemPrompt, skills, createdAt: Date.now() };
const id = makeId("a");
state.agents[id] = { id, emoji, name, desc, model, modelProfileId, systemPrompt, skills, createdAt: Date.now(), updatedAt: Date.now() };
}
saveAgents();
renderAgents();
persistAgents({ silent: true });
closeAgentModal();
toast("已保存");
toast(state.sharedConfigAvailable ? "已保存到服务器" : "已保存到本机");
}
function deleteAgent(id) {
if (!confirm("删除这个智能体?已有的对话不受影响。")) return;
delete state.agents[id];
saveAgents();
renderAgents();
persistAgents({ silent: true });
toast(state.sharedConfigAvailable ? "已从服务器删除" : "已从本机删除");
}
function chatWithAgent(id) {
const a = state.agents[id];
@@ -2456,7 +2769,7 @@ async function runClusterOne(agent, prompt, col) {
"Content-Type": "application/json",
"Authorization": "Bearer " + state.apiKey,
},
body: JSON.stringify({ model: agent.model, messages, stream: false }),
body: JSON.stringify({ model: modelForAgent(agent), messages, stream: false }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();