refactor: 聊天室 Agent 重构 — ToolLoopAgent + 论坛式布局

后端:
- 删除 agent-stream.ts,新建 alfred-agent.ts (ToolLoopAgent 工厂)
- 新建 get-current-time.ts 工具 (zod schema)
- 重构 send.ts: createAgentUIStreamResponse + onFinish 可靠持久化

前端:
- 删除 MessageBubble.tsx,新建 ToolCallCard.tsx (四态)
- 重构 ChatPanel.tsx: 论坛式 Card 布局 + PartRenderer 分派
- 移除 @ant-design/x 依赖,改用 antd 组件 + streamdown

依赖:
+ zod + streamdown
- @ant-design/x - @ant-design/x-markdown

测试: 306 pass, typecheck/lint 0 errors
This commit is contained in:
2026-05-31 17:25:29 +08:00
parent f83f434863
commit 6eeb4ced7b
16 changed files with 698 additions and 322 deletions

View File

@@ -1,71 +0,0 @@
import type { ModelMessage } from "ai";
import type Database from "bun:sqlite";
import { stepCountIs, streamText } from "ai";
import { eq } from "drizzle-orm";
import { wrap } from "../db/connection";
import { models, providers } from "../db/schema";
import { buildProviderRegistry } from "./registry";
const SYSTEM_PROMPT = "你是 Alfred 的 AI 助手。你可以帮助用户回答问题、分析数据和完成各种任务。请用中文回复。";
export interface AgentStreamOptions {
db: Database;
messages: IncomingMessage[];
modelDbId: string;
}
export interface IncomingMessage {
content?: string;
id?: string;
parts?: Array<{ text?: string; type: string }>;
role?: string;
}
export function agentStream(options: AgentStreamOptions) {
const db = wrap(options.db);
const modelRow = db.select().from(models).where(eq(models.id, options.modelDbId)).get();
if (!modelRow) throw new Error(`模型不存在: ${options.modelDbId}`);
const providerRow = db.select().from(providers).where(eq(providers.id, modelRow.providerId)).get();
if (!providerRow) throw new Error(`供应商不存在: ${modelRow.providerId}`);
const registry = buildProviderRegistry(options.db);
const model = registry.languageModel(`${providerRow.id}:${modelRow.modelId}`);
return streamText({
messages: toCoreMessages(options.messages),
model,
stopWhen: stepCountIs(1),
system: SYSTEM_PROMPT,
});
}
export function extractTextContent(msg: IncomingMessage): string {
return (
msg.content ??
(Array.isArray(msg.parts)
? msg.parts
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text!)
.join("")
: "")
);
}
function toCoreMessages(messages: IncomingMessage[]): ModelMessage[] {
return messages.map((msg) => {
const content =
msg.content ??
(Array.isArray(msg.parts)
? msg.parts
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text!)
.join("")
: "");
return { content, role: msg.role as ModelMessage["role"] } as ModelMessage;
});
}

View File

@@ -0,0 +1,20 @@
import { type LanguageModel, stepCountIs, ToolLoopAgent } from "ai";
import { getCurrentTime } from "../tools/get-current-time";
const SYSTEM_PROMPT = `你是 Alfred一个 AI 助手。
## 输出规范
- 使用中文回复
- 代码块用 Markdown 围栏语法,标注语言
- 给出结论时简洁直接,不要长篇铺垫
- 不确定的事明确说"不确定"`;
export function createAlfredAgent(model: LanguageModel) {
return new ToolLoopAgent({
instructions: SYSTEM_PROMPT,
model,
stopWhen: stepCountIs(20),
tools: { getCurrentTime },
});
}

View File

