"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(""); 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>(new Set()); const skillBootstrappingKeysRef = useRef>(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 (
{mounted ? ( } disabled={ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading } onContextChange={(context) => setSettings("context", context) } onFollowupsVisibilityChange={setShowFollowups} onSubmit={handleSubmit} onStop={handleStop} /> ) : (
); }