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

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