Inital Commit

This commit is contained in:
2026-05-05 19:45:12 -04:00
parent 729f421ec8
commit dbc97bddee
21 changed files with 3238 additions and 2 deletions
+341
View File
@@ -0,0 +1,341 @@
const form = document.getElementById("chat-form");
const input = document.getElementById("message-input");
const messages = document.getElementById("messages");
const statusEl = document.getElementById("status");
const pendingEl = document.getElementById("pending-actions");
const activityEl = document.getElementById("activity");
const warningEl = document.getElementById("warning");
const memoryInspectorEl = document.getElementById("memory-inspector");
const memoryRefreshButton = document.getElementById("memory-refresh");
const memoryClearButton = document.getElementById("memory-clear");
let ollamaOnline = true;
function addMessage(role, text) {
const node = document.createElement("div");
node.className = `message ${role}`;
setMessageMarkdown(node, text);
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
return node;
}
function setMessageMarkdown(node, text) {
node.innerHTML = renderMarkdown(text);
}
function renderMarkdown(text) {
const escaped = escapeHtml(text);
const formatted = escaped
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
const lines = formatted.split(/\n/);
const output = [];
let inList = false;
for (const line of lines) {
const trimmed = line.trim();
const item = line.match(/^(\s*)-\s+(.+)$/);
if (item) {
if (!inList) {
output.push("<ul>");
inList = true;
}
const nestedClass = item[1].length >= 2 ? ' class="nested"' : "";
output.push(`<li${nestedClass}>${item[2]}</li>`);
continue;
}
if (inList) {
output.push("</ul>");
inList = false;
}
if (/^---+$/.test(trimmed)) {
output.push("<hr>");
} else if (/^###\s+/.test(trimmed)) {
output.push(`<h3>${trimmed.replace(/^###\s+/, "")}</h3>`);
} else if (/^##\s+/.test(trimmed)) {
output.push(`<h3>${trimmed.replace(/^##\s+/, "")}</h3>`);
} else if (trimmed) {
output.push(`<p>${line}</p>`);
} else {
output.push("<br>");
}
}
if (inList) output.push("</ul>");
return output.join("");
}
function escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function setActivity(text, active = false) {
activityEl.innerHTML = "";
if (!text) return;
const label = document.createElement("span");
label.textContent = text;
activityEl.appendChild(label);
if (active) {
const dots = document.createElement("span");
dots.className = "working-dots";
dots.innerHTML = "<i></i><i></i><i></i>";
activityEl.appendChild(dots);
}
}
function setWarning(text) {
warningEl.hidden = !text;
warningEl.textContent = text || "";
}
async function checkHealth() {
try {
const response = await fetch("/api/health");
const result = await response.json();
const health = result.ollama || {};
ollamaOnline = Boolean(health.online);
if (!ollamaOnline) {
statusEl.textContent = "Offline";
setWarning(health.message || "Ollama is offline. Start Ollama before chatting.");
return false;
}
if (health.model_available === false) {
setWarning(`Ollama is online, but model "${health.model}" is not pulled. Run: ollama pull ${health.model}`);
} else {
setWarning("");
}
statusEl.textContent = "Ready";
return true;
} catch (error) {
ollamaOnline = false;
statusEl.textContent = "Offline";
setWarning(`Could not check Ollama health: ${error.message}`);
return false;
}
}
function renderPending(actions) {
pendingEl.innerHTML = "";
if (!actions.length) {
pendingEl.className = "pending-empty";
pendingEl.textContent = "No pending actions";
return;
}
pendingEl.className = "";
for (const action of actions) {
const card = document.createElement("div");
card.className = "pending-card";
const title = document.createElement("strong");
title.textContent = action.label;
const endpoint = document.createElement("p");
endpoint.className = "muted";
endpoint.textContent = action.endpoint;
const payload = document.createElement("pre");
payload.textContent = JSON.stringify(action.payload, null, 2);
const approve = document.createElement("button");
approve.textContent = "Approve";
approve.addEventListener("click", () => approveAction(action.id));
card.append(title, endpoint, payload, approve);
pendingEl.appendChild(card);
}
}
async function approveAction(id) {
statusEl.textContent = "Sending";
try {
const response = await fetch(`/api/approve/${id}`, { method: "POST" });
const result = await response.json();
addMessage("assistant", `Approval result:\n${JSON.stringify(result, null, 2)}`);
await refreshPending();
} catch (error) {
addMessage("assistant", `Approval failed: ${error.message}`);
} finally {
statusEl.textContent = "Ready";
}
}
async function refreshPending() {
const response = await fetch("/api/pending-actions");
const result = await response.json();
renderPending(result.pending_actions || []);
}
function renderMemory(data) {
memoryInspectorEl.innerHTML = "";
const counts = document.createElement("div");
counts.className = "memory-counts";
counts.textContent = `${data.memories.length} memories | ${data.conversations.length} chat rows | ${data.profile.length} profile keys | ${data.scheduled_jobs.length} jobs`;
memoryInspectorEl.appendChild(counts);
const path = document.createElement("div");
path.className = "memory-path";
path.textContent = data.path;
memoryInspectorEl.appendChild(path);
memoryInspectorEl.appendChild(memoryGroup("Memories", data.memories, (item) => `${item.kind} (${item.importance})\n${item.content}`));
memoryInspectorEl.appendChild(memoryGroup("Profile", data.profile, (item) => `${item.key}\n${JSON.stringify(item.value, null, 2)}`));
memoryInspectorEl.appendChild(memoryGroup("Recent Chat", data.conversations, (item) => `${item.created_at} ${item.role}\n${item.content}`));
memoryInspectorEl.appendChild(memoryGroup("Wake Jobs", data.scheduled_jobs, (item) => `${item.id}\n${item.trigger_type}: ${item.trigger_value}\n${item.prompt}`));
}
function memoryGroup(title, items, formatter) {
const group = document.createElement("details");
group.className = "memory-group";
const summary = document.createElement("summary");
summary.textContent = `${title} (${items.length})`;
group.appendChild(summary);
if (!items.length) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = "Empty";
group.appendChild(empty);
return group;
}
for (const item of items.slice(0, 20)) {
const entry = document.createElement("pre");
entry.textContent = formatter(item);
group.appendChild(entry);
}
return group;
}
async function refreshMemory() {
try {
const response = await fetch("/api/memory?limit=50");
const data = await response.json();
renderMemory(data);
} catch (error) {
memoryInspectorEl.textContent = `Memory load failed: ${error.message}`;
}
}
async function clearMemory() {
const payload = {
include_memories: document.getElementById("clear-memories").checked,
include_conversations: document.getElementById("clear-conversations").checked,
include_profile: document.getElementById("clear-profile").checked,
include_jobs: document.getElementById("clear-jobs").checked,
include_outbox: document.getElementById("clear-outbox").checked,
};
if (!Object.values(payload).some(Boolean)) return;
const confirmed = window.confirm("Clear the selected TraderAI memory? This cannot be undone.");
if (!confirmed) return;
try {
const response = await fetch("/api/memory/clear", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
renderMemory(result.memory);
addMessage("assistant", `Memory cleared:\n${JSON.stringify(result.deleted, null, 2)}`);
} catch (error) {
addMessage("assistant warning-message", `Memory clear failed: ${error.message}`);
}
}
async function pollNotifications() {
try {
const response = await fetch("/api/notifications");
const result = await response.json();
for (const notification of result.notifications || []) {
addMessage("assistant", notification.content);
}
} catch {
// Notification polling should never interrupt chat.
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
await sendMessage();
});
input.addEventListener("keydown", async (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
await sendMessage();
}
});
memoryRefreshButton.addEventListener("click", refreshMemory);
memoryClearButton.addEventListener("click", clearMemory);
async function sendMessage() {
const message = input.value.trim();
if (!message || input.disabled) return;
const healthy = await checkHealth();
if (!healthy) {
addMessage("assistant warning-message", "Ollama is offline. Start Ollama, then try again.");
return;
}
input.value = "";
input.disabled = true;
addMessage("user", message);
const assistantNode = addMessage("assistant streaming", "");
let assistantText = "";
statusEl.textContent = "Working";
setActivity("Thinking", true);
try {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() || "";
for (const rawEvent of events) {
const line = rawEvent.split("\n").find((entry) => entry.startsWith("data: "));
if (!line) continue;
const event = JSON.parse(line.slice(6));
if (event.type === "status") {
setActivity(event.message, true);
} else if (event.type === "warning") {
setWarning(event.message);
assistantText += event.message;
setMessageMarkdown(assistantNode, assistantText);
} else if (event.type === "token") {
assistantText += event.content;
setMessageMarkdown(assistantNode, assistantText);
messages.scrollTop = messages.scrollHeight;
} else if (event.type === "done") {
renderPending(event.pending_actions || []);
}
}
}
} catch (error) {
const message = error.message.includes("503")
? "Ollama is offline or unreachable. Start Ollama, then try again."
: `Chat failed: ${error.message}`;
setWarning(message);
setMessageMarkdown(assistantNode, message);
} finally {
assistantNode.classList.remove("streaming");
input.disabled = false;
input.focus();
statusEl.textContent = "Ready";
setActivity("");
}
}
addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything.");
refreshPending();
refreshMemory();
pollNotifications();
checkHealth();
setInterval(checkHealth, 30000);
setInterval(pollNotifications, 15000);
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TraderAI</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main class="shell">
<section class="workspace">
<header class="topbar">
<div>
<h1>TraderAI</h1>
<p>Local Ollama chat for UEX marketplace work</p>
</div>
<div class="status" id="status">Ready</div>
</header>
<div class="warning" id="warning" hidden></div>
<div class="messages" id="messages"></div>
<div class="composer-wrap">
<div class="activity" id="activity" aria-live="polite"></div>
<form class="composer" id="chat-form">
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
<button type="submit">Send</button>
</form>
</div>
</section>
<aside class="actions">
<section class="side-section">
<h2>Pending Approval</h2>
<div id="pending-actions" class="pending-empty">No pending actions</div>
</section>
<section class="side-section">
<div class="section-title-row">
<h2>Memory</h2>
<button class="secondary small-button" id="memory-refresh" type="button">Refresh</button>
</div>
<div class="memory-controls">
<label><input type="checkbox" id="clear-memories" checked> Memories</label>
<label><input type="checkbox" id="clear-conversations" checked> Chat</label>
<label><input type="checkbox" id="clear-outbox" checked> Notices</label>
<label><input type="checkbox" id="clear-profile"> Profile</label>
<label><input type="checkbox" id="clear-jobs"> Jobs</label>
</div>
<button class="danger-button" id="memory-clear" type="button">Clear Selected</button>
<div id="memory-inspector" class="memory-inspector"></div>
</section>
</aside>
</main>
<script src="/static/app.js"></script>
</body>
</html>
+383
View File
@@ -0,0 +1,383 @@
:root {
color-scheme: dark;
--bg: #111316;
--panel: #191d22;
--panel-2: #20262d;
--text: #edf1f5;
--muted: #97a1ad;
--accent: #44c2a5;
--accent-2: #e6b94d;
--border: #303842;
--danger: #e66b6b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Inter, Segoe UI, Arial, sans-serif;
}
.shell {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 1px;
min-height: 100vh;
background: var(--border);
}
.workspace,
.actions {
background: var(--panel);
}
.workspace {
display: grid;
grid-template-rows: auto 1fr auto;
min-width: 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 22px;
border-bottom: 1px solid var(--border);
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 22px;
font-weight: 700;
}
h2 {
font-size: 16px;
margin-bottom: 14px;
}
.topbar p,
.muted {
color: var(--muted);
font-size: 13px;
}
.status {
min-width: 76px;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.warning {
padding: 10px 22px;
border-bottom: 1px solid rgba(230, 185, 77, 0.32);
background: rgba(230, 185, 77, 0.11);
color: #f0d28a;
font-size: 13px;
}
.messages {
overflow: auto;
padding: 22px;
}
.message {
max-width: 900px;
margin-bottom: 14px;
padding: 13px 14px;
border: 1px solid var(--border);
border-radius: 8px;
line-height: 1.45;
}
.message p {
margin: 0 0 8px;
}
.message p:last-child,
.message ul:last-child {
margin-bottom: 0;
}
.message ul {
margin: 0 0 10px 20px;
padding: 0;
}
.message li {
margin: 3px 0;
}
.message li.nested {
margin-left: 20px;
color: #d7dde4;
}
.message strong {
color: #ffffff;
}
.message h3 {
margin: 12px 0 8px;
font-size: 15px;
line-height: 1.3;
}
.message hr {
height: 1px;
margin: 12px 0;
border: 0;
background: var(--border);
}
.message code {
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: 4px;
background: #0e1114;
color: #dfe5eb;
font-family: Consolas, Monaco, monospace;
font-size: 0.92em;
}
.message.user {
margin-left: auto;
background: var(--panel-2);
}
.message.assistant {
background: #15191e;
}
.message.warning-message {
border-color: rgba(230, 185, 77, 0.42);
background: rgba(230, 185, 77, 0.1);
}
.composer-wrap {
border-top: 1px solid var(--border);
}
.activity {
min-height: 24px;
padding: 7px 16px 0;
color: rgba(151, 161, 173, 0.72);
font-size: 12px;
}
.working-dots {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
transform: translateY(-1px);
}
.working-dots i {
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(151, 161, 173, 0.8);
animation: pulse-dot 1.2s infinite ease-in-out;
}
.working-dots i:nth-child(2) {
animation-delay: 0.16s;
}
.working-dots i:nth-child(3) {
animation-delay: 0.32s;
}
@keyframes pulse-dot {
0%,
80%,
100% {
opacity: 0.3;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-2px);
}
}
.composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
padding: 16px;
}
textarea {
width: 100%;
resize: vertical;
min-height: 48px;
max-height: 180px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
outline: none;
background: #101316;
color: var(--text);
font: inherit;
}
textarea:disabled {
opacity: 0.62;
}
button {
min-width: 92px;
border: 0;
border-radius: 8px;
background: var(--accent);
color: #07110f;
font-weight: 700;
cursor: pointer;
}
button.secondary {
min-width: 0;
padding: 9px 12px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
}
.small-button {
min-width: 0;
padding: 6px 9px;
font-size: 12px;
}
.danger-button {
width: 100%;
min-width: 0;
padding: 9px 10px;
margin: 10px 0;
border: 1px solid rgba(230, 107, 107, 0.45);
background: rgba(230, 107, 107, 0.14);
color: #f0b1b1;
}
.actions {
padding: 18px;
overflow: auto;
}
.side-section {
margin-bottom: 22px;
}
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.memory-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 10px;
color: var(--muted);
font-size: 12px;
}
.memory-controls label {
display: flex;
align-items: center;
gap: 6px;
}
.memory-inspector {
font-size: 12px;
}
.memory-counts {
color: var(--text);
margin-bottom: 6px;
}
.memory-path {
overflow-wrap: anywhere;
color: var(--muted);
margin-bottom: 10px;
}
.memory-group {
margin-bottom: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: #15191e;
}
.memory-group summary {
cursor: pointer;
padding: 9px 10px;
color: var(--text);
}
.memory-group pre {
margin: 0 10px 10px;
white-space: pre-wrap;
}
.pending-empty {
color: var(--muted);
font-size: 13px;
}
.pending-card {
padding: 12px;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #15191e;
}
.pending-card strong {
display: block;
margin-bottom: 8px;
}
pre {
overflow: auto;
max-height: 240px;
padding: 10px;
border-radius: 6px;
background: #0e1114;
color: #dfe5eb;
font-size: 12px;
}
.pending-card button {
width: 100%;
padding: 10px;
}
@media (max-width: 860px) {
.shell {
grid-template-columns: 1fr;
}
.actions {
border-top: 1px solid var(--border);
}
}