"use client"; import { Ticker } from "@tombcato/smart-ticker"; import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; import { DevDialog, DevDialogContent, DevDialogFooter, DevDialogHeader, DevDialogTitle, } from "@/components/ui/dev-dialog"; import { useSidebar } from "@/components/ui/sidebar"; import { ArtifactFileDetail, ArtifactFileList, useArtifacts, } from "@/components/workspace/artifacts"; import { useThreadChat } from "@/components/workspace/chats"; // import { DevTodoList } from "@/components/workspace/dev-todo-list"; import { IframeTestPanel } from "@/components/workspace/iframe-test-panel"; 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 { Tooltip } from "@/components/workspace/tooltip"; import { useSpecificChatMode } from "@/components/workspace/use-chat-mode"; import { Welcome } from "@/components/workspace/welcome"; import { getAPIClient } from "@/core/api"; import { useI18n } from "@/core/i18n/hooks"; import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; 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 { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener"; import { cn } from "@/lib/utils"; import "@tombcato/smart-ticker/style.css"; import motivationSlogans from "./motivation-slogans.json"; export default function ChatPage() { const { t } = useI18n(); useSpecificChatMode(); const [sloganIndex, setSloganIndex] = useState(0); const [settings, setSettings] = useLocalSettings(); const { setOpen: setSidebarOpen } = useSidebar(); const router = useRouter(); const { artifacts, open: artifactsOpen, setOpen: setArtifactsOpen, setArtifacts, select: selectArtifact, selectedArtifact, deselect: deselectArtifact, setFullscreen: setArtifactsFullscreen, fullscreen, } = useArtifacts(); const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } = useThreadChat(); // 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。 const shouldRenderHistory = !showWelcomeStyle; const safeThreadId = useMemo(() => { if (!threadId || threadId === "new") { return undefined; } return threadId; }, [threadId]); // `/new` + `thread_id` now reuses the pre-created thread, instead of creating // a new session on first submit. const createNewSession = useMemo( () => isNewThread && !safeThreadId, [isNewThread, safeThreadId], ); const streamThreadId = useMemo(() => { if (isNewThread && createNewSession) { return undefined; } return safeThreadId; }, [createNewSession, isNewThread, safeThreadId]); const apiClient = useMemo(() => getAPIClient(isMock), [isMock]); const warnedMissingThreadIdRef = useRef(false); const initializedThreadRef = useRef(null); const { showNotification } = useNotification(); const currentSlogan = motivationSlogans[ sloganIndex % motivationSlogans.length ] ?? { text: t.chatPage.defaultSlogan, color: "var(--color-ws-fg-primary)", }; const tickerCharacterList = useMemo(() => { const seen = new Set(); const uniqueChars: string[] = []; for (const slogan of motivationSlogans) { for (const char of slogan.text) { if (seen.has(char)) continue; seen.add(char); uniqueChars.push(char); } } return uniqueChars.join(""); }, []); useEffect(() => { if (motivationSlogans.length <= 1) return; const timer = window.setInterval( () => { setSloganIndex((prev) => (prev + 1) % motivationSlogans.length); }, 10 * 60 * 1000, ); return () => window.clearInterval(timer); }, []); useEffect(() => { if (!isNewThread) { warnedMissingThreadIdRef.current = false; return; } if (!safeThreadId) { if (!warnedMissingThreadIdRef.current) { warnedMissingThreadIdRef.current = true; toast.error(t.chatPage.missingThreadIdForCreate); } return; } warnedMissingThreadIdRef.current = false; if (initializedThreadRef.current === safeThreadId) return; initializedThreadRef.current = safeThreadId; void apiClient.threads // TODO: 先注释先删除再创建的逻辑 // .delete(safeThreadId) // .catch(() => undefined) // .then(() => // apiClient.threads.create({ // threadId: safeThreadId, // ifExists: "raise", // }), // ) .create({ threadId: safeThreadId, ifExists: "do_nothing", }) .catch(() => { initializedThreadRef.current = null; toast.error(t.chatPage.createSessionFailed); }); }, [ apiClient, isNewThread, safeThreadId, t.chatPage.createSessionFailed, t.chatPage.missingThreadIdForCreate, ]); // 监听宿主页 selectedSkill 消息 const { skillError: selectedSkillError, clearSkillError: clearSelectedSkillError, isBootstrapping: isSelectedSkillBootstrapping, } = useSelectedSkillListener({ threadId: safeThreadId ?? null }); // 对话行为控制器 const [thread, sendMessage, isUploading] = useThreadStream({ threadId: streamThreadId, context: settings.context, createNewSession, isMock, // 发送消息后跳转的逻辑 onStart: (currentThreadId) => { setIsNewThread(false); // if (!shouldStayOnNewRoute) { // Keep /new in history so router.back() can return to it. router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`); // } // history.pushState(null, "", pathOfThread(currentThreadId)); }, onFinish: (state) => { if (document.hidden || !document.hasFocus()) { let body = t.chatPage.conversationFinished; 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 title = useMemo(() => { const result = thread.values?.title ?? ""; return result === "Untitled" ? "" : result; }, [thread.values?.title]); const [hasSubmitted, setHasSubmitted] = useState(false); const [historyCutoff, setHistoryCutoff] = useState(null); useEffect(() => { if (shouldRenderHistory) { setHistoryCutoff(null); return; } if (hasSubmitted) return; // Welcome 态下、未提交前,把当前已有消息都当作“历史”切掉。 // 这样即使历史消息是后续异步补齐,也不会重新露出。 setHistoryCutoff((prev) => { const next = thread.messages.length; if (prev === null) return next; return next > prev ? next : prev; }); }, [ hasSubmitted, historyCutoff, shouldRenderHistory, thread.isThreadLoading, thread.messages.length, ]); useEffect(() => { const pageTitle = isNewThread ? t.pages.newChat : thread.values?.title && thread.values.title !== "Untitled" ? thread.values.title : t.pages.untitled; if (thread.isThreadLoading) { document.title = `${t.common.loading} - ${t.pages.appName}`; } else { document.title = `${pageTitle} - ${t.pages.appName}`; } }, [ isNewThread, t.common.loading, t.pages.newChat, t.pages.untitled, t.pages.appName, thread.values?.title, thread.isThreadLoading, ]); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); useEffect(() => { setArtifacts(thread.values.artifacts); if ( env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && autoSelectFirstArtifact ) { if (thread?.values?.artifacts?.length > 0) { setAutoSelectFirstArtifact(false); selectArtifact(thread.values.artifacts[0]!); } } }, [ autoSelectFirstArtifact, selectArtifact, setArtifacts, thread.values.artifacts, ]); const artifactPanelOpen = useMemo(() => { if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") { return artifactsOpen && artifacts?.length > 0; } return artifactsOpen; }, [artifactsOpen, artifacts]); const todoListCollapsed = true; const [showExitDialog, setShowExitDialog] = useState(false); const isStreaming = isUploading || thread.isLoading; const handleSubmit = useCallback( (message: Parameters[1]) => { if (isSelectedSkillBootstrapping) { return; } if (isNewThread && !safeThreadId) { toast.error(t.chatPage.missingThreadIdForSend); return; } setHasSubmitted(true); if (safeThreadId && (isNewThread || showWelcomeStyle)) { router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`); } void sendMessage(safeThreadId, message); }, [ isNewThread, isSelectedSkillBootstrapping, router, safeThreadId, sendMessage, showWelcomeStyle, t.chatPage.missingThreadIdForSend, ], ); const handleStop = useCallback(async () => { await thread.stop(); }, [thread]); const resetNewSessionState = useCallback(() => { setIsNewThread(true); setHasSubmitted(false); setHistoryCutoff(null); setArtifacts([]); deselectArtifact(); setArtifactsOpen(false); setArtifactsFullscreen(false); }, [ deselectArtifact, setArtifacts, setArtifactsFullscreen, setArtifactsOpen, setIsNewThread, ]); return (
{/* threadTitle={title} */} {title !== "Untitled" && ( // )}
{/* 取消TodoList */} {/*
{selectedArtifact ? ( ) : (
{thread.values.artifacts?.length === 0 ? ( } title={t.chatPage.noArtifactSelectedTitle} description={t.chatPage.noArtifactSelectedDescription} /> ) : (

{t.common.artifacts}

)}
)}
{/* Fixed 底部居中输入框容器 */}
{!(showWelcomeStyle && thread.isThreadLoading) ? ( <> {showWelcomeStyle && !hasSubmitted && ( )}
} disabled={ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isSelectedSkillBootstrapping || isUploading || (isNewThread && !safeThreadId) } onContextChange={(context) => setSettings("context", context)} onSubmit={handleSubmit} onStop={handleStop} /> ) : ( // "" )} {/* {isSelectedSkillBootstrapping && (
正在初始化 Skill 文件...
)} */} {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
{t.common.notAvailableInDemoMode}
)}
{/* 退出确认对话框 */} {t.chatPage.exitDialogTitle}

{t.chatPage.exitDialogDescription}

{/* selectedSkill 失败:错误弹窗 */} { if (!open) clearSelectedSkillError(); }} > ⚠️{" "} {selectedSkillError?.title ?? t.chatPage.selectedSkillLoadFailed}

{selectedSkillError?.message ?? t.chatPage.unknownErrorRetry}

{/* MARK: 开发测试:iframe 通信功能测试面板 */} {/* {process.env.NODE_ENV !== "production" && } */}
); }