Files
opendan/web/templates/index.html
T

641 lines
26 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenDAN Web</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
.header { background: #16213e; padding: 12px 20px; display: flex; align-items: center;
justify-content: space-between; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
.header h1 { font-size: 18px; color: #e94560; }
.header .status { font-size: 12px; color: #888; display: flex; align-items: center; gap: 6px; }
.header .status .dot { width: 8px; height: 8px; border-radius: 50%; }
.header .status .dot.online { background: #4ecca3; }
.header .status .dot.offline { background: #e94560; }
.header .status .dot.busy { background: #f0c040; animation: pulse 1s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.chat { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 85%; padding: 10px 14px; border-radius: 12px; line-height: 1.6;
font-size: 14px; animation: fadeIn .2s; overflow-wrap: break-word; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.msg.user { background: #0f3460; align-self: flex-end; border-bottom-right-radius: 4px; }
.msg.assistant { background: #16213e; align-self: flex-start; border-bottom-left-radius: 4px; }
.msg.assistant p { margin-bottom: 8px; }
.msg.assistant p:last-child { margin-bottom: 0; }
.msg.assistant code { background: #0a0a1a; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
.msg.assistant pre { background: #0a0a1a; padding: 10px; border-radius: 6px; overflow-x: auto;
margin: 8px 0; border: 1px solid #222; }
.msg.assistant pre code { background: none; padding: 0; font-size: 12px; }
.msg.assistant ul, .msg.assistant ol { padding-left: 20px; margin: 6px 0; }
.msg.assistant strong { color: #fff; }
.msg.assistant blockquote { border-left: 3px solid #e94560; padding-left: 10px; margin: 6px 0; color: #aaa; }
/* Tool execution cards */
.tool-group { border: 1px solid #333; border-radius: 8px; padding: 8px 12px; font-size: 12px;
font-family: monospace; background: #111; max-width: 100%; }
.tool-group .tool-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.tool-group .tool-header .tool-icon { font-size: 14px; }
.tool-group .tool-call { color: #e94560; font-weight: bold; }
.tool-group .tool-args { color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex:1; }
.tool-group .tool-status { margin-top: 4px; display: flex; align-items: center; gap: 6px; }
.tool-group .tool-status .spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #555;
border-top-color: #4ecca3; border-radius: 50%; animation: spin .6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.tool-group .tool-result-text { margin-top: 4px; background: #0a0a1a; padding: 6px 8px; border-radius: 4px;
max-height: 150px; overflow: auto; white-space: pre-wrap; font-size: 11px; color: #4ecca3; }
.tool-group .tool-result-text.error { color: #e94560; }
.tool-group .tool-result-text.skipped { color: #888; font-style: italic; }
/* Proposal cards */
.proposal-card { border: 1px solid #e9456044; border-radius: 10px; padding: 10px 14px;
background: #1a1a2e; max-width: 100%; animation: fadeIn .3s; }
.proposal-card .pc-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.proposal-card .pc-header .pc-icon { color: #f0c040; font-size: 16px; }
.proposal-card .pc-name { color: #e94560; font-weight: bold; font-family: monospace; font-size: 13px; }
.proposal-card .pc-args { background: #0a0a1a; border-radius: 4px; padding: 6px 8px;
font-family: monospace; font-size: 11px; max-height: 100px;
overflow: auto; white-space: pre-wrap; margin: 4px 0; color: #aaa; }
.proposal-card .pc-actions { display: flex; gap: 8px; margin-top: 8px; }
.proposal-card .pc-actions button { padding: 4px 14px; border-radius: 6px; border: none;
cursor: pointer; font-size: 12px; font-weight: bold; }
.proposal-card .pc-actions .btn-approve { background: #2a6e3c; color: #4ecca3; }
.proposal-card .pc-actions .btn-approve:hover { background: #3a8e4c; }
.proposal-card .pc-actions .btn-approve.active { background: #4ecca3; color: #111; }
.proposal-card .pc-actions .btn-reject { background: #6e2a2a; color: #e94560; }
.proposal-card .pc-actions .btn-reject:hover { background: #8e3a3a; }
.proposal-card .pc-actions .btn-reject.active { background: #e94560; color: #fff; }
.proposal-card .pc-actions .btn-approve:disabled, .proposal-card .pc-actions .btn-reject:disabled { opacity: .4; cursor: not-allowed; }
/* Run bar */
.run-bar { display: flex; align-items: center; gap: 10px; background: #16213e; border: 1px solid #0f3460;
border-radius: 10px; padding: 10px 16px; max-width: 100%; }
.run-bar .rb-text { font-size: 13px; color: #aaa; flex:1; }
.run-bar .rb-btn { background: #e94560; color: white; border: none; border-radius: 8px;
padding: 8px 20px; cursor: pointer; font-weight: bold; font-size: 13px; }
.run-bar .rb-btn:disabled { opacity: .4; cursor: not-allowed; }
.run-bar .rb-btn:hover:not(:disabled) { background: #d63851; }
/* Toolbar */
.toolbar { background: #16213e; padding: 6px 20px; border-top: 1px solid #0f3460;
display: flex; gap: 4px; flex-wrap: wrap; flex-shrink: 0; }
.toolbar .tbtn { background: #111a2e; border: 1px solid #333; border-radius: 6px;
padding: 3px 10px; cursor: pointer; font-size: 11px; color: #aaa;
display: flex; align-items: center; gap: 4px; transition: all .1s; }
.toolbar .tbtn:hover { border-color: #e94560; color: #e0e0e0; background: #1a1a3e; }
.toolbar .tbtn:active { transform: scale(.95); }
.toolbar .tbtn .ti { font-size: 13px; }
/* Thinking bar */
.think-bar { display: none; align-items: center; gap: 8px; background: #111a2e;
border: 1px solid #0f3460; border-radius: 8px; padding: 8px 14px;
font-size: 12px; color: #aaa; animation: fadeIn .15s; cursor: default;
position: sticky; bottom: 0; z-index: 10; margin-top: auto; }
.think-bar.visible { display: flex; }
.think-bar .tb-spinner { width: 12px; height: 12px; border: 2px solid #555;
border-top-color: #f0c040; border-radius: 50%;
animation: spin .6s linear infinite; flex-shrink: 0; }
.think-bar .tb-label { color: #f0c040; font-weight: bold; }
.think-bar .tb-detail { color: #888; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; flex: 1; min-width: 0; }
.think-bar .tb-hint { color: #555; font-size: 10px; flex-shrink: 0; }
/* Hover popup */
.think-popup { display: none; position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%);
width: min(90vw, 700px); max-height: 40vh; background: #0a0a1a;
border: 1px solid #0f3460; border-radius: 10px; padding: 14px;
font-family: monospace; font-size: 12px; color: #ccc; white-space: pre-wrap;
overflow: auto; z-index: 100; box-shadow: 0 8px 30px rgba(0,0,0,.5); }
.think-popup.visible { display: block; }
.think-popup .tp-label { color: #f0c040; font-size: 10px; text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 8px; }
.think-popup .tp-tool { color: #e94560; font-weight: bold; }
/* Suggestion chips */
.suggestions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; max-width: 85%; align-self: flex-start; }
.suggestions .chip { background: #0f3460; color: #aac; border: 1px solid #1f4470; border-radius: 16px;
padding: 6px 14px; font-size: 12px; cursor: pointer;
transition: all .15s; }
.suggestions .chip:hover { background: #1f4470; border-color: #e94560; color: #e0e0e0; }
.suggestions .chip:active { transform: scale(.96); }
/* Msg footer */
.msg { position: relative; }
.msg-content { min-height: 1em; }
.msg-footer { display: flex; gap: 4px; justify-content: flex-end; margin-top: 6px;
opacity: 0; transition: opacity .15s; }
.msg:hover .msg-footer { opacity: .4; }
.msg-footer:hover { opacity: 1 !important; }
.action-btn { background: none; border: 1px solid #444; border-radius: 4px; color: #888;
cursor: pointer; font-size: 11px; padding: 0 6px; line-height: 20px; }
.action-btn:hover { border-color: #e94560; color: #e94560; background: #1a1a3e; }
.cancel-btn { border-color: #e9456044; color: #e94560; }
.cancel-btn:hover { border-color: #e94560; background: #2a1a1e; }
/* Input */
.input-area { background: #16213e; padding: 12px 20px; border-top: 1px solid #0f3460;
display: flex; gap: 8px; flex-shrink: 0; }
.input-area input { flex: 1; background: #1a1a2e; border: 1px solid #333; border-radius: 8px;
padding: 10px 14px; color: #e0e0e0; font-size: 14px; outline: none; }
.input-area input:focus { border-color: #e94560; }
.input-area button { background: #e94560; color: white; border: none; border-radius: 8px;
padding: 10px 20px; cursor: pointer; font-weight: bold; font-size: 14px; }
.input-area button:disabled { opacity: .5; cursor: not-allowed; }
.input-area button:hover:not(:disabled) { background: #d63851; }
.clear-btn { background: none; border: 1px solid #333; color: #888; padding: 4px 10px;
border-radius: 6px; cursor: pointer; font-size: 12px; }
.clear-btn:hover { border-color: #e94560; color: #e94560; }
</style>
</head>
<body>
<div class="header">
<h1>OpenDAN</h1>
<div style="display:flex;align-items:center;gap:12px">
<span class="status"><span class="dot online" id="statusDot"></span><span id="statusText">Ready</span></span>
<button class="clear-btn" onclick="clearChat()">Clear</button>
</div>
</div>
<div class="chat" id="chat">
<div class="think-bar" id="thinkBar" onmouseenter="showThinkPopup()" onmouseleave="hideThinkPopup()">
<div class="tb-spinner"></div>
<span class="tb-label">Thinking</span>
<span class="tb-detail" id="thinkDetail"></span>
<span class="tb-hint">hover to see output</span>
</div>
</div>
<div class="think-popup" id="thinkPopup" onmouseenter="showThinkPopup()" onmouseleave="hideThinkPopup()">
<div class="tp-label">Raw output</div>
<div id="thinkPopupContent"></div>
</div>
<div class="toolbar">
<button class="tbtn" onclick="injectTool('Run: ')"><span class="ti"></span> Shell</button>
<button class="tbtn" onclick="injectTool('Run python: ')"><span class="ti">🐍</span> Code</button>
<button class="tbtn" onclick="injectTool('Search: ')"><span class="ti">🔍</span> Search</button>
<button class="tbtn" onclick="injectTool('Read: ')"><span class="ti">📄</span> Read</button>
<button class="tbtn" onclick="injectTool('Write to ')"><span class="ti">✏️</span> Write</button>
<button class="tbtn" onclick="injectTool('List: ')"><span class="ti">📁</span> List</button>
<button class="tbtn" onclick="injectTool('What time is it?')"><span class="ti">🕐</span> Now</button>
</div>
<div class="input-area">
<input type="text" id="input" placeholder="Ask me anything..." autofocus
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send()}">
<button id="sendBtn" onclick="send()">Send</button>
</div>
<script>
const chat = document.getElementById('chat');
const input = document.getElementById('input');
const sendBtn = document.getElementById('sendBtn');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const thinkBar = document.getElementById('thinkBar');
const thinkDetail = document.getElementById('thinkDetail');
const thinkPopup = document.getElementById('thinkPopup');
const thinkPopupContent = document.getElementById('thinkPopupContent');
let threadId = 'default';
let loading = false;
let abortController = null;
let _lastUserText = '';
let _currentMsgEl = null;
let _thinkContent = '';
let _thinkTool = '';
let _popupVisible = false;
function setStatus(state) {
statusDot.className = 'dot ' + state;
if (state === 'online') statusText.textContent = 'Ready';
else if (state === 'busy') statusText.textContent = 'Thinking...';
else if (state === 'waiting') statusText.textContent = 'Awaiting approval';
else statusText.textContent = 'Offline';
}
function scrollBottom() { chat.scrollTop = chat.scrollHeight; }
function createAssistant(contentHtml, isStreaming) {
const div = document.createElement('div');
div.className = 'msg assistant';
div.dataset.streaming = isStreaming ? '1' : '0';
div.innerHTML = `<div class="msg-content">${contentHtml}</div>
<div class="msg-footer">
<button class="action-btn cancel-btn" onclick="cancelMsg(this)" ${isStreaming ? '' : 'style="display:none"'}>✕</button>
<button class="action-btn retry-btn" onclick="retryMsg(this)" ${isStreaming ? 'style="display:none"' : ''}>↻</button>
</div>`;
chat.appendChild(div);
scrollBottom();
return div;
}
function finishStreaming(el) {
if (!el) return;
el.dataset.streaming = '0';
const c = el.querySelector('.cancel-btn'), r = el.querySelector('.retry-btn');
if (c) c.style.display = 'none';
if (r) r.style.display = '';
}
function cancelMsg(btn) {
if (abortController) { abortController.abort(); abortController = null; }
const el = btn.closest('.msg.assistant');
if (el) el.remove();
hideThink();
_currentMsgEl = null;
setStatus('online');
loading = false;
input.disabled = false;
sendBtn.disabled = false;
}
function retryMsg(btn) {
const el = btn.closest('.msg.assistant');
const userEl = el.previousElementSibling;
if (!_lastUserText) return;
if (userEl && userEl.classList.contains('msg.user')) userEl.remove();
el.remove();
input.value = _lastUserText;
send();
}
/* ── Thinking indicator ── */
function showThink() {
thinkBar.classList.add('visible');
_thinkContent = '';
_thinkTool = '';
thinkDetail.textContent = '';
}
function hideThink() {
thinkBar.classList.remove('visible');
thinkPopup.classList.remove('visible');
_popupVisible = false;
_thinkContent = '';
_thinkTool = '';
}
function updateThink(text, toolName) {
if (toolName) _thinkTool = toolName;
if (text) _thinkContent += text;
const detail = _thinkTool ? _thinkTool + '...' : (_thinkContent.slice(-60) || 'generating...');
thinkDetail.textContent = detail;
if (_popupVisible) {
thinkPopupContent.textContent = _thinkContent || '(waiting for output...)';
}
}
function setThinkTool(name) {
_thinkTool = name;
thinkDetail.textContent = name + '...';
}
function showThinkPopup() {
_popupVisible = true;
thinkPopup.classList.add('visible');
thinkPopupContent.textContent = _thinkContent || '(waiting for output...)';
}
function hideThinkPopup() {
_popupVisible = false;
thinkPopup.classList.remove('visible');
}
function addMsg(role, html) {
const div = document.createElement('div');
div.className = 'msg ' + role;
div.innerHTML = html;
chat.appendChild(div);
scrollBottom();
return div;
}
/* ── Proposal cards ── */
function addProposalCards(proposalId, toolCalls) {
const container = document.createElement('div');
container.id = 'proposal-' + proposalId;
container.style.cssText = 'display:flex;flex-direction:column;gap:8px;max-width:100%';
const decisions = {};
for (const tc of toolCalls) {
const card = document.createElement('div');
card.className = 'proposal-card';
card.dataset.tcId = tc.tool_call_id;
const argsStr = JSON.stringify(tc.args, null, 2);
card.innerHTML = `
<div class="pc-header">
<span class="pc-icon">⚡</span>
<span class="pc-name">${escaped(tc.name)}</span>
</div>
<div class="pc-args">${escaped(argsStr)}</div>
<div class="pc-actions">
<button class="btn-approve" onclick="setDecision('${proposalId}','${tc.tool_call_id}','approve')">✅ Approve</button>
<button class="btn-reject active" onclick="setDecision('${proposalId}','${tc.tool_call_id}','reject')">❌ Reject</button>
</div>`;
container.appendChild(card);
decisions[tc.tool_call_id] = false;
}
// Run bar
const runBar = document.createElement('div');
runBar.className = 'run-bar';
runBar.id = 'runbar-' + proposalId;
const approvedCount = 0;
runBar.innerHTML = `
<span class="rb-text" id="rb-text-${proposalId}">Select which tools to run</span>
<button class="rb-btn" id="rb-btn-${proposalId}" disabled onclick="executeProposal('${proposalId}')">▶ Run Selected</button>`;
container.appendChild(runBar);
chat.appendChild(container);
scrollBottom();
window._pd = window._pd || {};
window._pd[proposalId] = decisions;
}
function setDecision(proposalId, tcId, action) {
const pd = window._pd && window._pd[proposalId];
if (!pd) return;
pd[tcId] = action === 'approve';
const card = document.querySelector(`#proposal-${proposalId} .proposal-card[data-tc-id="${tcId}"]`);
if (card) {
const app = card.querySelector('.btn-approve');
const rej = card.querySelector('.btn-reject');
if (action === 'approve') { app.classList.add('active'); rej.classList.remove('active'); }
else { rej.classList.add('active'); app.classList.remove('active'); }
}
// Update run button
const vals = Object.values(pd);
const anyApproved = vals.some(v => v === true);
const allDone = vals.length > 0 && vals.every(v => v !== undefined);
const btn = document.getElementById('rb-btn-' + proposalId);
const txt = document.getElementById('rb-text-' + proposalId);
if (btn) {
const approved = vals.filter(v => v === true).length;
const total = vals.length;
if (approved === 0) {
btn.disabled = true;
txt.textContent = 'Select which tools to run';
} else {
btn.disabled = false;
txt.textContent = `${approved} of ${total} tool${total > 1 ? 's' : ''} approved`;
}
}
}
/* ── Tool execution display (non-proposal, instant mode) ── */
function addToolGroup(name, args) {
const div = document.createElement('div');
div.className = 'tool-group';
div.dataset.toolName = name;
const argsStr = JSON.stringify(args).slice(0, 300);
div.innerHTML = `
<div class="tool-header">
<span class="tool-icon">⚡</span>
<span class="tool-call">${escaped(name)}</span>
<span class="tool-args">${escaped(argsStr)}</span>
</div>
<div class="tool-status"><span class="spinner"></span> Running...</div>`;
chat.appendChild(div);
scrollBottom();
return div;
}
function updateToolGroup(el, result) {
const statusDiv = el.querySelector('.tool-status');
if (result.status === 'success') {
const text = result.stdout || result.content || (result.items ? result.items.length + ' items' : '') || 'Done';
statusDiv.innerHTML = '✅ Done';
if (text) {
const pre = document.createElement('div');
pre.className = 'tool-result-text';
pre.textContent = (typeof text === 'string' ? text : String(text)).slice(0, 2000);
el.appendChild(pre);
}
} else if (result.status === 'skipped') {
statusDiv.innerHTML = '⏭️ Skipped';
const pre = document.createElement('div');
pre.className = 'tool-result-text skipped';
pre.textContent = result.reason || 'Skipped';
el.appendChild(pre);
} else {
statusDiv.innerHTML = '❌ Error';
const pre = document.createElement('div');
pre.className = 'tool-result-text error';
pre.textContent = result.error || 'Tool failed';
el.appendChild(pre);
}
scrollBottom();
}
/* ── Suggestion chips ── */
function addSuggestions(suggestions) {
const sDiv = document.createElement('div');
sDiv.className = 'suggestions';
sDiv.id = 'suggestions-' + Date.now();
for (const s of suggestions) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = s;
chip.onclick = () => { input.value = s; send(); };
sDiv.appendChild(chip);
}
chat.appendChild(sDiv);
scrollBottom();
}
/* ── Send flow ── */
async function send() {
if (loading) return;
const text = input.value.trim();
if (!text) return;
_lastUserText = text;
input.value = '';
addMsg('user', escaped(text));
loading = true;
sendBtn.disabled = true;
input.disabled = true;
setStatus('busy');
showThink();
try {
// Phase 1: Propose
const proposeResp = await fetch('/v1/chat/propose', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: text, thread_id: threadId})
});
const propose = await proposeResp.json();
if (propose.tool_calls && propose.tool_calls.length > 0) {
// Show proposal cards
hideThink();
addProposalCards(propose.proposal_id, propose.tool_calls);
if (propose.content) {
createAssistant(marked.parse(propose.content), false);
}
setStatus('waiting');
loading = false;
sendBtn.disabled = false;
input.disabled = false;
input.focus();
return;
}
// No tools needed — display response directly
if (propose.response) {
hideThink();
createAssistant(marked.parse(propose.response), false);
finishLoading();
return;
}
} catch(e) {
hideThink();
createAssistant('Error: ' + e.message, false);
finishLoading();
}
}
async function executeProposal(proposalId) {
const pd = window._pd && window._pd[proposalId];
if (!pd) return;
const decisions = Object.entries(pd).map(([tcId, approved]) => ({
tool_call_id: tcId, approved
}));
if (decisions.length === 0) return;
// Remove proposal UI
const container = document.getElementById('proposal-' + proposalId);
if (container) container.remove();
window._pd && delete window._pd[proposalId];
loading = true;
sendBtn.disabled = true;
input.disabled = true;
abortController = new AbortController();
setStatus('busy');
const msgDiv = createAssistant('<span class="spinner" style="display:inline-block;width:14px;height:14px;border:2px solid #555;border-top-color:#4ecca3;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle"></span>', true);
try {
const resp = await fetch('/v1/chat/stream', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({proposal_id: proposalId, decisions, thread_id: threadId}),
signal: abortController.signal
});
_currentMsgEl = msgDiv;
await handleStream(resp, msgDiv);
} catch(e) {
if (e.name !== 'AbortError') msgDiv.textContent = 'Error: ' + e.message;
}
msgDiv.id = '';
_currentMsgEl = null;
finishStreaming(msgDiv);
finishLoading();
}
async function streamResponse(originalText) {
loading = true;
abortController = new AbortController();
const msgDiv = createAssistant('<span class="spinner"></span>', true);
msgDiv.id = 'streaming-msg';
try {
const resp = await fetch('/v1/chat/stream', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: originalText, thread_id: threadId}),
signal: abortController.signal
});
_currentMsgEl = msgDiv;
await handleStream(resp, msgDiv);
} catch(e) {
if (e.name !== 'AbortError') msgDiv.textContent = 'Error: ' + e.message;
}
msgDiv.id = '';
_currentMsgEl = null;
finishStreaming(msgDiv);
finishLoading();
}
async function handleStream(resp, msgDiv) {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let content = '';
const toolEls = [];
showThink();
while (true) {
const {done, value} = await reader.read();
if (done) break;
buf += decoder.decode(value, {stream: true});
const lines = buf.split('\n');
buf = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'token') {
const delta = data.data?.choices?.[0]?.delta;
if (delta?.content) {
content += delta.content;
updateThink(delta.content);
if (msgDiv.id === 'streaming-msg') {
try { msgDiv.innerHTML = marked.parse(content, {breaks: true}); }
catch(e) { msgDiv.textContent = content; }
}
scrollBottom();
}
} else if (data.type === 'tool_start') {
const el = addToolGroup(data.name, data.args || {});
if (data.tool_call_id) el.dataset.toolCallId = data.tool_call_id;
toolEls.push(el);
setThinkTool(data.name);
} else if (data.type === 'tool_result') {
const el = toolEls.find(e => {
if (data.tool_call_id) return e.dataset.toolCallId === data.tool_call_id;
return e.dataset.toolName === data.name && !e.dataset.done;
});
if (el) {
el.dataset.done = '1';
updateToolGroup(el, data.result);
}
} else if (data.type === 'suggestions') {
addSuggestions(data.suggestions);
} else if (data.type === 'error') {
const contentDiv = msgDiv.querySelector('.msg-content');
if (contentDiv) contentDiv.textContent = 'Error: ' + data.detail;
else msgDiv.textContent = 'Error: ' + data.detail;
}
} catch(e) {}
}
}
hideThink();
_currentMsgEl = null;
finishStreaming(msgDiv);
}
function finishLoading() {
loading = false;
sendBtn.disabled = false;
input.disabled = false;
input.focus();
setStatus('online');
}
async function clearChat() {
await fetch(`/v1/conversation/${threadId}`, {method: 'DELETE'});
chat.innerHTML = '';
createAssistant('Conversation cleared. How can I help you?', false);
}
function injectTool(template) {
input.value = template;
input.focus();
// Place cursor at end for templates that end with a space (user fills in the rest)
// For "What time is it?" the full prompt is ready to send
if (template.endsWith(' ')) {
const len = input.value.length;
input.setSelectionRange(len, len);
}
}
function escaped(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
</script>
</body>
</html>