From a34622c45c091dca8774a81b9ab0353858cf4295 Mon Sep 17 00:00:00 2001 From: MT-Fire <798521692@qq.com> Date: Sun, 29 Mar 2026 00:04:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E5=90=88=E5=B9=B6=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E6=B5=81=E4=B8=8E=E5=B7=A5=E4=BB=B6=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=B9=B6=E5=85=BC=E5=AE=B9=E8=81=8A=E5=A4=A9=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/workspace/chats/[thread_id]/page.tsx | 605 ++++-------------- .../artifacts/artifact-file-detail.tsx | 555 ++++------------ frontend/src/core/threads/hooks.ts | 597 ++++++++++++----- 3 files changed, 649 insertions(+), 1108 deletions(-) diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index b5ca373a..9c290ff0 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -1,550 +1,159 @@ "use client"; 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, useState } from "react"; +import { useCallback } from "react"; -import { ConversationEmptyState } from "@/components/ai-elements/conversation"; -import { Button } from "@/components/ui/button"; +import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; +import { ArtifactTrigger } from "@/components/workspace/artifacts"; 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 { DevTodoList } from "@/components/workspace/dev-todo-list"; + ChatBox, + useSpecificChatMode, + useThreadChat, +} from "@/components/workspace/chats"; +import { ExportTrigger } from "@/components/workspace/export-trigger"; 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 { 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 { useLocalSettings } from "@/core/settings"; -import { type AgentThread, type AgentThreadState } from "@/core/threads"; -import { useSubmitThread, useThreadStream } from "@/core/threads/hooks"; -import { - pathOfThread, - textOfMessage, - titleOfThread, -} from "@/core/threads/utils"; -import { uuid } from "@/core/utils/uuid"; +import type { AgentThreadState } from "@/core/threads"; +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"; export default function ChatPage() { const { t } = useI18n(); - const router = useRouter(); - useSpecificChatMode(); const [settings, setSettings] = useLocalSettings(); - const { setOpen: setSidebarOpen } = useSidebar(); - const { - artifacts, - open: artifactsOpen, - setOpen: setArtifactsOpen, - setArtifacts, - select: selectArtifact, - selectedArtifact, - fullscreen, - } = useArtifacts(); - const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); - const searchParams = useSearchParams(); - // 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(); + useSpecificChatMode(); 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: () => { + 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) => { - 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 [hasSubmitted, setHasSubmitted] = useState(false); - const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted; - - useEffect(() => { - const pageTitle = isNewThread - ? t.pages.newChat - : thread.values?.title && thread.values.title !== "Untitled" - ? thread.values.title - : t.pages.untitled; - if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) { - document.title = `Loading... - ${t.pages.appName}`; - } else { - document.title = `${pageTitle} - ${t.pages.appName}`; - } - }, [ - isNewThread, - t.pages.newChat, - t.pages.untitled, - t.pages.appName, - thread.values.title, - thread.isThreadLoading, - suppressExistingThreadPrefetchUi, - ]); - - 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] = useState(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]) => { - if (isSelectedSkillBootstrapping) { - return; - } - setHasSubmitted(true); - void submitThread(message); + (message: PromptInputMessage) => { + void sendMessage(threadId, message); }, - [isSelectedSkillBootstrapping, submitThread], + [sendMessage, threadId], ); const handleStop = useCallback(async () => { await thread.stop(); }, [thread]); - - if (!threadId) { - return null; - } + const legacyThread = thread as unknown as UseStream; return ( - -
-
-
+ +
+
-
-
-
- -
-
- {title !== "Untitled" && ( - - )} -
-
-
-
-
-
- -
-
+
+
-
-
-
- {selectedArtifact ? ( - - ) : ( -
-
- -
- {thread.values.artifacts?.length === 0 ? ( - } - title="No artifact selected" - description="Select an artifact to view its details" +
+ + + +
+
+
+
+ +
+
+
+
+
+
- )} -
-
-
- - {/* Fixed 底部居中输入框容器 */} -
-
- - {isNewThread && !hasSubmitted && ( - - )} -
- } - disabled={ - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || - isSelectedSkillBootstrapping - } - onContextChange={(context) => setSettings("context", context)} - onSubmit={handleSubmit} - onStop={handleStop} - /> - - {/* {isSelectedSkillBootstrapping && ( -
- 正在初始化 Skill 文件... -
- )} */} - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( -
- {t.common.notAvailableInDemoMode} -
- )} -
-
- - {/* 退出确认对话框 */} - - - - 提示 - -

