Containerised macro-strategy dashboard: 4-panel web UI (indicators, portfolio, flash news, AI strategic log), MariaDB store, hourly ingestion jobs, OpenRouter-backed AI analysis. Ports the four prototype scripts in the parent dir (market_pulse, flash_news, trading212, strategic_log) into async services backed by a persistent DB and served via FastAPI + Jinja2 + HTMX. APScheduler runs as a separate compose service for crash-safety and easier restarts. Portfolio composition + position names come live from Trading 212; news per-ticker headlines reuse those names. Tone (NOVICE/INTERMEDIATE/ PRO) and analysis style (DRY/SPECULATIVE) are env-configurable and stored on each log row so historical entries show what produced them. Default model is deepseek/deepseek-v4-flash (overridable via env). Light/dark theme toggle, sans-serif for prose surfaces, monospace for data. Bearer-token auth, OpenRouter monthly cost cap, RSS feeds auto- disabled on consecutive failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
73 lines
2.3 KiB
JavaScript
73 lines
2.3 KiB
JavaScript
// Cassandra chat sidebar — ephemeral, client-state conversation.
|
|
// No persistence: page refresh starts a new chat.
|
|
(() => {
|
|
const thread = document.getElementById('chat-thread');
|
|
const form = document.getElementById('chat-form');
|
|
const input = document.getElementById('chat-input');
|
|
const send = document.getElementById('chat-send');
|
|
if (!thread || !form || !input || !send) return;
|
|
|
|
/** @type {{role: string, content: string}[]} */
|
|
const messages = [];
|
|
|
|
function escapeHTML(s) {
|
|
return s.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
function append(role, html_or_text, opts) {
|
|
const div = document.createElement('div');
|
|
div.className = 'chat-msg chat-msg--' + role;
|
|
if (opts && opts.html) {
|
|
div.innerHTML = html_or_text;
|
|
} else {
|
|
div.textContent = html_or_text;
|
|
}
|
|
thread.appendChild(div);
|
|
thread.scrollTop = thread.scrollHeight;
|
|
return div;
|
|
}
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const text = input.value.trim();
|
|
if (!text || send.disabled) return;
|
|
messages.push({role: 'user', content: text});
|
|
append('user', text);
|
|
input.value = '';
|
|
send.disabled = true;
|
|
const thinking = append('assistant pending', '…');
|
|
try {
|
|
const r = await fetch('/api/chat', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({messages}),
|
|
});
|
|
if (!r.ok) {
|
|
const msg = await r.text();
|
|
thinking.className = 'chat-msg chat-msg--error';
|
|
thinking.textContent = 'HTTP ' + r.status + ': ' + msg.slice(0, 300);
|
|
return;
|
|
}
|
|
const data = await r.json();
|
|
thinking.className = 'chat-msg chat-msg--assistant';
|
|
thinking.innerHTML = data.content_html || escapeHTML(data.content);
|
|
messages.push({role: 'assistant', content: data.content});
|
|
} catch (err) {
|
|
thinking.className = 'chat-msg chat-msg--error';
|
|
thinking.textContent = 'error: ' + err.message;
|
|
} finally {
|
|
send.disabled = false;
|
|
input.focus();
|
|
}
|
|
});
|
|
|
|
// Enter to send; Shift+Enter for newline.
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
form.requestSubmit();
|
|
}
|
|
});
|
|
})();
|