feat: 工作台聊天室功能
This commit is contained in:
132
src/web/consoles/workbench/components/chat/ChatPanel.tsx
Normal file
132
src/web/consoles/workbench/components/chat/ChatPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { BubbleItemType } from "@ant-design/x";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { Bubble, Sender } from "@ant-design/x";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { App, Empty, Spin } from "antd";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { fetchMessages } from "../../../../hooks/use-conversations";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
|
||||
interface ChatPanelProps {
|
||||
conversationId: null | string;
|
||||
onConversationCreated: (id: string) => void;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
const { message } = App.useApp();
|
||||
const [input, setInput] = useState("");
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const fetchRef = useRef(fetchMessages);
|
||||
|
||||
const conversationIdRef = useRef(conversationId);
|
||||
useEffect(() => {
|
||||
conversationIdRef.current = conversationId;
|
||||
});
|
||||
|
||||
const { messages, sendMessage, setMessages, status } = useChat({
|
||||
onError: (err) => {
|
||||
void message.error(`发送失败:${err.message}`);
|
||||
},
|
||||
transport: new DefaultChatTransport({
|
||||
api: `/api/projects/${projectId}/chat`,
|
||||
}),
|
||||
});
|
||||
|
||||
const isLoading = status === "submitted" || status === "streaming";
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setLoadingHistory(true);
|
||||
setMessages([]);
|
||||
try {
|
||||
const data = await fetchRef.current(projectId, conversationId);
|
||||
if (cancelled) return;
|
||||
const history = data.items
|
||||
.filter((m: { role: string }) => m.role === "user" || m.role === "assistant")
|
||||
.reverse()
|
||||
.map((m: { content: string; id: string; role: string }) => ({
|
||||
id: m.id,
|
||||
parts: [{ text: m.content, type: "text" as const }],
|
||||
role: m.role as "assistant" | "user",
|
||||
}));
|
||||
setMessages(history);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
void message.error(`加载历史失败:${msg}`);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId, projectId, setMessages, message]);
|
||||
|
||||
const bubbleItems: BubbleItemType[] = messages.map((msg) => ({
|
||||
content: msg.parts
|
||||
.filter((p): p is { text: string; type: "text" } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join(""),
|
||||
key: msg.id,
|
||||
role: msg.role === "user" ? "user" : "ai",
|
||||
}));
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(nextInput: string) => {
|
||||
if (!nextInput.trim()) return;
|
||||
setInput("");
|
||||
void sendMessage({ text: nextInput }, { body: { conversationId: conversationIdRef.current } });
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
if (!conversationId) {
|
||||
return (
|
||||
<div className="app-chat-panel app-chat-panel-empty">
|
||||
<Empty description="选择或创建一个会话开始聊天" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-chat-panel">
|
||||
{loadingHistory ? (
|
||||
<div className="app-chat-panel-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<Bubble.List
|
||||
items={bubbleItems}
|
||||
role={{
|
||||
ai: {
|
||||
contentRender: (content: string) => <MessageBubble content={content} />,
|
||||
placement: "start",
|
||||
},
|
||||
user: {
|
||||
placement: "end",
|
||||
},
|
||||
}}
|
||||
style={{ flex: 1, overflow: "auto", padding: "16px" }}
|
||||
/>
|
||||
)}
|
||||
<div className="app-chat-panel-sender">
|
||||
<Sender loading={isLoading} onChange={setInput} onSubmit={onSubmit} placeholder="输入消息..." value={input} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
src/web/consoles/workbench/components/chat/ChatSidebar.tsx
Normal file
97
src/web/consoles/workbench/components/chat/ChatSidebar.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Flex, Popconfirm, Spin, Typography } from "antd";
|
||||
|
||||
import type { Conversation } from "../../../../../shared/api";
|
||||
|
||||
import { createConversation, deleteConversation, fetchConversations } from "../../../../hooks/use-conversations";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
activeId: null | string;
|
||||
onSelect: (id: null | string) => void;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function ChatSidebar({ activeId, onSelect, projectId }: ChatSidebarProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const CONVERSATIONS_KEY = ["conversations", projectId] as const;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryFn: () => fetchConversations(projectId),
|
||||
queryKey: CONVERSATIONS_KEY,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createConversation(projectId),
|
||||
onError: (err: Error) => {
|
||||
void message.error(`创建会话失败:${err.message}`);
|
||||
},
|
||||
onSuccess: (conversation: Conversation) => {
|
||||
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
|
||||
onSelect(conversation.id);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteConversation(projectId, id),
|
||||
onError: (err: Error) => {
|
||||
void message.error(`删除会话失败:${err.message}`);
|
||||
},
|
||||
onSuccess: (_data: void, id: string) => {
|
||||
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
|
||||
if (activeId === id) onSelect(null);
|
||||
},
|
||||
});
|
||||
|
||||
const conversations = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="app-chat-sidebar">
|
||||
<div className="app-chat-sidebar-header">
|
||||
<Button
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
loading={createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
type="primary"
|
||||
>
|
||||
新会话
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-chat-sidebar-list">
|
||||
{isLoading ? (
|
||||
<div className="app-chat-sidebar-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((item: Conversation) => (
|
||||
<Flex
|
||||
align="center"
|
||||
className={`app-chat-sidebar-item ${activeId === item.id ? "app-chat-sidebar-item-active" : ""}`}
|
||||
gap="small"
|
||||
justify="space-between"
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<Typography.Text className="app-chat-sidebar-item-title" ellipsis>
|
||||
{item.title}
|
||||
</Typography.Text>
|
||||
<Popconfirm onConfirm={() => deleteMutation.mutate(item.id)} title="确定删除此会话?">
|
||||
<Button
|
||||
className="app-chat-sidebar-item-action"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="small"
|
||||
type="text"
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
interface MessageBubbleProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function MessageBubble({ content }: MessageBubbleProps) {
|
||||
return <div className="app-chat-message-bubble">{content}</div>;
|
||||
}
|
||||
22
src/web/consoles/workbench/pages/ChatPage.tsx
Normal file
22
src/web/consoles/workbench/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Flex } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ChatPanel } from "../components/chat/ChatPanel";
|
||||
import { ChatSidebar } from "../components/chat/ChatSidebar";
|
||||
import { useCurrentProject } from "../useCurrentProject";
|
||||
|
||||
export function ChatPage() {
|
||||
const project = useCurrentProject();
|
||||
const [activeConversationId, setActiveConversationId] = useState<null | string>(null);
|
||||
|
||||
return (
|
||||
<Flex className="app-chat-page" gap={0} vertical={false}>
|
||||
<ChatSidebar activeId={activeConversationId} onSelect={setActiveConversationId} projectId={project.id} />
|
||||
<ChatPanel
|
||||
conversationId={activeConversationId}
|
||||
onConversationCreated={setActiveConversationId}
|
||||
projectId={project.id}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DashboardOutlined } from "@ant-design/icons";
|
||||
import { MessageOutlined } from "@ant-design/icons";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { MenuItemConfig } from "../../menu";
|
||||
|
||||
export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardOutlined), label: "总览", path: "", value: "overview" },
|
||||
{ icon: createElement(MessageOutlined), label: "聊天室", path: "", value: "chat" },
|
||||
] as const;
|
||||
|
||||
export function buildWorkbenchPath(projectId: string, relativePath = ""): string {
|
||||
|
||||
45
src/web/hooks/use-conversations.ts
Normal file
45
src/web/hooks/use-conversations.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationListResponse,
|
||||
ConversationResponse,
|
||||
MessageListResponse,
|
||||
} from "../../shared/api";
|
||||
|
||||
import { handleResponse, handleVoidResponse } from "../utils/api";
|
||||
|
||||
export async function createConversation(projectId: string, modelId?: string): Promise<Conversation> {
|
||||
const response = await fetch(`/api/projects/${projectId}/conversations`, {
|
||||
body: JSON.stringify({ modelId }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
return handleResponse(response, (data) => (data as ConversationResponse).conversation);
|
||||
}
|
||||
|
||||
export async function deleteConversation(projectId: string, conversationId: string): Promise<void> {
|
||||
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`, { method: "DELETE" });
|
||||
return handleVoidResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchConversation(projectId: string, conversationId: string): Promise<Conversation> {
|
||||
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`);
|
||||
return handleResponse(response, (data) => (data as ConversationResponse).conversation);
|
||||
}
|
||||
|
||||
export async function fetchConversations(projectId: string): Promise<ConversationListResponse> {
|
||||
const response = await fetch(`/api/projects/${projectId}/conversations?pageSize=100`);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<ConversationListResponse>;
|
||||
}
|
||||
|
||||
export async function fetchMessages(projectId: string, conversationId: string): Promise<MessageListResponse> {
|
||||
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}/messages?pageSize=200`);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<MessageListResponse>;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Card, Descriptions, Space, Typography } from "antd";
|
||||
|
||||
import { useCurrentProject } from "../../consoles/workbench/useCurrentProject";
|
||||
|
||||
export function WorkbenchOverviewPage() {
|
||||
const project = useCurrentProject();
|
||||
|
||||
const items = [
|
||||
{ children: project.name, key: "name", label: "项目名称" },
|
||||
{ children: project.description || "暂无描述", key: "description", label: "项目描述" },
|
||||
{ children: project.status === "active" ? "进行中" : "已归档", key: "status", label: "状态" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Space size="large" vertical>
|
||||
<Typography.Title level={2}>总览</Typography.Title>
|
||||
<Card>
|
||||
<Descriptions column={1} items={items} title={project.name} />
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Route, Routes } from "react-router";
|
||||
|
||||
import { AdminConsoleLayout } from "./consoles/admin/AdminConsoleLayout";
|
||||
import { ChatPage } from "./consoles/workbench/pages/ChatPage";
|
||||
import { WorkbenchProjectGate } from "./consoles/workbench/WorkbenchProjectGate";
|
||||
import { NotFoundPage } from "./pages/404";
|
||||
import { DashboardPage } from "./pages/dashboard";
|
||||
import { ModelsPage } from "./pages/models";
|
||||
import { ProjectsPage } from "./pages/projects";
|
||||
import { WorkbenchOverviewPage } from "./pages/workbench";
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
@@ -17,7 +17,8 @@ export function AppRoutes() {
|
||||
<Route element={<ModelsPage />} path="/models" />
|
||||
</Route>
|
||||
<Route element={<WorkbenchProjectGate />} path="/workbench/:projectId">
|
||||
<Route element={<WorkbenchOverviewPage />} path="" />
|
||||
<Route element={<ChatPage />} path="" />
|
||||
<Route element={<ChatPage />} path="chat" />
|
||||
</Route>
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
</Routes>
|
||||
|
||||
@@ -53,6 +53,10 @@ body {
|
||||
padding: var(--ant-padding-xl) var(--ant-padding-xl);
|
||||
}
|
||||
|
||||
.app-chat-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-console-title {
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: var(--ant-font-size);
|
||||
@@ -72,3 +76,87 @@ body {
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.app-chat-sidebar {
|
||||
display: flex;
|
||||
width: 260px;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--ant-color-border-secondary);
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.app-chat-sidebar-header {
|
||||
padding: var(--ant-padding-sm);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.app-chat-sidebar-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-chat-sidebar-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--ant-padding-xl);
|
||||
}
|
||||
|
||||
.app-chat-sidebar-item {
|
||||
cursor: pointer;
|
||||
padding: var(--ant-padding-xs) var(--ant-padding-sm);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.app-chat-sidebar-item:hover {
|
||||
background: var(--ant-color-bg-text-hover);
|
||||
}
|
||||
|
||||
.app-chat-sidebar-item-active {
|
||||
background: var(--ant-color-bg-text-hover);
|
||||
}
|
||||
|
||||
.app-chat-sidebar-item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-sidebar-item-action {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.app-chat-sidebar-item:hover .app-chat-sidebar-item-action,
|
||||
.app-chat-sidebar-item-active .app-chat-sidebar-item-action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-chat-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel-empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-chat-panel-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-chat-panel-sender {
|
||||
padding: var(--ant-padding-sm) var(--ant-padding);
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.app-chat-message-bubble {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user