225 lines
8.1 KiB
TypeScript
225 lines
8.1 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useState } from "react";
|
||
import { ListTodoIcon } from "lucide-react";
|
||
|
||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
DevDialog,
|
||
DevDialogContent,
|
||
DevDialogFooter,
|
||
DevDialogHeader,
|
||
DevDialogTitle,
|
||
} from "@/components/ui/dev-dialog";
|
||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||
import {
|
||
ChatBox,
|
||
useSpecificChatMode,
|
||
useThreadChat,
|
||
} from "@/components/workspace/chats";
|
||
import { DevTodoList } from "@/components/workspace/dev-todo-list";
|
||
import { InputBox } from "@/components/workspace/input-box";
|
||
import { MessageList } from "@/components/workspace/messages";
|
||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||
import { Welcome } from "@/components/workspace/welcome";
|
||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import { useNotification } from "@/core/notification/hooks";
|
||
import { useLocalSettings } from "@/core/settings";
|
||
import { useThreadStream } from "@/core/threads/hooks";
|
||
import { textOfMessage } from "@/core/threads/utils";
|
||
import { env } from "@/env";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
export default function ChatPage() {
|
||
const { t } = useI18n();
|
||
const [settings, setSettings] = useLocalSettings();
|
||
const [showExitDialog, setShowExitDialog] = useState(false);
|
||
const { fullscreen } = useArtifacts();
|
||
|
||
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
|
||
useSpecificChatMode();
|
||
|
||
const { showNotification } = useNotification();
|
||
|
||
const [thread, sendMessage] = useThreadStream({
|
||
threadId: isNewThread ? undefined : threadId,
|
||
context: settings.context,
|
||
isMock,
|
||
onStart: () => {
|
||
setIsNewThread(false);
|
||
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
||
history.replaceState(null, "", `/workspace/chats/${threadId}`);
|
||
},
|
||
onFinish: (state) => {
|
||
if (document.hidden || !document.hasFocus()) {
|
||
let body = "Conversation finished";
|
||
const lastMessage = state.messages.at(-1);
|
||
if (lastMessage) {
|
||
const textContent = textOfMessage(lastMessage);
|
||
if (textContent) {
|
||
body =
|
||
textContent.length > 200
|
||
? textContent.substring(0, 200) + "..."
|
||
: textContent;
|
||
}
|
||
}
|
||
showNotification(state.title, { body });
|
||
}
|
||
},
|
||
});
|
||
|
||
const handleSubmit = useCallback(
|
||
(message: PromptInputMessage) => {
|
||
void sendMessage(threadId, message);
|
||
},
|
||
[sendMessage, threadId],
|
||
);
|
||
const handleStop = useCallback(async () => {
|
||
await thread.stop();
|
||
}, [thread]);
|
||
|
||
return (
|
||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||
<ChatBox threadId={threadId}>
|
||
<div className="bg-background relative flex size-full min-h-0 justify-between">
|
||
<header
|
||
className={cn(
|
||
"absolute top-0 right-0 left-0 z-30 mx-[20px] grid h-[58px] shrink-0 grid-cols-3 items-center rounded-t-[20px] border-b py-[15px]",
|
||
isNewThread
|
||
? "bg-background/0 backdrop-blur-none"
|
||
: "bg-background/80 backdrop-blur",
|
||
)}
|
||
>
|
||
{/* 返回查看结果左箭头 */}
|
||
<div className="flex h-full w-full items-center text-sm font-medium">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-full px-[10px] py-[5px] text-sm font-medium"
|
||
onClick={() => setShowExitDialog(true)}
|
||
>
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 20 20"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14"
|
||
stroke="#666666"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</Button>
|
||
</div>
|
||
<div className="flex h-full w-full items-center justify-center overflow-hidden text-sm font-medium">
|
||
<ThreadTitle threadId={threadId} thread={thread} />
|
||
</div>
|
||
<div className="flex items-center justify-end gap-2 overflow-hidden">
|
||
<DevTodoList
|
||
className="bg-white"
|
||
todos={thread.values.todos ?? []}
|
||
hidden={
|
||
!thread.values.todos || thread.values.todos.length === 0
|
||
}
|
||
trigger={
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-full px-[10px] py-[5px] text-sm font-medium"
|
||
>
|
||
<ListTodoIcon className="size-4" /> To-dos
|
||
</Button>
|
||
}
|
||
/>
|
||
<ArtifactTrigger />
|
||
</div>
|
||
</header>
|
||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||
<div className="flex size-full justify-center">
|
||
<MessageList
|
||
className={cn("size-full", !isNewThread && "pt-10")}
|
||
threadId={threadId}
|
||
thread={thread}
|
||
/>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"pointer-events-none fixed right-0 bottom-3 left-0 z-30 flex justify-center px-4",
|
||
"transition-all duration-300 ease-in-out",
|
||
fullscreen
|
||
? "pointer-events-none translate-y-4 opacity-0"
|
||
: "translate-y-0 opacity-100",
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"pointer-events-auto relative w-full max-w-[720px]",
|
||
isNewThread && "top-[-65px] -translate-y-[calc(50vh-96px)]",
|
||
)}
|
||
>
|
||
<InputBox
|
||
className={cn(
|
||
"w-full -translate-y-4 rounded-[20px] bg-[#FBFAFC]",
|
||
)}
|
||
isNewThread={isNewThread}
|
||
threadId={threadId}
|
||
autoFocus={isNewThread}
|
||
status={thread.isLoading ? "streaming" : "ready"}
|
||
context={settings.context}
|
||
extraHeader={
|
||
isNewThread && <Welcome mode={settings.context.mode} />
|
||
}
|
||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||
onContextChange={(context) => setSettings("context", context)}
|
||
onSubmit={handleSubmit}
|
||
onStop={handleStop}
|
||
/>
|
||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||
{t.common.notAvailableInDemoMode}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</ChatBox>
|
||
|
||
{/* 退出确认对话框 */}
|
||
<DevDialog open={showExitDialog} onOpenChange={setShowExitDialog}>
|
||
<DevDialogContent>
|
||
<DevDialogHeader>
|
||
<DevDialogTitle>提示</DevDialogTitle>
|
||
</DevDialogHeader>
|
||
<p className="text-muted-foreground text-sm">
|
||
退出后,当前会话结束并销毁,请先下载保存当前结果!
|
||
</p>
|
||
<DevDialogFooter>
|
||
<Button
|
||
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
|
||
variant="ghost"
|
||
onClick={() => setShowExitDialog(false)}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
|
||
variant="ghost"
|
||
onClick={() => setShowExitDialog(false)}
|
||
>
|
||
确定
|
||
</Button>
|
||
</DevDialogFooter>
|
||
</DevDialogContent>
|
||
</DevDialog>
|
||
</ThreadContext.Provider>
|
||
);
|
||
}
|