feat: add admin panel
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
{% 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 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("/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 %}
|
||||
Reference in New Issue
Block a user