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 * as React from 'react'
|
||||||
import { TopBar } from './TopBar'
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-canvas flex flex-col">
|
<div className="min-h-screen bg-canvas flex flex-col">
|
||||||
<TopBar />
|
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 { Link } from '@tanstack/react-router'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
|
@ -9,6 +9,12 @@ export function TopBar() {
|
||||||
HWLab
|
HWLab
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link to="/test">
|
<Link to="/test">
|
||||||
<Cable className="w-4 h-4 mr-1.5" />
|
<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 IntakePage = lazy(() => import('./pages/IntakePage').then((m) => ({ default: m.IntakePage })))
|
||||||
const ScanPage = lazy(() => import('./pages/ScanPage').then((m) => ({ default: m.ScanPage })))
|
const ScanPage = lazy(() => import('./pages/ScanPage').then((m) => ({ default: m.ScanPage })))
|
||||||
const CableTestPage = lazy(() => import('./pages/CableTestPage').then((m) => ({ default: m.CableTestPage })))
|
const CableTestPage = lazy(() => import('./pages/CableTestPage').then((m) => ({ default: m.CableTestPage })))
|
||||||
|
const AdvisorPage = lazy(() => import('./pages/AdvisorPage').then((m) => ({ default: m.AdvisorPage })))
|
||||||
|
|
||||||
const Spinner = () => (
|
const Spinner = () => (
|
||||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
<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({
|
export const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue