mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
feat: refactor agent tools management and add UI integration
- Added endpoint to list agent tools with metadata, excluding hidden tools. - Updated NewChatRequest and RegenerateRequest schemas to include disabled tools. - Integrated disabled tools management in the NewChatPage and Composer components. - Improved tool instructions and visibility in the system prompt. - Refactored tool registration to support hidden tools and default enabled states. - Enhanced document chunk creation to handle strict zip behavior. - Cleaned up imports and formatting across various files for consistency.
This commit is contained in:
parent
c131912a08
commit
d8a05ae4d5
20 changed files with 538 additions and 283 deletions
|
|
@ -12,6 +12,7 @@ import { useParams, useSearchParams } from "next/navigation";
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { disabledToolsAtom } from "@/atoms/agent-tools/agent-tools.atoms";
|
||||
import {
|
||||
clearTargetCommentIdAtom,
|
||||
currentThreadAtom,
|
||||
|
|
@ -180,6 +181,9 @@ export default function NewChatPage() {
|
|||
interruptData: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
|
||||
// Get disabled tools from the tool toggle UI
|
||||
const disabledTools = useAtomValue(disabledToolsAtom);
|
||||
|
||||
// Get mentioned document IDs from the composer (derived from @ mentions + sidebar selections)
|
||||
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
|
||||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||
|
|
@ -640,6 +644,7 @@ export default function NewChatPage() {
|
|||
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
|
||||
? mentionedDocumentIds.surfsense_doc_ids
|
||||
: undefined,
|
||||
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
@ -918,6 +923,7 @@ export default function NewChatPage() {
|
|||
queryClient,
|
||||
currentThread,
|
||||
currentUser,
|
||||
disabledTools,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -1371,6 +1377,7 @@ export default function NewChatPage() {
|
|||
body: JSON.stringify({
|
||||
search_space_id: searchSpaceId,
|
||||
user_query: newUserQuery || null,
|
||||
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
@ -1542,7 +1549,7 @@ export default function NewChatPage() {
|
|||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[threadId, searchSpaceId, messages, setMessageThinkingSteps]
|
||||
[threadId, searchSpaceId, messages, setMessageThinkingSteps, disabledTools]
|
||||
);
|
||||
|
||||
// Handle editing a message - truncates history and regenerates with new query
|
||||
|
|
|
|||
96
surfsense_web/atoms/agent-tools/agent-tools.atoms.ts
Normal file
96
surfsense_web/atoms/agent-tools/agent-tools.atoms.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { atom } from "jotai";
|
||||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { agentToolsApiService, type AgentToolInfo } from "@/lib/apis/agent-tools-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
export const agentToolsAtom = atomWithQuery((_get) => ({
|
||||
queryKey: cacheKeys.agentTools.all(),
|
||||
staleTime: 30 * 60 * 1000, // 30 min – tool list rarely changes
|
||||
queryFn: async () => agentToolsApiService.getTools(),
|
||||
}));
|
||||
|
||||
const STORAGE_PREFIX = "surfsense-disabled-tools-";
|
||||
|
||||
function loadDisabledTools(searchSpaceId: string): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${searchSpaceId}`);
|
||||
return raw ? (JSON.parse(raw) as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveDisabledTools(searchSpaceId: string, tools: string[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (tools.length === 0) {
|
||||
localStorage.removeItem(`${STORAGE_PREFIX}${searchSpaceId}`);
|
||||
} else {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${searchSpaceId}`, JSON.stringify(tools));
|
||||
}
|
||||
}
|
||||
|
||||
const disabledToolsBaseAtom = atom<string[]>([]);
|
||||
|
||||
/** Tracks whether the atom has been hydrated from localStorage for the current search space */
|
||||
const hydratedForAtom = atom<string | null>(null);
|
||||
|
||||
/**
|
||||
* Read/write atom for the set of disabled tool names.
|
||||
* Persists to localStorage keyed by search space ID.
|
||||
*/
|
||||
export const disabledToolsAtom = atom(
|
||||
(get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
const hydratedFor = get(hydratedForAtom);
|
||||
if (searchSpaceId && hydratedFor !== searchSpaceId) {
|
||||
return loadDisabledTools(searchSpaceId);
|
||||
}
|
||||
return get(disabledToolsBaseAtom);
|
||||
},
|
||||
(get, set, update: string[] | ((prev: string[]) => string[])) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
const prev = get(disabledToolsBaseAtom);
|
||||
const next = typeof update === "function" ? update(prev) : update;
|
||||
set(disabledToolsBaseAtom, next);
|
||||
set(hydratedForAtom, searchSpaceId);
|
||||
if (searchSpaceId) {
|
||||
saveDisabledTools(searchSpaceId, next);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Hydrate disabled tools from localStorage when search space changes.
|
||||
* Call this from a useEffect in a component that has access to the search space.
|
||||
*/
|
||||
export const hydrateDisabledToolsAtom = atom(null, (get, set) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
if (!searchSpaceId) return;
|
||||
const stored = loadDisabledTools(searchSpaceId);
|
||||
set(disabledToolsBaseAtom, stored);
|
||||
set(hydratedForAtom, searchSpaceId);
|
||||
});
|
||||
|
||||
/** Toggle a single tool's enabled/disabled state */
|
||||
export const toggleToolAtom = atom(null, (get, set, toolName: string) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
const current = get(disabledToolsBaseAtom);
|
||||
const next = current.includes(toolName)
|
||||
? current.filter((t) => t !== toolName)
|
||||
: [...current, toolName];
|
||||
set(disabledToolsBaseAtom, next);
|
||||
set(hydratedForAtom, searchSpaceId);
|
||||
if (searchSpaceId) {
|
||||
saveDisabledTools(searchSpaceId, next);
|
||||
}
|
||||
});
|
||||
|
||||
/** Derive the count of currently enabled tools */
|
||||
export const enabledToolCountAtom = atom((get) => {
|
||||
const { data: tools } = get(agentToolsAtom);
|
||||
const disabled = get(disabledToolsAtom);
|
||||
if (!tools) return 0;
|
||||
return tools.length - disabled.filter((d) => tools.some((t) => t.name === d)).length;
|
||||
});
|
||||
|
|
@ -19,11 +19,10 @@ import {
|
|||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SquareIcon,
|
||||
Unplug,
|
||||
Upload,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
|
@ -46,11 +45,7 @@ import {
|
|||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||
import {
|
||||
ConnectorIndicator,
|
||||
type ConnectorIndicatorHandle,
|
||||
} from "@/components/assistant-ui/connector-popup";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
type InlineMentionEditorRef,
|
||||
|
|
@ -71,16 +66,20 @@ import {
|
|||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
agentToolsAtom,
|
||||
disabledToolsAtom,
|
||||
enabledToolCountAtom,
|
||||
hydrateDisabledToolsAtom,
|
||||
toggleToolAtom,
|
||||
} from "@/atoms/agent-tools/agent-tools.atoms";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Placeholder texts that cycle in new chats when input is empty */
|
||||
|
|
@ -548,6 +547,7 @@ const Composer: FC = () => {
|
|||
document.body
|
||||
)}
|
||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||
<ConnectorIndicator showTrigger={false} />
|
||||
<ConnectToolsBanner />
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
|
|
@ -562,11 +562,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||
const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom);
|
||||
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
|
||||
const connectorRef = useRef<ConnectorIndicatorHandle>(null);
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const connectorCount = connectors?.length ?? 0;
|
||||
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
|
||||
const isComposerTextEmpty = useAssistantState(({ composer }) => {
|
||||
const text = composer.text?.trim() || "";
|
||||
return text.length === 0;
|
||||
|
|
@ -577,6 +573,16 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
||||
|
||||
const { data: agentTools } = useAtomValue(agentToolsAtom);
|
||||
const disabledTools = useAtomValue(disabledToolsAtom);
|
||||
const toggleTool = useSetAtom(toggleToolAtom);
|
||||
const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom);
|
||||
const enabledCount = useAtomValue(enabledToolCountAtom);
|
||||
|
||||
useEffect(() => {
|
||||
hydrateDisabled();
|
||||
}, [hydrateDisabled]);
|
||||
|
||||
const hasModelConfigured = useMemo(() => {
|
||||
if (!preferences) return false;
|
||||
const agentLlmId = preferences.agent_llm_id;
|
||||
|
|
@ -593,50 +599,61 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-3 mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<DropdownMenu open={addMenuOpen} onOpenChange={setAddMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Popover open={toolsPopoverOpen} onOpenChange={setToolsPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Add files and more"
|
||||
tooltip="Manage tools"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||
aria-label="Add files and more"
|
||||
aria-label="Manage tools"
|
||||
data-joyride="connector-icon"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
<Wrench className="size-4" />
|
||||
</TooltipIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
className="w-[calc(100vw-2rem)] max-w-60 sm:w-60"
|
||||
className="w-[calc(100vw-2rem)] max-w-80 sm:w-80 p-0"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setAddMenuOpen(false);
|
||||
openUploadDialog();
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4 shrink-0" />
|
||||
Upload files
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setAddMenuOpen(false);
|
||||
connectorRef.current?.open();
|
||||
}}
|
||||
>
|
||||
<Unplug className="size-4 shrink-0" />
|
||||
{connectorCount > 0 ? "Manage tools" : "Connect your tools"}
|
||||
{connectorCount > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">{connectorCount}</span>
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b">
|
||||
<span className="text-sm font-medium">Agent Tools</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{enabledCount}/{agentTools?.length ?? 0} enabled
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{agentTools?.map((tool) => {
|
||||
const isDisabled = disabledTools.includes(tool.name);
|
||||
return (
|
||||
<Tooltip key={tool.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<label className="flex items-center gap-3 px-3 py-1.5 cursor-pointer hover:bg-muted-foreground/10 transition-colors">
|
||||
<span className="flex-1 min-w-0 text-sm font-medium truncate">{formatToolName(tool.name)}</span>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={() => toggleTool(tool.name)}
|
||||
className="shrink-0 scale-75"
|
||||
/>
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-64 text-xs">
|
||||
{tool.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{!agentTools?.length && (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
Loading tools...
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ConnectorIndicator ref={connectorRef} showTrigger={false} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{sidebarDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -702,6 +719,14 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
);
|
||||
};
|
||||
|
||||
/** Convert snake_case tool names to human-readable labels */
|
||||
function formatToolName(name: string): string {
|
||||
return name
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const MessageError: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Error>
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ function DigitWheel({
|
|||
|
||||
const seq = Array.from({ length: cycles * 10 }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
value: Math.floor(Math.random() * 10),
|
||||
value: (i * 7 + 3) % 10,
|
||||
}));
|
||||
const target = { id: "target", value: digit };
|
||||
if (reverse) {
|
||||
|
|
@ -217,7 +217,7 @@ function DigitWheel({
|
|||
const maxOffset = (sequence.length - 1) * itemSize;
|
||||
const endY = reverse ? 0 : -maxOffset;
|
||||
|
||||
const rollingStartItem = React.useRef(Math.floor(Math.random() * 10));
|
||||
const rollingStartItem = React.useRef(0);
|
||||
const startOffset = rollingStartItem.current * itemSize;
|
||||
|
||||
const y = useMotionValue(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
|
||||
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -62,6 +63,8 @@ export function DocumentsSidebar({
|
|||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const connectorCount = connectors?.length ?? 0;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
|
|
@ -237,7 +240,12 @@ export function DocumentsSidebar({
|
|||
className="flex items-center gap-2 min-w-0 flex-1 text-left"
|
||||
>
|
||||
<Unplug className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs text-muted-foreground">Connect your tools</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{connectorCount > 0 ? "Manage connectors" : "Connect connectors"}
|
||||
</span>
|
||||
{connectorCount > 0 && (
|
||||
<span className="ml-auto shrink-0 text-xs font-medium text-muted-foreground">{connectorCount}</span>
|
||||
)}
|
||||
<AvatarGroup className="ml-auto shrink-0">
|
||||
{SHOWCASE_CONNECTORS.map(({ type, label }, i) => (
|
||||
<Tooltip key={type}>
|
||||
|
|
|
|||
20
surfsense_web/lib/apis/agent-tools-api.service.ts
Normal file
20
surfsense_web/lib/apis/agent-tools-api.service.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { z } from "zod";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
const AgentToolInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
enabled_by_default: z.boolean(),
|
||||
});
|
||||
|
||||
export type AgentToolInfo = z.infer<typeof AgentToolInfoSchema>;
|
||||
|
||||
const AgentToolsListSchema = z.array(AgentToolInfoSchema);
|
||||
|
||||
class AgentToolsApiService {
|
||||
async getTools(): Promise<AgentToolInfo[]> {
|
||||
return baseApiService.get("/api/v1/agent/tools", AgentToolsListSchema);
|
||||
}
|
||||
}
|
||||
|
||||
export const agentToolsApiService = new AgentToolsApiService();
|
||||
|
|
@ -67,6 +67,9 @@ export const cacheKeys = {
|
|||
all: (searchSpaceId: string) => ["invites", searchSpaceId] as const,
|
||||
info: (inviteCode: string) => ["invites", "info", inviteCode] as const,
|
||||
},
|
||||
agentTools: {
|
||||
all: () => ["agent-tools"] as const,
|
||||
},
|
||||
connectors: {
|
||||
all: (searchSpaceId: string) => ["connectors", searchSpaceId] as const,
|
||||
withQueryParams: (queries: GetConnectorsRequest["queryParams"]) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue