refactor: remove legacy chat pages and home variant switch
This commit is contained in:
parent
77ab711d41
commit
1db4444c7b
@ -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("/");
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
2333
components/chat.tsx
2333
components/chat.tsx
File diff suppressed because it is too large
Load Diff
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
@ -503,8 +503,6 @@ function PureMultimodalInput({
|
||||
const canContinue = await ensureImagePointsBeforeSend();
|
||||
if (!canContinue) return;
|
||||
|
||||
window.history.pushState({}, "", `/chat/${chatId}`);
|
||||
|
||||
sendMessage({
|
||||
role: "user",
|
||||
parts: [
|
||||
|
||||
@ -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;
|
||||
});
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 }],
|
||||
|
||||
@ -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,
|
||||
|
||||
4
proxy.ts
4
proxy.ts
@ -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();
|
||||
|
||||
@ -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!");
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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!"
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user