From 1ab88b37584827d4876340eee79e6798bc05249c Mon Sep 17 00:00:00 2001 From: Tanmay Karande Date: Tue, 17 Mar 2026 01:39:16 -0400 Subject: [PATCH] feat: add personal-finance and ai-chat widgets Co-Authored-By: Claude Sonnet 4.6 --- internal/glance/glance.go | 1 + internal/glance/static/css/widget-ai-chat.css | 117 +++++++++++++++ internal/glance/static/css/widgets.css | 1 + internal/glance/static/js/ai-chat.js | 97 +++++++++++++ internal/glance/static/js/finance.js | 134 ++++++++++++++++++ internal/glance/static/js/page.js | 24 ++++ internal/glance/templates/ai-chat.html | 15 ++ .../glance/templates/personal-finance.html | 42 ++++++ internal/glance/widget-ai-chat.go | 77 ++++++++++ internal/glance/widget-personal-finance.go | 20 +++ internal/glance/widget.go | 4 + 11 files changed, 532 insertions(+) create mode 100644 internal/glance/static/css/widget-ai-chat.css create mode 100644 internal/glance/static/js/ai-chat.js create mode 100644 internal/glance/static/js/finance.js create mode 100644 internal/glance/templates/ai-chat.html create mode 100644 internal/glance/templates/personal-finance.html create mode 100644 internal/glance/widget-ai-chat.go create mode 100644 internal/glance/widget-personal-finance.go 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 = `No accounts.`; + return; + } + const inStyle = `width:100%;background:transparent;border:none;color:var(--color-text-highlight);padding:1rem;font-family:inherit;font-size:1.05rem;outline:none;transition:background 0.2s`; + data.accounts.forEach((acc, i) => { + const tr = document.createElement('tr'); + tr.style.borderBottom = '1px solid var(--color-widget-content-border)'; + + const td1 = document.createElement('td'); td1.style.padding = '0'; + td1.innerHTML = ``; + + const td2 = document.createElement('td'); td2.style.padding = '0'; + td2.innerHTML = ``; + + const td3 = document.createElement('td'); td3.style.padding = '0'; + td3.innerHTML = ``; + + const td4 = document.createElement('td'); td4.style.textAlign = 'right'; td4.style.padding = '1rem'; + td4.innerHTML = ``; + + tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); + tbody.appendChild(tr); + }); + } + + const app = { + update(i, key, val) { data.accounts[i][key] = val; renderTable(); save(); }, + addAccount() { data.accounts.push({ name: 'New Account', balance: 0, currency: 'USD' }); renderTable(); save(); }, + delete(i) { data.accounts.splice(i, 1); renderTable(); save(); } + }; + + // Attach app to widget element so inline event handlers can reach it + el.financeApp = app; + + // Also expose the add-account button + const addBtn = el.querySelector('[data-finance-add]'); + if (addBtn) addBtn.onclick = () => app.addAccount(); + + if (!window.Chart) { + const s = document.createElement('script'); + s.src = 'https://cdn.jsdelivr.net/npm/chart.js'; + s.onload = () => load(); + document.head.appendChild(s); + } else { + await load(); + } +} diff --git a/internal/glance/static/js/page.js b/internal/glance/static/js/page.js index 0212a4f..a0e9313 100644 --- a/internal/glance/static/js/page.js +++ b/internal/glance/static/js/page.js @@ -653,6 +653,28 @@ async function setupTodos() { } } +async function setupFinances() { + const elems = Array.from(document.getElementsByClassName("personal-finance")); + if (elems.length == 0) return; + + const finance = await import('./finance.js'); + + for (let i = 0; i < elems.length; i++) { + finance.default(elems[i]); + } +} + +async function setupAIChats() { + const elems = Array.from(document.getElementsByClassName("ai-chat")); + if (elems.length == 0) return; + + const aiChat = await import('./ai-chat.js'); + + for (let i = 0; i < elems.length; i++) { + aiChat.default(elems[i]); + } +} + function setupTruncatedElementTitles() { const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines"); @@ -757,6 +779,8 @@ async function setupPage() { setupClocks() await setupCalendars(); await setupTodos(); + await setupFinances(); + await setupAIChats(); setupCarousels(); setupSearchBoxes(); setupCollapsibleLists(); diff --git a/internal/glance/templates/ai-chat.html b/internal/glance/templates/ai-chat.html new file mode 100644 index 0000000..0b7d303 --- /dev/null +++ b/internal/glance/templates/ai-chat.html @@ -0,0 +1,15 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +
+
+
+ + +
+
+{{ end }} diff --git a/internal/glance/templates/personal-finance.html b/internal/glance/templates/personal-finance.html new file mode 100644 index 0000000..ff68722 --- /dev/null +++ b/internal/glance/templates/personal-finance.html @@ -0,0 +1,42 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +
+ +
+
+

