Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Lint
name: CI

on:
push:
Expand Down Expand Up @@ -31,10 +31,29 @@ jobs:
- name: Run format check
run: npm run format:check

test:
name: Test
runs-on: ubuntu-latest

steps:
- name: Check out Git repository
uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 20

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm run test:run

build:
name: Build
runs-on: ubuntu-latest
needs: lint-and-format
needs: [lint-and-format, test]

steps:
- name: Check out Git repository
Expand Down
276 changes: 255 additions & 21 deletions components/ai/AIChat.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,78 @@
<template>
<div class="ai-chat card">
<h2 class="title">AI Insights Chat</h2>
<div class="chat-window">
<div ref="chatWindow" class="chat-window">
<div
v-for="(message, index) in chatHistory"
:key="index"
class="chat-row"
:class="message.type"
>
<div class="bubble" :class="message.type">{{ message.text }}</div>
<div class="bubble" :class="message.type">
<div class="message-text">{{ message.text }}</div>
<div v-if="message.results?.length && message.formatType" class="results-container">
<template v-if="message.formatType === 'scalar'">
<div class="result-scalar">
{{ formatValue(Object.values(message.results[0])[0]) }}
</div>
</template>
<template v-else-if="message.formatType === 'pair'">
<div class="result-pair">
<span class="pair-label">{{ Object.keys(message.results[0])[0] }}:</span>
<span class="pair-value">{{
formatValue(Object.values(message.results[0])[1])
}}</span>
</div>
</template>
<template v-else-if="message.formatType === 'record'">
<div class="result-record">
<div v-for="(value, key) in message.results[0]" :key="key" class="record-row">
<span class="record-key">{{ formatKey(key) }}:</span>
<span class="record-value">{{ formatValue(value) }}</span>
</div>
</div>
</template>
<template v-else-if="message.formatType === 'list'">
<ul class="result-list">
<li v-for="(row, i) in message.results" :key="i">
{{ formatValue(Object.values(row)[0]) }}
</li>
</ul>
</template>
<template v-else-if="message.formatType === 'pair_list'">
<div class="result-pair-list">
<div v-for="(row, i) in message.results" :key="i" class="pair-row">
<span class="pair-label">{{ Object.values(row)[0] }}:</span>
<span class="pair-value">{{ formatValue(Object.values(row)[1]) }}</span>
</div>
</div>
</template>
<template v-else-if="message.formatType === 'table'">
<div class="result-table-wrapper">
<table class="result-table">
<thead>
<tr>
<th v-for="key in Object.keys(message.results[0])" :key="key">
{{ formatKey(key) }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in message.results" :key="i">
<td v-for="(value, key) in row" :key="key">{{ formatValue(value) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-else>
<pre class="result-raw">{{ JSON.stringify(message.results, null, 2) }}</pre>
</template>
</div>
</div>
</div>
<div v-if="isLoading" class="chat-row ai">
<div class="bubble ai loading">Thinking...</div>
</div>
</div>
<form class="composer" @submit.prevent="handleSendMessage">
Expand All @@ -17,40 +81,93 @@
type="text"
class="chat-input"
placeholder="Ask me anything about your finances..."
:disabled="isLoading"
/>
<button type="submit" class="send-btn">Send</button>
<button type="submit" class="send-btn" :disabled="isLoading || !input.trim()">
{{ isLoading ? '...' : 'Send' }}
</button>
</form>
</div>
</template>

<script setup>
import { ref } from 'vue';
<script setup lang="ts">
import { ref, nextTick } from 'vue';
import { aiApi, type FormatType } from '@/services/api/aiApi';

interface ChatMessage {
type: 'user' | 'ai';
text: string;
results?: Record<string, unknown>[];
formatType?: FormatType | null;
}

const chatHistory = ref([
const chatHistory = ref<ChatMessage[]>([
{ type: 'ai', text: 'Hello! I am your personal financial assistant. How can I help you today?' }
]);
const input = ref('');
const isLoading = ref(false);
const chatWindow = ref<HTMLElement | null>(null);

const handleSendMessage = () => {
if (!input.value.trim()) return;
const userMessage = { type: 'user', text: input.value };
chatHistory.value.push(userMessage);
const scrollToBottom = () => {
nextTick(() => {
if (chatWindow.value) {
chatWindow.value.scrollTop = chatWindow.value.scrollHeight;
}
});
};

const formatKey = (key: string): string => {
return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
};

let aiResponse = 'I am not quite sure how to answer that yet. Please try a different query.';
const lower = input.value.toLowerCase();
if (lower.includes('highest-paying months')) {
aiResponse =
"Based on last year's data, your highest-paying months were November ($8,500) and December ($7,900) due to holiday bonuses and year-end client projects.";
} else if (lower.includes('on track to save')) {
aiResponse =
'Yes, you are currently on track to save 20% of your income. Your current savings rate is 21.5%, which is slightly ahead of your goal.';
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '-';
if (typeof value === 'number') {
return Number.isInteger(value)
? value.toString()
: value.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
return String(value);
};

setTimeout(() => {
chatHistory.value.push({ type: 'ai', text: aiResponse });
}, 600);
const handleSendMessage = async () => {
if (!input.value.trim() || isLoading.value) return;

const userMessage: ChatMessage = { type: 'user', text: input.value };
chatHistory.value.push(userMessage);
const question = input.value;
input.value = '';
isLoading.value = true;
scrollToBottom();

try {
const response = await aiApi.ask(question);

if (response.success && response.data) {
chatHistory.value.push({
type: 'ai',
text: response.data.answer,
results: response.data.results,
formatType: response.data.format_type
});
} else {
chatHistory.value.push({
type: 'ai',
text: response.message || 'Sorry, I could not process your request. Please try again.'
});
}
} catch {
chatHistory.value.push({
type: 'ai',
text: 'Sorry, the AI service is currently unavailable. Please try again later.'
});
} finally {
isLoading.value = false;
scrollToBottom();
}
};
</script>

Expand Down Expand Up @@ -104,6 +221,21 @@ const handleSendMessage = () => {
background: $primary-light;
color: $primary;
border: 1px solid color.adjust($primary, $lightness: 35%);

&.loading {
opacity: 0.7;
animation: pulse 1.5s ease-in-out infinite;
}
}

@keyframes pulse {
0%,
100% {
opacity: 0.7;
}
50% {
opacity: 0.4;
}
}
.bubble.user {
background: $bg-gray;
Expand Down Expand Up @@ -142,4 +274,106 @@ const handleSendMessage = () => {
background: $primary-hover;
}
}

.message-text {
white-space: pre-wrap;
}

.results-container {
margin-top: $spacing-2;
padding-top: $spacing-2;
border-top: 1px solid color.adjust($primary, $lightness: 30%);
}

.result-scalar {
font-size: $font-size-2xl;
font-weight: $font-bold;
color: $primary;
}

.result-pair,
.pair-row {
display: flex;
gap: $spacing-2;
padding: $spacing-1 0;

.pair-label {
font-weight: $font-semibold;
color: $text-secondary;
}

.pair-value {
font-weight: $font-bold;
}
}

.result-record {
.record-row {
display: flex;
gap: $spacing-2;
padding: $spacing-1 0;
border-bottom: 1px solid color.adjust($primary, $lightness: 35%);

&:last-child {
border-bottom: none;
}

.record-key {
font-weight: $font-semibold;
color: $text-secondary;
min-width: 100px;
}
}
}

.result-list {
margin: 0;
padding-left: $spacing-4;

li {
padding: $spacing-1 0;
}
}

.result-pair-list {
display: flex;
flex-direction: column;
gap: $spacing-1;
}

.result-table-wrapper {
overflow-x: auto;
margin-top: $spacing-1;
}

.result-table {
width: 100%;
border-collapse: collapse;
font-size: $font-size-sm;

th,
td {
padding: $spacing-1 $spacing-2;
text-align: left;
border-bottom: 1px solid color.adjust($primary, $lightness: 35%);
}

th {
font-weight: $font-semibold;
background: color.adjust($primary-light, $lightness: 5%);
}

tr:last-child td {
border-bottom: none;
}
}

.result-raw {
background: $bg-gray;
padding: $spacing-2;
border-radius: $radius-md;
overflow-x: auto;
font-size: $font-size-sm;
margin: 0;
}
</style>
Loading
Loading