feat: tenant AI chat agent with function calling

Actionable AI assistant that uses per-tenant Azure OpenAI credentials
to execute natural language commands against the render pipeline.

Backend:
- ChatMessage model + migration (session-based conversations)
- Chat service with 10 OpenAI function-calling tools:
  list_orders, search_products, create_order, dispatch_renders,
  get_order_status, set_material_override, set_render_overrides,
  get_render_stats, check_materials, query_database
- All tools tenant-scoped (queries filtered by tenant_id)
- Write operations use httpx to call backend API internally
- Chat API: POST /chat/messages, GET /chat/sessions, DELETE session
- Conversation history preserved in DB (last 50 messages per session)

Frontend:
- Slide-out ChatPanel (right side, w-96, animated)
- User/assistant message styling with avatars and timestamps
- Session management (new chat, session history, delete)
- Typing indicator while waiting for AI response
- Floating chat button in bottom-right corner
- Error state for unconfigured AI tenants

Example: "Render all Kugellager products as WebP at 1024x1024"
→ Agent calls search_products + create_order + dispatch_renders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 12:46:21 +01:00
parent daad2c64f3
commit 59ce61098c
10 changed files with 1627 additions and 66 deletions
+50
View File
@@ -0,0 +1,50 @@
import api from './client'
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
}
export interface ChatSession {
session_id: string
last_message: string
message_count: number
created_at: string
}
export interface ChatResponse {
session_id: string
message: ChatMessage
response: ChatMessage
}
export async function sendChatMessage(
message: string,
sessionId?: string,
contextType?: string,
contextId?: string,
): Promise<ChatResponse> {
const res = await api.post<ChatResponse>('/chat/messages', {
message,
session_id: sessionId || undefined,
context_type: contextType || undefined,
context_id: contextId || undefined,
})
return res.data
}
export async function getChatSessions(): Promise<ChatSession[]> {
const res = await api.get<ChatSession[]>('/chat/sessions')
return res.data
}
export async function getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
const res = await api.get<ChatMessage[]>(`/chat/sessions/${sessionId}/messages`)
return res.data
}
export async function deleteChatSession(sessionId: string): Promise<void> {
await api.delete(`/chat/sessions/${sessionId}`)
}
+319
View File
@@ -0,0 +1,319 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { MessageSquare, Send, Loader2, X, Plus, Bot, User } from 'lucide-react'
import {
sendChatMessage,
getChatSessions,
getSessionMessages,
deleteChatSession,
type ChatMessage,
type ChatSession,
} from '../../api/chat'
interface ChatPanelProps {
open: boolean
onClose: () => void
contextType?: string
contextId?: string
}
export default function ChatPanel({ open, onClose, contextType, contextId }: ChatPanelProps) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [sessionId, setSessionId] = useState<string | undefined>()
const [input, setInput] = useState('')
const [showSessions, setShowSessions] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const queryClient = useQueryClient()
// Load sessions
const { data: sessions } = useQuery({
queryKey: ['chat-sessions'],
queryFn: getChatSessions,
enabled: open,
staleTime: 30_000,
})
// Load messages when session changes
const { data: sessionMessages } = useQuery({
queryKey: ['chat-messages', sessionId],
queryFn: () => getSessionMessages(sessionId!),
enabled: !!sessionId && open,
staleTime: 10_000,
})
useEffect(() => {
if (sessionMessages) {
setMessages(sessionMessages)
}
}, [sessionMessages])
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Focus input when panel opens
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 200)
}
}, [open])
// Send message mutation
const sendMut = useMutation({
mutationFn: (message: string) =>
sendChatMessage(message, sessionId, contextType, contextId),
onSuccess: (data) => {
setSessionId(data.session_id)
setMessages((prev) => [...prev, data.message, data.response])
queryClient.invalidateQueries({ queryKey: ['chat-sessions'] })
},
})
const handleSend = useCallback(() => {
const trimmed = input.trim()
if (!trimmed || sendMut.isPending) return
setInput('')
sendMut.mutate(trimmed)
}, [input, sendMut])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const handleNewChat = () => {
setSessionId(undefined)
setMessages([])
setShowSessions(false)
inputRef.current?.focus()
}
const handleSelectSession = (session: ChatSession) => {
setSessionId(session.session_id)
setShowSessions(false)
}
const handleDeleteSession = async (sid: string, e: React.MouseEvent) => {
e.stopPropagation()
await deleteChatSession(sid)
queryClient.invalidateQueries({ queryKey: ['chat-sessions'] })
if (sid === sessionId) {
handleNewChat()
}
}
const formatTime = (iso: string) => {
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
if (!open) return null
return (
<div
className="fixed right-0 top-0 h-full w-96 z-40 flex flex-col border-l border-border-default shadow-xl"
style={{
backgroundColor: 'var(--color-bg-surface)',
animation: 'slideInRight 0.25s ease-out',
}}
>
{/* Inline keyframes for slide animation */}
<style>{`
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes typingDot {
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-4px); }
}
`}</style>
{/* Header */}
<div
className="flex items-center gap-2 px-4 py-3 border-b border-border-default shrink-0"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<Bot size={20} className="text-accent" />
<button
onClick={() => setShowSessions((v) => !v)}
className="flex-1 text-left text-sm font-semibold text-content hover:text-accent transition-colors"
>
AI Assistant
</button>
<button
onClick={handleNewChat}
className="p-1.5 rounded-md text-content-secondary hover:bg-surface-hover transition-colors"
title="New Chat"
>
<Plus size={16} />
</button>
<button
onClick={onClose}
className="p-1.5 rounded-md text-content-secondary hover:bg-surface-hover transition-colors"
title="Close"
>
<X size={16} />
</button>
</div>
{/* Session list dropdown */}
{showSessions && sessions && sessions.length > 0 && (
<div
className="border-b border-border-default max-h-48 overflow-y-auto"
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
>
{sessions.map((s) => (
<div
key={s.session_id}
onClick={() => handleSelectSession(s)}
className="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-surface-hover transition-colors"
style={s.session_id === sessionId ? { backgroundColor: 'var(--color-bg-accent-light)' } : undefined}
>
<MessageSquare size={14} className="text-content-muted shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-content truncate">{s.last_message}</p>
<p className="text-[10px] text-content-muted">
{s.message_count} messages
</p>
</div>
<button
onClick={(e) => handleDeleteSession(s.session_id, e)}
className="p-1 rounded text-content-muted hover:text-red-500 transition-colors shrink-0"
title="Delete session"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.length === 0 && !sendMut.isPending && (
<div className="flex flex-col items-center justify-center h-full text-content-muted">
<Bot size={40} className="mb-3 opacity-30" />
<p className="text-sm">Ask me anything about your orders, products, or renders.</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
style={{ animation: 'fadeIn 0.2s ease-out' }}
>
<div className="flex gap-2 max-w-[85%]">
{msg.role === 'assistant' && (
<div
className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1"
style={{ backgroundColor: 'var(--color-bg-accent-light)' }}
>
<Bot size={14} className="text-accent" />
</div>
)}
<div>
<div
className={`px-3 py-2 text-sm leading-relaxed ${
msg.role === 'user'
? 'rounded-2xl rounded-br-md'
: 'rounded-2xl rounded-bl-md'
}`}
style={
msg.role === 'user'
? { backgroundColor: 'var(--color-bg-accent)', color: 'var(--color-text-accent-text)' }
: { backgroundColor: 'var(--color-bg-surface-hover)', color: 'var(--color-text-content)' }
}
>
{msg.content}
</div>
<p className="text-[10px] text-content-muted mt-0.5 px-1">
{formatTime(msg.created_at)}
</p>
</div>
{msg.role === 'user' && (
<div
className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1"
style={{ backgroundColor: 'var(--color-bg-accent)' }}
>
<User size={14} style={{ color: 'var(--color-text-accent-text)' }} />
</div>
)}
</div>
</div>
))}
{/* Typing indicator */}
{sendMut.isPending && (
<div className="flex justify-start">
<div className="flex gap-2 items-center">
<div
className="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: 'var(--color-bg-accent-light)' }}
>
<Bot size={14} className="text-accent" />
</div>
<div
className="px-4 py-2.5 rounded-2xl rounded-bl-md flex gap-1"
style={{ backgroundColor: 'var(--color-bg-surface-hover)' }}
>
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 rounded-full bg-content-muted"
style={{
animation: `typingDot 1.4s ease-in-out ${i * 0.2}s infinite`,
}}
/>
))}
</div>
</div>
</div>
)}
{/* Error state */}
{sendMut.isError && (
<div className="flex justify-center">
<p className="text-xs text-red-500 bg-red-50 px-3 py-1.5 rounded-full">
Failed to send. Please try again.
</p>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="px-4 py-3 border-t border-border-default shrink-0">
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={sendMut.isPending}
className="flex-1 text-sm px-3 py-2 rounded-lg border border-border-default bg-surface text-content placeholder-content-muted focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={!input.trim() || sendMut.isPending}
className="p-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-40 transition-colors"
>
{sendMut.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</button>
</div>
</div>
</div>
)
}
+17 -1
View File
@@ -1,5 +1,5 @@
import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom'
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt, Server, Upload, Menu, X } from 'lucide-react'
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt, Server, Upload, Menu, X, MessageSquare } from 'lucide-react'
import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../../store/auth'
import { clsx } from 'clsx'
import { useState } from 'react'
@@ -7,6 +7,7 @@ import { useQuery } from '@tanstack/react-query'
import { getWorkerActivity } from '../../api/worker'
import { listOrders } from '../../api/orders'
import NotificationCenter from './NotificationCenter'
import ChatPanel from '../chat/ChatPanel'
const nav = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
@@ -36,6 +37,7 @@ export default function Layout() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [chatOpen, setChatOpen] = useState(false)
const { data: activity } = useQuery({
queryKey: ['worker-activity'],
@@ -245,6 +247,20 @@ export default function Layout() {
<main className="flex-1 overflow-auto min-w-0 pt-12 md:pt-0">
<Outlet />
</main>
{/* Chat floating button */}
{!chatOpen && (
<button
onClick={() => setChatOpen(true)}
className="fixed bottom-6 right-6 z-30 w-12 h-12 rounded-full bg-accent text-accent-text shadow-lg hover:bg-accent-hover hover:scale-105 transition-all flex items-center justify-center"
title="AI Assistant"
>
<MessageSquare size={22} />
</button>
)}
{/* Chat panel */}
{chatOpen && <ChatPanel open={chatOpen} onClose={() => setChatOpen(false)} />}
</div>
)
}