diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 96b53dc..382e5aa 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -452,6 +452,7 @@ func (a *application) server() (func() error, func() error) { mux.HandleFunc("GET /api/finance", handleFinanceGet) mux.HandleFunc("POST /api/finance", handleFinancePost) + mux.HandleFunc("POST /api/ai-chat", handleAIChatProxy) if a.RequiresAuth { mux.HandleFunc("GET /login", a.handleLoginPageRequest) diff --git a/internal/glance/static/css/widget-ai-chat.css b/internal/glance/static/css/widget-ai-chat.css new file mode 100644 index 0000000..008c812 --- /dev/null +++ b/internal/glance/static/css/widget-ai-chat.css @@ -0,0 +1,117 @@ +.ai-chat { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.ai-chat-messages { + display: flex; + flex-direction: column; + gap: 0.6rem; + max-height: 320px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.1) transparent; + padding-right: 2px; +} + +.ai-chat-messages:empty::before { + content: 'Start a conversation'; + color: var(--color-text-subdue); + font-size: 0.9rem; + text-align: center; + padding: 1.5rem 0; + display: block; + opacity: 0.5; +} + +.ai-chat-msg { + padding: 0.55rem 0.85rem; + border-radius: 10px; + font-size: 0.95rem; + line-height: 1.55; + max-width: 88%; + word-break: break-word; + white-space: pre-wrap; +} + +.ai-chat-msg-user { + align-self: flex-end; + background: var(--color-primary); + color: var(--color-background); + border-bottom-right-radius: 3px; +} + +.ai-chat-msg-assistant { + align-self: flex-start; + background: rgba(255,255,255,0.06); + color: var(--color-text-highlight); + border-bottom-left-radius: 3px; +} + +.ai-chat-msg-loading::after { + content: '▋'; + animation: ai-chat-blink 0.8s step-end infinite; + opacity: 0.7; +} + +@keyframes ai-chat-blink { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 0; } +} + +.ai-chat-input-row { + display: flex; + gap: 0.5rem; + align-items: flex-end; +} + +.ai-chat-input { + flex: 1; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + color: var(--color-text-highlight); + font-family: inherit; + font-size: 0.95rem; + line-height: 1.5; + padding: 0.55rem 0.75rem; + resize: none; + outline: none; + transition: border-color 0.2s; + min-height: 36px; + max-height: 120px; +} + +.ai-chat-input:focus { + border-color: var(--color-primary); +} + +.ai-chat-input::placeholder { + color: var(--color-text-subdue); + opacity: 0.6; +} + +.ai-chat-send { + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 8px; + border: none; + background: var(--color-primary); + color: var(--color-background); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s; +} + +.ai-chat-send:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.ai-chat-send:not(:disabled):hover { + opacity: 0.85; +} diff --git a/internal/glance/static/css/widgets.css b/internal/glance/static/css/widgets.css index 876e5bd..a01f8d9 100644 --- a/internal/glance/static/css/widgets.css +++ b/internal/glance/static/css/widgets.css @@ -15,6 +15,7 @@ @import "widget-videos.css"; @import "widget-weather.css"; @import "widget-todo.css"; +@import "widget-ai-chat.css"; @import "forum-posts.css"; diff --git a/internal/glance/static/js/ai-chat.js b/internal/glance/static/js/ai-chat.js new file mode 100644 index 0000000..c5cbbf9 --- /dev/null +++ b/internal/glance/static/js/ai-chat.js @@ -0,0 +1,97 @@ +export default function setupAIChat(el) { + const model = el.dataset.model || 'llama3.2'; + const ollamaURL = el.dataset.ollamaUrl || 'http://localhost:11434'; + + const messagesEl = el.querySelector('.ai-chat-messages'); + const inputEl = el.querySelector('.ai-chat-input'); + const sendBtn = el.querySelector('.ai-chat-send'); + + const history = []; + let busy = false; + + function appendMessage(role, text) { + const div = document.createElement('div'); + div.className = `ai-chat-msg ai-chat-msg-${role}`; + div.textContent = text; + messagesEl.appendChild(div); + messagesEl.scrollTop = messagesEl.scrollHeight; + return div; + } + + async function send() { + const text = inputEl.value.trim(); + if (!text || busy) return; + + busy = true; + sendBtn.disabled = true; + inputEl.value = ''; + inputEl.style.height = 'auto'; + + history.push({ role: 'user', content: text }); + appendMessage('user', text); + + const assistantEl = appendMessage('assistant', ''); + assistantEl.classList.add('ai-chat-msg-loading'); + + let reply = ''; + + try { + const res = await fetch('/api/ai-chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ollama_url: ollamaURL, model, messages: history }), + }); + + if (!res.ok) { + assistantEl.textContent = `Error: ${res.statusText}`; + assistantEl.classList.remove('ai-chat-msg-loading'); + history.pop(); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const lines = decoder.decode(value, { stream: true }).split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + try { + const chunk = JSON.parse(line); + if (chunk.message && chunk.message.content) { + reply += chunk.message.content; + assistantEl.textContent = reply; + messagesEl.scrollTop = messagesEl.scrollHeight; + } + } catch (_) {} + } + } + } catch (e) { + assistantEl.textContent = 'Failed to reach Ollama.'; + } finally { + assistantEl.classList.remove('ai-chat-msg-loading'); + if (reply) history.push({ role: 'assistant', content: reply }); + busy = false; + sendBtn.disabled = false; + inputEl.focus(); + } + } + + sendBtn.addEventListener('click', send); + + inputEl.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }); + + // Auto-grow textarea + inputEl.addEventListener('input', () => { + inputEl.style.height = 'auto'; + inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px'; + }); +} diff --git a/internal/glance/static/js/finance.js b/internal/glance/static/js/finance.js new file mode 100644 index 0000000..2eeb856 --- /dev/null +++ b/internal/glance/static/js/finance.js @@ -0,0 +1,134 @@ +export default async function setupFinance(el) { + const resolveColor = (cssVar, fallback) => { + const div = document.createElement('div'); + div.style.color = fallback; + div.style.color = `var(${cssVar})`; + div.style.display = 'none'; + document.body.appendChild(div); + const color = getComputedStyle(div).color; + document.body.removeChild(div); + return color; + }; + + const addAlpha = (rgb, a) => { + const m = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/); + return m ? `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${a})` : rgb; + }; + + let data = null; + let charts = {}; + + async function load() { + try { + const res = await fetch('/api/finance'); + data = await res.json(); + if (!data.monthly || !data.monthly.labels) { + data.monthly = { labels: ['Jan'], income: [0], expenses: [0], spend: [0] }; + } + if (!data.accounts) data.accounts = []; + renderCharts(); + renderTable(); + } catch (e) { + console.error('Finance widget error:', e); + } + } + + async function save() { + await fetch('/api/finance', { + method: 'POST', + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' } + }); + } + + function renderCharts() { + const cPos = resolveColor('--color-positive', 'hsl(150, 60%, 50%)'); + const cNeg = resolveColor('--color-negative', 'hsl(0, 70%, 65%)'); + const cPri = resolveColor('--color-primary', 'hsl(12, 70%, 60%)'); + const cText = resolveColor('--color-text-subdue', 'rgba(255,255,255,0.6)'); + + Chart.defaults.color = cText; + Chart.defaults.font.family = 'Outfit, -apple-system, sans-serif'; + + const c1 = el.querySelector('#incomeExpenseChart'); + if (c1) { + if (charts.incomeExpense) charts.incomeExpense.destroy(); + charts.incomeExpense = new Chart(c1.getContext('2d'), { + type: 'bar', + data: { + labels: data.monthly.labels, + datasets: [ + { label: 'Income', data: data.monthly.income, backgroundColor: addAlpha(cPos, 0.6), borderColor: cPos, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 }, + { label: 'Expenses', data: data.monthly.expenses, backgroundColor: addAlpha(cNeg, 0.6), borderColor: cNeg, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 } + ] + }, + options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, layout: { padding: 10 }, scales: { y: { display: false, beginAtZero: true }, x: { display: false } } } + }); + } + + const c2 = el.querySelector('#spendChart'); + if (c2) { + if (charts.spend) charts.spend.destroy(); + charts.spend = new Chart(c2.getContext('2d'), { + type: 'line', + data: { + labels: data.monthly.labels, + datasets: [{ label: 'Spend', data: data.monthly.spend, borderColor: cPri, borderWidth: 2, tension: 0.4, fill: false, pointRadius: 0, pointHoverRadius: 5 }] + }, + options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, layout: { padding: 10 }, scales: { y: { display: false, beginAtZero: true }, x: { display: false } } } + }); + } + } + + function renderTable() { + const tbody = el.querySelector('#finance-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + if (!data.accounts || data.accounts.length === 0) { + tbody.innerHTML = `
Income vs Expense
+Monthly Discretionary Spend
+Account Balances
+ +| Account | +Balance | +Currency | +Actions | +
|---|