feat: AI chat now more accurate, has access to data from user, and can generate graphs, charts etc
This commit is contained in:
@@ -3,12 +3,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import { cn } from '@/lib/utils'
|
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 type { ChatMessage as ChatMessageType, SourceReference } from '@/types/chat'
|
||||||
|
import { ArtifactRenderer } from './artifacts/ArtifactRenderer'
|
||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
message: ChatMessageType
|
message: ChatMessageType
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
|
toolsExecuting?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourcesList({ sources }: { sources: SourceReference[] }) {
|
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'
|
const isUser = message.role === 'user'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,6 +124,11 @@ export function ChatMessage({ message, isStreaming }: ChatMessageProps) {
|
|||||||
: 'bg-muted/60 text-foreground'
|
: 'bg-muted/60 text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Tool execution indicator */}
|
||||||
|
{!isUser && isStreaming && toolsExecuting && toolsExecuting.length > 0 && !message.content && (
|
||||||
|
<ToolExecutingIndicator tools={toolsExecuting} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"text-sm break-words",
|
"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"
|
!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>
|
</div>
|
||||||
|
|
||||||
|
{/* Artifact visualization */}
|
||||||
|
{!isUser && message.artifact && (
|
||||||
|
<ArtifactRenderer artifact={message.artifact} />
|
||||||
|
)}
|
||||||
|
|
||||||
{!isUser && message.sources && message.sources.length > 0 && (
|
{!isUser && message.sources && message.sources.length > 0 && (
|
||||||
<SourcesList sources={message.sources} />
|
<SourcesList sources={message.sources} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function ChatPanel({ className }: ChatPanelProps) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
error,
|
error,
|
||||||
|
toolsExecuting,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
clearChat,
|
clearChat,
|
||||||
} = useChatStream({
|
} = useChatStream({
|
||||||
@@ -90,10 +91,22 @@ export function ChatPanel({ className }: ChatPanelProps) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-6 space-y-2 w-full max-w-xs">
|
<div className="mt-6 space-y-2 w-full max-w-xs">
|
||||||
<SuggestionButton
|
<SuggestionButton
|
||||||
onClick={() => sendMessage('Hur fungerar momsen på mina fakturor?')}
|
onClick={() => sendMessage('Hur går det för mitt företag?')}
|
||||||
disabled={isLoading}
|
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>
|
||||||
<SuggestionButton
|
<SuggestionButton
|
||||||
onClick={() => sendMessage('Vad kan jag dra av som företagare?')}
|
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?
|
Vad kan jag dra av som företagare?
|
||||||
</SuggestionButton>
|
</SuggestionButton>
|
||||||
<SuggestionButton
|
|
||||||
onClick={() => sendMessage('När måste jag momsregistrera mig?')}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
När måste jag momsregistrera mig?
|
|
||||||
</SuggestionButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -120,6 +127,13 @@ export function ChatPanel({ className }: ChatPanelProps) {
|
|||||||
index === messages.length - 1 &&
|
index === messages.length - 1 &&
|
||||||
message.role === 'assistant'
|
message.role === 'assistant'
|
||||||
}
|
}
|
||||||
|
toolsExecuting={
|
||||||
|
isStreaming &&
|
||||||
|
index === messages.length - 1 &&
|
||||||
|
message.role === 'assistant'
|
||||||
|
? toolsExecuting
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div ref={messagesEndRef} />
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
import type { ChatMessage, SourceReference, ChatSession } from '@/types/chat'
|
import type { ChatMessage, SourceReference, ArtifactSpec } from '@/types/chat'
|
||||||
|
|
||||||
interface UseChatStreamOptions {
|
interface UseChatStreamOptions {
|
||||||
onError?: (error: string) => void
|
onError?: (error: string) => void
|
||||||
@@ -13,6 +13,7 @@ interface UseChatStreamReturn {
|
|||||||
isStreaming: boolean
|
isStreaming: boolean
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
|
toolsExecuting: string[]
|
||||||
sendMessage: (message: string) => Promise<void>
|
sendMessage: (message: string) => Promise<void>
|
||||||
loadSession: (sessionId: string) => Promise<void>
|
loadSession: (sessionId: string) => Promise<void>
|
||||||
clearChat: () => void
|
clearChat: () => void
|
||||||
@@ -25,6 +26,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
|||||||
const [isStreaming, setIsStreaming] = useState(false)
|
const [isStreaming, setIsStreaming] = useState(false)
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [toolsExecuting, setToolsExecuting] = useState<string[]>([])
|
||||||
const abortControllerRef = useRef<AbortController | null>(null)
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
const sendMessage = useCallback(async (message: string) => {
|
const sendMessage = useCallback(async (message: string) => {
|
||||||
@@ -86,6 +88,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
|||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
let accumulatedContent = ''
|
let accumulatedContent = ''
|
||||||
let sources: SourceReference[] = []
|
let sources: SourceReference[] = []
|
||||||
|
let artifact: ArtifactSpec | null = null
|
||||||
let newSessionId = sessionId
|
let newSessionId = sessionId
|
||||||
let messageId: string | null = null
|
let messageId: string | null = null
|
||||||
|
|
||||||
@@ -106,6 +109,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
|||||||
setSessionId(data.session_id)
|
setSessionId(data.session_id)
|
||||||
} else if (data.type === 'content') {
|
} else if (data.type === 'content') {
|
||||||
accumulatedContent += data.content
|
accumulatedContent += data.content
|
||||||
|
setToolsExecuting([]) // Clear tool indicators when content starts
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const newMessages = [...prev]
|
const newMessages = [...prev]
|
||||||
const lastMsg = newMessages[newMessages.length - 1]
|
const lastMsg = newMessages[newMessages.length - 1]
|
||||||
@@ -124,8 +128,21 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
|||||||
}
|
}
|
||||||
return newMessages
|
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') {
|
} else if (data.type === 'done') {
|
||||||
messageId = data.message_id
|
messageId = data.message_id
|
||||||
|
setToolsExecuting([])
|
||||||
// Update the message IDs with real values
|
// Update the message IDs with real values
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const newMessages = [...prev]
|
const newMessages = [...prev]
|
||||||
@@ -207,6 +224,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
sessionId,
|
sessionId,
|
||||||
error,
|
error,
|
||||||
|
toolsExecuting,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
loadSession,
|
loadSession,
|
||||||
clearChat,
|
clearChat,
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
import { ChatPanel } from '@/components/chat/ChatPanel'
|
||||||
import { MessageSquare } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
|
export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
|
||||||
return (
|
return (
|
||||||
<EmptyExtensionState
|
<div className="h-[calc(100vh-10rem)] max-w-4xl mx-auto">
|
||||||
title="AI-assistent"
|
<ChatPanel className="h-full rounded-lg border border-border bg-background shadow-sm" />
|
||||||
description="Använd chattwidgeten i nedre högra hörnet för att ställa frågor om bokföring och skatt."
|
</div>
|
||||||
icon={<MessageSquare className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
||||||
import { generateChatResponse, streamChatResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
import { generateChatResponse, streamChatResponse, streamRoutedResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
||||||
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
||||||
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
|
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
|
||||||
|
|
||||||
// Simple in-memory rate limiting (per user)
|
// Simple in-memory rate limiting (per user)
|
||||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||||
@@ -258,6 +258,7 @@ async function handlePostStream(
|
|||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
let fullContent = ''
|
let fullContent = ''
|
||||||
let sources: SourceReference[] = []
|
let sources: SourceReference[] = []
|
||||||
|
let artifact: ArtifactSpec | null = null
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
@@ -267,22 +268,37 @@ async function handlePostStream(
|
|||||||
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
|
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stream the response
|
// Stream the routed response (handles knowledge, data, and hybrid)
|
||||||
for await (const chunk of streamChatResponse(message.trim(), conversationHistory)) {
|
for await (const event of streamRoutedResponse(
|
||||||
if (chunk.type === 'content') {
|
message.trim(),
|
||||||
fullContent += chunk.data as string
|
conversationHistory,
|
||||||
|
supabase,
|
||||||
|
userId,
|
||||||
|
sessionId
|
||||||
|
)) {
|
||||||
|
if (event.type === 'content') {
|
||||||
|
fullContent += event.content
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: chunk.data })}\n\n`)
|
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: event.content })}\n\n`)
|
||||||
)
|
)
|
||||||
} else if (chunk.type === 'sources') {
|
} else if (event.type === 'sources') {
|
||||||
sources = chunk.data as SourceReference[]
|
sources = event.sources
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: chunk.data })}\n\n`)
|
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: event.sources })}\n\n`)
|
||||||
|
)
|
||||||
|
} else if (event.type === 'tool_start') {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`data: ${JSON.stringify({ type: 'tool_start', toolName: event.toolName })}\n\n`)
|
||||||
|
)
|
||||||
|
} else if (event.type === 'artifact') {
|
||||||
|
artifact = event.artifact
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`data: ${JSON.stringify({ type: 'artifact', artifact: event.artifact })}\n\n`)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the complete assistant message
|
// Save the complete assistant message (including artifact)
|
||||||
const { data: savedMessage } = await supabase
|
const { data: savedMessage } = await supabase
|
||||||
.from('chat_messages')
|
.from('chat_messages')
|
||||||
.insert({
|
.insert({
|
||||||
@@ -291,6 +307,7 @@ async function handlePostStream(
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: fullContent,
|
content: fullContent,
|
||||||
sources,
|
sources,
|
||||||
|
...(artifact ? { artifact } : {}),
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { ChatAnthropic } from '@langchain/anthropic'
|
||||||
|
import { createReactAgent } from '@langchain/langgraph/prebuilt'
|
||||||
|
import { HumanMessage, AIMessage } from '@langchain/core/messages'
|
||||||
|
import type { StructuredToolInterface } from '@langchain/core/tools'
|
||||||
|
import { CHATBOT_CONFIG } from './config'
|
||||||
|
import {
|
||||||
|
SYSTEM_PROMPT_DATA,
|
||||||
|
SYSTEM_PROMPT_HYBRID,
|
||||||
|
formatConversationHistory,
|
||||||
|
} from './prompts'
|
||||||
|
import type { ChatMessage } from '@/types/chat'
|
||||||
|
import type { RouteType } from './router'
|
||||||
|
|
||||||
|
export interface AgentStreamEvent {
|
||||||
|
type: 'tool_start' | 'content' | 'done'
|
||||||
|
toolName?: string
|
||||||
|
content?: string
|
||||||
|
toolResults?: ToolResultEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResultEntry {
|
||||||
|
toolName: string
|
||||||
|
result: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the LangGraph agent with tool calling and stream events.
|
||||||
|
*/
|
||||||
|
export async function* streamAgentResponse(options: {
|
||||||
|
query: string
|
||||||
|
route: RouteType
|
||||||
|
tools: StructuredToolInterface[]
|
||||||
|
conversationHistory: ChatMessage[]
|
||||||
|
ragContext?: string
|
||||||
|
}): AsyncGenerator<AgentStreamEvent> {
|
||||||
|
const { query, route, tools, conversationHistory, ragContext } = options
|
||||||
|
|
||||||
|
// Build system prompt based on route
|
||||||
|
const historyText = formatConversationHistory(
|
||||||
|
conversationHistory.slice(-CHATBOT_CONFIG.maxHistoryMessages).map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
let systemPrompt: string
|
||||||
|
if (route === 'data') {
|
||||||
|
systemPrompt = SYSTEM_PROMPT_DATA.replace('{history}', historyText)
|
||||||
|
} else {
|
||||||
|
const context = ragContext || 'Ingen specifik kontext hittades i kunskapsbasen.'
|
||||||
|
systemPrompt = SYSTEM_PROMPT_HYBRID
|
||||||
|
.replace('{context}', context)
|
||||||
|
.replace('{history}', historyText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the model
|
||||||
|
const model = new ChatAnthropic({
|
||||||
|
modelName: CHATBOT_CONFIG.agentModel,
|
||||||
|
maxTokens: CHATBOT_CONFIG.agentMaxTokens,
|
||||||
|
temperature: CHATBOT_CONFIG.temperature,
|
||||||
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the agent
|
||||||
|
const agent = createReactAgent({
|
||||||
|
llm: model,
|
||||||
|
tools,
|
||||||
|
prompt: systemPrompt,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build input messages
|
||||||
|
const messages: (HumanMessage | AIMessage)[] = []
|
||||||
|
|
||||||
|
// Add recent history as messages for the agent
|
||||||
|
const recent = conversationHistory.slice(-CHATBOT_CONFIG.maxHistoryMessages)
|
||||||
|
for (const msg of recent) {
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
messages.push(new HumanMessage(msg.content))
|
||||||
|
} else {
|
||||||
|
messages.push(new AIMessage(msg.content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.push(new HumanMessage(query))
|
||||||
|
|
||||||
|
// Track tool results for artifact generation
|
||||||
|
const toolResults: ToolResultEntry[] = []
|
||||||
|
|
||||||
|
// Stream the agent execution using streamEvents for fine-grained control
|
||||||
|
const eventStream = agent.streamEvents(
|
||||||
|
{ messages },
|
||||||
|
{
|
||||||
|
version: 'v2',
|
||||||
|
recursionLimit: CHATBOT_CONFIG.maxAgentIterations * 2 + 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for await (const event of eventStream) {
|
||||||
|
// Tool start events
|
||||||
|
if (event.event === 'on_tool_start') {
|
||||||
|
yield { type: 'tool_start', toolName: event.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool end events — capture results
|
||||||
|
if (event.event === 'on_tool_end') {
|
||||||
|
const output = event.data?.output
|
||||||
|
const result = typeof output === 'string' ? output : JSON.stringify(output ?? '')
|
||||||
|
toolResults.push({
|
||||||
|
toolName: event.name,
|
||||||
|
result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLM streaming tokens (final response text)
|
||||||
|
if (event.event === 'on_chat_model_stream') {
|
||||||
|
const chunk = event.data?.chunk
|
||||||
|
if (chunk) {
|
||||||
|
const content = typeof chunk.content === 'string'
|
||||||
|
? chunk.content
|
||||||
|
: Array.isArray(chunk.content)
|
||||||
|
? chunk.content
|
||||||
|
.filter((c: { type: string }) => c.type === 'text')
|
||||||
|
.map((c: { text: string }) => c.text)
|
||||||
|
.join('')
|
||||||
|
: ''
|
||||||
|
if (content) {
|
||||||
|
yield { type: 'content', content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield { type: 'done', toolResults }
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { ChatAnthropic } from '@langchain/anthropic'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { CHATBOT_CONFIG } from './config'
|
||||||
|
import type { ToolResultEntry } from './agent'
|
||||||
|
import type { ArtifactSpec } from '@/types/chat'
|
||||||
|
|
||||||
|
// ── Artifact Zod Schemas ────────────────────────────────────────
|
||||||
|
|
||||||
|
const ChartDataPoint = z.object({
|
||||||
|
label: z.string(),
|
||||||
|
value: z.number(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ChartArtifact = z.object({
|
||||||
|
type: z.enum(['bar_chart', 'line_chart', 'pie_chart', 'stacked_bar']),
|
||||||
|
title: z.string(),
|
||||||
|
data: z.array(ChartDataPoint),
|
||||||
|
unit: z.string().optional(),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const TableColumn = z.object({
|
||||||
|
key: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
align: z.enum(['left', 'right']).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const TableArtifact = z.object({
|
||||||
|
type: z.literal('table'),
|
||||||
|
title: z.string(),
|
||||||
|
columns: z.array(TableColumn),
|
||||||
|
rows: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
|
||||||
|
summary_row: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const KpiCard = z.object({
|
||||||
|
label: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
trend: z.enum(['up', 'down', 'flat']).optional(),
|
||||||
|
change: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const KpiCardsArtifact = z.object({
|
||||||
|
type: z.literal('kpi_cards'),
|
||||||
|
title: z.string().optional(),
|
||||||
|
cards: z.array(KpiCard),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AgingBucket = z.object({
|
||||||
|
label: z.string(),
|
||||||
|
amount: z.number(),
|
||||||
|
count: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AgingBucketsArtifact = z.object({
|
||||||
|
type: z.literal('aging_buckets'),
|
||||||
|
title: z.string(),
|
||||||
|
buckets: z.array(AgingBucket),
|
||||||
|
total: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ArtifactSpecSchema = z.discriminatedUnion('type', [
|
||||||
|
ChartArtifact,
|
||||||
|
TableArtifact,
|
||||||
|
KpiCardsArtifact,
|
||||||
|
AgingBucketsArtifact,
|
||||||
|
])
|
||||||
|
|
||||||
|
export type { ArtifactSpec } from '@/types/chat'
|
||||||
|
|
||||||
|
// ── Artifact System Prompt ──────────────────────────────────────
|
||||||
|
|
||||||
|
const ARTIFACT_SYSTEM_PROMPT = `You are a data visualization expert. Given tool results and an AI response about accounting data, generate a structured artifact spec for visual display.
|
||||||
|
|
||||||
|
## EXACT schemas (follow field names precisely):
|
||||||
|
|
||||||
|
### Chart (bar_chart, line_chart, pie_chart, stacked_bar):
|
||||||
|
{"type":"bar_chart","title":"...","data":[{"label":"Category name","value":1234}],"unit":"kr"}
|
||||||
|
IMPORTANT: Each item in "data" MUST have "label" (string) and "value" (number). NOT "name", NOT "amount" — use exactly "label" and "value".
|
||||||
|
|
||||||
|
### Table:
|
||||||
|
{"type":"table","title":"...","columns":[{"key":"col1","label":"Header","align":"right"}],"rows":[{"col1":"value"}],"summary_row":{"col1":"Total"}}
|
||||||
|
|
||||||
|
### KPI cards:
|
||||||
|
{"type":"kpi_cards","title":"...","cards":[{"label":"Metric","value":"1 234 kr","trend":"up","change":"+12%"}]}
|
||||||
|
IMPORTANT: "trend" MUST be exactly "up", "down", or "flat". No other values allowed.
|
||||||
|
|
||||||
|
### Aging buckets:
|
||||||
|
{"type":"aging_buckets","title":"...","buckets":[{"label":"0 dagar","amount":1000,"count":2}],"total":5000}
|
||||||
|
|
||||||
|
## Rules:
|
||||||
|
1. Return ONLY a single JSON object (not an array!) or the word "null". The top-level must be an object with a "type" field.
|
||||||
|
2. Choose chart type based on data:
|
||||||
|
- Income/balance sheet sections → "bar_chart"
|
||||||
|
- Distribution (VAT, account classes) → "pie_chart"
|
||||||
|
- Company overview → "kpi_cards"
|
||||||
|
- AR/AP aging → "aging_buckets"
|
||||||
|
- Lists with >3 items + amounts → "table"
|
||||||
|
- Simple answers, few items, yes/no → null
|
||||||
|
3. Use Swedish labels. Use "kr" as unit for monetary charts.
|
||||||
|
4. Max 12 chart data points. Aggregate small items as "Övrigt".
|
||||||
|
5. For tables, include summary_row with totals where appropriate.`
|
||||||
|
|
||||||
|
// ── Normalizer ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix common LLM field name mistakes before Zod validation.
|
||||||
|
* Mutates the object in place.
|
||||||
|
*/
|
||||||
|
function normalizeArtifact(obj: Record<string, unknown>): void {
|
||||||
|
if (!obj || typeof obj !== 'object') return
|
||||||
|
|
||||||
|
// Chart types: normalize data[].name→label, data[].amount→value
|
||||||
|
const chartTypes = ['bar_chart', 'line_chart', 'pie_chart', 'stacked_bar']
|
||||||
|
if (chartTypes.includes(obj.type as string) && Array.isArray(obj.data)) {
|
||||||
|
for (const item of obj.data) {
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
if ('name' in item && !('label' in item)) {
|
||||||
|
item.label = item.name
|
||||||
|
delete item.name
|
||||||
|
}
|
||||||
|
if ('amount' in item && !('value' in item)) {
|
||||||
|
item.value = item.amount
|
||||||
|
delete item.amount
|
||||||
|
}
|
||||||
|
if ('total' in item && !('value' in item)) {
|
||||||
|
item.value = item.total
|
||||||
|
delete item.total
|
||||||
|
}
|
||||||
|
if ('value' in item && typeof item.value === 'string') {
|
||||||
|
const num = parseFloat(String(item.value).replace(/\s/g, '').replace(',', '.'))
|
||||||
|
if (!isNaN(num)) item.value = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KPI cards: normalize trend values
|
||||||
|
if (obj.type === 'kpi_cards' && Array.isArray(obj.cards)) {
|
||||||
|
const trendMap: Record<string, string> = {
|
||||||
|
neutral: 'flat', stable: 'flat', none: 'flat', '-': 'flat',
|
||||||
|
negative: 'down', decrease: 'down', declining: 'down',
|
||||||
|
positive: 'up', increase: 'up', increasing: 'up', growing: 'up',
|
||||||
|
}
|
||||||
|
for (const card of obj.cards) {
|
||||||
|
if (card && typeof card === 'object' && 'trend' in card) {
|
||||||
|
const t = String(card.trend).toLowerCase()
|
||||||
|
if (trendMap[t]) {
|
||||||
|
card.trend = trendMap[t]
|
||||||
|
} else if (t !== 'up' && t !== 'down' && t !== 'flat') {
|
||||||
|
// Unknown trend value — remove it so optional field passes
|
||||||
|
delete card.trend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generator ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an artifact spec from tool results using a post-processing LLM call.
|
||||||
|
* Returns null if no visualization is appropriate.
|
||||||
|
*/
|
||||||
|
export async function generateArtifact(
|
||||||
|
toolResults: ToolResultEntry[],
|
||||||
|
assistantResponse: string
|
||||||
|
): Promise<ArtifactSpec | null> {
|
||||||
|
if (toolResults.length === 0) return null
|
||||||
|
|
||||||
|
const model = new ChatAnthropic({
|
||||||
|
modelName: CHATBOT_CONFIG.artifactModel,
|
||||||
|
maxTokens: 1024,
|
||||||
|
temperature: 0,
|
||||||
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toolSummary = toolResults
|
||||||
|
.map((r) => `Tool: ${r.toolName}\nResult: ${r.result.slice(0, 2000)}`)
|
||||||
|
.join('\n\n---\n\n')
|
||||||
|
|
||||||
|
const prompt = `${ARTIFACT_SYSTEM_PROMPT}
|
||||||
|
|
||||||
|
## Tool results:
|
||||||
|
${toolSummary}
|
||||||
|
|
||||||
|
## AI response:
|
||||||
|
${assistantResponse.slice(0, 1000)}
|
||||||
|
|
||||||
|
Generate the artifact JSON or "null":`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await model.invoke(prompt)
|
||||||
|
const text = typeof response.content === 'string'
|
||||||
|
? response.content
|
||||||
|
: JSON.stringify(response.content)
|
||||||
|
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (trimmed === 'null' || trimmed === '"null"') return null
|
||||||
|
|
||||||
|
// Extract JSON from response (handle markdown code blocks)
|
||||||
|
let jsonStr = trimmed
|
||||||
|
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/)
|
||||||
|
if (codeBlockMatch) {
|
||||||
|
jsonStr = codeBlockMatch[1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = JSON.parse(jsonStr)
|
||||||
|
|
||||||
|
// If LLM returned an array, try to wrap it as kpi_cards
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
// Array of cards → wrap as kpi_cards
|
||||||
|
if (parsed.length > 0 && parsed[0] && typeof parsed[0] === 'object' && 'label' in parsed[0]) {
|
||||||
|
parsed = { type: 'kpi_cards', title: 'Översikt', cards: parsed }
|
||||||
|
} else {
|
||||||
|
console.warn('Artifact returned unexpected array')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize common LLM field name mistakes before validation
|
||||||
|
normalizeArtifact(parsed)
|
||||||
|
|
||||||
|
const validated = ArtifactSpecSchema.safeParse(parsed)
|
||||||
|
|
||||||
|
if (validated.success) {
|
||||||
|
return validated.data as ArtifactSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('Artifact validation failed:', validated.error.issues)
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Artifact generation failed:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
documentsToSources,
|
documentsToSources,
|
||||||
type RetrievedDocument,
|
type RetrievedDocument,
|
||||||
} from './retriever'
|
} from './retriever'
|
||||||
|
import { routeMessage, type RouteType } from './router'
|
||||||
|
import { createAccountingTools } from './tools'
|
||||||
|
import { streamAgentResponse, type ToolResultEntry } from './agent'
|
||||||
|
import { generateArtifact, type ArtifactSpec } from './artifacts'
|
||||||
import type { ChatMessage, SourceReference } from '@/types/chat'
|
import type { ChatMessage, SourceReference } from '@/types/chat'
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
// Initialize the LLM
|
// Initialize the LLM
|
||||||
function getChatModel() {
|
function getChatModel() {
|
||||||
@@ -135,3 +140,101 @@ export async function* streamChatResponse(
|
|||||||
// 6. Yield sources at the end
|
// 6. Yield sources at the end
|
||||||
yield { type: 'sources', data: documentsToSources(relevantDocs) }
|
yield { type: 'sources', data: documentsToSources(relevantDocs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Routed response (data / hybrid / knowledge) ────────────────
|
||||||
|
|
||||||
|
export type RoutedStreamEvent =
|
||||||
|
| { type: 'content'; content: string }
|
||||||
|
| { type: 'sources'; sources: SourceReference[] }
|
||||||
|
| { type: 'tool_start'; toolName: string }
|
||||||
|
| { type: 'artifact'; artifact: ArtifactSpec }
|
||||||
|
| { type: 'route'; route: RouteType }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level streaming function: routes the message, then either uses
|
||||||
|
* the existing RAG chain (knowledge) or the LangGraph agent (data/hybrid).
|
||||||
|
* Generates artifact post-hoc on data/hybrid routes.
|
||||||
|
*/
|
||||||
|
export async function* streamRoutedResponse(
|
||||||
|
userMessage: string,
|
||||||
|
conversationHistory: ChatMessage[],
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
sessionId?: string
|
||||||
|
): AsyncGenerator<RoutedStreamEvent> {
|
||||||
|
// 1. Route the message
|
||||||
|
const { route, rewrittenQuery } = await routeMessage(userMessage, conversationHistory)
|
||||||
|
yield { type: 'route', route }
|
||||||
|
|
||||||
|
// 2. Knowledge-only: use existing RAG chain
|
||||||
|
if (route === 'knowledge') {
|
||||||
|
for await (const chunk of streamChatResponse(rewrittenQuery, conversationHistory)) {
|
||||||
|
if (chunk.type === 'content') {
|
||||||
|
yield { type: 'content', content: chunk.data as string }
|
||||||
|
} else if (chunk.type === 'sources') {
|
||||||
|
yield { type: 'sources', sources: chunk.data as SourceReference[] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Data or hybrid: use LangGraph agent with tools
|
||||||
|
const tools = createAccountingTools(supabase, userId)
|
||||||
|
|
||||||
|
// For hybrid, get RAG context
|
||||||
|
let ragContext: string | undefined
|
||||||
|
let sources: SourceReference[] = []
|
||||||
|
if (route === 'hybrid') {
|
||||||
|
try {
|
||||||
|
const relevantDocs = await retrieveRelevantDocuments(rewrittenQuery)
|
||||||
|
ragContext = formatContextFromSources(
|
||||||
|
relevantDocs.map((doc) => ({
|
||||||
|
content: doc.content,
|
||||||
|
title: doc.title,
|
||||||
|
section_title: doc.section_title,
|
||||||
|
source_file: doc.source_file,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
sources = documentsToSources(relevantDocs)
|
||||||
|
} catch {
|
||||||
|
// RAG failure is non-critical for hybrid route
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fullContent = ''
|
||||||
|
let toolResults: ToolResultEntry[] = []
|
||||||
|
|
||||||
|
for await (const event of streamAgentResponse({
|
||||||
|
query: rewrittenQuery,
|
||||||
|
route,
|
||||||
|
tools,
|
||||||
|
conversationHistory,
|
||||||
|
ragContext,
|
||||||
|
})) {
|
||||||
|
if (event.type === 'tool_start') {
|
||||||
|
yield { type: 'tool_start', toolName: event.toolName! }
|
||||||
|
} else if (event.type === 'content') {
|
||||||
|
fullContent += event.content!
|
||||||
|
yield { type: 'content', content: event.content! }
|
||||||
|
} else if (event.type === 'done') {
|
||||||
|
toolResults = event.toolResults || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Yield sources if hybrid
|
||||||
|
if (sources.length > 0) {
|
||||||
|
yield { type: 'sources', sources }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Generate artifact (post-processing)
|
||||||
|
if (toolResults.length > 0 && fullContent.length > 0) {
|
||||||
|
try {
|
||||||
|
const artifact = await generateArtifact(toolResults, fullContent)
|
||||||
|
if (artifact) {
|
||||||
|
yield { type: 'artifact', artifact }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Artifact generation failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ export const CHATBOT_CONFIG = {
|
|||||||
maxTokens: 2048,
|
maxTokens: 2048,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
|
|
||||||
|
// Agent settings
|
||||||
|
agentModel: 'claude-sonnet-4-6',
|
||||||
|
agentMaxTokens: 4096,
|
||||||
|
maxAgentIterations: 5,
|
||||||
|
|
||||||
|
// Router settings
|
||||||
|
routerModel: 'claude-haiku-4-5-20251001',
|
||||||
|
|
||||||
|
// Artifact generation
|
||||||
|
artifactModel: 'claude-haiku-4-5-20251001',
|
||||||
|
|
||||||
// Retrieval settings
|
// Retrieval settings
|
||||||
retrievalK: 5,
|
retrievalK: 5,
|
||||||
similarityThreshold: 0.7,
|
similarityThreshold: 0.7,
|
||||||
|
|||||||
@@ -42,6 +42,60 @@ Fråga: {question}
|
|||||||
|
|
||||||
Sök efter information som hjälper att besvara frågan korrekt och fullständigt.`
|
Sök efter information som hjälper att besvara frågan korrekt och fullständigt.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System prompt for tool-calling agent (data route).
|
||||||
|
* No RAG context — relies entirely on tools.
|
||||||
|
*/
|
||||||
|
export const SYSTEM_PROMPT_DATA = `Du är en AI-assistent i en svensk ekonomiplattform. Du har tillgång till verktyg som hämtar användarens bokföringsdata i realtid.
|
||||||
|
|
||||||
|
## Instruktioner:
|
||||||
|
1. Svara alltid på svenska
|
||||||
|
2. Använd verktygen för att hämta data innan du svarar — gissa aldrig siffror
|
||||||
|
3. Presentera data tydligt med belopp i SEK om inget annat anges
|
||||||
|
4. Om ett verktyg returnerar tom data, berätta det vänligt (t.ex. "Du har inga obetalda fakturor just nu")
|
||||||
|
5. Avrunda belopp till hela kronor i text, men behåll decimaler i tabeller
|
||||||
|
6. Använd svenska bokföringstermer (verifikation, kontering, resultaträkning, etc.)
|
||||||
|
7. Förklara kort vad siffrorna betyder i kontext — var pedagogisk
|
||||||
|
|
||||||
|
## Formatering:
|
||||||
|
- Använd markdown: **fetstil** för belopp, punktlistor för detaljer
|
||||||
|
- ABSOLUT FÖRBJUDET att använda markdown-tabeller (|---|). Använd ALDRIG pipe-tecken för tabeller. Data visas automatiskt i en visuell komponent nedanför ditt svar
|
||||||
|
- Använd punktlistor eller fetstil istället för tabeller
|
||||||
|
- Sammanfatta huvudinsikten först, detaljer sedan
|
||||||
|
- Max 3-4 meningar för enkla frågor, mer för rapporter
|
||||||
|
- Använd inte emojis
|
||||||
|
|
||||||
|
## Tidigare konversation:
|
||||||
|
{history}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System prompt for hybrid route: RAG context + tools.
|
||||||
|
*/
|
||||||
|
export const SYSTEM_PROMPT_HYBRID = `Du är en expert AI-assistent i en svensk ekonomiplattform. Du har tillgång till verktyg som hämtar användarens bokföringsdata, samt kunskap om svenska skatteregler.
|
||||||
|
|
||||||
|
## Kunskapsområden:
|
||||||
|
- Svensk skattlagstiftning, moms, bokföring (BAS-kontoplanen)
|
||||||
|
- Avdrag, egenavgifter, socialförsäkring
|
||||||
|
- Fakturering, NE-bilaga, inkomstdeklaration
|
||||||
|
|
||||||
|
## Viktiga tröskelvärden:
|
||||||
|
- Momsregistrering: 120 000 kr/12 mån
|
||||||
|
- Direktavdrag: 26 250 kr
|
||||||
|
- Friskvårdsbidrag: 6 000 kr/år
|
||||||
|
|
||||||
|
## Instruktioner:
|
||||||
|
1. Svara alltid på svenska med korrekt terminologi
|
||||||
|
2. Använd verktygen för att hämta data — gissa aldrig siffror
|
||||||
|
3. Kombinera data med regelkunskap för att ge kontextuella råd
|
||||||
|
4. Om du är osäker, rekommendera att konsultera en revisor
|
||||||
|
5. Formatera tydligt med markdown, men ABSOLUT FÖRBJUDET att använda markdown-tabeller (|---|). Använd ALDRIG pipe-tecken för tabeller — data visas automatiskt i en visuell komponent. Använd punktlistor istället. Använd inte emojis
|
||||||
|
|
||||||
|
## Kontext från kunskapsbasen:
|
||||||
|
{context}
|
||||||
|
|
||||||
|
## Tidigare konversation:
|
||||||
|
{history}`
|
||||||
|
|
||||||
export function formatContextFromSources(
|
export function formatContextFromSources(
|
||||||
sources: Array<{
|
sources: Array<{
|
||||||
content: string
|
content: string
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { ChatAnthropic } from '@langchain/anthropic'
|
||||||
|
import { CHATBOT_CONFIG } from './config'
|
||||||
|
import type { ChatMessage } from '@/types/chat'
|
||||||
|
|
||||||
|
export type RouteType = 'knowledge' | 'data' | 'hybrid'
|
||||||
|
|
||||||
|
export interface RouterResult {
|
||||||
|
route: RouteType
|
||||||
|
rewrittenQuery: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swedish data-related keywords for fast-path heuristic
|
||||||
|
const DATA_NOUNS = [
|
||||||
|
'faktura', 'fakturor', 'fakturorna',
|
||||||
|
'leverantörsfaktura', 'leverantörsfakturor',
|
||||||
|
'transaktion', 'transaktioner', 'transaktionerna',
|
||||||
|
'verifikation', 'verifikationer', 'verifikationerna',
|
||||||
|
'resultaträkning', 'balansräkning',
|
||||||
|
'moms', 'momsdeklaration', 'momssammanställning',
|
||||||
|
'saldo', 'saldon', 'kontosaldo',
|
||||||
|
'konto', 'konton', 'kontona',
|
||||||
|
'kunder', 'kundfordringar',
|
||||||
|
'leverantörsskulder',
|
||||||
|
'intäkter', 'kostnader', 'utgifter',
|
||||||
|
'resultat', 'årsresultat',
|
||||||
|
'bokföring', 'bokförda', 'obokförda',
|
||||||
|
'obetalda', 'förfallna',
|
||||||
|
'nyckeltal', 'företaget', 'företagsinfo',
|
||||||
|
]
|
||||||
|
|
||||||
|
const POSSESSIVE_PRONOUNS = ['mina', 'min', 'mitt', 'mig', 'våra', 'vår', 'vårt']
|
||||||
|
|
||||||
|
const KNOWLEDGE_TERMS = [
|
||||||
|
'momsgransen', 'momsgränsen', 'avdrag', 'skatteregler',
|
||||||
|
'bokföringslag', 'bokföringslagen', 'regler', 'lag',
|
||||||
|
'hur fungerar', 'vad innebär', 'vad betyder', 'vad är',
|
||||||
|
'när måste', 'hur räknar', 'hur beräknar',
|
||||||
|
'enskild firma', 'aktiebolag', 'egenavgifter',
|
||||||
|
'prisbasbelopp', 'schablonavdrag', 'representation',
|
||||||
|
'friskvårdsbidrag', 'traktamente',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast-path keyword heuristic. Returns a route if confident, null otherwise.
|
||||||
|
*/
|
||||||
|
function heuristicClassify(query: string): RouteType | null {
|
||||||
|
const lower = query.toLowerCase()
|
||||||
|
const words = lower.split(/\s+/)
|
||||||
|
|
||||||
|
const hasPossessive = POSSESSIVE_PRONOUNS.some((p) => words.includes(p))
|
||||||
|
const hasDataNoun = DATA_NOUNS.some((n) => lower.includes(n))
|
||||||
|
const hasKnowledgeTerm = KNOWLEDGE_TERMS.some((t) => lower.includes(t))
|
||||||
|
|
||||||
|
// "Visa mina fakturor" — clearly data
|
||||||
|
if (hasPossessive && hasDataNoun && !hasKnowledgeTerm) return 'data'
|
||||||
|
|
||||||
|
// Action verbs with data nouns
|
||||||
|
const actionVerbs = ['visa', 'hämta', 'lista', 'sök', 'hitta', 'hur går', 'hur ser', 'hur mycket', 'hur många', 'vilka']
|
||||||
|
const hasAction = actionVerbs.some((v) => lower.includes(v))
|
||||||
|
if (hasAction && hasDataNoun && !hasKnowledgeTerm) return 'data'
|
||||||
|
|
||||||
|
// Pure knowledge question with no data references
|
||||||
|
if (hasKnowledgeTerm && !hasPossessive && !hasDataNoun) return 'knowledge'
|
||||||
|
|
||||||
|
// "Hur ser min resultaträkning ut?" — data (has possessive + data noun)
|
||||||
|
if (hasPossessive && hasDataNoun && hasKnowledgeTerm) return 'hybrid'
|
||||||
|
|
||||||
|
return null // ambiguous → fall through to LLM
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM-based classification + query rewrite for multi-turn context.
|
||||||
|
*/
|
||||||
|
async function llmClassify(
|
||||||
|
query: string,
|
||||||
|
conversationHistory: ChatMessage[]
|
||||||
|
): Promise<RouterResult> {
|
||||||
|
const model = new ChatAnthropic({
|
||||||
|
modelName: CHATBOT_CONFIG.routerModel,
|
||||||
|
maxTokens: 256,
|
||||||
|
temperature: 0,
|
||||||
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||||
|
})
|
||||||
|
|
||||||
|
const historyContext = conversationHistory
|
||||||
|
.slice(-4)
|
||||||
|
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 200)}`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const prompt = `Classify the user's question and rewrite it for a data query system.
|
||||||
|
|
||||||
|
Conversation history:
|
||||||
|
${historyContext || '(none)'}
|
||||||
|
|
||||||
|
User question: "${query}"
|
||||||
|
|
||||||
|
Classification rules:
|
||||||
|
- "knowledge": General questions about Swedish tax law, accounting rules, regulations (no user-specific data needed)
|
||||||
|
- "data": Questions about the user's own accounting data (invoices, transactions, balances, reports)
|
||||||
|
- "hybrid": Questions that need both user data AND knowledge context
|
||||||
|
|
||||||
|
Rewriting rules:
|
||||||
|
- Resolve pronouns ("dem", "de", "den") using conversation history
|
||||||
|
- Make the query self-contained (no context needed to understand it)
|
||||||
|
- If it's a knowledge question, keep the original query
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON:
|
||||||
|
{"route": "knowledge"|"data"|"hybrid", "rewrittenQuery": "..."}
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await model.invoke(prompt)
|
||||||
|
const text = typeof response.content === 'string'
|
||||||
|
? response.content
|
||||||
|
: JSON.stringify(response.content)
|
||||||
|
|
||||||
|
// Extract JSON from response
|
||||||
|
const jsonMatch = text.match(/\{[^}]+\}/)
|
||||||
|
if (jsonMatch) {
|
||||||
|
const parsed = JSON.parse(jsonMatch[0])
|
||||||
|
const route = ['knowledge', 'data', 'hybrid'].includes(parsed.route)
|
||||||
|
? (parsed.route as RouteType)
|
||||||
|
: 'hybrid'
|
||||||
|
return {
|
||||||
|
route,
|
||||||
|
rewrittenQuery: parsed.rewrittenQuery || query,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Router LLM classification failed, defaulting to hybrid:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { route: 'hybrid', rewrittenQuery: query }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route a user message: fast-path heuristic first, LLM fallback for ambiguous cases.
|
||||||
|
*/
|
||||||
|
export async function routeMessage(
|
||||||
|
query: string,
|
||||||
|
conversationHistory: ChatMessage[]
|
||||||
|
): Promise<RouterResult> {
|
||||||
|
const heuristicResult = heuristicClassify(query)
|
||||||
|
|
||||||
|
if (heuristicResult) {
|
||||||
|
// For data/hybrid with conversation history, still rewrite the query for context
|
||||||
|
if (heuristicResult !== 'knowledge' && conversationHistory.length > 0) {
|
||||||
|
const { rewrittenQuery } = await llmClassify(query, conversationHistory)
|
||||||
|
return { route: heuristicResult, rewrittenQuery }
|
||||||
|
}
|
||||||
|
return { route: heuristicResult, rewrittenQuery: query }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ambiguous — use LLM
|
||||||
|
return llmClassify(query, conversationHistory)
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
import { tool } from '@langchain/core/tools'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract name from a Supabase join result (could be object or array).
|
||||||
|
*/
|
||||||
|
function extractName(joined: unknown): string | null {
|
||||||
|
if (!joined) return null
|
||||||
|
if (Array.isArray(joined)) {
|
||||||
|
return joined[0]?.name ?? null
|
||||||
|
}
|
||||||
|
if (typeof joined === 'object' && 'name' in joined) {
|
||||||
|
return (joined as { name: string }).name
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current fiscal period for a user. Falls back to latest period.
|
||||||
|
*/
|
||||||
|
async function resolveCurrentPeriod(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
fiscalPeriodId?: string
|
||||||
|
): Promise<{ id: string; start: string; end: string } | null> {
|
||||||
|
if (fiscalPeriodId) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('fiscal_periods')
|
||||||
|
.select('id, period_start, period_end')
|
||||||
|
.eq('id', fiscalPeriodId)
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.single()
|
||||||
|
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: latest open period, or just the latest period
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('fiscal_periods')
|
||||||
|
.select('id, period_start, period_end, is_closed')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('period_start', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create all 10 accounting tools bound to a specific Supabase client and user.
|
||||||
|
*/
|
||||||
|
export function createAccountingTools(supabase: SupabaseClient, userId: string) {
|
||||||
|
const getInvoices = tool(
|
||||||
|
async ({ status, customer_name, date_from, date_to, limit }) => {
|
||||||
|
let query = supabase
|
||||||
|
.from('invoices')
|
||||||
|
.select('id, invoice_number, invoice_date, due_date, status, total, paid_amount, currency, vat_amount, customer:customers(name)')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('invoice_date', { ascending: false })
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
if (status) query = query.eq('status', status)
|
||||||
|
if (customer_name) query = query.ilike('customers.name', `%${customer_name}%`)
|
||||||
|
if (date_from) query = query.gte('invoice_date', date_from)
|
||||||
|
if (date_to) query = query.lte('invoice_date', date_to)
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
|
.from('invoices')
|
||||||
|
.select('id', { count: 'exact', head: true })
|
||||||
|
.eq('user_id', userId)
|
||||||
|
|
||||||
|
const { data: invoices, error: fetchError } = await query
|
||||||
|
|
||||||
|
if (fetchError) return `Fel vid hämtning av fakturor: ${fetchError.message}`
|
||||||
|
if (!invoices || invoices.length === 0) return 'Inga fakturor hittades.'
|
||||||
|
|
||||||
|
const result = invoices.map((inv) => ({
|
||||||
|
invoice_number: inv.invoice_number,
|
||||||
|
date: inv.invoice_date,
|
||||||
|
due_date: inv.due_date,
|
||||||
|
status: inv.status,
|
||||||
|
total: inv.total,
|
||||||
|
paid: inv.paid_amount || 0,
|
||||||
|
currency: inv.currency || 'SEK',
|
||||||
|
vat: inv.vat_amount || 0,
|
||||||
|
customer: extractName(inv.customer) || 'Okänd',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const summary: Record<string, unknown> = { invoices: result }
|
||||||
|
if (count && count > limit) {
|
||||||
|
summary.note = `Visar ${result.length} av totalt ${count} fakturor.`
|
||||||
|
}
|
||||||
|
return JSON.stringify(summary)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_invoices',
|
||||||
|
description: 'Hämtar användarens försäljningsfakturor (kundfakturor). Kan filtrera på status, kundnamn och datumintervall.',
|
||||||
|
schema: z.object({
|
||||||
|
status: z.enum(['draft', 'sent', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på fakturastatus'),
|
||||||
|
customer_name: z.string().optional().describe('Sök på kundnamn (delmatchning)'),
|
||||||
|
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
|
||||||
|
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
|
||||||
|
limit: z.number().max(20).default(10).describe('Max antal fakturor att returnera'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getSupplierInvoices = tool(
|
||||||
|
async ({ status, supplier_name, overdue_only, limit }) => {
|
||||||
|
let query = supabase
|
||||||
|
.from('supplier_invoices')
|
||||||
|
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, remaining_amount, currency, vat_amount, supplier:suppliers(name)')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('invoice_date', { ascending: false })
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
if (status) query = query.eq('status', status)
|
||||||
|
if (overdue_only) query = query.eq('status', 'overdue')
|
||||||
|
if (supplier_name) query = query.ilike('suppliers.name', `%${supplier_name}%`)
|
||||||
|
|
||||||
|
const { data: invoices, error } = await query
|
||||||
|
|
||||||
|
if (error) return `Fel vid hämtning av leverantörsfakturor: ${error.message}`
|
||||||
|
if (!invoices || invoices.length === 0) return 'Inga leverantörsfakturor hittades.'
|
||||||
|
|
||||||
|
const result = invoices.map((inv) => ({
|
||||||
|
number: inv.supplier_invoice_number,
|
||||||
|
date: inv.invoice_date,
|
||||||
|
due_date: inv.due_date,
|
||||||
|
status: inv.status,
|
||||||
|
total: inv.total,
|
||||||
|
remaining: inv.remaining_amount || 0,
|
||||||
|
currency: inv.currency || 'SEK',
|
||||||
|
vat: inv.vat_amount || 0,
|
||||||
|
supplier: extractName(inv.supplier) || 'Okänd',
|
||||||
|
}))
|
||||||
|
|
||||||
|
return JSON.stringify({ supplier_invoices: result })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_supplier_invoices',
|
||||||
|
description: 'Hämtar användarens leverantörsfakturor (inköpsfakturor). Kan filtrera på status, leverantörsnamn och förfallodag.',
|
||||||
|
schema: z.object({
|
||||||
|
status: z.enum(['registered', 'approved', 'partially_paid', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på status'),
|
||||||
|
supplier_name: z.string().optional().describe('Sök på leverantörsnamn (delmatchning)'),
|
||||||
|
overdue_only: z.boolean().optional().describe('Visa bara förfallna fakturor'),
|
||||||
|
limit: z.number().max(20).default(10).describe('Max antal fakturor'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getAccountBalances = tool(
|
||||||
|
async ({ account_numbers, account_class, fiscal_period_id }) => {
|
||||||
|
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||||
|
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||||
|
|
||||||
|
const { generateTrialBalance } = await import('@/lib/reports/trial-balance')
|
||||||
|
const { rows } = await generateTrialBalance(supabase, userId, period.id)
|
||||||
|
|
||||||
|
let filtered = rows
|
||||||
|
if (account_numbers && account_numbers.length > 0) {
|
||||||
|
filtered = rows.filter((r) => account_numbers.includes(r.account_number))
|
||||||
|
} else if (account_class) {
|
||||||
|
filtered = rows.filter((r) => r.account_class === account_class)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0) return 'Inga konton med saldo hittades.'
|
||||||
|
|
||||||
|
const result = filtered.map((r) => ({
|
||||||
|
account: r.account_number,
|
||||||
|
name: r.account_name,
|
||||||
|
debit: r.closing_debit,
|
||||||
|
credit: r.closing_credit,
|
||||||
|
balance: r.closing_debit - r.closing_credit,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
period: `${period.start} – ${period.end}`,
|
||||||
|
accounts: result,
|
||||||
|
total_debit: Math.round(result.reduce((s, r) => s + r.debit, 0) * 100) / 100,
|
||||||
|
total_credit: Math.round(result.reduce((s, r) => s + r.credit, 0) * 100) / 100,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_account_balances',
|
||||||
|
description: 'Hämtar saldon för BAS-konton. Kan filtrera på kontonummer eller kontoklass (1=tillgångar, 2=skulder, 3=intäkter, 4-7=kostnader, 8=finansiella).',
|
||||||
|
schema: z.object({
|
||||||
|
account_numbers: z.array(z.string()).optional().describe('Specifika kontonummer att hämta'),
|
||||||
|
account_class: z.number().min(1).max(8).optional().describe('Kontoklass 1-8'),
|
||||||
|
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getTransactions = tool(
|
||||||
|
async ({ uncategorized_only, description, date_from, date_to, limit }) => {
|
||||||
|
let query = supabase
|
||||||
|
.from('transactions')
|
||||||
|
.select('id, date, description, amount, currency, category, is_business, merchant_name, journal_entry_id')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('date', { ascending: false })
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
if (uncategorized_only) query = query.is('journal_entry_id', null)
|
||||||
|
if (description) query = query.ilike('description', `%${description}%`)
|
||||||
|
if (date_from) query = query.gte('date', date_from)
|
||||||
|
if (date_to) query = query.lte('date', date_to)
|
||||||
|
|
||||||
|
const { data: transactions, error } = await query
|
||||||
|
|
||||||
|
if (error) return `Fel vid hämtning av transaktioner: ${error.message}`
|
||||||
|
if (!transactions || transactions.length === 0) return 'Inga transaktioner hittades.'
|
||||||
|
|
||||||
|
const result = transactions.map((tx) => ({
|
||||||
|
date: tx.date,
|
||||||
|
description: tx.description,
|
||||||
|
amount: tx.amount,
|
||||||
|
currency: tx.currency || 'SEK',
|
||||||
|
category: tx.category,
|
||||||
|
is_business: tx.is_business,
|
||||||
|
merchant: tx.merchant_name,
|
||||||
|
booked: !!tx.journal_entry_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return JSON.stringify({ transactions: result })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_transactions',
|
||||||
|
description: 'Hämtar användarens banktransaktioner. Kan filtrera på obokförda, beskrivning (textsökning) och datumintervall.',
|
||||||
|
schema: z.object({
|
||||||
|
uncategorized_only: z.boolean().optional().describe('Visa bara obokförda transaktioner'),
|
||||||
|
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
|
||||||
|
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
|
||||||
|
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
|
||||||
|
limit: z.number().max(20).default(10).describe('Max antal transaktioner'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getJournalEntries = tool(
|
||||||
|
async ({ limit, fiscal_period_id, account_number, description }) => {
|
||||||
|
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from('journal_entries')
|
||||||
|
.select('id, voucher_number, entry_date, description, status, source_type')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('status', 'posted')
|
||||||
|
.order('voucher_number', { ascending: false })
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
if (period) query = query.eq('fiscal_period_id', period.id)
|
||||||
|
if (description) query = query.ilike('description', `%${description}%`)
|
||||||
|
|
||||||
|
const { data: entries, error } = await query
|
||||||
|
|
||||||
|
if (error) return `Fel vid hämtning av verifikationer: ${error.message}`
|
||||||
|
if (!entries || entries.length === 0) return 'Inga verifikationer hittades.'
|
||||||
|
|
||||||
|
// Fetch lines for these entries
|
||||||
|
const entryIds = entries.map((e) => e.id)
|
||||||
|
const { data: lines } = await supabase
|
||||||
|
.from('journal_entry_lines')
|
||||||
|
.select('journal_entry_id, account_number, debit_amount, credit_amount, line_description')
|
||||||
|
.in('journal_entry_id', entryIds)
|
||||||
|
|
||||||
|
// If filtering by account, only include entries with matching lines
|
||||||
|
let filteredEntries = entries
|
||||||
|
if (account_number && lines) {
|
||||||
|
const matchingEntryIds = new Set(
|
||||||
|
lines.filter((l) => l.account_number === account_number).map((l) => l.journal_entry_id)
|
||||||
|
)
|
||||||
|
filteredEntries = entries.filter((e) => matchingEntryIds.has(e.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const linesByEntry = new Map<string, typeof lines>()
|
||||||
|
for (const line of lines || []) {
|
||||||
|
const group = linesByEntry.get(line.journal_entry_id) || []
|
||||||
|
group.push(line)
|
||||||
|
linesByEntry.set(line.journal_entry_id, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = filteredEntries.map((e) => ({
|
||||||
|
voucher: e.voucher_number,
|
||||||
|
date: e.entry_date,
|
||||||
|
description: e.description,
|
||||||
|
source: e.source_type,
|
||||||
|
lines: (linesByEntry.get(e.id) || []).map((l) => ({
|
||||||
|
account: l.account_number,
|
||||||
|
debit: l.debit_amount,
|
||||||
|
credit: l.credit_amount,
|
||||||
|
text: l.line_description,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return JSON.stringify({ journal_entries: result })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_journal_entries',
|
||||||
|
description: 'Hämtar bokförda verifikationer med konteringsrader. Kan filtrera på kontonummer, beskrivning och räkenskapsperiod.',
|
||||||
|
schema: z.object({
|
||||||
|
limit: z.number().max(20).default(10).describe('Max antal verifikationer'),
|
||||||
|
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID'),
|
||||||
|
account_number: z.string().optional().describe('Filtrera på kontonummer i rader'),
|
||||||
|
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getIncomeStatement = tool(
|
||||||
|
async ({ fiscal_period_id }) => {
|
||||||
|
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||||
|
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||||
|
|
||||||
|
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
|
||||||
|
const report = await generateIncomeStatement(supabase, userId, period.id)
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
...report.revenue_sections.map((s) => ({
|
||||||
|
category: 'Intäkter',
|
||||||
|
title: s.title,
|
||||||
|
amount: s.subtotal,
|
||||||
|
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||||
|
})),
|
||||||
|
...report.expense_sections.map((s) => ({
|
||||||
|
category: 'Kostnader',
|
||||||
|
title: s.title,
|
||||||
|
amount: s.subtotal,
|
||||||
|
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||||
|
})),
|
||||||
|
...report.financial_sections.map((s) => ({
|
||||||
|
category: 'Finansiella poster',
|
||||||
|
title: s.title,
|
||||||
|
amount: s.subtotal,
|
||||||
|
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
period: `${period.start} – ${period.end}`,
|
||||||
|
total_revenue: report.total_revenue,
|
||||||
|
total_expenses: report.total_expenses,
|
||||||
|
total_financial: report.total_financial,
|
||||||
|
net_result: report.net_result,
|
||||||
|
sections,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_income_statement',
|
||||||
|
description: 'Hämtar resultaträkning med intäkter, kostnader och årets resultat. Visar alla kontona grupperade i sektioner.',
|
||||||
|
schema: z.object({
|
||||||
|
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getBalanceSheet = tool(
|
||||||
|
async ({ fiscal_period_id }) => {
|
||||||
|
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||||
|
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||||
|
|
||||||
|
const { generateBalanceSheet } = await import('@/lib/reports/balance-sheet')
|
||||||
|
const report = await generateBalanceSheet(supabase, userId, period.id)
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
...report.asset_sections.map((s) => ({
|
||||||
|
category: 'Tillgångar',
|
||||||
|
title: s.title,
|
||||||
|
amount: s.subtotal,
|
||||||
|
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||||
|
})),
|
||||||
|
...report.equity_liability_sections.map((s) => ({
|
||||||
|
category: 'Eget kapital & skulder',
|
||||||
|
title: s.title,
|
||||||
|
amount: s.subtotal,
|
||||||
|
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
period: `${period.start} – ${period.end}`,
|
||||||
|
total_assets: report.total_assets,
|
||||||
|
total_equity_liabilities: report.total_equity_liabilities,
|
||||||
|
balanced: Math.abs(report.total_assets - report.total_equity_liabilities) < 0.01,
|
||||||
|
sections,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_balance_sheet',
|
||||||
|
description: 'Hämtar balansräkning med tillgångar, eget kapital och skulder.',
|
||||||
|
schema: z.object({
|
||||||
|
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getVatSummary = tool(
|
||||||
|
async ({ fiscal_period_id }) => {
|
||||||
|
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||||
|
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||||
|
|
||||||
|
// Get company settings for moms period type
|
||||||
|
const { data: settings } = await supabase
|
||||||
|
.from('company_settings')
|
||||||
|
.select('moms_period')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const periodType = settings?.moms_period || 'quarterly'
|
||||||
|
const startDate = new Date(period.start)
|
||||||
|
const year = startDate.getFullYear()
|
||||||
|
let periodNum = 1
|
||||||
|
if (periodType === 'monthly') {
|
||||||
|
periodNum = startDate.getMonth() + 1
|
||||||
|
} else if (periodType === 'quarterly') {
|
||||||
|
periodNum = Math.ceil((startDate.getMonth() + 1) / 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { calculateVatDeclaration, getVatDeclarationSummary } = await import('@/lib/reports/vat-declaration')
|
||||||
|
const declaration = await calculateVatDeclaration(supabase, userId, periodType, year, periodNum)
|
||||||
|
const summary = getVatDeclarationSummary(declaration)
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
period: `${period.start} – ${period.end}`,
|
||||||
|
output_vat_25: declaration.rutor.ruta05,
|
||||||
|
output_vat_12: declaration.rutor.ruta06,
|
||||||
|
output_vat_6: declaration.rutor.ruta07,
|
||||||
|
total_output_vat: summary.totalOutputVat,
|
||||||
|
input_vat: summary.totalInputVat,
|
||||||
|
vat_to_pay: summary.vatToPay,
|
||||||
|
is_refund: summary.isRefund,
|
||||||
|
revenue_basis_25: declaration.rutor.ruta10,
|
||||||
|
revenue_basis_12: declaration.rutor.ruta11,
|
||||||
|
revenue_basis_6: declaration.rutor.ruta12,
|
||||||
|
invoice_count: declaration.invoiceCount,
|
||||||
|
transaction_count: declaration.transactionCount,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_vat_summary',
|
||||||
|
description: 'Hämtar momssammanställning med utgående moms, ingående moms och moms att betala/återfå.',
|
||||||
|
schema: z.object({
|
||||||
|
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getCompanyOverview = tool(
|
||||||
|
async () => {
|
||||||
|
const { data: settings } = await supabase
|
||||||
|
.from('company_settings')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!settings) return 'Inga företagsinställningar hittades.'
|
||||||
|
|
||||||
|
// Get quick KPIs
|
||||||
|
const period = await resolveCurrentPeriod(supabase, userId)
|
||||||
|
|
||||||
|
const [
|
||||||
|
{ count: invoiceCount },
|
||||||
|
{ count: unpaidCount },
|
||||||
|
{ count: txCount },
|
||||||
|
{ count: unbookedCount },
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('user_id', userId),
|
||||||
|
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('user_id', userId).in('status', ['sent', 'overdue']),
|
||||||
|
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('user_id', userId),
|
||||||
|
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('user_id', userId).is('journal_entry_id', null),
|
||||||
|
])
|
||||||
|
|
||||||
|
let netResult: number | null = null
|
||||||
|
if (period) {
|
||||||
|
try {
|
||||||
|
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
|
||||||
|
const report = await generateIncomeStatement(supabase, userId, period.id)
|
||||||
|
netResult = report.net_result
|
||||||
|
} catch {
|
||||||
|
// Non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
company: {
|
||||||
|
name: settings.company_name,
|
||||||
|
entity_type: settings.entity_type,
|
||||||
|
org_number: settings.org_number,
|
||||||
|
vat_registered: settings.vat_registered,
|
||||||
|
accounting_method: settings.accounting_method,
|
||||||
|
moms_period: settings.moms_period,
|
||||||
|
},
|
||||||
|
kpis: {
|
||||||
|
total_invoices: invoiceCount || 0,
|
||||||
|
unpaid_invoices: unpaidCount || 0,
|
||||||
|
total_transactions: txCount || 0,
|
||||||
|
unbooked_transactions: unbookedCount || 0,
|
||||||
|
...(netResult !== null ? { net_result: netResult } : {}),
|
||||||
|
...(period ? { current_period: `${period.start} – ${period.end}` } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_company_overview',
|
||||||
|
description: 'Hämtar företagsinformation och nyckeltal (KPIs): antal fakturor, obetalda fakturor, transaktioner, obokförda transaktioner, årets resultat.',
|
||||||
|
schema: z.object({}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getAgingReport = tool(
|
||||||
|
async ({ type, limit }) => {
|
||||||
|
if (type === 'receivable') {
|
||||||
|
const { generateARLedger } = await import('@/lib/reports/ar-ledger')
|
||||||
|
const report = await generateARLedger(supabase, userId)
|
||||||
|
|
||||||
|
if (report.entries.length === 0) return 'Inga utestående kundfordringar.'
|
||||||
|
|
||||||
|
const entries = report.entries.slice(0, limit).map((e) => ({
|
||||||
|
name: e.customer_name,
|
||||||
|
current: e.current,
|
||||||
|
'1_30': e.days_1_30,
|
||||||
|
'31_60': e.days_31_60,
|
||||||
|
'61_90': e.days_61_90,
|
||||||
|
'90_plus': e.days_90_plus,
|
||||||
|
total: e.total_outstanding,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'receivable',
|
||||||
|
total_outstanding: report.total_outstanding,
|
||||||
|
total_current: report.total_current,
|
||||||
|
total_overdue: report.total_overdue,
|
||||||
|
unpaid_count: report.unpaid_count,
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const { generateSupplierLedger } = await import('@/lib/reports/supplier-ledger')
|
||||||
|
const report = await generateSupplierLedger(supabase, userId)
|
||||||
|
|
||||||
|
if (report.entries.length === 0) return 'Inga utestående leverantörsskulder.'
|
||||||
|
|
||||||
|
const entries = report.entries.slice(0, limit).map((e) => ({
|
||||||
|
name: e.supplier_name,
|
||||||
|
current: e.current,
|
||||||
|
'1_30': e.days_1_30,
|
||||||
|
'31_60': e.days_31_60,
|
||||||
|
'61_90': e.days_61_90,
|
||||||
|
'90_plus': e.days_90_plus,
|
||||||
|
total: e.total_outstanding,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'payable',
|
||||||
|
total_outstanding: report.total_outstanding,
|
||||||
|
total_current: report.total_current,
|
||||||
|
total_overdue: report.total_overdue,
|
||||||
|
unpaid_count: report.unpaid_count,
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_aging_report',
|
||||||
|
description: 'Hämtar åldersanalys för kundfordringar (receivable) eller leverantörsskulder (payable). Visar utestående belopp uppdelat i ålderskategorier.',
|
||||||
|
schema: z.object({
|
||||||
|
type: z.enum(['receivable', 'payable']).describe("'receivable' för kundfordringar, 'payable' för leverantörsskulder"),
|
||||||
|
limit: z.number().max(20).default(10).describe('Max antal poster'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
getInvoices,
|
||||||
|
getSupplierInvoices,
|
||||||
|
getAccountBalances,
|
||||||
|
getTransactions,
|
||||||
|
getJournalEntries,
|
||||||
|
getIncomeStatement,
|
||||||
|
getBalanceSheet,
|
||||||
|
getVatSummary,
|
||||||
|
getCompanyOverview,
|
||||||
|
getAgingReport,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { CallbackHandler } from '@langfuse/langchain'
|
||||||
|
|
||||||
|
let langfuseConfigured: boolean | null = null
|
||||||
|
|
||||||
|
function isLangfuseConfigured(): boolean {
|
||||||
|
if (langfuseConfigured !== null) return langfuseConfigured
|
||||||
|
langfuseConfigured = !!(
|
||||||
|
process.env.LANGFUSE_SECRET_KEY &&
|
||||||
|
process.env.LANGFUSE_PUBLIC_KEY
|
||||||
|
)
|
||||||
|
return langfuseConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Langfuse callback handler for LangChain tracing.
|
||||||
|
* Returns null if Langfuse is not configured (graceful degradation).
|
||||||
|
*/
|
||||||
|
export function createTraceHandler(options: {
|
||||||
|
sessionId?: string
|
||||||
|
userId?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}): CallbackHandler | null {
|
||||||
|
if (!isLangfuseConfigured()) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new CallbackHandler({
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
userId: options.userId,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.warn('Failed to create Langfuse handler, tracing disabled')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush Langfuse handler. Safe to call with null.
|
||||||
|
*/
|
||||||
|
export async function flushTrace(handler: CallbackHandler | null): Promise<void> {
|
||||||
|
if (!handler) return
|
||||||
|
try {
|
||||||
|
// Langfuse CallbackHandler may expose flush via different methods
|
||||||
|
if ('shutdownAsync' in handler && typeof handler.shutdownAsync === 'function') {
|
||||||
|
await handler.shutdownAsync()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — tracing failure should never block response
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,16 +5,17 @@
|
|||||||
"entryPoint": "@/extensions/general/ai-chat",
|
"entryPoint": "@/extensions/general/ai-chat",
|
||||||
"workspace": "@/components/extensions/general/AiChatWorkspace",
|
"workspace": "@/components/extensions/general/AiChatWorkspace",
|
||||||
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||||
"optionalEnvVars": [],
|
"optionalEnvVars": ["LANGFUSE_SECRET_KEY", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_BASE_URL"],
|
||||||
"npmDependencies": ["@langchain/anthropic", "@langchain/core", "langchain", "@langchain/openai"],
|
"npmDependencies": ["@langchain/anthropic", "@langchain/core", "langchain", "@langchain/openai", "@langchain/langgraph", "@langfuse/core", "@langfuse/langchain"],
|
||||||
"definition": {
|
"definition": {
|
||||||
"name": "AI-assistent",
|
"name": "AI-assistent",
|
||||||
"category": "operations",
|
"category": "operations",
|
||||||
"icon": "MessageSquare",
|
"icon": "MessageSquare",
|
||||||
"dataPattern": "manual",
|
"dataPattern": "both",
|
||||||
|
"readsCoreTables": ["invoices", "supplier_invoices", "transactions", "journal_entries", "journal_entry_lines", "fiscal_periods", "company_settings", "customers", "suppliers", "chart_of_accounts"],
|
||||||
"hasOwnData": true,
|
"hasOwnData": true,
|
||||||
"description": "AI-assistent för skatte- och bokföringsfrågor",
|
"description": "AI-assistent för skatte- och bokföringsfrågor med tillgång till din data",
|
||||||
"longDescription": "Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.",
|
"longDescription": "Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Kan hämta och visualisera din bokföringsdata — fakturor, transaktioner, resultaträkning, balansräkning och mer.",
|
||||||
"quickAction": {
|
"quickAction": {
|
||||||
"label": "AI-assistent",
|
"label": "AI-assistent",
|
||||||
"description": "Fråga om bokföring",
|
"description": "Fråga om bokföring",
|
||||||
|
|||||||
@@ -39,9 +39,21 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
|||||||
"sector": "general",
|
"sector": "general",
|
||||||
"category": "operations",
|
"category": "operations",
|
||||||
"icon": "MessageSquare",
|
"icon": "MessageSquare",
|
||||||
"dataPattern": "manual",
|
"dataPattern": "both",
|
||||||
"description": "AI-assistent för skatte- och bokföringsfrågor",
|
"description": "AI-assistent för skatte- och bokföringsfrågor med tillgång till din data",
|
||||||
"longDescription": "Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.",
|
"longDescription": "Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Kan hämta och visualisera din bokföringsdata — fakturor, transaktioner, resultaträkning, balansräkning och mer.",
|
||||||
|
"readsCoreTables": [
|
||||||
|
"invoices",
|
||||||
|
"supplier_invoices",
|
||||||
|
"transactions",
|
||||||
|
"journal_entries",
|
||||||
|
"journal_entry_lines",
|
||||||
|
"fiscal_periods",
|
||||||
|
"company_settings",
|
||||||
|
"customers",
|
||||||
|
"suppliers",
|
||||||
|
"chart_of_accounts"
|
||||||
|
],
|
||||||
"hasOwnData": true,
|
"hasOwnData": true,
|
||||||
"quickAction": {
|
"quickAction": {
|
||||||
"label": "AI-assistent",
|
"label": "AI-assistent",
|
||||||
|
|||||||
Generated
+64
-13
@@ -12,7 +12,10 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@langchain/anthropic": "^1.3.13",
|
"@langchain/anthropic": "^1.3.13",
|
||||||
"@langchain/core": "^1.1.18",
|
"@langchain/core": "^1.1.18",
|
||||||
|
"@langchain/langgraph": "^1.2.0",
|
||||||
"@langchain/openai": "^1.2.4",
|
"@langchain/openai": "^1.2.4",
|
||||||
|
"@langfuse/core": "^4.6.1",
|
||||||
|
"@langfuse/langchain": "^4.6.1",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -1662,13 +1665,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@langchain/langgraph": {
|
"node_modules/@langchain/langgraph": {
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.2.0.tgz",
|
||||||
"integrity": "sha512-kpZCttZ0N+jHSl5Vh/zVNElD5SxGR4sTjjLiBC00aLGf9JK+Sa/XXO6Bsk3WWXFtA1dY+4tUzUqH0mAHfN0WvA==",
|
"integrity": "sha512-wyqKIzXTAfXX3L1d8R7icM+HmRQBTbuNLWtUlpRJ/JP/ux1ei/sOSt6p8f90ARoOP2iJVlM70wOBYWaGErdBlA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@langchain/langgraph-checkpoint": "^1.0.0",
|
"@langchain/langgraph-checkpoint": "^1.0.0",
|
||||||
"@langchain/langgraph-sdk": "~1.5.5",
|
"@langchain/langgraph-sdk": "~1.6.5",
|
||||||
"@standard-schema/spec": "1.1.0",
|
"@standard-schema/spec": "1.1.0",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1676,7 +1679,7 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@langchain/core": "^1.0.1",
|
"@langchain/core": "^1.1.16",
|
||||||
"zod": "^3.25.32 || ^4.2.0",
|
"zod": "^3.25.32 || ^4.2.0",
|
||||||
"zod-to-json-schema": "^3.x"
|
"zod-to-json-schema": "^3.x"
|
||||||
},
|
},
|
||||||
@@ -1702,17 +1705,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@langchain/langgraph-sdk": {
|
"node_modules/@langchain/langgraph-sdk": {
|
||||||
"version": "1.5.5",
|
"version": "1.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.6.5.tgz",
|
||||||
"integrity": "sha512-SyiAs6TVXPWlt/8cI9pj/43nbIvclY3ytKqUFbL5MplCUnItetEyqvH87EncxyVF5D7iJKRZRfSVYBMmOZbjbQ==",
|
"integrity": "sha512-JjprmbhgCnoNJ9DUKcvrEU+C9FfKsNGyT3ooqWxAY5Cx2qofhXmDJOpTCqqbxfDHPKG0RjTs5HgVK3WW5M6Big==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
"p-queue": "^9.0.1",
|
"p-queue": "^9.0.1",
|
||||||
"p-retry": "^7.1.1",
|
"p-retry": "^7.1.1",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@langchain/core": "^1.1.15",
|
"@langchain/core": "^1.1.16",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"react-dom": "^18 || ^19"
|
"react-dom": "^18 || ^19"
|
||||||
},
|
},
|
||||||
@@ -1786,6 +1790,44 @@
|
|||||||
"@langchain/core": "^1.0.0"
|
"@langchain/core": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@langfuse/core": {
|
||||||
|
"version": "4.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@langfuse/core/-/core-4.6.1.tgz",
|
||||||
|
"integrity": "sha512-DtQoKWHQh0I0MsJxcKrBQVKAJ3fea6+raXlISVY3NDMFG/zSKkdkNouQvUXQtSCHBbOFupHMBw8imM30lbhq3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": "^1.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@langfuse/langchain": {
|
||||||
|
"version": "4.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@langfuse/langchain/-/langchain-4.6.1.tgz",
|
||||||
|
"integrity": "sha512-cU7zKPVHuaxZ0g1WsvshiHUIsMqAqCHTkRgh2NDekw1IE6HXxSZDj9puNS5Vqk35xldcCYJQnaBXaI8Vn0oL2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@langfuse/core": "^4.6.1",
|
||||||
|
"@langfuse/tracing": "^4.6.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@langchain/core": ">=0.3.8",
|
||||||
|
"@opentelemetry/api": "^1.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@langfuse/tracing": {
|
||||||
|
"version": "4.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@langfuse/tracing/-/tracing-4.6.1.tgz",
|
||||||
|
"integrity": "sha512-Ld1bPU6RxzifgGEDtN70Og8u2eL906jtnnEnt62BEOcML8UUiMgzwAKZDBbIjF2midnfac7Xnho3s546fcCDtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@langfuse/core": "^4.6.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": "^1.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/canvas": {
|
"node_modules/@napi-rs/canvas": {
|
||||||
"version": "0.1.89",
|
"version": "0.1.89",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.89.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.89.tgz",
|
||||||
@@ -2241,6 +2283,16 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@opentelemetry/api": {
|
||||||
|
"version": "1.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
|
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/number": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
@@ -4381,7 +4433,6 @@
|
|||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/json5": {
|
"node_modules/@types/json5": {
|
||||||
@@ -8268,9 +8319,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-network-error": {
|
"node_modules/is-network-error": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz",
|
||||||
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
|
"integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@langchain/anthropic": "^1.3.13",
|
"@langchain/anthropic": "^1.3.13",
|
||||||
"@langchain/core": "^1.1.18",
|
"@langchain/core": "^1.1.18",
|
||||||
|
"@langchain/langgraph": "^1.2.0",
|
||||||
"@langchain/openai": "^1.2.4",
|
"@langchain/openai": "^1.2.4",
|
||||||
|
"@langfuse/core": "^4.6.1",
|
||||||
|
"@langfuse/langchain": "^4.6.1",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add artifact column to chat_messages for structured visualization specs
|
||||||
|
ALTER TABLE public.chat_messages ADD COLUMN artifact jsonb;
|
||||||
|
COMMENT ON COLUMN public.chat_messages.artifact IS 'Structured visualization spec (ArtifactSpec JSON)';
|
||||||
+41
-1
@@ -7,6 +7,7 @@ export interface ChatMessage {
|
|||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
sources: SourceReference[]
|
sources: SourceReference[]
|
||||||
|
artifact?: ArtifactSpec | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +49,47 @@ export interface ChatResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamChunk {
|
export interface StreamChunk {
|
||||||
type: 'content' | 'sources' | 'done' | 'error'
|
type: 'content' | 'sources' | 'done' | 'error' | 'tool_start' | 'artifact'
|
||||||
content?: string
|
content?: string
|
||||||
sources?: SourceReference[]
|
sources?: SourceReference[]
|
||||||
error?: string
|
error?: string
|
||||||
|
toolName?: string
|
||||||
|
artifact?: ArtifactSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Artifact Types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ArtifactSpec =
|
||||||
|
| ChartArtifact
|
||||||
|
| TableArtifact
|
||||||
|
| KpiCardsArtifact
|
||||||
|
| AgingBucketsArtifact
|
||||||
|
|
||||||
|
export interface ChartArtifact {
|
||||||
|
type: 'bar_chart' | 'line_chart' | 'pie_chart' | 'stacked_bar'
|
||||||
|
title: string
|
||||||
|
data: { label: string; value: number; color?: string }[]
|
||||||
|
unit?: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableArtifact {
|
||||||
|
type: 'table'
|
||||||
|
title: string
|
||||||
|
columns: { key: string; label: string; align?: 'left' | 'right' }[]
|
||||||
|
rows: Record<string, string | number>[]
|
||||||
|
summary_row?: Record<string, string | number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KpiCardsArtifact {
|
||||||
|
type: 'kpi_cards'
|
||||||
|
title?: string
|
||||||
|
cards: { label: string; value: string; trend?: 'up' | 'down' | 'flat'; change?: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgingBucketsArtifact {
|
||||||
|
type: 'aging_buckets'
|
||||||
|
title: string
|
||||||
|
buckets: { label: string; amount: number; count: number }[]
|
||||||
|
total: number
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user