Add custom web server and startup script
This commit is contained in:
@@ -0,0 +1,640 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user