- 退出后,当前会话结束并销毁,请先下载保存当前结果! -

- - - - -
-
- - {/* selectedSkill 失败:错误弹窗 */} - { - if (!open) clearSelectedSkillError(); - }} - > - - - - ⚠️ {selectedSkillError?.title ?? "技能加载失败"} - - -

- {selectedSkillError?.message ?? "发生了未知错误,请稍后重试。"} -

- - - -
-
- - {/* MARK: 开发测试:iframe 通信功能测试面板 */} - {/* */} -
+ context={settings.context} + extraHeader={ + isNewThread && + } + disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading} + onContextChange={(context) => setSettings("context", context)} + onSubmit={handleSubmit} + onStop={handleStop} + /> + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( +
+ {t.common.notAvailableInDemoMode} +
+ )} +
+ + + +
); } diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 64e5cbe2..267320b8 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -1,11 +1,14 @@ -import { DownloadIcon, FileTextIcon, LoaderIcon, FileTypeIcon } from "lucide-react"; import { - useCallback, - useEffect, - useMemo, - useState, - type HTMLAttributes, -} from "react"; + Code2Icon, + CopyIcon, + DownloadIcon, + EyeIcon, + LoaderIcon, + PackageIcon, + SquareArrowOutUpRightIcon, + XIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -17,26 +20,27 @@ import { ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; +import { Select, SelectItem } from "@/components/ui/select"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { DropdownSelector } from "@/components/ui/dropdown-selector"; + SelectContent, + SelectGroup, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { useArtifactContent } from "@/core/artifacts/hooks"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; -import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; import { checkCodeFile, getFileName } from "@/core/utils/files"; -import { useMarkdownDownload } from "@/core/utils/markdown-download"; -import { cn, copyToClipboard, truncateMiddle } from "@/lib/utils"; +import { env } from "@/env"; +import { cn } from "@/lib/utils"; -import { CitationLink } from "../citations/citation-link"; +import { ArtifactLink } from "../citations/artifact-link"; +import { useThread } from "../messages/context"; +import { Tooltip } from "../tooltip"; import { useArtifacts } from "./context"; @@ -50,8 +54,7 @@ export function ArtifactFileDetail({ threadId: string; }) { const { t } = useI18n(); - const { artifacts, setOpen, select, fullscreen, setFullscreen } = - useArtifacts(); + const { artifacts, setOpen, select } = useArtifacts(); const isWriteFile = useMemo(() => { return filepathFromProps.startsWith("write-file:"); }, [filepathFromProps]); @@ -77,9 +80,9 @@ export function ArtifactFileDetail({ } return checkCodeFile(filepath); }, [filepath, isWriteFile, isSkillFile]); - const previewable = useMemo(() => { - return (language === "html" && !isWriteFile) || language === "markdown"; - }, [isWriteFile, language]); + const isSupportPreview = useMemo(() => { + return language === "html" || language === "markdown"; + }, [language]); const { content } = useArtifactContent({ threadId, filepath: filepathFromProps, @@ -88,62 +91,16 @@ export function ArtifactFileDetail({ const displayContent = content ?? ""; - const artifactOptions = useMemo(() => { - return (artifacts ?? []).map((artifactPath) => ({ - value: artifactPath, - label: getFileName(artifactPath), - })); - }, [artifacts]); - const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [isInstalling, setIsInstalling] = useState(false); - const [zoom, setZoom] = useState(80); - - // 获取文件名(不含路径) - const fileName = useMemo(() => getFileName(filepath), [filepath]); - - // 是否可以转换为docx/pdf(仅markdown文件支持) - const canConvertToDocxPdf = language === "markdown"; - - // 使用 Markdown 下载 hook - const { isDownloading, downloadAsDocx, downloadAsPdf } = useMarkdownDownload({ - onError: (error, format) => { - console.error(`Failed to download as ${format}:`, error); - toast.error(`Failed to download as ${format.toUpperCase()}`); - }, - }); - - // 下载为 DOCX - const handleDownloadDocx = useCallback(() => { - if (content) { - void downloadAsDocx(content, fileName); - } - }, [content, fileName, downloadAsDocx]); - - // 下载为 PDF - const handleDownloadPdf = useCallback(() => { - if (content) { - void downloadAsPdf(content, fileName); - } - }, [content, fileName, downloadAsPdf]); - - // 全屏切换处理 - const handleFullscreenToggle = useCallback(() => { - const newFullscreen = !fullscreen; - setFullscreen(newFullscreen); - sendToParent({ - type: POST_MESSAGE_TYPES.FULLSCREEN, - fullscreen: newFullscreen, - }); - }, [fullscreen, setFullscreen]); - + const { isMock } = useThread(); useEffect(() => { - if (previewable) { + if (isSupportPreview) { setViewMode("preview"); } else { setViewMode("code"); } - }, [previewable]); + }, [isSupportPreview]); const handleInstallSkill = useCallback(async () => { if (isInstalling) return; @@ -166,18 +123,38 @@ export function ArtifactFileDetail({ setIsInstalling(false); } }, [threadId, filepath, isInstalling]); - return ( - // 给滚动遮挡头部定位relative - - -
- {previewable && ( + + +
+ + {isWriteFile ? ( +
{getFileName(filepath)}
+ ) : ( + + )} +
+
+
+ {isSupportPreview && ( { if (value) { @@ -186,75 +163,47 @@ export function ArtifactFileDetail({ }} > - - - - - + - - - - + )} - {/* 放大缩小选择器 */} -
-
- - {isWriteFile ? ( -
{truncateMiddle(getFileName(filepath), 50)}
- ) : ( - - )} -
-
-
+
+ {!isWriteFile && filepath.endsWith(".skill") && ( + + + + )} + {!isWriteFile && ( + + + + )} {isCodeFile && ( { try { - await copyToClipboard(displayContent ?? ""); + await navigator.clipboard.writeText(displayContent ?? ""); toast.success(t.clipboard.copiedToClipboard); } catch (error) { toast.error("Failed to copy to clipboard"); @@ -262,210 +211,49 @@ export function ArtifactFileDetail({ } }} tooltip={t.clipboard.copyToClipboard} - > - - - - - + /> )} {!isWriteFile && ( - - - - {isDownloading ? ( - - ) : ( - - - - - )} - - - - - - - {t.common.downloadOriginal} - - - {/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */} - {canConvertToDocxPdf && ( - <> - - - {isDownloading === "docx" ? t.common.loading : t.common.downloadAsDocx} - - - - {isDownloading === "pdf" ? t.common.loading : t.common.downloadAsPdf} - - - )} - - - )} - {/* 全屏按钮 */} - - {fullscreen ? ( - - - - - - - ) : ( - - - - )} - - {!fullscreen && ( - setOpen(false)} - tooltip={t.common.close} + - - - - + + )} + setOpen(false)} + tooltip={t.common.close} + />
- - {/* 遮挡多余的滚动顶部 */} -
- {previewable && + + {isSupportPreview && viewMode === "preview" && (language === "markdown" || language === "html") && ( )} {isCodeFile && viewMode === "code" && ( )} {!isCodeFile && (