deerflow2/frontend/src/app/workspace/chats/[thread_id]/page.tsx

270 lines
9.7 KiB
TypeScript

"use client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import {
ChatBox,
useSpecificChatMode,
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { bootstrapRemoteSkill } from "@/core/skills";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import { env } from "@/env";
import { cn } from "@/lib/utils";
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export default function ChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const searchParams = useSearchParams(); const generatedThreadIdRef = useRef<string>("");
if (!generatedThreadIdRef.current) {
const queryThreadId = searchParams.get("thread_id")?.trim();
generatedThreadIdRef.current =
queryThreadId && UUID_REGEX.test(queryThreadId) ? queryThreadId : uuid();
}
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat({
newThreadId: generatedThreadIdRef.current,
});
const [settings, setSettings] = useThreadSettings(threadId);
const [mounted, setMounted] = useState(false);
useSpecificChatMode();
useEffect(() => {
setMounted(true);
}, []);
const { showNotification } = useNotification();
const skillBootstrappedKeysRef = useRef<Set<string>>(new Set());
const skillBootstrappingKeysRef = useRef<Set<string>>(new Set());
const skillBootstrap = useMemo(() => {
const skillIdRaw = searchParams.get("skill_id")?.trim();
if (!skillIdRaw) return undefined;
const contentIds = skillIdRaw
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0)
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
// Deduplicate while preserving incoming order.
const uniqueContentIds = Array.from(new Set(contentIds));
if (uniqueContentIds.length === 0) return undefined;
const languageTypeRaw =
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
return {
contentIds: uniqueContentIds,
languageType: Number.isFinite(languageType) ? languageType : 0,
};
}, [searchParams]);
const [thread, sendMessage, isUploading] = 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 });
}
},
});
useEffect(() => {
if (!threadId || !skillBootstrap?.contentIds?.length) {
return;
}
const languageType = skillBootstrap.languageType ?? 0;
const initKey = `${threadId}:${skillBootstrap.contentIds.join(",")}:${languageType}`;
if (
skillBootstrappedKeysRef.current.has(initKey) ||
skillBootstrappingKeysRef.current.has(initKey)
) {
return;
}
skillBootstrappingKeysRef.current.add(initKey);
const runBootstrap = async () => {
try {
await bootstrapRemoteSkill({
thread_id: threadId,
content_ids: skillBootstrap.contentIds,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
skillBootstrappedKeysRef.current.add(initKey);
} catch (error) {
const message =
error instanceof Error ? error.message : "Skill initialization failed";
showNotification("Skill initialization failed", { body: message });
} finally {
skillBootstrappingKeysRef.current.delete(initKey);
}
};
void runBootstrap();
}, [threadId, skillBootstrap, showNotification]);
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
void sendMessage(threadId, message);
},
[sendMessage, threadId],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4",
isNewThread
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center gap-2">
<TokenUsageIndicator messages={thread.messages} />
<ExportTrigger threadId={threadId} />
<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}
paddingBottom={messageListPaddingBottom}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
{mounted ? (
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread}
threadId={threadId}
autoFocus={isNewThread}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isNewThread && <Welcome mode={settings.context.mode} />
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
}
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
)}
/>
)}
{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>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}