diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/layout.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/layout.tsx new file mode 100644 index 00000000..68f51b60 --- /dev/null +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/layout.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { PromptInputProvider } from "@/components/ai-elements/prompt-input"; +import { ArtifactsProvider } from "@/components/workspace/artifacts"; +import { SubtasksProvider } from "@/core/tasks/context"; + +export default function AgentChatLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx new file mode 100644 index 00000000..12ba855e --- /dev/null +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { BotIcon, PlusSquare } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback } 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 } 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 { useNotification } from "@/core/notification/hooks"; +import { useLocalSettings } 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 [settings, setSettings] = useLocalSettings(); + const router = useRouter(); + + const { agent_name } = useParams<{ + agent_name: string; + }>(); + + const { agent } = useAgent(agent_name); + + const { threadId, isNewThread, setIsNewThread } = useThreadChat(); + + const { showNotification } = useNotification(); + const [thread, sendMessage] = useThreadStream({ + threadId: isNewThread ? undefined : threadId, + context: { ...settings.context, agent_name: agent_name }, + onStart: () => { + setIsNewThread(false); + // ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead. + history.replaceState( + null, + "", + `/workspace/agents/${agent_name}/chats/${threadId}`, + ); + }, + 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) => { + void sendMessage(threadId, message, { agent_name }); + }, + [sendMessage, threadId, agent_name], + ); + + const handleStop = useCallback(async () => { + await thread.stop(); + }, [thread]); + + return ( + + + + + {/* Agent badge */} + + + + {agent?.name ?? agent_name} + + + + + + + + + { + router.push(`/workspace/agents/${agent_name}/chats/new`); + }} + > + {t.agents.newChat} + + + + + + + + + + + + + + + + + + + + + + + ) + } + disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} + onContextChange={(context) => setSettings("context", context)} + onSubmit={handleSubmit} + onStop={handleStop} + /> + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( + + {t.common.notAvailableInDemoMode} + + )} + + + + + + + ); +} diff --git a/frontend/src/app/workspace/agents/new/page.tsx b/frontend/src/app/workspace/agents/new/page.tsx new file mode 100644 index 00000000..9424a5f5 --- /dev/null +++ b/frontend/src/app/workspace/agents/new/page.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { ArrowLeftIcon, BotIcon, CheckCircleIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useMemo, useState } from "react"; + +import { + PromptInput, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, +} from "@/components/ai-elements/prompt-input"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ArtifactsProvider } from "@/components/workspace/artifacts"; +import { MessageList } from "@/components/workspace/messages"; +import { ThreadContext } from "@/components/workspace/messages/context"; +import type { Agent } from "@/core/agents"; +import { checkAgentName, getAgent } from "@/core/agents/api"; +import { useI18n } from "@/core/i18n/hooks"; +import { useThreadStream } from "@/core/threads/hooks"; +import { uuid } from "@/core/utils/uuid"; +import { cn } from "@/lib/utils"; + +type Step = "name" | "chat"; + +const NAME_RE = /^[A-Za-z0-9-]+$/; + +export default function NewAgentPage() { + const { t } = useI18n(); + const router = useRouter(); + + // ── Step 1: name form ────────────────────────────────────────────────────── + const [step, setStep] = useState("name"); + const [nameInput, setNameInput] = useState(""); + const [nameError, setNameError] = useState(""); + const [isCheckingName, setIsCheckingName] = useState(false); + const [agentName, setAgentName] = useState(""); + const [agent, setAgent] = useState(null); + // ── Step 2: chat ─────────────────────────────────────────────────────────── + + // Stable thread ID — all turns belong to the same thread + const threadId = useMemo(() => uuid(), []); + + const [thread, sendMessage] = useThreadStream({ + threadId: step === "chat" ? threadId : undefined, + context: { + mode: "flash", + is_bootstrap: true, + }, + onToolEnd({ name }) { + if (name !== "setup_agent" || !agentName) return; + getAgent(agentName) + .then((fetched) => setAgent(fetched)) + .catch(() => { + // agent write may not be flushed yet — ignore silently + }); + }, + }); + + // ── Handlers ─────────────────────────────────────────────────────────────── + + const handleConfirmName = useCallback(async () => { + const trimmed = nameInput.trim(); + if (!trimmed) return; + if (!NAME_RE.test(trimmed)) { + setNameError(t.agents.nameStepInvalidError); + return; + } + setNameError(""); + setIsCheckingName(true); + try { + const result = await checkAgentName(trimmed); + if (!result.available) { + setNameError(t.agents.nameStepAlreadyExistsError); + return; + } + } catch { + setNameError(t.agents.nameStepCheckError); + return; + } finally { + setIsCheckingName(false); + } + setAgentName(trimmed); + setStep("chat"); + await sendMessage(threadId, { + text: t.agents.nameStepBootstrapMessage.replace("{name}", trimmed), + files: [], + }); + }, [ + nameInput, + sendMessage, + threadId, + t.agents.nameStepBootstrapMessage, + t.agents.nameStepInvalidError, + t.agents.nameStepAlreadyExistsError, + t.agents.nameStepCheckError, + ]); + + const handleNameKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleConfirmName(); + } + }; + + const handleChatSubmit = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!trimmed || thread.isLoading) return; + await sendMessage( + threadId, + { text: trimmed, files: [] }, + { agent_name: agentName }, + ); + }, + [thread.isLoading, sendMessage, threadId, agentName], + ); + + // ── Shared header ────────────────────────────────────────────────────────── + + const header = ( + + router.push("/workspace/agents")} + > + + + {t.agents.createPageTitle} + + ); + + // ── Step 1: name form ────────────────────────────────────────────────────── + + if (step === "name") { + return ( + + {header} + + + + + + + + + {t.agents.nameStepTitle} + + + {t.agents.nameStepHint} + + + + + + { + setNameInput(e.target.value); + setNameError(""); + }} + onKeyDown={handleNameKeyDown} + className={cn(nameError && "border-destructive")} + /> + {nameError && ( + {nameError} + )} + void handleConfirmName()} + disabled={!nameInput.trim() || isCheckingName} + > + {t.agents.nameStepContinue} + + + + + + ); + } + + // ── Step 2: chat ─────────────────────────────────────────────────────────── + + return ( + + + + {header} + + + {/* ── Message area ── */} + + + + + {/* ── Bottom action area ── */} + + + {agent ? ( + // ✅ Success card + + + {t.agents.agentCreated} + + + router.push( + `/workspace/agents/${agentName}/chats/new`, + ) + } + > + {t.agents.startChatting} + + router.push("/workspace/agents")} + > + {t.agents.backToGallery} + + + + ) : ( + // 📝 Normal input + void handleChatSubmit(text)} + > + + + + + + )} + + + + + + + ); +} diff --git a/frontend/src/app/workspace/agents/page.tsx b/frontend/src/app/workspace/agents/page.tsx new file mode 100644 index 00000000..46fdbf2f --- /dev/null +++ b/frontend/src/app/workspace/agents/page.tsx @@ -0,0 +1,5 @@ +import { AgentGallery } from "@/components/workspace/agents/agent-gallery"; + +export default function AgentsPage() { + return ; +} diff --git a/frontend/src/components/workspace/agents/agent-card.tsx b/frontend/src/components/workspace/agents/agent-card.tsx new file mode 100644 index 00000000..6b2a510b --- /dev/null +++ b/frontend/src/components/workspace/agents/agent-card.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { BotIcon, MessageSquareIcon, Trash2Icon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useDeleteAgent } from "@/core/agents"; +import type { Agent } from "@/core/agents"; +import { useI18n } from "@/core/i18n/hooks"; + +interface AgentCardProps { + agent: Agent; +} + +export function AgentCard({ agent }: AgentCardProps) { + const { t } = useI18n(); + const router = useRouter(); + const deleteAgent = useDeleteAgent(); + const [deleteOpen, setDeleteOpen] = useState(false); + + function handleChat() { + router.push(`/workspace/agents/${agent.name}/chats/new`); + } + + async function handleDelete() { + try { + await deleteAgent.mutateAsync(agent.name); + toast.success(t.agents.deleteSuccess); + setDeleteOpen(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + return ( + <> + + + + + + + + + + {agent.name} + + {agent.model && ( + + {agent.model} + + )} + + + + {agent.description && ( + + {agent.description} + + )} + + + {agent.tool_groups && agent.tool_groups.length > 0 && ( + + + {agent.tool_groups.map((group) => ( + + {group} + + ))} + + + )} + + + + + {t.agents.chat} + + + setDeleteOpen(true)} + title={t.agents.delete} + > + + + + + + + {/* Delete Confirm */} + + + + {t.agents.delete} + {t.agents.deleteConfirm} + + + setDeleteOpen(false)} + disabled={deleteAgent.isPending} + > + {t.common.cancel} + + + {deleteAgent.isPending ? t.common.loading : t.common.delete} + + + + + > + ); +} diff --git a/frontend/src/components/workspace/agents/agent-gallery.tsx b/frontend/src/components/workspace/agents/agent-gallery.tsx new file mode 100644 index 00000000..73986265 --- /dev/null +++ b/frontend/src/components/workspace/agents/agent-gallery.tsx @@ -0,0 +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 ( + + {/* Page header */} + + + {t.agents.title} + + {t.agents.description} + + + + + {t.agents.newAgent} + + + + {/* Content */} + + {isLoading ? ( + + {t.common.loading} + + ) : agents.length === 0 ? ( + + + + + + {t.agents.emptyTitle} + + {t.agents.emptyDescription} + + + + + {t.agents.newAgent} + + + ) : ( + + {agents.map((agent) => ( + + ))} + + )} + + + ); +} diff --git a/frontend/src/core/agents/api.ts b/frontend/src/core/agents/api.ts new file mode 100644 index 00000000..d9c2f176 --- /dev/null +++ b/frontend/src/core/agents/api.ts @@ -0,0 +1,67 @@ +import { getBackendBaseURL } from "@/core/config"; + +import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types"; + +export async function listAgents(): Promise { + const res = await fetch(`${getBackendBaseURL()}/api/agents`); + if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`); + const data = (await res.json()) as { agents: Agent[] }; + return data.agents; +} + +export async function getAgent(name: string): Promise { + const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`); + if (!res.ok) throw new Error(`Agent '${name}' not found`); + return res.json() as Promise; +} + +export async function createAgent(request: CreateAgentRequest): Promise { + const res = await fetch(`${getBackendBaseURL()}/api/agents`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error(err.detail ?? `Failed to create agent: ${res.statusText}`); + } + return res.json() as Promise; +} + +export async function updateAgent( + name: string, + request: UpdateAgentRequest, +): Promise { + const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error(err.detail ?? `Failed to update agent: ${res.statusText}`); + } + return res.json() as Promise; +} + +export async function deleteAgent(name: string): Promise { + const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`); +} + +export async function checkAgentName( + name: string, +): Promise<{ available: boolean; name: string }> { + const res = await fetch( + `${getBackendBaseURL()}/api/agents/check?name=${encodeURIComponent(name)}`, + ); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error( + err.detail ?? `Failed to check agent name: ${res.statusText}`, + ); + } + return res.json() as Promise<{ available: boolean; name: string }>; +} diff --git a/frontend/src/core/agents/hooks.ts b/frontend/src/core/agents/hooks.ts new file mode 100644 index 00000000..c40f0dae --- /dev/null +++ b/frontend/src/core/agents/hooks.ts @@ -0,0 +1,64 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + createAgent, + deleteAgent, + getAgent, + listAgents, + updateAgent, +} from "./api"; +import type { CreateAgentRequest, UpdateAgentRequest } from "./types"; + +export function useAgents() { + const { data, isLoading, error } = useQuery({ + queryKey: ["agents"], + queryFn: () => listAgents(), + }); + return { agents: data ?? [], isLoading, error }; +} + +export function useAgent(name: string | null | undefined) { + const { data, isLoading, error } = useQuery({ + queryKey: ["agents", name], + queryFn: () => getAgent(name!), + enabled: !!name, + }); + return { agent: data ?? null, isLoading, error }; +} + +export function useCreateAgent() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: CreateAgentRequest) => createAgent(request), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["agents"] }); + }, + }); +} + +export function useUpdateAgent() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + name, + request, + }: { + name: string; + request: UpdateAgentRequest; + }) => updateAgent(name, request), + onSuccess: (_data, { name }) => { + void queryClient.invalidateQueries({ queryKey: ["agents"] }); + void queryClient.invalidateQueries({ queryKey: ["agents", name] }); + }, + }); +} + +export function useDeleteAgent() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => deleteAgent(name), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["agents"] }); + }, + }); +} diff --git a/frontend/src/core/agents/index.ts b/frontend/src/core/agents/index.ts new file mode 100644 index 00000000..0733bf1a --- /dev/null +++ b/frontend/src/core/agents/index.ts @@ -0,0 +1,3 @@ +export * from "./api"; +export * from "./hooks"; +export * from "./types"; diff --git a/frontend/src/core/agents/types.ts b/frontend/src/core/agents/types.ts new file mode 100644 index 00000000..0ff0efff --- /dev/null +++ b/frontend/src/core/agents/types.ts @@ -0,0 +1,22 @@ +export interface Agent { + name: string; + description: string; + model: string | null; + tool_groups: string[] | null; + soul?: string | null; +} + +export interface CreateAgentRequest { + name: string; + description?: string; + model?: string | null; + tool_groups?: string[] | null; + soul?: string; +} + +export interface UpdateAgentRequest { + description?: string | null; + model?: string | null; + tool_groups?: string[] | null; + soul?: string | null; +}
+ {t.agents.nameStepHint} +
{nameError}
{t.agents.agentCreated}
+ {t.agents.description} +
{t.agents.emptyTitle}
+ {t.agents.emptyDescription} +