feat: 聊天页优化 — 欢迎页、标题自动生成、消息操作

This commit is contained in:
2026-06-01 07:37:23 +08:00
parent f2e3d84fb1
commit 8463274c4b
10 changed files with 516 additions and 70 deletions

View File

@@ -61,4 +61,12 @@ bun run dev config.yaml
在 Workbench 工作台中,默认进入聊天室页面。左侧为会话列表,可新建和删除会话;右侧为聊天面板,输入消息后 AI 将流式回复。
未选择会话时,聊天面板显示欢迎页面,用户可直接输入消息发送,系统会自动创建新会话。会话标题在首次对话后自动生成。
消息支持以下操作(仅限最后一条消息):
- **复制**:所有消息均支持复制文本内容
- **编辑**:最后一条用户消息可编辑,确认后重新发送
- **重新生成**:最后一条 AI 消息可重新生成回复
使用聊天功能前,需先在 Admin 管理台的模型管理页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。

View File

@@ -29,8 +29,7 @@ export function formatCurrentTime(timezone?: string) {
export const getCurrentTime = tool({
description: "获取当前日期和时间。可选指定时区,默认返回本地时间。",
// eslint-disable-next-line @typescript-eslint/require-await
execute: async ({ timezone }) => formatCurrentTime(timezone),
execute: ({ timezone }) => Promise.resolve(formatCurrentTime(timezone)),
inputSchema: z.object({
timezone: z.string().optional().describe("IANA 时区名称,如 'Asia/Shanghai'、'America/New_York'"),
}),

View File

@@ -1,19 +1,25 @@
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 type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { createAlfredAgent } from "../../ai/agents/alfred-agent";
import { buildProviderRegistry } from "../../ai/registry";
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 { createApiError, jsonResponse } from "../../helpers";
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 projectId = url.pathname.split("/")[3];
@@ -104,6 +110,58 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
role: "assistant",
});
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,
});

View File

@@ -160,7 +160,7 @@ export function startServer(options: StartServerOptions) {
POST: withErrorHandler(
async (req) => {
const { handleSendChat } = await import("./routes/chat/send");
return handleSendChat(req, db, mode);
return handleSendChat(req, db, mode, logger);
},
mode,
logger,

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -143,9 +143,12 @@ body {
min-width: 0;
}
.app-chat-panel-empty {
.chat-welcome-area {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: var(--ant-padding-xl);
}
.app-chat-panel-loading {
@@ -160,7 +163,8 @@ body {
display: flex;
flex-direction: column;
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);
}
@@ -180,7 +184,7 @@ body {
flex: 1;
min-height: 0;
overflow: auto;
padding-left: var(--ant-padding-lg);
padding-left: var(--ant-padding-sm);
}
.chat-loading-indicator {
@@ -227,3 +231,11 @@ body {
.chat-model-select {
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);
}

View File

@@ -7,6 +7,7 @@ import type { Conversation, Message, RuntimeMode } from "../../../src/shared/api
import { createModel } from "../../../src/server/db/models";
import { createProject } from "../../../src/server/db/projects";
import { createProvider } from "../../../src/server/db/providers";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
@@ -92,7 +93,7 @@ function seedProvider(db: Database, name = "测试供应商"): string {
async function sendChatViaHandler(req: Request, db: Database): Promise<Response> {
const { handleSendChat: h } = await import("../../../src/server/routes/chat/send");
return h(req, db, MODE);
return h(req, db, MODE, createNoopLogger());
}
describe("聊天 API 路由", () => {

View 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();
});
});
});