erDiagram
conversations {
UUID id PK "Unique conversation ID"
VARCHAR user_ref "Backstage user entity ref (e.g., user:default/guest)"
VARCHAR title "Optional conversation title"
TIMESTAMPTZ created_at "Timestamp when created"
TIMESTAMPTZ updated_at "Timestamp of last message or update"
}
messages {
UUID id PK "Unique message ID"
UUID conversation_id FK "Links to conversations.id"
VARCHAR(10) sender_type "Either 'user' or 'ai'"
TEXT content "The actual text content of the message"
JSONB metadata "Optional: Stores RAG context, sources, etc. for AI messages"
TIMESTAMPTZ timestamp "Timestamp when message was created"
}
message_feedback {
UUID id PK "Unique feedback record ID"
UUID message_id FK "Links to messages.id (the message being rated)"
VARCHAR user_ref "Backstage user entity ref giving feedback"
VARCHAR(10) feedback_type "Either 'up' or 'down'"
TEXT feedback_comment "Optional textual comment with feedback"
TEXT rated_message_content "Denormalized: Copy of the message content when feedback was given"
JSONB rag_context "Denormalized: Copy of the RAG context used for the message when feedback was given"
TIMESTAMPTZ created_at "Timestamp when feedback was first given"
TIMESTAMPTZ updated_at "Timestamp when feedback was last updated"
}
conversations ||--o{ messages : "has"
messages ||--o{ message_feedback : "receives feedback on"
%% Note: UNIQUE constraint on (message_id, user_ref) should be applied in the actual DB schema
这个设计的核心目标是:
- 持久化存储:将用户的对话(包括用户输入和 AI 回复)保存下来。
- 可视化列表:在聊天界面旁边或合适的位置展示历史对话列表。
- 加载与切换:用户可以点击列表中的某一项,加载该对话的完整内容到聊天窗口。
- 新建对话:用户可以随时发起新的对话,新对话会自动出现在历史列表中。
- (可选)管理:提供删除历史对话的功能。
- (可选)命名:允许用户或系统自动为对话命名。
我们将从前端、后端和 API 三个层面来设计。
- 前端 (Frontend - React + TypeScript):
ChatHistorySidebar: 一个侧边栏组件,用于展示对话历史列表,处理新建对话、选择对话、删除对话等操作。ConversationListItem:ChatHistorySidebar中的列表项,显示对话的摘要信息(如标题或首句、时间戳)并响应点击事件。ChatWindow: 现有的聊天窗口组件,需要改造以支持加载不同conversationId的消息记录,并能在发送新消息时关联到当前conversationId。- 状态管理 (State Management): 需要管理
conversationList、currentConversationId、messages等状态。可以使用 React Context API、Zustand 或 Redux Toolkit。
- 后端 (Backend - Backstage Backend Plugin + TypeScript):
- 存储层 (Storage): 需要一个地方存储对话数据。推荐使用 Backstage 数据库 (
@backstage/plugin-db-manager),你可以定义自己的表结构来存储对话和消息。备选方案是外部数据库或简单的文件存储(不推荐用于生产)。 - 服务层 (Service): 处理来自前端的请求,如获取历史列表、获取特定对话消息、保存新消息、删除对话等。
- 存储层 (Storage): 需要一个地方存储对话数据。推荐使用 Backstage 数据库 (
- API (Backstage API):
- 定义清晰的 RESTful API 接口供前端调用,用于历史记录的增删查改。
如果你使用关系型数据库(如 PostgreSQL,通常与 Backstage 搭配),可以设计如下表结构:
conversations表: 存储每个独立的对话会话。-- Example for PostgreSQL CREATE TABLE conversations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Unique ID for the conversation user_ref VARCHAR(255) NOT NULL, -- Reference to the Backstage user (e.g., user entity ref) title VARCHAR(255) DEFAULT 'New Chat', -- Optional title for the conversation created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Index for faster lookup by user CREATE INDEX idx_conversations_user_ref ON conversations(user_ref); CREATE INDEX idx_conversations_updated_at ON conversations(updated_at);
messages表: 存储每次对话中的具体消息。-- Example for PostgreSQL CREATE TABLE messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Unique ID for the message conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, -- Link to the conversation sender_type VARCHAR(10) NOT NULL CHECK (sender_type IN ('user', 'ai')), -- Who sent the message content TEXT NOT NULL, -- The actual message text metadata JSONB, -- Optional: Store RAG context, sources, etc. timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Index for faster lookup by conversation and ordering CREATE INDEX idx_messages_conversation_id_timestamp ON messages(conversation_id, timestamp);
-
ChatHistorySidebar.tsx- 使用
@backstage/core-plugin-api的WorkspaceApi调用后端 API 获取对话列表 (/api/rag-chat/history)。 - 使用
useState或状态管理库存储conversations列表。 - 渲染
ConversationListItem列表。 - 包含一个 "New Chat" / "新建对话" 按钮,点击时将
currentConversationId设置为null或一个特殊值,并清空ChatWindow的消息。 - 提供删除按钮,调用后端 API 删除对话 (
DELETE /api/rag-chat/conversations/{id}) 并更新列表。
// Example Snippet for ChatHistorySidebar.tsx import React, { useState, useEffect, useCallback } from 'react'; import { List, ListItem, ListItemText, IconButton, Button } from '@material-ui/core'; import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; import DeleteIcon from '@material-ui/icons/Delete'; import { useApi, fetchApiRef } from '@backstage/core-plugin-api'; type ConversationMeta = { id: string; title: string; updated_at: string; }; type ChatHistorySidebarProps = { onSelectConversation: (id: string | null) => void; // Callback when a conversation is selected or new chat is clicked currentConversationId: string | null; }; export const ChatHistorySidebar = ({ onSelectConversation, currentConversationId }: ChatHistorySidebarProps) => { const [conversations, setConversations] = useState<ConversationMeta[]>([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const fetchApi = useApi(fetchApiRef); const fetchHistory = useCallback(async () => { setIsLoading(true); setError(null); try { const response = await fetchApi.fetch('/api/rag-chat/history'); // Adjust API path if (!response.ok) { throw new Error(`Failed to fetch history: ${response.statusText}`); } const data = await response.json(); setConversations(data.conversations || []); // Assuming API returns { conversations: [...] } } catch (e: any) { setError(e); } finally { setIsLoading(false); } }, [fetchApi]); useEffect(() => { fetchHistory(); }, [fetchHistory]); const handleDelete = async (id: string, event: React.MouseEvent) => { event.stopPropagation(); // Prevent selection when clicking delete if (window.confirm('Are you sure you want to delete this conversation?')) { try { const response = await fetchApi.fetch(`/api/rag-chat/conversations/${id}`, { method: 'DELETE' }); // Adjust API path if (!response.ok) { throw new Error(`Failed to delete conversation: ${response.statusText}`); } // Refresh list or remove locally setConversations(prev => prev.filter(conv => conv.id !== id)); // If deleting the current conversation, select "New Chat" if (id === currentConversationId) { onSelectConversation(null); } } catch (e: any) { console.error("Deletion failed:", e); // Show error to user } } }; const handleNewChat = () => { onSelectConversation(null); }; if (isLoading) return <div>Loading history...</div>; if (error) return <div>Error loading history: {error.message}</div>; return ( <div> <Button variant="contained" color="primary" startIcon={<AddCircleOutlineIcon />} onClick={handleNewChat} fullWidth style={{ marginBottom: '10px' }} > New Chat </Button> <List component="nav" aria-label="conversation history"> {conversations.map((conv) => ( <ListItem button key={conv.id} selected={conv.id === currentConversationId} onClick={() => onSelectConversation(conv.id)} > <ListItemText primary={conv.title || `Chat from ${new Date(conv.updated_at).toLocaleString()}`} // secondary={`Last updated: ${new Date(conv.updated_at).toLocaleString()}`} /> <IconButton edge="end" aria-label="delete" onClick={(e) => handleDelete(conv.id, e)}> <DeleteIcon /> </IconButton> </ListItem> ))} </List> </div> ); };
- 使用
-
ChatWindow.tsx- 接收
currentConversationId: string | null作为 prop。 - 当
currentConversationId改变时:- 如果
currentConversationId是null,清空消息列表。 - 如果
currentConversationId是一个有效的 ID,调用 API (GET /api/rag-chat/conversations/{id}) 获取该对话的所有消息并显示。
- 如果
- 当用户发送消息时:
- 如果
currentConversationId是null,表示这是一个新对话的第一条消息。后端在处理这条消息时,应先创建一个新的conversation记录,并将conversation_id返回给前端,或者前端在发送第一条消息前先调用创建接口。之后的消息使用这个新的conversationId。 - 如果
currentConversationId已存在,将消息和conversationId一起发送到后端处理接口(可能是你现有的 RAG 处理接口,但需要增加conversationId参数)。 - 后端保存用户消息和 AI 回复时,都需要关联到对应的
conversation_id。
- 如果
- 接收
-
Container Component (e.g.,
ChatPage.tsx)- 管理
currentConversationId状态。 - 渲染
ChatHistorySidebar和ChatWindow。 - 将
currentConversationId传递给两个子组件。 - 提供
handleSelectConversation回调给ChatHistorySidebar来更新currentConversationId。
// Example Snippet for ChatPage.tsx (Container) import React, { useState } from 'react'; import { Grid, Paper } from '@material-ui/core'; import { ChatHistorySidebar } from './ChatHistorySidebar'; import { ChatWindow } from './ChatWindow'; // Assuming ChatWindow exists export const ChatPage = () => { const [currentConversationId, setCurrentConversationId] = useState<string | null>(null); const handleSelectConversation = (id: string | null) => { setCurrentConversationId(id); }; return ( <Grid container spacing={2} style={{ height: 'calc(100vh - 64px)', padding: '10px' }}> {/* Adjust height based on header */} <Grid item xs={3} style={{ height: '100%', overflowY: 'auto' }}> <Paper style={{ height: '100%', padding: '10px' }}> <ChatHistorySidebar onSelectConversation={handleSelectConversation} currentConversationId={currentConversationId} /> </Paper> </Grid> <Grid item xs={9} style={{ height: '100%' }}> <Paper style={{ height: '100%', display: 'flex', flexDirection: 'column' }}> {/* Ensure ChatWindow takes conversationId and handles message loading/sending */} <ChatWindow conversationId={currentConversationId} /> </Paper> </Grid> </Grid> ); };
- 管理
- 创建一个新的 Backend Plugin 或在你现有的 RAG 相关插件中添加功能。
- Router Setup: 使用 Express Router 定义 API 路由。
// Example Backend Router Snippet (in createRouter function of your plugin) import { LoggerService, DatabaseService } from '@backstage/backend-plugin-api'; import { IdentityApi } from '@backstage/plugin-auth-node'; import express from 'express'; import Router from 'express-promise-router'; // Import your database interaction logic (e.g., DbConversationStore) export interface RouterOptions { logger: LoggerService; database: DatabaseService; identity: IdentityApi; // To get current user // Add other dependencies like your RAG service client } export async function createRouter( options: RouterOptions, ): Promise<express.Router> { const { logger, database, identity } = options; // const dbStore = new DbConversationStore(await database.getClient()); // Your data access layer const router = Router(); router.use(express.json()); // Middleware to get user identity (optional but recommended) router.use(async (req, res, next) => { const user = await identity.getIdentity({ request: req }); // Attach user to request or handle unauthorized access if (!user) { res.status(401).json({ error: 'Unauthorized' }); return; } (req as any).user = user; // Or store userRef appropriately next(); }); // GET /history - List conversations for the current user router.get('/history', async (req, res) => { const userRef = (req as any).user.identity.userEntityRef; // const conversations = await dbStore.listConversations(userRef); // Mock data for example: const conversations = [ { id: 'uuid-1', title: 'Kube Deployment Issue', updated_at: new Date().toISOString() }, { id: 'uuid-2', title: 'React Best Practices', updated_at: new Date(Date.now() - 3600000).toISOString() }, ]; res.json({ conversations }); }); // GET /conversations/:id - Get messages for a specific conversation router.get('/conversations/:id', async (req, res) => { const { id } = req.params; const userRef = (req as any).user.identity.userEntityRef; // Add logic to verify user owns this conversation before fetching // const messages = await dbStore.getMessages(id, userRef); // Mock data for example: const messages = [ { id: 'msg-1', sender_type: 'user', content: 'Help me with k8s', timestamp: new Date().toISOString() }, { id: 'msg-2', sender_type: 'ai', content: 'Sure, what is the issue?', timestamp: new Date().toISOString() }, ]; res.json({ messages }); }); // POST /conversations/:id/messages - Add a message (likely modifies your existing RAG endpoint) // Your existing RAG endpoint needs modification: // 1. Accept `conversationId` (optional, if null, create a new one). // 2. Save user message to DB associated with `conversationId`. // 3. Call RAG logic. // 4. Save AI response to DB associated with `conversationId`. // 5. Update conversation's `updated_at`. // 6. Return AI response (and maybe the `conversationId` if it was newly created). // DELETE /conversations/:id - Delete a conversation router.delete('/conversations/:id', async (req, res) => { const { id } = req.params; const userRef = (req as any).user.identity.userEntityRef; // Add logic to verify user owns this conversation // await dbStore.deleteConversation(id, userRef); logger.info(`User ${userRef} deleted conversation ${id}`); res.status(204).send(); // No Content }); // Error Handling Middleware (add at the end) router.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { logger.error("API Error:", err); res.status(500).json({ error: 'Internal Server Error' }); }); return router; }
GET /api/rag-chat/history: 获取当前用户的对话历史列表 (返回[{id, title, updated_at}, ...])。GET /api/rag-chat/conversations/:id: 获取指定对话的所有消息 (返回[{id, sender_type, content, timestamp, metadata}, ...])。POST /api/rag-chat/conversations/:id/messages(或你的 RAG 交互端点): 发送消息到指定对话。如果:id是特殊值(如 'new')或 body 中conversationId为空,则创建新对话。DELETE /api/rag-chat/conversations/:id: 删除指定对话。
- Frontend Plugin:
- 在你的 RAG 聊天插件中添加
ChatHistorySidebar和修改ChatWindow。 - 确保使用
@backstage/core-plugin-api的useApi(fetchApiRef)来进行 API 调用,这样可以利用 Backstage 的认证和代理。 - 将新的组件添加到插件的导出和
App.tsx或相关路由中。
- 在你的 RAG 聊天插件中添加
- Backend Plugin:
- 创建或修改后端插件以包含上述
createRouter逻辑。 - 在
packages/backend/src/index.ts(或你的plugins.ts) 中设置和挂载你的插件路由,并传入所需的依赖(logger,database,identity)。 - 确保你的插件依赖
@backstage/plugin-auth-node(获取用户信息) 和@backstage/plugin-db-manager(如果使用 Backstage 数据库)。
- 创建或修改后端插件以包含上述
- 语言: TypeScript (符合要求)
- 包管理器: pnpm (符合要求)
- 使用
pnpm add <package-name>在相应的前端/后端插件包中添加依赖。 - 使用
pnpm install在根目录安装所有依赖。 - 使用
pnpm --filter <your-plugin-package-name> build来构建特定插件。
- 使用
- 后端:
- 设计并实现数据库表结构(如果使用数据库)。
- 在后端插件中创建数据访问层 (DAL) 来封装数据库操作。
- 实现 API 路由 (
createRouter),处理 CRUD 操作。特别注意修改现有的消息处理逻辑以包含conversationId。 - 集成 IdentityApi 获取用户信息。
- 前端:
- 创建
ChatHistorySidebar组件。 - 创建
ConversationListItem组件。 - 修改
ChatWindow组件以支持加载和发送带有conversationId的消息。 - 创建容器组件 (
ChatPage) 来协调状态和布局。 - 实现 API 调用逻辑 (使用
WorkspaceApi)。 - 集成状态管理。
- 创建
- 集成与测试:
- 在 Backstage App 中正确挂载前端和后端插件。
- 进行端到端测试:新建对话、发送多条消息、切换到旧对话、加载历史、删除对话。
- 用户身份: 确保后端能正确识别当前用户 (通过
IdentityApi),以便只显示和操作该用户的对话历史。 - 性能: 如果对话历史非常多,考虑在
GET /historyAPI 中加入分页。 - 错误处理: 在前端和后端都添加健壮的错误处理和用户反馈。
- 标题生成: 可以考虑自动生成对话标题(例如,使用第一条用户消息的前 N 个词),或者允许用户稍后编辑标题。
- RAG Context: 当加载旧对话并继续提问时,是否需要将之前的对话内容作为上下文提供给 RAG 模型?这需要在后端处理消息时考虑。可能需要将历史消息(或其摘要)传给 RAG 后端。
- UI/UX: 考虑侧边栏的响应式设计,以及加载状态、空状态(没有历史记录时)的显示。