Files
beaver-gateway/src/beaver_gateway/frontends/admin/templates/chat.html
T
2026-05-21 23:27:57 +02:00

356 lines
11 KiB
HTML

{% extends "_layout.html" %}
{% set active = "chat" %}
{% block title %}beaver-gateway · chat{% endblock %}
{% block content %}
<div class="chat-wrap">
<div class="chat-toolbar">
<label>
<span>Agent</span>
<select id="agent-select">
{% for a in agents %}
<option value="{{ a.name }}">{{ a.name }} · {{ a.model }}</option>
{% else %}
<option disabled>no agents with a backend</option>
{% endfor %}
</select>
</label>
<div class="spacer"></div>
<button id="new-chat-btn" type="button">New chat</button>
</div>
<div id="messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" class="chat-input">
<textarea id="chat-text" rows="3" placeholder="Message… (⌘/Ctrl+Enter to send)" required></textarea>
<button type="submit" class="primary" id="send-btn">Send</button>
</form>
</div>
<style>
.chat-wrap {
display: flex; flex-direction: column;
height: calc(100vh - 200px); min-height: 480px;
}
.chat-toolbar {
display: flex; gap: 0.85rem; align-items: end;
margin-bottom: 1rem;
}
.chat-toolbar .spacer { flex: 1; }
.chat-toolbar label {
display: flex; flex-direction: column; gap: 0.3rem;
min-width: 260px;
}
.chat-toolbar label > span {
font-size: 0.8em; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.05em;
}
.chat-messages {
flex: 1; overflow-y: auto;
background: var(--surface); border: 1px solid var(--line);
border-radius: 14px; padding: 1.25rem;
display: flex; flex-direction: column; gap: 1rem;
}
.chat-empty { color: var(--muted); margin: auto; }
.msg { display: flex; }
.msg.user { justify-content: flex-end; }
.msg.user .bubble {
max-width: 78%; padding: 0.7rem 0.95rem; border-radius: 14px;
background: var(--accent); color: white;
white-space: pre-wrap; word-wrap: break-word;
}
.msg.assistant .blocks {
display: flex; flex-direction: column; gap: 0.5rem;
max-width: 88%;
}
.block-text {
background: var(--code-bg); padding: 0.7rem 0.95rem;
border-radius: 14px;
white-space: pre-wrap; word-wrap: break-word;
}
details.tool-call, details.thinking-block {
border: 1px solid var(--line); border-radius: 10px;
background: var(--surface); padding: 0 0.85rem;
font-size: 0.9em;
}
details.tool-call > summary,
details.thinking-block > summary {
cursor: pointer; padding: 0.55rem 0;
list-style: none;
display: flex; align-items: center; gap: 0.55rem;
}
details > summary::-webkit-details-marker { display: none; }
details.tool-call > summary::before,
details.thinking-block > summary::before {
content: "▸"; color: var(--muted); font-size: 0.78em;
}
details.tool-call[open] > summary::before,
details.thinking-block[open] > summary::before { content: "▾"; }
.tool-name {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-weight: 500;
}
.tool-id, .tool-label {
color: var(--muted); font-size: 0.78em;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
details.tool-call pre, details.thinking-block pre {
margin: 0 0 0.6rem; padding: 0.7rem 0.85rem;
background: var(--code-bg); border-radius: 8px;
font-size: 0.85em; max-height: 360px; overflow: auto;
}
.tool-label { display: block; margin: 0.15rem 0 0.25rem; }
.chat-input { display: flex; gap: 0.75rem; margin-top: 1rem; }
.chat-input textarea {
flex: 1; padding: 0.7rem 0.9rem;
border: 1px solid var(--line); border-radius: 12px;
font-family: inherit; font-size: 0.95em;
background: var(--surface); color: var(--fg); resize: vertical;
}
.chat-input textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.chat-input button { align-self: stretch; padding-left: 1.4rem; padding-right: 1.4rem; }
.chat-error {
color: var(--danger); font-size: 0.85em;
padding: 0.55rem 0.75rem;
background: #fff0f0; border-radius: 10px;
border: 1px solid #f5c2c5;
}
.chat-input button:disabled { opacity: 0.55; cursor: progress; }
</style>
<script>
(() => {
const CSRF = {{ csrf | tojson }};
const URL_PREFIX = {{ p | tojson }};
const agentSelect = document.getElementById("agent-select");
const messagesEl = document.getElementById("messages");
const form = document.getElementById("chat-form");
const textEl = document.getElementById("chat-text");
const sendBtn = document.getElementById("send-btn");
const newBtn = document.getElementById("new-chat-btn");
// Anthropic-style history sent to the backend. We keep assistant
// content text-only — tool_use blocks can't round-trip without
// matching tool_result, and backends (claude-code) run tools
// internally anyway.
let apiMessages = [];
function renderEmpty() {
messagesEl.innerHTML = '<div class="chat-empty">No messages yet — say something.</div>';
}
renderEmpty();
newBtn.addEventListener("click", () => {
apiMessages = [];
renderEmpty();
});
function clearEmpty() {
const e = messagesEl.querySelector(".chat-empty");
if (e) e.remove();
}
function scrollDown() { messagesEl.scrollTop = messagesEl.scrollHeight; }
function appendUser(text) {
clearEmpty();
const row = document.createElement("div");
row.className = "msg user";
const bub = document.createElement("div");
bub.className = "bubble";
bub.textContent = text;
row.appendChild(bub);
messagesEl.appendChild(row);
scrollDown();
}
function appendAssistant() {
clearEmpty();
const row = document.createElement("div");
row.className = "msg assistant";
const blocks = document.createElement("div");
blocks.className = "blocks";
row.appendChild(blocks);
messagesEl.appendChild(row);
scrollDown();
return blocks;
}
function appendError(text) {
clearEmpty();
const e = document.createElement("div");
e.className = "chat-error";
e.textContent = text;
messagesEl.appendChild(e);
scrollDown();
}
function ensureBlock(state, index, type, extra) {
if (state.blocks[index]) return state.blocks[index];
const block = { type };
if (type === "text") {
const el = document.createElement("div");
el.className = "block-text";
state.container.appendChild(el);
block.el = el;
} else if (type === "thinking") {
const det = document.createElement("details");
det.className = "thinking-block";
const sum = document.createElement("summary");
sum.textContent = "Thinking";
det.appendChild(sum);
const pre = document.createElement("pre");
det.appendChild(pre);
state.container.appendChild(det);
block.el = pre;
} else if (type === "tool_use") {
const det = document.createElement("details");
det.className = "tool-call";
const sum = document.createElement("summary");
const name = document.createElement("span");
name.className = "tool-name";
name.textContent = "🔧 " + (extra.name || "tool");
sum.appendChild(name);
if (extra.id) {
const idEl = document.createElement("span");
idEl.className = "tool-id";
idEl.textContent = extra.id;
sum.appendChild(idEl);
}
det.appendChild(sum);
const label = document.createElement("span");
label.className = "tool-label";
label.textContent = "input";
det.appendChild(label);
const pre = document.createElement("pre");
det.appendChild(pre);
state.container.appendChild(det);
block.el = pre;
block.jsonBuf = "";
block.seedInput = extra.input;
}
state.blocks[index] = block;
return block;
}
function applyEvent(state, ev) {
const t = ev.type;
if (t === "content_block_start") {
const cb = ev.content_block || {};
if (cb.type === "text") {
ensureBlock(state, ev.index, "text", {});
} else if (cb.type === "thinking") {
ensureBlock(state, ev.index, "thinking", {});
} else if (cb.type === "tool_use") {
ensureBlock(state, ev.index, "tool_use",
{ name: cb.name, id: cb.id, input: cb.input });
}
} else if (t === "content_block_delta") {
const d = ev.delta || {};
const b = state.blocks[ev.index];
if (!b) return;
if (d.type === "text_delta") {
b.el.textContent += d.text || "";
state.assistantText += d.text || "";
scrollDown();
} else if (d.type === "thinking_delta") {
b.el.textContent += d.thinking || "";
scrollDown();
} else if (d.type === "input_json_delta") {
b.jsonBuf += d.partial_json || "";
}
} else if (t === "content_block_stop") {
const b = state.blocks[ev.index];
if (!b) return;
if (b.type === "tool_use") {
let input = null;
if (b.jsonBuf && b.jsonBuf.trim()) {
try { input = JSON.parse(b.jsonBuf); }
catch { input = b.jsonBuf; }
} else if (b.seedInput !== undefined && b.seedInput !== null) {
input = b.seedInput;
}
b.el.textContent = input === null
? "(no input)"
: (typeof input === "string"
? input
: JSON.stringify(input, null, 2));
}
} else if (t === "error") {
const msg = (ev.error && ev.error.message) || "stream error";
appendError(msg);
}
}
async function send() {
const text = textEl.value.trim();
if (!text) return;
const model = agentSelect.value;
if (!model) { appendError("no agent selected"); return; }
apiMessages.push({ role: "user", content: text });
appendUser(text);
textEl.value = "";
sendBtn.disabled = true;
const container = appendAssistant();
const state = { container, blocks: {}, assistantText: "" };
let resp;
try {
resp = await fetch(URL_PREFIX + "/chat/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF,
},
body: JSON.stringify({ model, messages: apiMessages }),
});
} catch (e) {
appendError("network error: " + e.message);
sendBtn.disabled = false;
return;
}
if (!resp.ok || !resp.body) {
let msg = resp.status + " " + resp.statusText;
try { const body = await resp.text(); if (body) msg = body; } catch {}
appendError(msg);
sendBtn.disabled = false;
return;
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const raw = buf.slice(0, i);
buf = buf.slice(i + 2);
if (!raw) continue;
let dataLine = "";
for (const line of raw.split("\n")) {
if (line.startsWith("data:")) dataLine += line.slice(5).trimStart();
}
if (!dataLine) continue;
let payload;
try { payload = JSON.parse(dataLine); } catch { continue; }
applyEvent(state, payload);
}
}
if (state.assistantText) {
apiMessages.push({ role: "assistant", content: state.assistantText });
}
sendBtn.disabled = false;
textEl.focus();
}
form.addEventListener("submit", (e) => { e.preventDefault(); send(); });
textEl.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
send();
}
});
})();
</script>
{% endblock %}