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:
@@ -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;
|
||||
});
|
||||
}
|
||||
20
src/server/ai/agents/alfred-agent.ts
Normal file
20
src/server/ai/agents/alfred-agent.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
37
src/server/ai/tools/get-current-time.ts
Normal file
37
src/server/ai/tools/get-current-time.ts
Normal 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'"),
|
||||
}),
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import type { BubbleItemType } from "@ant-design/x";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { Bubble, Sender } from "@ant-design/x";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { App, Empty, Spin } from "antd";
|
||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||
import { App, Avatar, Button, Card, Collapse, Divider, Empty, Flex, Input, Spin, Typography } from "antd";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { fetchMessages } from "../../../../hooks/use-conversations";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { ToolCallCard } from "./ToolCallCard";
|
||||
|
||||
interface ChatPanelProps {
|
||||
conversationId: null | string;
|
||||
onConversationCreated: (id: string) => void;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
@@ -20,23 +17,21 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const fetchRef = useRef(fetchMessages);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const conversationIdRef = useRef(conversationId);
|
||||
useEffect(() => {
|
||||
conversationIdRef.current = conversationId;
|
||||
});
|
||||
|
||||
const { messages, sendMessage, setMessages, status } = useChat({
|
||||
const { messages, sendMessage, setMessages, status, stop } = useChat({
|
||||
onError: (err) => {
|
||||
void message.error(`发送失败:${err.message}`);
|
||||
},
|
||||
transport: new DefaultChatTransport({
|
||||
api: `/api/projects/${projectId}/chat`,
|
||||
}),
|
||||
transport: new DefaultChatTransport({ api: `/api/projects/${projectId}/chat` }),
|
||||
});
|
||||
|
||||
const isLoading = status === "submitted" || status === "streaming";
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ behavior: "smooth", top: scrollRef.current.scrollHeight });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setMessages([]);
|
||||
@@ -47,6 +42,7 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
|
||||
const load = async () => {
|
||||
setLoadingHistory(true);
|
||||
setInput("");
|
||||
setMessages([]);
|
||||
try {
|
||||
const data = await fetchRef.current(projectId, conversationId);
|
||||
@@ -54,9 +50,9 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
const history = data.items
|
||||
.filter((m: { role: string }) => m.role === "user" || m.role === "assistant")
|
||||
.reverse()
|
||||
.map((m: { content: string; id: string; role: string }) => ({
|
||||
.map((m: { content: string; id: string; parts: null | string; role: string }) => ({
|
||||
id: m.id,
|
||||
parts: [{ text: m.content, type: "text" as const }],
|
||||
parts: m.parts ? (JSON.parse(m.parts) as UIMessage["parts"]) : [{ text: m.content, type: "text" as const }],
|
||||
role: m.role as "assistant" | "user",
|
||||
}));
|
||||
setMessages(history);
|
||||
@@ -77,23 +73,12 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
};
|
||||
}, [conversationId, projectId, setMessages, message]);
|
||||
|
||||
const bubbleItems: BubbleItemType[] = messages.map((msg) => ({
|
||||
content: msg.parts
|
||||
.filter((p): p is { text: string; type: "text" } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join(""),
|
||||
key: msg.id,
|
||||
role: msg.role === "user" ? "user" : "ai",
|
||||
}));
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(nextInput: string) => {
|
||||
if (!nextInput.trim()) return;
|
||||
setInput("");
|
||||
void sendMessage({ text: nextInput }, { body: { conversationId: conversationIdRef.current } });
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
const handleSend = useCallback(() => {
|
||||
if (!input.trim() || !conversationId) return;
|
||||
const text = input;
|
||||
setInput("");
|
||||
void sendMessage({ text }, { body: { conversationId } });
|
||||
}, [input, sendMessage, conversationId]);
|
||||
|
||||
if (!conversationId) {
|
||||
return (
|
||||
@@ -110,23 +95,96 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<Bubble.List
|
||||
items={bubbleItems}
|
||||
role={{
|
||||
ai: {
|
||||
contentRender: (content: string) => <MessageBubble content={content} />,
|
||||
placement: "start",
|
||||
},
|
||||
user: {
|
||||
placement: "end",
|
||||
},
|
||||
}}
|
||||
style={{ flex: 1, overflow: "auto", padding: "16px" }}
|
||||
/>
|
||||
<div className="chat-scroll-area" ref={scrollRef}>
|
||||
<Flex gap={8} vertical>
|
||||
{messages.map((msg) => (
|
||||
<Card className={msg.role === "user" ? "msg-user" : "msg-ai"} key={msg.id} size="small">
|
||||
<Card.Meta
|
||||
avatar={
|
||||
<Avatar className={msg.role === "assistant" ? "avatar-ai" : undefined} size="small">
|
||||
{msg.role === "user" ? "你" : "AI"}
|
||||
</Avatar>
|
||||
}
|
||||
title={msg.role === "user" ? "你" : "Alfred"}
|
||||
/>
|
||||
<div className="message-body">
|
||||
{msg.parts.map((part: Record<string, unknown>, i: number) => (
|
||||
<PartRenderer key={i} part={part} role={msg.role} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{isLoading && (
|
||||
<Flex className="chat-loading-indicator" justify="center">
|
||||
<Spin size="small" />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
<div className="app-chat-panel-sender">
|
||||
<Sender loading={isLoading} onChange={setInput} onSubmit={onSubmit} placeholder="输入消息..." value={input} />
|
||||
</div>
|
||||
<Flex align="end" className="chat-input-area" gap={8}>
|
||||
<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}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
void stop?.();
|
||||
}}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSend} type="primary">
|
||||
发送
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PartRenderer({ part, role }: { part: Record<string, unknown>; role: string }) {
|
||||
const partType = typeof part["type"] === "string" ? part["type"] : "";
|
||||
|
||||
if (partType === "text" && role === "user") {
|
||||
return <Typography.Paragraph className="message-body-text">{part["text"] as string}</Typography.Paragraph>;
|
||||
}
|
||||
if (partType === "text" && role === "assistant") {
|
||||
return <Streamdown parseIncompleteMarkdown>{part["text"] as string}</Streamdown>;
|
||||
}
|
||||
if (partType.startsWith("tool-")) {
|
||||
return <ToolCallCard part={part} />;
|
||||
}
|
||||
if (partType === "reasoning") {
|
||||
return (
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
children: <Typography.Text type="secondary">{part["text"] as string}</Typography.Text>,
|
||||
key: "reasoning",
|
||||
label: <Typography.Text type="secondary">思考过程</Typography.Text>,
|
||||
},
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (partType === "step-start") {
|
||||
return <Divider className="step-divider" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
interface MessageBubbleProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function MessageBubble({ content }: MessageBubbleProps) {
|
||||
return <div className="app-chat-message-bubble">{content}</div>;
|
||||
}
|
||||
70
src/web/consoles/workbench/components/chat/ToolCallCard.tsx
Normal file
70
src/web/consoles/workbench/components/chat/ToolCallCard.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Collapse, Flex, Tag, Typography } from "antd";
|
||||
|
||||
interface ToolPart {
|
||||
errorText?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function getToolState(part: ToolPart) {
|
||||
if ("errorText" in part && part.errorText) return "output-error" as const;
|
||||
if ("output" in part) return "output-available" as const;
|
||||
if ("input" in part) return "input-available" as const;
|
||||
return "input-streaming" as const;
|
||||
}
|
||||
|
||||
const FORMAT_JSON = (v: unknown) => JSON.stringify(v, null, 2);
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolPart;
|
||||
}
|
||||
|
||||
export function ToolCallCard({ part }: ToolCallCardProps) {
|
||||
const state = getToolState(part);
|
||||
const toolName = part.toolName ?? (part.type ?? "unknown").replace(/^tool-/, "");
|
||||
|
||||
switch (state) {
|
||||
case "input-available":
|
||||
return <Tag color="processing">{toolName} · 执行中</Tag>;
|
||||
|
||||
case "input-streaming":
|
||||
return <Tag color="processing">生成参数中...</Tag>;
|
||||
|
||||
case "output-available":
|
||||
return (
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
children: (
|
||||
<Flex gap={4} vertical>
|
||||
<Typography.Text type="secondary">参数:</Typography.Text>
|
||||
<pre className="tool-result-pre">{FORMAT_JSON(part.input)}</pre>
|
||||
<Typography.Text type="secondary">结果:</Typography.Text>
|
||||
<pre className="tool-result-pre">{FORMAT_JSON(part.output)}</pre>
|
||||
</Flex>
|
||||
),
|
||||
key: part.toolCallId ?? toolName,
|
||||
label: (
|
||||
<>
|
||||
<Tag color="success">完成</Tag> {toolName}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
|
||||
case "output-error":
|
||||
return (
|
||||
<Flex align="center" gap={4}>
|
||||
<Tag color="error">失败</Tag>
|
||||
<Typography.Text type="danger">{part.errorText}</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,7 @@ export function ChatPage() {
|
||||
return (
|
||||
<Flex className="app-chat-page" gap={0} vertical={false}>
|
||||
<ChatSidebar activeId={activeConversationId} onSelect={setActiveConversationId} projectId={project.id} />
|
||||
<ChatPanel
|
||||
conversationId={activeConversationId}
|
||||
onConversationCreated={setActiveConversationId}
|
||||
projectId={project.id}
|
||||
/>
|
||||
<ChatPanel conversationId={activeConversationId} projectId={project.id} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,13 +150,58 @@ body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-chat-panel-sender {
|
||||
padding: var(--ant-padding-sm) var(--ant-padding);
|
||||
.chat-input-area {
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.app-chat-message-bubble {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
.msg-user .ant-card-body {
|
||||
background: var(--ant-color-bg-text-hover);
|
||||
}
|
||||
|
||||
.msg-ai .ant-card-body {
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.message-body {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.message-body pre {
|
||||
background: var(--ant-color-bg-layout);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-scroll-area {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-loading-indicator {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.chat-textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message-body-text {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.step-divider {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.tool-result-pre {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.avatar-ai {
|
||||
background-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user