<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeI</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--sidebar: #141720;
--border: #2a2f3d;
--text: #e6e9ef;
--muted: #8b93a7;
--accent: #6c9eff;
--accent-hover: #8ab4ff;
--accent-dim: rgba(108, 158, 255, 0.12);
--user-bg: #243049;
--assistant-bg: #1e222e;
--tool-bg: #151c18;
--error: #ff6b6b;
--radius: 10px;
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: ui-monospace, "Cascadia Code", "Fira Code", monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 1rem;
background: var(--surface);
flex-shrink: 0;
}
header h1 { font-size: 1.2rem; font-weight: 600; }
header h1 span { color: var(--accent); }
.layout {
flex: 1;
display: flex;
min-height: 0;
}
.sidebar {
width: 280px;
flex-shrink: 0;
background: var(--sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar-header {
padding: 0.875rem;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sidebar-header input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-family: var(--mono);
font-size: 0.8125rem;
}
.sidebar-header input:focus {
outline: none;
border-color: var(--accent);
}
button {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: #0f1117;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
white-space: nowrap;
}
button:hover:not(:disabled) { background: var(--accent-hover); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.session-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.session-item {
width: 100%;
text-align: left;
padding: 0.75rem;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text);
font-weight: 400;
cursor: pointer;
margin-bottom: 0.25rem;
}
.session-item:hover { background: rgba(255,255,255,0.04); }
.session-item.active {
background: var(--accent-dim);
border-color: rgba(108, 158, 255, 0.35);
}
.session-item-title {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-item-meta {
font-size: 0.75rem;
color: var(--muted);
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-empty {
padding: 1.5rem 1rem;
color: var(--muted);
font-size: 0.875rem;
text-align: center;
}
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.chat-header {
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border);
font-size: 0.8125rem;
color: var(--muted);
font-family: var(--mono);
flex-shrink: 0;
}
.chat-header strong { color: var(--text); font-weight: 500; }
#messages {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.25rem;
min-height: 0;
}
.turn {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.msg {
padding: 0.75rem 1rem;
border-radius: var(--radius);
line-height: 1.6;
font-size: 0.9375rem;
word-break: break-word;
}
.msg.user {
background: var(--user-bg);
align-self: flex-end;
max-width: 85%;
white-space: pre-wrap;
}
.msg.assistant {
background: var(--assistant-bg);
border: 1px solid var(--border);
}
.md-content :first-child { margin-top: 0; }
.md-content :last-child { margin-bottom: 0; }
.md-content p { margin: 0.5em 0; }
.md-content h1, .md-content h2, .md-content h3,
.md-content h4, .md-content h5, .md-content h6 {
margin: 0.85em 0 0.4em;
line-height: 1.35;
font-weight: 600;
}
.md-content h1 { font-size: 1.35em; }
.md-content h2 { font-size: 1.2em; }
.md-content h3 { font-size: 1.08em; }
.md-content ul, .md-content ol {
margin: 0.5em 0;
padding-left: 1.4em;
}
.md-content li { margin: 0.2em 0; }
.md-content blockquote {
margin: 0.5em 0;
padding: 0.25em 0.75em;
border-left: 3px solid var(--accent);
color: var(--muted);
}
.md-content hr {
border: none;
border-top: 1px solid var(--border);
margin: 0.75em 0;
}
.md-content code {
font-family: var(--mono);
font-size: 0.875em;
background: rgba(0, 0, 0, 0.35);
padding: 0.15em 0.35em;
border-radius: 4px;
}
.md-content pre {
margin: 0.6em 0;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--border);
border-radius: 8px;
overflow-x: auto;
}
.md-content pre code {
background: none;
padding: 0;
font-size: 0.8125rem;
line-height: 1.5;
white-space: pre;
}
.md-content a {
color: var(--accent);
text-decoration: none;
}
.md-content a:hover { text-decoration: underline; }
.md-content table {
width: 100%;
border-collapse: collapse;
font-size: 0.875em;
}
.md-content .table-wrap {
margin: 0.6em 0;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.md-content .table-wrap table { margin: 0; }
.md-content th, .md-content td {
border: 1px solid var(--border);
padding: 0.35em 0.6em;
text-align: left;
}
.md-content th { background: rgba(255, 255, 255, 0.05); }
.msg.error {
background: #2a1a1a;
border: 1px solid var(--error);
color: var(--error);
}
.tool-block {
border: 1px solid #2a3d35;
border-radius: var(--radius);
background: var(--tool-bg);
overflow: hidden;
}
.tool-block summary {
cursor: pointer;
padding: 0.5rem 0.75rem;
font-family: var(--mono);
font-size: 0.8125rem;
color: var(--muted);
user-select: none;
list-style: none;
}
.tool-block summary::-webkit-details-marker { display: none; }
.tool-block summary::before {
content: "▸ ";
display: inline-block;
transition: transform 0.15s;
}
.tool-block[open] summary::before { transform: rotate(90deg); }
.tool-block.running summary { color: var(--accent); }
.tool-section {
padding: 0 0.75rem 0.75rem;
border-top: 1px solid rgba(255,255,255,0.05);
}
.tool-section-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
margin: 0.5rem 0 0.25rem;
}
.tool-body {
margin: 0;
padding: 0.625rem 0.75rem;
background: rgba(0,0,0,0.25);
border-radius: 6px;
font-family: var(--mono);
font-size: 0.75rem;
line-height: 1.5;
color: #b8c0d0;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.token-info {
align-self: flex-start;
font-size: 0.75rem;
color: var(--muted);
font-family: var(--mono);
padding: 0.25rem 0.5rem;
}
.empty {
color: var(--muted);
text-align: center;
padding: 3rem 1rem;
font-size: 0.9375rem;
}
.composer {
padding: 1rem 1.25rem 1.25rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.composer textarea {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font-family: var(--font);
font-size: 0.9375rem;
resize: none;
min-height: 52px;
max-height: 200px;
line-height: 1.5;
}
.composer textarea:focus {
outline: none;
border-color: var(--accent);
}
.typing::after {
content: "▋";
animation: blink 1s step-end infinite;
color: var(--accent);
}
@keyframes blink { 50% { opacity: 0; } }
</style>
</head>
<body>
<header>
<h1><span>Code</span>I</h1>
</header>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<input type="text" id="cwd" placeholder="工作目录" />
<button id="create-btn" type="button">新建会话</button>
</div>
<div class="session-list" id="session-list">
<div class="sidebar-empty" id="sidebar-empty">暂无会话</div>
</div>
</aside>
<section class="chat-panel">
<div class="chat-header" id="chat-header">选择或创建一个会话</div>
<div id="messages">
<div class="empty" id="placeholder">在左侧输入工作目录并新建会话,然后开始对话。</div>
</div>
<div class="composer">
<textarea id="input" rows="2" placeholder="输入消息… (Enter 发送, Shift+Enter 换行)" disabled></textarea>
<button id="send-btn" type="button" disabled>发送</button>
</div>
</section>
</div>
<script>
const cwdInput = document.getElementById("cwd");
const createBtn = document.getElementById("create-btn");
const sessionListEl = document.getElementById("session-list");
const sidebarEmptyEl = document.getElementById("sidebar-empty");
const chatHeaderEl = document.getElementById("chat-header");
const messagesEl = document.getElementById("messages");
const placeholder = document.getElementById("placeholder");
const input = document.getElementById("input");
const sendBtn = document.getElementById("send-btn");
let sessionId = null;
let streaming = false;
let currentTurnEl = null;
let currentAssistantEl = null;
let currentAssistantText = "";
let pendingTools = [];
init();
async function init() {
try {
const res = await fetch("/api/config");
const cfg = await res.json();
cwdInput.value = cfg.default_cwd || "";
} catch (_) {}
await refreshSessionList();
}
function sessionTitle(item) {
if (item.title) return item.title;
const base = item.cwd.split("/").filter(Boolean).pop();
return base || item.id.slice(0, 8);
}
function formatTime(iso) {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
async function refreshSessionList() {
try {
const res = await fetch("/api/sessions");
const sessions = await res.json();
renderSessionList(sessions);
} catch (_) {}
}
function renderSessionList(sessions) {
sessionListEl.querySelectorAll(".session-item").forEach((el) => el.remove());
if (!sessions.length) {
sidebarEmptyEl.style.display = "block";
return;
}
sidebarEmptyEl.style.display = "none";
for (const item of sessions) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "session-item" + (item.id === sessionId ? " active" : "");
btn.dataset.id = item.id;
btn.innerHTML =
`<div class="session-item-title">${escapeHtml(sessionTitle(item))}</div>` +
`<div class="session-item-meta">${escapeHtml(item.cwd)} · ${item.message_count} 条</div>`;
btn.addEventListener("click", () => selectSession(item.id));
sessionListEl.appendChild(btn);
}
}
async function selectSession(id) {
if (streaming) return;
sessionId = id;
try {
const res = await fetch(`/api/sessions/${id}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载失败");
cwdInput.value = data.cwd;
chatHeaderEl.innerHTML = `工作目录:<strong>${escapeHtml(data.cwd)}</strong>`;
renderHistory(data.messages);
setComposerEnabled(true);
await refreshSessionList();
input.focus();
} catch (err) {
alert(err.message);
}
}
function clearMessages() {
messagesEl.innerHTML = "";
currentTurnEl = null;
currentAssistantEl = null;
pendingTools = [];
}
function showPlaceholder(text) {
clearMessages();
const el = document.createElement("div");
el.className = "empty";
el.id = "placeholder";
el.textContent = text;
messagesEl.appendChild(el);
}
function clearPlaceholder() {
const el = document.getElementById("placeholder");
if (el) el.remove();
}
function createTurnEl() {
const turn = document.createElement("div");
turn.className = "turn";
messagesEl.appendChild(turn);
return turn;
}
function appendUserMessage(content) {
clearPlaceholder();
const el = document.createElement("div");
el.className = "msg user";
el.textContent = content;
messagesEl.appendChild(el);
scrollToBottom();
return el;
}
function finalizeAssistantSegment() {
if (!currentAssistantEl) return;
currentAssistantEl.classList.remove("typing");
if (!currentAssistantText.trim()) {
currentAssistantEl.remove();
}
currentAssistantEl = null;
currentAssistantText = "";
}
function startNewAssistantSegment(typing) {
if (!currentTurnEl) return;
currentAssistantText = "";
currentAssistantEl = createAssistantMessage("", typing);
currentTurnEl.appendChild(currentAssistantEl);
}
function beginTurn() {
clearPlaceholder();
currentTurnEl = createTurnEl();
pendingTools = [];
startNewAssistantSegment(true);
}
function createToolBlock(name, status, argsText, resultText) {
const details = document.createElement("details");
details.className = "tool-block" + (status === "running" ? " running" : "");
if (status === "running") details.open = true;
const summary = document.createElement("summary");
summary.textContent = `${statusIcon(status)} ${name}`;
details.appendChild(summary);
if (argsText) {
const section = document.createElement("div");
section.className = "tool-section";
section.innerHTML = `<div class="tool-section-label">参数</div>`;
const pre = document.createElement("pre");
pre.className = "tool-body";
pre.textContent = argsText;
section.appendChild(pre);
details.appendChild(section);
}
if (resultText != null) {
const section = document.createElement("div");
section.className = "tool-section";
section.innerHTML = `<div class="tool-section-label">结果</div>`;
const pre = document.createElement("pre");
pre.className = "tool-body";
pre.textContent = resultText;
section.appendChild(pre);
details.appendChild(section);
}
return details;
}
function statusIcon(status) {
if (status === "running") return "▶";
if (status === "error") return "✗";
return "✓";
}
function appendToolToTurn(details) {
if (!currentTurnEl) return;
finalizeAssistantSegment();
currentTurnEl.appendChild(details);
startNewAssistantSegment(true);
scrollToBottom();
}
function renderHistory(messages) {
clearMessages();
if (!messages.length) {
showPlaceholder("会话已就绪,发送第一条消息吧。");
return;
}
let turnEl = null;
for (const msg of messages) {
if (msg.role === "user") {
appendUserMessage(msg.content);
turnEl = null;
continue;
}
if (msg.role === "tool") {
if (!turnEl) turnEl = createTurnEl();
const name = msg.tool_name || "tool";
const details = createToolBlock(name, "done", null, msg.content);
turnEl.appendChild(details);
continue;
}
if (msg.role === "assistant") {
if (!turnEl) turnEl = createTurnEl();
turnEl.appendChild(createAssistantMessage(msg.content, false));
}
}
scrollToBottom();
}
function escapeHtml(text) {
const d = document.createElement("div");
d.textContent = text;
return d.innerHTML;
}
function renderInline(text) {
let s = escapeHtml(text);
s = s.replace(/`([^`\n]+)`/g, "<code>$1</code>");
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
s = s.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/__([^_\n]+)__/g, "<strong>$1</strong>");
s = s.replace(/\*([^*\n]+)\*/g, "<em>$1</em>");
s = s.replace(/_([^_\n]+)_/g, "<em>$1</em>");
return s;
}
function isTableRow(line) {
const trimmed = line.trim();
return trimmed.length > 0 && trimmed.includes("|");
}
function isTableSeparator(line) {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
if (!trimmed.includes("-")) return false;
return trimmed.split("|").every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
}
function parseTableCells(line) {
let row = line.trim();
if (row.startsWith("|")) row = row.slice(1);
if (row.endsWith("|")) row = row.slice(0, -1);
return row.split("|").map((cell) => cell.trim());
}
function parseTableAlignments(separatorLine) {
return parseTableCells(separatorLine).map((cell) => {
const t = cell.trim();
if (t.startsWith(":") && t.endsWith(":")) return "center";
if (t.endsWith(":")) return "right";
return "left";
});
}
function renderTable(headerLine, separatorLine, bodyLines) {
const headers = parseTableCells(headerLine);
const aligns = parseTableAlignments(separatorLine);
let out = '<div class="table-wrap"><table><thead><tr>';
headers.forEach((header, idx) => {
const align = aligns[idx] || "left";
out += `<th style="text-align:${align}">${renderInline(header)}</th>`;
});
out += "</tr></thead><tbody>";
for (const rowLine of bodyLines) {
const cells = parseTableCells(rowLine);
out += "<tr>";
cells.forEach((cell, idx) => {
const align = aligns[idx] || "left";
out += `<td style="text-align:${align}">${renderInline(cell)}</td>`;
});
out += "</tr>";
}
out += "</tbody></table></div>";
return out;
}
function renderMarkdownBlock(text) {
const lines = text.split("\n");
const html = [];
let inUl = false;
let inOl = false;
let inQuote = false;
const closeLists = () => {
if (inUl) { html.push("</ul>"); inUl = false; }
if (inOl) { html.push("</ol>"); inOl = false; }
};
const closeQuote = () => {
if (inQuote) { html.push("</blockquote>"); inQuote = false; }
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (isTableRow(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
closeLists();
closeQuote();
const headerLine = line;
const separatorLine = lines[i + 1];
const bodyLines = [];
i += 2;
while (i < lines.length && isTableRow(lines[i]) && !isTableSeparator(lines[i])) {
bodyLines.push(lines[i]);
i++;
}
html.push(renderTable(headerLine, separatorLine, bodyLines));
i--;
continue;
}
const trimmed = line.trim();
if (/^-{3,}$/.test(trimmed) || /^\*{3,}$/.test(trimmed)) {
closeLists();
closeQuote();
html.push("<hr>");
continue;
}
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
closeLists();
closeQuote();
const level = heading[1].length;
html.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
continue;
}
const ul = line.match(/^[\-*]\s+(.*)$/);
if (ul) {
closeQuote();
if (!inUl) {
closeLists();
html.push("<ul>");
inUl = true;
}
html.push(`<li>${renderInline(ul[1])}</li>`);
continue;
}
const ol = line.match(/^\d+\.\s+(.*)$/);
if (ol) {
closeQuote();
if (!inOl) {
closeLists();
html.push("<ol>");
inOl = true;
}
html.push(`<li>${renderInline(ol[1])}</li>`);
continue;
}
const quote = line.match(/^>\s?(.*)$/);
if (quote) {
closeLists();
if (!inQuote) {
html.push("<blockquote>");
inQuote = true;
}
html.push(`<p>${renderInline(quote[1])}</p>`);
continue;
}
if (trimmed === "") {
closeLists();
closeQuote();
continue;
}
closeLists();
closeQuote();
html.push(`<p>${renderInline(line)}</p>`);
}
closeLists();
closeQuote();
return html.join("");
}
function renderMarkdown(source) {
if (!source) return "";
const parts = [];
let lastIndex = 0;
const re = /```([^\n]*)\n([\s\S]*?)```/g;
let match;
while ((match = re.exec(source)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: "text", content: source.slice(lastIndex, match.index) });
}
parts.push({ type: "code", lang: match[1].trim(), content: match[2].replace(/\n$/, "") });
lastIndex = match.index + match[0].length;
}
if (lastIndex < source.length) {
parts.push({ type: "text", content: source.slice(lastIndex) });
}
return parts.map((part) => {
if (part.type === "code") {
const lang = part.lang
? ` class="language-${escapeHtml(part.lang)}"`
: "";
return `<pre><code${lang}>${escapeHtml(part.content)}</code></pre>`;
}
return renderMarkdownBlock(part.content);
}).join("");
}
function createAssistantMessage(content, typing) {
const el = document.createElement("div");
el.className = "msg assistant" + (typing ? " typing" : "");
const md = document.createElement("div");
md.className = "md-content";
md.innerHTML = renderMarkdown(content || "");
el.appendChild(md);
return el;
}
function updateAssistantMarkdown(el, text) {
const md = el.querySelector(".md-content");
if (md) {
md.innerHTML = renderMarkdown(text);
} else {
el.textContent = text;
}
}
function scrollToBottom() {
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function setComposerEnabled(enabled) {
input.disabled = !enabled;
sendBtn.disabled = !enabled || streaming;
}
createBtn.addEventListener("click", async () => {
const cwd = cwdInput.value.trim();
if (!cwd) {
alert("请输入工作目录");
return;
}
createBtn.disabled = true;
try {
const res = await fetch("/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "创建失败");
await refreshSessionList();
await selectSession(data.id);
} catch (err) {
alert(err.message);
} finally {
createBtn.disabled = false;
}
});
async function sendMessage() {
const text = input.value.trim();
if (!text || !sessionId || streaming) return;
input.value = "";
appendUserMessage(text);
beginTurn();
streaming = true;
sendBtn.disabled = true;
try {
const res = await fetch(`/api/sessions/${sessionId}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "请求失败");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
const lines = part.split("\n");
let eventData = null;
for (const line of lines) {
if (line.startsWith("data: ")) eventData = line.slice(6);
}
if (!eventData || eventData === "[DONE]") continue;
let event;
try { event = JSON.parse(eventData); } catch { continue; }
handleEvent(event);
}
}
} catch (err) {
if (currentAssistantEl) {
currentAssistantEl.classList.remove("typing");
currentAssistantEl.classList.add("error");
currentAssistantEl.textContent = err.message;
} else {
const el = document.createElement("div");
el.className = "msg error";
el.textContent = err.message;
messagesEl.appendChild(el);
}
} finally {
streaming = false;
finalizeAssistantSegment();
currentTurnEl = null;
pendingTools = [];
setComposerEnabled(true);
await refreshSessionList();
input.focus();
}
}
function handleEvent(event) {
switch (event.type) {
case "assistant_delta":
if (!currentAssistantEl) startNewAssistantSegment(true);
if (currentAssistantEl) {
currentAssistantText += event.text;
updateAssistantMarkdown(currentAssistantEl, currentAssistantText);
scrollToBottom();
}
break;
case "tool_started": {
const argsText = JSON.stringify(event.args, null, 2);
const details = createToolBlock(event.name, "running", argsText, null);
appendToolToTurn(details);
pendingTools.push({ name: event.name, details });
break;
}
case "tool_finished": {
let idx = -1;
for (let i = pendingTools.length - 1; i >= 0; i--) {
if (pendingTools[i].name === event.name) {
idx = i;
break;
}
}
if (idx >= 0) {
const entry = pendingTools[idx];
pendingTools.splice(idx, 1);
const status = event.is_error ? "error" : "done";
const updated = createToolBlock(
event.name,
status,
entry.details.querySelector(".tool-body")?.textContent || null,
event.content
);
entry.details.replaceWith(updated);
scrollToBottom();
} else {
const details = createToolBlock(
event.name,
event.is_error ? "error" : "done",
null,
event.content
);
if (currentTurnEl) {
finalizeAssistantSegment();
currentTurnEl.appendChild(details);
startNewAssistantSegment(true);
}
scrollToBottom();
}
break;
}
case "turn_complete":
if (event.input_tokens != null && currentTurnEl) {
const info = document.createElement("div");
info.className = "token-info";
info.textContent = `tokens: in ${event.input_tokens} · out ${event.output_tokens}`;
currentTurnEl.appendChild(info);
scrollToBottom();
}
break;
case "error":
if (currentAssistantEl) {
currentAssistantEl.classList.remove("typing");
currentAssistantEl.classList.add("error");
currentAssistantEl.textContent = event.message;
currentAssistantEl = null;
} else {
const el = document.createElement("div");
el.className = "msg error";
el.textContent = event.message;
messagesEl.appendChild(el);
}
break;
}
}
sendBtn.addEventListener("click", sendMessage);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
</script>
</body>
</html>