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:
@@ -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}`)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user