@@ -0,0 +1,37 @@
import { tool } from "ai";
import { z } from "zod";
export function formatCurrentTime(timezone?: string) {
const now = new Date();
const iso = now.toISOString();
const timestamp = now.getTime();
let local: string;
if (timezone) {
try {
local = new Intl.DateTimeFormat("zh-CN", {
dateStyle: "full",
timeStyle: "long",
timeZone: timezone,
}).format(now);
} catch {
local = now.toString();
}
} else {
local = new Intl.DateTimeFormat("zh-CN", {
dateStyle: "full",
timeStyle: "long",
}).format(now);
}
return { iso, local, timestamp };
}
export const getCurrentTime = tool({
description: "获取当前日期和时间。可选指定时区,默认返回本地时间。",
// eslint-disable-next-line @typescript-eslint/require-await
execute: async ({ timezone }) => formatCurrentTime(timezone),
inputSchema: z.object({
timezone: z.string().optional().describe("IANA 时区名称,如 'Asia/Shanghai'、'America/New_York'"),
}),
});

View File

@@ -1,10 +1,15 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { IncomingMessage } from "../../ai/agent-stream";
import { createAgentUIStreamResponse, type UIMessage } from "ai";
import { eq } from "drizzle-orm";
import { agentStream, extractTextContent } from "../../ai/agent-stream";
import type { RuntimeMode } from "../../../shared/api";
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 { models, providers } from "../../db/schema";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
@@ -15,7 +20,7 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
const validated = validateIdParam(projectId ?? "", mode);
if (validated instanceof Response) return validated;
let body: { conversationId?: string; messages?: IncomingMessage[] };
let body: { conversationId?: string; messages?: UIMessage[] };
try {
body = (await req.json()) as typeof body;
} catch {
@@ -46,44 +51,62 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
}
for (const msg of body.messages ?? []) {
const lastMsg = body.messages[body.messages.length - 1];
if (lastMsg?.role === "user") {
const content =
lastMsg.parts
?.filter((p) => p.type === "text")
.map((p) => p.text)
.join("") ?? "";
createMessage(db, {
content: extractTextContent(msg),
content,
conversationId: conversation.id,
role: (msg.role ?? "user") as "assistant" | "system" | "user",
parts: JSON.stringify(lastMsg.parts ?? []),
role: "user",
});
}
updateConversationTimestamp(db, conversation.id);
let model;
try {
const result = agentStream({
db,
messages: body.messages,
modelDbId: conversation.modelId,
const d = wrap(db);
const modelRow = d.select().from(models).where(eq(models.id, conversation.modelId)).get();
if (!modelRow) {
return jsonResponse(createApiError(`模型不存在: ${conversation.modelId}`, 500), { mode, status: 500 });
}
const providerRow = d.select().from(providers).where(eq(providers.id, modelRow.providerId)).get();
if (!providerRow) {
return jsonResponse(createApiError(`供应商不存在: ${modelRow.providerId}`, 500), { mode, status: 500 });
}
const registry = buildProviderRegistry(db);
model = registry.languageModel(`${providerRow.id}:${modelRow.modelId}`);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 });
}
try {
const agent = createAlfredAgent(model);
return await createAgentUIStreamResponse({
agent,
onFinish: ({ responseMessage }) => {
const text = responseMessage.parts
.filter((p): p is { text: string; type: "text" } => p.type === "text")
.map((p) => p.text)
.join("");
createMessage(db, {
content: text,
conversationId: conversation.id,
parts: JSON.stringify(responseMessage.parts),
role: "assistant",
});
updateConversationTimestamp(db, conversation.id);
},
uiMessages: body.messages,
});
const stream = result.toUIMessageStreamResponse();
const saveReply = async () => {
try {
const fullContent = await result.text;
if (fullContent) {
createMessage(db, {
content: fullContent,
conversationId: conversation.id,
role: "assistant",
});
updateConversationTimestamp(db, conversation.id);
}
} catch {
// stream ended without content, nothing to persist
}
};
void saveReply();
return stream;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return jsonResponse(createApiError(`AI 调用失败:${msg}`, 500), { mode, status: 500 });