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/server
|
||||
backend/desktop
|
||||
!src/**/*
|
||||
|
||||
# Embedfs generated
|
||||
embedfs/assets/
|
||||
|
||||
@@ -2,7 +2,7 @@ import type Database from "bun:sqlite";
|
||||
|
||||
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 { 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 {
|
||||
const db = wrap(raw);
|
||||
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,
|
||||
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": {
|
||||
GET: withErrorHandler(
|
||||
|
||||
@@ -42,11 +42,6 @@ export interface CreateProjectRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 在此定义你的业务类型
|
||||
// 前后端共享的类型都放在这个文件中
|
||||
// ==========================================
|
||||
|
||||
export interface CreateProviderRequest {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
@@ -54,6 +49,11 @@ export interface CreateProviderRequest {
|
||||
type: ProviderType;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 在此定义你的业务类型
|
||||
// 前后端共享的类型都放在这个文件中
|
||||
// ==========================================
|
||||
|
||||
export interface Message {
|
||||
content: string;
|
||||
conversationId: string;
|
||||
@@ -104,6 +104,11 @@ export interface SendMessageRequest {
|
||||
messages: Array<{ content: string; role: "assistant" | "system" | "user" }>;
|
||||
}
|
||||
|
||||
export interface UpdateConversationRequest {
|
||||
modelId?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const MODEL_CAPABILITIES: readonly ModelCapability[] = [
|
||||
"audio-generation",
|
||||
"audio-recognition",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
ConversationResponse,
|
||||
MessageListResponse,
|
||||
UpdateConversationRequest,
|
||||
} from "../../shared/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>;
|
||||
}
|
||||
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
@@ -177,7 +180,7 @@ body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
padding-left: var(--ant-padding-lg);
|
||||
}
|
||||
|
||||
.chat-loading-indicator {
|
||||
@@ -204,3 +207,23 @@ body {
|
||||
.msg-title-ai {
|
||||
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);
|
||||
}
|
||||
|
||||
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, {
|
||||
capabilities: ["text"],
|
||||
modelId: "gpt-4o",
|
||||
modelId,
|
||||
name: modelName,
|
||||
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", () => {
|
||||
test("跨项目获取消息返回 403", async () => {
|
||||
const handle = createMigratedMemoryTestDatabase("chat-msg-403");
|
||||
|
||||
Reference in New Issue
Block a user