auto-save 2026-05-09 18:47 (~5)
This commit is contained in:
226
src/app.js
226
src/app.js
@@ -10,6 +10,7 @@ const LS_AGENTS = "hermes-ui-agents-v1";
|
||||
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 state = {
|
||||
apiBase: "/api/v1",
|
||||
@@ -37,6 +38,9 @@ const state = {
|
||||
editingFlowId: null,
|
||||
flowEditSelected: [], // 编辑 flow 时的临时 skill id 数组
|
||||
|
||||
// 周报记录
|
||||
weeklyReports: [], // [{id,title,task,report,messages,createdAt,...}]
|
||||
|
||||
// 本次一次性使用的智能体 ID (不绑定会话)
|
||||
pendingAgent: null,
|
||||
|
||||
@@ -55,6 +59,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
loadFlows();
|
||||
loadAgents();
|
||||
loadConversations();
|
||||
loadWeeklyReports();
|
||||
bindTabs();
|
||||
bindChat();
|
||||
bindSearch();
|
||||
@@ -104,7 +109,7 @@ function loadSettings() {
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
function saveSettings() {
|
||||
function saveSettings(options = {}) {
|
||||
state.apiBase = document.getElementById("apiBase").value.trim();
|
||||
state.apiKey = document.getElementById("apiKey").value.trim();
|
||||
state.stream = document.getElementById("streamMode").checked;
|
||||
@@ -113,7 +118,7 @@ function saveSettings() {
|
||||
apiKey: state.apiKey,
|
||||
stream: state.stream,
|
||||
}));
|
||||
toast("设置已保存");
|
||||
if (!options.silent) toast("设置已保存");
|
||||
pingBackend();
|
||||
}
|
||||
|
||||
@@ -353,7 +358,10 @@ function switchTab(name, options = {}) {
|
||||
if (name === "cron") refreshCron();
|
||||
if (name === "memory") refreshMemory();
|
||||
if (name === "tools") refreshTools();
|
||||
if (name === "settings") refreshFeishuApps();
|
||||
if (name === "settings") {
|
||||
renderWeeklyReports();
|
||||
refreshFeishuApps();
|
||||
}
|
||||
if (name === "runs") setTimeout(() => document.getElementById("runPrompt")?.focus(), 50);
|
||||
if (name === "dashboard" && _dashboardDirty) {
|
||||
// 推迟到下一帧,避免阻塞切换动画
|
||||
@@ -521,6 +529,27 @@ async function pingBackend() {
|
||||
}
|
||||
}
|
||||
|
||||
async function testApiConnection() {
|
||||
saveSettings({ silent: true });
|
||||
try {
|
||||
const res = await apiFetch(state.apiBase + "/models", {
|
||||
headers: { "Authorization": "Bearer " + state.apiKey },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast("API 检测失败: HTTP " + res.status);
|
||||
return;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const list = data?.data || data?.models || [];
|
||||
toast("API 连接正常" + (list.length ? " · " + list.length + " 个模型" : ""));
|
||||
} catch (e) {
|
||||
toast("API 检测失败: " + (e.message || e));
|
||||
} finally {
|
||||
pingBackend();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIP() {
|
||||
const el = document.getElementById("statIP");
|
||||
if (el) el.textContent = location.hostname;
|
||||
@@ -891,6 +920,15 @@ function renderChat(streaming = false) {
|
||||
actions.appendChild(pullBtn);
|
||||
}
|
||||
|
||||
if (m.role === "assistant") {
|
||||
const weeklyBtn = document.createElement("button");
|
||||
weeklyBtn.className = "msg-action-btn";
|
||||
weeklyBtn.title = "保存这条回答为周报记录";
|
||||
weeklyBtn.innerHTML = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5z"/><path d="M8 7h8"/><path d="M8 11h8"/></svg><span>存周报</span>';
|
||||
weeklyBtn.onclick = () => saveWeeklyReportFromMessage(capturedIdx);
|
||||
actions.appendChild(weeklyBtn);
|
||||
}
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.className = "msg-action-btn";
|
||||
copyBtn.title = "复制";
|
||||
@@ -963,6 +1001,183 @@ async function refreshDashboard() {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ---------- 周报记录 ----------
|
||||
function loadWeeklyReports() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_WEEKLY_REPORTS);
|
||||
state.weeklyReports = raw ? (JSON.parse(raw) || []) : [];
|
||||
if (!Array.isArray(state.weeklyReports)) state.weeklyReports = [];
|
||||
} catch (e) {
|
||||
state.weeklyReports = [];
|
||||
}
|
||||
}
|
||||
function saveWeeklyReports() {
|
||||
const sorted = (state.weeklyReports || [])
|
||||
.filter(r => r && r.id && r.report)
|
||||
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
|
||||
.slice(0, 80);
|
||||
state.weeklyReports = sorted;
|
||||
localStorage.setItem(LS_WEEKLY_REPORTS, JSON.stringify(sorted));
|
||||
refreshDashboardLocal();
|
||||
}
|
||||
function findWeeklyAssistantIndex(messages, idx) {
|
||||
if (!messages?.length) return -1;
|
||||
if (Number.isInteger(idx) && messages[idx]?.role === "assistant" && String(messages[idx].content || "").trim()) {
|
||||
return idx;
|
||||
}
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "assistant" && String(messages[i].content || "").trim()) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function findTaskBefore(messages, idx) {
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "user" && String(messages[i].content || "").trim()) return messages[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function compactText(text, max = 180) {
|
||||
const s = String(text || "").replace(/\s+/g, " ").trim();
|
||||
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
||||
}
|
||||
function formatRecordTime(ts) {
|
||||
const d = new Date(ts || Date.now());
|
||||
return d.toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
function makeWeeklyTitle(task, convo) {
|
||||
const base = compactText(task || convo?.title || "周报记录", 28);
|
||||
return base || "周报记录";
|
||||
}
|
||||
function buildWeeklyRecord(messageIndex) {
|
||||
const c = activeConvo();
|
||||
const messages = c.messages || [];
|
||||
const assistantIndex = findWeeklyAssistantIndex(messages, messageIndex);
|
||||
if (assistantIndex < 0) {
|
||||
toast("还没有可保存的周报内容");
|
||||
return null;
|
||||
}
|
||||
const answer = messages[assistantIndex];
|
||||
const taskMsg = findTaskBefore(messages, assistantIndex);
|
||||
const task = taskMsg?.content || c.title || "";
|
||||
const contextStart = Math.max(0, assistantIndex - 8);
|
||||
return {
|
||||
id: "wr_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
title: makeWeeklyTitle(task, c),
|
||||
task,
|
||||
report: answer.content,
|
||||
messages: messages.slice(contextStart, assistantIndex + 1).map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
ts: m.ts || Date.now(),
|
||||
agentId: m.agentId || null,
|
||||
})),
|
||||
tags: Array.isArray(c.tags) ? c.tags.slice(0, 8) : [],
|
||||
sourceConvoId: c.id,
|
||||
sourceAssistantTs: answer.ts || Date.now(),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
function persistWeeklyRecord(record) {
|
||||
if (!record) return;
|
||||
const existing = state.weeklyReports.findIndex(r =>
|
||||
r.sourceConvoId === record.sourceConvoId &&
|
||||
r.sourceAssistantTs === record.sourceAssistantTs
|
||||
);
|
||||
if (existing >= 0) {
|
||||
state.weeklyReports[existing] = { ...state.weeklyReports[existing], ...record, id: state.weeklyReports[existing].id };
|
||||
saveWeeklyReports();
|
||||
renderWeeklyReports();
|
||||
toast("周报记录已更新");
|
||||
return;
|
||||
}
|
||||
state.weeklyReports.unshift(record);
|
||||
saveWeeklyReports();
|
||||
renderWeeklyReports();
|
||||
toast("周报记录已保存");
|
||||
}
|
||||
function saveWeeklyReportFromChat() {
|
||||
persistWeeklyRecord(buildWeeklyRecord());
|
||||
}
|
||||
function saveWeeklyReportFromMessage(index) {
|
||||
persistWeeklyRecord(buildWeeklyRecord(index));
|
||||
}
|
||||
function weeklyById(id) {
|
||||
return (state.weeklyReports || []).find(r => r.id === id);
|
||||
}
|
||||
function renderWeeklyReports() {
|
||||
const box = document.getElementById("weeklyReportsList");
|
||||
if (!box) return;
|
||||
const reports = (state.weeklyReports || []).slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
if (!reports.length) {
|
||||
box.innerHTML = '<div class="settings-help">还没有保存过周报记录。</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = reports.map(r => `
|
||||
<div class="weekly-report-card">
|
||||
<div class="weekly-report-top">
|
||||
<div>
|
||||
<div class="weekly-report-title">${escapeHTML(r.title || "周报记录")}</div>
|
||||
<div class="weekly-report-meta">${escapeHTML(formatRecordTime(r.createdAt))}${r.tags?.length ? " · " + escapeHTML(r.tags.join(" / ")) : ""}</div>
|
||||
</div>
|
||||
<span class="weekly-report-badge">已保存</span>
|
||||
</div>
|
||||
<div class="weekly-report-section">
|
||||
<div class="weekly-report-label">任务描述</div>
|
||||
<div class="weekly-report-text">${escapeHTML(compactText(r.task || "未记录任务描述", 220))}</div>
|
||||
</div>
|
||||
<div class="weekly-report-section">
|
||||
<div class="weekly-report-label">周报内容</div>
|
||||
<div class="weekly-report-text">${escapeHTML(compactText(r.report, 260))}</div>
|
||||
</div>
|
||||
<div class="weekly-report-actions">
|
||||
<button class="glass-btn-sm" onclick="openWeeklyRecord('${r.id}')">打开</button>
|
||||
<button class="glass-btn-sm" onclick="copyWeeklyReport('${r.id}')">复制周报</button>
|
||||
<button class="glass-btn-sm" onclick="copyWeeklyTask('${r.id}')">复制任务</button>
|
||||
<button class="glass-btn-sm danger-btn" onclick="deleteWeeklyReport('${r.id}')">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
function openWeeklyRecord(id) {
|
||||
const r = weeklyById(id);
|
||||
if (!r) return;
|
||||
const cid = createConvo();
|
||||
const c = state.conversations[cid];
|
||||
const savedMessages = Array.isArray(r.messages) && r.messages.length ? r.messages : [
|
||||
{ role: "user", content: r.task || r.title || "周报任务", ts: r.createdAt || Date.now() },
|
||||
{ role: "assistant", content: r.report || "", ts: r.createdAt || Date.now() },
|
||||
];
|
||||
c.title = "周报记录 · " + (r.title || "未命名").slice(0, 32);
|
||||
c.tags = Array.from(new Set(["周报记录", ...(r.tags || [])]));
|
||||
c.messages = savedMessages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
ts: m.ts || Date.now(),
|
||||
agentId: m.agentId || null,
|
||||
}));
|
||||
c.updatedAt = Date.now();
|
||||
saveConversations();
|
||||
renderSidebar();
|
||||
renderChat();
|
||||
switchTab("chat");
|
||||
}
|
||||
function copyWeeklyReport(id) {
|
||||
const r = weeklyById(id);
|
||||
if (r) copyText(r.report || "");
|
||||
}
|
||||
function copyWeeklyTask(id) {
|
||||
const r = weeklyById(id);
|
||||
if (r) copyText(r.task || "");
|
||||
}
|
||||
function deleteWeeklyReport(id) {
|
||||
const r = weeklyById(id);
|
||||
if (!r) return;
|
||||
if (!confirm("删除这条周报记录?")) return;
|
||||
state.weeklyReports = state.weeklyReports.filter(item => item.id !== id);
|
||||
saveWeeklyReports();
|
||||
renderWeeklyReports();
|
||||
}
|
||||
|
||||
// ---------- 每日用量聚合 ----------
|
||||
let selectedDay = null;
|
||||
|
||||
@@ -3310,6 +3525,7 @@ function exportData() {
|
||||
stream: state.stream,
|
||||
},
|
||||
conversations: state.conversations,
|
||||
weeklyReports: state.weeklyReports,
|
||||
agents: state.agents,
|
||||
customSkills: state.customSkills,
|
||||
flows: state.flows,
|
||||
@@ -3336,6 +3552,10 @@ function importData(event) {
|
||||
if (!data.version) { toast("文件格式不对"); return; }
|
||||
if (!confirm("导入会覆盖当前所有对话、智能体和设置,确定吗?")) return;
|
||||
if (data.conversations) state.conversations = data.conversations;
|
||||
if (Array.isArray(data.weeklyReports)) {
|
||||
state.weeklyReports = data.weeklyReports;
|
||||
saveWeeklyReports();
|
||||
}
|
||||
if (data.agents) state.agents = data.agents;
|
||||
if (data.customSkills) { state.customSkills = data.customSkills; saveCustomSkillsToLS(); }
|
||||
if (data.flows) { state.flows = data.flows; saveFlowsToLS(); }
|
||||
|
||||
@@ -309,6 +309,7 @@
|
||||
<option value="gemini-2.5-flash">Gemini 2.5 Flash</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="chat-clear" onclick="saveWeeklyReportFromChat()" title="保存最近一条回答为周报记录">存周报</button>
|
||||
<button class="chat-clear" id="clearBtn" title="清空对话">清空</button>
|
||||
</div>
|
||||
|
||||
@@ -1011,6 +1012,10 @@ git push # Gitea kangwan/hermes-glass-ui-personal
|
||||
<input type="password" id="apiKey" value="hermes-mini-local-key-2026">
|
||||
<div class="settings-help">任意字符串,只要和 Hermes <code>.env</code> 里的 <code>API_SERVER_KEY</code> 一致</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="glass-btn-sm" onclick="saveSettings()">保存 API</button>
|
||||
<button class="glass-btn-sm" onclick="testApiConnection()">测试连接</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1088,6 +1093,28 @@ git push # Gitea kangwan/hermes-glass-ui-personal
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 周报记录 -->
|
||||
<div class="settings-group wide" id="weeklyReportGroup">
|
||||
<div class="settings-group-head">
|
||||
<div class="settings-group-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5z"/><path d="M8 7h8"/><path d="M8 11h8"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="settings-group-title">周报记录</div>
|
||||
<div class="settings-group-desc">保存任务描述、优化过程和最终周报</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group-body">
|
||||
<div class="weekly-toolbar">
|
||||
<div class="settings-help">对话页点「存周报」会把最近一次任务描述和回答存到这里,之后可以打开、复制或删除。</div>
|
||||
<button class="glass-btn-sm" onclick="renderWeeklyReports()">刷新</button>
|
||||
</div>
|
||||
<div class="weekly-report-list" id="weeklyReportsList">
|
||||
<div class="settings-help">还没有保存过周报记录。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 飞书集成 -->
|
||||
<div class="settings-group wide" id="feishuSettingsGroup">
|
||||
<div class="settings-group-head">
|
||||
|
||||
@@ -2296,6 +2296,89 @@ a { color: var(--orange-3); text-decoration: none; }
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.weekly-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.weekly-toolbar .settings-help { flex: 1 1 280px; }
|
||||
.weekly-report-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.weekly-report-card {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
[data-theme="light"] .weekly-report-card { background: rgba(15,22,40,0.035); }
|
||||
.weekly-report-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.weekly-report-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.weekly-report-meta {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim2);
|
||||
}
|
||||
.weekly-report-badge {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(255,178,89,0.32);
|
||||
background: rgba(255,178,89,0.1);
|
||||
color: var(--orange-3);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.weekly-report-section {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(0,0,0,0.12);
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
[data-theme="light"] .weekly-report-section { background: rgba(15,22,40,0.04); }
|
||||
.weekly-report-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--text-dim2);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.weekly-report-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: var(--text-dim);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.weekly-report-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.weekly-report-actions .glass-btn-sm {
|
||||
margin: 0;
|
||||
padding: 8px 11px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.feishu-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user