feat: 聊天室模型选择器 + 会话更新 API + 消息部件重构

- 新增 PATCH /api/projects/:id/conversations/:cid 端点,支持更新 modelId 和 title
- 聊天面板新增模型选择下拉框,切换模型自动持久化
- 新建会话时传入默认文本模型 modelId
- 将 ToolCallCard 拆分为 ReasoningPart / TextPart / ToolPart 独立部件
- ToolPart 增加流式状态图标、折叠面板自动展开、错误详情展示
- ReasoningPart 增加思考中/思考完成状态指示
- 补充 PATCH 端点测试:更新成功、跨项目 403、不存在 404、无效 modelId 400
This commit is contained in:
2026-05-31 21:56:50 +08:00
parent 3e1f3b554d
commit f2e3d84fb1
15 changed files with 536 additions and 122 deletions

View File

@@ -1,11 +1,13 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { App, Button, Card, Collapse, Empty, Flex, Input, Spin, Typography } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { App, Button, Card, Empty, Flex, Input, Select, Spin } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchMessages } from "../../../../hooks/use-conversations";
import { ToolCallCard } from "./ToolCallCard";
import { fetchConversation, fetchMessages, updateConversation } from "../../../../hooks/use-conversations";
import { useModelList } from "../../../../hooks/use-models";
import { ReasoningPart } from "./parts/ReasoningPart";
import { TextPart } from "./parts/TextPart";
import { ToolPart } from "./parts/ToolPart";
interface ChatPanelProps {
conversationId: null | string;
@@ -16,9 +18,18 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
const { message } = App.useApp();
const [input, setInput] = useState("");
const [loadingHistory, setLoadingHistory] = useState(false);
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
const fetchRef = useRef(fetchMessages);
const scrollRef = useRef<HTMLDivElement>(null);
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = useMemo(
() => (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text")),
[modelsData],
);
const modelOptions = useMemo(() => textModels.map((m) => ({ label: m.name, value: m.id })), [textModels]);
const { messages, sendMessage, setMessages, status, stop } = useChat({
onError: (err) => {
void message.error(`发送失败:${err.message}`);
@@ -45,8 +56,21 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
setInput("");
setMessages([]);
try {
const data = await fetchRef.current(projectId, conversationId);
const convPromise = fetchConversation(projectId, conversationId);
const msgPromise = fetchRef.current(projectId, conversationId);
const conv = await convPromise;
const data = await msgPromise;
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
.filter((m: { role: string }) => m.role === "user" || m.role === "assistant")
.reverse()
@@ -71,7 +95,19 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
return () => {
cancelled = true;
};
}, [conversationId, projectId, setMessages, message]);
}, [conversationId, projectId, setMessages, message, textModels]);
const displayModelId = conversationId ? selectedModelId : (textModels[0]?.id ?? null);
const handleModelChange = useCallback(
(value: string) => {
setSelectedModelId(value);
if (conversationId) {
void updateConversation(projectId, conversationId, { modelId: value });
}
},
[projectId, conversationId],
);
const handleSend = useCallback(() => {
if (!input.trim() || !conversationId) return;
@@ -118,7 +154,7 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
</Flex>
</div>
)}
<Flex align="end" className="chat-input-area" gap={8}>
<div className="chat-input-area">
<Input.TextArea
autoSize={{ maxRows: 6, minRows: 1 }}
className="chat-textarea"
@@ -133,21 +169,33 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
placeholder="输入消息..."
value={input}
/>
{isLoading ? (
<Button
danger
onClick={() => {
void stop?.();
}}
>
</Button>
) : (
<Button onClick={handleSend} type="primary">
</Button>
)}
</Flex>
<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>
</div>
);
}
@@ -155,29 +203,14 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
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 === "text") {
return <TextPart part={part} role={role} />;
}
if (partType.startsWith("tool-")) {
return <ToolCallCard part={part} />;
return <ToolPart 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"
/>
);
return <ReasoningPart part={part} />;
}
return null;
}

View File