Income vs Expense

+
+ +
+
+
+

Monthly Discretionary Spend

+
+ +
+
+
+ +
+
+

Account Balances

+ +
+
+ + + + + + + + + + +
AccountBalanceCurrencyActions
+
+
+ +
+{{ end }} diff --git a/internal/glance/widget-ai-chat.go b/internal/glance/widget-ai-chat.go new file mode 100644 index 0000000..248fe1b --- /dev/null +++ b/internal/glance/widget-ai-chat.go @@ -0,0 +1,77 @@ +package glance + +import ( + "bytes" + "encoding/json" + "html/template" + "io" + "net/http" +) + +var aiChatWidgetTemplate = mustParseTemplate("ai-chat.html", "widget-base.html") + +type aiChatWidget struct { + widgetBase `yaml:",inline"` + OllamaURL string `yaml:"ollama-url"` + Model string `yaml:"model"` + cachedHTML template.HTML `yaml:"-"` +} + +func (w *aiChatWidget) initialize() error { + w.withTitle("AI Assistant").withError(nil) + if w.OllamaURL == "" { + w.OllamaURL = "http://localhost:11434" + } + if w.Model == "" { + w.Model = "llama3.2" + } + w.cachedHTML = w.renderTemplate(w, aiChatWidgetTemplate) + return nil +} + +func (w *aiChatWidget) Render() template.HTML { + return w.cachedHTML +} + +func handleAIChatProxy(resp http.ResponseWriter, req *http.Request) { + var body struct { + OllamaURL string `json:"ollama_url"` + Model string `json:"model"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + http.Error(resp, err.Error(), http.StatusBadRequest) + return + } + + if body.OllamaURL == "" { + body.OllamaURL = "http://localhost:11434" + } + + ollamaReq := map[string]interface{}{ + "model": body.Model, + "messages": body.Messages, + "stream": true, + } + + ollamaBody, err := json.Marshal(ollamaReq) + if err != nil { + http.Error(resp, err.Error(), http.StatusInternalServerError) + return + } + + ollamaResp, err := http.Post(body.OllamaURL+"/api/chat", "application/json", bytes.NewReader(ollamaBody)) + if err != nil { + http.Error(resp, "could not reach Ollama: "+err.Error(), http.StatusBadGateway) + return + } + defer ollamaResp.Body.Close() + + resp.Header().Set("Content-Type", "application/x-ndjson") + resp.Header().Set("X-Content-Type-Options", "nosniff") + io.Copy(resp, ollamaResp.Body) +} diff --git a/internal/glance/widget-personal-finance.go b/internal/glance/widget-personal-finance.go new file mode 100644 index 0000000..c4f2917 --- /dev/null +++ b/internal/glance/widget-personal-finance.go @@ -0,0 +1,20 @@ +package glance + +import "html/template" + +var personalFinanceWidgetTemplate = mustParseTemplate("personal-finance.html", "widget-base.html") + +type personalFinanceWidget struct { + widgetBase `yaml:",inline"` + cachedHTML template.HTML `yaml:"-"` +} + +func (widget *personalFinanceWidget) initialize() error { + widget.withTitle("Personal Finance").withError(nil) + widget.cachedHTML = widget.renderTemplate(widget, personalFinanceWidgetTemplate) + return nil +} + +func (widget *personalFinanceWidget) Render() template.HTML { + return widget.cachedHTML +} diff --git a/internal/glance/widget.go b/internal/glance/widget.go index 50dc3cb..7d93a96 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -81,6 +81,10 @@ func newWidget(widgetType string) (widget, error) { w = &serverStatsWidget{} case "to-do": w = &todoWidget{} + case "personal-finance": + w = &personalFinanceWidget{} + case "ai-chat": + w = &aiChatWidget{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) }