- Add admin DAV tab (rename from CalDAV/CardDAV) and Pushover tab
- Add per-user Pushover tab (User Key only; App Token stays admin-managed)
- Remove system-wide CalDAV/CardDAV fallback — per-user config only
- Rewrite contacts_tool.py using httpx directly (caldav 2.x dropped AddressBook)
- Fix CardDAV REPORT/PROPFIND using SOGo URL pattern
- Fix CalDAV/CardDAV test endpoints (POST method, URL scheme normalization)
- Fix Show Password button — API now returns actual credential values
- Convert Credentials tab to generic key-value store; dedicated keys
(CalDAV, Pushover, trusted_proxy) excluded via _DEDICATED_CRED_KEYS
245 lines
9.9 KiB
HTML
245 lines
9.9 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Chats — {{ agent_name }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page">
|
|
|
|
<!-- Header -->
|
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px;flex-wrap:wrap">
|
|
<h2 style="font-size:18px;font-weight:600;flex:1;min-width:120px">Chat History</h2>
|
|
<input id="chats-search" type="text" class="form-input" placeholder="Search…"
|
|
style="width:220px;height:34px;padding:5px 12px;font-size:13px"
|
|
oninput="chatsSearch(this.value)">
|
|
<button class="btn btn-ghost btn-small" id="sel-all-btn" onclick="toggleSelectAll()" style="display:none">Select all</button>
|
|
<button class="btn btn-ghost btn-small" id="del-sel-btn" onclick="deleteSelected()"
|
|
style="display:none;color:var(--danger,#dc3c3c)">Delete selected (<span id="sel-count">0</span>)</button>
|
|
<button class="btn btn-ghost btn-small" id="del-all-btn" onclick="deleteAll()"
|
|
style="color:var(--danger,#dc3c3c)">Delete all</button>
|
|
<a class="btn btn-primary btn-small" href="/"
|
|
onclick="localStorage.removeItem('current_session_id')">+ New Chat</a>
|
|
</div>
|
|
|
|
<!-- List -->
|
|
<div id="chats-list"><p style="color:var(--text-dim)">Loading…</p></div>
|
|
|
|
<!-- Pagination -->
|
|
<div id="chats-pagination" style="display:flex;justify-content:center;gap:8px;margin-top:20px"></div>
|
|
|
|
</div>
|
|
|
|
<!-- Rename modal -->
|
|
<div class="modal-overlay hidden" id="rename-modal" onclick="if(event.target===this)closeRenameModal()">
|
|
<div class="modal" style="max-width:420px;width:90%">
|
|
<h3 style="font-size:15px;font-weight:600;margin-bottom:12px">Rename chat</h3>
|
|
<input id="rename-input" type="text" class="form-input" style="margin-bottom:14px"
|
|
placeholder="Chat title" maxlength="120">
|
|
<div class="modal-buttons">
|
|
<button class="btn btn-ghost" onclick="closeRenameModal()">Cancel</button>
|
|
<button class="btn btn-primary" onclick="submitRename()">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
let _chatsPage = 1;
|
|
let _chatsQ = "";
|
|
let _renameId = null;
|
|
let _allIds = []; // ids on the current page
|
|
let _selected = new Set();
|
|
let _chatMap = {}; // id -> { id, title } for current page
|
|
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
}
|
|
|
|
/* ── Load & render ─────────────────────────────────────────────────────── */
|
|
|
|
async function loadChats(page) {
|
|
page = page || _chatsPage;
|
|
_chatsPage = page;
|
|
_selected.clear();
|
|
updateSelectionUI();
|
|
const list = document.getElementById("chats-list");
|
|
list.innerHTML = "<p style='color:var(--text-dim)'>Loading…</p>";
|
|
try {
|
|
const params = new URLSearchParams({ page, per_page: 40 });
|
|
if (_chatsQ) params.set("q", _chatsQ);
|
|
const r = await fetch("/api/conversations?" + params);
|
|
if (!r.ok) throw new Error(await r.text());
|
|
renderChats(await r.json());
|
|
} catch(e) {
|
|
list.innerHTML = `<p style='color:var(--danger)'>Failed to load: ${e.message}</p>`;
|
|
}
|
|
}
|
|
|
|
function chatsSearch(q) {
|
|
_chatsQ = q;
|
|
_chatsPage = 1;
|
|
clearTimeout(chatsSearch._t);
|
|
chatsSearch._t = setTimeout(() => loadChats(1), 300);
|
|
}
|
|
|
|
function renderChats(data) {
|
|
const list = document.getElementById("chats-list");
|
|
_allIds = data.conversations.map(c => c.id);
|
|
_chatMap = {};
|
|
data.conversations.forEach(c => { _chatMap[c.id] = { id: c.id, title: c.title || "Untitled chat" }; });
|
|
|
|
if (!data.conversations.length) {
|
|
list.innerHTML = "<p style='color:var(--text-dim)'>No saved chats yet. Start a conversation and it will appear here automatically.</p>";
|
|
document.getElementById("chats-pagination").innerHTML = "";
|
|
document.getElementById("sel-all-btn").style.display = "none";
|
|
return;
|
|
}
|
|
|
|
document.getElementById("sel-all-btn").style.display = "";
|
|
|
|
list.innerHTML = data.conversations.map(c => {
|
|
const title = c.title || "Untitled chat";
|
|
const date = c.ended_at ? formatDate(c.ended_at) : "—";
|
|
const turns = Math.floor((c.message_count || 0) / 2);
|
|
const model = c.model
|
|
? `<span style="font-size:11px;color:var(--text-dim);background:var(--bg2);border:1px solid var(--border);border-radius:3px;padding:1px 6px;white-space:nowrap">${escHtml(c.model)}</span>`
|
|
: "";
|
|
const turnLabel = turns
|
|
? `<span style="font-size:12px;color:var(--text-dim)">${turns} turn${turns !== 1 ? "s" : ""}</span>`
|
|
: "";
|
|
return `
|
|
<div class="chat-row" data-id="${c.id}"
|
|
style="display:flex;align-items:center;gap:12px;padding:12px 14px;
|
|
border:1px solid var(--border);border-radius:var(--radius);
|
|
margin-bottom:8px;background:var(--bg1)">
|
|
<input type="checkbox" class="chat-chk" data-id="${c.id}"
|
|
style="flex-shrink:0;width:15px;height:15px;cursor:pointer;accent-color:var(--accent)"
|
|
onchange="onCheckChange(this)">
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-size:14px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:3px"
|
|
title="${escHtml(title)}">${escHtml(title)}</div>
|
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
<span style="font-size:12px;color:var(--text-dim)">${date}</span>
|
|
${turnLabel}
|
|
${model}
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:6px;flex-shrink:0">
|
|
<a class="btn btn-ghost btn-small" href="/?session=${c.id}"
|
|
data-id="${c.id}" onclick="localStorage.setItem('current_session_id',this.dataset.id)">Open</a>
|
|
<button class="btn btn-ghost btn-small"
|
|
data-id="${c.id}" onclick="openRenameModal(this.dataset.id)">Rename</button>
|
|
<a class="btn btn-ghost btn-small" href="/api/conversations/${c.id}/export" download>Export</a>
|
|
<button class="btn btn-ghost btn-small" style="color:var(--danger,#dc3c3c)"
|
|
data-id="${c.id}" onclick="deleteSingle(this.dataset.id)">Delete</button>
|
|
</div>
|
|
</div>`;
|
|
}).join("");
|
|
|
|
// Pagination
|
|
const pages = Math.ceil(data.total / data.per_page);
|
|
const pg = document.getElementById("chats-pagination");
|
|
if (pages <= 1) { pg.innerHTML = ""; return; }
|
|
let html = "";
|
|
for (let i = 1; i <= pages; i++) {
|
|
html += `<button class="btn btn-ghost btn-small${i === _chatsPage ? " btn-active" : ""}"
|
|
onclick="loadChats(${i})">${i}</button>`;
|
|
}
|
|
pg.innerHTML = html;
|
|
}
|
|
|
|
/* ── Selection ─────────────────────────────────────────────────────────── */
|
|
|
|
function onCheckChange(cb) {
|
|
if (cb.checked) _selected.add(cb.dataset.id);
|
|
else _selected.delete(cb.dataset.id);
|
|
updateSelectionUI();
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
const allSelected = _allIds.every(id => _selected.has(id));
|
|
if (allSelected) {
|
|
_selected.clear();
|
|
} else {
|
|
_allIds.forEach(id => _selected.add(id));
|
|
}
|
|
document.querySelectorAll(".chat-chk").forEach(cb => {
|
|
cb.checked = _selected.has(cb.dataset.id);
|
|
});
|
|
updateSelectionUI();
|
|
}
|
|
|
|
function updateSelectionUI() {
|
|
const n = _selected.size;
|
|
const allSelected = _allIds.length > 0 && _allIds.every(id => _selected.has(id));
|
|
document.getElementById("sel-all-btn").textContent = allSelected ? "Deselect all" : "Select all";
|
|
const delSelBtn = document.getElementById("del-sel-btn");
|
|
delSelBtn.style.display = n > 0 ? "" : "none";
|
|
document.getElementById("sel-count").textContent = n;
|
|
}
|
|
|
|
/* ── Delete actions ────────────────────────────────────────────────────── */
|
|
|
|
async function deleteSingle(id) {
|
|
const title = (_chatMap[id] || {}).title || id;
|
|
if (!confirm(`Delete "${title}"?\nThis cannot be undone.`)) return;
|
|
const r = await fetch("/api/conversations/" + id, { method: "DELETE" });
|
|
if (r.ok) { _selected.delete(id); loadChats(); }
|
|
else alert("Failed to delete.");
|
|
}
|
|
|
|
async function deleteSelected() {
|
|
const ids = [..._selected];
|
|
if (!ids.length) return;
|
|
if (!confirm(`Delete ${ids.length} selected chat${ids.length !== 1 ? "s" : ""}?\nThis cannot be undone.`)) return;
|
|
await Promise.all(ids.map(id => fetch("/api/conversations/" + id, { method: "DELETE" })));
|
|
_selected.clear();
|
|
loadChats();
|
|
}
|
|
|
|
async function deleteAll() {
|
|
if (!confirm("Delete ALL saved chats?\nThis cannot be undone.")) return;
|
|
// Fetch all IDs (no pagination limit) then delete
|
|
const r = await fetch("/api/conversations?per_page=10000");
|
|
const data = await r.json();
|
|
await Promise.all(data.conversations.map(c => fetch("/api/conversations/" + c.id, { method: "DELETE" })));
|
|
loadChats();
|
|
}
|
|
|
|
/* ── Rename ────────────────────────────────────────────────────────────── */
|
|
|
|
function openRenameModal(id) {
|
|
const chat = _chatMap[id];
|
|
if (!chat) return;
|
|
_renameId = id;
|
|
document.getElementById("rename-input").value = chat.title;
|
|
document.getElementById("rename-modal").classList.remove("hidden");
|
|
setTimeout(() => document.getElementById("rename-input").select(), 50);
|
|
}
|
|
|
|
function closeRenameModal() {
|
|
document.getElementById("rename-modal").classList.add("hidden");
|
|
_renameId = null;
|
|
}
|
|
|
|
async function submitRename() {
|
|
if (!_renameId) return;
|
|
const title = document.getElementById("rename-input").value.trim();
|
|
if (!title) return;
|
|
const r = await fetch("/api/conversations/" + _renameId, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
if (r.ok) { closeRenameModal(); loadChats(); }
|
|
else alert("Failed to rename.");
|
|
}
|
|
|
|
document.getElementById("rename-input").addEventListener("keydown", e => {
|
|
if (e.key === "Enter") submitRename();
|
|
if (e.key === "Escape") closeRenameModal();
|
|
});
|
|
|
|
loadChats();
|
|
</script>
|
|
{% endblock %}
|