Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts: # frontend-web/src/components/workspace/agents/agent-card.tsx # frontend-web/src/pages/NewAgentPage.tsx
This commit is contained in:
commit
50bda1ddee
19 files changed
+2562
-1583
No files matched your search
+50
-155
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user