From 8463274c4ba200c2daa5811a4465cb7dcccd1f71 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Mon, 1 Jun 2026 07:37:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=81=8A=E5=A4=A9=E9=A1=B5=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20=E2=80=94=20=E6=AC=A2=E8=BF=8E=E9=A1=B5=E3=80=81?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E3=80=81?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/user/usage.md | 8 + src/server/ai/tools/get-current-time.ts | 3 +- src/server/routes/chat/send.ts | 64 ++++- src/server/server.ts | 2 +- .../components/chat/ChatInputArea.tsx | 68 +++++ .../workbench/components/chat/ChatPanel.tsx | 247 +++++++++++++----- src/web/consoles/workbench/pages/ChatPage.tsx | 6 +- src/web/styles.css | 18 +- tests/server/routes/chat.test.ts | 3 +- tests/web/components/ChatPanel.test.tsx | 167 ++++++++++++ 10 files changed, 516 insertions(+), 70 deletions(-) create mode 100644 src/web/consoles/workbench/components/chat/ChatInputArea.tsx create mode 100644 tests/web/components/ChatPanel.test.tsx diff --git a/docs/user/usage.md b/docs/user/usage.md index 51fd718..9d24764 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -61,4 +61,12 @@ bun run dev config.yaml 在 Workbench 工作台中,默认进入聊天室页面。左侧为会话列表,可新建和删除会话;右侧为聊天面板,输入消息后 AI 将流式回复。 +未选择会话时,聊天面板显示欢迎页面,用户可直接输入消息发送,系统会自动创建新会话。会话标题在首次对话后自动生成。 + +消息支持以下操作(仅限最后一条消息): + +- **复制**:所有消息均支持复制文本内容 +- **编辑**:最后一条用户消息可编辑,确认后重新发送 +- **重新生成**:最后一条 AI 消息可重新生成回复 + 使用聊天功能前,需先在 Admin 管理台的模型管理页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。 diff --git a/src/server/ai/tools/get-current-time.ts b/src/server/ai/tools/get-current-time.ts index f440b10..3355836 100644 --- a/src/server/ai/tools/get-current-time.ts +++ b/src/server/ai/tools/get-current-time.ts @@ -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'"), }), diff --git a/src/server/routes/chat/send.ts b/src/server/routes/chat/send.ts index 7c24992..e952f98 100644 --- a/src/server/routes/chat/send.ts +++ b/src/server/routes/chat/send.ts @@ -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 { +export async function handleSendChat(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Promise { 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, }); diff --git a/src/server/server.ts b/src/server/server.ts index 4572d80..37e6c9b 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -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, diff --git a/src/web/consoles/workbench/components/chat/ChatInputArea.tsx b/src/web/consoles/workbench/components/chat/ChatInputArea.tsx new file mode 100644 index 0000000..99bd9be --- /dev/null +++ b/src/web/consoles/workbench/components/chat/ChatInputArea.tsx @@ -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 ( +
+ onInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void onSend(); + } + }} + placeholder="输入消息..." + value={input} + /> + + - - {isLoading ? ( - - ) : ( - - )} - - -
+ { + void handleSend(); + }} + onStop={() => { + void stop(); + }} + /> ); } diff --git a/src/web/consoles/workbench/pages/ChatPage.tsx b/src/web/consoles/workbench/pages/ChatPage.tsx index bfdb99b..f52b739 100644 --- a/src/web/consoles/workbench/pages/ChatPage.tsx +++ b/src/web/consoles/workbench/pages/ChatPage.tsx @@ -12,7 +12,11 @@ export function ChatPage() { return ( - + ); } diff --git a/src/web/styles.css b/src/web/styles.css index e7fc285..e60e557 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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); +} diff --git a/tests/server/routes/chat.test.ts b/tests/server/routes/chat.test.ts index 7259f22..5ca0600 100644 --- a/tests/server/routes/chat.test.ts +++ b/tests/server/routes/chat.test.ts @@ -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 { const { handleSendChat: h } = await import("../../../src/server/routes/chat/send"); - return h(req, db, MODE); + return h(req, db, MODE, createNoopLogger()); } describe("聊天 API 路由", () => { diff --git a/tests/web/components/ChatPanel.test.tsx b/tests/web/components/ChatPanel.test.tsx new file mode 100644 index 0000000..061e542 --- /dev/null +++ b/tests/web/components/ChatPanel.test.tsx @@ -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(); + }); + }); +});