feat(ux): Implement advanced reasoning view with progress tracking and refined Markdown rendering.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { MessageList } from "./MessageList"
|
||||
import { ChatInput } from "./ChatInput"
|
||||
import { ExecutionStatus } from "./ExecutionStatus"
|
||||
import { ChatService, type MessageResponse } from "@/services/chat"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
@@ -22,18 +23,17 @@ export function ChatInterface({
|
||||
const [isStreaming, setIsStreaming] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
|
||||
// Get steps from the currently active assistant message if streaming
|
||||
const activeSteps = isStreaming && messages.length > 0
|
||||
? messages[messages.length - 1].steps || []
|
||||
: []
|
||||
|
||||
// Sync messages if threadId or initialMessages changes
|
||||
React.useEffect(() => {
|
||||
console.log("ChatInterface: Syncing messages", initialMessages)
|
||||
setMessages(initialMessages)
|
||||
setError(null)
|
||||
}, [threadId, initialMessages])
|
||||
|
||||
// Log messages changes for debugging
|
||||
React.useEffect(() => {
|
||||
console.log("ChatInterface: Messages state updated", messages)
|
||||
}, [messages])
|
||||
|
||||
const handleSendMessage = async (text: string) => {
|
||||
setError(null)
|
||||
setIsStreaming(true)
|
||||
@@ -69,7 +69,7 @@ export function ChatInterface({
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background rounded-xl border shadow-lg overflow-hidden">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="m-4">
|
||||
<Alert variant="destructive" className="m-4 shrink-0">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
@@ -78,6 +78,12 @@ export function ChatInterface({
|
||||
|
||||
<MessageList messages={messages} />
|
||||
|
||||
{isStreaming && activeSteps.length > 0 && (
|
||||
<div className="px-4 pb-4 shrink-0">
|
||||
<ExecutionStatus steps={activeSteps} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
disabled={isStreaming}
|
||||
|
||||
64
frontend/src/components/chat/ExecutionStatus.tsx
Normal file
64
frontend/src/components/chat/ExecutionStatus.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Loader2Icon, CheckCircle2Icon } from "lucide-react"
|
||||
|
||||
interface ExecutionStatusProps {
|
||||
steps: string[]
|
||||
isComplete?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PHASE_CONFIG = [
|
||||
{ label: "Analyzing query...", match: "Query analysis complete." },
|
||||
{ label: "Generating strategic plan...", match: "Strategic plan generated." },
|
||||
{ label: "Writing analysis code...", match: "Analysis code generated." },
|
||||
{ label: "Performing data analysis...", match: "Data analysis and visualization complete." }
|
||||
]
|
||||
|
||||
export function ExecutionStatus({ steps, isComplete, className }: ExecutionStatusProps) {
|
||||
if (steps.length === 0) return null
|
||||
|
||||
// Calculate progress based on phase completion
|
||||
const completedCount = PHASE_CONFIG.filter(p => steps.includes(p.match)).length
|
||||
const totalPhases = PHASE_CONFIG.length
|
||||
const progress = Math.min((completedCount / totalPhases) * 100, 100)
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3 p-3 bg-muted/30 rounded-lg border border-border/50", className)}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-out"
|
||||
style={{ width: `${isComplete ? 100 : progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium tabular-nums opacity-60">
|
||||
{isComplete ? "100%" : `${Math.round(progress)}%`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{steps.map((step, index) => {
|
||||
const isLast = index === steps.length - 1
|
||||
const isCompletionStep = PHASE_CONFIG.some(p => p.match === step)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-start gap-2 transition-all duration-300",
|
||||
isLast && !isComplete ? "text-xs font-medium opacity-100" : "text-[10px] opacity-50"
|
||||
)}
|
||||
>
|
||||
{isLast && !isComplete && !isCompletionStep ? (
|
||||
<Loader2Icon className="h-3 w-3 mt-0.5 animate-spin text-primary" />
|
||||
) : (
|
||||
<CheckCircle2Icon className="h-3 w-3 mt-0.5 text-primary opacity-80" />
|
||||
)}
|
||||
<span className="leading-tight">{step}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,26 +24,6 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
: "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
{isAssistant && message.steps && message.steps.length > 0 && (
|
||||
<div className="mb-3 space-y-1 border-b border-secondary-foreground/10 pb-2">
|
||||
{message.steps.map((step, index) => {
|
||||
const isLast = index === message.steps!.length - 1
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 transition-all duration-300",
|
||||
isLast ? "text-xs font-medium opacity-80" : "text-[10px] opacity-40"
|
||||
)}
|
||||
>
|
||||
<div className={cn("rounded-full bg-current", isLast ? "h-1 w-1" : "h-0.5 w-0.5")} />
|
||||
{step}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="break-words text-sm">
|
||||
{isAssistant ? (
|
||||
message.content ? (
|
||||
|
||||
@@ -22,14 +22,14 @@ describe("ChatService SSE Parsing", () => {
|
||||
const events = ChatService.parseSSEChunk(rawChunk)
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events[1].data.chunk).toBe(" World")
|
||||
expect(events[1].data!.chunk).toBe(" World")
|
||||
})
|
||||
|
||||
it("should parse encoded plots from executor node", () => {
|
||||
const rawChunk = `data: {"type": "on_chain_end", "name": "executor", "data": {"encoded_plots": ["base64data"]}}\n\n`
|
||||
const events = ChatService.parseSSEChunk(rawChunk)
|
||||
|
||||
expect(events[0].data.encoded_plots).toEqual(["base64data"])
|
||||
expect(events[0].data!.encoded_plots).toEqual(["base64data"])
|
||||
})
|
||||
|
||||
it("should identify the done event", () => {
|
||||
|
||||
@@ -10,13 +10,26 @@ export interface MessageResponse {
|
||||
steps?: string[] // reasoning steps
|
||||
}
|
||||
|
||||
export interface ConversationResponse {
|
||||
id: string
|
||||
name: string
|
||||
summary?: string
|
||||
created_at: string
|
||||
data_state: string
|
||||
}
|
||||
|
||||
export interface ChatEvent {
|
||||
type: string
|
||||
name?: string
|
||||
node?: string
|
||||
data?: {
|
||||
chunk?: { content?: string }
|
||||
output?: { messages?: { content: string }[] }
|
||||
output?: {
|
||||
messages?: { content: string }[]
|
||||
plots?: string[]
|
||||
dfs?: Record<string, string>
|
||||
summary?: string
|
||||
}
|
||||
encoded_plots?: string[]
|
||||
message?: string
|
||||
}
|
||||
@@ -47,7 +60,7 @@ export const ChatService = {
|
||||
if (!dataStr) continue
|
||||
|
||||
try {
|
||||
const event = JSON.parse(dataStr)
|
||||
const event = JSON.parse(dataStr) as ChatEvent
|
||||
events.push(event)
|
||||
} catch (err) {
|
||||
console.error("Failed to parse SSE event JSON:", err, dataStr)
|
||||
@@ -245,36 +258,37 @@ export const ChatService = {
|
||||
}
|
||||
|
||||
activeMessages = this.updateMessagesWithEvent(activeMessages, event)
|
||||
onMessageUpdate(activeMessages)
|
||||
onMessageUpdate([...activeMessages])
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error("Streaming error:", err)
|
||||
if (onError) onError(err.message || "Connection failed")
|
||||
const message = err instanceof Error ? err.message : "Connection failed"
|
||||
if (onError) onError(message)
|
||||
}
|
||||
},
|
||||
|
||||
async listConversations() {
|
||||
const response = await api.get("/conversations")
|
||||
async listConversations(): Promise<ConversationResponse[]> {
|
||||
const response = await api.get<ConversationResponse[]>("/conversations")
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createConversation(name: string = "New Conversation") {
|
||||
const response = await api.post("/conversations", { name })
|
||||
async createConversation(name: string = "New Conversation"): Promise<ConversationResponse> {
|
||||
const response = await api.post<ConversationResponse>("/conversations", { name })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async renameConversation(conversationId: string, name: string) {
|
||||
const response = await api.patch(`/conversations/${conversationId}`, { name })
|
||||
async renameConversation(conversationId: string, name: string): Promise<ConversationResponse> {
|
||||
const response = await api.patch<ConversationResponse>(`/conversations/${conversationId}`, { name })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteConversation(conversationId: string) {
|
||||
async deleteConversation(conversationId: string): Promise<void> {
|
||||
await api.delete(`/conversations/${conversationId}`)
|
||||
},
|
||||
|
||||
async getMessages(conversationId: string) {
|
||||
const response = await api.get(`/conversations/${conversationId}/messages`)
|
||||
async getMessages(conversationId: string): Promise<MessageResponse[]> {
|
||||
const response = await api.get<MessageResponse[]>(`/conversations/${conversationId}/messages`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import api from "./api"
|
||||
import { ChatService } from "./chat"
|
||||
import { AxiosResponse } from "axios"
|
||||
|
||||
vi.mock("./api")
|
||||
const mockedApi = api as any
|
||||
const mockedApi = vi.mocked(api)
|
||||
|
||||
describe("ChatService History Management", () => {
|
||||
beforeEach(() => {
|
||||
@@ -12,7 +13,7 @@ describe("ChatService History Management", () => {
|
||||
|
||||
it("should list conversations", async () => {
|
||||
const mockData = [{ id: "c1", name: "Conv 1" }]
|
||||
mockedApi.get.mockResolvedValueOnce({ data: mockData })
|
||||
mockedApi.get.mockResolvedValueOnce({ data: mockData } as AxiosResponse)
|
||||
|
||||
const result = await ChatService.listConversations()
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/conversations")
|
||||
@@ -21,7 +22,7 @@ describe("ChatService History Management", () => {
|
||||
|
||||
it("should create a conversation", async () => {
|
||||
const mockData = { id: "c2", name: "New Conv" }
|
||||
mockedApi.post.mockResolvedValueOnce({ data: mockData })
|
||||
mockedApi.post.mockResolvedValueOnce({ data: mockData } as AxiosResponse)
|
||||
|
||||
const result = await ChatService.createConversation("New Conv")
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/conversations", { name: "New Conv" })
|
||||
@@ -30,7 +31,7 @@ describe("ChatService History Management", () => {
|
||||
|
||||
it("should rename a conversation", async () => {
|
||||
const mockData = { id: "c1", name: "Renamed" }
|
||||
mockedApi.patch.mockResolvedValueOnce({ data: mockData })
|
||||
mockedApi.patch.mockResolvedValueOnce({ data: mockData } as AxiosResponse)
|
||||
|
||||
const result = await ChatService.renameConversation("c1", "Renamed")
|
||||
expect(mockedApi.patch).toHaveBeenCalledWith("/conversations/c1", { name: "Renamed" })
|
||||
@@ -38,7 +39,7 @@ describe("ChatService History Management", () => {
|
||||
})
|
||||
|
||||
it("should delete a conversation", async () => {
|
||||
mockedApi.delete.mockResolvedValueOnce({})
|
||||
mockedApi.delete.mockResolvedValueOnce({} as AxiosResponse)
|
||||
|
||||
await ChatService.deleteConversation("c1")
|
||||
expect(mockedApi.delete).toHaveBeenCalledWith("/conversations/c1")
|
||||
@@ -46,7 +47,7 @@ describe("ChatService History Management", () => {
|
||||
|
||||
it("should fetch messages", async () => {
|
||||
const mockData = [{ id: "m1", content: "Hi" }]
|
||||
mockedApi.get.mockResolvedValueOnce({ data: mockData })
|
||||
mockedApi.get.mockResolvedValueOnce({ data: mockData } as AxiosResponse)
|
||||
|
||||
const result = await ChatService.getMessages("c1")
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/conversations/c1/messages")
|
||||
|
||||
Reference in New Issue
Block a user