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

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