diff --git a/src/application/App.tsx b/src/application/App.tsx index 1dd0d88..28558a6 100644 --- a/src/application/App.tsx +++ b/src/application/App.tsx @@ -6,6 +6,8 @@ import SettingsPage from "@/application/pages/SettingsPage"; // Lazy-load secondary routes for code-splitting / smaller initial bundle. const AgentsPage = lazy(() => import("@/application/pages/AgentsPage")); const RagPage = lazy(() => import("@/application/pages/RagPage")); +const SkillsPage = lazy(() => import("@/application/pages/SkillsPage")); +const MemoriesPage = lazy(() => import("@/application/pages/MemoriesPage")); function PageFallback() { return ( @@ -24,6 +26,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> ); diff --git a/src/application/components/agent/AgentConfigForm.tsx b/src/application/components/agent/AgentConfigForm.tsx index 928ae72..ffae6c4 100644 --- a/src/application/components/agent/AgentConfigForm.tsx +++ b/src/application/components/agent/AgentConfigForm.tsx @@ -1,19 +1,15 @@ import { useCallback, memo } from "react"; -import { - useForm, - useFieldArray, - useWatch, - type Control, -} from "react-hook-form"; +import { useForm, useFieldArray, useWatch, type Control } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Plus } from "lucide-react"; import type { AgentConfig, SubAgentConfig } from "@/domain/entities/agent/agentConfig"; -import { BackendType, MiddlewareType } from "@/domain/entities/agent/agentConfig"; +import { BackendType } from "@/domain/entities/agent/agentConfig"; import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig"; import type { McpServerConfig } from "@/domain/entities/agent/mcpServerConfig"; import { agentConfigSchema, type AgentConfigFormData, + type AgentConfigFormInput, } from "@/domain/entities/agent/agentConfigSchema"; import { Button } from "@/application/components/ui/button"; import { Input } from "@/application/components/ui/input"; @@ -34,17 +30,17 @@ import { SelectValue, } from "@/application/components/ui/select"; import StringListEditor from "./StringListEditor"; +import { SkillPillMultiSelect, MemoryPillMultiSelect } from "./SkillMemorySelects"; import McpServerEditor from "./McpServerEditor"; import SubAgentEditor from "./SubAgentEditor"; import HITLEditor from "./HITLEditor"; import ResponseFormatEditor from "./ResponseFormatEditor"; import { AGENT_CONFIG_FORM_ID as FORM_ID } from "./formConstants"; -import { cn } from "@/application/lib/utils"; type SectionValue = | "general" | "system-prompt" - | "tools-middleware" + | "tools" | "backend" | "hitl" | "memory-skills" @@ -57,7 +53,7 @@ const DEFAULT_OPEN_SECTIONS: SectionValue[] = ["general", "system-prompt"]; const SECTION_META: Record = { general: { label: "General" }, "system-prompt": { label: "System Prompt" }, - "tools-middleware": { label: "Tools & Middleware" }, + tools: { label: "Tools" }, backend: { label: "Backend" }, hitl: { label: "HITL" }, "memory-skills": { label: "Memory & Skills" }, @@ -66,17 +62,9 @@ const SECTION_META: Record = { "response-format": { label: "Response Format" }, }; -const MIDDLEWARE_OPTIONS: { label: string; value: MiddlewareType }[] = [ - { value: MiddlewareType.TODO_LIST, label: "todo_list" }, - { value: MiddlewareType.FILESYSTEM, label: "filesystem" }, - { value: MiddlewareType.SUB_AGENT, label: "sub_agent" }, -]; - -const BACKEND_OPTIONS: { label: string; value: BackendType }[] = [ - { value: BackendType.STATE, label: "state" }, - { value: BackendType.STORE, label: "store" }, - { value: BackendType.FILESYSTEM, label: "filesystem" }, - { value: BackendType.COMPOSITE, label: "composite" }, +const BACKEND_STORAGE_OPTIONS: { label: string; value: "memory" | "postgres" }[] = [ + { value: "memory", label: "memory" }, + { value: "postgres", label: "postgres" }, ]; const EMPTY_MCP_SERVER: McpServerConfig = { @@ -107,8 +95,10 @@ const DEFAULT_FORM_VALUES: AgentConfigFormData = { system_prompt: "", system_prompt_file: "", tools: [], - middleware: [], - backend: { type: BackendType.STATE }, + backend: { + type: BackendType.STORE, + checkpoint_backend: "memory", + }, hitl: { rules: {} }, memory: [], skills: [], @@ -131,10 +121,6 @@ function getDefaultValues(mode: "create" | "edit", initialData?: AgentConfig): A return { ...DEFAULT_FORM_VALUES }; } -function showRootDir(type: BackendType): boolean { - return type === BackendType.FILESYSTEM || type === BackendType.COMPOSITE; -} - interface AgentConfigFormProps { mode: "create" | "edit"; initialData?: AgentConfig; @@ -155,18 +141,15 @@ export default function AgentConfigForm({ handleSubmit, control, setValue, - getValues, formState: { errors }, - } = useForm({ + } = useForm({ resolver: zodResolver(agentConfigSchema), defaultValues: getDefaultValues(mode, initialData), }); const subagentsArray = useFieldArray({ control, name: "subagents" }); - // eslint-disable-next-line react-hooks/incompatible-library - const backendType = useWatch({ control, name: "backend.type" }); - const middlewareValues = useWatch({ control, name: "middleware" }); + const checkpointBackend = useWatch({ control, name: "backend.checkpoint_backend" }); function handleFormSubmit(data: AgentConfigFormData) { const cleaned: AgentConfigFormData = { @@ -182,18 +165,6 @@ export default function AgentConfigForm({ subagentsArray.append({ ...EMPTY_SUBAGENT }); } - function toggleMiddleware(mw: MiddlewareType, isChecked: boolean) { - const current = getValues("middleware"); - if (isChecked) { - setValue("middleware", [...current, mw]); - } else { - setValue( - "middleware", - current.filter((m) => m !== mw), - ); - } - } - return (
- - + + setValue("tools", tools)} placeholder="Add tool…" /> -
- -
- {MIDDLEWARE_OPTIONS.map((opt) => { - const isActive = middlewareValues.includes(opt.value); - return ( - - ); - })} -
-
@@ -301,16 +248,22 @@ export default function AgentConfigForm({
- + +

store

+
+
+
- {showRootDir(backendType) && ( - - - - )}
@@ -345,18 +287,20 @@ export default function AgentConfigForm({ - setValue("memory", memory)} - placeholder="Add memory path…" - /> - setValue("skills", skills)} - placeholder="Add skill…" - /> +
+ + setValue("memory", memory)} + /> +
+
+ + setValue("skills", skills)} + /> +
@@ -461,7 +405,7 @@ const McpServersAccordionItem = memo(function McpServersAccordionItem({ control, defaultValue, }: Readonly<{ - control: Control; + control: Control; defaultValue: SectionValue[]; }>) { const mcpServersArray = useFieldArray({ control, name: "mcp_servers" }); diff --git a/src/application/components/agent/AgentConfigViewer.tsx b/src/application/components/agent/AgentConfigViewer.tsx index 54aa28b..a90b17c 100644 --- a/src/application/components/agent/AgentConfigViewer.tsx +++ b/src/application/components/agent/AgentConfigViewer.tsx @@ -9,6 +9,7 @@ import type { AgentConfig } from "@/domain/entities/agent/agentConfig"; import type { AgentConfigFormData } from "@/domain/entities/agent/agentConfigSchema"; import StatusBadge from "@/application/components/shared/StatusBadge"; import ToolTag from "@/application/components/shared/ToolTag"; +import { SkillPillMultiSelect, MemoryPillMultiSelect } from "./SkillMemorySelects"; import { Dialog, DialogContent, @@ -200,29 +201,29 @@ export default function AgentConfigViewer({ )} - {config.middleware.length > 0 && ( +
+ Backend +
+

+ checkpoint_backend:{" "} + {config.backend.checkpoint_backend} +

+
+
+ + {config.skills.length > 0 && (
- Middleware ({config.middleware.length}) -
- {config.middleware.map((mw) => ( - - ))} -
+ Skills ({config.skills.length}) +
)} -
- Backend -

- Type: {config.backend.type} - {config.backend.root_dir && ( - <> - {" · "}Root:{" "} - {config.backend.root_dir} - - )} -

-
+ {config.memory.length > 0 && ( +
+ Memories ({config.memory.length}) + +
+ )} {Object.keys(config.hitl.rules).length > 0 && (
diff --git a/src/application/components/agent/SkillMemorySelects.tsx b/src/application/components/agent/SkillMemorySelects.tsx new file mode 100644 index 0000000..62ccf31 --- /dev/null +++ b/src/application/components/agent/SkillMemorySelects.tsx @@ -0,0 +1,107 @@ +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { useStoreFiles } from "@/application/hooks/store/useStoreFiles"; +import { storeApi } from "@/infrastructure/api/store/storeApi"; +import { parseFrontmatter } from "@/application/lib/frontmatter"; +import PillMultiSelect, { + type PillMultiSelectOption, +} from "@/application/components/shared/PillMultiSelect"; + +const SKILLS_PREFIX = "/skills/"; +const MEMORIES_PREFIX = "/memories/"; + +/** + * Multi-select of available skills for use inside the agent form. Each option + * value is the skill directory path (e.g. `/skills/rag/`); labels come from + * each skill's frontmatter `name`. Skills are fetched in parallel via + * `useQueries` so we don't create an N+1 fetch waterfall. + */ +export function SkillPillMultiSelect({ + selected, + onChange, + readOnly = false, +}: Readonly<{ selected: string[]; onChange?: (selected: string[]) => void; readOnly?: boolean }>) { + const { data: files } = useStoreFiles(SKILLS_PREFIX); + + const skillPaths = useMemo( + () => + (files ?? []) + .map((f) => f.path) + .filter((p) => p.endsWith("SKILL.md")) + .sort(), + [files], + ); + + // Fetch every SKILL.md in parallel to read its frontmatter name. The query + // key mirrors useStoreFile so results are shared/cached with the viewer. + const fileResults = useQueries({ + queries: skillPaths.map((path) => ({ + queryKey: ["store-file", path], + queryFn: () => storeApi.getFile(path), + })), + }); + + const options = useMemo(() => { + return skillPaths.map((path, index) => { + const file = fileResults[index]?.data; + const dir = path.replace(/\/SKILL\.md$/, ""); + const name = file ? parseFrontmatter(file.content).data.name : null; + return { + value: `${dir}/`, + label: name ?? deriveNameFromPath(path), + description: file ? parseFrontmatter(file.content).data.description : undefined, + }; + }); + }, [skillPaths, fileResults]); + + return ( + + ); +} + +/** + * Multi-select of available memories for use inside the agent form. Each + * option value is the full memory path (e.g. `/memories/AGENTS.md`); the label + * is the filename. + */ +export function MemoryPillMultiSelect({ + selected, + onChange, + readOnly = false, +}: Readonly<{ selected: string[]; onChange?: (selected: string[]) => void; readOnly?: boolean }>) { + const { data: files } = useStoreFiles(MEMORIES_PREFIX); + + const options = useMemo( + () => + (files ?? []) + .map((f) => f.path) + .sort() + .map((path) => ({ + value: path, + label: path.split("/").filter(Boolean).pop() ?? path, + })), + [files], + ); + + return ( + + ); +} + +function deriveNameFromPath(path: string): string { + // /skills/rag/SKILL.md -> rag + const parts = path.split("/").filter(Boolean); + return parts.length >= 2 ? parts[1] : path; +} diff --git a/src/application/components/layout/Sidebar.tsx b/src/application/components/layout/Sidebar.tsx index c340807..1c01a0f 100644 --- a/src/application/components/layout/Sidebar.tsx +++ b/src/application/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { NavLink } from "react-router-dom"; -import { Bot, Database, MessagesSquare, Settings, X } from "lucide-react"; +import { BookOpen, Bot, Database, MessagesSquare, Settings, Sparkles, X } from "lucide-react"; import { cn } from "@/application/lib/utils"; import { useSidebarStore } from "@/application/stores/useSidebarStore"; @@ -14,6 +14,8 @@ interface NavItem { const NAV_ITEMS: readonly NavItem[] = [ { to: "/chat", label: "Orchestration", icon: MessagesSquare, odId: "nav-orchestration" }, { to: "/agents", label: "Agents", icon: Bot, odId: "nav-agents" }, + { to: "/skills", label: "Skills", icon: Sparkles, odId: "nav-skills" }, + { to: "/memories", label: "Memories", icon: BookOpen, odId: "nav-memories" }, { to: "/rag", label: "RAG Storage", icon: Database, odId: "nav-rag" }, ] as const; diff --git a/src/application/components/memory/CreateMemoryDialog.tsx b/src/application/components/memory/CreateMemoryDialog.tsx new file mode 100644 index 0000000..d42572e --- /dev/null +++ b/src/application/components/memory/CreateMemoryDialog.tsx @@ -0,0 +1,133 @@ +import { useState, type FormEvent } from "react"; +import { toast } from "sonner"; +import { useCreateMemory } from "@/application/hooks/memory/useCreateMemory"; +import { extractApiMessage } from "@/infrastructure/api/store/storeApi"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/application/components/ui/dialog"; +import { Button } from "@/application/components/ui/button"; +import { Input } from "@/application/components/ui/input"; +import { Label } from "@/application/components/ui/label"; +import { Textarea } from "@/application/components/ui/textarea"; + +interface CreateMemoryDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +const MEMORY_FORM_ID = "memory-create-form"; + +/** + * Dialog to create a new memory file: a name (becomes + * `/memories/{name}.md`) and a markdown body. No frontmatter for memories. + */ +export function CreateMemoryDialog({ open, onOpenChange }: Readonly) { + const [name, setName] = useState(""); + const [content, setContent] = useState(""); + const createMemory = useCreateMemory(); + + function handleClose() { + setName(""); + setContent(""); + onOpenChange(false); + } + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + const trimmedName = name.trim(); + if (!trimmedName) { + toast.error("Memory name is required"); + return; + } + createMemory.mutate( + { name: trimmedName, content }, + { + onSuccess: () => { + toast.success("Memory created successfully"); + handleClose(); + }, + onError: (error) => { + toast.error(extractApiMessage(error)); + }, + }, + ); + } + + return ( + { + if (!v) handleClose(); + }} + > + +
+ + Create Memory + + Create a markdown memory file at + /memories/<name>.md. + + +
+ +
+ +
+ + setName(e.target.value)} + placeholder="AGENTS" + autoComplete="off" + spellCheck={false} + aria-label="Name" + /> +
+ +
+ +