655 lines
30 KiB
HTML
655 lines
30 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Omnigent Chat</title>
|
|
<!-- Markdown + syntax highlighting + HTML sanitization from CDN.
|
|
Versions are pinned and protected with Subresource Integrity (SRI):
|
|
the browser refuses to run a file whose hash doesn't match, so a
|
|
compromised/swapped CDN asset can't inject code. Bumping a version
|
|
requires regenerating the matching integrity hash. -->
|
|
<script src="https://cdn.jsdelivr.net/npm/marked@18.0.5/lib/marked.umd.min.js" integrity="sha512-7W46WS6vPCNlOKFEEO1KksrE1R2BfdQoBF9vUTz+mlGqaYrNNDDBqIe+Ckd1alkxeVr0A8M3ISkA34kc+hFGjQ==" crossorigin="anonymous"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.11/dist/purify.min.js" integrity="sha512-lBPWWUhZJjVB3av+jFeCwgle3o8VaQIQu867R9fZh4HlNdPl/9/jeGVayR4BEV635JFXnVecK52KIBEu7UH9Ug==" crossorigin="anonymous"></script>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/github-dark.min.css" integrity="sha512-rO+olRTkcf304DQBxSWxln8JXCzTHlKnIdnMUwYvQa9/Jd4cQaNkItIUj6Z4nvW1dqK0SKXLbn9h4KwZTNtAyw==" crossorigin="anonymous">
|
|
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js" integrity="sha512-EBLzUL8XLl+va/zAsmXwS7Z2B1F9HUHkZwyS/VKwh3S7T/U0nF4BaU29EP/ZSf6zgiIxYAnKLu6bJ8dqpmX5uw==" crossorigin="anonymous"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #f8f9fa; --fg: #1a1a2e; --muted: #f0f0f0; --muted-fg: #6b7280;
|
|
--border: #e5e7eb; --primary: #2563eb; --primary-fg: #fff;
|
|
--card: #ffffff; --card-shadow: 0 1px 3px rgba(0,0,0,.08);
|
|
--code-bg: #1e1e2e; --accent: #eff3ff;
|
|
--success: #16a34a; --warning: #d97706; --error: #dc2626;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #0f0f14; --fg: #e5e7eb; --muted: #1c1c24; --muted-fg: #9ca3af;
|
|
--border: #2a2a36; --primary: #3b82f6; --card: #16161e;
|
|
--card-shadow: 0 1px 3px rgba(0,0,0,.3); --accent: #1e293b;
|
|
}
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
background: var(--bg); color: var(--fg); height: 100vh; display: flex; flex-direction: column;
|
|
}
|
|
/* ---- Header ---- */
|
|
header { flex-shrink: 0; border-bottom: 1px solid var(--border); background: var(--card); padding: 0 24px; }
|
|
.header-inner {
|
|
max-width: 800px; margin: 0 auto; padding: 12px 0;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
}
|
|
.agent-id { display: flex; align-items: center; gap: 12px; }
|
|
.avatar-mascot {
|
|
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.avatar-mascot pre {
|
|
margin: 0; font-family: "SF Mono", Consolas, monospace; font-size: 10px; line-height: 1.1;
|
|
text-align: center; white-space: pre; user-select: none;
|
|
}
|
|
.avatar-letter {
|
|
flex-shrink: 0; width: 36px; height: 36px; border-radius: 10px; background: var(--primary);
|
|
color: var(--primary-fg); font-weight: 700; font-size: 15px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.agent-meta h1 { font-size: 15px; font-weight: 600; line-height: 1.2; }
|
|
.agent-meta .sub { font-size: 11px; color: var(--muted-fg); display: flex; align-items: center; gap: 6px; }
|
|
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); display: inline-block; }
|
|
.tools-toggle { cursor: pointer; border: none; background: none; color: var(--muted-fg); font-size: 11px; text-decoration: underline; text-underline-offset: 2px; }
|
|
.tools-toggle:hover { color: var(--fg); }
|
|
.header-actions { display: flex; gap: 8px; align-items: center; }
|
|
.btn-secondary {
|
|
padding: 6px 14px; border-radius: 8px; font-size: 12px; font-weight: 500;
|
|
border: 1px solid var(--border); background: var(--card); color: var(--fg);
|
|
cursor: pointer; transition: all .15s;
|
|
}
|
|
.btn-secondary:hover { background: var(--muted); }
|
|
/* ---- Tabs ---- */
|
|
#tab-bar {
|
|
flex-shrink: 0; display: flex; align-items: center; gap: 0;
|
|
border-bottom: 1px solid var(--border); background: var(--muted);
|
|
padding: 0 16px; overflow-x: auto; min-height: 36px;
|
|
}
|
|
.tab {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 7px 12px; font-size: 12px; cursor: pointer; white-space: nowrap;
|
|
border-bottom: 2px solid transparent; color: var(--muted-fg); transition: all .15s;
|
|
}
|
|
.tab:hover { color: var(--fg); background: var(--card); }
|
|
.tab.active { color: var(--fg); border-bottom-color: var(--primary); background: var(--card); font-weight: 500; }
|
|
.tab .close-tab {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 16px; height: 16px; border-radius: 4px; font-size: 11px; line-height: 1;
|
|
border: none; background: transparent; color: var(--muted-fg); cursor: pointer;
|
|
}
|
|
.tab .close-tab:hover { background: var(--border); color: var(--fg); }
|
|
.tab-add {
|
|
padding: 6px 10px; font-size: 16px; cursor: pointer; color: var(--muted-fg);
|
|
border: none; background: none; line-height: 1;
|
|
}
|
|
.tab-add:hover { color: var(--fg); }
|
|
/* ---- Tools bar ---- */
|
|
.tools-bar { border-bottom: 1px solid var(--border); background: var(--muted); padding: 8px 16px; display: none; }
|
|
.tools-bar.open { display: block; }
|
|
.tools-bar-inner { max-width: 800px; margin: 0 auto; }
|
|
.tools-bar label { font-size: 10px; font-weight: 600; color: var(--muted-fg); letter-spacing: .5px; text-transform: uppercase; }
|
|
.tools-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
|
.tool-chip { font-size: 11px; font-family: "SF Mono", Consolas, monospace; padding: 3px 8px; border-radius: 6px; background: var(--card); border: 1px solid var(--border); }
|
|
/* ---- Chat area ---- */
|
|
#chat { flex: 1; overflow-y: auto; padding: 24px 16px; }
|
|
.chat-panel { display: none; }
|
|
.chat-panel.active { display: flex; flex-direction: column; gap: 16px; max-width: 800px; margin: 0 auto; }
|
|
.chat-messages { display: flex; flex-direction: column; gap: 16px; flex: 1; min-height: 0; }
|
|
/* ---- Welcome ---- */
|
|
.welcome { text-align: center; padding: 60px 20px; }
|
|
.welcome .welcome-mascot { margin-bottom: 12px; }
|
|
.welcome .welcome-mascot pre {
|
|
margin: 0; font-family: "SF Mono", Consolas, monospace; font-size: 18px; line-height: 1.15;
|
|
text-align: center; white-space: pre; user-select: none;
|
|
}
|
|
.welcome .avatar-lg {
|
|
width: 64px; height: 64px; border-radius: 18px; background: var(--primary);
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
color: var(--primary-fg); font-weight: 700; font-size: 26px; margin-bottom: 12px;
|
|
box-shadow: 0 4px 12px rgba(37,99,235,.25);
|
|
}
|
|
.welcome h2 { font-size: 20px; margin-bottom: 6px; }
|
|
.welcome p { font-size: 13px; color: var(--muted-fg); max-width: 480px; margin: 0 auto; }
|
|
/* ---- Messages ---- */
|
|
.msg-row { display: flex; }
|
|
.msg-row.user { justify-content: flex-end; }
|
|
.msg-row.assistant { justify-content: flex-start; }
|
|
.bubble { max-width: 85%; padding: 10px 16px; border-radius: 16px; font-size: 14px; line-height: 1.6; word-break: break-word; box-shadow: var(--card-shadow); }
|
|
.msg-row.user .bubble { background: var(--primary); color: var(--primary-fg); border-bottom-right-radius: 4px; }
|
|
.msg-row.assistant .bubble { background: var(--card); border: 1px solid var(--border); border-bottom-left-radius: 4px; }
|
|
/* Markdown */
|
|
.bubble.md-content p { margin: .4em 0; }
|
|
.bubble.md-content ul, .bubble.md-content ol { padding-left: 1.4em; margin: .4em 0; }
|
|
.bubble.md-content code:not(pre code) { background: var(--muted); padding: 1px 5px; border-radius: 4px; font-size: .85em; font-family: "SF Mono", Consolas, monospace; }
|
|
.bubble.md-content pre { margin: .6em 0; border-radius: 8px; overflow-x: auto; position: relative; }
|
|
.bubble.md-content pre code {
|
|
display: block; padding: 12px 16px; font-size: .8em; line-height: 1.5;
|
|
background: var(--code-bg) !important; color: #d4d4d8 !important; border-radius: 8px;
|
|
}
|
|
.bubble.md-content pre code.hljs, .bubble.md-content pre code[class*="language-"] { color: #d4d4d8 !important; background: var(--code-bg) !important; }
|
|
.bubble.md-content pre code .hljs-keyword, .bubble.md-content pre code .hljs-selector-tag { color: #c678dd; }
|
|
.bubble.md-content pre code .hljs-string, .bubble.md-content pre code .hljs-addition { color: #98c379; }
|
|
.bubble.md-content pre code .hljs-number, .bubble.md-content pre code .hljs-literal { color: #d19a66; }
|
|
.bubble.md-content pre code .hljs-comment, .bubble.md-content pre code .hljs-quote { color: #7f848e; font-style: italic; }
|
|
.bubble.md-content pre code .hljs-built_in, .bubble.md-content pre code .hljs-type { color: #e5c07b; }
|
|
.bubble.md-content pre code .hljs-title { color: #61afef; }
|
|
.bubble.md-content pre code .hljs-attr, .bubble.md-content pre code .hljs-selector-class, .bubble.md-content pre code .hljs-variable { color: #d19a66; }
|
|
.bubble.md-content pre code .hljs-deletion { color: #e06c75; }
|
|
.bubble.md-content pre code .hljs-meta, .bubble.md-content pre code .hljs-tag { color: #abb2bf; }
|
|
.copy-btn { position: absolute; top: 6px; right: 6px; padding: 3px 8px; font-size: 10px; border-radius: 4px; border: none; background: rgba(255,255,255,.12); color: #aaa; cursor: pointer; opacity: 0; transition: opacity .2s; }
|
|
.bubble.md-content pre:hover .copy-btn { opacity: 1; }
|
|
.copy-btn:hover { background: rgba(255,255,255,.2); }
|
|
.blocked-img { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: .8em; background: var(--muted); color: var(--muted-fg); border: 1px dashed var(--border); }
|
|
/* ---- Tool cards ---- */
|
|
.tool-card { margin: 4px 0 4px 16px; max-width: 80%; border-radius: 10px; overflow: hidden; border: 1px solid var(--border); font-size: 12px; }
|
|
.tool-card-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; background: var(--muted); user-select: none; }
|
|
.tool-card-header .icon { font-size: 13px; }
|
|
.tool-card-header .name { font-family: "SF Mono", Consolas, monospace; font-weight: 600; }
|
|
.tool-card-header .status { color: var(--muted-fg); margin-left: auto; }
|
|
.tool-card-body { display: none; padding: 10px 12px; background: var(--card); }
|
|
.tool-card.open .tool-card-body { display: block; }
|
|
.tool-card-body label { font-weight: 600; color: var(--muted-fg); font-size: 10px; text-transform: uppercase; letter-spacing: .5px; display: block; margin-bottom: 4px; }
|
|
.tool-card-body pre { background: var(--code-bg); color: #ccc; border-radius: 6px; padding: 8px 10px; font-size: 11px; overflow-x: auto; margin-bottom: 8px; white-space: pre-wrap; font-family: "SF Mono", Consolas, monospace; }
|
|
.tool-card.success .tool-card-header { border-left: 3px solid var(--success); }
|
|
.tool-card.running .tool-card-header { border-left: 3px solid var(--warning); }
|
|
.tool-card.error .tool-card-header { border-left: 3px solid var(--error); }
|
|
.tool-card.blocked .tool-card-header { border-left: 3px solid var(--muted-fg); }
|
|
/* ---- Thinking / Error ---- */
|
|
.thinking { display: flex; align-items: center; gap: 8px; color: var(--muted-fg); font-size: 13px; padding: 4px 0; }
|
|
.thinking .dots span { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--primary); margin-right: 3px; animation: bounce .6s infinite alternate; }
|
|
.thinking .dots span:nth-child(2) { animation-delay: .15s; }
|
|
.thinking .dots span:nth-child(3) { animation-delay: .3s; }
|
|
@keyframes bounce { to { opacity: .3; transform: translateY(-4px); } }
|
|
.error-msg { background: #fef2f2; color: var(--error); border: 1px solid #fecaca; padding: 8px 14px; border-radius: 10px; font-size: 13px; align-self: center; }
|
|
@media (prefers-color-scheme: dark) { .error-msg { background: #1c0f0f; border-color: #5c2020; } }
|
|
/* ---- Input ---- */
|
|
#input-area { flex-shrink: 0; border-top: 1px solid var(--border); background: var(--card); padding: 12px 16px; }
|
|
.input-inner { max-width: 800px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; }
|
|
#msg-input { flex: 1; border: 1px solid var(--border); border-radius: 12px; padding: 10px 16px; font-size: 14px; font-family: inherit; resize: none; max-height: 140px; background: var(--muted); color: var(--fg); outline: none; transition: border-color .15s; }
|
|
#msg-input:focus { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(37,99,235,.15); }
|
|
#msg-input::placeholder { color: var(--muted-fg); }
|
|
.btn-send { flex-shrink: 0; border-radius: 12px; background: var(--primary); color: var(--primary-fg); border: none; padding: 10px 20px; font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity .15s; display: flex; align-items: center; gap: 6px; }
|
|
.btn-send:hover { opacity: .9; }
|
|
.btn-send:disabled { opacity: .4; cursor: not-allowed; }
|
|
.spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .5s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.powered { text-align: center; font-size: 10px; color: var(--muted-fg); padding: 4px 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="header-inner">
|
|
<div class="agent-id">
|
|
<div class="avatar-letter" id="avatar">A</div>
|
|
<div class="agent-meta">
|
|
<h1 id="agent-name">Agent</h1>
|
|
<div class="sub">
|
|
<span class="status-dot"></span> Online
|
|
<button class="tools-toggle" id="tools-toggle" style="display:none" onclick="toggleTools()">0 tools</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button class="btn-secondary" onclick="newChat()">+ New chat</button>
|
|
</div>
|
|
</div>
|
|
<div class="tools-bar" id="tools-bar">
|
|
<div class="tools-bar-inner">
|
|
<label>Available tools</label>
|
|
<div class="tools-list" id="tools-list"></div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="tab-bar"></div>
|
|
|
|
<div id="chat">
|
|
<!-- chat panels are created dynamically per tab -->
|
|
</div>
|
|
|
|
<div id="input-area">
|
|
<div class="input-inner">
|
|
<textarea id="msg-input" rows="1" placeholder="Type a message..." onkeydown="handleKey(event)"></textarea>
|
|
<button class="btn-send" id="send-btn" onclick="sendMessage()">Send</button>
|
|
</div>
|
|
<div class="powered">Powered by Omnigent</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ====== Multi-tab state ======
|
|
const chatEl = document.getElementById('chat');
|
|
const inputEl = document.getElementById('msg-input');
|
|
const sendBtn = document.getElementById('send-btn');
|
|
const tabBar = document.getElementById('tab-bar');
|
|
|
|
let agentInfo = null;
|
|
let tabIdCounter = 0;
|
|
let tabs = []; // [{id, title, messages, sessionId, panelEl, messagesEl, streaming}]
|
|
let activeTabId = null;
|
|
|
|
// ====== Markdown setup (with security) ======
|
|
if (typeof marked !== 'undefined') {
|
|
const renderer = new marked.Renderer();
|
|
renderer.image = function(token) {
|
|
const href = (token && token.href) || '', text = (token && token.text) || '';
|
|
if ((href.startsWith('/') && !href.startsWith('//')) || href.startsWith('./') || href.startsWith('data:image/'))
|
|
return '<img src="' + esc(href) + '" alt="' + esc(text) + '" style="max-width:100%;border-radius:6px">';
|
|
return '<span class="blocked-img" title="External image blocked">[image: ' + esc(text || href) + ']</span>';
|
|
};
|
|
renderer.link = function(token) {
|
|
const href = (token && token.href) || '', text = (token && token.text) || href;
|
|
// Allowlist safe URL schemes (and relative / anchor links). Anything
|
|
// else (javascript:, data:, vbscript:, unknown schemes) renders as
|
|
// inert text. Allowlisting beats denylisting: it can't be bypassed by
|
|
// an unenumerated dangerous scheme.
|
|
if (!isSafeUrl(href)) return esc(text);
|
|
return '<a href="' + esc(href) + '" target="_blank" rel="noopener noreferrer nofollow">' + esc(text) + '</a>';
|
|
};
|
|
marked.setOptions({
|
|
renderer,
|
|
highlight: (code, lang) => (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) ? hljs.highlight(code, {language: lang}).value : code,
|
|
breaks: true,
|
|
});
|
|
}
|
|
|
|
// ====== Helpers ======
|
|
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
function scrollToBottom() { chatEl.scrollTop = chatEl.scrollHeight; }
|
|
// Allowlist of URL schemes safe to emit as live <a href>/<img src>.
|
|
// Relative paths and anchors are also safe; everything else is treated
|
|
// as untrusted and rendered inert.
|
|
function isSafeUrl(href) {
|
|
const s = String(href || '').trim();
|
|
if (s.startsWith('//')) return false; // protocol-relative — resolves to an external host
|
|
if (/^(\.{0,2}\/|#)/.test(s)) return true; // relative path or in-page anchor
|
|
return /^(https?|mailto|tel):/i.test(s);
|
|
}
|
|
function renderMd(text) {
|
|
if (typeof marked === 'undefined') return esc(text);
|
|
let html = marked.parse(text);
|
|
// DOMPurify is a complete, parser-based sanitizer — it removes scripts,
|
|
// event-handler attributes, and dangerous tags/URLs that a regex pass
|
|
// can be tricked into missing. Fall back to escaping the raw text if the
|
|
// library failed to load rather than emit unsanitized markup.
|
|
if (typeof DOMPurify === 'undefined') return esc(text);
|
|
html = DOMPurify.sanitize(html, { ADD_ATTR: ['target'] });
|
|
// Inject copy buttons after sanitization so DOMPurify doesn't strip the
|
|
// inline handler (copyCode is a trusted, first-party function).
|
|
html = html.replace(/<pre><code/g, '<pre><button class="copy-btn" onclick="copyCode(this)">Copy</button><code');
|
|
return html;
|
|
}
|
|
function copyCode(btn) { navigator.clipboard.writeText(btn.nextElementSibling.textContent); btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); }
|
|
function toggleTools() { document.getElementById('tools-bar').classList.toggle('open'); }
|
|
|
|
inputEl.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 140) + 'px'; });
|
|
|
|
// ====== Tab management ======
|
|
function getTab(id) { return tabs.find(t => t.id === id); }
|
|
function activeTab() { return getTab(activeTabId); }
|
|
|
|
// Sync the shared send button to reflect the active tab's streaming state.
|
|
function syncButtonState() {
|
|
const tab = activeTab();
|
|
if (tab && tab.streaming) {
|
|
sendBtn.disabled = true;
|
|
sendBtn.innerHTML = '<span class="spinner"></span>';
|
|
} else {
|
|
sendBtn.disabled = false;
|
|
sendBtn.innerHTML = 'Send';
|
|
}
|
|
}
|
|
|
|
function createTab() {
|
|
const id = ++tabIdCounter;
|
|
const panelEl = document.createElement('div');
|
|
panelEl.className = 'chat-panel';
|
|
panelEl.dataset.tabId = id;
|
|
const messagesEl = document.createElement('div');
|
|
messagesEl.className = 'chat-messages';
|
|
panelEl.appendChild(messagesEl);
|
|
chatEl.appendChild(panelEl);
|
|
const tab = {
|
|
id, title: 'Chat ' + id, messages: [], sessionId: null,
|
|
panelEl, messagesEl, streaming: false,
|
|
};
|
|
tabs.push(tab);
|
|
renderTabs();
|
|
switchTab(id);
|
|
showWelcomeIn(tab);
|
|
return tab;
|
|
}
|
|
|
|
function switchTab(id) {
|
|
activeTabId = id;
|
|
tabs.forEach(t => t.panelEl.classList.toggle('active', t.id === id));
|
|
renderTabs();
|
|
syncButtonState();
|
|
scrollToBottom();
|
|
inputEl.focus();
|
|
}
|
|
|
|
function closeTab(id) {
|
|
const idx = tabs.findIndex(t => t.id === id);
|
|
if (idx === -1) return;
|
|
tabs[idx].panelEl.remove();
|
|
tabs.splice(idx, 1);
|
|
if (tabs.length === 0) {
|
|
createTab();
|
|
} else if (activeTabId === id) {
|
|
switchTab(tabs[Math.min(idx, tabs.length - 1)].id);
|
|
} else {
|
|
renderTabs();
|
|
}
|
|
}
|
|
|
|
function renderTabs() {
|
|
tabBar.innerHTML = '';
|
|
tabs.forEach(t => {
|
|
const div = document.createElement('div');
|
|
div.className = 'tab' + (t.id === activeTabId ? ' active' : '');
|
|
const label = document.createElement('span');
|
|
label.textContent = t.title;
|
|
label.onclick = () => switchTab(t.id);
|
|
div.appendChild(label);
|
|
if (tabs.length > 1) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'close-tab';
|
|
btn.innerHTML = '×';
|
|
btn.onclick = (e) => { e.stopPropagation(); closeTab(t.id); };
|
|
div.appendChild(btn);
|
|
}
|
|
tabBar.appendChild(div);
|
|
});
|
|
const addBtn = document.createElement('button');
|
|
addBtn.className = 'tab-add';
|
|
addBtn.innerHTML = '+';
|
|
addBtn.title = 'New chat';
|
|
addBtn.onclick = () => newChat();
|
|
tabBar.appendChild(addBtn);
|
|
}
|
|
|
|
function newChat() { createTab(); inputEl.focus(); }
|
|
|
|
// ====== Rendering helpers ======
|
|
// Stream/bubble helpers take ``messagesEl`` (not the outer panel) so the mascot strip
|
|
// stays mounted when the first message clears the welcome block.
|
|
|
|
function fillHeaderMascot() {
|
|
const el = document.getElementById('avatar');
|
|
const m = agentInfo && agentInfo.mascot;
|
|
const name = agentInfo && agentInfo.name ? agentInfo.name : 'Agent';
|
|
if (m && m.lines && m.lines.length) {
|
|
const c = m.color;
|
|
const style = (typeof c === 'string' && /^#[0-9A-Fa-f]{6}$/.test(c)) ? 'color:' + c : '';
|
|
el.className = 'avatar-mascot';
|
|
el.innerHTML = '<pre style="' + style + '">' + esc(m.lines.join('\n')) + '</pre>';
|
|
} else {
|
|
el.className = 'avatar-letter';
|
|
el.textContent = name.charAt(0).toUpperCase();
|
|
}
|
|
}
|
|
|
|
function addUserBubble(p, text) {
|
|
if (!p) return;
|
|
const row = document.createElement('div');
|
|
row.className = 'msg-row user';
|
|
row.innerHTML = '<div class="bubble">' + esc(text) + '</div>';
|
|
p.appendChild(row);
|
|
scrollToBottom();
|
|
}
|
|
function createAssistantBubble(p) {
|
|
if (!p) return null;
|
|
const row = document.createElement('div');
|
|
row.className = 'msg-row assistant';
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'bubble md-content';
|
|
row.appendChild(bubble);
|
|
p.appendChild(row);
|
|
return bubble;
|
|
}
|
|
function showThinking(p) {
|
|
if (!p) return;
|
|
let el = p.querySelector('.thinking');
|
|
if (!el) { el = document.createElement('div'); el.className = 'thinking'; el.innerHTML = '<div class="dots"><span></span><span></span><span></span></div> Thinking...'; p.appendChild(el); }
|
|
scrollToBottom();
|
|
}
|
|
function hideThinking(p) { if (!p) return; const el = p.querySelector('.thinking'); if (el) el.remove(); }
|
|
|
|
function addToolCard(p, toolName, status) {
|
|
if (!p) return null;
|
|
const card = document.createElement('div');
|
|
card.className = 'tool-card ' + status;
|
|
const icons = {success: '✓', error: '✗', blocked: '⛔', running: '○'};
|
|
const labels = {success: 'completed', error: 'failed', blocked: 'blocked', running: 'running...'};
|
|
card.innerHTML =
|
|
'<div class="tool-card-header" onclick="this.parentElement.classList.toggle(\'open\')">' +
|
|
'<span class="icon">' + (icons[status] || icons.running) + '</span>' +
|
|
'<span class="name">' + esc(toolName) + '</span>' +
|
|
'<span class="status">' + (labels[status] || status) + ' ▾</span>' +
|
|
'</div>' +
|
|
'<div class="tool-card-body"></div>';
|
|
p.appendChild(card);
|
|
scrollToBottom();
|
|
return card;
|
|
}
|
|
|
|
function addError(p, text) {
|
|
if (!p) return;
|
|
const el = document.createElement('div');
|
|
el.className = 'error-msg';
|
|
el.textContent = text;
|
|
p.appendChild(el);
|
|
scrollToBottom();
|
|
}
|
|
|
|
function showWelcomeIn(tab) {
|
|
const name = agentInfo?.name || 'Agent', prompt = agentInfo?.prompt || '', tools = agentInfo?.tools || [];
|
|
const m = agentInfo && agentInfo.mascot;
|
|
let hero = '';
|
|
if (m && m.lines && m.lines.length) {
|
|
const c = m.color;
|
|
const style = (typeof c === 'string' && /^#[0-9A-Fa-f]{6}$/.test(c)) ? 'color:' + c : '';
|
|
hero = '<div class="welcome-mascot"><pre style="' + style + '">' + esc(m.lines.join('\n')) + '</pre></div>';
|
|
} else {
|
|
hero = '<div class="avatar-lg">' + esc(name.charAt(0).toUpperCase()) + '</div>';
|
|
}
|
|
tab.messagesEl.innerHTML = '<div class="welcome">' + hero +
|
|
'<h2>' + esc(name) + '</h2>' +
|
|
(prompt ? '<p>' + esc(prompt) + '</p>' : '') +
|
|
(tools.length > 0 ? '<div style="margin-top:16px"><div class="tools-list" style="justify-content:center">' + tools.map(t => '<span class="tool-chip">' + esc(t) + '</span>').join('') + '</div></div>' : '') +
|
|
'<p style="margin-top:20px; opacity:.6">Send a message to start chatting.</p></div>';
|
|
}
|
|
|
|
// ====== SSE stream client ======
|
|
async function sendViaStream(tab, text) {
|
|
// Capture the panel element NOW — it won't change even if the user
|
|
// switches to a different tab while this stream is in flight.
|
|
const p = tab.messagesEl;
|
|
|
|
tab.streaming = true;
|
|
syncButtonState(); // update button for the current view
|
|
showThinking(p);
|
|
|
|
tab.messages.push({role: 'user', content: text});
|
|
if (tab.messages.filter(m => m.role === 'user').length === 1) {
|
|
tab.title = text.length > 24 ? text.substring(0, 24) + '...' : text;
|
|
renderTabs();
|
|
}
|
|
|
|
let currentBubble = null, currentBubbleText = '', allAssistantText = '';
|
|
let toolCards = {};
|
|
|
|
try {
|
|
const reqBody = { messages: tab.messages.map(m => ({role: m.role, content: m.content})) };
|
|
if (tab.sessionId) reqBody.session_id = tab.sessionId;
|
|
|
|
const res = await fetch('/api/chat/stream', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(reqBody),
|
|
});
|
|
if (!res.ok) { const err = await res.json().catch(() => ({error: res.statusText})); throw new Error(err.error || 'Request failed'); }
|
|
|
|
const newSid = res.headers.get('X-Session-Id');
|
|
if (newSid) tab.sessionId = newSid;
|
|
|
|
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 lines = buffer.split('\n\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith('data: ')) continue;
|
|
const payload = trimmed.substring(6);
|
|
if (payload === '[DONE]') continue;
|
|
let evt;
|
|
try { evt = JSON.parse(payload); } catch { continue; }
|
|
|
|
switch (evt.type) {
|
|
case 'text-start':
|
|
hideThinking(p);
|
|
currentBubble = createAssistantBubble(p);
|
|
currentBubbleText = '';
|
|
break;
|
|
case 'text-delta':
|
|
if (evt.delta) {
|
|
currentBubbleText += evt.delta;
|
|
allAssistantText += evt.delta;
|
|
if (currentBubble) currentBubble.innerHTML = renderMd(currentBubbleText);
|
|
scrollToBottom();
|
|
}
|
|
break;
|
|
case 'text-end':
|
|
if (currentBubble) currentBubble.innerHTML = renderMd(currentBubbleText);
|
|
currentBubble = null;
|
|
break;
|
|
case 'tool-input-start':
|
|
hideThinking(p);
|
|
toolCards[evt.toolCallId] = addToolCard(p, evt.toolName, 'running');
|
|
break;
|
|
case 'tool-input-available':
|
|
if (toolCards[evt.toolCallId]) {
|
|
const tc = toolCards[evt.toolCallId];
|
|
const body = tc.querySelector('.tool-card-body');
|
|
if (body && evt.input) body.innerHTML = '<label>Input</label><pre>' + esc(JSON.stringify(evt.input, null, 2)) + '</pre>';
|
|
}
|
|
break;
|
|
case 'tool-output-available': {
|
|
const tc = toolCards[evt.toolCallId];
|
|
if (tc) {
|
|
const st = evt.status || 'success';
|
|
const icons = {success: '✓', error: '✗', blocked: '⛔'};
|
|
const labels = {success: 'completed', error: 'failed', blocked: 'blocked'};
|
|
tc.className = 'tool-card ' + st;
|
|
const hdr = tc.querySelector('.tool-card-header');
|
|
if (hdr) {
|
|
hdr.querySelector('.icon').innerHTML = icons[st] || '○';
|
|
hdr.querySelector('.status').innerHTML = (labels[st] || st) + ' ▾';
|
|
}
|
|
const body = tc.querySelector('.tool-card-body');
|
|
if (body) {
|
|
body.innerHTML += '<label>Output</label><pre>' + esc(typeof evt.output === 'string' ? evt.output : JSON.stringify(evt.output, null, 2)) + '</pre>';
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case 'error':
|
|
hideThinking(p);
|
|
addError(p, evt.errorText || 'Unknown error');
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allAssistantText) {
|
|
tab.messages.push({role: 'assistant', content: allAssistantText});
|
|
notifyTurnDone(tab, allAssistantText);
|
|
}
|
|
} catch (e) {
|
|
hideThinking(p);
|
|
addError(p, 'Error: ' + e.message);
|
|
} finally {
|
|
tab.streaming = false;
|
|
syncButtonState();
|
|
}
|
|
}
|
|
|
|
// ====== Browser notifications ======
|
|
// Request permission lazily on first send. Fire a notification when a
|
|
// turn finishes and the page is not visible (user switched browser tabs
|
|
// or minimised the window).
|
|
let notifPermissionRequested = false;
|
|
function requestNotifPermission() {
|
|
if (notifPermissionRequested) return;
|
|
notifPermissionRequested = true;
|
|
if ('Notification' in window && Notification.permission === 'default') {
|
|
Notification.requestPermission();
|
|
}
|
|
}
|
|
function notifyTurnDone(tab, preview) {
|
|
if (!('Notification' in window)) return;
|
|
if (Notification.permission !== 'granted') return;
|
|
// Notify when:
|
|
// - the browser tab is hidden (user alt-tabbed or minimised), OR
|
|
// - the chat tab that finished is not the one the user is looking at
|
|
const userIsWatching = !document.hidden && tab.id === activeTabId;
|
|
if (userIsWatching) return;
|
|
const title = (agentInfo?.name || 'Omnigent') + ' replied';
|
|
const body = (tab.title !== ('Chat ' + tab.id) ? tab.title + ': ' : '') +
|
|
(preview.length > 120 ? preview.substring(0, 120) + '...' : preview);
|
|
const n = new Notification(title, { body, tag: 'omnigent-' + tab.id });
|
|
n.onclick = () => { window.focus(); switchTab(tab.id); n.close(); };
|
|
setTimeout(() => n.close(), 8000);
|
|
}
|
|
|
|
// ====== UI actions ======
|
|
function sendMessage() {
|
|
const text = inputEl.value.trim();
|
|
if (!text) return;
|
|
const tab = activeTab();
|
|
if (!tab || tab.streaming) return;
|
|
requestNotifPermission();
|
|
if (tab.messages.length === 0) tab.messagesEl.innerHTML = '';
|
|
addUserBubble(tab.messagesEl, text);
|
|
inputEl.value = '';
|
|
inputEl.style.height = 'auto';
|
|
sendViaStream(tab, text);
|
|
}
|
|
function handleKey(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }
|
|
|
|
// ====== Init ======
|
|
async function init() {
|
|
try {
|
|
const res = await fetch('/api/agent');
|
|
agentInfo = await res.json();
|
|
const name = agentInfo.name || 'Agent';
|
|
document.getElementById('agent-name').textContent = name;
|
|
fillHeaderMascot();
|
|
document.title = 'Omnigent \u2014 ' + name;
|
|
if (agentInfo.tools?.length > 0) {
|
|
const toggle = document.getElementById('tools-toggle');
|
|
toggle.style.display = '';
|
|
toggle.textContent = agentInfo.tools.length + ' tool' + (agentInfo.tools.length !== 1 ? 's' : '');
|
|
document.getElementById('tools-list').innerHTML = agentInfo.tools.map(t => '<span class="tool-chip">' + esc(t) + '</span>').join('');
|
|
}
|
|
} catch (e) { console.error('Failed to fetch agent info:', e); }
|
|
createTab();
|
|
}
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|