refactor: remove legacy chat pages and home variant switch

This commit is contained in:
Your Name 2026-05-26 19:32:37 +08:00
parent 77ab711d41
commit 1db4444c7b
27 changed files with 40 additions and 4997 deletions

View File

@ -1,83 +1,5 @@
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
import { convertToUIMessages } from "@/lib/utils";
export default function Page(props: { params: Promise<{ id: string }> }) {
return (
<Suspense fallback={<div className="flex h-dvh" />}>
<ChatPage params={props.params} />
</Suspense>
);
}
async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const chat = await getChatById({ id });
if (!chat) {
notFound();
}
const session = await auth();
// 如果用户未登录,重定向到登录页面
if (!session?.user) {
redirect("/login");
}
if (chat.visibility === "private") {
if (session.user.id !== chat.userId) {
return notFound();
}
}
const messagesFromDb = await getMessagesByChatId({
id,
});
const uiMessages = convertToUIMessages(messagesFromDb);
const cookieStore = await cookies();
const chatModelFromCookie = cookieStore.get("chat-model");
if (!chatModelFromCookie) {
return (
<>
<Chat
autoResume={true}
id={chat.id}
initialChatModel={DEFAULT_CHAT_MODEL}
initialLastContext={chat.lastContext ?? undefined}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
userType={session?.user?.type}
/>
<DataStreamHandler />
</>
);
}
return (
<>
<Chat
autoResume={true}
id={chat.id}
initialChatModel={chatModelFromCookie.value}
initialLastContext={chat.lastContext ?? undefined}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
userType={session?.user?.type}
/>
<DataStreamHandler />
</>
);
export default function Page() {
redirect("/");
}

View File

@ -1,110 +0,0 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { ClassicChatStart } from "@/components/classic-chat-start";
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags";
import {
getDefaultAiModel,
getEnabledAiModelsWithProvider,
getUserById,
} from "@/lib/db/queries";
import { generateUUID } from "@/lib/utils";
import { auth } from "../../(auth)/auth";
function isNanoBananaProModel(model: {
displayName?: string | null;
modelId?: string | null;
}) {
const displayName = String(model.displayName || "").toLowerCase();
const modelId = String(model.modelId || "").toLowerCase();
return (
displayName.includes("nano banana pro") ||
displayName.includes("nano-banana-pro") ||
modelId.includes("nano-banana-pro") ||
modelId.includes("nano_banana_pro") ||
modelId.includes("nano-banana")
);
}
export default function ClassicPage() {
return (
<Suspense fallback={<div className="flex h-dvh" />}>
<ClassicChatPage />
</Suspense>
);
}
async function ClassicChatPage() {
const id = generateUUID();
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
if (!session?.user) {
redirect("/login");
}
const modelIdFromCookie = cookieStore.get("chat-model");
let initialModelId = modelIdFromCookie?.value;
const currentUser = session.user.id
? await getUserById({ id: session.user.id }).catch(() => undefined)
: undefined;
const allowHiddenModels =
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
const defaultModel = await getDefaultAiModel();
const enabledModels = await getEnabledAiModelsWithProvider(false, true);
const visibleModels = enabledModels.filter(
({ model }) =>
!isAiModelAgentAuto(model) && (allowHiddenModels || !model.isHidden)
);
const preferredImageModels = visibleModels.filter(
({ model, provider }) =>
model.supportsImageGen &&
!isVideoModelDescriptor({
providerType: provider.type,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
})
);
const nanoBananaModel = preferredImageModels.find(({ model }) =>
isNanoBananaProModel(model)
);
const cookieModel = initialModelId
? visibleModels.find(({ model }) => model.id === initialModelId)
: undefined;
if (!initialModelId || !cookieModel) {
if (nanoBananaModel) {
initialModelId = nanoBananaModel.model.id;
} else if (
defaultModel &&
!isAiModelAgentAuto(defaultModel) &&
(allowHiddenModels || !defaultModel.isHidden) &&
preferredImageModels.some(({ model }) => model.id === defaultModel.id)
) {
initialModelId = defaultModel.id;
} else if (preferredImageModels.length > 0) {
const preferredDefaultImageModel = preferredImageModels.find(
({ model }) => model.isDefault
);
initialModelId = (preferredDefaultImageModel || preferredImageModels[0])
.model.id;
} else if (
defaultModel &&
!isAiModelAgentAuto(defaultModel) &&
(allowHiddenModels || !defaultModel.isHidden)
) {
initialModelId = defaultModel.id;
} else if (visibleModels.length > 0) {
initialModelId = visibleModels[0].model.id;
}
}
return (
<ClassicChatStart
id={id}
initialModelId={initialModelId || ""}
userType={session.user.type}
/>
);
}

View File

