feat(06-03): add AdvisorPage, /advisor route, and TopBar link
- AdvisorPage: two-panel layout (sidebar + chat), streaming SSE tokens - Sidebar: conversation list with refetchInterval 5s, New Chat button - Chat: optimistic user messages, streaming cursor, model dropdown - router.tsx: lazy AdvisorPage import + advisorRoute at /advisor - TopBar.tsx: MessageSquare icon + Advisor link between Test and Scan - AppShell.tsx: noPadding prop for full-height layouts
This commit is contained in:
parent
811223ddf7
commit
bcc360892c
4 changed files with 315 additions and 4 deletions
|
|
@ -1,11 +1,17 @@
|
|||
import * as React from 'react'
|
||||
import { TopBar } from './TopBar'
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode
|
||||
/** When true, removes default padding/max-width from the main content area */
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
export function AppShell({ children, noPadding = false }: AppShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas flex flex-col">
|
||||
<TopBar />
|
||||
<main className="flex-1 px-4 py-6 max-w-7xl mx-auto w-full">
|
||||
<main className={noPadding ? 'flex-1 flex flex-col overflow-hidden' : 'flex-1 px-4 py-6 max-w-7xl mx-auto w-full'}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Plus, QrCode, Cable } from 'lucide-react'
|
||||
import { Plus, QrCode, Cable, MessageSquare } from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
|
|
@ -9,6 +9,12 @@ export function TopBar() {
|
|||
HWLab
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/advisor">
|
||||
<MessageSquare className="w-4 h-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">Advisor</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/test">
|
||||
<Cable className="w-4 h-4 mr-1.5" />
|
||||
|
|
|
|||
288
web/src/pages/AdvisorPage.tsx
Normal file
288
web/src/pages/AdvisorPage.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Send, MessageSquare, Plus } from 'lucide-react'
|
||||
import { AppShell } from '@/components/layout/AppShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
streamChat,
|
||||
fetchConversations,
|
||||
fetchConversation,
|
||||
type ChatMessage,
|
||||
type ConversationSummary,
|
||||
} from '@/api/advisor'
|
||||
|
||||
const AVAILABLE_MODELS = [
|
||||
'anthropic/claude-opus-4',
|
||||
'anthropic/claude-sonnet-4-5',
|
||||
'anthropic/claude-3-5-haiku',
|
||||
'openai/gpt-4o',
|
||||
]
|
||||
|
||||
const DEFAULT_MODEL = 'anthropic/claude-opus-4'
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
export function AdvisorPage() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [currentConversationId, setCurrentConversationId] = useState<string | undefined>()
|
||||
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL)
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedSidebarId, setSelectedSidebarId] = useState<string | undefined>()
|
||||
|
||||
// Use a ref to capture latest streamingContent inside async callback
|
||||
const streamingContentRef = useRef('')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const conversationsQuery = useQuery({
|
||||
queryKey: ['advisor-conversations'],
|
||||
queryFn: fetchConversations,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const conversationQuery = useQuery({
|
||||
queryKey: ['advisor-conversation', selectedSidebarId],
|
||||
queryFn: () => fetchConversation(selectedSidebarId!),
|
||||
enabled: !!selectedSidebarId,
|
||||
})
|
||||
|
||||
// Load messages when a sidebar conversation is selected
|
||||
useEffect(() => {
|
||||
if (conversationQuery.data && selectedSidebarId) {
|
||||
setMessages(conversationQuery.data.messages)
|
||||
setCurrentConversationId(selectedSidebarId)
|
||||
setStreamingContent('')
|
||||
}
|
||||
}, [conversationQuery.data, selectedSidebarId])
|
||||
|
||||
// Scroll to bottom when messages or streaming content changes
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streamingContent])
|
||||
|
||||
function handleNewChat() {
|
||||
setMessages([])
|
||||
setCurrentConversationId(undefined)
|
||||
setStreamingContent('')
|
||||
streamingContentRef.current = ''
|
||||
setInput('')
|
||||
setSelectedSidebarId(undefined)
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
function handleSelectConversation(conv: ConversationSummary) {
|
||||
if (isStreaming) return
|
||||
setSelectedSidebarId(conv.id)
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed || isStreaming) return
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
setInput('')
|
||||
setIsStreaming(true)
|
||||
setStreamingContent('')
|
||||
streamingContentRef.current = ''
|
||||
|
||||
await streamChat(
|
||||
{
|
||||
conversationId: currentConversationId,
|
||||
message: trimmed,
|
||||
model: selectedModel,
|
||||
},
|
||||
(token, convId) => {
|
||||
streamingContentRef.current += token
|
||||
setStreamingContent(streamingContentRef.current)
|
||||
setCurrentConversationId(convId)
|
||||
},
|
||||
() => {
|
||||
// onDone — finalize assistant message
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: streamingContentRef.current,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantMessage])
|
||||
setStreamingContent('')
|
||||
streamingContentRef.current = ''
|
||||
setIsStreaming(false)
|
||||
// Refresh sidebar conversation list
|
||||
conversationsQuery.refetch().catch(() => {})
|
||||
},
|
||||
(err) => {
|
||||
toast.error(err)
|
||||
setIsStreaming(false)
|
||||
setStreamingContent('')
|
||||
streamingContentRef.current = ''
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const allMessages = messages
|
||||
|
||||
return (
|
||||
<AppShell noPadding>
|
||||
<div className="flex h-[calc(100vh-57px)]">
|
||||
{/* Left sidebar */}
|
||||
<aside className="hidden lg:flex flex-col w-[280px] flex-shrink-0 border-r border-charcoal/60 bg-[#0a0a0a] overflow-hidden">
|
||||
<div className="p-4 border-b border-charcoal/60">
|
||||
<h2 className="text-volt font-bold text-lg mb-3">Lab Advisor</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{conversationsQuery.isLoading && (
|
||||
<p className="text-[#666] text-xs text-center py-4">Loading...</p>
|
||||
)}
|
||||
{conversationsQuery.data?.length === 0 && (
|
||||
<p className="text-[#666] text-xs text-center py-4 px-3">
|
||||
No conversations yet
|
||||
</p>
|
||||
)}
|
||||
{conversationsQuery.data?.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => handleSelectConversation(conv)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-charcoal/30 hover:bg-[#111] transition-colors ${
|
||||
selectedSidebarId === conv.id ? 'bg-[#1a1a1a]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MessageSquare className="w-3 h-3 text-volt flex-shrink-0" />
|
||||
<span className="text-[#e0e0e0] text-xs font-medium truncate">
|
||||
{conv.model.split('/').pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[#666] text-xs">
|
||||
{conv.message_count} msg{conv.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="text-[#555] text-xs">
|
||||
{formatRelativeTime(conv.started_at)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main chat panel */}
|
||||
<main className="flex flex-col flex-1 min-w-0">
|
||||
{/* Messages area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{allMessages.length === 0 && !streamingContent && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
|
||||
<MessageSquare className="w-12 h-12 text-[#333]" />
|
||||
<p className="text-[#666] text-sm">Ask anything about your homelab</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg p-3 max-w-[80%] text-sm whitespace-pre-wrap break-words ${
|
||||
msg.role === 'user'
|
||||
? 'bg-[#1a1a1a] text-white'
|
||||
: 'bg-[#111] text-[#e0e0e0]'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Streaming assistant message */}
|
||||
{streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg p-3 max-w-[80%] text-sm whitespace-pre-wrap break-words bg-[#111] text-[#e0e0e0]">
|
||||
{streamingContent}
|
||||
<span className="inline-block w-0.5 h-4 bg-volt ml-0.5 animate-pulse align-text-bottom" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input row */}
|
||||
<div className="border-t border-charcoal/60 p-4 flex gap-2 items-end">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={isStreaming}
|
||||
className="bg-[#111] border border-charcoal/60 text-[#e0e0e0] text-xs rounded-md px-2 py-2 focus:outline-none focus:border-volt/50 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m.split('/').pop()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={2}
|
||||
disabled={isStreaming}
|
||||
placeholder="Ask about your homelab..."
|
||||
className="flex-1 bg-[#111] border border-charcoal/60 text-white text-sm rounded-md px-3 py-2 resize-none focus:outline-none focus:border-volt/50 placeholder-[#555] disabled:opacity-50"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="forest"
|
||||
size="sm"
|
||||
onClick={handleSend}
|
||||
disabled={isStreaming || !input.trim()}
|
||||
className="shrink-0 self-end"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ const ItemDetailPage = lazy(() => import('./pages/ItemDetailPage').then((m) => (
|
|||
const IntakePage = lazy(() => import('./pages/IntakePage').then((m) => ({ default: m.IntakePage })))
|
||||
const ScanPage = lazy(() => import('./pages/ScanPage').then((m) => ({ default: m.ScanPage })))
|
||||
const CableTestPage = lazy(() => import('./pages/CableTestPage').then((m) => ({ default: m.CableTestPage })))
|
||||
const AdvisorPage = lazy(() => import('./pages/AdvisorPage').then((m) => ({ default: m.AdvisorPage })))
|
||||
|
||||
const Spinner = () => (
|
||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||
|
|
@ -74,7 +75,17 @@ const cableTestRoute = createRoute({
|
|||
),
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute, cableTestRoute])
|
||||
const advisorRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/advisor',
|
||||
component: () => (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<AdvisorPage />
|
||||
</Suspense>
|
||||
),
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute, cableTestRoute, advisorRoute])
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue