refactor: 标题生成重构、UI样式优化、测试增强

- 将标题生成逻辑提取为独立函数,提前到Agent调用前非阻塞执行

- 修复模型/供应商不存在时的HTTP状态码 500→400

- ChatPanel: 分离模型选择useEffect、CSS类替代内联样式、按钮样式统一

- use-conversations: fetchConversations/fetchMessages改用handleResponse去重

- 聊天面板滚动优化(scroll-behavior: smooth, overflow-anchor: auto)

- 测试: mock支持onFinish回调,新增首次消息标题生成测试

- 移除未使用的SendMessageRequest接口
This commit is contained in:
2026-06-01 10:49:38 +08:00
parent f34028368d
commit 897fad95eb
6 changed files with 170 additions and 92 deletions

View File

@@ -79,12 +79,12 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
const d = wrap(db); const d = wrap(db);
const modelRow = d.select().from(models).where(eq(models.id, conversation.modelId)).get(); const modelRow = d.select().from(models).where(eq(models.id, conversation.modelId)).get();
if (!modelRow) { if (!modelRow) {
return jsonResponse(createApiError(`模型不存在: ${conversation.modelId}`, 500), { mode, status: 500 }); return jsonResponse(createApiError(`模型不存在: ${conversation.modelId}`, 400), { mode, status: 400 });
} }
const providerRow = d.select().from(providers).where(eq(providers.id, modelRow.providerId)).get(); const providerRow = d.select().from(providers).where(eq(providers.id, modelRow.providerId)).get();
if (!providerRow) { if (!providerRow) {
return jsonResponse(createApiError(`供应商不存在: ${modelRow.providerId}`, 500), { mode, status: 500 }); return jsonResponse(createApiError(`供应商不存在: ${modelRow.providerId}`, 400), { mode, status: 400 });
} }
const registry = buildProviderRegistry(db); const registry = buildProviderRegistry(db);
@@ -95,6 +95,17 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
} }
try { try {
const firstUserMsg = body.messages.find((m) => m.role === "user");
const firstUserText =
firstUserMsg?.parts
?.filter((p) => p.type === "text")
.map((p) => p.text)
.join("") ?? "";
if (conversation.title === "新会话" && firstUserText) {
generateConversationTitle(firstUserText, model, db, conversation.id, logger);
}
const agent = createAlfredAgent(model); const agent = createAlfredAgent(model);
return await createAgentUIStreamResponse({ return await createAgentUIStreamResponse({
agent, agent,
@@ -110,58 +121,6 @@ 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,
}); });
@@ -170,3 +129,35 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
return jsonResponse(createApiError(`AI 调用失败:${msg}`, 500), { mode, status: 500 }); return jsonResponse(createApiError(`AI 调用失败:${msg}`, 500), { mode, status: 500 });
} }
} }
function generateConversationTitle(
firstUserText: string,
model: ReturnType<ReturnType<typeof buildProviderRegistry>["languageModel"]>,
db: Database,
conversationId: string,
logger: Logger,
): void {
if (firstUserText.length <= 5) {
updateConversation(db, conversationId, { title: firstUserText });
return;
}
void generateText({
model,
prompt: `请根据以下对话开头生成一个简短标题不超过10个字${firstUserText}`,
system: "你是一个标题生成助手,只返回标题文本,不要解释。",
})
.then((result) => {
const title = result.text.trim().slice(0, 10);
updateConversation(db, conversationId, { title: title || firstUserText.slice(0, 10) });
})
.catch((titleError: unknown) => {
const titleMsg = titleError instanceof Error ? titleError.message : String(titleError);
logger.error({ conversationId, error: titleMsg }, "标题生成失败");
try {
updateConversation(db, conversationId, { title: firstUserText.slice(0, 10) });
} catch {
logger.error({ conversationId }, "标题兜底更新失败");
}
});
}

View File

@@ -99,11 +99,6 @@ export type ModelCapability =
| "video-generation" | "video-generation"
| "video-recognition"; | "video-recognition";
export interface SendMessageRequest {
conversationId: string;
messages: Array<{ content: string; role: "assistant" | "system" | "user" }>;
}
export interface UpdateConversationRequest { export interface UpdateConversationRequest {
modelId?: string; modelId?: string;
title?: string; title?: string;

View File

@@ -70,21 +70,11 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
setInput(""); setInput("");
setMessages([]); setMessages([]);
try { try {
const convPromise = fetchConversation(projectId, conversationId);
const msgPromise = fetchRef.current(projectId, conversationId); const msgPromise = fetchRef.current(projectId, conversationId);
const conv = await convPromise;
const data = await msgPromise; const data = await msgPromise;
if (cancelled) return; if (cancelled) return;
const firstTextId = textModels[0]?.id;
if (firstTextId && textModels.every((m) => m.id !== conv.modelId)) {
setSelectedModelId(firstTextId);
void updateConversation(projectId, conversationId, { modelId: firstTextId });
} else {
setSelectedModelId(conv.modelId);
}
const history = data.items const history = data.items
.filter((m: { role: string }) => m.role === "user" || m.role === "assistant") .filter((m: { role: string }) => m.role === "user" || m.role === "assistant")
.reverse() .reverse()
@@ -109,7 +99,22 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [conversationId, projectId, setMessages, message, textModels]); }, [conversationId, projectId, setMessages, message]);
useEffect(() => {
if (!conversationId) return;
const firstTextId = textModels[0]?.id;
if (!firstTextId) return;
void fetchConversation(projectId, conversationId).then((conv) => {
if (textModels.every((m) => m.id !== conv.modelId)) {
setSelectedModelId(firstTextId);
void updateConversation(projectId, conversationId, { modelId: firstTextId });
} else {
setSelectedModelId(conv.modelId);
}
});
}, [conversationId, textModels, projectId]);
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo({ behavior: "smooth", top: scrollRef.current.scrollHeight }); scrollRef.current?.scrollTo({ behavior: "smooth", top: scrollRef.current.scrollHeight });
@@ -213,18 +218,39 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
const buttons: React.ReactNode[] = []; const buttons: React.ReactNode[] = [];
buttons.push( buttons.push(
<Button icon={<CopyOutlined />} key="copy" onClick={() => handleCopy(msg)} size="small" type="text" />, <Button
className="btn-dimmed"
icon={<CopyOutlined />}
key="copy"
onClick={() => handleCopy(msg)}
size="small"
type="text"
/>,
); );
if (isLastUser && !isEditing) { if (isLastUser && !isEditing) {
buttons.push( buttons.push(
<Button icon={<EditOutlined />} key="edit" onClick={() => handleEditStart(msg)} size="small" type="text" />, <Button
className="btn-dimmed"
icon={<EditOutlined />}
key="edit"
onClick={() => handleEditStart(msg)}
size="small"
type="text"
/>,
); );
} }
if (isLastAssistant) { if (isLastAssistant) {
buttons.push( buttons.push(
<Button icon={<RedoOutlined />} key="regenerate" onClick={handleRegenerate} size="small" type="text" />, <Button
className="btn-dimmed"
icon={<RedoOutlined />}
key="regenerate"
onClick={handleRegenerate}
size="small"
type="text"
/>,
); );
} }
@@ -238,8 +264,8 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
<div className="app-chat-panel"> <div className="app-chat-panel">
<div className="chat-welcome-area"> <div className="chat-welcome-area">
<Flex align="center" gap={12} vertical> <Flex align="center" gap={12} vertical>
<RobotOutlined style={{ color: "var(--ant-color-primary)", fontSize: 48 }} /> <RobotOutlined className="welcome-icon" />
<Typography.Title level={3} style={{ margin: 0 }}> <Typography.Title className="welcome-title" level={3}>
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
@@ -274,6 +300,7 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
<Flex gap={8} vertical> <Flex gap={8} vertical>
{messages.map((msg, idx) => ( {messages.map((msg, idx) => (
<Card <Card
classNames={{ extra: "card-extra-actions" }}
extra={getCardExtra(msg, idx)} extra={getCardExtra(msg, idx)}
key={msg.id} key={msg.id}
size="small" size="small"

View File

@@ -29,20 +29,12 @@ export async function fetchConversation(projectId: string, conversationId: strin
export async function fetchConversations(projectId: string): Promise<ConversationListResponse> { export async function fetchConversations(projectId: string): Promise<ConversationListResponse> {
const response = await fetch(`/api/projects/${projectId}/conversations?pageSize=100`); const response = await fetch(`/api/projects/${projectId}/conversations?pageSize=100`);
if (!response.ok) { return handleResponse(response, (data) => data as ConversationListResponse);
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> { export async function fetchMessages(projectId: string, conversationId: string): Promise<MessageListResponse> {
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}/messages?pageSize=200`); const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}/messages?pageSize=200`);
if (!response.ok) { return handleResponse(response, (data) => data as MessageListResponse);
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>;
} }
export async function updateConversation( export async function updateConversation(

View File

@@ -184,7 +184,9 @@ body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
overflow-anchor: auto;
padding-left: var(--ant-padding-sm); padding-left: var(--ant-padding-sm);
scroll-behavior: smooth;
} }
.chat-loading-indicator { .chat-loading-indicator {
@@ -228,14 +230,23 @@ body {
color: var(--ant-color-error); color: var(--ant-color-error);
} }
.welcome-icon {
color: var(--ant-color-primary);
font-size: 48px;
}
.welcome-title {
margin: 0;
}
.chat-model-select { .chat-model-select {
width: 180px; width: 180px;
} }
.ant-card-extra .ant-btn-text { .card-extra-actions .btn-dimmed {
color: var(--ant-color-text-quaternary); color: var(--ant-color-text-quaternary);
} }
.ant-card-extra .ant-btn-text:hover { .card-extra-actions .btn-dimmed:hover {
color: var(--ant-color-text-secondary); color: var(--ant-color-text-secondary);
} }

View File

@@ -13,19 +13,33 @@ import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test"; const MODE: RuntimeMode = "test";
void mock.module("ai", () => ({ void mock.module("ai", () => ({
createAgentUIStreamResponse: () => createAgentUIStreamResponse: (opts: {
Promise.resolve( agent: unknown;
messages: unknown[];
onFinish:
| ((event: { finishReason?: string; responseMessage: { parts?: Array<{ text: string; type: string }> } }) => void)
| undefined;
}) => {
if (opts.onFinish) {
opts.onFinish({
responseMessage: {
parts: [{ text: "test reply from AI", type: "text" }],
},
});
}
return Promise.resolve(
new Response( new Response(
'data: {"type":"start-step"}\n\ndata: {"type":"text-start","id":"txt-1"}\n\ndata: {"type":"text-delta","id":"txt-1","delta":"test reply from AI"}\n\ndata: {"type":"text-end","id":"txt-1"}\n\ndata: {"type":"finish-step"}\n\ndata: {"type":"finish"}\n\n', 'data: {"type":"start-step"}\n\ndata: {"type":"text-start","id":"txt-1"}\n\ndata: {"type":"text-delta","id":"txt-1","delta":"test reply from AI"}\n\ndata: {"type":"text-end","id":"txt-1"}\n\ndata: {"type":"finish-step"}\n\ndata: {"type":"finish"}\n\n',
{ {
headers: { "Content-Type": "text/event-stream" }, headers: { "Content-Type": "text/event-stream" },
}, },
), ),
), );
},
createProviderRegistry: () => ({ createProviderRegistry: () => ({
languageModel: () => ({}), languageModel: () => ({}),
}), }),
generateText: () => Promise.resolve({ text: "", usage: {} }), generateText: () => Promise.resolve({ text: "AI总结标题", usage: {} }),
stepCountIs: () => () => true, stepCountIs: () => () => true,
tool: () => ({ execute: async () => await Promise.resolve({}) }), tool: () => ({ execute: async () => await Promise.resolve({}) }),
ToolLoopAgent: function M() { ToolLoopAgent: function M() {
@@ -593,8 +607,56 @@ describe("聊天 API 路由", () => {
db, db,
); );
const msgBody = (await msgRes.json()) as { items: Message[] }; const msgBody = (await msgRes.json()) as { items: Message[] };
expect(msgBody.items.length).toBeGreaterThanOrEqual(1); expect(msgBody.items.length).toBeGreaterThanOrEqual(2);
expect(msgBody.items.some((m) => m.role === "user")).toBe(true); expect(msgBody.items.some((m) => m.role === "user")).toBe(true);
expect(msgBody.items.some((m) => m.role === "assistant")).toBe(true);
handle.close();
} finally {
handle.cleanup();
}
});
test("首次发送消息时触发标题生成", async () => {
const handle = createMigratedMemoryTestDatabase("chat-send-title");
try {
const db = handle.db;
const projectId = seedProject(db);
const providerId = seedProvider(db);
const modelId = seedModel(db, providerId);
const createRes = await createConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
expect(created.title).toBe("新会话");
await sendChatViaHandler(
new Request(`http://localhost/api/projects/${projectId}/chat`, {
body: JSON.stringify({
conversationId: created.id,
messages: [{ parts: [{ text: "请帮我分析一下这个项目的性能瓶颈", type: "text" }], role: "user" }],
modelDbId: modelId,
}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
await new Promise((resolve) => setTimeout(resolve, 50));
const getRes = await getConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`),
db,
);
const body = (await getRes.json()) as { conversation: Conversation };
expect(body.conversation.title).not.toBe("新会话");
expect(body.conversation.title).toBe("AI总结标题");
handle.close(); handle.close();
} finally { } finally {
handle.cleanup(); handle.cleanup();