From 547fd053ec796d554ef08f725548a02ac45d9b2b Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 27 Feb 2026 08:48:28 +0100 Subject: [PATCH] feat: AI chat now more accurate, has access to data from user, and can generate graphs, charts etc --- components/chat/ChatMessage.tsx | 45 +- components/chat/ChatPanel.tsx | 30 +- .../chat/artifacts/ArtifactRenderer.tsx | 45 ++ .../chat/artifacts/ChatAgingBuckets.tsx | 76 +++ components/chat/artifacts/ChatChart.tsx | 130 ++++ components/chat/artifacts/ChatDataTable.tsx | 80 +++ components/chat/artifacts/ChatKpiCards.tsx | 47 ++ components/chat/useChatStream.ts | 20 +- .../extensions/general/AiChatWorkspace.tsx | 11 +- extensions/general/ai-chat/api-routes.ts | 39 +- extensions/general/ai-chat/chatbot/agent.ts | 133 ++++ .../general/ai-chat/chatbot/artifacts.ts | 237 +++++++ extensions/general/ai-chat/chatbot/chain.ts | 103 +++ extensions/general/ai-chat/chatbot/config.ts | 11 + extensions/general/ai-chat/chatbot/prompts.ts | 54 ++ extensions/general/ai-chat/chatbot/router.ts | 156 +++++ extensions/general/ai-chat/chatbot/tools.ts | 585 ++++++++++++++++++ extensions/general/ai-chat/chatbot/tracing.ts | 49 ++ extensions/general/ai-chat/manifest.json | 11 +- .../_generated/sector-definitions.ts | 18 +- package-lock.json | 77 ++- package.json | 3 + .../20240101000046_chat_artifacts.sql | 3 + types/chat.ts | 42 +- 24 files changed, 1954 insertions(+), 51 deletions(-) create mode 100644 components/chat/artifacts/ArtifactRenderer.tsx create mode 100644 components/chat/artifacts/ChatAgingBuckets.tsx create mode 100644 components/chat/artifacts/ChatChart.tsx create mode 100644 components/chat/artifacts/ChatDataTable.tsx create mode 100644 components/chat/artifacts/ChatKpiCards.tsx create mode 100644 extensions/general/ai-chat/chatbot/agent.ts create mode 100644 extensions/general/ai-chat/chatbot/artifacts.ts create mode 100644 extensions/general/ai-chat/chatbot/router.ts create mode 100644 extensions/general/ai-chat/chatbot/tools.ts create mode 100644 extensions/general/ai-chat/chatbot/tracing.ts create mode 100644 supabase/migrations/20240101000046_chat_artifacts.sql diff --git a/components/chat/ChatMessage.tsx b/components/chat/ChatMessage.tsx index e77333bc..f3d739d1 100644 --- a/components/chat/ChatMessage.tsx +++ b/components/chat/ChatMessage.tsx @@ -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 = { + 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 ( +
+ {tools.map((toolName, i) => ( +
+ + + {TOOL_LABELS[toolName] || toolName}... +
+ ))} +
+ ) +} + +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 && ( + + )} +
+ {/* Artifact visualization */} + {!isUser && message.artifact && ( + + )} + {!isUser && message.sources && message.sources.length > 0 && ( )} diff --git a/components/chat/ChatPanel.tsx b/components/chat/ChatPanel.tsx index 73d82eb8..0df404f3 100644 --- a/components/chat/ChatPanel.tsx +++ b/components/chat/ChatPanel.tsx @@ -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) {

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? + + sendMessage('Visa mina senaste fakturor')} + disabled={isLoading} + > + Visa mina senaste fakturor + + sendMessage('Hur ser min resultaträkning ut?')} + disabled={isLoading} + > + Hur ser min resultaträkning ut? 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? - sendMessage('När måste jag momsregistrera mig?')} - disabled={isLoading} - > - När måste jag momsregistrera mig? -
) : ( @@ -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 + } /> ))}
diff --git a/components/chat/artifacts/ArtifactRenderer.tsx b/components/chat/artifacts/ArtifactRenderer.tsx new file mode 100644 index 00000000..730675b4 --- /dev/null +++ b/components/chat/artifacts/ArtifactRenderer.tsx @@ -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 ( +
+ +
+ ) + case 'table': + return ( +
+ +
+ ) + case 'kpi_cards': + return ( +
+ +
+ ) + case 'aging_buckets': + return ( +
+ +
+ ) + default: + return null + } +} diff --git a/components/chat/artifacts/ChatAgingBuckets.tsx b/components/chat/artifacts/ChatAgingBuckets.tsx new file mode 100644 index 00000000..72b55755 --- /dev/null +++ b/components/chat/artifacts/ChatAgingBuckets.tsx @@ -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 ( +
+
+

{title}

+ + {formatAmount(total)} kr + +
+ + {/* Stacked bar */} + {total > 0 && ( +
+ {buckets.map((bucket, i) => { + const widthPercent = (bucket.amount / total) * 100 + if (widthPercent < 0.5) return null + return ( +
+ ) + })} +
+ )} + + {/* Legend */} +
+ {buckets.map((bucket, i) => ( +
+
+
+ {bucket.label} +
+
+ + {bucket.count} st + + + {formatAmount(bucket.amount)} kr + +
+
+ ))} +
+
+ ) +} diff --git a/components/chat/artifacts/ChatChart.tsx b/components/chat/artifacts/ChatChart.tsx new file mode 100644 index 00000000..daf71166 --- /dev/null +++ b/components/chat/artifacts/ChatChart.tsx @@ -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 ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ + {type === 'pie_chart' ? ( + + { + 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) => ( + + ))} + + formatValue(Number(value ?? 0), unit)} + /> + + ) : type === 'line_chart' ? ( + + + + formatValue(v, unit)} + className="text-muted-foreground" + /> + formatValue(Number(value ?? 0), unit)} + /> + + + ) : ( + + + + formatValue(v, unit)} + className="text-muted-foreground" + /> + formatValue(Number(value ?? 0), unit)} + /> + + {chartData.map((entry, i) => ( + + ))} + + + )} + +
+
+ ) +} diff --git a/components/chat/artifacts/ChatDataTable.tsx b/components/chat/artifacts/ChatDataTable.tsx new file mode 100644 index 00000000..aef58be4 --- /dev/null +++ b/components/chat/artifacts/ChatDataTable.tsx @@ -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 ( +
+

{title}

+
+ + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + {summary_row && ( + + {columns.map((col) => ( + + ))} + + )} + +
+ {col.label} +
+ {formatCell(row[col.key], col.align)} +
+ {summary_row[col.key] !== undefined + ? formatCell(summary_row[col.key], col.align) + : ''} +
+
+
+ ) +} diff --git a/components/chat/artifacts/ChatKpiCards.tsx b/components/chat/artifacts/ChatKpiCards.tsx new file mode 100644 index 00000000..685e5097 --- /dev/null +++ b/components/chat/artifacts/ChatKpiCards.tsx @@ -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 ( +
+ {title &&

{title}

} +
+ {cards.map((card, i) => ( +
+

{card.label}

+
+ {card.value} + {card.trend && ( + + {card.trend === 'up' && } + {card.trend === 'down' && } + {card.trend === 'flat' && } + {card.change} + + )} +
+
+ ))} +
+
+ ) +} diff --git a/components/chat/useChatStream.ts b/components/chat/useChatStream.ts index 7e1dd36c..477224e5 100644 --- a/components/chat/useChatStream.ts +++ b/components/chat/useChatStream.ts @@ -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 loadSession: (sessionId: string) => Promise clearChat: () => void @@ -25,6 +26,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream const [isStreaming, setIsStreaming] = useState(false) const [sessionId, setSessionId] = useState(null) const [error, setError] = useState(null) + const [toolsExecuting, setToolsExecuting] = useState([]) const abortControllerRef = useRef(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, diff --git a/components/extensions/general/AiChatWorkspace.tsx b/components/extensions/general/AiChatWorkspace.tsx index 70d88e7c..4994642f 100644 --- a/components/extensions/general/AiChatWorkspace.tsx +++ b/components/extensions/general/AiChatWorkspace.tsx @@ -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 ( - } - /> +
+ +
) } diff --git a/extensions/general/ai-chat/api-routes.ts b/extensions/general/ai-chat/api-routes.ts index a82df1e6..e76dce89 100644 --- a/extensions/general/ai-chat/api-routes.ts +++ b/extensions/general/ai-chat/api-routes.ts @@ -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() @@ -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() diff --git a/extensions/general/ai-chat/chatbot/agent.ts b/extensions/general/ai-chat/chatbot/agent.ts new file mode 100644 index 00000000..5d464dfc --- /dev/null +++ b/extensions/general/ai-chat/chatbot/agent.ts @@ -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 { + 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 } +} diff --git a/extensions/general/ai-chat/chatbot/artifacts.ts b/extensions/general/ai-chat/chatbot/artifacts.ts new file mode 100644 index 00000000..de062d00 --- /dev/null +++ b/extensions/general/ai-chat/chatbot/artifacts.ts @@ -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): 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 = { + 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 { + 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 + } +} diff --git a/extensions/general/ai-chat/chatbot/chain.ts b/extensions/general/ai-chat/chatbot/chain.ts index b54bedd9..9539c3b0 100644 --- a/extensions/general/ai-chat/chatbot/chain.ts +++ b/extensions/general/ai-chat/chatbot/chain.ts @@ -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 { + // 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) + } + } +} diff --git a/extensions/general/ai-chat/chatbot/config.ts b/extensions/general/ai-chat/chatbot/config.ts index 116da996..47886626 100644 --- a/extensions/general/ai-chat/chatbot/config.ts +++ b/extensions/general/ai-chat/chatbot/config.ts @@ -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, diff --git a/extensions/general/ai-chat/chatbot/prompts.ts b/extensions/general/ai-chat/chatbot/prompts.ts index 1837a000..34732346 100644 --- a/extensions/general/ai-chat/chatbot/prompts.ts +++ b/extensions/general/ai-chat/chatbot/prompts.ts @@ -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 diff --git a/extensions/general/ai-chat/chatbot/router.ts b/extensions/general/ai-chat/chatbot/router.ts new file mode 100644 index 00000000..548b43b5 --- /dev/null +++ b/extensions/general/ai-chat/chatbot/router.ts @@ -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 { + 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 { + 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) +} diff --git a/extensions/general/ai-chat/chatbot/tools.ts b/extensions/general/ai-chat/chatbot/tools.ts new file mode 100644 index 00000000..590f3d40 --- /dev/null +++ b/extensions/general/ai-chat/chatbot/tools.ts @@ -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 = { 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() + 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, + ] +} diff --git a/extensions/general/ai-chat/chatbot/tracing.ts b/extensions/general/ai-chat/chatbot/tracing.ts new file mode 100644 index 00000000..938f9574 --- /dev/null +++ b/extensions/general/ai-chat/chatbot/tracing.ts @@ -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 +}): 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 { + 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 + } +} diff --git a/extensions/general/ai-chat/manifest.json b/extensions/general/ai-chat/manifest.json index d8671160..3e9956c9 100644 --- a/extensions/general/ai-chat/manifest.json +++ b/extensions/general/ai-chat/manifest.json @@ -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", diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index 9369e95a..2f36dcd9 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -39,9 +39,21 @@ export const EXTENSION_DEFINITIONS: Record = { "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", diff --git a/package-lock.json b/package-lock.json index 4886d206..672b442a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 2dde3633..0cfcdb7f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/supabase/migrations/20240101000046_chat_artifacts.sql b/supabase/migrations/20240101000046_chat_artifacts.sql new file mode 100644 index 00000000..ee620bed --- /dev/null +++ b/supabase/migrations/20240101000046_chat_artifacts.sql @@ -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)'; diff --git a/types/chat.ts b/types/chat.ts index df8cf590..337a71e6 100644 --- a/types/chat.ts +++ b/types/chat.ts @@ -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[] + summary_row?: Record +} + +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 }