@@ -5,6 +5,7 @@ import { App, Button, Flex, Popconfirm, Spin, Typography } from "antd";
import type { Conversation } from "../../../../../shared/api";
import { createConversation, deleteConversation, fetchConversations } from "../../../../hooks/use-conversations";
import { useModelList } from "../../../../hooks/use-models";
interface ChatSidebarProps {
activeId: null | string;
@@ -23,8 +24,12 @@ export function ChatSidebar({ activeId, onSelect, projectId }: ChatSidebarProps)
queryKey: CONVERSATIONS_KEY,
});
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text"));
const defaultModelId = textModels[0]?.id;
const createMutation = useMutation({
mutationFn: () => createConversation(projectId),
mutationFn: () => createConversation(projectId, defaultModelId),
onError: (err: Error) => {
void message.error(`创建会话失败:${err.message}`);
},

View File

@@ -1,70 +0,0 @@
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>
);
}
}

View File

@@ -0,0 +1,34 @@
import { CheckCircleFilled, LoadingOutlined } from "@ant-design/icons";
import { Collapse, Flex, Typography } from "antd";
import type { PartProps } from "./types";
export function ReasoningPart({ part }: PartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
const state = typeof part["state"] === "string" ? part["state"] : "";
const isStreaming = state === "streaming";
return (
<Collapse
ghost
items={[
{
children: <Typography.Text type="secondary">{text}</Typography.Text>,
key: "reasoning",
label: isStreaming ? (
<Flex align="center" component="span" gap={4}>
<LoadingOutlined className="icon-primary" />
<Typography.Text type="secondary"></Typography.Text>
</Flex>
) : (
<Flex align="center" component="span" gap={4}>
<CheckCircleFilled className="icon-success" />
<Typography.Text type="secondary"></Typography.Text>
</Flex>
),
},
]}
size="small"
/>
);
}

View File

@@ -0,0 +1,22 @@
import { Typography } from "antd";
import { Streamdown } from "streamdown";
import type { PartProps } from "./types";
interface TextPartProps extends PartProps {
role: string;
}
export function TextPart({ part, role }: TextPartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
return (
<div className="part-body">
{role === "user" ? (
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : (
<Streamdown parseIncompleteMarkdown>{text}</Streamdown>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from "@ant-design/icons";
import { Collapse, Flex, Typography } from "antd";
import type { PartProps } from "./types";
interface ToolPartData {
errorText?: string;
input?: unknown;
output?: unknown;
toolCallId?: string;
toolName?: string;
type?: string;
}
function getToolState(part: ToolPartData) {
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);
export function ToolPart({ part }: PartProps) {
const toolPart = part as unknown as ToolPartData;
const state = getToolState(toolPart);
const toolName = toolPart.toolName ?? (toolPart.type ?? "unknown").replace(/^tool-/, "");
const isStreaming = state === "input-streaming" || state === "input-available";
if (state === "output-error") {
return (
<Collapse
ghost
items={[
{
children: <Typography.Text type="danger">{toolPart.errorText}</Typography.Text>,
key: toolPart.toolCallId ?? toolName,
label: (
<Flex align="center" component="span" gap={4}>
<CloseCircleFilled className="icon-error" />
<Typography.Text type="danger">{toolName} </Typography.Text>
</Flex>
),
},
]}
size="small"
/>
);
}
return (
<Collapse
defaultActiveKey={isStreaming ? [toolPart.toolCallId ?? toolName] : undefined}
ghost
items={[
{
children: (
<Flex gap={4} vertical>
{toolPart.input != null && (
<>
<Typography.Text type="secondary"></Typography.Text>
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.input)}</pre>
</>
)}
{"output" in toolPart && toolPart.output != null && (
<>
<Typography.Text type="secondary"></Typography.Text>
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.output)}</pre>
</>
)}
{!toolPart.input && !("output" in toolPart) && (
<Typography.Text type="secondary">...</Typography.Text>
)}
</Flex>
),
key: toolPart.toolCallId ?? toolName,
label: isStreaming ? (
<Flex align="center" component="span" gap={4}>
<LoadingOutlined className="icon-primary" />
<Typography.Text type="secondary">
{state === "input-streaming" ? "生成参数" : `调用 ${toolName}`}
</Typography.Text>
</Flex>
) : (
<Flex align="center" component="span" gap={4}>
<CheckCircleFilled className="icon-success" />
<Typography.Text type="secondary">{toolName}</Typography.Text>
</Flex>
),
},
]}
size="small"
/>
);
}

View File

@@ -0,0 +1,3 @@
export interface PartProps {
part: Record<string, unknown>;
}