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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -415,6 +415,7 @@ data/
|
|||||||
backend/bin
|
backend/bin
|
||||||
backend/server
|
backend/server
|
||||||
backend/desktop
|
backend/desktop
|
||||||
|
!src/**/*
|
||||||
|
|
||||||
# Embedfs generated
|
# Embedfs generated
|
||||||
embedfs/assets/
|
embedfs/assets/
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type Database from "bun:sqlite";
|
|||||||
|
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import type { Conversation, Message } from "../../shared/api";
|
import type { Conversation, Message, UpdateConversationRequest } from "../../shared/api";
|
||||||
|
|
||||||
import { paginateQuery, wrap } from "./connection";
|
import { paginateQuery, wrap } from "./connection";
|
||||||
import { conversations, messages, models } from "./schema";
|
import { conversations, messages, models } from "./schema";
|
||||||
@@ -150,6 +150,33 @@ export function listMessages(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateConversation(
|
||||||
|
raw: Database,
|
||||||
|
id: string,
|
||||||
|
data: UpdateConversationRequest,
|
||||||
|
): { conversation: Conversation } | { error: string; status: number } {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const existing = db.select().from(conversations).where(eq(conversations.id, id)).get();
|
||||||
|
if (!existing) return { error: "会话不存在", status: 404 };
|
||||||
|
|
||||||
|
const updates: { modelId?: string; title?: string; updatedAt: string } = { updatedAt: new Date().toISOString() };
|
||||||
|
|
||||||
|
if (data.modelId !== undefined) {
|
||||||
|
const model = db.select().from(models).where(eq(models.id, data.modelId)).get();
|
||||||
|
if (!model) return { error: "模型不存在", status: 400 };
|
||||||
|
updates.modelId = data.modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.title !== undefined) {
|
||||||
|
updates.title = data.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(conversations).set(updates).where(eq(conversations.id, id)).run();
|
||||||
|
|
||||||
|
const row = db.select().from(conversations).where(eq(conversations.id, id)).get();
|
||||||
|
return { conversation: toConversation(row!) };
|
||||||
|
}
|
||||||
|
|
||||||
export function updateConversationTimestamp(raw: Database, id: string): void {
|
export function updateConversationTimestamp(raw: Database, id: string): void {
|
||||||
const db = wrap(raw);
|
const db = wrap(raw);
|
||||||
db.update(conversations).set({ updatedAt: new Date().toISOString() }).where(eq(conversations.id, id)).run();
|
db.update(conversations).set({ updatedAt: new Date().toISOString() }).where(eq(conversations.id, id)).run();
|
||||||
|
|||||||
47
src/server/routes/chat/update.ts
Normal file
47
src/server/routes/chat/update.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { RuntimeMode, UpdateConversationRequest } from "../../../shared/api";
|
||||||
|
|
||||||
|
import { getConversation, updateConversation } from "../../db/conversations";
|
||||||
|
import { createApiError, jsonResponse } from "../../helpers";
|
||||||
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
|
export async function handleUpdateConversation(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const parts = url.pathname.split("/");
|
||||||
|
const projectId = parts[3];
|
||||||
|
const conversationId = parts[5];
|
||||||
|
|
||||||
|
const validatedProject = validateIdParam(projectId ?? "", mode);
|
||||||
|
if (validatedProject instanceof Response) return validatedProject;
|
||||||
|
|
||||||
|
const validatedConv = validateIdParam(conversationId ?? "", mode);
|
||||||
|
if (validatedConv instanceof Response) return validatedConv;
|
||||||
|
|
||||||
|
const existing = getConversation(db, validatedConv.id);
|
||||||
|
if ("error" in existing) {
|
||||||
|
return jsonResponse(createApiError(existing.error, existing.status), { mode, status: existing.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.conversation.projectId !== validatedProject.id) {
|
||||||
|
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: UpdateConversationRequest;
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as UpdateConversationRequest;
|
||||||
|
} catch {
|
||||||
|
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.modelId === undefined && body.title === undefined) {
|
||||||
|
return jsonResponse(createApiError("至少需要传 modelId 或 title", 400), { mode, status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = updateConversation(db, validatedConv.id, body);
|
||||||
|
if ("error" in result) {
|
||||||
|
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ conversation: result.conversation }, { mode });
|
||||||
|
}
|
||||||
@@ -201,6 +201,14 @@ export function startServer(options: StartServerOptions) {
|
|||||||
mode,
|
mode,
|
||||||
logger,
|
logger,
|
||||||
),
|
),
|
||||||
|
PATCH: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleUpdateConversation } = await import("./routes/chat/update");
|
||||||
|
return handleUpdateConversation(req, db, mode);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"/api/projects/:id/conversations/:cid/messages": {
|
"/api/projects/:id/conversations/:cid/messages": {
|
||||||
GET: withErrorHandler(
|
GET: withErrorHandler(
|
||||||
|
|||||||
@@ -42,11 +42,6 @@ export interface CreateProjectRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 在此定义你的业务类型
|
|
||||||
// 前后端共享的类型都放在这个文件中
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
export interface CreateProviderRequest {
|
export interface CreateProviderRequest {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@@ -54,6 +49,11 @@ export interface CreateProviderRequest {
|
|||||||
type: ProviderType;
|
type: ProviderType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 在此定义你的业务类型
|
||||||
|
// 前后端共享的类型都放在这个文件中
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
content: string;
|
content: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -104,6 +104,11 @@ export interface SendMessageRequest {
|
|||||||
messages: Array<{ content: string; role: "assistant" | "system" | "user" }>;
|
messages: Array<{ content: string; role: "assistant" | "system" | "user" }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateConversationRequest {
|
||||||
|
modelId?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const MODEL_CAPABILITIES: readonly ModelCapability[] = [
|
export const MODEL_CAPABILITIES: readonly ModelCapability[] = [
|
||||||
"audio-generation",
|
"audio-generation",
|
||||||
"audio-recognition",
|
"audio-recognition",
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||||
import { App, Button, Card, Collapse, Empty, Flex, Input, Spin, Typography } from "antd";
|
import { App, Button, Card, Empty, Flex, Input, Select, Spin } from "antd";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
|
||||||
|
|
||||||
import { fetchMessages } from "../../../../hooks/use-conversations";
|
import { fetchConversation, fetchMessages, updateConversation } from "../../../../hooks/use-conversations";
|
||||||
import { ToolCallCard } from "./ToolCallCard";
|
import { useModelList } from "../../../../hooks/use-models";
|
||||||
|
import { ReasoningPart } from "./parts/ReasoningPart";
|
||||||
|
import { TextPart } from "./parts/TextPart";
|
||||||
|
import { ToolPart } from "./parts/ToolPart";
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
conversationId: null | string;
|
conversationId: null | string;
|
||||||
@@ -16,9 +18,18 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
|
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
|
||||||
const fetchRef = useRef(fetchMessages);
|
const fetchRef = useRef(fetchMessages);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
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({
|
const { messages, sendMessage, setMessages, status, stop } = useChat({
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
void message.error(`发送失败:${err.message}`);
|
void message.error(`发送失败:${err.message}`);
|
||||||
@@ -45,8 +56,21 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
setInput("");
|
setInput("");
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
try {
|
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;
|
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()
|
||||||
@@ -71,7 +95,19 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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(() => {
|
const handleSend = useCallback(() => {
|
||||||
if (!input.trim() || !conversationId) return;
|
if (!input.trim() || !conversationId) return;
|
||||||
@@ -118,7 +154,7 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Flex align="end" className="chat-input-area" gap={8}>
|
<div className="chat-input-area">
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
autoSize={{ maxRows: 6, minRows: 1 }}
|
autoSize={{ maxRows: 6, minRows: 1 }}
|
||||||
className="chat-textarea"
|
className="chat-textarea"
|
||||||
@@ -133,21 +169,33 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
placeholder="输入消息..."
|
placeholder="输入消息..."
|
||||||
value={input}
|
value={input}
|
||||||
/>
|
/>
|
||||||
{isLoading ? (
|
<Flex align="center" gap={8} justify="space-between">
|
||||||
<Button
|
<Select
|
||||||
danger
|
className="chat-model-select"
|
||||||
onClick={() => {
|
disabled={isLoading}
|
||||||
void stop?.();
|
onChange={handleModelChange}
|
||||||
}}
|
options={modelOptions}
|
||||||
>
|
placeholder="选择模型"
|
||||||
停止
|
value={displayModelId}
|
||||||
</Button>
|
/>
|
||||||
) : (
|
<Flex align="center" gap={8}>
|
||||||
<Button onClick={handleSend} type="primary">
|
{isLoading ? (
|
||||||
发送
|
<Button
|
||||||
</Button>
|
danger
|
||||||
)}
|
onClick={() => {
|
||||||
</Flex>
|
void stop?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleSend} type="primary">
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,29 +203,14 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
|
|||||||
function PartRenderer({ part, role }: { part: Record<string, unknown>; role: string }) {
|
function PartRenderer({ part, role }: { part: Record<string, unknown>; role: string }) {
|
||||||
const partType = typeof part["type"] === "string" ? part["type"] : "";
|
const partType = typeof part["type"] === "string" ? part["type"] : "";
|
||||||
|
|
||||||
if (partType === "text" && role === "user") {
|
if (partType === "text") {
|
||||||
return <Typography.Paragraph className="message-body-text">{part["text"] as string}</Typography.Paragraph>;
|
return <TextPart part={part} role={role} />;
|
||||||
}
|
|
||||||
if (partType === "text" && role === "assistant") {
|
|
||||||
return <Streamdown parseIncompleteMarkdown>{part["text"] as string}</Streamdown>;
|
|
||||||
}
|
}
|
||||||
if (partType.startsWith("tool-")) {
|
if (partType.startsWith("tool-")) {
|
||||||
return <ToolCallCard part={part} />;
|
return <ToolPart part={part} />;
|
||||||
}
|
}
|
||||||
if (partType === "reasoning") {
|
if (partType === "reasoning") {
|
||||||
return (
|
return <ReasoningPart part={part} />;
|
||||||
<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 null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { App, Button, Flex, Popconfirm, Spin, Typography } from "antd";
|
|||||||
import type { Conversation } from "../../../../../shared/api";
|
import type { Conversation } from "../../../../../shared/api";
|
||||||
|
|
||||||
import { createConversation, deleteConversation, fetchConversations } from "../../../../hooks/use-conversations";
|
import { createConversation, deleteConversation, fetchConversations } from "../../../../hooks/use-conversations";
|
||||||
|
import { useModelList } from "../../../../hooks/use-models";
|
||||||
|
|
||||||
interface ChatSidebarProps {
|
interface ChatSidebarProps {
|
||||||
activeId: null | string;
|
activeId: null | string;
|
||||||
@@ -23,8 +24,12 @@ export function ChatSidebar({ activeId, onSelect, projectId }: ChatSidebarProps)
|
|||||||
queryKey: CONVERSATIONS_KEY,
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: () => createConversation(projectId),
|
mutationFn: () => createConversation(projectId, defaultModelId),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
void message.error(`创建会话失败:${err.message}`);
|
void message.error(`创建会话失败:${err.message}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface PartProps {
|
||||||
|
part: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ConversationListResponse,
|
ConversationListResponse,
|
||||||
ConversationResponse,
|
ConversationResponse,
|
||||||
MessageListResponse,
|
MessageListResponse,
|
||||||
|
UpdateConversationRequest,
|
||||||
} from "../../shared/api";
|
} from "../../shared/api";
|
||||||
|
|
||||||
import { handleResponse, handleVoidResponse } from "../utils/api";
|
import { handleResponse, handleVoidResponse } from "../utils/api";
|
||||||
@@ -43,3 +44,16 @@ export async function fetchMessages(projectId: string, conversationId: string):
|
|||||||
}
|
}
|
||||||
return response.json() as Promise<MessageListResponse>;
|
return response.json() as Promise<MessageListResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateConversation(
|
||||||
|
projectId: string,
|
||||||
|
conversationId: string,
|
||||||
|
data: UpdateConversationRequest,
|
||||||
|
): Promise<Conversation> {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`, {
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "PATCH",
|
||||||
|
});
|
||||||
|
return handleResponse(response, (data) => (data as ConversationResponse).conversation);
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ body {
|
|||||||
|
|
||||||
.chat-input-area {
|
.chat-input-area {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-top: 1px solid var(--ant-color-border-secondary);
|
border-top: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
@@ -177,7 +180,7 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 16px;
|
padding-left: var(--ant-padding-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-loading-indicator {
|
.chat-loading-indicator {
|
||||||
@@ -204,3 +207,23 @@ body {
|
|||||||
.msg-title-ai {
|
.msg-title-ai {
|
||||||
color: var(--ant-color-primary);
|
color: var(--ant-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.part-body {
|
||||||
|
padding: 0 var(--ant-padding-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-primary {
|
||||||
|
color: var(--ant-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-success {
|
||||||
|
color: var(--ant-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-error {
|
||||||
|
color: var(--ant-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-model-select {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,10 +57,15 @@ async function listMessagesViaHandler(req: Request, db: Database): Promise<Respo
|
|||||||
return h(req, db, MODE);
|
return h(req, db, MODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedModel(db: Database, providerId: string, modelName = "GPT-4o"): string {
|
async function patchConversationViaHandler(req: Request, db: Database): Promise<Response> {
|
||||||
|
const { handleUpdateConversation: h } = await import("../../../src/server/routes/chat/update");
|
||||||
|
return h(req, db, MODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedModel(db: Database, providerId: string, modelName = "GPT-4o", modelId = "gpt-4o"): string {
|
||||||
const result = createModel(db, {
|
const result = createModel(db, {
|
||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
modelId: "gpt-4o",
|
modelId,
|
||||||
name: modelName,
|
name: modelName,
|
||||||
providerId,
|
providerId,
|
||||||
});
|
});
|
||||||
@@ -352,6 +357,167 @@ describe("聊天 API 路由", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/projects/:id/conversations/:cid", () => {
|
||||||
|
test("更新会话 modelId 成功", async () => {
|
||||||
|
const handle = createMigratedMemoryTestDatabase("chat-patch-model");
|
||||||
|
try {
|
||||||
|
const db = handle.db;
|
||||||
|
const projectId = seedProject(db);
|
||||||
|
const providerId = seedProvider(db);
|
||||||
|
const modelId1 = seedModel(db, providerId, "GPT-4o", "gpt-4o");
|
||||||
|
const modelId2 = seedModel(db, providerId, "GPT-4o-mini", "gpt-4o-mini");
|
||||||
|
|
||||||
|
const createRes = await createConversationViaHandler(
|
||||||
|
new Request(`http://localhost/api/projects/${projectId}/conversations`, {
|
||||||
|
body: JSON.stringify({ modelId: modelId1 }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
|
||||||
|
|
||||||
|
const res = await patchConversationViaHandler(
|
||||||
|
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, {
|
||||||
|
body: JSON.stringify({ modelId: modelId2 }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "PATCH",
|
||||||
|
}),
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { conversation: Conversation };
|
||||||
|
expect(body.conversation.modelId).toBe(modelId2);
|
||||||
|
handle.close();
|
||||||
|
} finally {
|
||||||
|
handle.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("更新会话 title 成功", async () => {
|
||||||
|
const handle = createMigratedMemoryTestDatabase("chat-patch-title");
|
||||||
|
try {
|
||||||
|
const db = handle.db;
|
||||||
|
const projectId = seedProject(db);
|
||||||
|
const providerId = seedProvider(db);
|
||||||
|
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;
|
||||||
|
|
||||||
|
const res = await patchConversationViaHandler(
|
||||||
|
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, {
|
||||||
|
body: JSON.stringify({ title: "新标题" }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "PATCH",
|
||||||
|
}),
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { conversation: Conversation };
|
||||||
|
expect(body.conversation.title).toBe("新标题");
|
||||||
|
handle.close();
|
||||||
|
} finally {
|
||||||
|
handle.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("跨项目更新会话返回 403", async () => {
|
||||||
|
const handle = createMigratedMemoryTestDatabase("chat-patch-403");
|
||||||
|
try {
|
||||||
|
const db = handle.db;
|
||||||
|
const projectA = seedProject(db, "项目A");
|
||||||
|
const projectB = seedProject(db, "项目B");
|
||||||
|
const providerId = seedProvider(db);
|
||||||
|
seedModel(db, providerId);
|
||||||
|
|
||||||
|
const createRes = await createConversationViaHandler(
|
||||||
|
new Request(`http://localhost/api/projects/${projectA}/conversations`, {
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
|
||||||
|
|
||||||
|
const res = await patchConversationViaHandler(
|
||||||
|
new Request(`http://localhost/api/projects/${projectB}/conversations/${created.id}`, {
|
||||||
|
body: JSON.stringify({ title: "探测" }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "PATCH",
|
||||||
|
}),
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
handle.close();
|
||||||
|
} finally {
|
||||||
|
handle.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("不存在的会话返回 404", async () => {
|
||||||
|
const handle = createMigratedMemoryTestDatabase("chat-patch-404");
|
||||||
|
try {
|
||||||
|
const db = handle.db;
|
||||||
|
const projectId = seedProject(db);
|
||||||
|
|
||||||
|
const res = await patchConversationViaHandler(
|
||||||
|
new Request(`http://localhost/api/projects/${projectId}/conversations/nonexistent`, {
|
||||||
|
body: JSON.stringify({ title: "探测" }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "PATCH",
|
||||||
|
}),
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
handle.close();
|
||||||
|
} finally {
|
||||||
|
handle.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("无效 modelId 返回 400", async () => {
|
||||||
|
const handle = createMigratedMemoryTestDatabase("chat-patch-bad-model");
|
||||||
|
try {
|
||||||
|
const db = handle.db;
|
||||||
|
const projectId = seedProject(db);
|
||||||
|
const providerId = seedProvider(db);
|
||||||
|
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;
|
||||||
|
|
||||||
|
const res = await patchConversationViaHandler(
|
||||||
|
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, {
|
||||||
|
body: JSON.stringify({ modelId: "invalid-model-id" }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "PATCH",
|
||||||
|
}),
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
handle.close();
|
||||||
|
} finally {
|
||||||
|
handle.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GET /api/projects/:id/conversations/:cid/messages", () => {
|
describe("GET /api/projects/:id/conversations/:cid/messages", () => {
|
||||||
test("跨项目获取消息返回 403", async () => {
|
test("跨项目获取消息返回 403", async () => {
|
||||||
const handle = createMigratedMemoryTestDatabase("chat-msg-403");
|
const handle = createMigratedMemoryTestDatabase("chat-msg-403");
|
||||||
|
|||||||
Reference in New Issue
Block a user