# Conflicts:
#	frontend-web/src/components/workspace/agents/agent-card.tsx
#	frontend-web/src/pages/NewAgentPage.tsx
This commit is contained in:
吉鑫升 committed 2026-05-13 20:10:21 +08:00
commit 50bda1ddee
19 files changed
+2562 -1583

No files matched your search

+50 -155
View File
@@ -1,155 +1,50 @@
import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { QueryClientProvider } from "@/components/query-client-provider";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { Toaster } from "sonner";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocale } from "@/core/i18n/locale";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import ChatsPage from "./pages/ChatsPage";
import ChatPage from "./pages/ChatPage";
import AgentsPage from "./pages/AgentsPage";
import AgentChatPage from "./pages/AgentChatPage";
import NewAgentPage from "./pages/NewAgentPage";
import LoginPage from "./pages/LoginPage";
import ScheduledTaskRunDetailPage from "./pages/ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./pages/ScheduledTasksPage";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
return (
<NextThemesProvider
attribute="class"
enableSystem
disableTransitionOnChange
forcedTheme={forcedTheme}
>
<I18nProvider initialLocale={locale}>
<QueryClientProvider>{children}</QueryClientProvider>
</I18nProvider>
</NextThemesProvider>
);
}
function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider className="h-screen" defaultOpen>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
<CommandPalette />
<Toaster position="top-center" />
</SidebarProvider>
);
}
function ChatRuntime({ children }: { children: React.ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
export function App() {
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
<Route
path="/workspace"
element={<Navigate to="/workspace/chats/new" replace />}
/>
<Route
path="/workspace/chats"
element={
<WorkspaceLayout>
<ChatsPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents"
element={
<WorkspaceLayout>
<AgentsPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/scheduled"
element={<Navigate to="/workspace/scheduled/tasks" replace />}
/>
<Route
path="/workspace/scheduled/tasks"
element={
<WorkspaceLayout>
<ScheduledTasksPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/scheduled/runs/:run_id"
element={
<WorkspaceLayout>
<ScheduledTaskRunDetailPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents/new"
element={
<WorkspaceLayout>
<NewAgentPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents/:agent_id/chats"
element={<Navigate to="/workspace/agents" replace />}
/>
<Route
path="/workspace/agents/:agent_id/chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
}
import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { QueryClientProvider } from "@/components/query-client-provider";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocale } from "@/core/i18n/locale";
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
return (
<NextThemesProvider
attribute="class"
enableSystem
disableTransitionOnChange
forcedTheme={forcedTheme}
>
<I18nProvider initialLocale={locale}>
<QueryClientProvider>{children}</QueryClientProvider>
</I18nProvider>
</NextThemesProvider>
);
}
export function App() {
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
}
@@ -1,69 +1,69 @@
"use client";
import { BotIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { useAgents } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const handleNewAgent = () => {
router.push("/workspace/agents/new");
};
return (
<div className="flex size-full flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description}
</p>
</div>
<Button onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : agents.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-muted-foreground h-7 w-7" />
</div>
<div>
<p className="font-medium">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">
{t.agents.emptyDescription}
</p>
</div>
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
)}
</div>
</div>
);
}
"use client";
import { BotIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { useAgents } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const handleNewAgent = () => {
router.push("/page/workspace/agents/new");
};
return (
<div className="flex size-full flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description}
</p>
</div>
<Button onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : agents.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-muted-foreground h-7 w-7" />
</div>
<div>
<p className="font-medium">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">
{t.agents.emptyDescription}
</p>
</div>
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
)}
</div>
</div>
);
}
@@ -1,134 +1,134 @@
"use client";
import {
KeyboardIcon,
MessageSquarePlusIcon,
SettingsIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, 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 [isMac, setIsMac] = 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);
useEffect(() => {
setIsMac(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>
</>
);
}
"use client";
import {
KeyboardIcon,
MessageSquarePlusIcon,
SettingsIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, 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 [isMac, setIsMac] = useState(false);
const handleNewChat = useCallback(() => {
router.push("/page/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);
useEffect(() => {
setIsMac(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>
</>
);
}
@@ -1,97 +1,97 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
function BrandMark({ className }: { className?: string }) {
return (
<div
className={cn(
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
className,
)}
aria-hidden
>
C
</div>
);
}
function SidebarCollapseButton({ className }: { className?: string }) {
const { open, toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
type="button"
variant="ghost"
size="icon"
className={cn(
"text-muted-foreground hover:text-sidebar-foreground size-8 shrink-0",
className,
)}
onClick={() => toggleSidebar()}
>
{open ? (
<ChevronLeft className="size-4" strokeWidth={2} />
) : (
<ChevronRight className="size-4" strokeWidth={2} />
)}
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
export function WorkspaceHeader({ className }: { className?: string }) {
const { t } = useI18n();
const { state } = useSidebar();
const titleBlock = (
<div className="min-w-0 flex-1">
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
{t.pages.appName}
</div>
<div className="text-muted-foreground truncate text-[11px] leading-tight">
{t.pages.brandSubtitle}
</div>
</div>
);
return (
<div
className={cn(
"group/workspace-header border-sidebar-border/80 border-b",
className,
)}
>
{state === "collapsed" ? (
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
</div>
</div>
) : (
<div className="flex items-center gap-2.5 px-2.5 py-3">
<BrandMark />
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
<Link href="/workspace/chats/new" className="min-w-0 flex-1 outline-none">
{titleBlock}
</Link>
) : (
titleBlock
)}
<SidebarCollapseButton />
</div>
)}
</div>
);
}
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
function BrandMark({ className }: { className?: string }) {
return (
<div
className={cn(
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
className,
)}
aria-hidden
>
C
</div>
);
}
function SidebarCollapseButton({ className }: { className?: string }) {
const { open, toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
type="button"
variant="ghost"
size="icon"
className={cn(
"text-muted-foreground hover:text-sidebar-foreground size-8 shrink-0",
className,
)}
onClick={() => toggleSidebar()}
>
{open ? (
<ChevronLeft className="size-4" strokeWidth={2} />
) : (
<ChevronRight className="size-4" strokeWidth={2} />
)}
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
export function WorkspaceHeader({ className }: { className?: string }) {
const { t } = useI18n();
const { state } = useSidebar();
const titleBlock = (
<div className="min-w-0 flex-1">
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
{t.pages.appName}
</div>
<div className="text-muted-foreground truncate text-[11px] leading-tight">
{t.pages.brandSubtitle}
</div>
</div>
);
return (
<div
className={cn(
"group/workspace-header border-sidebar-border/80 border-b",
className,
)}
>
{state === "collapsed" ? (
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
</div>
</div>
) : (
<div className="flex items-center gap-2.5 px-2.5 py-3">
<BrandMark />
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
<Link href="/page/workspace/chats/new" className="min-w-0 flex-1 outline-none">
{titleBlock}
</Link>
) : (
titleBlock
)}
<SidebarCollapseButton />
</div>
)}
</div>
);
}
@@ -1,65 +1,65 @@
"use client";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
return (
<SidebarGroup className="px-2 pt-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/workspace/chats/new"}
asChild
>
<Link className="text-sidebar-foreground" href="/workspace/chats/new">
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton isActive={pathname === "/workspace/chats"} asChild>
<Link className="text-sidebar-foreground" href="/workspace/chats">
<MessagesSquare />
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/workspace/agents")}
asChild
>
<Link className="text-sidebar-foreground" href="/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/workspace/scheduled/tasks"}
asChild
>
<Link className="text-sidebar-foreground" href="/workspace/scheduled/tasks">
<ListChecks />
<span>任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
"use client";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
return (
<SidebarGroup className="px-2 pt-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/chats/new">
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton isActive={pathname === "/page/workspace/chats"} asChild>
<Link className="text-sidebar-foreground" href="/page/workspace/chats">
<MessagesSquare />
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/agents")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/scheduled/tasks">
<ListChecks />
<span>任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
+58 -58
View File
@@ -1,58 +1,58 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread, AgentThreadContext } from "./types";
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
type ThreadRouteTarget =
| string
| {
thread_id: string;
context?: ThreadAgentRef | null;
metadata?: Record<string, unknown> | null;
};
function pickAgentRef(
context: ThreadAgentRef | null | undefined,
metadata: Record<string, unknown> | null | undefined,
): string | undefined {
if (context?.agent_id) return context.agent_id;
if (context?.agent_name) return context.agent_name;
const metaId = metadata?.agent_id;
if (typeof metaId === "string" && metaId) return metaId;
const metaName = metadata?.agent_name;
if (typeof metaName === "string" && metaName) return metaName;
return undefined;
}
export function pathOfThread(
thread: ThreadRouteTarget,
context?: ThreadAgentRef | null,
) {
const threadId = typeof thread === "string" ? thread : thread.thread_id;
const agentRef =
typeof thread === "string"
? pickAgentRef(context, null)
: pickAgentRef(thread.context, thread.metadata);
return agentRef
? `/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
: `/workspace/chats/${threadId}`;
}
export function textOfMessage(message: Message) {
if (typeof message.content === "string") {
return message.content;
} else if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === "text") {
return part.text;
}
}
}
return null;
}
export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled";
}
import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread, AgentThreadContext } from "./types";
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
type ThreadRouteTarget =
| string
| {
thread_id: string;
context?: ThreadAgentRef | null;
metadata?: Record<string, unknown> | null;
};
function pickAgentRef(
context: ThreadAgentRef | null | undefined,
metadata: Record<string, unknown> | null | undefined,
): string | undefined {
if (context?.agent_id) return context.agent_id;
if (context?.agent_name) return context.agent_name;
const metaId = metadata?.agent_id;
if (typeof metaId === "string" && metaId) return metaId;
const metaName = metadata?.agent_name;
if (typeof metaName === "string" && metaName) return metaName;
return undefined;
}
export function pathOfThread(
thread: ThreadRouteTarget,
context?: ThreadAgentRef | null,
) {
const threadId = typeof thread === "string" ? thread : thread.thread_id;
const agentRef =
typeof thread === "string"
? pickAgentRef(context, null)
: pickAgentRef(thread.context, thread.metadata);
return agentRef
? `/page/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
: `/page/workspace/chats/${threadId}`;
}
export function textOfMessage(message: Message) {
if (typeof message.content === "string") {
return message.content;
} else if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === "text") {
return part.text;
}
}
}
return null;
}
export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled";
}
+213 -222
View File
@@ -1,233 +1,224 @@
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} 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 { useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const router = useRouter();
const { agent_id } = useParams<{
agent_id: string;
}>();
const { agent } = useAgent(agent_id);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
}, [isNewThread, threadId]);
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
assistantId: agent_id,
context: { ...settings.context, agent_id },
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#/workspace/agents/${agent_id}/chats/${createdThreadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} 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 { useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const router = useRouter();
const { agent_id } = useParams<{
agent_id: string;
}>();
const { agent } = useAgent(agent_id);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
}, [isNewThread, threadId]);
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
assistantId: agent_id,
context: { ...settings.context, agent_id },
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#/page/workspace/agents/${agent_id}/chats/${createdThreadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message, { agent_id });
},
[sendMessage, threadId, agent_id],
);
const handleClarificationSubmit = useCallback(
async (answer: string) => {
setHasStartedConversation(true);
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message, { agent_id });
},
[sendMessage, threadId, agent_id],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
{agent?.name ?? agent_id}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(`/workspace/agents/${agent_id}/chats/new`);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
{agent?.name ?? agent_id}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(`/page/workspace/agents/${agent_id}/chats/new`);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
+201 -209
View File
@@ -1,219 +1,211 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import {
ChatBox,
useSpecificChatMode,
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} 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 { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function ChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
useSpecificChatMode();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
}, [isNewThread, threadId]);
const { showNotification } = useNotification();
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
isMock,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#/workspace/chats/${createdThreadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages.at(-1);
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
"use client";
import { useCallback, useEffect, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import {
ChatBox,
useSpecificChatMode,
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} 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 { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function ChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
useSpecificChatMode();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
}, [isNewThread, threadId]);
const { showNotification } = useNotification();
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
isMock,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#/page/workspace/chats/${createdThreadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages.at(-1);
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message);
},
[sendMessage, threadId],
);
const handleClarificationSubmit = useCallback(
async (answer: string) => {
setHasStartedConversation(true);
await sendMessage(threadId, { text: answer, files: [] });
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message);
},
[sendMessage, threadId],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center gap-2">
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center gap-2">
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
{mounted ? (
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && <Welcome mode={settings.context.mode} />
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
}
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
)}
/>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
{mounted ? (
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && <Welcome mode={settings.context.mode} />
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
}
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
)}
/>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
+67 -67
View File
@@ -1,67 +1,67 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
const DEFAULT_USERNAME = "guest";
type LoginStatus = "loading" | "success" | "error";
export default function LoginPage() {
const { username } = useParams<{ username?: string }>();
const router = useRouter();
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
}, [username]);
const [status, setStatus] = useState<LoginStatus>("loading");
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
let cancelled = false;
async function runLogin() {
setStatus("loading");
setErrorMessage("");
try {
const auth = await loginByUsername(resolvedUsername);
if (cancelled) {
return;
}
setStoredAuth(auth);
setStatus("success");
router.replace("/workspace/chats/new");
} catch (error) {
if (cancelled) {
return;
}
setStatus("error");
setErrorMessage(
error instanceof Error ? error.message : "Login request failed.",
);
}
}
void runLogin();
return () => {
cancelled = true;
};
}, [resolvedUsername, router]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
{status === "error" && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"}
</p>
)}
</div>
</div>
);
}
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
const DEFAULT_USERNAME = "guest";
type LoginStatus = "loading" | "success" | "error";
export default function LoginPage() {
const { username } = useParams<{ username?: string }>();
const router = useRouter();
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
}, [username]);
const [status, setStatus] = useState<LoginStatus>("loading");
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
let cancelled = false;
async function runLogin() {
setStatus("loading");
setErrorMessage("");
try {
const auth = await loginByUsername(resolvedUsername);
if (cancelled) {
return;
}
setStoredAuth(auth);
setStatus("success");
router.replace("/page/workspace/chats/new");
} catch (error) {
if (cancelled) {
return;
}
setStatus("error");
setErrorMessage(
error instanceof Error ? error.message : "Login request failed.",
);
}
}
void runLogin();
return () => {
cancelled = true;
};
}, [resolvedUsername, router]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
{status === "error" && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"}
</p>
)}
</div>
</div>
);
}
+20 -20
View File
@@ -1,20 +1,20 @@
import { Link } from "react-router-dom";
export function NotFoundPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
<h1 className="text-2xl font-semibold">Page not found</h1>
<p className="text-muted-foreground max-w-md text-center text-sm">
The Vite rewrite currently focuses on the workspace chat flows first.
Marketing, docs, blog, and Next-specific server routes still need a
dedicated migration pass.
</p>
<Link
to="/workspace/chats/new"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
>
Open workspace
</Link>
</main>
);
}
import { Link } from "react-router-dom";
export function NotFoundPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
<h1 className="text-2xl font-semibold">Page not found</h1>
<p className="text-muted-foreground max-w-md text-center text-sm">
The Vite rewrite currently focuses on the workspace chat flows first.
Marketing, docs, blog, and Next-specific server routes still need a
dedicated migration pass.
</p>
<Link
to="/page/workspace/chats/new"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
>
Open workspace
</Link>
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Navigate, Route, Routes } from "react-router-dom";
import WorkspaceRoutes from "./WorkspaceRoutes";
// 根路由容器,统一承接 /page/* 下的所有子系统路由
export default function PageRoutes() {
return (
<Routes>
<Route index element={<Navigate to="workspace" replace />} />
{/* 现有系统:DeerFlow Workspace */}
<Route path="workspace/*" element={<WorkspaceRoutes />} />
{/* TODO: 引入外部系统路由
import AnotherSystemRoutes from "外部系统路径";
<Route path="other/*" element={<AnotherSystemRoutes />} />
*/}
</Routes>
);
}
+31 -31
View File
@@ -1,31 +1,31 @@
"use client";
import { Navigate } from "react-router-dom";
import {
WorkspaceBody,
WorkspaceContainer,
} from "@/components/workspace/workspace-container";
import { useSchedulerThread } from "@/core/scheduled-tasks";
export default function ScheduledChatPage() {
const { data, isLoading, error } = useSchedulerThread();
if (data?.thread_id) {
return <Navigate to={`/workspace/chats/${data.thread_id}`} replace />;
}
return (
<WorkspaceContainer>
<WorkspaceBody>
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
{isLoading
? "正在打开定时任务助手..."
: error
? "无法打开定时任务助手"
: "准备定时任务助手..."}
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
"use client";
import { Navigate } from "react-router-dom";
import {
WorkspaceBody,
WorkspaceContainer,
} from "@/components/workspace/workspace-container";
import { useSchedulerThread } from "@/core/scheduled-tasks";
export default function ScheduledChatPage() {
const { data, isLoading, error } = useSchedulerThread();
if (data?.thread_id) {
return <Navigate to={`/page/workspace/chats/${data.thread_id}`} replace />;
}
return (
<WorkspaceContainer>
<WorkspaceBody>
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
{isLoading
? "正在打开定时任务助手..."
: error
? "无法打开定时任务助手"
: "准备定时任务助手..."}
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
@@ -1,455 +1,318 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
updateScheduledTaskRunFile,
useScheduledTaskActions,
useScheduledTaskRun,
} from "@/core/scheduled-tasks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
function formatDuration(run?: ScheduledTaskRun | null) {
if (!run?.started_at || !run.finished_at) return "暂无";
const duration = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
if (!Number.isFinite(duration) || duration < 0) return "暂无";
return `${Math.round(duration / 1000)} 秒`;
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
if (status === "succeeded") return "default";
if (status === "failed") return "destructive";
if (status === "running") return "secondary";
return "outline";
}
function isMarkdownFile(file: ScheduledTaskResultFileContent) {
const name = file.name.toLowerCase();
return (
file.kind === "markdown" ||
file.mime_type === "text/markdown" ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
}
function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
if (!file.previewable || !file.content) {
return (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
该文件不是文本内容,无法直接预览,请下载查看。
</div>
);
}
if (isMarkdownFile(file)) {
return (
<MarkdownContent
className="max-w-none"
content={file.content}
isLoading={false}
rehypePlugins={[]}
/>
);
}
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
type EditState = {
draft: string;
saving: boolean;
};
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
const actions = useScheduledTaskActions();
const { data: run, isLoading } = useScheduledTaskRun(run_id);
const result = run?.result ?? null;
const canEdit = run?.can_edit ?? false;
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
// Per-file-index edit state. `null` = not in edit mode.
const [edits, setEdits] = useState<Record<number, EditState | null>>({});
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
const resultMetadata = useMemo(() => {
if (!result) return "";
return JSON.stringify(
{
title: result.title,
format: result.format,
created_at: result.created_at,
files: result.files,
},
null,
2,
);
}, [result]);
useEffect(() => {
let cancelled = false;
async function loadFiles() {
if (!run || !result || resultFiles.length === 0) {
setFileContents([]);
return;
}
setFilesLoading(true);
try {
const loaded = await Promise.all(
resultFiles.map((f) => {
const index = result.files.indexOf(f);
return getScheduledTaskRunFileContent(run.id, index);
}),
);
if (!cancelled) {
setFileContents(loaded);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
}
} finally {
if (!cancelled) {
setFilesLoading(false);
}
}
}
loadFiles();
return () => {
cancelled = true;
};
}, [run, result, resultFiles]);
const handleDownloadResult = async () => {
if (!run) return;
try {
await downloadScheduledTaskRun(run.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDownloadFile = async (index: number) => {
if (!run) return;
try {
await downloadScheduledTaskRunFile(run.id, index);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDeleteRun = async () => {
if (!run) return;
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
navigate("/workspace/scheduled/tasks");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
const handleEnterEdit = useCallback(
(file: ScheduledTaskResultFileContent) => {
setEdits((prev) => ({
...prev,
[file.index]: { draft: file.content ?? "", saving: false },
}));
},
[],
);
const handleCancelEdit = useCallback((index: number) => {
setEdits((prev) => ({ ...prev, [index]: null }));
}, []);
const handleSaveEdit = useCallback(
async (file: ScheduledTaskResultFileContent) => {
if (!run) return;
const current = edits[file.index];
if (!current) return;
setEdits((prev) => ({
...prev,
[file.index]: { ...current, saving: true },
}));
try {
const updated = await updateScheduledTaskRunFile(run.id, file.index, current.draft);
setFileContents((prev) =>
prev.map((entry) =>
entry.index === file.index
? { ...entry, content: current.draft, size: updated.size }
: entry,
),
);
setEdits((prev) => ({ ...prev, [file.index]: null }));
toast.success(`文件 ${file.name} 已保存`);
} catch (error) {
setEdits((prev) => ({
...prev,
[file.index]: { ...current, saving: false },
}));
toast.error(error instanceof Error ? error.message : "保存失败");
}
},
[edits, run],
);
const handleDownloadAsWord = useCallback(
async (file: ScheduledTaskResultFileContent) => {
const draft = edits[file.index]?.draft;
const source = draft ?? file.content ?? "";
try {
await downloadMarkdownAsDocx(source, file.name, result?.title);
} catch (error) {
toast.error(error instanceof Error ? error.message : "导出 Word 失败");
}
},
[edits, result?.title],
);
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col p-3">
<Card className="flex min-h-0 flex-1 flex-col">
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="mr-auto flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
</span>
{run && <Badge variant={statusVariant(run.status)} className="shrink-0">{statusLabel(run.status)}</Badge>}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setShowMeta((v) => !v)}>
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
执行信息
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/workspace/scheduled/tasks")}>
<ListChecks className="size-3" />
返回记录
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
<Download className="size-3" />
下载全部
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
<Trash2 className="size-3" />
删除
</Button>
</div>
{/* 执行信息(可折叠) */}
{showMeta && run && (
<div className="border-b px-3 py-2">
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
<div>ID:{run.id}</div>
<div>计划:{formatDate(run.scheduled_for)}</div>
<div>开始:{formatDate(run.started_at)}</div>
<div>结束:{formatDate(run.finished_at)}</div>
<div>耗时:{formatDuration(run)}</div>
</div>
{run.error && (
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
{run.error}
</div>
)}
</div>
)}
{showMeta && !isLoading && !run && (
<div className="text-muted-foreground border-b px-3 py-2 text-xs">
没有找到这条执行记录。
</div>
)}
{/* 内容区 */}
{!result && run && (
<div className="text-muted-foreground p-4 text-sm">
这条记录暂时还没有结果,任务可能仍在执行中。
</div>
)}
{result && (
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
正文
</TabsTrigger>
<TabsTrigger value="files" className="text-xs">
结果文件{fileCount > 0 && `(${fileCount})`}
</TabsTrigger>
<TabsTrigger value="meta" className="text-xs">
元数据
</TabsTrigger>
</TabsList>
</div>
<ScrollArea className="min-h-0 flex-1">
<TabsContent value="body" className="m-0 p-4">
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
className="max-w-none"
content={result.content || "无正文内容"}
isLoading={false}
rehypePlugins={[]}
/>
) : (
<pre className="whitespace-pre-wrap text-sm">
{result.content || "无正文内容"}
</pre>
)}
</TabsContent>
<TabsContent value="files" className="m-0 p-3">
{filesLoading && (
<div className="text-muted-foreground text-sm">正在加载文件内容...</div>
)}
{!filesLoading && fileContents.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
本次执行没有生成文件。
</div>
)}
<div className="flex flex-col gap-3">
{fileContents.map((file) => {
const isMarkdown = isMarkdownFile(file);
const editable = canEdit && file.previewable;
const editState = edits[file.index] ?? null;
const isEditing = editState !== null;
return (
<div key={file.index} className="rounded-md border">
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
</div>
</div>
<div className="flex flex-wrap gap-2">
{isMarkdown && (
<Button
size="sm"
variant="outline"
onClick={() => handleDownloadAsWord(file)}
disabled={!file.content && !editState?.draft}
title="将 Markdown 导出为 Word"
>
<FileText className="size-4" />
下载 Word
</Button>
)}
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
{editable && !isEditing && (
<Button size="sm" variant="outline" onClick={() => handleEnterEdit(file)}>
<Edit3 className="size-4" />
编辑
</Button>
)}
{isEditing && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleCancelEdit(file.index)}
disabled={editState?.saving}
>
<X className="size-4" />
取消
</Button>
<Button
size="sm"
onClick={() => handleSaveEdit(file)}
disabled={editState?.saving}
>
{editState?.saving ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
保存
</Button>
</>
)}
</div>
</div>
<div className="p-3">
{isEditing ? (
<Textarea
className="min-h-80 font-mono text-xs"
value={editState?.draft ?? ""}
disabled={editState?.saving}
onChange={(event) =>
setEdits((prev) => ({
...prev,
[file.index]: {
draft: event.target.value,
saving: prev[file.index]?.saving ?? false,
},
}))
}
/>
) : (
<RenderPreview file={file} />
)}
</div>
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
{resultMetadata}
</pre>
</TabsContent>
</ScrollArea>
</Tabs>
)}
</Card>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
useScheduledTaskActions,
useScheduledTaskRun,
} from "@/core/scheduled-tasks";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
function formatDuration(run?: ScheduledTaskRun | null) {
if (!run?.started_at || !run.finished_at) return "暂无";
const duration = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
if (!Number.isFinite(duration) || duration < 0) return "暂无";
return `${Math.round(duration / 1000)} 秒`;
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
if (status === "succeeded") return "default";
if (status === "failed") return "destructive";
if (status === "running") return "secondary";
return "outline";
}
function isMarkdownFile(file: ScheduledTaskResultFileContent) {
const name = file.name.toLowerCase();
return (
file.kind === "markdown" ||
file.mime_type === "text/markdown" ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
}
function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
if (!file.previewable || !file.content) {
return (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
该文件不是文本内容,无法直接预览,请下载查看。
</div>
);
}
if (isMarkdownFile(file)) {
return (
<MarkdownContent
className="max-w-none"
content={file.content}
isLoading={false}
rehypePlugins={[]}
/>
);
}
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
const actions = useScheduledTaskActions();
const { data: run, isLoading } = useScheduledTaskRun(run_id);
const result = run?.result ?? null;
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
const resultMetadata = useMemo(() => {
if (!result) return "";
return JSON.stringify(
{
title: result.title,
format: result.format,
created_at: result.created_at,
files: result.files,
},
null,
2,
);
}, [result]);
useEffect(() => {
let cancelled = false;
async function loadFiles() {
if (!run || !result || resultFiles.length === 0) {
setFileContents([]);
return;
}
setFilesLoading(true);
try {
const loaded = await Promise.all(
resultFiles.map((f) => {
const index = result.files.indexOf(f);
return getScheduledTaskRunFileContent(run.id, index);
}),
);
if (!cancelled) {
setFileContents(loaded);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
}
} finally {
if (!cancelled) {
setFilesLoading(false);
}
}
}
loadFiles();
return () => {
cancelled = true;
};
}, [run, result, resultFiles]);
const handleDownloadResult = async () => {
if (!run) return;
try {
await downloadScheduledTaskRun(run.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDownloadFile = async (index: number) => {
if (!run) return;
try {
await downloadScheduledTaskRunFile(run.id, index);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDeleteRun = async () => {
if (!run) return;
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
navigate("/page/workspace/scheduled/tasks");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col p-3">
<Card className="flex min-h-0 flex-1 flex-col">
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="mr-auto flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
</span>
{run && <Badge variant={statusVariant(run.status)} className="shrink-0">{statusLabel(run.status)}</Badge>}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setShowMeta((v) => !v)}>
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
执行信息
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
<ListChecks className="size-3" />
返回记录
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
<Download className="size-3" />
下载全部
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
<Trash2 className="size-3" />
删除
</Button>
</div>
{/* 执行信息(可折叠) */}
{showMeta && run && (
<div className="border-b px-3 py-2">
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
<div>ID:{run.id}</div>
<div>计划:{formatDate(run.scheduled_for)}</div>
<div>开始:{formatDate(run.started_at)}</div>
<div>结束:{formatDate(run.finished_at)}</div>
<div>耗时:{formatDuration(run)}</div>
</div>
{run.error && (
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
{run.error}
</div>
)}
</div>
)}
{showMeta && !isLoading && !run && (
<div className="text-muted-foreground border-b px-3 py-2 text-xs">
没有找到这条执行记录。
</div>
)}
{/* 内容区 */}
{!result && run && (
<div className="text-muted-foreground p-4 text-sm">
这条记录暂时还没有结果,任务可能仍在执行中。
</div>
)}
{result && (
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
正文
</TabsTrigger>
<TabsTrigger value="files" className="text-xs">
结果文件{fileCount > 0 && `(${fileCount})`}
</TabsTrigger>
<TabsTrigger value="meta" className="text-xs">
元数据
</TabsTrigger>
</TabsList>
</div>
<ScrollArea className="min-h-0 flex-1">
<TabsContent value="body" className="m-0 p-4">
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
className="max-w-none"
content={result.content || "无正文内容"}
isLoading={false}
rehypePlugins={[]}
/>
) : (
<pre className="whitespace-pre-wrap text-sm">
{result.content || "无正文内容"}
</pre>
)}
</TabsContent>
<TabsContent value="files" className="m-0 p-3">
{filesLoading && (
<div className="text-muted-foreground text-sm">正在加载文件内容...</div>
)}
{!filesLoading && fileContents.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
本次执行没有生成文件。
</div>
)}
<div className="flex flex-col gap-3">
{fileContents.map((file) => (
<div key={file.index} className="rounded-md border">
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
</div>
<div className="p-3">
<RenderPreview file={file} />
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
{resultMetadata}
</pre>
</TabsContent>
</ScrollArea>
</Tabs>
)}
</Card>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
@@ -686,7 +686,7 @@ export default function ScheduledTasksPage() {
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/workspace/scheduled/runs/${run.id}`)}
onClick={() => navigate(`/page/workspace/scheduled/runs/${run.id}`)}
>
<Eye className="size-4" />
查看结果
@@ -0,0 +1,94 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { Toaster } from "sonner";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
import AgentsPage from "./AgentsPage";
import AgentChatPage from "./AgentChatPage";
import NewAgentPage from "./NewAgentPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider className="h-screen" defaultOpen>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
<CommandPalette />
<Toaster position="top-center" />
</SidebarProvider>
);
}
function ChatRuntime({ children }: { children: React.ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
export default function WorkspaceRoutes() {
return (
<Routes>
<Route index element={<Navigate to="chats/new" replace />} />
<Route
path="chats"
element={<WorkspaceLayout><ChatsPage /></WorkspaceLayout>}
/>
<Route
path="chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="agents"
element={<WorkspaceLayout><AgentsPage /></WorkspaceLayout>}
/>
<Route
path="agents/new"
element={<WorkspaceLayout><NewAgentPage /></WorkspaceLayout>}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/workspace/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
/>
<Route
path="scheduled/tasks"
element={<WorkspaceLayout><ScheduledTasksPage /></WorkspaceLayout>}
/>
<Route
path="scheduled/runs/:run_id"
element={<WorkspaceLayout><ScheduledTaskRunDetailPage /></WorkspaceLayout>}
/>
</Routes>
);
}