feat(frontend): 从快照新增 workspace 与 core 文件
This commit is contained in:
parent
4b41b7510c
commit
63a5cc22c2
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full flex-col items-center justify-center gap-2 px-8 py-4 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{displayName}</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground max-w-sm text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Tooltip content="Show artifacts of this conversation">
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(true);
|
||||
}}
|
||||
>
|
||||
<FilesIcon />
|
||||
{t.common.artifacts}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<GroupImperativeHandle>(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 (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
>
|
||||
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id={`${resizableIdBase}-separator`}
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out",
|
||||
!artifactsOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full p-4 transition-transform duration-300 ease-in-out",
|
||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChatBox };
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./chat-box";
|
||||
export * from "./use-chat-mode";
|
||||
export * from "./use-thread-chat";
|
||||
|
|
@ -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<string | undefined>(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]);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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<HTMLAnchorElement>) {
|
||||
if (typeof props.children === "string") {
|
||||
const match = /^citation:(.+)$/.exec(props.children);
|
||||
if (match) {
|
||||
const [, text] = match;
|
||||
return <CitationLink {...props}>{text}</CitationLink>;
|
||||
}
|
||||
}
|
||||
const { className, target, rel, ...rest } = props;
|
||||
const external = isExternalUrl(props.href);
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
className={cn(
|
||||
"text-primary underline decoration-primary/30 underline-offset-2 hover:decoration-primary/60 transition-colors",
|
||||
className,
|
||||
)}
|
||||
target={target ?? (external ? "_blank" : undefined)}
|
||||
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t.shortcuts.searchActions} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
|
||||
<CommandGroup heading={t.shortcuts.actions}>
|
||||
<CommandItem onSelect={handleNewChat}>
|
||||
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
|
||||
{t.sidebar.newChat}
|
||||
<CommandShortcut>{metaKey}{shiftKey}N</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenSettings}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{t.common.settings}
|
||||
<CommandShortcut>{metaKey},</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleShowShortcuts}>
|
||||
<KeyboardIcon className="mr-2 h-4 w-4" />
|
||||
{t.shortcuts.keyboardShortcuts}
|
||||
<CommandShortcut>{metaKey}/</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
||||
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.shortcuts.keyboardShortcuts}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.shortcuts.keyboardShortcutsDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={keys} className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<kbd className="bg-muted text-muted-foreground rounded px-2 py-0.5 font-mono text-xs">
|
||||
{keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<DropdownMenu>
|
||||
<Tooltip content={t.common.export}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
>
|
||||
<Download />
|
||||
{t.common.export}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => handleExport("markdown")}>
|
||||
<FileText className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsMarkdown}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => handleExport("json")}>
|
||||
<FileJson className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsJSON}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-muted-foreground flex cursor-default items-center gap-1 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CoinsIcon size={14} />
|
||||
<span>{formatTokenCount(usage.totalTokens)}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end">
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="font-medium">{t.tokenUsage.title}</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>{t.tokenUsage.input}</span>
|
||||
<span className="font-mono">
|
||||
{formatTokenCount(usage.inputTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>{t.tokenUsage.output}</span>
|
||||
<span className="font-mono">
|
||||
{formatTokenCount(usage.outputTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t pt-1">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>{t.tokenUsage.total}</span>
|
||||
<span className="font-mono font-medium">
|
||||
{formatTokenCount(usage.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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<string>();
|
||||
|
||||
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<T>(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],
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<string, unknown>).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`;
|
||||
}
|
||||
|
|
@ -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(
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>Thinking</summary>",
|
||||
"",
|
||||
reasoning,
|
||||
"",
|
||||
"</details>",
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
Loading…
Reference in New Issue