From 63a5cc22c2f94e3e96c8e4912509c228748f76ee Mon Sep 17 00:00:00 2001 From: MT-Fire <798521692@qq.com> Date: Sat, 28 Mar 2026 22:38:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E4=BB=8E=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20workspace=20=E4=B8=8E=20core=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/workspace/agent-welcome.tsx | 36 ++++ .../workspace/artifacts/artifact-trigger.tsx | 30 +++ .../components/workspace/chats/chat-box.tsx | 180 ++++++++++++++++++ .../src/components/workspace/chats/index.ts | 3 + .../workspace/chats/use-chat-mode.ts | 41 ++++ .../workspace/chats/use-thread-chat.ts | 29 +++ .../workspace/citations/artifact-link.tsx | 33 ++++ .../components/workspace/command-palette.tsx | 130 +++++++++++++ .../components/workspace/export-trigger.tsx | 81 ++++++++ .../workspace/token-usage-indicator.tsx | 74 +++++++ frontend/src/core/api/stream-mode.test.ts | 43 +++++ frontend/src/core/api/stream-mode.ts | 68 +++++++ frontend/src/core/i18n/locale.ts | 36 ++++ frontend/src/core/messages/usage.ts | 62 ++++++ frontend/src/core/threads/export.ts | 142 ++++++++++++++ frontend/src/hooks/use-global-shortcuts.ts | 53 ++++++ 16 files changed, 1041 insertions(+) create mode 100644 frontend/src/components/workspace/agent-welcome.tsx create mode 100644 frontend/src/components/workspace/artifacts/artifact-trigger.tsx create mode 100644 frontend/src/components/workspace/chats/chat-box.tsx create mode 100644 frontend/src/components/workspace/chats/index.ts create mode 100644 frontend/src/components/workspace/chats/use-chat-mode.ts create mode 100644 frontend/src/components/workspace/chats/use-thread-chat.ts create mode 100644 frontend/src/components/workspace/citations/artifact-link.tsx create mode 100644 frontend/src/components/workspace/command-palette.tsx create mode 100644 frontend/src/components/workspace/export-trigger.tsx create mode 100644 frontend/src/components/workspace/token-usage-indicator.tsx create mode 100644 frontend/src/core/api/stream-mode.test.ts create mode 100644 frontend/src/core/api/stream-mode.ts create mode 100644 frontend/src/core/i18n/locale.ts create mode 100644 frontend/src/core/messages/usage.ts create mode 100644 frontend/src/core/threads/export.ts create mode 100644 frontend/src/hooks/use-global-shortcuts.ts diff --git a/frontend/src/components/workspace/agent-welcome.tsx b/frontend/src/components/workspace/agent-welcome.tsx new file mode 100644 index 00000000..7d30b9b3 --- /dev/null +++ b/frontend/src/components/workspace/agent-welcome.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { BotIcon } from "lucide-react"; + +import { type Agent } from "@/core/agents"; +import { cn } from "@/lib/utils"; + +export function AgentWelcome({ + className, + agent, + agentName, +}: { + className?: string; + agent: Agent | null | undefined; + agentName: string; +}) { + const displayName = agent?.name ?? agentName; + const description = agent?.description; + + return ( +
+
+ +
+
{displayName}
+ {description && ( +

{description}

+ )} +
+ ); +} diff --git a/frontend/src/components/workspace/artifacts/artifact-trigger.tsx b/frontend/src/components/workspace/artifacts/artifact-trigger.tsx new file mode 100644 index 00000000..df1fe684 --- /dev/null +++ b/frontend/src/components/workspace/artifacts/artifact-trigger.tsx @@ -0,0 +1,30 @@ +import { FilesIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Tooltip } from "@/components/workspace/tooltip"; +import { useI18n } from "@/core/i18n/hooks"; + +import { useArtifacts } from "./context"; + +export const ArtifactTrigger = () => { + const { t } = useI18n(); + const { artifacts, setOpen: setArtifactsOpen } = useArtifacts(); + + if (!artifacts || artifacts.length === 0) { + return null; + } + return ( + + + + ); +}; diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx new file mode 100644 index 00000000..a57aa522 --- /dev/null +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -0,0 +1,180 @@ +import { FilesIcon, XIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { GroupImperativeHandle } from "react-resizable-panels"; + +import { ConversationEmptyState } from "@/components/ai-elements/conversation"; +import { Button } from "@/components/ui/button"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { env } from "@/env"; +import { cn } from "@/lib/utils"; + +import { + ArtifactFileDetail, + ArtifactFileList, + useArtifacts, +} from "../artifacts"; +import { useThread } from "../messages/context"; + +const CLOSE_MODE = { chat: 100, artifacts: 0 }; +const OPEN_MODE = { chat: 60, artifacts: 40 }; + +const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ + children, + threadId, +}) => { + const { thread } = useThread(); + const pathname = usePathname(); + const threadIdRef = useRef(threadId); + const layoutRef = useRef(null); + + const { + artifacts, + open: artifactsOpen, + setOpen: setArtifactsOpen, + setArtifacts, + select: selectArtifact, + deselect, + selectedArtifact, + } = useArtifacts(); + + const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); + useEffect(() => { + if (threadIdRef.current !== threadId) { + threadIdRef.current = threadId; + deselect(); + } + + // Update artifacts from the current thread + setArtifacts(thread.values.artifacts); + + // DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now. + // if ( + // selectedArtifact && + // !thread.values.artifacts?.includes(selectedArtifact) + // ) { + // deselect(); + // } + + if ( + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && + autoSelectFirstArtifact + ) { + if (thread?.values?.artifacts?.length > 0) { + setAutoSelectFirstArtifact(false); + selectArtifact(thread.values.artifacts[0]!); + } + } + }, [ + threadId, + autoSelectFirstArtifact, + deselect, + selectArtifact, + selectedArtifact, + 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 resizableIdBase = useMemo(() => { + return pathname.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); + }, [pathname]); + + useEffect(() => { + if (layoutRef.current) { + if (artifactPanelOpen) { + layoutRef.current.setLayout(OPEN_MODE); + } else { + layoutRef.current.setLayout(CLOSE_MODE); + } + } + }, [artifactPanelOpen]); + + return ( + + + {children} + + + +
+ {selectedArtifact ? ( + + ) : ( +
+
+ +
+ {thread.values.artifacts?.length === 0 ? ( + } + title="No artifact selected" + description="Select an artifact to view its details" + /> + ) : ( +
+
+

Artifacts

+
+
+ +
+
+ )} +
+ )} +
+
+
+ ); +}; + +export { ChatBox }; diff --git a/frontend/src/components/workspace/chats/index.ts b/frontend/src/components/workspace/chats/index.ts new file mode 100644 index 00000000..5a915422 --- /dev/null +++ b/frontend/src/components/workspace/chats/index.ts @@ -0,0 +1,3 @@ +export * from "./chat-box"; +export * from "./use-chat-mode"; +export * from "./use-thread-chat"; diff --git a/frontend/src/components/workspace/chats/use-chat-mode.ts b/frontend/src/components/workspace/chats/use-chat-mode.ts new file mode 100644 index 00000000..75de6d13 --- /dev/null +++ b/frontend/src/components/workspace/chats/use-chat-mode.ts @@ -0,0 +1,41 @@ +import { useParams, useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useRef } from "react"; + +import { usePromptInputController } from "@/components/ai-elements/prompt-input"; +import { useI18n } from "@/core/i18n/hooks"; + +/** + * Hook to determine if the chat is in a specific mode based on URL parameters, and to set an initial prompt input value accordingly. + */ +export function useSpecificChatMode() { + const { t } = useI18n(); + const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); + const searchParams = useSearchParams(); + const promptInputController = usePromptInputController(); + const inputInitialValue = useMemo(() => { + if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") { + return undefined; + } + return t.inputBox.createSkillPrompt; + }, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]); + const lastInitialValueRef = useRef(undefined); + const setInputRef = useRef(promptInputController.textInput.setInput); + setInputRef.current = promptInputController.textInput.setInput; + useEffect(() => { + if ( + inputInitialValue && + inputInitialValue !== lastInitialValueRef.current + ) { + lastInitialValueRef.current = inputInitialValue; + setTimeout(() => { + setInputRef.current(inputInitialValue); + const textarea = document.querySelector("textarea"); + if (textarea) { + textarea.focus(); + textarea.selectionStart = textarea.value.length; + textarea.selectionEnd = textarea.value.length; + } + }, 100); + } + }, [inputInitialValue]); +} diff --git a/frontend/src/components/workspace/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts new file mode 100644 index 00000000..b3164485 --- /dev/null +++ b/frontend/src/components/workspace/chats/use-thread-chat.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useParams, usePathname, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { uuid } from "@/core/utils/uuid"; + +export function useThreadChat() { + const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); + const pathname = usePathname(); + + const searchParams = useSearchParams(); + const [threadId, setThreadId] = useState(() => { + return threadIdFromPath === "new" ? uuid() : threadIdFromPath; + }); + + const [isNewThread, setIsNewThread] = useState( + () => threadIdFromPath === "new", + ); + + useEffect(() => { + if (pathname.endsWith("/new")) { + setIsNewThread(true); + setThreadId(uuid()); + } + }, [pathname]); + const isMock = searchParams.get("mock") === "true"; + return { threadId, isNewThread, setIsNewThread, isMock }; +} diff --git a/frontend/src/components/workspace/citations/artifact-link.tsx b/frontend/src/components/workspace/citations/artifact-link.tsx new file mode 100644 index 00000000..c7bc1719 --- /dev/null +++ b/frontend/src/components/workspace/citations/artifact-link.tsx @@ -0,0 +1,33 @@ +import type { AnchorHTMLAttributes } from "react"; + +import { cn } from "@/lib/utils"; + +import { CitationLink } from "./citation-link"; + +function isExternalUrl(href: string | undefined): boolean { + return !!href && /^https?:\/\//.test(href); +} + +/** Link renderer for artifact markdown: citation: prefix โ†’ CitationLink, otherwise underlined text. */ +export function ArtifactLink(props: AnchorHTMLAttributes) { + if (typeof props.children === "string") { + const match = /^citation:(.+)$/.exec(props.children); + if (match) { + const [, text] = match; + return {text}; + } + } + const { className, target, rel, ...rest } = props; + const external = isExternalUrl(props.href); + return ( + + ); +} diff --git a/frontend/src/components/workspace/command-palette.tsx b/frontend/src/components/workspace/command-palette.tsx new file mode 100644 index 00000000..4f9a417b --- /dev/null +++ b/frontend/src/components/workspace/command-palette.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { + KeyboardIcon, + MessageSquarePlusIcon, + SettingsIcon, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useMemo, useState } from "react"; + +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandShortcut, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useI18n } from "@/core/i18n/hooks"; +import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts"; + +import { SettingsDialog } from "./settings"; + +export function CommandPalette() { + const { t } = useI18n(); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + const handleNewChat = useCallback(() => { + router.push("/workspace/chats/new"); + setOpen(false); + }, [router]); + + const handleOpenSettings = useCallback(() => { + setOpen(false); + setSettingsOpen(true); + }, []); + + const handleShowShortcuts = useCallback(() => { + setOpen(false); + setShortcutsOpen(true); + }, []); + + const shortcuts = useMemo( + () => [ + { key: "k", meta: true, action: () => setOpen((o) => !o) }, + { key: "n", meta: true, shift: true, action: handleNewChat }, + { key: ",", meta: true, action: handleOpenSettings }, + { key: "/", meta: true, action: handleShowShortcuts }, + ], + [handleNewChat, handleOpenSettings, handleShowShortcuts], + ); + + useGlobalShortcuts(shortcuts); + + + const isMac = + typeof navigator !== "undefined" && navigator.userAgent.includes("Mac"); + const metaKey = isMac ? "โŒ˜" : "Ctrl+"; + const shiftKey = isMac ? "โ‡ง" : "Shift+"; + + return ( + <> + + + + + {t.shortcuts.noResults} + + + + {t.sidebar.newChat} + {metaKey}{shiftKey}N + + + + {t.common.settings} + {metaKey}, + + + + {t.shortcuts.keyboardShortcuts} + {metaKey}/ + + + + + + + + + {t.shortcuts.keyboardShortcuts} + + {t.shortcuts.keyboardShortcutsDescription} + + +
+ {[ + { keys: `${metaKey}K`, label: t.shortcuts.openCommandPalette }, + { keys: `${metaKey}${shiftKey}N`, label: t.sidebar.newChat }, + { keys: `${metaKey}B`, label: t.shortcuts.toggleSidebar }, + { keys: `${metaKey},`, label: t.common.settings }, + { + keys: `${metaKey}/`, + label: t.shortcuts.keyboardShortcuts, + }, + ].map(({ keys, label }) => ( +
+ {label} + + {keys} + +
+ ))} +
+
+
+ + ); +} diff --git a/frontend/src/components/workspace/export-trigger.tsx b/frontend/src/components/workspace/export-trigger.tsx new file mode 100644 index 00000000..b75d4e45 --- /dev/null +++ b/frontend/src/components/workspace/export-trigger.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Download, FileJson, FileText } from "lucide-react"; +import { useCallback } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useI18n } from "@/core/i18n/hooks"; +import { + exportThreadAsJSON, + exportThreadAsMarkdown, +} from "@/core/threads/export"; +import type { AgentThread } from "@/core/threads/types"; + +import { useThread } from "./messages/context"; +import { Tooltip } from "./tooltip"; + +export function ExportTrigger({ threadId }: { threadId: string }) { + const { t } = useI18n(); + const { thread } = useThread(); + + const messages = thread.messages; + + const handleExport = useCallback( + (format: "markdown" | "json") => { + if (messages.length === 0) { + toast.error(t.conversation.noMessages); + return; + } + const agentThread = { + thread_id: threadId, + updated_at: new Date().toISOString(), + values: thread.values, + } as AgentThread; + + if (format === "markdown") { + exportThreadAsMarkdown(agentThread, messages); + } else { + exportThreadAsJSON(agentThread, messages); + } + toast.success(t.common.exportSuccess); + }, + [messages, thread.values, threadId, t], + ); + + if (messages.length === 0) { + return null; + } + + return ( + + + + + + + + handleExport("markdown")}> + + {t.common.exportAsMarkdown} + + handleExport("json")}> + + {t.common.exportAsJSON} + + + + ); +} diff --git a/frontend/src/components/workspace/token-usage-indicator.tsx b/frontend/src/components/workspace/token-usage-indicator.tsx new file mode 100644 index 00000000..9f0b02f7 --- /dev/null +++ b/frontend/src/components/workspace/token-usage-indicator.tsx @@ -0,0 +1,74 @@ +"use client"; + +import type { Message } from "@langchain/langgraph-sdk"; +import { CoinsIcon } from "lucide-react"; +import { useMemo } from "react"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useI18n } from "@/core/i18n/hooks"; +import { accumulateUsage, formatTokenCount } from "@/core/messages/usage"; +import { cn } from "@/lib/utils"; + +interface TokenUsageIndicatorProps { + messages: Message[]; + className?: string; +} + +export function TokenUsageIndicator({ + messages, + className, +}: TokenUsageIndicatorProps) { + const { t } = useI18n(); + + const usage = useMemo(() => accumulateUsage(messages), [messages]); + + if (!usage) { + return null; + } + + return ( + + + + + +
+
{t.tokenUsage.title}
+
+ {t.tokenUsage.input} + + {formatTokenCount(usage.inputTokens)} + +
+
+ {t.tokenUsage.output} + + {formatTokenCount(usage.outputTokens)} + +
+
+
+ {t.tokenUsage.total} + + {formatTokenCount(usage.totalTokens)} + +
+
+
+
+
+ ); +} diff --git a/frontend/src/core/api/stream-mode.test.ts b/frontend/src/core/api/stream-mode.test.ts new file mode 100644 index 00000000..879cf03d --- /dev/null +++ b/frontend/src/core/api/stream-mode.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { sanitizeRunStreamOptions } = await import( + new URL("./stream-mode.ts", import.meta.url).href +); + +void test("drops unsupported stream modes from array payloads", () => { + const sanitized = sanitizeRunStreamOptions({ + streamMode: [ + "values", + "messages-tuple", + "custom", + "updates", + "events", + "tools", + ], + }); + + assert.deepEqual(sanitized.streamMode, [ + "values", + "messages-tuple", + "custom", + "updates", + "events", + ]); +}); + +void test("drops unsupported stream modes from scalar payloads", () => { + const sanitized = sanitizeRunStreamOptions({ + streamMode: "tools", + }); + + assert.equal(sanitized.streamMode, undefined); +}); + +void test("keeps payloads without streamMode untouched", () => { + const options = { + streamSubgraphs: true, + }; + + assert.equal(sanitizeRunStreamOptions(options), options); +}); diff --git a/frontend/src/core/api/stream-mode.ts b/frontend/src/core/api/stream-mode.ts new file mode 100644 index 00000000..7acae864 --- /dev/null +++ b/frontend/src/core/api/stream-mode.ts @@ -0,0 +1,68 @@ +const SUPPORTED_RUN_STREAM_MODES = new Set([ + "values", + "messages", + "messages-tuple", + "updates", + "events", + "debug", + "tasks", + "checkpoints", + "custom", +] as const); + +const warnedUnsupportedStreamModes = new Set(); + +export function warnUnsupportedStreamModes( + modes: string[], + warn: (message: string) => void = console.warn, +) { + const unseenModes = modes.filter((mode) => { + if (warnedUnsupportedStreamModes.has(mode)) { + return false; + } + warnedUnsupportedStreamModes.add(mode); + return true; + }); + + if (unseenModes.length === 0) { + return; + } + + warn( + `[deer-flow] Dropped unsupported LangGraph stream mode(s): ${unseenModes.join(", ")}`, + ); +} + +export function sanitizeRunStreamOptions(options: T): T { + if ( + typeof options !== "object" || + options === null || + !("streamMode" in options) + ) { + return options; + } + + const streamMode = options.streamMode; + if (streamMode == null) { + return options; + } + + const requestedModes = Array.isArray(streamMode) ? streamMode : [streamMode]; + const sanitizedModes = requestedModes.filter((mode) => + SUPPORTED_RUN_STREAM_MODES.has(mode), + ); + + if (sanitizedModes.length === requestedModes.length) { + return options; + } + + const droppedModes = requestedModes.filter( + (mode) => !SUPPORTED_RUN_STREAM_MODES.has(mode), + ); + warnUnsupportedStreamModes(droppedModes); + + return { + ...options, + streamMode: Array.isArray(streamMode) ? sanitizedModes : sanitizedModes[0], + }; +} diff --git a/frontend/src/core/i18n/locale.ts b/frontend/src/core/i18n/locale.ts new file mode 100644 index 00000000..7fbde14d --- /dev/null +++ b/frontend/src/core/i18n/locale.ts @@ -0,0 +1,36 @@ +export const SUPPORTED_LOCALES = ["en-US", "zh-CN"] as const; +export type Locale = (typeof SUPPORTED_LOCALES)[number]; +export const DEFAULT_LOCALE: Locale = "en-US"; + +export function isLocale(value: string): value is Locale { + return (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +export function normalizeLocale(locale: string | null | undefined): Locale { + if (!locale) { + return DEFAULT_LOCALE; + } + + if (isLocale(locale)) { + return locale; + } + + if (locale.toLowerCase().startsWith("zh")) { + return "zh-CN"; + } + + return DEFAULT_LOCALE; +} + +// Helper function to detect browser locale +export function detectLocale(): Locale { + if (typeof window === "undefined") { + return DEFAULT_LOCALE; + } + + const browserLang = + navigator.language || + (navigator as unknown as { userLanguage: string }).userLanguage; + + return normalizeLocale(browserLang); +} diff --git a/frontend/src/core/messages/usage.ts b/frontend/src/core/messages/usage.ts new file mode 100644 index 00000000..44cee07e --- /dev/null +++ b/frontend/src/core/messages/usage.ts @@ -0,0 +1,62 @@ +import type { Message } from "@langchain/langgraph-sdk"; + +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + totalTokens: number; +} + +/** + * Extract usage_metadata from an AI message if present. + * The field is added by the backend (PR #1218) but not typed in the SDK. + */ +function getUsageMetadata( + message: Message, +): TokenUsage | null { + if (message.type !== "ai") { + return null; + } + const usage = (message as Record).usage_metadata as + | { input_tokens?: number; output_tokens?: number; total_tokens?: number } + | undefined; + if (!usage) { + return null; + } + return { + inputTokens: usage.input_tokens ?? 0, + outputTokens: usage.output_tokens ?? 0, + totalTokens: usage.total_tokens ?? 0, + }; +} + +/** + * Accumulate token usage across all AI messages in a thread. + */ +export function accumulateUsage(messages: Message[]): TokenUsage | null { + const cumulative: TokenUsage = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + }; + let hasUsage = false; + for (const message of messages) { + const usage = getUsageMetadata(message); + if (usage) { + hasUsage = true; + cumulative.inputTokens += usage.inputTokens; + cumulative.outputTokens += usage.outputTokens; + cumulative.totalTokens += usage.totalTokens; + } + } + return hasUsage ? cumulative : null; +} + +/** + * Format a token count for display: 1234 -> "1,234", 12345 -> "12.3K" + */ +export function formatTokenCount(count: number): string { + if (count < 10_000) { + return count.toLocaleString(); + } + return `${(count / 1000).toFixed(1)}K`; +} diff --git a/frontend/src/core/threads/export.ts b/frontend/src/core/threads/export.ts new file mode 100644 index 00000000..cf3e2f3b --- /dev/null +++ b/frontend/src/core/threads/export.ts @@ -0,0 +1,142 @@ +import type { Message } from "@langchain/langgraph-sdk"; + +import { + extractContentFromMessage, + extractReasoningContentFromMessage, + hasContent, + hasToolCalls, + stripUploadedFilesTag, +} from "../messages/utils"; + +import type { AgentThread } from "./types"; +import { titleOfThread } from "./utils"; + +function formatMessageContent(message: Message): string { + const text = extractContentFromMessage(message); + if (!text) return ""; + return stripUploadedFilesTag(text); +} + +function formatToolCalls(message: Message): string { + if (message.type !== "ai" || !hasToolCalls(message)) return ""; + const calls = message.tool_calls ?? []; + return calls.map((call) => `- **Tool:** \`${call.name}\``).join("\n"); +} + +export function formatThreadAsMarkdown( + thread: AgentThread, + messages: Message[], +): string { + const title = titleOfThread(thread); + const createdAt = thread.created_at + ? new Date(thread.created_at).toLocaleString() + : "Unknown"; + + const lines: string[] = [ + `# ${title}`, + "", + `*Exported on ${new Date().toLocaleString()} ยท Created ${createdAt}*`, + "", + "---", + "", + ]; + + for (const message of messages) { + if (message.type === "human") { + const content = formatMessageContent(message); + if (content) { + lines.push(`## ๐Ÿง‘ User`, "", content, "", "---", ""); + } + } else if (message.type === "ai") { + const reasoning = extractReasoningContentFromMessage(message); + const content = formatMessageContent(message); + const toolCalls = formatToolCalls(message); + + if (!content && !toolCalls && !reasoning) continue; + + lines.push(`## ๐Ÿค– Assistant`); + + if (reasoning) { + lines.push( + "", + "
", + "Thinking", + "", + reasoning, + "", + "
", + ); + } + + if (toolCalls) { + lines.push("", toolCalls); + } + + if (content && hasContent(message)) { + lines.push("", content); + } + + lines.push("", "---", ""); + } + } + + return lines.join("\n").trimEnd() + "\n"; +} + +export function formatThreadAsJSON( + thread: AgentThread, + messages: Message[], +): string { + const exportData = { + title: titleOfThread(thread), + thread_id: thread.thread_id, + created_at: thread.created_at, + exported_at: new Date().toISOString(), + messages: messages.map((msg) => ({ + type: msg.type, + id: msg.id, + content: typeof msg.content === "string" ? msg.content : msg.content, + ...(msg.type === "ai" && msg.tool_calls?.length + ? { tool_calls: msg.tool_calls } + : {}), + })), + }; + return JSON.stringify(exportData, null, 2); +} + +function sanitizeFilename(name: string): string { + return ( + name.replace(/[^\p{L}\p{N}_\- ]/gu, "").trim() || "conversation" + ); +} + +export function downloadAsFile( + content: string, + filename: string, + mimeType: string, +) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +export function exportThreadAsMarkdown( + thread: AgentThread, + messages: Message[], +) { + const markdown = formatThreadAsMarkdown(thread, messages); + const filename = `${sanitizeFilename(titleOfThread(thread))}.md`; + downloadAsFile(markdown, filename, "text/markdown;charset=utf-8"); +} + +export function exportThreadAsJSON(thread: AgentThread, messages: Message[]) { + const json = formatThreadAsJSON(thread, messages); + const filename = `${sanitizeFilename(titleOfThread(thread))}.json`; + downloadAsFile(json, filename, "application/json;charset=utf-8"); +} diff --git a/frontend/src/hooks/use-global-shortcuts.ts b/frontend/src/hooks/use-global-shortcuts.ts new file mode 100644 index 00000000..f0423582 --- /dev/null +++ b/frontend/src/hooks/use-global-shortcuts.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; + +type ShortcutAction = () => void; + +interface Shortcut { + key: string; + meta: boolean; + shift?: boolean; + action: ShortcutAction; +} + +/** + * Register global keyboard shortcuts on window. + * Shortcuts are suppressed when focus is inside an input, textarea, or + * contentEditable element - except for Cmd+K which always fires. + */ +export function useGlobalShortcuts(shortcuts: Shortcut[]) { + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + const meta = event.metaKey || event.ctrlKey; + + for (const shortcut of shortcuts) { + if ( + event.key.toLowerCase() === shortcut.key.toLowerCase() && + meta === shortcut.meta && + (shortcut.shift ?? false) === event.shiftKey + ) { + // Allow Cmd+K even in inputs (standard command palette behavior) + if (shortcut.key !== "k") { + const target = event.target as HTMLElement; + const tag = target.tagName; + if ( + tag === "INPUT" || + tag === "TEXTAREA" || + target.isContentEditable + ) { + continue; + } + } + + event.preventDefault(); + shortcut.action(); + return; + } + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [shortcuts]); +}