feat: persistent storage of conversation history

This commit is contained in:
zhayujie
2026-02-25 18:01:39 +08:00
parent 1a7a8c98d9
commit 29bfbecdc9
7 changed files with 882 additions and 20 deletions

View File

@@ -232,19 +232,37 @@ function renderMarkdown(text) {
// =====================================================================
// Chat Module
// =====================================================================
let sessionId = generateSessionId();
let isPolling = false;
let loadingContainers = {};
let activeStreams = {}; // request_id -> EventSource
let isComposing = false;
let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '' };
const SESSION_ID_KEY = 'cow_session_id';
function generateSessionId() {
return 'session_' + ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
// Restore session_id from localStorage so conversation history survives page refresh.
// A new id is only generated when the user explicitly starts a new chat.
function loadOrCreateSessionId() {
const stored = localStorage.getItem(SESSION_ID_KEY);
if (stored) return stored;
const fresh = generateSessionId();
localStorage.setItem(SESSION_ID_KEY, fresh);
return fresh;
}
let sessionId = loadOrCreateSessionId();
// ---- Conversation history state ----
let historyPage = 0; // last page fetched (0 = nothing fetched yet)
let historyHasMore = false;
let historyLoading = false;
fetch('/config').then(r => r.json()).then(data => {
if (data.status === 'success') {
appConfig = data;
@@ -257,7 +275,9 @@ fetch('/config').then(r => r.json()).then(data => {
document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--';
document.getElementById('cfg-channel').textContent = data.channel_type || '--';
}
}).catch(() => {});
// Load conversation history after config is ready
loadHistory(1);
}).catch(() => { loadHistory(1); });
const chatInput = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
@@ -530,7 +550,7 @@ function startPolling() {
poll();
}
function addUserMessage(content, timestamp) {
function createUserMessageEl(content, timestamp) {
const el = document.createElement('div');
el.className = 'flex justify-end px-4 sm:px-6 py-3';
el.innerHTML = `
@@ -541,28 +561,139 @@ function addUserMessage(content, timestamp) {
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
</div>
`;
return el;
}
function renderToolCallsHtml(toolCalls) {
if (!toolCalls || toolCalls.length === 0) return '';
return toolCalls.map(tc => {
const argsStr = formatToolArgs(tc.arguments || {});
const resultStr = tc.result ? escapeHtml(String(tc.result)) : '';
const hasResult = !!resultStr;
return `
<div class="agent-step agent-tool-step">
<div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')">
<i class="fas fa-check text-primary-400 flex-shrink-0 tool-icon"></i>
<span class="tool-name">${escapeHtml(tc.name || '')}</span>
<i class="fas fa-chevron-right tool-chevron"></i>
</div>
<div class="tool-detail">
<div class="tool-detail-section">
<div class="tool-detail-label">Input</div>
<pre class="tool-detail-content">${argsStr}</pre>
</div>
${hasResult ? `
<div class="tool-detail-section tool-output-section">
<div class="tool-detail-label">Output</div>
<pre class="tool-detail-content">${resultStr}</pre>
</div>` : ''}
</div>
</div>`;
}).join('');
}
function createBotMessageEl(content, timestamp, requestId, toolCalls) {
const el = document.createElement('div');
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
if (requestId) el.dataset.requestId = requestId;
const toolsHtml = renderToolCallsHtml(toolCalls);
el.innerHTML = `
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
<div class="min-w-0 flex-1 max-w-[85%]">
<div class="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl px-4 py-3 text-sm leading-relaxed msg-content text-slate-700 dark:text-slate-200">
${toolsHtml ? `<div class="agent-steps">${toolsHtml}</div>` : ''}
<div class="answer-content">${renderMarkdown(content)}</div>
</div>
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5">${formatTime(timestamp)}</div>
</div>
`;
applyHighlighting(el);
return el;
}
function addUserMessage(content, timestamp) {
const el = createUserMessageEl(content, timestamp);
messagesDiv.appendChild(el);
scrollChatToBottom();
}
function addBotMessage(content, timestamp, requestId) {
const el = document.createElement('div');
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
if (requestId) el.dataset.requestId = requestId;
el.innerHTML = `
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
<div class="min-w-0 flex-1 max-w-[85%]">
<div class="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl px-4 py-3 text-sm leading-relaxed msg-content text-slate-700 dark:text-slate-200">
${renderMarkdown(content)}
</div>
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5">${formatTime(timestamp)}</div>
</div>
`;
const el = createBotMessageEl(content, timestamp, requestId);
messagesDiv.appendChild(el);
applyHighlighting(el);
scrollChatToBottom();
}
// Load conversation history from the server (page 1 = most recent messages).
// Subsequent pages prepend older messages when the user scrolls to the top.
function loadHistory(page) {
if (historyLoading) return;
historyLoading = true;
fetch(`/api/history?session_id=${encodeURIComponent(sessionId)}&page=${page}&page_size=20`)
.then(r => r.json())
.then(data => {
if (data.status !== 'success' || data.messages.length === 0) return;
const prevScrollHeight = messagesDiv.scrollHeight;
const isFirstLoad = page === 1;
// On first load, remove the welcome screen if history exists
if (isFirstLoad) {
const ws = document.getElementById('welcome-screen');
if (ws) ws.remove();
}
// Build a fragment of history message elements in chronological order
const fragment = document.createDocumentFragment();
if (data.has_more && page > 1) {
// Keep the "load more" sentinel in place (inserted below)
}
data.messages.forEach(msg => {
const hasContent = msg.content && msg.content.trim();
const hasToolCalls = msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0;
if (!hasContent && !hasToolCalls) return;
const ts = new Date(msg.created_at * 1000);
const el = msg.role === 'user'
? createUserMessageEl(msg.content, ts)
: createBotMessageEl(msg.content || '', ts, null, msg.tool_calls);
fragment.appendChild(el);
});
// Prepend history above any existing messages
const sentinel = document.getElementById('history-load-more');
const insertBefore = sentinel ? sentinel.nextSibling : messagesDiv.firstChild;
messagesDiv.insertBefore(fragment, insertBefore);
// Manage the "load more" sentinel at the very top
if (data.has_more) {
if (!document.getElementById('history-load-more')) {
const btn = document.createElement('div');
btn.id = 'history-load-more';
btn.className = 'flex justify-center py-3';
btn.innerHTML = `<button class="text-xs text-slate-400 dark:text-slate-500 hover:text-primary-400 transition-colors" onclick="loadHistory(historyPage + 1)">Load earlier messages</button>`;
messagesDiv.insertBefore(btn, messagesDiv.firstChild);
}
} else {
const sentinel = document.getElementById('history-load-more');
if (sentinel) sentinel.remove();
}
historyHasMore = data.has_more;
historyPage = page;
if (isFirstLoad) {
scrollChatToBottom();
} else {
// Restore scroll position so loading older messages doesn't jump the view
messagesDiv.scrollTop = messagesDiv.scrollHeight - prevScrollHeight;
}
})
.catch(() => {})
.finally(() => { historyLoading = false; });
}
function addLoadingIndicator() {
const el = document.createElement('div');
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
@@ -586,7 +717,9 @@ function newChat() {
Object.values(activeStreams).forEach(es => { try { es.close(); } catch (_) {} });
activeStreams = {};
// Generate a fresh session and persist it so the next page load also starts clean
sessionId = generateSessionId();
localStorage.setItem(SESSION_ID_KEY, sessionId);
isPolling = false;
loadingContainers = {};
messagesDiv.innerHTML = '';

View File

@@ -302,6 +302,7 @@ class WebChannel(ChatChannel):
'/api/memory', 'MemoryHandler',
'/api/memory/content', 'MemoryContentHandler',
'/api/scheduler', 'SchedulerHandler',
'/api/history', 'HistoryHandler',
'/api/logs', 'LogsHandler',
'/assets/(.*)', 'AssetsHandler',
)
@@ -471,6 +472,37 @@ class SchedulerHandler:
return json.dumps({"status": "error", "message": str(e)})
class HistoryHandler:
def GET(self):
"""
Return paginated conversation history for a session.
Query params:
session_id (required)
page int, default 1 (1 = most recent messages)
page_size int, default 20
"""
web.header('Content-Type', 'application/json; charset=utf-8')
web.header('Access-Control-Allow-Origin', '*')
try:
params = web.input(session_id='', page='1', page_size='20')
session_id = params.session_id.strip()
if not session_id:
return json.dumps({"status": "error", "message": "session_id required"})
from agent.memory import get_conversation_store
store = get_conversation_store()
result = store.load_history_page(
session_id=session_id,
page=int(params.page),
page_size=int(params.page_size),
)
return json.dumps({"status": "success", **result}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] History API error: {e}")
return json.dumps({"status": "error", "message": str(e)})
class LogsHandler:
def GET(self):
"""Stream the last N lines of run.log as SSE, then tail new lines."""