From 724a5aca31204db49ec5e729da6942ef7b971146 Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Mon, 30 Mar 2026 16:02:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E7=AC=AC=E4=BA=8C=E7=89=88?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/workspace/chats/[thread_id]/page.tsx | 175 ++++-------------- frontend/src/components/ui/dropdown-menu.tsx | 74 ++++---- frontend/src/components/ui/input-group.tsx | 2 +- .../artifacts/artifact-file-detail.tsx | 28 +-- .../src/components/workspace/input-box.tsx | 4 +- frontend/src/components/workspace/welcome.tsx | 16 +- frontend/src/core/threads/hooks.ts | 7 +- 7 files changed, 91 insertions(+), 215 deletions(-) diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 2db533f3..81f780c8 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -1,14 +1,9 @@ "use client"; -import type { Message } from "@langchain/langgraph-sdk"; -import type { UseStream } from "@langchain/langgraph-sdk/react"; import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ConversationEmptyState } from "@/components/ai-elements/conversation"; -import { usePromptInputController } from "@/components/ai-elements/prompt-input"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DevDialog, @@ -23,12 +18,12 @@ import { ArtifactFileList, useArtifacts, } from "@/components/workspace/artifacts"; +import { 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 { TodoList } from "@/components/workspace/todo-list"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { Tooltip } from "@/components/workspace/tooltip"; import { useSpecificChatMode } from "@/components/workspace/use-chat-mode"; @@ -36,25 +31,14 @@ import { Welcome } from "@/components/workspace/welcome"; import { useI18n } from "@/core/i18n/hooks"; import { useNotification } from "@/core/notification/hooks"; import { useLocalSettings } from "@/core/settings"; -import { bootstrapRemoteSkill } from "@/core/skills"; -import { type AgentThread, type AgentThreadState } from "@/core/threads"; -import { - useSubmitThread, - useThreadStreamLegacy as useThreadStream, -} from "@/core/threads/hooks"; -import { - pathOfThread, - textOfMessage, - titleOfThread, -} from "@/core/threads/utils"; -import { uuid } from "@/core/utils/uuid"; +import { useThreadStream } from "@/core/threads/hooks"; +import { pathOfThread, textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener"; import { cn } from "@/lib/utils"; export default function ChatPage() { const { t } = useI18n(); - const router = useRouter(); useSpecificChatMode(); const [settings, setSettings] = useLocalSettings(); const { setOpen: setSidebarOpen } = useSidebar(); @@ -67,106 +51,48 @@ export default function ChatPage() { selectedArtifact, fullscreen, } = useArtifacts(); - const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); - const searchParams = useSearchParams(); - const promptInputController = usePromptInputController(); - - // UI mode depends only on route: /workspace/chats/new is always "new page" mode. - const isNewThread = useMemo( - () => threadIdFromPath === "new", - [threadIdFromPath], - ); - - // Submission strategy is controlled by `isnew` query param only. - // - isnew=false: reuse existing thread - // - otherwise: create/start a new session - const createNewSession = useMemo(() => { - if (threadIdFromPath !== "new") { - return false; - } - - return searchParams.get("isnew")?.trim().toLowerCase() !== "false"; - }, [threadIdFromPath, searchParams]); - - const uploadTarget = useMemo(() => { - const target = searchParams.get("upload_target")?.trim().toLowerCase(); - return target === "skill" ? "skill" : undefined; - }, [searchParams]); - - const [threadId, setThreadId] = useState(null); - useEffect(() => { - if (threadIdFromPath !== "new") { - setThreadId(threadIdFromPath); - } else { - const queryThreadId = searchParams.get("thread_id")?.trim(); - setThreadId(queryThreadId ?? uuid()); - } - }, [threadIdFromPath, searchParams]); - - // Runtime strategy for /new page: - // - UI remains new-page mode - // - if isnew=false, execute against existing thread_id without creating a new one - const reuseExistingThread = useMemo( - () => threadIdFromPath === "new" && !createNewSession && !!threadId, - [threadIdFromPath, createNewSession, threadId], - ); + const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat(); const { showNotification } = useNotification(); // 监听宿主页 selectedSkill 消息 const { - selectedSkill, skillError: selectedSkillError, clearSkillError: clearSelectedSkillError, isBootstrapping: isSelectedSkillBootstrapping, } = useSelectedSkillListener({ threadId }); - const [finalState, setFinalState] = useState(null); - const thread = useThreadStream({ - // Keep UI in new-page mode, but runtime may reuse existing thread - isNewThread: reuseExistingThread ? false : isNewThread, - threadId, - fetchStateHistory: true, + const [thread, sendMessage, isUploading] = useThreadStream({ + threadId: isNewThread ? undefined : threadId, + context: settings.context, + isMock, + onStart: (currentThreadId) => { + setIsNewThread(false); + history.replaceState(null, "", pathOfThread(currentThreadId)); + }, onFinish: (state) => { - setFinalState(state); - // 新对话完成后导航到对话页面 - if (isNewThread && threadId) { - router.push(pathOfThread(threadId)); - } if (document.hidden || !document.hasFocus()) { let body = "Conversation finished"; - const lastMessage = state.messages[state.messages.length - 1]; + const lastMessage = state.messages.at(-1); if (lastMessage) { const textContent = textOfMessage(lastMessage); if (textContent) { - if (textContent.length > 200) { - body = textContent.substring(0, 200) + "..."; - } else { - body = textContent; - } + body = + textContent.length > 200 + ? textContent.substring(0, 200) + "..." + : textContent; } } - showNotification(state.title, { - body, - }); + showNotification(state.title, { body }); } }, - }) as unknown as UseStream; - useEffect(() => { - if (thread.isLoading) setFinalState(null); - }, [thread.isLoading]); + }); const title = useMemo(() => { - let result = isNewThread - ? "" - : titleOfThread(thread as unknown as AgentThread); - if (result === "Untitled") { - result = ""; - } - return result; - }, [thread, isNewThread]); + const result = thread.values?.title ?? ""; + return result === "Untitled" ? "" : result; + }, [thread.values?.title]); const [hasSubmitted, setHasSubmitted] = useState(false); - const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted; useEffect(() => { const pageTitle = isNewThread @@ -174,7 +100,7 @@ export default function ChatPage() { : thread.values?.title && thread.values.title !== "Untitled" ? thread.values.title : t.pages.untitled; - if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) { + if (thread.isThreadLoading) { document.title = `Loading... - ${t.pages.appName}`; } else { document.title = `${pageTitle} - ${t.pages.appName}`; @@ -184,9 +110,8 @@ export default function ChatPage() { t.pages.newChat, t.pages.untitled, t.pages.appName, - thread.values.title, + thread.values?.title, thread.isThreadLoading, - suppressExistingThreadPrefetchUi, ]); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); @@ -215,44 +140,22 @@ export default function ChatPage() { return artifactsOpen; }, [artifactsOpen, artifacts]); - const [todoListCollapsed, setTodoListCollapsed] = useState(true); + const todoListCollapsed = true; const [showExitDialog, setShowExitDialog] = useState(false); - - const submitThread = useSubmitThread({ - isNewThread, - createNewSession, - threadId, - thread, - uploadTarget, - threadContext: { - ...settings.context, - thinking_enabled: settings.context.mode !== "flash", - is_plan_mode: - settings.context.mode === "pro" || settings.context.mode === "ultra", - subagent_enabled: settings.context.mode === "ultra", - }, - afterSubmit() { - // 导航已在 onFinish 中处理,确保 stream 完成后再导航 - }, - }); const handleSubmit = useCallback( - (message: Parameters[0]) => { + (message: Parameters[1]) => { if (isSelectedSkillBootstrapping) { return; } setHasSubmitted(true); - void submitThread(message); + void sendMessage(threadId, message); }, - [isSelectedSkillBootstrapping, submitThread], + [isSelectedSkillBootstrapping, sendMessage, threadId], ); const handleStop = useCallback(async () => { await thread.stop(); }, [thread]); - if (!threadId) { - return null; - } - return (
@@ -449,13 +344,14 @@ export default function ChatPage() { > setSettings("context", context)} onSubmit={handleSubmit} @@ -517,7 +414,7 @@ export default function ChatPage() { setShowExitDialog(false); // 使用完整页面刷新确保组件重新挂载,isNewThread 为 true window.location.href = "/workspace/chats/new"; - }} + }} > 确定 diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx index bbe6fb01..1270e58f 100644 --- a/frontend/src/components/ui/dropdown-menu.tsx +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -1,15 +1,15 @@ -"use client" +"use client"; -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function DropdownMenu({ ...props }: React.ComponentProps) { - return + return ; } function DropdownMenuPortal({ @@ -17,18 +17,20 @@ function DropdownMenuPortal({ }: React.ComponentProps) { return ( - ) + ); } function DropdownMenuTrigger({ + className, ...props }: React.ComponentProps) { return ( - ) + ); } function DropdownMenuContent({ @@ -42,13 +44,13 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", - className + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[20px] border p-[20px] shadow-md", + className, )} {...props} /> - ) + ); } function DropdownMenuGroup({ @@ -56,7 +58,7 @@ function DropdownMenuGroup({ }: React.ComponentProps) { return ( - ) + ); } function DropdownMenuItem({ @@ -65,8 +67,8 @@ function DropdownMenuItem({ variant = "default", ...props }: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" + inset?: boolean; + variant?: "default" | "destructive"; }) { return ( - ) + ); } function DropdownMenuCheckboxItem({ @@ -93,7 +95,7 @@ function DropdownMenuCheckboxItem({ data-slot="dropdown-menu-checkbox-item" className={cn( "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - className + className, )} checked={checked} {...props} @@ -105,7 +107,7 @@ function DropdownMenuCheckboxItem({ {children} - ) + ); } function DropdownMenuRadioGroup({ @@ -116,7 +118,7 @@ function DropdownMenuRadioGroup({ data-slot="dropdown-menu-radio-group" {...props} /> - ) + ); } function DropdownMenuRadioItem({ @@ -128,8 +130,8 @@ function DropdownMenuRadioItem({ @@ -140,7 +142,7 @@ function DropdownMenuRadioItem({ {children} - ) + ); } function DropdownMenuLabel({ @@ -148,7 +150,7 @@ function DropdownMenuLabel({ inset, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( - ) + ); } function DropdownMenuSeparator({ @@ -173,7 +175,7 @@ function DropdownMenuSeparator({ className={cn("bg-border -mx-1 my-1 h-px", className)} {...props} /> - ) + ); } function DropdownMenuShortcut({ @@ -185,17 +187,17 @@ function DropdownMenuShortcut({ data-slot="dropdown-menu-shortcut" className={cn( "text-muted-foreground ml-auto text-xs tracking-widest", - className + className, )} {...props} /> - ) + ); } function DropdownMenuSub({ ...props }: React.ComponentProps) { - return + return ; } function DropdownMenuSubTrigger({ @@ -204,7 +206,7 @@ function DropdownMenuSubTrigger({ children, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( {children} - ) + ); } function DropdownMenuSubContent({ @@ -230,12 +232,12 @@ function DropdownMenuSubContent({ - ) + ); } export { @@ -254,4 +256,4 @@ export { DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, -} +}; diff --git a/frontend/src/components/ui/input-group.tsx b/frontend/src/components/ui/input-group.tsx index a1fea926..bd7e854f 100644 --- a/frontend/src/components/ui/input-group.tsx +++ b/frontend/src/components/ui/input-group.tsx @@ -152,7 +152,7 @@ function InputGroupTextarea({