UNPKG

c9ai

Version:

Universal AI assistant with vibe-based workflows, hybrid cloud+local AI, and comprehensive tool integration

638 lines (591 loc) 25.6 kB
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <title>c9ai — Agent Chat</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style> :root { --bg: #000000; --panel: #121212; --muted: #1a1a1a; --text: #ffffff; --sub: #b3b3b3; --primary: #7c83ff; --primary-ink: #ffffff; --bubble: #1a1a1a; --user: #7c83ff; --danger: #ff4d6d; --ok: #2ecc71; --border: #333333; } /* Light mode - can be toggled with data-theme="light" on body */ [data-theme="light"] { --bg: #f7f7f8; --panel: #ffffff; --muted: #f0f0f0; --text: #1a1a1a; --sub: #666666; --primary: #7c83ff; --primary-ink: #ffffff; --bubble: #f9f9f9; --user: #7c83ff; --danger: #ff4d6d; --ok: #2ecc71; --border: #e1e1e1; } * { box-sizing: border-box; } html, body { height: 100%; } body { margin: 0; font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji", "Segoe UI Emoji"; color: var(--text); background: var(--bg); min-height: 100vh; max-height: 100vh; overflow: hidden; } a { color: var(--primary); text-decoration: none; } .shell { max-width: 1200px; margin: 0 auto; padding: 16px; display: grid; gap: 16px; grid-template-columns: 320px 1fr; min-height: calc(100vh - 32px); max-height: calc(100vh - 32px); overflow: hidden; } @media (max-width: 960px) { .shell { grid-template-columns: 1fr; } } .card { background: var(--panel); border: 1px solid var(--border); border-radius: 16px; box-shadow: 0 6px 20px rgba(0,0,0,.25); display: flex; flex-direction: column; } .card .hd { padding: 14px 16px; border-bottom: 1px solid var(--border); font-weight: 600; display: flex; align-items: center; gap: 8px; } .card .bd { padding: 16px; flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } /* Chat area styling for better layout */ .chat { flex: 1; overflow-y: auto; min-height: 0; max-height: calc(60vh - 100px); /* Reasonable max height */ padding: 8px; /* Ensure smooth scrolling */ scroll-behavior: smooth; } /* Input area should not shrink */ .input-area { flex-shrink: 0; } .row { display: flex; gap: 10px; align-items: center; } .col { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; } /* Ensure chat card takes appropriate space */ .col .card:first-child { flex: 1; min-height: 400px; max-height: calc(70vh); } /* Timeline card should be smaller */ .col .card:last-child { flex: 0 0 auto; max-height: 300px; } /* Timeline itself should be scrollable */ .timeline { overflow-y: auto; max-height: 250px; padding: 8px; scroll-behavior: smooth; } /* Message styling improvements */ .msg { margin-bottom: 12px; } .msg .bubble { word-wrap: break-word; white-space: pre-wrap; line-height: 1.4; } .pill { background: var(--muted); color: var(--text); padding: 6px 10px; border-radius: 999px; font-size: 12px; border: 1px solid var(--border); } .btn { background: var(--primary); color: white; border: 0; padding: 10px 14px; border-radius: 12px; cursor: pointer; font-weight: 600; } .btn:disabled { opacity: .6; cursor: not-allowed; } .btn.secondary { background: var(--muted); color: var(--text); } .btn.ghost { background: transparent; border: 1px solid var(--border); } .input, .select, .textarea { width: 100%; background: var(--muted); color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; } .textarea { min-height: 72px; resize: vertical; } .hint { font-size: 12px; color: var(--sub); font-weight: 500; } .stack { display: grid; gap: 8px; } .chips { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px; } .chip { background: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 8px 12px; cursor: pointer; font-size: 12px; transition: all 0.2s ease; white-space: nowrap; } .chip:hover { border-color: var(--primary); background: var(--accent); transform: translateY(-1px); } .toggle { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); } .toggle input { width: 18px; height: 18px; } .msg { display: flex; } .msg .bubble { max-width: min(85%, 740px); padding: 10px 14px; border-radius: 16px; white-space: pre-wrap; word-break: break-word; border: 1px solid var(--border); box-shadow: 0 4px 12px rgba(0,0,0,.25); } .msg.user { justify-content: flex-end; } .msg.user .bubble { background: var(--user); color: var(--primary-ink); } .msg.assistant .bubble { background: var(--bubble); } .msg.system .bubble { background: #122; color: #acf; } .timeline { flex: 2; min-height: 200px; max-height: none; overflow: auto; padding: 8px; border-radius: 12px; background: var(--panel); border: 1px solid var(--border); } .trow { display: grid; grid-template-columns: 80px 1fr; gap: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 13px; line-height: 1.4; padding: 2px 0; } .trow .time { color: var(--text); opacity: 0.7; font-weight: 500; font-size: 12px; } .ok { color: var(--ok); } .err { color: var(--danger); } /* Enhanced status indicators */ .status-starting { color: #fbbf24; animation: pulse 2s infinite; } .status-tool { color: #3b82f6; } .status-result { color: #10b981; } .status-error { color: var(--danger); } .status-success { color: var(--ok); font-weight: 600; } /* Loading animation */ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } /* Status type indicators */ .trow[data-status="starting"] { background: rgba(251, 191, 36, 0.05); } .trow[data-status="tool"] { background: rgba(59, 130, 246, 0.05); } .trow[data-status="error"] { background: rgba(239, 68, 68, 0.05); } .trow[data-status="success"] { background: rgba(16, 185, 129, 0.05); } .divider { height: 1px; background: var(--border); margin: 8px 0; } .kbd { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono"; background: #0f1220; border: 1px solid var(--border); padding: 0 6px; border-radius: 6px; color: #c9d2ff; } </style> </head> <body> <div class="shell"> <!-- Controls --> <div class="card"> <div class="hd">🤖 c9ai — Agent <div style="margin-left:auto; display:flex; gap:8px;"> <button id="btnBench" class="btn ghost">Speed Test</button> <button id="btnHealth" class="btn ghost">Connections</button> <button id="btnSettings" class="btn secondary">Settings</button> </div> </div> <div class="bd"> <div class="stack"> <div class="stack"> <label class="hint">Provider</label> <select id="provider" class="select"> <option value="llamacpp" selected>llama.cpp (local)</option> <option value="ollama">Ollama (local)</option> <option value="claude">Claude (cloud)</option> <option value="gemini">Gemini (cloud)</option> </select> </div> <div class="stack"> <label class="hint">Allowed tools</label> <div class="row" style="flex-wrap:wrap; gap:10px 14px"> <label class="toggle"><input type="checkbox" class="tool" value="shell.run" checked> shell.run</label> <label class="toggle"><input type="checkbox" class="tool" value="script.run" checked> script.run</label> <label class="toggle"><input type="checkbox" class="tool" value="fs.read" checked> fs.read</label> <label class="toggle"><input type="checkbox" class="tool" value="fs.write" checked> fs.write</label> </div> </div> <div class="row"> <label class="toggle"><input id="streamFinal" type="checkbox" checked> Stream final</label> <span class="pill">live</span> </div> <div class="stack"> <label class="hint">Agent API Base</label> <input id="apiBase" class="input" value="http://127.0.0.1:8787" /> </div> <div class="divider"></div> <div class="stack"> <div class="hint">Quick Examples</div> <div class="chips" id="examples" style="max-height: 120px; overflow-y: auto;"></div> </div> </div> </div> </div> <!-- Chat + Timeline --> <div class="col"> <div class="card"> <div class="hd">💬 Chat</div> <div class="bd"> <div id="chat" class="chat"></div> <div class="input-area"> <div class="row" style="margin-top:10px;"> <textarea id="prompt" class="textarea" placeholder="Type a task and press Send…"></textarea> <button id="send" class="btn" style="height:72px; min-width:90px;">Send</button> </div> <div class="hint" style="margin-top:8px;">Tip: Press <span class="kbd">Ctrl</span> + <span class="kbd">Enter</span> to send</div> </div> </div> </div> <div class="card"> <div class="hd">🖥️ Agent Timeline</div> <div class="bd"> <div id="timeline" class="timeline"></div> </div> </div> </div> </div> <div style="padding:8px 12px;color:#8b8ea6;font-size:12px;text-align:center"> Powered locally by <span style="opacity:.9">llama.cpp</span> </div> <script> // ===== Utilities ===== const $ = sel => document.querySelector(sel); const $$ = sel => Array.from(document.querySelectorAll(sel)); const chat = $("#chat"); const timeline = $("#timeline"); const sendBtn = $("#send"); const promptEl = $("#prompt"); const providerEl = $("#provider"); const apiBaseEl = $("#apiBase"); const streamFinalEl = $("#streamFinal"); const quick = [ { label: "Create a text file with greeting", prompt: "Create hello-ui.txt with: Hello from the UI agent. Then read it back." }, { label: "List files in directory", prompt: "Run a shell command to print working directory and list files." }, { label: "Create HTML page about space", prompt: "Create index.html with a simple HTML page about space exploration, then tell me where the file is." }, { label: "Create JSON config file", prompt: "Create config.json with {\"name\":\"demo\",\"enabled\":true} and then show its contents." }, ]; const exWrap = $("#examples"); quick.forEach(q => { const b = document.createElement("button"); b.className = "chip"; b.textContent = q.label; b.onclick = () => promptEl.value = q.prompt; exWrap.appendChild(b); }); function timeStr() { const now = new Date(); return now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); } function addMsg(role, content) { const wrap = document.createElement("div"); wrap.className = `msg ${role}`; const b = document.createElement("div"); b.className = "bubble"; b.textContent = content; wrap.appendChild(b); chat.appendChild(wrap); chat.scrollTop = chat.scrollHeight; } function addTimeline(type, text, cls="") { const row = document.createElement("div"); row.className = "trow"; // Add data attribute for enhanced styling row.setAttribute("data-status", type); // Determine enhanced CSS class based on type and content let enhancedCls = cls; if (!enhancedCls) { if (type === "status" && text.includes("starting")) enhancedCls = "status-starting"; else if (type === "tool") enhancedCls = "status-tool"; else if (type === "toolResult") enhancedCls = "status-result"; else if (type === "error") enhancedCls = "status-error"; else if (type === "final" || text.includes("✅")) enhancedCls = "status-success"; } const t = document.createElement("div"); t.className = "time"; t.textContent = timeStr(); const c = document.createElement("div"); c.innerHTML = (enhancedCls ? `<span class="${enhancedCls}">` : "") + text + (enhancedCls ? "</span>" : ""); row.appendChild(t); row.appendChild(c); timeline.appendChild(row); timeline.scrollTop = timeline.scrollHeight; // Auto-remove pulse animation after some time for starting status if (enhancedCls === "status-starting") { setTimeout(() => { const spans = row.querySelectorAll('.status-starting'); spans.forEach(span => span.classList.remove('status-starting')); }, 10000); // Remove after 10 seconds } } // Robust SSE reader (multi-line data safe) async function* readSSE(response) { const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let event = { type: "", dataLines: [] }; const flush = () => { const dataStr = event.dataLines.join("\n"); let payload = null; // Try to parse as JSON, but fallback to string for events that send plain text if (dataStr) { try { payload = JSON.parse(dataStr); } catch (e) { // For status, detected, and token events, the data is often plain text if (event.type === "status" || event.type === "detected" || event.type === "token") { payload = dataStr; // Keep as string } else { // For other events, log the error as this might be a real JSON issue console.warn(`SSE JSON parse failed for event '${event.type}':`, e.message, "raw:", dataStr); payload = dataStr; // Fallback to string } } } const out = { type: event.type, payload, raw: dataStr }; event = { type: "", dataLines: [] }; return out; }; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf("\n\n")) >= 0) { const chunk = buffer.slice(0, idx); buffer = buffer.slice(idx + 2); const lines = chunk.split(/\r?\n/); for (const line of lines) { if (line.startsWith("event:")) { if (event.type || event.dataLines.length) yield flush(); event.type = line.slice(6).trim(); } else if (line.startsWith("data:")) { event.dataLines.push(line.slice(5).trimStart()); } } if (event.type || event.dataLines.length) yield flush(); } } } async function runAgent(prompt) { const provider = providerEl.value; const allow = $$(".tool").filter(x => x.checked).map(x => x.value); const apiBase = apiBaseEl.value || "http://127.0.0.1:8787"; const body = JSON.stringify({ prompt, provider, allow }); addMsg("user", prompt); addTimeline("status", "🟡 starting…"); sendBtn.disabled = true; try { const resp = await fetch(apiBase + "/api/agent", { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body }); for await (const ev of readSSE(resp)) { if (!ev || !ev.type) continue; if (ev.type === "status") addTimeline("status", "🟡 " + String(ev.payload)); if (ev.type === "detected") addTimeline("detected", "🔎 " + String(ev.payload)); if (ev.type === "plan") addTimeline("plan", "🧭 " + JSON.stringify(ev.payload)); if (ev.type === "tool") addTimeline("tool", "🔧 " + ev.payload?.name + " " + JSON.stringify(ev.payload?.args)); if (ev.type === "toolResult") addTimeline("toolResult", "🛠️ " + ev.payload?.name + " → " + (typeof ev.payload?.out === "string" ? ev.payload?.out : JSON.stringify(ev.payload?.out))); if (ev.type === "final") { const text = ev.payload?.text || ""; addMsg("assistant", text); addTimeline("final", "✅ done", "ok"); } if (ev.type === "error") addTimeline("error", "❌ " + (ev.payload?.error || JSON.stringify(ev.payload)), "err"); } } catch (e) { addTimeline("error", "❌ fetch failed: " + e.message, "err"); } finally { sendBtn.disabled = false; } } // ===== Settings Modal (basic) ===== // Modal HTML const modal = document.createElement("div"); modal.id = "settingsModal"; modal.style.cssText = "position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.45);z-index:9999"; modal.innerHTML = ` <div style="background:#0f1117;border:1px solid #2a2d3a;border-radius:12px;min-width:320px;max-width:520px;padding:16px;"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px"> <b>Settings</b> <button id="settingsClose" class="btn ghost">Close</button> </div> <div class="stack"> <label class="hint">Default provider</label> <select id="stProvider" class="select"> <optgroup label="🏠 Local"> <option value="llamacpp">llamacpp</option> <option value="ollama">ollama</option> </optgroup> <optgroup label="☁️ Cloud"> <option value="claude">claude</option> <option value="gemini">gemini</option> <option value="openai">openai</option> <option value="deepseek">deepseek</option> </optgroup> <optgroup label="🔀 Hybrid"> <option value="claude-hybrid">claude + local tools</option> <option value="gemini-hybrid">gemini + local tools</option> <option value="openai-hybrid">openai + local tools</option> <option value="deepseek-hybrid">deepseek + local tools</option> </optgroup> </select> <label class="hint">Confirm threshold (0..1)</label> <input id="stThreshold" class="input" type="number" step="0.05" min="0" max="1" /> <label class="hint">Allowed tools (comma-separated)</label> <input id="stTools" class="input" placeholder="shell.run,script.run,fs.read,fs.write" /> <div class="divider"></div> <label class="hint">API Keys</label> <input id="stClaude" class="input" placeholder="ANTHROPIC_API_KEY" /> <input id="stGemini" class="input" placeholder="GEMINI_API_KEY" /> <input id="stOpenAI" class="input" placeholder="OPENAI_API_KEY" /> <input id="stDeepSeek" class="input" placeholder="DEEPSEEK_API_KEY" /> <input id="stSerp" class="input" placeholder="SERPAPI_KEY" /> <div class="row" style="justify-content:flex-end;gap:8px;margin-top:8px"> <button id="settingsSave" class="btn">Save</button> </div> </div> </div>`; document.body.appendChild(modal); const btnStartLocal = $("#btnStartLocal"); const btnBench = $("#btnBench"); const btnHealth = $("#btnHealth"); const btnSettings = $("#btnSettings"); const settingsClose = $("#settingsClose"); const stProvider = $("#stProvider"); const stThreshold = $("#stThreshold"); const stTools = $("#stTools"); const stClaude = $("#stClaude"); const stGemini = $("#stGemini"); const stOpenAI = $("#stOpenAI"); const stDeepSeek = $("#stDeepSeek"); const stSerp = $("#stSerp"); const settingsSave = $("#settingsSave"); async function loadSettings() { try { const r = await fetch((apiBaseEl.value || "http://127.0.0.1:8787") + "/api/settings"); const s = await r.json(); stProvider.value = s.provider || "llamacpp"; stThreshold.value = typeof s.confirmThreshold === "number" ? s.confirmThreshold : 0.6; stTools.value = Array.isArray(s.allowedTools) ? s.allowedTools.join(",") : "shell.run,script.run,fs.read,fs.write"; stClaude.value = s.apiKeys?.ANTHROPIC_API_KEY || ""; stGemini.value = s.apiKeys?.GEMINI_API_KEY || ""; stOpenAI.value = s.apiKeys?.OPENAI_API_KEY || ""; stDeepSeek.value = s.apiKeys?.DEEPSEEK_API_KEY || ""; stSerp.value = s.apiKeys?.SERPAPI_KEY || ""; // also sync UI defaults providerEl.value = stProvider.value; } catch (e) { console.warn("settings load failed", e); } } async function saveSettings() { const api = apiBaseEl.value || "http://127.0.0.1:8787"; const payload = { provider: stProvider.value || "llamacpp", confirmThreshold: Number(stThreshold.value || 0.6), allowedTools: (stTools.value || "").split(",").map(s => s.trim()).filter(Boolean), apiKeys: { ANTHROPIC_API_KEY: stClaude.value || "", GEMINI_API_KEY: stGemini.value || "", OPENAI_API_KEY: stOpenAI.value || "", DEEPSEEK_API_KEY: stDeepSeek.value || "", SERPAPI_KEY: stSerp.value || "" } }; try { await fetch(api + "/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); providerEl.value = payload.provider; addTimeline("status", "✅ Settings saved", "ok"); modal.style.display = "none"; } catch (e) { addTimeline("error", "❌ Settings save failed: " + e.message, "err"); } } btnStartLocal?.addEventListener("click", async () => { const api = "http://127.0.0.1:8787"; addTimeline("status", "Starting local llama.cpp…"); try { // default: GPU start (no CPU fallback) const r = await fetch(api + "/api/launch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) }); if (!r.ok) throw new Error(await r.text()); const { baseUrl, model } = await r.json(); addTimeline("status", `Local ready at ${baseUrl} (model: ${model})`); // Optionally redirect the tab to llama UI window.open(baseUrl + "/#/chat", "_blank"); } catch (e) { addTimeline("error", "Failed to start local: " + (e?.message || e)); } }); btnBench?.addEventListener("click", async () => { const api = "http://127.0.0.1:8787"; addTimeline("status", "Benchmarking speed…"); try { const r = await fetch(api + "/api/bench"); const j = await r.json(); if (!r.ok) throw new Error(j?.error || r.statusText); if (j?.tps) { addTimeline("status", `⚡ ${j.tps.toFixed(2)} tokens/sec (${j.tokens} tokens in ${j.ms} ms)`); } else { addTimeline("status", `Benchmark ok but no timings. Raw: ${JSON.stringify(j)}`); } } catch (e) { addTimeline("error", "Bench failed: " + (e?.message || e)); } }); btnHealth?.addEventListener("click", async () => { const r = await fetch("http://127.0.0.1:8787/api/health"); const j = await r.json(); addTimeline("status", `llama: ${j.llama ? "ok" : "down"} · claude key: ${j.claude} · gemini key: ${j.gemini} · openai key: ${j.openai}`); }); btnSettings?.addEventListener("click", () => { modal.style.display = "flex"; loadSettings(); }); settingsClose?.addEventListener("click", () => { modal.style.display = "none"; }); settingsSave?.addEventListener("click", saveSettings); // ===== Wire controls sendBtn.addEventListener("click", () => { const text = promptEl.value.trim(); if (!text) return; promptEl.value = ""; runAgent(text); }); promptEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendBtn.click(); } }); </script> </body> </html>