@ -1,4 +1,3 @@
import { cookies } from "next/headers";
import { Suspense } from "react";
import { ChatShell } from "@/components/chat-shell";
import { DataStreamProvider } from "@/components/data-stream-provider";
@ -6,7 +5,6 @@ import { auth } from "../(auth)/auth";
import {
getNavLinks,
getSiteSettings,
getSystemSetting,
getTutorialVideos,
} from "@/lib/db/queries";
@ -21,18 +19,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}
async function SidebarWrapper({ children }: { children: React.ReactNode }) {
const [session, cookieStore, siteSettings, navLinks, tutorialVideos, homeVariantSetting] =
const [session, siteSettings, navLinks, tutorialVideos] =
await Promise.all([
auth(),
cookies(),
getSiteSettings(),
getNavLinks(),
getTutorialVideos(),
getSystemSetting({ key: "home_variant" }).catch(() => undefined),
]);
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
const homeVariant =
homeVariantSetting?.value === "lovart" ? "lovart" : "classic";
const settingsHref =
session?.user?.type === "admin"
? "/settings"
@ -43,8 +36,6 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
return (
<ChatShell
user={session?.user}
isCollapsed={isCollapsed}
homeVariant={homeVariant}
siteName={siteSettings.siteName}
siteLogo={siteSettings.siteLogo || undefined}
siteLogoDark={siteSettings.siteLogoDark || undefined}

View File

@ -1,11 +1,7 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { LovartHome } from "@/components/home/lovart-home";
import { auth } from "../(auth)/auth";
import {
getSiteSettings,
getSystemSetting,
} from "@/lib/db/queries";
import { getSiteSettings, getSystemSetting } from "@/lib/db/queries";
export default function Page() {
return (
@ -16,25 +12,19 @@ export default function Page() {
}
async function NewChatPage() {
const [session, siteSettings, homeVariantSetting, surpriseCategoriesSetting] =
const [session, siteSettings, surpriseCategoriesSetting] =
await Promise.all([
auth(),
getSiteSettings(),
getSystemSetting({ key: "home_variant" }).catch(() => undefined),
getSystemSetting({ key: "home_surprise_categories" }).catch(() => undefined),
]);
const homeVariant = homeVariantSetting?.value === "lovart" ? "lovart" : "classic";
const surpriseCategories = Array.isArray(surpriseCategoriesSetting?.value)
? surpriseCategoriesSetting?.value
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: ["全部", "精选"];
if (homeVariant !== "lovart") {
redirect("/classic");
}
return (
<LovartHome
user={

View File

@ -118,7 +118,6 @@ import {
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
import type { AgentCategoryTreeConfig } from "@/types/agent";
const HOME_VARIANT_KEY = "home_variant";
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
const AI_DETAIL_GENERATOR_ENABLED_KEY = "ai_detail_generator_enabled";
@ -314,10 +313,6 @@ function createAgentWorkflowSettingsSignature(valueByKey: Map<string, unknown>):
return createSettingsValueSignature(createAgentWorkflowSettingsStateFromValues(valueByKey));
}
function normalizeHomeVariant(value: unknown): "classic" | "lovart" {
return value === "lovart" ? "lovart" : "classic";
}
function normalizeSurpriseCategories(value: unknown): string[] {
if (!Array.isArray(value)) {
return ["全部", "精选"];
@ -933,7 +928,6 @@ export async function GET(request: NextRequest) {
const [
settings,
notificationSetting,
homeVariantSetting,
surpriseCategoriesSetting,
aiDetailGeneratorEnabledSetting,
lovartComposerPreviewTextsSetting,
@ -975,12 +969,6 @@ export async function GET(request: NextRequest) {
.where(eq(systemSettings.key, "login_notification"))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, HOME_VARIANT_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
@ -1177,7 +1165,6 @@ export async function GET(request: NextRequest) {
]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
const homeVariant = normalizeHomeVariant(homeVariantSetting?.value);
const surpriseCategories = normalizeSurpriseCategories(surpriseCategoriesSetting?.value);
const aiDetailGenerationEnabled = Boolean(aiDetailGeneratorEnabledSetting?.value);
const lovartComposerPreviewTexts = normalizeLovartComposerPreviewTexts(
@ -1335,7 +1322,6 @@ export async function GET(request: NextRequest) {
lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
homeVariant,
surpriseCategories,
aiDetailGenerationEnabled,
lovartComposerPrefix,
@ -1358,7 +1344,6 @@ export async function GET(request: NextRequest) {
lovartHeroTitle: baseSettings.lovartHeroTitle || "由壹点AI 让设计更简单",
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
notification,
homeVariant,
surpriseCategories,
aiDetailGenerationEnabled,
lovartComposerPrefix,
@ -1836,7 +1821,6 @@ const updateSchema = z.object({
lovartPromoActionText: z.string().max(32).optional(),
lovartHeroTitle: z.string().max(300).optional(),
lovartHeroSubtitle: z.string().max(300).optional(),
homeVariant: z.enum(["classic", "lovart"]).optional(),
surpriseCategories: z.array(z.string().trim().min(1).max(20)).max(12).optional(),
lovartComposerPrefix: z.string().trim().min(1).max(40).optional(),
lovartComposerPreviewTexts: z.array(z.string().trim().min(1).max(120)).max(20).optional(),
@ -1926,7 +1910,6 @@ export async function PUT(request: NextRequest) {
}
const data = updateSchema.parse(body);
const {
homeVariant,
surpriseCategories,
aiDetailGenerationEnabled,
lovartComposerPrefix,
@ -2138,25 +2121,6 @@ export async function PUT(request: NextRequest) {
.where(eq(siteSettings.id, existing.id))
.returning();
if (homeVariant !== undefined) {
await db
.insert(systemSettings)
.values({
key: HOME_VARIANT_KEY,
value: homeVariant,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: homeVariant,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (surpriseCategories !== undefined) {
const normalized = normalizeSurpriseCategories(surpriseCategories);
await db
@ -2644,7 +2608,6 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
...updated,
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
surpriseCategories:
surpriseCategories !== undefined
? normalizeSurpriseCategories(surpriseCategories)
@ -2809,25 +2772,6 @@ export async function PUT(request: NextRequest) {
})
.returning();
if (homeVariant !== undefined) {
await db
.insert(systemSettings)
.values({
key: HOME_VARIANT_KEY,
value: homeVariant,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: homeVariant,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (surpriseCategories !== undefined) {
const normalized = normalizeSurpriseCategories(surpriseCategories);
await db
@ -3293,7 +3237,6 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
...created,
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
surpriseCategories:
surpriseCategories !== undefined
? normalizeSurpriseCategories(surpriseCategories)

View File

@ -1,140 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { User } from "next-auth";
import { useState } from "react";
import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { PlusIcon, TrashIcon } from "@/components/icons";
import {
getChatHistoryPaginationKey,
SidebarHistory,
} from "@/components/sidebar-history";
import { SidebarUserNav } from "@/components/sidebar-user-nav";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function AppSidebar({
user,
}: {
user: User | undefined;
}) {
const router = useRouter();
const { setOpenMobile } = useSidebar();
const { mutate } = useSWRConfig();
const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false);
const handleDeleteAll = () => {
const deletePromise = fetch("/api/history", {
method: "DELETE",
});
toast.promise(deletePromise, {
loading: "正在删除所有会话...",
success: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
router.push("/");
setShowDeleteAllDialog(false);
return "所有会话已删除";
},
error: "删除会话失败,请稍后重试",
});
};
return (
<>
<Sidebar className="group-data-[side=left]:border-r-0">
<SidebarHeader>
<SidebarMenu>
<div className="flex flex-row items-center justify-between w-full">
{/* Logo removed */}
<div className="flex-1" />
<div className="flex flex-row gap-1">
{user && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 p-1 md:h-fit md:p-2"
onClick={() => setShowDeleteAllDialog(true)}
type="button"
variant="ghost"
>
<TrashIcon />
</Button>
</TooltipTrigger>
<TooltipContent align="end" side="bottom" className="hidden md:block">
删除所有会话
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 p-1 md:h-fit md:p-2"
onClick={() => {
setOpenMobile(false);
router.push("/");
router.refresh();
}}
type="button"
variant="ghost"
>
<PlusIcon />
</Button>
</TooltipTrigger>
<TooltipContent align="end" side="bottom" className="hidden md:block">
新建会话
</TooltipContent>
</Tooltip>
</div>
</div>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarHistory user={user} />
</SidebarContent>
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
</Sidebar>
<AlertDialog
onOpenChange={setShowDeleteAllDialog}
open={showDeleteAllDialog}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定删除全部会话?</AlertDialogTitle>
<AlertDialogDescription>
操作不可撤销,所有会话记录将从服务器永久移除。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteAll}>
删除所有
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -1,61 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { memo } from "react";
import { useWindowSize } from "usehooks-ts";
import { SidebarToggle } from "@/components/sidebar-toggle";
import { Button } from "@/components/ui/button";
import { PlusIcon } from "@/components/icons";
import { useSidebar } from "@/components/ui/sidebar";
import { VisibilitySelector, type VisibilityType } from "@/components/visibility-selector";
function PureChatHeader({
chatId,
selectedVisibilityType,
isReadonly,
}: {
chatId: string;
selectedVisibilityType: VisibilityType;
isReadonly: boolean;
}) {
const router = useRouter();
const { open } = useSidebar();
const { width: windowWidth } = useWindowSize();
return (
<header className="sticky top-0 flex items-center gap-2 bg-background px-2 py-1.5 md:px-2">
<SidebarToggle />
{(!open || windowWidth < 768) && (
<Button
className="order-2 ml-auto h-8 px-2 md:order-1 md:ml-0 md:h-fit md:px-2"
onClick={() => {
router.push("/");
router.refresh();
}}
variant="outline"
>
<PlusIcon />
<span className="md:sr-only">新建会话</span>
</Button>
)}
{!isReadonly && (
<VisibilitySelector
chatId={chatId}
className="order-1 md:order-2"
selectedVisibilityType={selectedVisibilityType}
/>
)}
</header>
);
}
export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => {
return (
prevProps.chatId === nextProps.chatId &&
prevProps.selectedVisibilityType === nextProps.selectedVisibilityType &&
prevProps.isReadonly === nextProps.isReadonly
);
});

View File

@ -1,9 +1,7 @@
"use client";
import type { User } from "next-auth";
import { AppSidebar } from "@/components/app-sidebar";
import { TopNavbar } from "@/components/top-navbar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
type CustomLink = {
id: string;
@ -25,7 +23,6 @@ type TutorialVideo = {
export type ChatShellChromeProps = {
children: React.ReactNode;
user?: User;
isCollapsed: boolean;
siteName: string;
siteLogo?: string;
siteLogoDark?: string;
@ -47,7 +44,6 @@ export type ChatShellChromeProps = {
export function ChatShellChrome({
children,
user,
isCollapsed,
siteName,
siteLogo,
siteLogoDark,
@ -66,7 +62,7 @@ export function ChatShellChrome({
videoCanvasEnabled,
}: ChatShellChromeProps) {
return (
<SidebarProvider defaultOpen={!isCollapsed}>
<>
<TopNavbar
agentEnabled={agentEnabled}
customLinks={customLinks}
@ -86,9 +82,8 @@ export function ChatShellChrome({
videoCanvasEnabled={videoCanvasEnabled}
/>
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
<AppSidebar user={user} />
<SidebarInset className="h-full">{children}</SidebarInset>
<main className="h-full min-w-0 flex-1">{children}</main>
</div>
</SidebarProvider>
</>
);
}

View File

@ -25,8 +25,6 @@ type TutorialVideo = {
type ChatShellProps = {
children: React.ReactNode;
user?: User;
isCollapsed: boolean;
homeVariant: "classic" | "lovart";
siteName: string;
siteLogo?: string;
siteLogoDark?: string;
@ -59,8 +57,6 @@ const ChatShellChrome = dynamic(
export function ChatShell({
children,
user,
isCollapsed,
homeVariant,
siteName,
siteLogo,
siteLogoDark,
@ -79,7 +75,7 @@ export function ChatShell({
videoCanvasEnabled,
}: ChatShellProps) {
const pathname = usePathname();
const isLovartHome = pathname === "/" && homeVariant === "lovart";
const isLovartHome = pathname === "/";
const isAiCadWorkspace = pathname === "/ai-cad";
const isFlowBoardWorkspace = pathname === "/flow-board";
@ -96,7 +92,6 @@ export function ChatShell({
children,
customLinks,
isAdmin,
isCollapsed,
productPlanningEnabled,
rankingEnabled,
settingsHref,

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +0,0 @@
import type { UserType } from "@/app/(auth)/auth";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
type ClassicChatStartProps = {
id: string;
initialModelId: string;
userType?: UserType;
};
export function ClassicChatStart({
id,
initialModelId,
userType,
}: ClassicChatStartProps) {
return (
<>
<Chat
autoResume={false}
id={id}
initialChatModel={initialModelId}
initialMessages={[]}
initialVisibilityType="private"
isReadonly={false}
key={id}
userType={userType}
/>
<DataStreamHandler />
</>
);
}

View File

@ -1,83 +0,0 @@
"use client";
import { useEffect } from "react";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { artifactDefinitions } from "./artifact";
import { useDataStream } from "./data-stream-provider";
export function DataStreamHandler() {
const { dataStream, setDataStream } = useDataStream();
const { artifact, setArtifact, setMetadata } = useArtifact();
useEffect(() => {
if (!dataStream?.length) {
return;
}
const newDeltas = dataStream.slice();
setDataStream([]);
for (const delta of newDeltas) {
const artifactDefinition = artifactDefinitions.find(
(currentArtifactDefinition) =>
currentArtifactDefinition.kind === artifact.kind
);
if (artifactDefinition?.onStreamPart) {
artifactDefinition.onStreamPart({
streamPart: delta,
setArtifact,
setMetadata,
});
}
setArtifact((draftArtifact) => {
if (!draftArtifact) {
return { ...initialArtifactData, status: "streaming" };
}
switch (delta.type) {
case "data-id":
return {
...draftArtifact,
documentId: delta.data,
status: "streaming",
};
case "data-title":
return {
...draftArtifact,
title: delta.data,
status: "streaming",
};
case "data-kind":
return {
...draftArtifact,
kind: delta.data,
status: "streaming",
};
case "data-clear":
return {
...draftArtifact,
content: "",
status: "streaming",
};
case "data-finish":
return {
...draftArtifact,
status: "idle",
};
default:
return draftArtifact;
}
});
}
}, [dataStream, setArtifact, setMetadata, artifact, setDataStream]);
return null;
}

View File

@ -1,804 +0,0 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { ArrowDownIcon } from "lucide-react";
import { memo, useEffect, useState, useRef } from "react";
import useSWR from "swr";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { useMessages } from "@/hooks/use-messages";
import type { PendingGeneration } from "@/hooks/use-pending-generation";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { useDataStream } from "./data-stream-provider";
import { GeminiImageLoading } from "./gemini-image-loading";
import { QianwenVideoLoading } from "./qianwen-video-loading";
import { VolcanoVisionLoading } from "./volcano-vision-loading";
import { ImageGenerationLoading } from "./image-generation-loading";
import { Greeting } from "./greeting";
import { PreviewMessage, ThinkingMessage } from "./message";
// 模型数据类型
type DynamicModel = {
id: string;
providerType: string;
modelId: string;
displayName: string;
supportsImageGen?: boolean;
capabilities?: unknown;
};
const fetcher = (url: string) => fetch(url).then((res) => res.json());
type MessagesProps = {
chatId: string;
status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
isReadonly: boolean;
isArtifactVisible: boolean;
selectedModelId: string;
onImageEdit?: (imageUrl: string) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
onImageAngleEdit?: (imageUrl: string) => void;
onImageWatermarkRemove?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
isProcessingAngleEdit?: boolean;
isProcessingWatermarkRemove?: boolean;
pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态
imageCount?: number; // 🆕 用户选择的图片数量
isGeneratingImageFlag?: boolean; // 🆕 正在生成图片的标志(来自 Chat 组件)
imageGenerationPhase?: "idle" | "queued" | "processing" | "finalizing" | "failed";
imageGenerationStartedAt?: number | null;
};
function PureMessages({
chatId,
status,
votes,
messages,
setMessages,
regenerate,
sendMessage,
isReadonly,
selectedModelId,
onImageEdit,
onDrawingComplete,
onImageEnhance,
onImageRmbg,
onImageExtend,
onImageAngleEdit,
onImageWatermarkRemove,
isProcessingEnhance = false,
isProcessingRmbg = false,
isProcessingExtend = false,
isProcessingAngleEdit = false,
isProcessingWatermarkRemove = false,
pendingGeneration,
imageCount = 1,
isGeneratingImageFlag = false,
imageGenerationPhase = "idle",
imageGenerationStartedAt = null,
}: MessagesProps) {
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
isAtBottom,
scrollToBottom,
hasSentMessage,
} = useMessages({
status,
});
useDataStream();
// 获取模型列表以判断当前模型类型
const { data: modelsData } = useSWR<{ models: DynamicModel[] }>(
"/api/models",
fetcher
);
// 判断当前选中的模型类型
// 🆕 包含 Gemini 和 Grsai GPT Image 图片生成 provider
const isGeminiModel = modelsData?.models?.some(
(m: DynamicModel) => m.id === selectedModelId && (
m.providerType === "gemini" ||
m.providerType === "gemini-image" ||
m.providerType === "grsai-gpt-image" ||
m.providerType === "huanke-gemini"
)
) ?? false;
const isQianwenVideoModel = modelsData?.models?.some(
(m: DynamicModel) =>
m.id === selectedModelId &&
(m.providerType === "qianwen-video" || m.providerType === "fal-video" || m.providerType === "ruxa-video" || m.providerType === "krea")
) ?? false;
const isVolcanoVisionModel = modelsData?.models?.some(
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "volcano-vision"
) ?? false;
// 🆕 判断是否是阿里生图模型(或者智能自动模式默认使用阿里生图占位图)
const isAlibabaImageModel = modelsData?.models?.some(
(m: DynamicModel) =>
m.id === selectedModelId &&
isImageGenerationModelDescriptor({
providerType: m.providerType,
modelId: m.modelId,
supportsImageGen: m.supportsImageGen,
})
) ?? false;
// 🆕 获取当前模型的显示名称
const currentModelDisplayName = modelsData?.models?.find(
(m: DynamicModel) => m.id === selectedModelId
)?.displayName;
const currentModelProviderType = modelsData?.models?.find(
(m: DynamicModel) => m.id === selectedModelId
)?.providerType;
// 判断是否是视频生成模型(通过 providerType 或模型名称)
const isVideoModel = modelsData?.models?.some(
(m: DynamicModel) =>
m.id === selectedModelId &&
isVideoModelDescriptor({
providerType: m.providerType,
modelId: m.modelId,
supportsImageGen: m.supportsImageGen,
})
) ?? false;
// 控制等待动画延迟消失(Gemini 图片模型延迟 4 秒,其他模型 6 秒)
const [showThinkingWithDelay, setShowThinkingWithDelay] = useState(false);
const [autoImagePlaceholderReady, setAutoImagePlaceholderReady] = useState(false);
const autoImagePlaceholderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [imageGenerationElapsedSeconds, setImageGenerationElapsedSeconds] = useState(0);
const isImageGenerationActive =
imageGenerationPhase === "queued" ||
imageGenerationPhase === "processing" ||
imageGenerationPhase === "finalizing";
const stripInternalMarkers = (text: string) =>
text
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.trim();
const hasImageContentInMessage = (message?: ChatMessage | null) => {
if (!message?.parts || message.parts.length === 0) return false;
return message.parts.some((part: any) => {
if (part?.type === "text" && typeof part.text === "string") {
return (
part.text.includes("![thumb|") ||
part.text.includes("![thumb|||") ||
part.text.includes("![Generated Image")
);
}
if (part?.type === "image" && typeof part.url === "string" && part.url.length > 0) {
return true;
}
if (
part?.type === "file" &&
typeof part.url === "string" &&
((typeof part.mediaType === "string" && part.mediaType.startsWith("image/")) ||
/\.(png|jpe?g|webp|gif|bmp|svg)(\?|$)/i.test(part.url))
) {
return true;
}
return false;
});
};
useEffect(() => {
return () => {
if (autoImagePlaceholderTimerRef.current) {
clearTimeout(autoImagePlaceholderTimerRef.current);
autoImagePlaceholderTimerRef.current = null;
}
};
}, []);
useEffect(() => {
if (!isImageGenerationActive || !imageGenerationStartedAt) {
setImageGenerationElapsedSeconds(0);
return;
}
const updateElapsed = () => {
setImageGenerationElapsedSeconds(
Math.max(0, Math.floor((Date.now() - imageGenerationStartedAt) / 1000))
);
};
updateElapsed();
const timer = setInterval(updateElapsed, 1000);
return () => clearInterval(timer);
}, [isImageGenerationActive, imageGenerationStartedAt]);
useEffect(() => {
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
// 对于视频模型,检查是否有真正的视频内容(包含 <video 标签或 [视频] 标记)
const hasVideoContent = lastMessage?.parts && lastMessage.parts.some(
(part: any) => part.type === "text" && part.text && (
part.text.includes("[视频]") ||
part.text.includes("<video") ||
part.text.includes("视频生成完成")
)
);
// 调试日志
if (isVideoModel) {
const firstPart = lastMessage?.parts?.[0];
const messageText = firstPart && 'text' in firstPart ? firstPart.text?.substring(0, 100) : undefined;
console.log("[占位图调试] 状态:", {
status,
isVideoModel,
hasVideoContent,
messageRole: lastMessage?.role,
messageText
});
}
// 🆕 智能自动模式:检查用户最后一条消息是否包含图片,如果有则视为图片编辑场景
// 注意:需要找用户的最后一条消息,而不是整个对话的最后一条
const lastUserMessage = [...messages].reverse().find(m => m.role === "user");
const isAutoModeWithImage = selectedModelId === "auto" &&
lastUserMessage?.parts?.some((p: any) => p.type === "file" || p.type === "image");
// 🆕 检查是否有"图片正在生成"的标记(表示对话消息已发送,正在等待图片生成)
const lastUserIndexForMarkers = messages.findLastIndex(m => m.role === "user");
const messagesAfterLastUser = lastUserIndexForMarkers >= 0 ? messages.slice(lastUserIndexForMarkers + 1) : messages;
const hasGenerationStart = messagesAfterLastUser.some(msg =>
msg.role === "assistant" && msg.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_START -->")
)
);
const hasGenerationEnd = messagesAfterLastUser.some(msg =>
msg.role === "assistant" && msg.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_END -->")
)
);
const isGeneratingImage = hasGenerationStart && !hasGenerationEnd;
// 对于图片模型,检查是否有图片内容(包含 ![thumb| 或 ![thumb||| 标记)
// 🆕 在智能自动模式下,需要检查最后一条用户消息之后的所有 assistant 消息
let hasImageContent = false;
if (isAutoModeWithImage) {
// 找到最后一条用户消息的索引
const lastUserIndex = messages.findLastIndex(m => m.role === "user");
// 检查之后所有的 assistant 消息是否包含图片
const assistantMessagesAfterUser = messages.slice(lastUserIndex + 1).filter(m => m.role === "assistant");
hasImageContent = assistantMessagesAfterUser.some((msg) => hasImageContentInMessage(msg));
} else {
// 普通模式:只检查最后一条消息
hasImageContent = hasImageContentInMessage(lastMessage);
}
// 对于其他模型,检查是否有实际的文本内容
const hasTextContent = lastMessage?.parts && lastMessage.parts.some(
(part: any) => part.type === "text" && part.text && part.text.trim().length > 0
);
// 判断是否是图片生成模型(包括 Gemini、火山、阿里)
const isImageGenModel = isGeminiModel || isVolcanoVisionModel || isAlibabaImageModel;
// 🆕 智能自动模式的特殊处理
// - 收到 GENERATING_IMAGE 标记前:显示普通思考动画
// - 收到 GENERATING_IMAGE 标记后:显示生图占位图
// - 收到图片后:隐藏占位图
let hasContent = false;
if (isQianwenVideoModel || isVideoModel) {
hasContent = hasVideoContent ?? false;
} else if (isImageGenModel) {
hasContent = hasImageContent ?? false;
} else if (isAutoModeWithImage) {
// 智能自动模式:只有图片出现才算有内容
hasContent = hasImageContent ?? false;
} else {
hasContent = hasTextContent ?? false;
}
// 根据模型类型设置延迟时间
const delayTime = (isQianwenVideoModel || isVideoModel) ? 60000 : (isImageGenModel || isAutoModeWithImage) ? 4000 : 6000;
// 🆕 智能自动模式的占位图逻辑
if (isAutoModeWithImage) {
console.log("[Messages useEffect] 智能模式检查:", {
hasImageContent,
isGeneratingImage,
isGeneratingImageFlag,
status,
});
// 🆕 简化的占位图逻辑 - 完全由 isGeneratingImageFlag 控制
// isGeneratingImageFlag 的生命周期:
// - 初始: false
// - 收到 AI 文字回复后 3 秒: true(开始显示生图占位图)
// - 收到图片消息: false(隐藏占位图)
// 检查当前轮次是否已收到 AI 文字回复(只看最后一条用户消息之后)
const lastUserIndex = messages.findLastIndex((m) => m.role === "user");
const assistantMessagesAfterUser = lastUserIndex >= 0
? messages.slice(lastUserIndex + 1).filter((m) => m.role === "assistant")
: messages.filter((m) => m.role === "assistant");
const hasAssistantTextReply = assistantMessagesAfterUser.some((msg) =>
msg.parts?.some((part: any) => {
if (part.type !== "text" || !part.text) return false;
return stripInternalMarkers(part.text).length > 0;
})
);
const waitingForFirstAgentReply = !hasAssistantTextReply && !hasImageContent;
const shouldKeepThinking =
status === "streaming" ||
status === "submitted" ||
(status === "ready" && waitingForFirstAgentReply && !!pendingGeneration) ||
(lastMessage?.role === "user" && waitingForFirstAgentReply);
if (hasImageContent) {
// 有图片内容,隐藏占位图
console.log("[Messages useEffect] 有图片内容,隐藏占位图");
setShowThinkingWithDelay(false);
} else if (isGeneratingImageFlag) {
// 正在生成图片,显示生图占位图
console.log("[Messages useEffect] isGeneratingImageFlag=true,显示生成占位图");
setShowThinkingWithDelay(true);
} else if (hasAssistantTextReply) {
// 收到了 AI 文字回复但还没开始生图(延迟期间),隐藏占位图让用户看文字
console.log("[Messages useEffect] 已收到AI文字回复,延迟期间隐藏占位图");
setShowThinkingWithDelay(false);
} else if (shouldKeepThinking) {
// 正在处理中(分析意图/流式传输)或 SSE 首包延迟,保持思考占位图
console.log("[Messages useEffect] keep thinking placeholder");
setShowThinkingWithDelay(true);
} else {
// ready 或其他状态:不显示占位图
console.log("[Messages useEffect] ready状态,隐藏占位图");
setShowThinkingWithDelay(false);
}
} else {
// 非智能自动模式的原有逻辑
// 🆕 调试日志
console.log("[Messages useEffect] 非智能模式:", {
status,
isImageGenModel,
isGeminiModel,
lastMessageRole: lastMessage?.role,
hasContent,
selectedModelId,
});
if (status === "submitted") {
console.log("[Messages useEffect] status=submitted,显示占位图");
setShowThinkingWithDelay(true);
} else if (status === "streaming" && lastMessage?.role === "assistant" && !hasContent) {
console.log("[Messages useEffect] streaming无内容,显示占位图");
setShowThinkingWithDelay(true);
} else if (status === "streaming" && lastMessage?.role === "assistant" && hasContent) {
console.log("[Messages useEffect] streaming有内容,延迟隐藏占位图");
const timer = setTimeout(() => {
setShowThinkingWithDelay(false);
}, delayTime);
return () => clearTimeout(timer);
} else {
console.log("[Messages useEffect] 其他状态,隐藏占位图");
setShowThinkingWithDelay(false);
}
}
}, [status, messages, isGeminiModel, isQianwenVideoModel, isVolcanoVisionModel, isVideoModel, isAlibabaImageModel, selectedModelId, isGeneratingImageFlag, pendingGeneration]);
// 🆕 智能自动模式:确保文字可见后再显示生图占位图
useEffect(() => {
const isAutoMode = selectedModelId === "auto";
const lastUserIndex = messages.findLastIndex((m) => m.role === "user");
const assistantMessagesAfterUser = lastUserIndex >= 0
? messages.slice(lastUserIndex + 1).filter((m) => m.role === "assistant")
: messages.filter((m) => m.role === "assistant");
const hasAssistantTextReplyWithContent = assistantMessagesAfterUser.some((msg) =>
msg.parts?.some((part: any) => {
if (part.type !== "text" || !part.text) return false;
return stripInternalMarkers(part.text).length > 0;
})
);
const hasImageContent = assistantMessagesAfterUser.some((msg) =>
hasImageContentInMessage(msg)
);
const hasGenerationStart = assistantMessagesAfterUser.some((msg) =>
msg.parts?.some(
(part: any) =>
part.type === "text" &&
part.text?.includes("<!-- IMAGE_GENERATION_START -->")
)
);
const isAutoImageFlow =
isAutoMode && (isGeneratingImageFlag || hasGenerationStart || hasImageContent);
if (!isAutoImageFlow || !isGeneratingImageFlag || !hasAssistantTextReplyWithContent || hasImageContent) {
if (autoImagePlaceholderTimerRef.current) {
clearTimeout(autoImagePlaceholderTimerRef.current);
autoImagePlaceholderTimerRef.current = null;
}
if (autoImagePlaceholderReady) {
setAutoImagePlaceholderReady(false);
}
return;
}
if (!autoImagePlaceholderReady && !autoImagePlaceholderTimerRef.current) {
autoImagePlaceholderTimerRef.current = setTimeout(() => {
setAutoImagePlaceholderReady(true);
autoImagePlaceholderTimerRef.current = null;
}, 800);
}
}, [messages, selectedModelId, isGeneratingImageFlag, autoImagePlaceholderReady]);
// 🆕 监听新消息添加,当用户发送带图片的消息或添加加载消息时,强制滚动到底部
const prevMessagesLengthRef = useRef(messages.length);
useEffect(() => {
if (messages.length > prevMessagesLengthRef.current) {
const lastMessage = messages[messages.length - 1];
// 如果是新添加的用户消息且包含图片附件,强制滚动到底部
if (lastMessage?.role === "user") {
const hasImageAttachment = lastMessage.parts?.some(
(part: any) => part.type === "file" && part.mediaType?.startsWith("image/")
);
if (hasImageAttachment) {
// 延迟一小段时间确保DOM已更新
setTimeout(() => scrollToBottom("smooth"), 100);
}
}
// 如果是新添加的助手消息且是加载消息(正在变清晰/抠图/图像拓展),强制滚动到底部
if (lastMessage?.role === "assistant") {
const firstPart = lastMessage.parts?.[0];
if (firstPart && "text" in firstPart) {
const loadingText = firstPart.text;
if (loadingText === "正在变清晰..." || loadingText === "正在抠图..." || loadingText === "正在图像拓展...") {
// 延迟一小段时间确保DOM已更新
setTimeout(() => scrollToBottom("smooth"), 100);
}
}
}
}
prevMessagesLengthRef.current = messages.length;
}, [messages, scrollToBottom]);
// 🔍 调试:追踪消息数组变化
useEffect(() => {
console.log("[Messages] 📋 消息数组更新:", {
count: messages.length,
messages: messages.map(m => ({
id: m.id?.substring(0, 8),
role: m.role,
partsCount: m.parts?.length,
firstPartType: m.parts?.[0]?.type,
firstPartText: m.parts?.[0]?.type === "text" ? m.parts[0].text?.substring(0, 50) : undefined
}))
});
}, [messages]);
return (
<div className="relative flex-1">
<div
className="absolute inset-0 touch-pan-y overflow-y-auto"
ref={messagesContainerRef}
>
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
{messages.length === 0 && <Greeting />}
{messages.map((message, index) => {
// 🆕 清理消息中的内部标记(如 <!-- GENERATING_IMAGE -->)
const cleanedMessage = {
...message,
parts: message.parts?.map((part: any) => {
if (part.type === "text" && part.text) {
return {
...part,
text: part.text
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.trim()
};
}
return part;
})
};
// 🆕 检查清理后的消息是否有实际内容
// 如果所有 parts 都为空,跳过渲染
const hasContent = cleanedMessage.parts?.some((part: any) => {
if (part.type === "text") return part.text && part.text.trim().length > 0;
if (part.type === "file" || part.type === "image") return part.url;
return true; // 其他类型的 part 默认有内容
});
// 如果没有内容,不渲染这条消息
if (!hasContent && cleanedMessage.parts?.length > 0) {
return null;
}
return (
<PreviewMessage
chatId={chatId}
isLoading={
status === "streaming" && messages.length - 1 === index
}
isReadonly={isReadonly}
key={message.id}
message={cleanedMessage}
regenerate={regenerate}
sendMessage={sendMessage}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
onImageWatermarkRemove={onImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
fallbackImageModelName={currentModelDisplayName}
/>
);
})}
{/* 等待动画:根据模型类型显示不同的加载效果 */}
{/* 🆕 支持恢复的生成状态(用户离开再返回) */}
{(() => {
// 检查最后一条消息是否是用户消息(表示还在等待 AI 响应)
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
const isWaitingForResponse = lastMessage?.role === "user";
// 恢复的待处理状态:有 pendingGeneration 且最后一条是用户消息
// 🆕 但如果收到了 AI 回复(包含 GENERATING_IMAGE 标记),不应该再显示恢复的占位图
const hasAssistantReply = messages.some(msg =>
msg.role === "assistant" && msg.parts?.some((part: any) => {
if (part.type !== "text" || !part.text) return false;
const cleanedText = part.text
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.trim();
return cleanedText.length > 0;
})
);
const hasRestoredPending = pendingGeneration && isWaitingForResponse && !hasAssistantReply;
// 🔍 调试日志
console.log("[Messages] 占位图状态检查:", {
pendingGeneration,
lastMessageRole: lastMessage?.role,
isWaitingForResponse,
hasRestoredPending,
hasAssistantReply,
showThinkingWithDelay,
messagesCount: messages.length,
isGeneratingImageFlag,
});
// 显示条件:分阶段图片生成状态 / 正常等待动画 / 恢复的待处理状态
const shouldShow = isImageGenerationActive || showThinkingWithDelay || hasRestoredPending;
if (!shouldShow) {
console.log("[Messages] 不显示占位图: shouldShow=false");
return null;
}
console.log("[Messages] ✅ 显示占位图!");
// 优先使用恢复的状态,否则使用当前模型类型
const modelType = pendingGeneration?.modelType;
const showGemini = modelType === "gemini" || (!modelType && isGeminiModel);
const showVolcano = modelType === "volcano-vision" || (!modelType && isVolcanoVisionModel);
const showAlibabaImage = modelType === "alibaba-image" || (!modelType && isAlibabaImageModel);
const showVideo =
modelType === "volcano-video" ||
modelType === "qianwen-video" ||
modelType === "fal-video" ||
modelType === "ruxa-video" ||
modelType === "krea-video" ||
(!modelType && (isQianwenVideoModel || isVideoModel));
const isAutoMode = selectedModelId === "auto";
// 🆕 检查是否收到了 GENERATING_IMAGE 标记(表示对话已发送,开始生图)
const lastUserIndexForMarkers = messages.findLastIndex(m => m.role === "user");
const messagesAfterLastUser = lastUserIndexForMarkers >= 0 ? messages.slice(lastUserIndexForMarkers + 1) : messages;
const hasGeneratingMarker = messagesAfterLastUser.some(msg =>
msg.role === "assistant" && msg.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_START -->")
)
);
const hasGenerationEnd = messagesAfterLastUser.some(msg =>
msg.role === "assistant" && msg.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_END -->")
)
);
const assistantMessagesAfterUser = lastUserIndexForMarkers >= 0
? messages.slice(lastUserIndexForMarkers + 1).filter((msg) => msg.role === "assistant")
: messages.filter((msg) => msg.role === "assistant");
const hasImageContent = assistantMessagesAfterUser.some((msg) =>
hasImageContentInMessage(msg)
);
const hasAssistantTextReplyWithContent = assistantMessagesAfterUser.some((msg) =>
msg.parts?.some((part: any) => {
if (part.type !== "text" || !part.text) return false;
return stripInternalMarkers(part.text).length > 0;
})
);
const shouldWaitForAgentReplyBeforeImagePlaceholder =
(isAutoMode || showGemini || showVolcano || showAlibabaImage) &&
!hasAssistantTextReplyWithContent;
const isAutoImageFlow =
isAutoMode &&
(isGeneratingImageFlag || hasGeneratingMarker || hasGenerationEnd || hasImageContent);
if (showVideo) {
const videoProviderType =
modelType === "ruxa-video"
? "ruxa-video"
: modelType === "fal-video"
? "fal-video"
: modelType === "krea-video"
? "krea"
: modelType === "volcano-video"
? "volcano-video"
: modelType === "qianwen-video"
? "qianwen-video"
: currentModelProviderType === "doubao-video"
? "volcano-video"
: currentModelProviderType;
const videoPhase =
imageGenerationPhase === "idle" ? "processing" : imageGenerationPhase;
return (
<QianwenVideoLoading
providerType={videoProviderType}
modelName={currentModelDisplayName}
phase={videoPhase}
elapsedSeconds={imageGenerationElapsedSeconds}
/>
);
}
if (shouldWaitForAgentReplyBeforeImagePlaceholder) {
return <ThinkingMessage />;
}
if (showGemini) return <GeminiImageLoading imageCount={imageCount} />;
if (showVolcano) return <VolcanoVisionLoading imageCount={imageCount} />;
if (showAlibabaImage) {
return (
<ImageGenerationLoading
imageCount={imageCount}
maxImages={
currentModelProviderType === "openai-image" ||
currentModelProviderType === "stepfun-image" ||
currentModelProviderType === "midjourney"
? 1
: 6
}
providerType={
currentModelProviderType === "ruxa-image"
? "ruxa-image"
: currentModelProviderType === "fal-image"
? "fal-image"
: currentModelProviderType === "openai-image"
? "openai-image"
: currentModelProviderType === "stepfun-image"
? "stepfun-image"
: currentModelProviderType === "midjourney"
? "midjourney"
: "alibaba-image"
}
modelName={currentModelDisplayName}
phase={imageGenerationPhase}
elapsedSeconds={imageGenerationElapsedSeconds}
/>
);
}
// 🆕 智能自动模式占位图
if (isAutoImageFlow) {
// 🆕 只用 isGeneratingImageFlag 来控制是否显示生图占位图
// isGeneratingImageFlag 由 chat.tsx 控制:延迟 3 秒后设置为 true,收到图片后设置为 false
if (
autoImagePlaceholderReady &&
isGeneratingImageFlag &&
imageGenerationPhase !== "queued" &&
hasAssistantTextReplyWithContent &&
!hasGenerationEnd
) {
return (
<ImageGenerationLoading
imageCount={imageCount}
maxImages={6}
providerType="alibaba-image"
modelName="智能处理"
phase={imageGenerationPhase}
elapsedSeconds={imageGenerationElapsedSeconds}
/>
);
}
// 否则显示思考动画(在等待 AI 分析或延迟期间)
return <ThinkingMessage />;
}
// 其他情况显示通用思考动画
return <ThinkingMessage />;
})()}
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
/>
</div>
</div>
<button
aria-label="滚动至底部"
className={`-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
isAtBottom
? "pointer-events-none scale-0 opacity-0"
: "pointer-events-auto scale-100 opacity-100"
}`}
onClick={() => scrollToBottom("smooth")}
type="button"
>
<ArrowDownIcon className="size-4" />
</button>
</div>
);
}
export const Messages = memo(PureMessages, (prevProps: MessagesProps, nextProps: MessagesProps) => {
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) {
return true;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
return false;
}
// 🆕 检查持久化生成状态变化
if (!equal(prevProps.pendingGeneration, nextProps.pendingGeneration)) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (!equal(prevProps.messages, nextProps.messages)) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
return false;
});

View File

@ -503,8 +503,6 @@ function PureMultimodalInput({
const canContinue = await ensureImagePointsBeforeSend();
if (!canContinue) return;
window.history.pushState({}, "", `/chat/${chatId}`);
sendMessage({
role: "user",
parts: [

View File

@ -1,130 +0,0 @@
"use client";
import { memo } from "react";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Chat } from "@/lib/db/schema";
import {
CheckCircleFillIcon,
GlobeIcon,
LockIcon,
MoreHorizontalIcon,
ShareIcon,
TrashIcon,
} from "./icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import {
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "./ui/sidebar";
const PureChatItem = ({
chat,
isActive,
onDelete,
setOpenMobile,
}: {
chat: Chat;
isActive: boolean;
onDelete: (chatId: string) => void;
setOpenMobile: (open: boolean) => void;
}) => {
const { visibilityType, setVisibilityType } = useChatVisibility({
chatId: chat.id,
initialVisibilityType: chat.visibility,
});
const handleNavigation = (e: React.MouseEvent) => {
e.preventDefault();
setOpenMobile(false);
window.location.href = `/chat/${chat.id}`;
};
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<a
href={`/chat/${chat.id}`}
onClick={handleNavigation}
>
<span>{chat.title}</span>
</a>
</SidebarMenuButton>
<DropdownMenu modal={true}>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
className="mr-0.5 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
showOnHover={!isActive}
>
<MoreHorizontalIcon />
<span className="sr-only">更多操作</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<ShareIcon />
<span>分享</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType("private");
}}
>
<div className="flex flex-row items-center gap-2">
<LockIcon size={12} />
<span>仅自己可见</span>
</div>
{visibilityType === "private" ? (
<CheckCircleFillIcon />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType("public");
}}
>
<div className="flex flex-row items-center gap-2">
<GlobeIcon />
<span>公开</span>
</div>
{visibilityType === "public" ? <CheckCircleFillIcon /> : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
className="cursor-pointer text-destructive focus:bg-destructive/15 focus:text-destructive dark:text-red-500"
onSelect={() => onDelete(chat.id)}
>
<TrashIcon />
<span>删除</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
};
export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
if (prevProps.isActive !== nextProps.isActive) {
return false;
}
return true;
});

View File

@ -1,368 +0,0 @@
"use client";
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
import { motion } from "framer-motion";
import { useParams, useRouter } from "next/navigation";
import type { User } from "next-auth";
import { useState } from "react";
import { toast } from "sonner";
import useSWRInfinite from "swr/infinite";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import type { Chat } from "@/lib/db/schema";
import { fetcher } from "@/lib/utils";
import { LoaderIcon } from "./icons";
import { ChatItem } from "./sidebar-history-item";
type GroupedChats = {
today: Chat[];
yesterday: Chat[];
lastWeek: Chat[];
lastMonth: Chat[];
older: Chat[];
};
export type ChatHistory = {
chats: Chat[];
hasMore: boolean;
};
const PAGE_SIZE = 20;
const groupChatsByDate = (chats: Chat[]): GroupedChats => {
const now = new Date();
const oneWeekAgo = subWeeks(now, 1);
const oneMonthAgo = subMonths(now, 1);
return chats.reduce(
(groups, chat) => {
const chatDate = new Date(chat.createdAt);
if (isToday(chatDate)) {
groups.today.push(chat);
} else if (isYesterday(chatDate)) {
groups.yesterday.push(chat);
} else if (chatDate > oneWeekAgo) {
groups.lastWeek.push(chat);
} else if (chatDate > oneMonthAgo) {
groups.lastMonth.push(chat);
} else {
groups.older.push(chat);
}
return groups;
},
{
today: [],
yesterday: [],
lastWeek: [],
lastMonth: [],
older: [],
} as GroupedChats
);
};
export function getChatHistoryPaginationKey(
pageIndex: number,
previousPageData: ChatHistory
) {
if (previousPageData && previousPageData.hasMore === false) {
return null;
}
if (pageIndex === 0) {
return `/api/history?limit=${PAGE_SIZE}`;
}
const firstChatFromPage = previousPageData.chats.at(-1);
if (!firstChatFromPage) {
return null;
}
return `/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
}
export function SidebarHistory({ user }: { user: User | undefined }) {
const { setOpenMobile } = useSidebar();
const { id } = useParams();
const {
data: paginatedChatHistories,
setSize,
isValidating,
isLoading,
mutate,
} = useSWRInfinite<ChatHistory>(getChatHistoryPaginationKey, fetcher, {
fallbackData: [],
});
const router = useRouter();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const hasReachedEnd = paginatedChatHistories
? paginatedChatHistories.some((page) => page.hasMore === false)
: false;
const hasEmptyChatHistory = paginatedChatHistories
? paginatedChatHistories.every((page) => page.chats.length === 0)
: false;
const handleDelete = () => {
const deletePromise = fetch(`/api/chat?id=${deleteId}`, {
method: "DELETE",
});
toast.promise(deletePromise, {
loading: "正在删除会话...",
success: () => {
mutate((chatHistories) => {
if (chatHistories) {
return chatHistories.map((chatHistory) => ({
...chatHistory,
chats: chatHistory.chats.filter((chat) => chat.id !== deleteId),
}));
}
});
return "会话删除成功";
},
error: "删除会话失败",
});
setShowDeleteDialog(false);
if (deleteId === id) {
router.push("/");
}
};
if (!user) {
return (
<SidebarGroup>
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
登录后即可保存并查看历史会话
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
if (isLoading) {
return (
<SidebarGroup>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
今天
</div>
<SidebarGroupContent>
<div className="flex flex-col">
{[44, 32, 28, 64, 52].map((item) => (
<div
className="flex h-8 items-center gap-2 rounded-md px-2"
key={item}
>
<div
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-sidebar-accent-foreground/10"
style={
{
"--skeleton-width": `${item}%`,
} as React.CSSProperties
}
/>
</div>
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
if (hasEmptyChatHistory) {
return (
<SidebarGroup>
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
开始聊天后,会话记录将展示在这里
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
return (
<>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{paginatedChatHistories &&
(() => {
const chatsFromHistory = paginatedChatHistories.flatMap(
(paginatedChatHistory) => paginatedChatHistory.chats
);
const groupedChats = groupChatsByDate(chatsFromHistory);
return (
<div className="flex flex-col gap-6">
{groupedChats.today.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
今天
</div>
{groupedChats.today.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.yesterday.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
昨天
</div>
{groupedChats.yesterday.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.lastWeek.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
最近 7 天
</div>
{groupedChats.lastWeek.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.lastMonth.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
最近 30 天
</div>
{groupedChats.lastMonth.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.older.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
早于 30 天
</div>
{groupedChats.older.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
</div>
);
})()}
</SidebarMenu>
<motion.div
onViewportEnter={() => {
if (!isValidating && !hasReachedEnd) {
setSize((size) => size + 1);
}
}}
/>
{hasReachedEnd ? (
<div className="mt-8 flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
没有更多会话记录了
</div>
) : (
<div className="mt-8 flex flex-row items-center gap-2 p-2 text-zinc-500 dark:text-zinc-400">
<div className="animate-spin">
<LoaderIcon />
</div>
<div>正在加载会话...</div>
</div>
)}
</SidebarGroupContent>
</SidebarGroup>
<AlertDialog onOpenChange={setShowDeleteDialog} open={showDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定要删除该会话?</AlertDialogTitle>
<AlertDialogDescription>
删除后将无法恢复,并会从服务器永久移除。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -15,7 +15,7 @@ type SuggestedActionsProps = {
selectedVisibilityType: VisibilityType;
};
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
function PureSuggestedActions({ sendMessage }: SuggestedActionsProps) {
const { data: suggestedActions } = useSWR<string[]>("/api/starter-questions", fetcher, {
fallbackData: [
// AI图片编辑
@ -50,7 +50,6 @@ function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
<Suggestion
className="h-auto w-full whitespace-normal p-3 text-left"
onClick={(suggestion) => {
window.history.pushState({}, "", `/chat/${chatId}`);
sendMessage({
role: "user",
parts: [{ type: "text", text: suggestion }],

View File

@ -1,13 +1,8 @@
"use client";
import { useMemo } from "react";
import useSWR, { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import useSWR from "swr";
import { updateChatVisibility } from "@/app/(chat)/actions";
import {
type ChatHistory,
getChatHistoryPaginationKey,
} from "@/components/sidebar-history";
import type { VisibilityType } from "@/components/visibility-selector";
export function useChatVisibility({
@ -17,9 +12,6 @@ export function useChatVisibility({
chatId: string;
initialVisibilityType: VisibilityType;
}) {
const { mutate, cache } = useSWRConfig();
const history: ChatHistory = cache.get("/api/history")?.data;
const { data: localVisibility, mutate: setLocalVisibility } = useSWR(
`${chatId}-visibility`,
null,
@ -29,19 +21,11 @@ export function useChatVisibility({
);
const visibilityType = useMemo(() => {
if (!history) {
return localVisibility;
}
const chat = history.chats.find((currentChat) => currentChat.id === chatId);
if (!chat) {
return "private";
}
return chat.visibility;
}, [history, chatId, localVisibility]);
return localVisibility;
}, [localVisibility]);
const setVisibilityType = (updatedVisibilityType: VisibilityType) => {
setLocalVisibility(updatedVisibilityType);
mutate(unstable_serialize(getChatHistoryPaginationKey));
updateChatVisibility({
chatId,

View File

@ -218,6 +218,10 @@ export async function proxy(request: NextRequest) {
return NextResponse.next();
}
if (pathname === "/classic" || pathname.startsWith("/chat/")) {
return NextResponse.redirect(new URL("/", request.url));
}
// 允许访问认证相关 API
if (pathname.startsWith("/api/auth")) {
return NextResponse.next();

View File

@ -1,77 +0,0 @@
import { expect, test } from "../fixtures";
import { ArtifactPage } from "../pages/artifact";
import { ChatPage } from "../pages/chat";
test.describe("Artifacts activity", () => {
let chatPage: ChatPage;
let artifactPage: ArtifactPage;
test.beforeEach(async ({ page }) => {
chatPage = new ChatPage(page);
artifactPage = new ArtifactPage(page);
await chatPage.createNewChat();
});
test("Create a text artifact", async () => {
test.fixme();
await chatPage.createNewChat();
await chatPage.sendUserMessage(
"Help me write an essay about Silicon Valley"
);
await artifactPage.isGenerationComplete();
expect(artifactPage.artifact).toBeVisible();
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toBe(
"A document was created and is now visible to the user."
);
await chatPage.hasChatIdInUrl();
});
test("Toggle artifact visibility", async () => {
test.fixme();
await chatPage.createNewChat();
await chatPage.sendUserMessage(
"Help me write an essay about Silicon Valley"
);
await artifactPage.isGenerationComplete();
expect(artifactPage.artifact).toBeVisible();
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toBe(
"A document was created and is now visible to the user."
);
await artifactPage.closeArtifact();
await chatPage.isElementNotVisible("artifact");
});
test("Send follow up message after generation", async () => {
test.fixme();
await chatPage.createNewChat();
await chatPage.sendUserMessage(
"Help me write an essay about Silicon Valley"
);
await artifactPage.isGenerationComplete();
expect(artifactPage.artifact).toBeVisible();
const assistantMessage = await artifactPage.getRecentAssistantMessage();
expect(assistantMessage.content).toBe(
"A document was created and is now visible to the user."
);
await artifactPage.sendUserMessage("Thanks!");
await artifactPage.isGenerationComplete();
const secondAssistantMessage = await chatPage.getRecentAssistantMessage();
expect(secondAssistantMessage.content).toBe("You're welcome!");
});
});

View File

@ -1,172 +0,0 @@
import { expect, test } from "../fixtures";
import { ChatPage } from "../pages/chat";
test.describe("Chat activity", () => {
let chatPage: ChatPage;
test.beforeEach(async ({ page }) => {
chatPage = new ChatPage(page);
await chatPage.createNewChat();
});
test("Send a user message and receive response", async () => {
await chatPage.sendUserMessage("Why is grass green?");
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toContain("It's just green duh!");
});
test("Redirect to /chat/:id after submitting message", async () => {
await chatPage.sendUserMessage("Why is grass green?");
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toContain("It's just green duh!");
await chatPage.hasChatIdInUrl();
});
test("Send a user message from suggestion", async () => {
await chatPage.sendUserMessageFromSuggestion();
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toContain(
"With Next.js, you can ship fast!"
);
});
test("Toggle between send/stop button based on activity", async () => {
await expect(chatPage.sendButton).toBeVisible();
await expect(chatPage.sendButton).toBeDisabled();
await chatPage.sendUserMessage("Why is grass green?");
await expect(chatPage.sendButton).not.toBeVisible();
await expect(chatPage.stopButton).toBeVisible();
await chatPage.isGenerationComplete();
await expect(chatPage.stopButton).not.toBeVisible();
await expect(chatPage.sendButton).toBeVisible();
});
test("Stop generation during submission", async () => {
await chatPage.sendUserMessage("Why is grass green?");
await expect(chatPage.stopButton).toBeVisible();
await chatPage.stopButton.click();
await expect(chatPage.sendButton).toBeVisible();
});
test("Edit user message and resubmit", async () => {
await chatPage.sendUserMessage("Why is grass green?");
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toContain("It's just green duh!");
const userMessage = await chatPage.getRecentUserMessage();
await userMessage.edit("Why is the sky blue?");
await chatPage.isGenerationComplete();
const updatedAssistantMessage = await chatPage.getRecentAssistantMessage();
expect(updatedAssistantMessage.content).toContain("It's just blue duh!");
});
test("Hide suggested actions after sending message", async () => {
await chatPage.isElementVisible("suggested-actions");
await chatPage.sendUserMessageFromSuggestion();
await chatPage.isElementNotVisible("suggested-actions");
});
test("Upload file and send image attachment with message", async () => {
await chatPage.addImageAttachment();
await chatPage.isElementVisible("attachments-preview");
await chatPage.isElementVisible("input-attachment-loader");
await chatPage.isElementNotVisible("input-attachment-loader");
await chatPage.sendUserMessage("Who painted this?");
const userMessage = await chatPage.getRecentUserMessage();
expect(userMessage.attachments).toHaveLength(1);
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toBe("This painting is by Monet!");
});
test("Call weather tool", async () => {
await chatPage.sendUserMessage("What's the weather in sf?");
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toBe(
"The current temperature in San Francisco is 17°C."
);
});
test("Upvote message", async () => {
await chatPage.sendUserMessage("Why is the sky blue?");
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
await assistantMessage.upvote();
await chatPage.isVoteComplete();
});
test("Downvote message", async () => {
await chatPage.sendUserMessage("Why is the sky blue?");
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
await assistantMessage.downvote();
await chatPage.isVoteComplete();
});
test("Update vote", async () => {
await chatPage.sendUserMessage("Why is the sky blue?");
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
await assistantMessage.upvote();
await chatPage.isVoteComplete();
await assistantMessage.downvote();
await chatPage.isVoteComplete();
});
test("Create message from url query", async ({ page }) => {
await page.goto("/?query=Why is the sky blue?");
await chatPage.isGenerationComplete();
const userMessage = await chatPage.getRecentUserMessage();
expect(userMessage.content).toBe("Why is the sky blue?");
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toContain("It's just blue duh!");
});
test("auto-scrolls to bottom after submitting new messages", async () => {
test.fixme();
await chatPage.sendMultipleMessages(5, (i) => `filling message #${i}`);
await chatPage.waitForScrollToBottom();
});
test("scroll button appears when user scrolls up, hides on click", async () => {
test.fixme();
await chatPage.sendMultipleMessages(5, (i) => `filling message #${i}`);
await expect(chatPage.scrollToBottomButton).not.toBeVisible();
await chatPage.scrollToTop();
await expect(chatPage.scrollToBottomButton).toBeVisible();
await chatPage.scrollToBottomButton.click();
await chatPage.waitForScrollToBottom();
await expect(chatPage.scrollToBottomButton).not.toBeVisible();
});
});

View File

@ -1,63 +0,0 @@
import { expect, test } from "../fixtures";
import { ChatPage } from "../pages/chat";
test.describe("chat activity with reasoning", () => {
let chatPage: ChatPage;
test.beforeEach(async ({ curieContext }) => {
chatPage = new ChatPage(curieContext.page);
await chatPage.createNewChat();
});
test("Curie can send message and generate response with reasoning", async () => {
await chatPage.sendUserMessage("Why is the sky blue?");
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toBe("It's just blue duh!");
expect(assistantMessage.reasoning).toBe(
"The sky is blue because of rayleigh scattering!"
);
});
test("Curie can toggle reasoning visibility", async () => {
await chatPage.sendUserMessage("Why is the sky blue?");
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
const reasoningElement =
assistantMessage.element.getByTestId("message-reasoning");
expect(reasoningElement).toBeVisible();
await assistantMessage.toggleReasoningVisibility();
await expect(reasoningElement).not.toBeVisible();
await assistantMessage.toggleReasoningVisibility();
await expect(reasoningElement).toBeVisible();
});
test("Curie can edit message and resubmit", async () => {
await chatPage.sendUserMessage("Why is the sky blue?");
await chatPage.isGenerationComplete();
const assistantMessage = await chatPage.getRecentAssistantMessage();
const reasoningElement =
assistantMessage.element.getByTestId("message-reasoning");
expect(reasoningElement).toBeVisible();
const userMessage = await chatPage.getRecentUserMessage();
const generationCompletePromise = chatPage.isGenerationComplete();
await userMessage.edit("Why is grass green?");
await generationCompletePromise;
const updatedAssistantMessage = await chatPage.getRecentAssistantMessage();
expect(updatedAssistantMessage.content).toBe("It's just green duh!");
expect(updatedAssistantMessage.reasoning).toBe(
"Grass is green because of chlorophyll absorption!"
);
});
});

View File

@ -1,8 +1,6 @@
import { getMessageByErrorCode } from "@/lib/errors";
import { expect, test } from "../fixtures";
import { generateRandomTestUser } from "../helpers";
import { AuthPage } from "../pages/auth";
import { ChatPage } from "../pages/chat";
test.describe
.serial("Guest Session", () => {
@ -15,7 +13,8 @@ test.describe
throw new Error("Failed to load page");
}
let request = response.request();
let request: ReturnType<typeof response.request> | null =
response.request();
const chain: string[] = [];
@ -57,7 +56,8 @@ test.describe
throw new Error("Failed to load page");
}
let request = response.request();
let request: ReturnType<typeof response.request> | null =
response.request();
const chain: string[] = [];
@ -195,21 +195,29 @@ test.describe
const secondaryAuthPage = new AuthPage(secondaryPage);
try {
await primaryAuthPage.register(isolatedUser.email, isolatedUser.password);
await primaryAuthPage.expectToastToContain("Account created successfully!");
await primaryAuthPage.register(
isolatedUser.email,
isolatedUser.password
);
await primaryAuthPage.expectToastToContain(
"Account created successfully!"
);
await page.waitForURL("/");
const beforeInvalidation = await page.context().request.get(
"/api/user/account-center"
);
const beforeInvalidation = await page
.context()
.request.get("/api/user/account-center");
expect(beforeInvalidation.status()).toBe(200);
await secondaryAuthPage.login(isolatedUser.email, isolatedUser.password);
await secondaryAuthPage.login(
isolatedUser.email,
isolatedUser.password
);
await secondaryPage.waitForURL("/");
const afterInvalidation = await page.context().request.get(
"/api/user/account-center"
);
const afterInvalidation = await page
.context()
.request.get("/api/user/account-center");
expect(afterInvalidation.status()).toBe(401);
await page.goto("/login");
@ -219,26 +227,3 @@ test.describe
}
});
});
test.describe("Entitlements", () => {
let chatPage: ChatPage;
test.beforeEach(({ page }) => {
chatPage = new ChatPage(page);
});
test("Guest user cannot send more than 20 messages/day", async () => {
test.fixme();
await chatPage.createNewChat();
for (let i = 0; i <= 20; i++) {
await chatPage.sendUserMessage("Why is the sky blue?");
await chatPage.isGenerationComplete();
}
await chatPage.sendUserMessage("Why is the sky blue?");
await chatPage.expectToastToContain(
getMessageByErrorCode("rate_limit:chat")
);
});
});

View File

@ -9,7 +9,6 @@ import {
} from "@playwright/test";
import { generateId } from "ai";
import { getUnixTime } from "date-fns";
import { ChatPage } from "./pages/chat";
export type UserContext = {
context: BrowserContext;
@ -49,11 +48,6 @@ export async function createAuthenticatedContext({
"Account created successfully!"
);
const chatPage = new ChatPage(page);
await chatPage.createNewChat();
await chatPage.chooseModelFromSelector("chat-model-reasoning");
await expect(chatPage.getSelectedModel()).resolves.toEqual("Reasoning model");
await page.waitForTimeout(1000);
await context.storageState({ path: storageFile });
await page.close();

View File

@ -1,120 +0,0 @@
import { expect, type Page } from "@playwright/test";
export class ArtifactPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get artifact() {
return this.page.getByTestId("artifact");
}
get sendButton() {
return this.artifact.getByTestId("send-button");
}
get stopButton() {
return this.page.getByTestId("stop-button");
}
get multimodalInput() {
return this.page.getByTestId("multimodal-input");
}
async isGenerationComplete() {
const response = await this.page.waitForResponse((currentResponse) =>
currentResponse.url().includes("/api/chat")
);
await response.finished();
}
async sendUserMessage(message: string) {
await this.artifact.getByTestId("multimodal-input").click();
await this.artifact.getByTestId("multimodal-input").fill(message);
await this.artifact.getByTestId("send-button").click();
}
async getRecentAssistantMessage() {
const messageElements = await this.artifact
.getByTestId("message-assistant")
.all();
const lastMessageElement = messageElements.at(-1);
if (!lastMessageElement) {
return null;
}
const content = await lastMessageElement
.getByTestId("message-content")
.innerText()
.catch(() => null);
const reasoningElement = await lastMessageElement
.getByTestId("message-reasoning")
.isVisible()
.then(async (visible) =>
visible
? await lastMessageElement
.getByTestId("message-reasoning")
.innerText()
: null
)
.catch(() => null);
return {
element: lastMessageElement,
content,
reasoning: reasoningElement,
async toggleReasoningVisibility() {
await lastMessageElement
.getByTestId("message-reasoning-toggle")
.click();
},
};
}
async getRecentUserMessage() {
const messageElements = await this.artifact
.getByTestId("message-user")
.all();
const lastMessageElement = messageElements.at(-1);
if (!lastMessageElement) {
return null;
}
const content = await lastMessageElement.innerText();
const hasAttachments = await lastMessageElement
.getByTestId("message-attachments")
.isVisible()
.catch(() => false);
const attachments = hasAttachments
? await lastMessageElement.getByTestId("message-attachments").all()
: [];
const page = this.artifact;
return {
element: lastMessageElement,
content,
attachments,
async edit(newMessage: string) {
await page.getByTestId("message-edit-button").click();
await page.getByTestId("message-editor").fill(newMessage);
await page.getByTestId("message-editor-send-button").click();
await expect(
page.getByTestId("message-editor-send-button")
).not.toBeVisible();
},
};
}
closeArtifact() {
return this.page.getByTestId("artifact-close-button").click();
}
}

View File

@ -1,265 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { chatModels } from "@/lib/ai/models";
const CHAT_ID_REGEX =
/^http:\/\/localhost:3000\/chat\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
export class ChatPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get sendButton() {
return this.page.getByTestId("send-button");
}
get stopButton() {
return this.page.getByTestId("stop-button");
}
get multimodalInput() {
return this.page.getByTestId("multimodal-input");
}
get scrollContainer() {
return this.page.locator(".overflow-y-scroll");
}
get scrollToBottomButton() {
return this.page.getByTestId("scroll-to-bottom-button");
}
async createNewChat() {
await this.page.goto("/");
}
getCurrentURL(): string {
return this.page.url();
}
async sendUserMessage(message: string) {
await this.multimodalInput.click();
await this.multimodalInput.fill(message);
await this.sendButton.click();
}
async isGenerationComplete() {
const response = await this.page.waitForResponse((currentResponse) =>
currentResponse.url().includes("/api/chat")
);
await response.finished();
}
async isVoteComplete() {
const response = await this.page.waitForResponse((currentResponse) =>
currentResponse.url().includes("/api/vote")
);
await response.finished();
}
async hasChatIdInUrl() {
await expect(this.page).toHaveURL(CHAT_ID_REGEX);
}
async sendUserMessageFromSuggestion() {
await this.page
.getByRole("button", { name: "What are the advantages of" })
.click();
}
async isElementVisible(elementId: string) {
await expect(this.page.getByTestId(elementId)).toBeVisible();
}
async isElementNotVisible(elementId: string) {
await expect(this.page.getByTestId(elementId)).not.toBeVisible();
}
async addImageAttachment() {
this.page.on("filechooser", async (fileChooser) => {
const filePath = path.join(
process.cwd(),
"public",
"images",
"mouth of the seine, monet.jpg"
);
const imageBuffer = fs.readFileSync(filePath);
await fileChooser.setFiles({
name: "mouth of the seine, monet.jpg",
mimeType: "image/jpeg",
buffer: imageBuffer,
});
});
await this.page.getByTestId("attachments-button").click();
}
async getSelectedModel() {
const modelId = await this.page.getByTestId("model-selector").innerText();
return modelId;
}
async chooseModelFromSelector(chatModelId: string) {
const chatModel = chatModels.find(
(currentChatModel) => currentChatModel.id === chatModelId
);
if (!chatModel) {
throw new Error(`Model with id ${chatModelId} not found`);
}
await this.page.getByTestId("model-selector").click();
await this.page.getByTestId(`model-selector-item-${chatModelId}`).click();
expect(await this.getSelectedModel()).toBe(chatModel.name);
}
async getSelectedVisibility() {
const visibilityId = await this.page
.getByTestId("visibility-selector")
.innerText();
return visibilityId;
}
async chooseVisibilityFromSelector(chatVisibility: "public" | "private") {
await this.page.getByTestId("visibility-selector").click();
await this.page
.getByTestId(`visibility-selector-item-${chatVisibility}`)
.click();
expect(await this.getSelectedVisibility()).toBe(chatVisibility);
}
async getRecentAssistantMessage() {
const messageElements = await this.page
.getByTestId("message-assistant")
.all();
const lastMessageElement = messageElements.at(-1);
if (!lastMessageElement) {
return null;
}
const content = await lastMessageElement
.getByTestId("message-content")
.innerText()
.catch(() => null);
const reasoningElement = await lastMessageElement
.getByTestId("message-reasoning")
.isVisible()
.then(async (visible) =>
visible
? await lastMessageElement
.getByTestId("message-reasoning")
.innerText()
: null
)
.catch(() => null);
return {
element: lastMessageElement,
content,
reasoning: reasoningElement,
async toggleReasoningVisibility() {
await lastMessageElement
.getByTestId("message-reasoning-toggle")
.click();
},
async upvote() {
await lastMessageElement.getByTestId("message-upvote").click();
},
async downvote() {
await lastMessageElement.getByTestId("message-downvote").click();
},
};
}
async getRecentUserMessage() {
const messageElements = await this.page.getByTestId("message-user").all();
const lastMessageElement = messageElements.at(-1);
if (!lastMessageElement) {
throw new Error("No user message found");
}
const content = await lastMessageElement
.getByTestId("message-content")
.innerText()
.catch(() => null);
const hasAttachments = await lastMessageElement
.getByTestId("message-attachments")
.isVisible()
.catch(() => false);
const attachments = hasAttachments
? await lastMessageElement.getByTestId("message-attachments").all()
: [];
const page = this.page;
return {
element: lastMessageElement,
content,
attachments,
async edit(newMessage: string) {
await page.getByTestId("message-edit-button").click();
await page.getByTestId("message-editor").fill(newMessage);
await page.getByTestId("message-editor-send-button").click();
await expect(
page.getByTestId("message-editor-send-button")
).not.toBeVisible();
},
};
}
async expectToastToContain(text: string) {
await expect(this.page.getByTestId("toast")).toContainText(text);
}
async openSideBar() {
const sidebarToggleButton = this.page.getByTestId("sidebar-toggle-button");
await sidebarToggleButton.click();
}
isScrolledToBottom(): Promise<boolean> {
return this.scrollContainer.evaluate(
(el) => Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 1
);
}
async waitForScrollToBottom(timeout = 5000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (await this.isScrolledToBottom()) {
return;
}
await this.page.waitForTimeout(100);
}
throw new Error(`Timed out waiting for scroll bottom after ${timeout}ms`);
}
async sendMultipleMessages(
count: number,
makeMessage: (i: number) => string
) {
for (let i = 0; i < count; i++) {
await this.sendUserMessage(makeMessage(i));
await this.isGenerationComplete();
}
}
async scrollToTop(): Promise<void> {
await this.scrollContainer.evaluate((element) => {
element.scrollTop = 0;
});
}
}

View File

@ -25,7 +25,7 @@ function normalizeStreamData(lines: string[]): string[] {
}
test.describe
.serial("/api/chat", () => {
.serial("/api/chat internal generation API", () => {
test("Ada cannot invoke a chat generation with empty request body", async ({
adaContext,
}) => {