feat: 聊天页优化 — 欢迎页、标题自动生成、消息操作
This commit is contained in:
@@ -61,4 +61,12 @@ bun run dev config.yaml
|
|||||||
|
|
||||||
在 Workbench 工作台中,默认进入聊天室页面。左侧为会话列表,可新建和删除会话;右侧为聊天面板,输入消息后 AI 将流式回复。
|
在 Workbench 工作台中,默认进入聊天室页面。左侧为会话列表,可新建和删除会话;右侧为聊天面板,输入消息后 AI 将流式回复。
|
||||||
|
|
||||||
|
未选择会话时,聊天面板显示欢迎页面,用户可直接输入消息发送,系统会自动创建新会话。会话标题在首次对话后自动生成。
|
||||||
|
|
||||||
|
消息支持以下操作(仅限最后一条消息):
|
||||||
|
|
||||||
|
- **复制**:所有消息均支持复制文本内容
|
||||||
|
- **编辑**:最后一条用户消息可编辑,确认后重新发送
|
||||||
|
- **重新生成**:最后一条 AI 消息可重新生成回复
|
||||||
|
|
||||||
使用聊天功能前,需先在 Admin 管理台的模型管理页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。
|
使用聊天功能前,需先在 Admin 管理台的模型管理页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ export function formatCurrentTime(timezone?: string) {
|
|||||||
|
|
||||||
export const getCurrentTime = tool({
|
export const getCurrentTime = tool({
|
||||||
description: "获取当前日期和时间。可选指定时区,默认返回本地时间。",
|
description: "获取当前日期和时间。可选指定时区,默认返回本地时间。",
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
execute: ({ timezone }) => Promise.resolve(formatCurrentTime(timezone)),
|
||||||
execute: async ({ timezone }) => formatCurrentTime(timezone),
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
timezone: z.string().optional().describe("IANA 时区名称,如 'Asia/Shanghai'、'America/New_York'"),
|
timezone: z.string().optional().describe("IANA 时区名称,如 'Asia/Shanghai'、'America/New_York'"),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import type Database from "bun:sqlite";
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
import { createAgentUIStreamResponse, type UIMessage } from "ai";
|
import { createAgentUIStreamResponse, generateText, type UIMessage } from "ai";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
import type { RuntimeMode } from "../../../shared/api";
|
import type { RuntimeMode } from "../../../shared/api";
|
||||||
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
import { createAlfredAgent } from "../../ai/agents/alfred-agent";
|
import { createAlfredAgent } from "../../ai/agents/alfred-agent";
|
||||||
import { buildProviderRegistry } from "../../ai/registry";
|
import { buildProviderRegistry } from "../../ai/registry";
|
||||||
import { wrap } from "../../db/connection";
|
import { wrap } from "../../db/connection";
|
||||||
import { createMessage, getConversation, updateConversationTimestamp } from "../../db/conversations";
|
import {
|
||||||
|
createMessage,
|
||||||
|
getConversation,
|
||||||
|
updateConversation,
|
||||||
|
updateConversationTimestamp,
|
||||||
|
} from "../../db/conversations";
|
||||||
import { models, providers } from "../../db/schema";
|
import { models, providers } from "../../db/schema";
|
||||||
import { createApiError, jsonResponse } from "../../helpers";
|
import { createApiError, jsonResponse } from "../../helpers";
|
||||||
import { validateIdParam } from "../../middleware";
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
export async function handleSendChat(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
|
export async function handleSendChat(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Promise<Response> {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const projectId = url.pathname.split("/")[3];
|
const projectId = url.pathname.split("/")[3];
|
||||||
|
|
||||||
@@ -104,6 +110,58 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
});
|
});
|
||||||
updateConversationTimestamp(db, conversation.id);
|
updateConversationTimestamp(db, conversation.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (conversation.title === "新会话") {
|
||||||
|
const firstUserText =
|
||||||
|
body.messages
|
||||||
|
?.find((m) => m.role === "user")
|
||||||
|
?.parts?.filter((p) => p.type === "text")
|
||||||
|
?.map((p) => p.text)
|
||||||
|
?.join("") ?? "";
|
||||||
|
|
||||||
|
if (firstUserText) {
|
||||||
|
if (firstUserText.length <= 5) {
|
||||||
|
updateConversation(db, conversation.id, { title: firstUserText });
|
||||||
|
} else {
|
||||||
|
void generateText({
|
||||||
|
model,
|
||||||
|
prompt: `请根据以下对话开头生成一个简短标题(不超过10个字):${firstUserText}`,
|
||||||
|
system: "你是一个标题生成助手,只返回标题文本,不要解释。",
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
const title = result.text.trim().slice(0, 10);
|
||||||
|
updateConversation(db, conversation.id, { title: title || firstUserText.slice(0, 10) });
|
||||||
|
})
|
||||||
|
.catch((titleError: unknown) => {
|
||||||
|
const titleMsg = titleError instanceof Error ? titleError.message : String(titleError);
|
||||||
|
logger.error({ conversationId: conversation.id, error: titleMsg }, "标题生成失败");
|
||||||
|
try {
|
||||||
|
updateConversation(db, conversation.id, { title: firstUserText.slice(0, 10) });
|
||||||
|
} catch {
|
||||||
|
logger.error({ conversationId: conversation.id }, "标题兜底更新失败");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (titleError: unknown) {
|
||||||
|
const titleMsg = titleError instanceof Error ? titleError.message : String(titleError);
|
||||||
|
logger.error({ conversationId: conversation.id, error: titleMsg }, "标题生成失败");
|
||||||
|
try {
|
||||||
|
const fallbackTitle =
|
||||||
|
body.messages
|
||||||
|
?.find((m) => m.role === "user")
|
||||||
|
?.parts?.filter((p) => p.type === "text")
|
||||||
|
?.map((p) => p.text)
|
||||||
|
?.join("")
|
||||||
|
?.slice(0, 10) ?? "新会话";
|
||||||
|
updateConversation(db, conversation.id, { title: fallbackTitle });
|
||||||
|
} catch (fallbackError: unknown) {
|
||||||
|
const fbMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||||
|
logger.error({ conversationId: conversation.id, error: fbMsg }, "标题兜底更新失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
uiMessages: body.messages,
|
uiMessages: body.messages,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function startServer(options: StartServerOptions) {
|
|||||||
POST: withErrorHandler(
|
POST: withErrorHandler(
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const { handleSendChat } = await import("./routes/chat/send");
|
const { handleSendChat } = await import("./routes/chat/send");
|
||||||
return handleSendChat(req, db, mode);
|
return handleSendChat(req, db, mode, logger);
|
||||||
},
|
},
|
||||||
mode,
|
mode,
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
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 { 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 { 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 { 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 { useModelList } from "../../../../hooks/use-models";
|
||||||
|
import { ChatInputArea } from "./ChatInputArea";
|
||||||
import { ReasoningPart } from "./parts/ReasoningPart";
|
import { ReasoningPart } from "./parts/ReasoningPart";
|
||||||
import { TextPart } from "./parts/TextPart";
|
import { TextPart } from "./parts/TextPart";
|
||||||
import { ToolPart } from "./parts/ToolPart";
|
import { ToolPart } from "./parts/ToolPart";
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
conversationId: null | string;
|
conversationId: null | string;
|
||||||
|
onConversationCreated: (id: string) => void;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
export function ChatPanel({ conversationId, onConversationCreated, projectId }: ChatPanelProps) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
const [editingMessageId, setEditingMessageId] = useState<null | string>(null);
|
||||||
|
const [editText, setEditText] = useState("");
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
|
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
|
||||||
const fetchRef = useRef(fetchMessages);
|
const fetchRef = useRef(fetchMessages);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skipHistoryLoadRef = useRef<null | string>(null);
|
||||||
|
|
||||||
const { data: modelsData } = useModelList({ pageSize: 200 });
|
const { data: modelsData } = useModelList({ pageSize: 200 });
|
||||||
const textModels = useMemo(
|
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 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) => {
|
onError: (err) => {
|
||||||
void message.error(`发送失败:${err.message}`);
|
void message.error(`发送失败:${err.message}`);
|
||||||
},
|
},
|
||||||
@@ -39,16 +52,17 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
|
|
||||||
const isLoading = status === "submitted" || status === "streaming";
|
const isLoading = status === "submitted" || status === "streaming";
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollRef.current?.scrollTo({ behavior: "smooth", top: scrollRef.current.scrollHeight });
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skipHistoryLoadRef.current === conversationId) {
|
||||||
|
skipHistoryLoadRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -97,6 +111,16 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
};
|
};
|
||||||
}, [conversationId, projectId, setMessages, message, textModels]);
|
}, [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 displayModelId = conversationId ? selectedModelId : (textModels[0]?.id ?? null);
|
||||||
|
|
||||||
const handleModelChange = useCallback(
|
const handleModelChange = useCallback(
|
||||||
@@ -109,17 +133,131 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
[projectId, conversationId],
|
[projectId, conversationId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!input.trim() || !conversationId) return;
|
if (!input.trim()) return;
|
||||||
const text = input;
|
const text = input;
|
||||||
setInput("");
|
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 } });
|
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) {
|
if (!conversationId) {
|
||||||
return (
|
return (
|
||||||
<div className="app-chat-panel app-chat-panel-empty">
|
<div className="app-chat-panel">
|
||||||
<Empty description="选择或创建一个会话开始聊天" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,16 +271,35 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="chat-scroll-area" ref={scrollRef}>
|
<div className="chat-scroll-area" ref={scrollRef}>
|
||||||
<Flex gap={8} vertical>
|
<Flex gap={8} vertical>
|
||||||
{messages.map((msg) => (
|
{messages.map((msg, idx) => (
|
||||||
<Card
|
<Card
|
||||||
|
extra={getCardExtra(msg, idx)}
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
size="small"
|
size="small"
|
||||||
title={msg.role === "user" ? "用户" : <span className="msg-title-ai">阿福</span>}
|
title={msg.role === "user" ? "用户" : <span className="msg-title-ai">阿福</span>}
|
||||||
>
|
>
|
||||||
<div className="message-body">
|
<div className="message-body">
|
||||||
{msg.parts.map((part: Record<string, unknown>, i: number) => (
|
{editingMessageId === msg.id ? (
|
||||||
<PartRenderer key={i} part={part} role={msg.role} />
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -154,48 +311,20 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="chat-input-area">
|
<ChatInputArea
|
||||||
<Input.TextArea
|
displayModelId={displayModelId}
|
||||||
autoSize={{ maxRows: 6, minRows: 1 }}
|
input={input}
|
||||||
className="chat-textarea"
|
isLoading={isLoading}
|
||||||
disabled={isLoading}
|
modelOptions={modelOptions}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onInputChange={setInput}
|
||||||
onKeyDown={(e) => {
|
onModelChange={handleModelChange}
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
onSend={() => {
|
||||||
e.preventDefault();
|
void handleSend();
|
||||||
handleSend();
|
}}
|
||||||
}
|
onStop={() => {
|
||||||
}}
|
void stop();
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ export function ChatPage() {
|
|||||||
return (
|
return (
|
||||||
<Flex className="app-chat-page" gap={0} vertical={false}>
|
<Flex className="app-chat-page" gap={0} vertical={false}>
|
||||||
<ChatSidebar activeId={activeConversationId} onSelect={setActiveConversationId} projectId={project.id} />
|
<ChatSidebar activeId={activeConversationId} onSelect={setActiveConversationId} projectId={project.id} />
|
||||||
<ChatPanel conversationId={activeConversationId} projectId={project.id} />
|
<ChatPanel
|
||||||
|
conversationId={activeConversationId}
|
||||||
|
onConversationCreated={setActiveConversationId}
|
||||||
|
projectId={project.id}
|
||||||
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,9 +143,12 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel-empty {
|
.chat-welcome-area {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: var(--ant-padding-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel-loading {
|
.app-chat-panel-loading {
|
||||||
@@ -160,7 +163,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding-left: var(--ant-padding-sm);
|
||||||
|
padding-top: var(--ant-padding-sm);
|
||||||
border-top: 1px solid var(--ant-color-border-secondary);
|
border-top: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +184,7 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-left: var(--ant-padding-lg);
|
padding-left: var(--ant-padding-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-loading-indicator {
|
.chat-loading-indicator {
|
||||||
@@ -227,3 +231,11 @@ body {
|
|||||||
.chat-model-select {
|
.chat-model-select {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-card-extra .ant-btn-text {
|
||||||
|
color: var(--ant-color-text-quaternary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-extra .ant-btn-text:hover {
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Conversation, Message, RuntimeMode } from "../../../src/shared/api
|
|||||||
import { createModel } from "../../../src/server/db/models";
|
import { createModel } from "../../../src/server/db/models";
|
||||||
import { createProject } from "../../../src/server/db/projects";
|
import { createProject } from "../../../src/server/db/projects";
|
||||||
import { createProvider } from "../../../src/server/db/providers";
|
import { createProvider } from "../../../src/server/db/providers";
|
||||||
|
import { createNoopLogger } from "../../../src/server/logger";
|
||||||
import { createMigratedMemoryTestDatabase } from "../../helpers";
|
import { createMigratedMemoryTestDatabase } from "../../helpers";
|
||||||
|
|
||||||
const MODE: RuntimeMode = "test";
|
const MODE: RuntimeMode = "test";
|
||||||
@@ -92,7 +93,7 @@ function seedProvider(db: Database, name = "测试供应商"): string {
|
|||||||
|
|
||||||
async function sendChatViaHandler(req: Request, db: Database): Promise<Response> {
|
async function sendChatViaHandler(req: Request, db: Database): Promise<Response> {
|
||||||
const { handleSendChat: h } = await import("../../../src/server/routes/chat/send");
|
const { handleSendChat: h } = await import("../../../src/server/routes/chat/send");
|
||||||
return h(req, db, MODE);
|
return h(req, db, MODE, createNoopLogger());
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("聊天 API 路由", () => {
|
describe("聊天 API 路由", () => {
|
||||||
|
|||||||
167
tests/web/components/ChatPanel.test.tsx
Normal file
167
tests/web/components/ChatPanel.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, mock, test } from "bun:test";
|
||||||
|
import { createElement } from "react";
|
||||||
|
|
||||||
|
import type { Conversation, Model } from "../../../src/shared/api";
|
||||||
|
|
||||||
|
import { ChatPanel } from "../../../src/web/consoles/workbench/components/chat/ChatPanel";
|
||||||
|
import { installFetchMock, jsonResponse, renderWithProviders } from "../test-utils";
|
||||||
|
|
||||||
|
const PROJECT_ID = "proj-1";
|
||||||
|
|
||||||
|
const TEXT_MODEL: Model = {
|
||||||
|
capabilities: ["text"],
|
||||||
|
contextLength: null,
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
id: "model-1",
|
||||||
|
maxOutputTokens: null,
|
||||||
|
modelId: "gpt-4o",
|
||||||
|
name: "GPT-4o",
|
||||||
|
providerId: "pv1",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONVERSATION: Conversation = {
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
id: "conv-1",
|
||||||
|
modelId: "model-1",
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
title: "新会话",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const noop = () => {
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
void mock.module("@ai-sdk/react", () => ({
|
||||||
|
useChat: () => ({
|
||||||
|
messages: [],
|
||||||
|
regenerate: noop,
|
||||||
|
sendMessage: noop,
|
||||||
|
setMessages: (msgs: unknown) => msgs,
|
||||||
|
status: "ready",
|
||||||
|
stop: noop,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
void mock.module("ai", () => ({
|
||||||
|
DefaultChatTransport: function () {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getSendButton() {
|
||||||
|
return screen.getByRole("button", { name: /发.*送/ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupFetchMock() {
|
||||||
|
return installFetchMock((call) => {
|
||||||
|
if (call.url.includes("/models")) {
|
||||||
|
return jsonResponse({ items: [TEXT_MODEL], total: 1 });
|
||||||
|
}
|
||||||
|
if (call.url.endsWith("/conversations") && call.method === "POST") {
|
||||||
|
return jsonResponse({ conversation: { ...CONVERSATION, id: "conv-new" } }, { status: 201 });
|
||||||
|
}
|
||||||
|
if (/\/conversations\/conv-1$/.exec(call.url)) {
|
||||||
|
return jsonResponse({ conversation: CONVERSATION });
|
||||||
|
}
|
||||||
|
if (call.url.includes("/messages")) {
|
||||||
|
return jsonResponse({ items: [], total: 0 });
|
||||||
|
}
|
||||||
|
if (/\/conversations$/.exec(call.url) && call.method === "GET") {
|
||||||
|
return jsonResponse({ items: [], total: 0 });
|
||||||
|
}
|
||||||
|
return jsonResponse({ error: "not found" }, { status: 404 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ChatPanel", () => {
|
||||||
|
describe("欢迎页", () => {
|
||||||
|
test("无会话时显示欢迎页和输入框", () => {
|
||||||
|
setupFetchMock();
|
||||||
|
renderWithProviders(
|
||||||
|
createElement(ChatPanel, {
|
||||||
|
conversationId: null,
|
||||||
|
onConversationCreated: noop,
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("你好,我是阿福")).toBeTruthy();
|
||||||
|
expect(screen.getByText("有什么我可以帮助你的吗?")).toBeTruthy();
|
||||||
|
expect(screen.getByPlaceholderText("输入消息...")).toBeTruthy();
|
||||||
|
expect(getSendButton()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("自动创建会话", () => {
|
||||||
|
test("输入并发送后自动创建会话并通知父组件", async () => {
|
||||||
|
const calls = setupFetchMock();
|
||||||
|
const onCreated = mock<(id: string) => void>(() => undefined);
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
createElement(ChatPanel, {
|
||||||
|
conversationId: null,
|
||||||
|
onConversationCreated: onCreated,
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("输入消息...");
|
||||||
|
fireEvent.change(input, { target: { value: "你好" } });
|
||||||
|
fireEvent.click(getSendButton());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onCreated).toHaveBeenCalledWith("conv-new");
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCall = calls.find((c) => c.url.endsWith("/conversations") && c.method === "POST");
|
||||||
|
expect(createCall).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("创建会话失败时恢复输入文本", async () => {
|
||||||
|
installFetchMock((call) => {
|
||||||
|
if (call.url.includes("/models")) {
|
||||||
|
return jsonResponse({ items: [TEXT_MODEL], total: 1 });
|
||||||
|
}
|
||||||
|
if (call.url.endsWith("/conversations") && call.method === "POST") {
|
||||||
|
return jsonResponse({ error: "服务器错误" }, { status: 500 });
|
||||||
|
}
|
||||||
|
return jsonResponse({ error: "not found" }, { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
createElement(ChatPanel, {
|
||||||
|
conversationId: null,
|
||||||
|
onConversationCreated: noop,
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("输入消息...");
|
||||||
|
fireEvent.change(input, { target: { value: "测试输入" } });
|
||||||
|
fireEvent.click(getSendButton());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((input as HTMLTextAreaElement).value).toBe("测试输入");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("聊天面板", () => {
|
||||||
|
test("选中会话时显示消息列表区域", () => {
|
||||||
|
setupFetchMock();
|
||||||
|
renderWithProviders(
|
||||||
|
createElement(ChatPanel, {
|
||||||
|
conversationId: "conv-1",
|
||||||
|
onConversationCreated: noop,
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("你好,我是阿福")).toBeNull();
|
||||||
|
expect(screen.getByPlaceholderText("输入消息...")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user