feat(history): Implement conversation history management with artifact restoration and Markdown rendering.

This commit is contained in:
Yunxiao Xu
2026-02-13 01:59:37 -08:00
parent 339f69a2a3
commit dc6e73ec79
18 changed files with 2288 additions and 62 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"
import { ChatService, type ChatEvent } from "./chat"
import { describe, it, expect } from "vitest"
import { ChatService, type ChatEvent, type MessageResponse } from "./chat"
describe("ChatService SSE Parsing", () => {
it("should correctly parse a text stream chunk", () => {
@@ -42,26 +42,26 @@ describe("ChatService SSE Parsing", () => {
describe("ChatService Message State Management", () => {
it("should append text chunks to the last message content", () => {
const messages = [{ id: "1", role: "assistant", content: "Initial", created_at: new Date().toISOString() }]
const messages: MessageResponse[] = [{ id: "1", role: "assistant", content: "Initial", created_at: new Date().toISOString() }]
const event: ChatEvent = {
type: "on_chat_model_stream",
node: "summarizer",
data: { chunk: { content: " text" } }
}
const updatedMessages = ChatService.updateMessagesWithEvent(messages as any, event)
const updatedMessages = ChatService.updateMessagesWithEvent(messages, event)
expect(updatedMessages[0].content).toBe("Initial text")
})
it("should add plots to the message state", () => {
const messages = [{ id: "1", role: "assistant", content: "Analysis", created_at: new Date().toISOString(), plots: [] }]
const messages: MessageResponse[] = [{ id: "1", role: "assistant", content: "Analysis", created_at: new Date().toISOString(), plots: [] }]
const event: ChatEvent = {
type: "on_chain_end",
name: "executor",
data: { encoded_plots: ["plot1"] }
}
const updatedMessages = ChatService.updateMessagesWithEvent(messages as any, event)
const updatedMessages = ChatService.updateMessagesWithEvent(messages, event)
expect(updatedMessages[0].plots).toEqual(["plot1"])
})
})

View File

@@ -5,7 +5,8 @@ export interface MessageResponse {
role: "user" | "assistant"
content: string
created_at: string
plots?: string[] // base64 encoded plots
plots?: string[] // base64 encoded plots (from stream)
plot_ids?: string[] // plot IDs (from history)
steps?: string[] // reasoning steps
}
@@ -13,11 +14,17 @@ export interface ChatEvent {
type: string
name?: string
node?: string
data?: any
data?: {
chunk?: { content?: string }
output?: { messages?: { content: string }[] }
encoded_plots?: string[]
message?: string
}
}
export interface StreamCallbacks {
onMessageUpdate: (messages: MessageResponse[]) => void
onMessagesFinal?: (messages: MessageResponse[]) => void
onDone?: () => void
onError?: (error: string) => void
}
@@ -173,7 +180,7 @@ export const ChatService = {
currentMessages: MessageResponse[],
callbacks: StreamCallbacks
) {
const { onMessageUpdate, onDone, onError } = callbacks
const { onMessageUpdate, onMessagesFinal, onDone, onError } = callbacks
// Add user message and a placeholder assistant message
let activeMessages: MessageResponse[] = [
@@ -228,6 +235,7 @@ export const ChatService = {
for (const event of events) {
if (event.type === "done") {
if (onMessagesFinal) onMessagesFinal(activeMessages)
if (onDone) onDone()
continue
}
@@ -256,6 +264,15 @@ export const ChatService = {
return response.data
},
async renameConversation(conversationId: string, name: string) {
const response = await api.patch(`/conversations/${conversationId}`, { name })
return response.data
},
async deleteConversation(conversationId: string) {
await api.delete(`/conversations/${conversationId}`)
},
async getMessages(conversationId: string) {
const response = await api.get(`/conversations/${conversationId}/messages`)
return response.data

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import api from "./api"
import { ChatService } from "./chat"
vi.mock("./api")
const mockedApi = api as any
describe("ChatService History Management", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("should list conversations", async () => {
const mockData = [{ id: "c1", name: "Conv 1" }]
mockedApi.get.mockResolvedValueOnce({ data: mockData })
const result = await ChatService.listConversations()
expect(mockedApi.get).toHaveBeenCalledWith("/conversations")
expect(result).toEqual(mockData)
})
it("should create a conversation", async () => {
const mockData = { id: "c2", name: "New Conv" }
mockedApi.post.mockResolvedValueOnce({ data: mockData })
const result = await ChatService.createConversation("New Conv")
expect(mockedApi.post).toHaveBeenCalledWith("/conversations", { name: "New Conv" })
expect(result).toEqual(mockData)
})
it("should rename a conversation", async () => {
const mockData = { id: "c1", name: "Renamed" }
mockedApi.patch.mockResolvedValueOnce({ data: mockData })
const result = await ChatService.renameConversation("c1", "Renamed")
expect(mockedApi.patch).toHaveBeenCalledWith("/conversations/c1", { name: "Renamed" })
expect(result).toEqual(mockData)
})
it("should delete a conversation", async () => {
mockedApi.delete.mockResolvedValueOnce({})
await ChatService.deleteConversation("c1")
expect(mockedApi.delete).toHaveBeenCalledWith("/conversations/c1")
})
it("should fetch messages", async () => {
const mockData = [{ id: "m1", content: "Hi" }]
mockedApi.get.mockResolvedValueOnce({ data: mockData })
const result = await ChatService.getMessages("c1")
expect(mockedApi.get).toHaveBeenCalledWith("/conversations/c1/messages")
expect(result).toEqual(mockData)
})
})