Public pages (Login, Register, Forgot/Reset Password, Verify Email, Survey Thank You) get descriptions for SEO. Authenticated pages (Dashboard, Flow Library, My Flows, Session History, AI Assistant, Account Settings, Step Library, My Shares, Feedback, Guides) get proper tab titles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { Sparkles, Send, Loader2, Flag } from 'lucide-react'
|
|
import { PageMeta } from '@/components/common/PageMeta'
|
|
import { assistantChatApi } from '@/api/assistantChat'
|
|
import { toast } from '@/lib/toast'
|
|
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
|
|
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
|
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
|
import type { ChatListItem, AssistantChatMessage as ChatMessageType, ConclusionOutcome } from '@/types/assistant-chat'
|
|
import type { SuggestedFlow } from '@/types/copilot'
|
|
|
|
interface MessageWithMeta extends ChatMessageType {
|
|
suggestedFlows?: SuggestedFlow[]
|
|
}
|
|
|
|
export default function AssistantChatPage() {
|
|
const [chats, setChats] = useState<ChatListItem[]>([])
|
|
const [activeChatId, setActiveChatId] = useState<string | null>(null)
|
|
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
|
const [input, setInput] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [showConclude, setShowConclude] = useState(false)
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
// Load chat list
|
|
useEffect(() => {
|
|
loadChats()
|
|
}, [])
|
|
|
|
// Auto-scroll
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
const loadChats = async () => {
|
|
try {
|
|
const list = await assistantChatApi.listChats(1, 100)
|
|
setChats(list)
|
|
} catch {
|
|
// silently handle
|
|
}
|
|
}
|
|
|
|
const selectChat = useCallback(async (chatId: string) => {
|
|
setActiveChatId(chatId)
|
|
try {
|
|
const chat = await assistantChatApi.getChat(chatId)
|
|
setMessages(chat.messages.map(m => ({ ...m })))
|
|
} catch {
|
|
setMessages([])
|
|
}
|
|
}, [])
|
|
|
|
const handleNewChat = async () => {
|
|
try {
|
|
const chat = await assistantChatApi.createChat()
|
|
setChats(prev => [
|
|
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
|
|
...prev,
|
|
])
|
|
setActiveChatId(chat.id)
|
|
setMessages([])
|
|
} catch {
|
|
toast.error('Failed to create chat')
|
|
}
|
|
}
|
|
|
|
const handleDeleteChat = async (chatId: string) => {
|
|
try {
|
|
await assistantChatApi.deleteChat(chatId)
|
|
setChats(prev => prev.filter(c => c.id !== chatId))
|
|
if (activeChatId === chatId) {
|
|
setActiveChatId(null)
|
|
setMessages([])
|
|
}
|
|
} catch {
|
|
toast.error('Failed to delete chat')
|
|
}
|
|
}
|
|
|
|
const handleTogglePin = async (chatId: string, pinned: boolean) => {
|
|
try {
|
|
await assistantChatApi.updateChat(chatId, { pinned })
|
|
setChats(prev =>
|
|
prev.map(c => c.id === chatId ? { ...c, pinned } : c)
|
|
)
|
|
} catch {
|
|
toast.error('Failed to update chat')
|
|
}
|
|
}
|
|
|
|
const handleSend = async () => {
|
|
if (!input.trim() || !activeChatId || loading) return
|
|
|
|
const userMessage = input.trim()
|
|
setInput('')
|
|
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
|
|
setLoading(true)
|
|
|
|
try {
|
|
const response = await assistantChatApi.sendMessage(activeChatId, userMessage)
|
|
setMessages(prev => [
|
|
...prev,
|
|
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
|
])
|
|
// Update chat list title if it was the first message
|
|
setChats(prev =>
|
|
prev.map(c =>
|
|
c.id === activeChatId
|
|
? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
|
|
: c
|
|
)
|
|
)
|
|
} catch {
|
|
setMessages(prev => [
|
|
...prev,
|
|
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again.' },
|
|
])
|
|
} finally {
|
|
setLoading(false)
|
|
requestAnimationFrame(() => inputRef.current?.focus())
|
|
}
|
|
}
|
|
|
|
const handleConclude = async (outcome: ConclusionOutcome, notes: string): Promise<string> => {
|
|
if (!activeChatId) throw new Error('No active chat')
|
|
const response = await assistantChatApi.concludeChat(activeChatId, { outcome, notes: notes || undefined })
|
|
// Update chat in sidebar to show concluded status
|
|
setChats(prev =>
|
|
prev.map(c =>
|
|
c.id === activeChatId
|
|
? { ...c, concluded_at: response.concluded_at, conclusion_outcome: outcome }
|
|
: c
|
|
)
|
|
)
|
|
return response.summary
|
|
}
|
|
|
|
const handleResumeNew = async (summary: string) => {
|
|
try {
|
|
const chat = await assistantChatApi.createChat()
|
|
setChats(prev => [
|
|
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
|
|
...prev,
|
|
])
|
|
setActiveChatId(chat.id)
|
|
setMessages([])
|
|
|
|
// Send the summary as the first message to prime the new chat
|
|
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
|
|
setInput('')
|
|
setMessages([{ role: 'user', content: resumePrompt }])
|
|
setLoading(true)
|
|
|
|
const response = await assistantChatApi.sendMessage(chat.id, resumePrompt)
|
|
setMessages(prev => [
|
|
...prev,
|
|
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
|
])
|
|
setChats(prev =>
|
|
prev.map(c =>
|
|
c.id === chat.id
|
|
? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
|
|
: c
|
|
)
|
|
)
|
|
} catch {
|
|
toast.error('Failed to create resume chat')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageMeta title="AI Assistant" />
|
|
<div className="flex h-[calc(100vh-3.5rem)]">
|
|
{/* Sidebar */}
|
|
<ChatSidebar
|
|
chats={chats}
|
|
activeChatId={activeChatId}
|
|
onSelectChat={selectChat}
|
|
onNewChat={handleNewChat}
|
|
onDeleteChat={handleDeleteChat}
|
|
onTogglePin={handleTogglePin}
|
|
/>
|
|
|
|
{/* Main chat area */}
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
{activeChatId ? (
|
|
<>
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
|
{messages.length === 0 && !loading && (
|
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
|
<Sparkles size={28} className="text-primary" />
|
|
</div>
|
|
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">
|
|
AI Assistant
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground max-w-md">
|
|
Ask me anything about IT infrastructure, networking, Active Directory,
|
|
cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{messages.map((msg, i) => (
|
|
<ChatMessage
|
|
key={i}
|
|
role={msg.role}
|
|
content={msg.content}
|
|
suggestedFlows={msg.suggestedFlows}
|
|
/>
|
|
))}
|
|
{loading && (
|
|
<div className="flex gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
|
<Sparkles size={14} className="text-primary" />
|
|
</div>
|
|
<div className="bg-white/[0.04] border border-brand-border rounded-2xl px-4 py-3">
|
|
<Loader2 size={16} className="animate-spin text-primary" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="px-6 py-4 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
|
<div className="max-w-3xl mx-auto">
|
|
<div className="flex items-end gap-3">
|
|
<textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={e => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Ask about IT, networking, troubleshooting..."
|
|
rows={3}
|
|
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-hidden focus:border-primary/30"
|
|
style={{ borderColor: 'var(--glass-border)' }}
|
|
disabled={loading}
|
|
/>
|
|
<div className="flex flex-col gap-2">
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!input.trim() || loading}
|
|
className="bg-gradient-brand text-brand-dark p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
|
title="Send message"
|
|
>
|
|
<Send size={18} />
|
|
</button>
|
|
{messages.length >= 2 && (
|
|
<button
|
|
onClick={() => setShowConclude(true)}
|
|
disabled={loading}
|
|
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
|
|
style={{ borderColor: 'var(--glass-border)' }}
|
|
title="Conclude session"
|
|
>
|
|
<Flag size={18} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
|
<Sparkles size={32} className="text-primary" />
|
|
</div>
|
|
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
|
|
AI Assistant
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
|
Your Senior Systems & Network Engineer. Ask anything about IT infrastructure,
|
|
or start a new chat to get personalized help with your team's flows.
|
|
</p>
|
|
<button
|
|
onClick={handleNewChat}
|
|
className="bg-gradient-brand text-brand-dark font-semibold text-sm rounded-[10px] px-6 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all"
|
|
>
|
|
Start a Conversation
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Conclude Session Modal */}
|
|
<ConcludeSessionModal
|
|
isOpen={showConclude}
|
|
onClose={() => setShowConclude(false)}
|
|
onConclude={handleConclude}
|
|
onResumeNew={handleResumeNew}
|
|
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
|
/>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|