feat: 聊天页优化 — 欢迎页、标题自动生成、消息操作
This commit is contained in:
68
src/web/consoles/workbench/components/chat/ChatInputArea.tsx
Normal file
68
src/web/consoles/workbench/components/chat/ChatInputArea.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Button, Flex, Input, Select } from "antd";
|
||||
|
||||
interface ChatInputAreaProps {
|
||||
displayModelId: null | string;
|
||||
input: string;
|
||||
isLoading: boolean;
|
||||
modelOptions: Array<{ label: string; value: string }>;
|
||||
onInputChange: (value: string) => void;
|
||||
onModelChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputArea({
|
||||
displayModelId,
|
||||
input,
|
||||
isLoading,
|
||||
modelOptions,
|
||||
onInputChange,
|
||||
onModelChange,
|
||||
onSend,
|
||||
onStop,
|
||||
}: ChatInputAreaProps) {
|
||||
return (
|
||||
<div className="chat-input-area">
|
||||
<Input.TextArea
|
||||
autoSize={{ maxRows: 6, minRows: 1 }}
|
||||
className="chat-textarea"
|
||||
disabled={isLoading}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void onSend();
|
||||
}
|
||||
}}
|
||||
placeholder="输入消息..."
|
||||
value={input}
|
||||
/>
|
||||
<Flex align="center" gap={8} justify="space-between">
|
||||
<Select
|
||||
className="chat-model-select"
|
||||
disabled={isLoading}
|
||||
onChange={onModelChange}
|
||||
options={modelOptions}
|
||||
placeholder="选择模型"
|
||||
value={displayModelId}
|
||||
/>
|
||||
<Flex align="center" gap={8}>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
onStop?.();
|
||||
}}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => void onSend()} type="primary">
|
||||
发送
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,39 @@
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { CopyOutlined, EditOutlined, RedoOutlined, RobotOutlined } from "@ant-design/icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||
import { App, Button, Card, Empty, Flex, Input, Select, Spin } from "antd";
|
||||
import { App, Button, Card, Flex, Input, Spin, Typography } from "antd";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { fetchConversation, fetchMessages, updateConversation } from "../../../../hooks/use-conversations";
|
||||
import {
|
||||
createConversation,
|
||||
fetchConversation,
|
||||
fetchMessages,
|
||||
updateConversation,
|
||||
} from "../../../../hooks/use-conversations";
|
||||
import { useModelList } from "../../../../hooks/use-models";
|
||||
import { ChatInputArea } from "./ChatInputArea";
|
||||
import { ReasoningPart } from "./parts/ReasoningPart";
|
||||
import { TextPart } from "./parts/TextPart";
|
||||
import { ToolPart } from "./parts/ToolPart";
|
||||
|
||||
interface ChatPanelProps {
|
||||
conversationId: null | string;
|
||||
onConversationCreated: (id: string) => void;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
export function ChatPanel({ conversationId, onConversationCreated, projectId }: ChatPanelProps) {
|
||||
const { message } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const [input, setInput] = useState("");
|
||||
const [editingMessageId, setEditingMessageId] = useState<null | string>(null);
|
||||
const [editText, setEditText] = useState("");
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
|
||||
const fetchRef = useRef(fetchMessages);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const skipHistoryLoadRef = useRef<null | string>(null);
|
||||
|
||||
const { data: modelsData } = useModelList({ pageSize: 200 });
|
||||
const textModels = useMemo(
|
||||
@@ -30,7 +43,7 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
|
||||
const modelOptions = useMemo(() => textModels.map((m) => ({ label: m.name, value: m.id })), [textModels]);
|
||||
|
||||
const { messages, sendMessage, setMessages, status, stop } = useChat({
|
||||
const { messages, regenerate, sendMessage, setMessages, status, stop } = useChat({
|
||||
onError: (err) => {
|
||||
void message.error(`发送失败:${err.message}`);
|
||||
},
|
||||
@@ -39,16 +52,17 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
|
||||
const isLoading = status === "submitted" || status === "streaming";
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ behavior: "smooth", top: scrollRef.current.scrollHeight });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipHistoryLoadRef.current === conversationId) {
|
||||
skipHistoryLoadRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
@@ -97,6 +111,16 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
};
|
||||
}, [conversationId, projectId, setMessages, message, textModels]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ behavior: "smooth", top: scrollRef.current.scrollHeight });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "ready" && conversationId) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["conversations", projectId] });
|
||||
}
|
||||
}, [status, conversationId, projectId, queryClient]);
|
||||
|
||||
const displayModelId = conversationId ? selectedModelId : (textModels[0]?.id ?? null);
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
@@ -109,17 +133,131 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
[projectId, conversationId],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!input.trim() || !conversationId) return;
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim()) return;
|
||||
const text = input;
|
||||
setInput("");
|
||||
|
||||
if (!conversationId) {
|
||||
try {
|
||||
const conv = await createConversation(projectId);
|
||||
skipHistoryLoadRef.current = conv.id;
|
||||
void sendMessage({ text }, { body: { conversationId: conv.id } });
|
||||
onConversationCreated(conv.id);
|
||||
} catch (err: unknown) {
|
||||
setInput(text);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
void message.error(`创建会话失败:${msg}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void sendMessage({ text }, { body: { conversationId } });
|
||||
}, [input, sendMessage, conversationId]);
|
||||
}, [input, sendMessage, conversationId, projectId, onConversationCreated, message]);
|
||||
|
||||
const extractText = useCallback((msg: UIMessage) => {
|
||||
return msg.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => (p as { text: string; type: "text" }).text)
|
||||
.join("");
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(msg: UIMessage) => {
|
||||
const text = extractText(msg);
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
void message.success("已复制");
|
||||
});
|
||||
},
|
||||
[extractText, message],
|
||||
);
|
||||
|
||||
const handleEditStart = useCallback(
|
||||
(msg: UIMessage) => {
|
||||
setEditingMessageId(msg.id);
|
||||
setEditText(extractText(msg));
|
||||
},
|
||||
[extractText],
|
||||
);
|
||||
|
||||
const handleEditConfirm = useCallback(() => {
|
||||
if (!editText.trim() || !conversationId) return;
|
||||
setEditingMessageId(null);
|
||||
const idx = messages.findIndex((m) => m.id === editingMessageId);
|
||||
if (idx === -1) return;
|
||||
setMessages(messages.slice(0, idx));
|
||||
void sendMessage({ text: editText }, { body: { conversationId } });
|
||||
}, [editText, conversationId, messages, editingMessageId, setMessages, sendMessage]);
|
||||
|
||||
const handleEditCancel = useCallback(() => {
|
||||
setEditingMessageId(null);
|
||||
setEditText("");
|
||||
}, []);
|
||||
|
||||
const handleRegenerate = useCallback(() => {
|
||||
if (!conversationId) return;
|
||||
void regenerate({ body: { conversationId } });
|
||||
}, [regenerate, conversationId]);
|
||||
|
||||
const getCardExtra = useCallback(
|
||||
(msg: UIMessage, idx: number) => {
|
||||
const isLast = idx === messages.length - 1;
|
||||
const lastUserIdx = messages.findLastIndex((m) => m.role === "user");
|
||||
const isLastUser = lastUserIdx >= 0 && idx === lastUserIdx;
|
||||
const isLastAssistant = isLast && msg.role === "assistant";
|
||||
const isEditing = editingMessageId === msg.id;
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
const buttons: React.ReactNode[] = [];
|
||||
|
||||
buttons.push(
|
||||
<Button icon={<CopyOutlined />} key="copy" onClick={() => handleCopy(msg)} size="small" type="text" />,
|
||||
);
|
||||
|
||||
if (isLastUser && !isEditing) {
|
||||
buttons.push(
|
||||
<Button icon={<EditOutlined />} key="edit" onClick={() => handleEditStart(msg)} size="small" type="text" />,
|
||||
);
|
||||
}
|
||||
|
||||
if (isLastAssistant) {
|
||||
buttons.push(
|
||||
<Button icon={<RedoOutlined />} key="regenerate" onClick={handleRegenerate} size="small" type="text" />,
|
||||
);
|
||||
}
|
||||
|
||||
return <Flex gap={4}>{buttons}</Flex>;
|
||||
},
|
||||
[messages, isLoading, editingMessageId, handleCopy, handleEditStart, handleRegenerate],
|
||||
);
|
||||
|
||||
if (!conversationId) {
|
||||
return (
|
||||
<div className="app-chat-panel app-chat-panel-empty">
|
||||
<Empty description="选择或创建一个会话开始聊天" />
|
||||
<div className="app-chat-panel">
|
||||
<div className="chat-welcome-area">
|
||||
<Flex align="center" gap={12} vertical>
|
||||
<RobotOutlined style={{ color: "var(--ant-color-primary)", fontSize: 48 }} />
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
你好,我是阿福
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">有什么我可以帮助你的吗?</Typography.Text>
|
||||
</Flex>
|
||||
</div>
|
||||
<ChatInputArea
|
||||
displayModelId={displayModelId}
|
||||
input={input}
|
||||
isLoading={isLoading}
|
||||
modelOptions={modelOptions}
|
||||
onInputChange={setInput}
|
||||
onModelChange={handleModelChange}
|
||||
onSend={() => {
|
||||
void handleSend();
|
||||
}}
|
||||
onStop={() => {
|
||||
void stop();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,16 +271,35 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
) : (
|
||||
<div className="chat-scroll-area" ref={scrollRef}>
|
||||
<Flex gap={8} vertical>
|
||||
{messages.map((msg) => (
|
||||
{messages.map((msg, idx) => (
|
||||
<Card
|
||||
extra={getCardExtra(msg, idx)}
|
||||
key={msg.id}
|
||||
size="small"
|
||||
title={msg.role === "user" ? "用户" : <span className="msg-title-ai">阿福</span>}
|
||||
>
|
||||
<div className="message-body">
|
||||
{msg.parts.map((part: Record<string, unknown>, i: number) => (
|
||||
<PartRenderer key={i} part={part} role={msg.role} />
|
||||
))}
|
||||
{editingMessageId === msg.id ? (
|
||||
<Flex gap={8} vertical>
|
||||
<Input.TextArea
|
||||
autoSize={{ maxRows: 6, minRows: 1 }}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
value={editText}
|
||||
/>
|
||||
<Flex gap={8} justify="flex-end">
|
||||
<Button onClick={handleEditCancel} size="small">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleEditConfirm} size="small" type="primary">
|
||||
确认
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
msg.parts.map((part: Record<string, unknown>, i: number) => (
|
||||
<PartRenderer key={i} part={part} role={msg.role} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
@@ -154,48 +311,20 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-input-area">
|
||||
<Input.TextArea
|
||||
autoSize={{ maxRows: 6, minRows: 1 }}
|
||||
className="chat-textarea"
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="输入消息..."
|
||||
value={input}
|
||||
/>
|
||||
<Flex align="center" gap={8} justify="space-between">
|
||||
<Select
|
||||
className="chat-model-select"
|
||||
disabled={isLoading}
|
||||
onChange={handleModelChange}
|
||||
options={modelOptions}
|
||||
placeholder="选择模型"
|
||||
value={displayModelId}
|
||||
/>
|
||||
<Flex align="center" gap={8}>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
void stop?.();
|
||||
}}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSend} type="primary">
|
||||
发送
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
<ChatInputArea
|
||||
displayModelId={displayModelId}
|
||||
input={input}
|
||||
isLoading={isLoading}
|
||||
modelOptions={modelOptions}
|
||||
onInputChange={setInput}
|
||||
onModelChange={handleModelChange}
|
||||
onSend={() => {
|
||||
void handleSend();
|
||||
}}
|
||||
onStop={() => {
|
||||
void stop();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ export function ChatPage() {
|
||||
return (
|
||||
<Flex className="app-chat-page" gap={0} vertical={false}>
|
||||
<ChatSidebar activeId={activeConversationId} onSelect={setActiveConversationId} projectId={project.id} />
|
||||
<ChatPanel conversationId={activeConversationId} projectId={project.id} />
|
||||
<ChatPanel
|
||||
conversationId={activeConversationId}
|
||||
onConversationCreated={setActiveConversationId}
|
||||
projectId={project.id}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user