feat: AI chat now more accurate, has access to data from user, and can generate graphs, charts etc

This commit is contained in:
Jakob Wennberg
2026-02-27 08:48:28 +01:00
parent 0c422fcd25
commit 547fd053ec
24 changed files with 1954 additions and 51 deletions
+43 -2
View File
@@ -3,12 +3,14 @@
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { cn } from '@/lib/utils'
import { ChevronDown, ChevronUp, FileText, User, Bot } from 'lucide-react'
import { ChevronDown, ChevronUp, FileText, User, Bot, Database, Loader2 } from 'lucide-react'
import type { ChatMessage as ChatMessageType, SourceReference } from '@/types/chat'
import { ArtifactRenderer } from './artifacts/ArtifactRenderer'
interface ChatMessageProps {
message: ChatMessageType
isStreaming?: boolean
toolsExecuting?: string[]
}
function SourcesList({ sources }: { sources: SourceReference[] }) {
@@ -58,7 +60,36 @@ function SourcesList({ sources }: { sources: SourceReference[] }) {
)
}
export function ChatMessage({ message, isStreaming }: ChatMessageProps) {
const TOOL_LABELS: Record<string, string> = {
get_invoices: 'Hämtar fakturor',
get_supplier_invoices: 'Hämtar leverantörsfakturor',
get_account_balances: 'Hämtar kontosaldon',
get_transactions: 'Hämtar transaktioner',
get_journal_entries: 'Hämtar verifikationer',
get_income_statement: 'Genererar resultaträkning',
get_balance_sheet: 'Genererar balansräkning',
get_vat_summary: 'Beräknar momssammanställning',
get_company_overview: 'Hämtar företagsöversikt',
get_aging_report: 'Genererar åldersanalys',
}
function ToolExecutingIndicator({ tools }: { tools: string[] }) {
if (tools.length === 0) return null
return (
<div className="flex flex-col gap-1 mb-2">
{tools.map((toolName, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<Database className="h-3 w-3" />
<span>{TOOL_LABELS[toolName] || toolName}...</span>
</div>
))}
</div>
)
}
export function ChatMessage({ message, isStreaming, toolsExecuting }: ChatMessageProps) {
const isUser = message.role === 'user'
return (
@@ -93,6 +124,11 @@ export function ChatMessage({ message, isStreaming }: ChatMessageProps) {
: 'bg-muted/60 text-foreground'
)}
>
{/* Tool execution indicator */}
{!isUser && isStreaming && toolsExecuting && toolsExecuting.length > 0 && !message.content && (
<ToolExecutingIndicator tools={toolsExecuting} />
)}
<div className={cn(
"text-sm break-words",
!isUser && "prose prose-sm max-w-none prose-headings:text-foreground prose-headings:font-semibold prose-h1:text-base prose-h2:text-sm prose-h3:text-sm prose-p:text-foreground prose-p:my-1.5 prose-headings:my-2 prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:text-foreground prose-strong:text-foreground prose-table:my-2 prose-table:text-xs prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1 prose-th:bg-muted/50 prose-th:border prose-td:border prose-th:border-border/50 prose-td:border-border/50 prose-th:text-foreground prose-td:text-foreground"
@@ -107,6 +143,11 @@ export function ChatMessage({ message, isStreaming }: ChatMessageProps) {
)}
</div>
{/* Artifact visualization */}
{!isUser && message.artifact && (
<ArtifactRenderer artifact={message.artifact} />
)}
{!isUser && message.sources && message.sources.length > 0 && (
<SourcesList sources={message.sources} />
)}
+22 -8
View File
@@ -21,6 +21,7 @@ export function ChatPanel({ className }: ChatPanelProps) {
isLoading,
isStreaming,
error,
toolsExecuting,
sendMessage,
clearChat,
} = useChatStream({
@@ -90,10 +91,22 @@ export function ChatPanel({ className }: ChatPanelProps) {
</p>
<div className="mt-6 space-y-2 w-full max-w-xs">
<SuggestionButton
onClick={() => sendMessage('Hur fungerar momsen på mina fakturor?')}
onClick={() => sendMessage('Hur går det för mitt företag?')}
disabled={isLoading}
>
Hur fungerar momsen på mina fakturor?
Hur går det för mitt företag?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('Visa mina senaste fakturor')}
disabled={isLoading}
>
Visa mina senaste fakturor
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('Hur ser min resultaträkning ut?')}
disabled={isLoading}
>
Hur ser min resultaträkning ut?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('Vad kan jag dra av som företagare?')}
@@ -101,12 +114,6 @@ export function ChatPanel({ className }: ChatPanelProps) {
>
Vad kan jag dra av som företagare?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('När måste jag momsregistrera mig?')}
disabled={isLoading}
>
När måste jag momsregistrera mig?
</SuggestionButton>
</div>
</div>
) : (
@@ -120,6 +127,13 @@ export function ChatPanel({ className }: ChatPanelProps) {
index === messages.length - 1 &&
message.role === 'assistant'
}
toolsExecuting={
isStreaming &&
index === messages.length - 1 &&
message.role === 'assistant'
? toolsExecuting
: undefined
}
/>
))}
<div ref={messagesEndRef} />
@@ -0,0 +1,45 @@
'use client'
import type { ArtifactSpec } from '@/types/chat'
import { ChatChart } from './ChatChart'
import { ChatDataTable } from './ChatDataTable'
import { ChatKpiCards } from './ChatKpiCards'
import { ChatAgingBuckets } from './ChatAgingBuckets'
interface ArtifactRendererProps {
artifact: ArtifactSpec
}
export function ArtifactRenderer({ artifact }: ArtifactRendererProps) {
switch (artifact.type) {
case 'bar_chart':
case 'line_chart':
case 'pie_chart':
case 'stacked_bar':
return (
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
<ChatChart artifact={artifact} />
</div>
)
case 'table':
return (
<div className="mt-3">
<ChatDataTable artifact={artifact} />
</div>
)
case 'kpi_cards':
return (
<div className="mt-3">
<ChatKpiCards artifact={artifact} />
</div>
)
case 'aging_buckets':
return (
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
<ChatAgingBuckets artifact={artifact} />
</div>
)
default:
return null
}
}
@@ -0,0 +1,76 @@
'use client'
import type { AgingBucketsArtifact } from '@/types/chat'
const BUCKET_COLORS = [
'bg-green-500',
'bg-yellow-400',
'bg-orange-400',
'bg-red-400',
'bg-red-600',
]
interface ChatAgingBucketsProps {
artifact: AgingBucketsArtifact
}
function formatAmount(amount: number): string {
return new Intl.NumberFormat('sv-SE').format(Math.round(amount))
}
export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
const { title, buckets, total } = artifact
const maxAmount = Math.max(...buckets.map((b) => b.amount), 1)
return (
<div className="w-full">
<div className="flex items-baseline justify-between mb-3">
<h4 className="text-sm font-semibold">{title}</h4>
<span className="text-sm font-bold tabular-nums">
{formatAmount(total)} kr
</span>
</div>
{/* Stacked bar */}
{total > 0 && (
<div className="flex h-6 rounded-md overflow-hidden mb-3">
{buckets.map((bucket, i) => {
const widthPercent = (bucket.amount / total) * 100
if (widthPercent < 0.5) return null
return (
<div
key={i}
className={`${BUCKET_COLORS[i % BUCKET_COLORS.length]} transition-all`}
style={{ width: `${widthPercent}%` }}
title={`${bucket.label}: ${formatAmount(bucket.amount)} kr`}
/>
)
})}
</div>
)}
{/* Legend */}
<div className="space-y-1.5">
{buckets.map((bucket, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-sm ${BUCKET_COLORS[i % BUCKET_COLORS.length]}`}
/>
<span className="text-muted-foreground">{bucket.label}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-muted-foreground">
{bucket.count} st
</span>
<span className="font-medium tabular-nums">
{formatAmount(bucket.amount)} kr
</span>
</div>
</div>
))}
</div>
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
'use client'
import {
BarChart,
Bar,
LineChart,
Line,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
import type { ChartArtifact } from '@/types/chat'
const DEFAULT_COLORS = [
'var(--color-chart-1, #3b82f6)',
'var(--color-chart-2, #10b981)',
'var(--color-chart-3, #f59e0b)',
'var(--color-chart-4, #ef4444)',
'var(--color-chart-5, #8b5cf6)',
'var(--color-chart-6, #ec4899)',
'var(--color-chart-7, #06b6d4)',
'var(--color-chart-8, #84cc16)',
]
function formatValue(value: number, unit?: string): string {
const formatted = new Intl.NumberFormat('sv-SE').format(Math.round(value))
return unit ? `${formatted} ${unit}` : formatted
}
interface ChatChartProps {
artifact: ChartArtifact
}
export function ChatChart({ artifact }: ChatChartProps) {
const { type, title, data, unit, subtitle } = artifact
const chartData = data.map((d, i) => ({
...d,
fill: d.color || DEFAULT_COLORS[i % DEFAULT_COLORS.length],
}))
return (
<div className="w-full">
<div className="mb-3">
<h4 className="text-sm font-semibold">{title}</h4>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
{type === 'pie_chart' ? (
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
outerRadius="80%"
label={(props) => {
const name = props.name ?? ''
const percent = typeof props.percent === 'number' ? props.percent : 0
return `${name} (${(percent * 100).toFixed(0)}%)`
}}
labelLine={false}
>
{chartData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Pie>
<Tooltip
formatter={(value) => formatValue(Number(value ?? 0), unit)}
/>
</PieChart>
) : type === 'line_chart' ? (
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11 }}
className="text-muted-foreground"
/>
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => formatValue(v, unit)}
className="text-muted-foreground"
/>
<Tooltip
formatter={(value) => formatValue(Number(value ?? 0), unit)}
/>
<Line
type="monotone"
dataKey="value"
stroke={DEFAULT_COLORS[0]}
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
) : (
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11 }}
className="text-muted-foreground"
/>
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => formatValue(v, unit)}
className="text-muted-foreground"
/>
<Tooltip
formatter={(value) => formatValue(Number(value ?? 0), unit)}
/>
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{chartData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Bar>
</BarChart>
)}
</ResponsiveContainer>
</div>
</div>
)
}
@@ -0,0 +1,80 @@
'use client'
import type { TableArtifact } from '@/types/chat'
interface ChatDataTableProps {
artifact: TableArtifact
}
function formatCell(value: string | number, align?: 'left' | 'right'): string {
if (typeof value === 'number') {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: value % 1 !== 0 ? 2 : 0,
maximumFractionDigits: 2,
}).format(value)
}
return String(value)
}
export function ChatDataTable({ artifact }: ChatDataTableProps) {
const { title, columns, rows, summary_row } = artifact
return (
<div className="w-full">
<h4 className="text-sm font-semibold mb-2">{title}</h4>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-xs">
<thead>
<tr className="bg-muted/50">
{columns.map((col) => (
<th
key={col.key}
className={`px-3 py-2 font-medium text-muted-foreground border-b border-border ${
col.align === 'right' ? 'text-right' : 'text-left'
}`}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={i}
className="border-b border-border/50 hover:bg-muted/20 transition-colors"
>
{columns.map((col) => (
<td
key={col.key}
className={`px-3 py-1.5 ${
col.align === 'right' ? 'text-right tabular-nums' : 'text-left'
}`}
>
{formatCell(row[col.key], col.align)}
</td>
))}
</tr>
))}
{summary_row && (
<tr className="bg-muted/30 font-semibold">
{columns.map((col) => (
<td
key={col.key}
className={`px-3 py-2 border-t border-border ${
col.align === 'right' ? 'text-right tabular-nums' : 'text-left'
}`}
>
{summary_row[col.key] !== undefined
? formatCell(summary_row[col.key], col.align)
: ''}
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,47 @@
'use client'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import type { KpiCardsArtifact } from '@/types/chat'
interface ChatKpiCardsProps {
artifact: KpiCardsArtifact
}
export function ChatKpiCards({ artifact }: ChatKpiCardsProps) {
const { title, cards } = artifact
return (
<div className="w-full">
{title && <h4 className="text-sm font-semibold mb-2">{title}</h4>}
<div className="grid grid-cols-2 gap-2">
{cards.map((card, i) => (
<div
key={i}
className="rounded-lg border border-border bg-card p-3"
>
<p className="text-xs text-muted-foreground mb-1">{card.label}</p>
<div className="flex items-baseline gap-2">
<span className="text-lg font-bold tabular-nums">{card.value}</span>
{card.trend && (
<span
className={`flex items-center gap-0.5 text-xs ${
card.trend === 'up'
? 'text-green-600 dark:text-green-400'
: card.trend === 'down'
? 'text-red-600 dark:text-red-400'
: 'text-muted-foreground'
}`}
>
{card.trend === 'up' && <TrendingUp className="h-3 w-3" />}
{card.trend === 'down' && <TrendingDown className="h-3 w-3" />}
{card.trend === 'flat' && <Minus className="h-3 w-3" />}
{card.change}
</span>
)}
</div>
</div>
))}
</div>
</div>
)
}
+19 -1
View File
@@ -1,7 +1,7 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import type { ChatMessage, SourceReference, ChatSession } from '@/types/chat'
import type { ChatMessage, SourceReference, ArtifactSpec } from '@/types/chat'
interface UseChatStreamOptions {
onError?: (error: string) => void
@@ -13,6 +13,7 @@ interface UseChatStreamReturn {
isStreaming: boolean
sessionId: string | null
error: string | null
toolsExecuting: string[]
sendMessage: (message: string) => Promise<void>
loadSession: (sessionId: string) => Promise<void>
clearChat: () => void
@@ -25,6 +26,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
const [isStreaming, setIsStreaming] = useState(false)
const [sessionId, setSessionId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [toolsExecuting, setToolsExecuting] = useState<string[]>([])
const abortControllerRef = useRef<AbortController | null>(null)
const sendMessage = useCallback(async (message: string) => {
@@ -86,6 +88,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
const decoder = new TextDecoder()
let accumulatedContent = ''
let sources: SourceReference[] = []
let artifact: ArtifactSpec | null = null
let newSessionId = sessionId
let messageId: string | null = null
@@ -106,6 +109,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
setSessionId(data.session_id)
} else if (data.type === 'content') {
accumulatedContent += data.content
setToolsExecuting([]) // Clear tool indicators when content starts
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
@@ -124,8 +128,21 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
}
return newMessages
})
} else if (data.type === 'tool_start') {
setToolsExecuting((prev) => [...prev, data.toolName])
} else if (data.type === 'artifact') {
artifact = data.artifact
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.artifact = artifact
}
return newMessages
})
} else if (data.type === 'done') {
messageId = data.message_id
setToolsExecuting([])
// Update the message IDs with real values
setMessages((prev) => {
const newMessages = [...prev]
@@ -207,6 +224,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
isStreaming,
sessionId,
error,
toolsExecuting,
sendMessage,
loadSession,
clearChat,
@@ -1,15 +1,12 @@
'use client'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { MessageSquare } from 'lucide-react'
import { ChatPanel } from '@/components/chat/ChatPanel'
export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
return (
<EmptyExtensionState
title="AI-assistent"
description="Använd chattwidgeten i nedre högra hörnet för att ställa frågor om bokföring och skatt."
icon={<MessageSquare className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<div className="h-[calc(100vh-10rem)] max-w-4xl mx-auto">
<ChatPanel className="h-full rounded-lg border border-border bg-background shadow-sm" />
</div>
)
}