Files
oai-web/server/web/templates/chats.html
Rune Olsen 7b0a9ccc2b Settings: add dedicated DAV/Pushover tabs, fix CalDAV/CardDAV bugs
- 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
2026-04-10 12:06:23 +02:00

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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
/* ── 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 %}