05/31 Phase 5

This commit is contained in:
2026-05-31 16:49:18 -04:00
parent 9c846159ef
commit b3d495e57c
9 changed files with 793 additions and 2 deletions
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}AI History{% endblock %}
{% block page_title %}AI History{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('ai.index') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-stars me-1"></i>Open Chat</a>
{% endblock %}
{% block content %}
<div class="pcard p-0">
{% if insights.items %}
<table class="pfm-table">
<thead>
<tr>
<th style="padding-left:20px;">Date</th>
<th>Type</th>
<th>Content</th>
<th class="d-none d-md-table-cell text-end" style="padding-right:20px;">Tokens</th>
</tr>
</thead>
<tbody>
{% for item in insights.items %}
<tr>
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">
{{ item.created_at.strftime('%b %d, %H:%M') }}
</td>
<td>
<span class="badge" style="font-size:11px;
{% if item.insight_type == 'daily_summary' %}background:#ede9fe;color:#5b21b6;
{% elif item.insight_type == 'chat_response' %}background:#dbeafe;color:#1e40af;
{% else %}background:#f1f5f9;color:#64748b;{% endif %}">
{{ item.insight_type | replace('_',' ') | title }}
</span>
</td>
<td>
{% if item.prompt_summary and item.insight_type == 'chat_response' %}
<div style="font-size:11px;color:var(--muted);margin-bottom:3px;"><i class="bi bi-person me-1"></i>{{ item.prompt_summary }}</div>
{% endif %}
<div style="font-size:13px;color:var(--text);">{{ item.content | truncate(180) }}</div>
</td>
<td class="d-none d-md-table-cell text-end mono" style="font-size:12px;color:var(--muted);padding-right:20px;">
{{ item.tokens_used or '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if insights.pages > 1 %}
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
<span>Page {{ insights.page }} of {{ insights.pages }}</span>
<div class="d-flex gap-1">
{% if insights.has_prev %}
<a href="{{ url_for('ai.history', page=insights.prev_num) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
{% endif %}
{% if insights.has_next %}
<a href="{{ url_for('ai.history', page=insights.next_num) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-stars text-muted" style="font-size:2.5rem;"></i>
<p class="text-muted mt-2">No AI history yet. Start a conversation.</p>
<a href="{{ url_for('ai.index') }}" class="btn btn-sm btn-primary">Open Chat</a>
</div>
{% endif %}
</div>
{% endblock %}
+239
View File
@@ -0,0 +1,239 @@
{% extends "base.html" %}
{% block title %}AI Assistant{% endblock %}
{% block page_title %}AI Assistant{% endblock %}
{% block extra_css %}
#chatMessages { height: 440px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding: 16px; scroll-behavior: smooth; }
.msg-user { align-self: flex-end; max-width: 75%; background: #3b82f6; color: #fff; border-radius: 16px 16px 4px 16px; padding: 10px 14px; font-size: 13.5px; line-height: 1.5; }
.msg-ai { align-self: flex-start; max-width: 82%; background: #1e293b; color: #e2e8f0; border-radius: 16px 16px 16px 4px; padding: 10px 14px; font-size: 13.5px; line-height: 1.6; }
.msg-ai.streaming { border-left: 2px solid #818cf8; }
.msg-system { align-self: center; font-size: 12px; color: var(--muted); font-style: italic; }
.suggestion-btn { background: #f1f5f9; border: 1px solid var(--border); border-radius: 20px; padding: 6px 14px; font-size: 12px; color: var(--text); cursor: pointer; transition: all .15s; white-space: nowrap; }
.suggestion-btn:hover { background: #e2e8f0; border-color: #94a3b8; }
.typing-dot { width: 6px; height: 6px; border-radius: 50%; background: #818cf8; display: inline-block; animation: bounce .8s infinite; }
.typing-dot:nth-child(2) { animation-delay: .15s; }
.typing-dot:nth-child(3) { animation-delay: .3s; }
@keyframes bounce { 0%,60%,100% { transform: translateY(0); } 30% { transform: translateY(-6px); } }
{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('ai.history') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-clock-history me-1"></i>History</a>
{% endblock %}
{% block content %}
<div class="row g-3">
<!-- Chat panel -->
<div class="col-12 col-lg-8">
<div class="pcard p-0 d-flex flex-column" style="height:600px;background:#0f172a;border-color:#1e293b;">
<!-- Header -->
<div style="padding:14px 16px;border-bottom:1px solid #1e293b;display:flex;align-items:center;gap:10px;">
<div style="width:32px;height:32px;border-radius:8px;background:#312e81;display:flex;align-items:center;justify-content:center;">
<i class="bi bi-stars" style="color:#818cf8;font-size:15px;"></i>
</div>
<div>
<div style="font-size:13px;font-weight:600;color:#f1f5f9;">Finance AI</div>
<div style="font-size:11px;color:#475569;" id="statusLine">Powered by Groq · Context: last 90 days</div>
</div>
</div>
<!-- Messages -->
<div id="chatMessages">
<div class="msg-system">Ask me anything about your finances.</div>
{% if recent_chats %}
{% for chat in recent_chats|reverse %}
<div class="msg-user">{{ chat.prompt_summary }}</div>
<div class="msg-ai">{{ chat.content | replace('\n', '<br>') | safe }}</div>
{% endfor %}
{% endif %}
</div>
<!-- Input -->
<div style="padding:12px 16px;border-top:1px solid #1e293b;margin-top:auto;">
<div style="display:flex;gap:8px;align-items:flex-end;">
<textarea id="chatInput" rows="2"
style="flex:1;background:#1e293b;border:1px solid #334155;border-radius:10px;color:#f1f5f9;font-size:13.5px;padding:10px 14px;resize:none;font-family:'DM Sans',sans-serif;outline:none;transition:border .2s;"
placeholder="Ask about your spending, budgets, goals…"
onfocus="this.style.borderColor='#818cf8'" onblur="this.style.borderColor='#334155'"></textarea>
<button id="sendBtn" onclick="sendMessage()"
style="width:40px;height:40px;border-radius:10px;background:#4f46e5;border:none;color:#fff;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s;">
<i class="bi bi-send"></i>
</button>
</div>
<div style="font-size:11px;color:#334155;margin-top:6px;">Enter to send · Shift+Enter for new line</div>
</div>
</div>
</div>
<!-- Right column: daily insight + suggestions -->
<div class="col-12 col-lg-4 d-flex flex-column gap-3">
<!-- Daily insight -->
{% if latest_insight %}
<div class="pcard" style="background:#0f172a;border-color:#1e293b;">
<div class="d-flex justify-content-between align-items-center mb-2">
<span style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#818cf8;">
<i class="bi bi-stars me-1"></i>Daily Insight
</span>
<span style="font-size:10px;color:#475569;">{{ latest_insight.insight_date.strftime('%b %d') }}</span>
</div>
<p style="font-size:13px;color:#94a3b8;line-height:1.6;margin:0;">{{ latest_insight.content }}</p>
<form method="POST" action="{{ url_for('ai.trigger_insight') }}" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" style="font-size:11px;background:none;border:none;color:#475569;cursor:pointer;padding:0;" title="Regenerate today's insight">
<i class="bi bi-arrow-clockwise me-1"></i>Regenerate
</button>
</form>
</div>
{% else %}
<div class="pcard" style="background:#0f172a;border-color:#1e293b;">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#475569;margin-bottom:8px;">Daily Insight</div>
<p style="font-size:13px;color:#64748b;margin-bottom:10px;">No insight generated yet.</p>
<form method="POST" action="{{ url_for('ai.trigger_insight') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm" style="background:#312e81;color:#818cf8;border:none;font-size:12px;">
<i class="bi bi-stars me-1"></i>Generate Now
</button>
</form>
</div>
{% endif %}
<!-- Suggested questions -->
<div class="pcard" style="background:#0f172a;border-color:#1e293b;">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#475569;margin-bottom:10px;">Suggested Questions</div>
<div class="d-flex flex-wrap gap-2">
{% set suggestions = [
"Where did I overspend this month?",
"How is my budget looking?",
"Am I on track for my goals?",
"What's my biggest expense category?",
"Summarize my finances",
"How can I save more?",
"What's my net worth trend?",
"Review my investments",
] %}
{% for s in suggestions %}
<button class="suggestion-btn" onclick="fillSuggestion(this.textContent)">{{ s }}</button>
{% endfor %}
</div>
</div>
<!-- Tips -->
<div class="pcard" style="background:#0f172a;border-color:#1e293b;">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#475569;margin-bottom:8px;">Tips</div>
<ul style="font-size:12px;color:#64748b;padding-left:16px;margin:0;line-height:1.8;">
<li>AI sees your last 90 days of transactions</li>
<li>Budget alerts and goal progress are included</li>
<li>No personal names are sent to the AI</li>
<li>Daily insights auto-generate at midnight</li>
</ul>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const chatMessages = document.getElementById('chatMessages');
const chatInput = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
const statusLine = document.getElementById('statusLine');
// Enter to send
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
function fillSuggestion(text) {
chatInput.value = text;
chatInput.focus();
}
function scrollBottom() {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function addMessage(text, type) {
const div = document.createElement('div');
div.className = 'msg-' + type;
if (type === 'ai') div.innerHTML = text;
else div.textContent = text;
chatMessages.appendChild(div);
scrollBottom();
return div;
}
function sendMessage() {
const msg = chatInput.value.trim();
if (!msg) return;
chatInput.value = '';
chatInput.disabled = true;
sendBtn.disabled = true;
sendBtn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
// User bubble
addMessage(msg, 'user');
// Typing indicator
const typingDiv = document.createElement('div');
typingDiv.className = 'msg-ai';
typingDiv.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
chatMessages.appendChild(typingDiv);
scrollBottom();
statusLine.textContent = 'Thinking…';
// SSE stream
const url = '/ai/stream?message=' + encodeURIComponent(msg);
const evtSource = new EventSource(url);
let aiDiv = null;
let buffer = '';
let first = true;
evtSource.onmessage = function(e) {
if (e.data === '[DONE]') {
evtSource.close();
chatInput.disabled = false;
sendBtn.disabled = false;
sendBtn.innerHTML = '<i class="bi bi-send"></i>';
statusLine.textContent = 'Powered by Groq · Context: last 90 days';
chatInput.focus();
return;
}
if (first) {
typingDiv.remove();
aiDiv = document.createElement('div');
aiDiv.className = 'msg-ai streaming';
chatMessages.appendChild(aiDiv);
first = false;
}
// Replace <br> back to newlines for rendering
const chunk = e.data.replace(/<br>/g, '\n');
buffer += chunk;
aiDiv.innerHTML = buffer.replace(/\n/g, '<br>');
aiDiv.classList.remove('streaming');
scrollBottom();
};
evtSource.onerror = function() {
evtSource.close();
typingDiv.remove();
if (!aiDiv) {
addMessage('Connection error. Please try again.', 'system');
}
chatInput.disabled = false;
sendBtn.disabled = false;
sendBtn.innerHTML = '<i class="bi bi-send"></i>';
statusLine.textContent = 'Powered by Groq · Context: last 90 days';
};
}
// Scroll to bottom on load
scrollBottom();
</script>
{% endblock %}