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 ReactMarkdown from 'react-markdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown, ChevronUp, FileText, User, Bot } from 'lucide-react'
|
||||
import { ChevronDown, ChevronUp, FileText, User, Bot, Database, Loader2 } from 'lucide-react'
|
||||
import type { ChatMessage as ChatMessageType, SourceReference } from '@/types/chat'
|
||||
import { ArtifactRenderer } from './artifacts/ArtifactRenderer'
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatMessageType
|
||||
isStreaming?: boolean
|
||||
toolsExecuting?: string[]
|
||||
}
|
||||
|
||||
function SourcesList({ sources }: { sources: SourceReference[] }) {
|
||||
@@ -58,7 +60,36 @@ function SourcesList({ sources }: { sources: SourceReference[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, isStreaming }: ChatMessageProps) {
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
get_invoices: 'Hämtar fakturor',
|
||||
get_supplier_invoices: 'Hämtar leverantörsfakturor',
|
||||
get_account_balances: 'Hämtar kontosaldon',
|
||||
get_transactions: 'Hämtar transaktioner',
|
||||
get_journal_entries: 'Hämtar verifikationer',
|
||||
get_income_statement: 'Genererar resultaträkning',
|
||||
get_balance_sheet: 'Genererar balansräkning',
|
||||
get_vat_summary: 'Beräknar momssammanställning',
|
||||
get_company_overview: 'Hämtar företagsöversikt',
|
||||
get_aging_report: 'Genererar åldersanalys',
|
||||
}
|
||||
|
||||
function ToolExecutingIndicator({ tools }: { tools: string[] }) {
|
||||
if (tools.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 mb-2">
|
||||
{tools.map((toolName, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<Database className="h-3 w-3" />
|
||||
<span>{TOOL_LABELS[toolName] || toolName}...</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, isStreaming, toolsExecuting }: ChatMessageProps) {
|
||||
const isUser = message.role === 'user'
|
||||
|
||||
return (
|
||||
@@ -93,6 +124,11 @@ export function ChatMessage({ message, isStreaming }: ChatMessageProps) {
|
||||
: 'bg-muted/60 text-foreground'
|
||||
)}
|
||||
>
|
||||
{/* Tool execution indicator */}
|
||||
{!isUser && isStreaming && toolsExecuting && toolsExecuting.length > 0 && !message.content && (
|
||||
<ToolExecutingIndicator tools={toolsExecuting} />
|
||||
)}
|
||||
|
||||
<div className={cn(
|
||||
"text-sm break-words",
|
||||
!isUser && "prose prose-sm max-w-none prose-headings:text-foreground prose-headings:font-semibold prose-h1:text-base prose-h2:text-sm prose-h3:text-sm prose-p:text-foreground prose-p:my-1.5 prose-headings:my-2 prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:text-foreground prose-strong:text-foreground prose-table:my-2 prose-table:text-xs prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1 prose-th:bg-muted/50 prose-th:border prose-td:border prose-th:border-border/50 prose-td:border-border/50 prose-th:text-foreground prose-td:text-foreground"
|
||||
@@ -107,6 +143,11 @@ export function ChatMessage({ message, isStreaming }: ChatMessageProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Artifact visualization */}
|
||||
{!isUser && message.artifact && (
|
||||
<ArtifactRenderer artifact={message.artifact} />
|
||||
)}
|
||||
|
||||
{!isUser && message.sources && message.sources.length > 0 && (
|
||||
<SourcesList sources={message.sources} />
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ export function ChatPanel({ className }: ChatPanelProps) {
|
||||
isLoading,
|
||||
isStreaming,
|
||||
error,
|
||||
toolsExecuting,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
} = useChatStream({
|
||||
@@ -90,10 +91,22 @@ export function ChatPanel({ className }: ChatPanelProps) {
|
||||
</p>
|
||||
<div className="mt-6 space-y-2 w-full max-w-xs">
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Hur fungerar momsen på mina fakturor?')}
|
||||
onClick={() => sendMessage('Hur går det för mitt företag?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Hur fungerar momsen på mina fakturor?
|
||||
Hur går det för mitt företag?
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Visa mina senaste fakturor')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Visa mina senaste fakturor
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Hur ser min resultaträkning ut?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Hur ser min resultaträkning ut?
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Vad kan jag dra av som företagare?')}
|
||||
@@ -101,12 +114,6 @@ export function ChatPanel({ className }: ChatPanelProps) {
|
||||
>
|
||||
Vad kan jag dra av som företagare?
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('När måste jag momsregistrera mig?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
När måste jag momsregistrera mig?
|
||||
</SuggestionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -120,6 +127,13 @@ export function ChatPanel({ className }: ChatPanelProps) {
|
||||
index === messages.length - 1 &&
|
||||
message.role === 'assistant'
|
||||
}
|
||||
toolsExecuting={
|
||||
isStreaming &&
|
||||
index === messages.length - 1 &&
|
||||
message.role === 'assistant'
|
||||
? toolsExecuting
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import type { ArtifactSpec } from '@/types/chat'
|
||||
import { ChatChart } from './ChatChart'
|
||||
import { ChatDataTable } from './ChatDataTable'
|
||||
import { ChatKpiCards } from './ChatKpiCards'
|
||||
import { ChatAgingBuckets } from './ChatAgingBuckets'
|
||||
|
||||
interface ArtifactRendererProps {
|
||||
artifact: ArtifactSpec
|
||||
}
|
||||
|
||||
export function ArtifactRenderer({ artifact }: ArtifactRendererProps) {
|
||||
switch (artifact.type) {
|
||||
case 'bar_chart':
|
||||
case 'line_chart':
|
||||
case 'pie_chart':
|
||||
case 'stacked_bar':
|
||||
return (
|
||||
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
|
||||
<ChatChart artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'table':
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<ChatDataTable artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'kpi_cards':
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<ChatKpiCards artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'aging_buckets':
|
||||
return (
|
||||
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
|
||||
<ChatAgingBuckets artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import type { AgingBucketsArtifact } from '@/types/chat'
|
||||
|
||||
const BUCKET_COLORS = [
|
||||
'bg-green-500',
|
||||
'bg-yellow-400',
|
||||
'bg-orange-400',
|
||||
'bg-red-400',
|
||||
'bg-red-600',
|
||||
]
|
||||
|
||||
interface ChatAgingBucketsProps {
|
||||
artifact: AgingBucketsArtifact
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE').format(Math.round(amount))
|
||||
}
|
||||
|
||||
export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
|
||||
const { title, buckets, total } = artifact
|
||||
|
||||
const maxAmount = Math.max(...buckets.map((b) => b.amount), 1)
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
<span className="text-sm font-bold tabular-nums">
|
||||
{formatAmount(total)} kr
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stacked bar */}
|
||||
{total > 0 && (
|
||||
<div className="flex h-6 rounded-md overflow-hidden mb-3">
|
||||
{buckets.map((bucket, i) => {
|
||||
const widthPercent = (bucket.amount / total) * 100
|
||||
if (widthPercent < 0.5) return null
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${BUCKET_COLORS[i % BUCKET_COLORS.length]} transition-all`}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
title={`${bucket.label}: ${formatAmount(bucket.amount)} kr`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-1.5">
|
||||
{buckets.map((bucket, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-sm ${BUCKET_COLORS[i % BUCKET_COLORS.length]}`}
|
||||
/>
|
||||
<span className="text-muted-foreground">{bucket.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{bucket.count} st
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatAmount(bucket.amount)} kr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { ChatMessage, SourceReference, ChatSession } from '@/types/chat'
|
||||
import type { ChatMessage, SourceReference, ArtifactSpec } from '@/types/chat'
|
||||
|
||||
interface UseChatStreamOptions {
|
||||
onError?: (error: string) => void
|
||||
@@ -13,6 +13,7 @@ interface UseChatStreamReturn {
|
||||
isStreaming: boolean
|
||||
sessionId: string | null
|
||||
error: string | null
|
||||
toolsExecuting: string[]
|
||||
sendMessage: (message: string) => Promise<void>
|
||||
loadSession: (sessionId: string) => Promise<void>
|
||||
clearChat: () => void
|
||||
@@ -25,6 +26,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [toolsExecuting, setToolsExecuting] = useState<string[]>([])
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const sendMessage = useCallback(async (message: string) => {
|
||||
@@ -86,6 +88,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
let sources: SourceReference[] = []
|
||||
let artifact: ArtifactSpec | null = null
|
||||
let newSessionId = sessionId
|
||||
let messageId: string | null = null
|
||||
|
||||
@@ -106,6 +109,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
||||
setSessionId(data.session_id)
|
||||
} else if (data.type === 'content') {
|
||||
accumulatedContent += data.content
|
||||
setToolsExecuting([]) // Clear tool indicators when content starts
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
@@ -124,8 +128,21 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} else if (data.type === 'tool_start') {
|
||||
setToolsExecuting((prev) => [...prev, data.toolName])
|
||||
} else if (data.type === 'artifact') {
|
||||
artifact = data.artifact
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.artifact = artifact
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} else if (data.type === 'done') {
|
||||
messageId = data.message_id
|
||||
setToolsExecuting([])
|
||||
// Update the message IDs with real values
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
@@ -207,6 +224,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
||||
isStreaming,
|
||||
sessionId,
|
||||
error,
|
||||
toolsExecuting,
|
||||
sendMessage,
|
||||
loadSession,
|
||||
clearChat,
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
import { MessageSquare } from 'lucide-react'
|
||||
import { ChatPanel } from '@/components/chat/ChatPanel'
|
||||
|
||||
export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="AI-assistent"
|
||||
description="Använd chattwidgeten i nedre högra hörnet för att ställa frågor om bokföring och skatt."
|
||||
icon={<MessageSquare className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
<div className="h-[calc(100vh-10rem)] max-w-4xl mx-auto">
|
||||
<ChatPanel className="h-full rounded-lg border border-border bg-background shadow-sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
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 type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
|
||||
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||
@@ -258,6 +258,7 @@ async function handlePostStream(
|
||||
const encoder = new TextEncoder()
|
||||
let fullContent = ''
|
||||
let sources: SourceReference[] = []
|
||||
let artifact: ArtifactSpec | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
@@ -267,22 +268,37 @@ async function handlePostStream(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
|
||||
)
|
||||
|
||||
// Stream the response
|
||||
for await (const chunk of streamChatResponse(message.trim(), conversationHistory)) {
|
||||
if (chunk.type === 'content') {
|
||||
fullContent += chunk.data as string
|
||||
// Stream the routed response (handles knowledge, data, and hybrid)
|
||||
for await (const event of streamRoutedResponse(
|
||||
message.trim(),
|
||||
conversationHistory,
|
||||
supabase,
|
||||
userId,
|
||||
sessionId
|
||||
)) {
|
||||
if (event.type === 'content') {
|
||||
fullContent += event.content
|
||||
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') {
|
||||
sources = chunk.data as SourceReference[]
|
||||
} else if (event.type === 'sources') {
|
||||
sources = event.sources
|
||||
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
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
@@ -291,6 +307,7 @@ async function handlePostStream(
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
sources,
|
||||
...(artifact ? { artifact } : {}),
|
||||
})
|
||||
.select()
|
||||
.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,
|
||||
type RetrievedDocument,
|
||||
} 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 { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
// Initialize the LLM
|
||||
function getChatModel() {
|
||||
@@ -135,3 +140,101 @@ export async function* streamChatResponse(
|
||||
// 6. Yield sources at the end
|
||||
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,
|
||||
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
|
||||
retrievalK: 5,
|
||||
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.`
|
||||
|
||||
/**
|
||||
* 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(
|
||||
sources: Array<{
|
||||
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",
|
||||
"workspace": "@/components/extensions/general/AiChatWorkspace",
|
||||
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
"optionalEnvVars": [],
|
||||
"npmDependencies": ["@langchain/anthropic", "@langchain/core", "langchain", "@langchain/openai"],
|
||||
"optionalEnvVars": ["LANGFUSE_SECRET_KEY", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_BASE_URL"],
|
||||
"npmDependencies": ["@langchain/anthropic", "@langchain/core", "langchain", "@langchain/openai", "@langchain/langgraph", "@langfuse/core", "@langfuse/langchain"],
|
||||
"definition": {
|
||||
"name": "AI-assistent",
|
||||
"category": "operations",
|
||||
"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,
|
||||
"description": "AI-assistent för skatte- och bokföringsfrågor",
|
||||
"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.",
|
||||
"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. Kan hämta och visualisera din bokföringsdata — fakturor, transaktioner, resultaträkning, balansräkning och mer.",
|
||||
"quickAction": {
|
||||
"label": "AI-assistent",
|
||||
"description": "Fråga om bokföring",
|
||||
|
||||
@@ -39,9 +39,21 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"sector": "general",
|
||||
"category": "operations",
|
||||
"icon": "MessageSquare",
|
||||
"dataPattern": "manual",
|
||||
"description": "AI-assistent för skatte- och bokföringsfrågor",
|
||||
"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.",
|
||||
"dataPattern": "both",
|
||||
"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. 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,
|
||||
"quickAction": {
|
||||
"label": "AI-assistent",
|
||||
|
||||
Generated
+64
-13
@@ -12,7 +12,10 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@langchain/anthropic": "^1.3.13",
|
||||
"@langchain/core": "^1.1.18",
|
||||
"@langchain/langgraph": "^1.2.0",
|
||||
"@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-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -1662,13 +1665,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.1.2.tgz",
|
||||
"integrity": "sha512-kpZCttZ0N+jHSl5Vh/zVNElD5SxGR4sTjjLiBC00aLGf9JK+Sa/XXO6Bsk3WWXFtA1dY+4tUzUqH0mAHfN0WvA==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.2.0.tgz",
|
||||
"integrity": "sha512-wyqKIzXTAfXX3L1d8R7icM+HmRQBTbuNLWtUlpRJ/JP/ux1ei/sOSt6p8f90ARoOP2iJVlM70wOBYWaGErdBlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph-checkpoint": "^1.0.0",
|
||||
"@langchain/langgraph-sdk": "~1.5.5",
|
||||
"@langchain/langgraph-sdk": "~1.6.5",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
@@ -1676,7 +1679,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.0.1",
|
||||
"@langchain/core": "^1.1.16",
|
||||
"zod": "^3.25.32 || ^4.2.0",
|
||||
"zod-to-json-schema": "^3.x"
|
||||
},
|
||||
@@ -1702,17 +1705,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.5.5.tgz",
|
||||
"integrity": "sha512-SyiAs6TVXPWlt/8cI9pj/43nbIvclY3ytKqUFbL5MplCUnItetEyqvH87EncxyVF5D7iJKRZRfSVYBMmOZbjbQ==",
|
||||
"version": "1.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.6.5.tgz",
|
||||
"integrity": "sha512-JjprmbhgCnoNJ9DUKcvrEU+C9FfKsNGyT3ooqWxAY5Cx2qofhXmDJOpTCqqbxfDHPKG0RjTs5HgVK3WW5M6Big==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"p-queue": "^9.0.1",
|
||||
"p-retry": "^7.1.1",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.15",
|
||||
"@langchain/core": "^1.1.16",
|
||||
"react": "^18 || ^19",
|
||||
"react-dom": "^18 || ^19"
|
||||
},
|
||||
@@ -1786,6 +1790,44 @@
|
||||
"@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": {
|
||||
"version": "0.1.89",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.89.tgz",
|
||||
@@ -2241,6 +2283,16 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -4381,7 +4433,6 @@
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json5": {
|
||||
@@ -8268,9 +8319,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/is-network-error": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
|
||||
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz",
|
||||
"integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@langchain/anthropic": "^1.3.13",
|
||||
"@langchain/core": "^1.1.18",
|
||||
"@langchain/langgraph": "^1.2.0",
|
||||
"@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-dialog": "^1.1.15",
|
||||
"@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'
|
||||
content: string
|
||||
sources: SourceReference[]
|
||||
artifact?: ArtifactSpec | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -48,8 +49,47 @@ export interface ChatResponse {
|
||||
}
|
||||
|
||||
export interface StreamChunk {
|
||||
type: 'content' | 'sources' | 'done' | 'error'
|
||||
type: 'content' | 'sources' | 'done' | 'error' | 'tool_start' | 'artifact'
|
||||
content?: string
|
||||
sources?: SourceReference[]
|
||||
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