From 8c634677825a25f7aa689a842e8b1e9dad1cc028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Mon, 19 May 2025 14:12:48 +0200 Subject: [PATCH] refactor: refactor conversation settings component --- src/components/ConversationSettings.tsx | 839 +----------------- .../settings/EnvironmentVariables.tsx | 106 +++ src/components/settings/McpConfiguration.tsx | 312 +++++++ .../settings/ToolsConfiguration.tsx | 137 +++ src/hooks/useConversationSettings.ts | 188 ++++ src/schemas/conversationSettings.ts | 48 + 6 files changed, 815 insertions(+), 815 deletions(-) create mode 100644 src/components/settings/EnvironmentVariables.tsx create mode 100644 src/components/settings/McpConfiguration.tsx create mode 100644 src/components/settings/ToolsConfiguration.tsx create mode 100644 src/hooks/useConversationSettings.ts create mode 100644 src/schemas/conversationSettings.ts diff --git a/src/components/ConversationSettings.tsx b/src/components/ConversationSettings.tsx index 3eaa6ba..1179268 100644 --- a/src/components/ConversationSettings.tsx +++ b/src/components/ConversationSettings.tsx @@ -1,21 +1,15 @@ -import { use$ } from '@legendapp/state/react'; -import { conversations$, updateConversation } from '@/stores/conversations'; -import { useEffect, useState, type FC } from 'react'; -import { useApi } from '@/contexts/ApiContext'; +import { useState, type FC } from 'react'; import { DeleteConversationConfirmationDialog } from './DeleteConversationConfirmationDialog'; -import { Trash } from 'lucide-react'; +import { Trash, Loader2 } from 'lucide-react'; import { AVAILABLE_MODELS } from './ConversationContent'; -import { useForm, useFieldArray } from 'react-hook-form'; -import { z } from 'zod'; -import { zodResolver } from '@hookform/resolvers/zod'; import { Form, - FormControl, FormField, FormItem, FormLabel, - FormMessage, + FormControl, FormDescription, + FormMessage, } from '@/components/ui/form'; import { Select, @@ -24,414 +18,29 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import type { ChatConfig } from '@/types/api'; import { Button } from '@/components/ui/button'; -import { ChevronDown, ChevronRight, Loader2, Plus, X } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; -import { ToolFormat } from '@/types/api'; -import { toast } from 'sonner'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; +import { EnvironmentVariables } from './settings/EnvironmentVariables'; +import { ToolsConfiguration } from './settings/ToolsConfiguration'; +import { McpConfiguration } from './settings/McpConfiguration'; +import { useConversationSettings } from '@/hooks/useConversationSettings'; interface ConversationSettingsProps { conversationId: string; } -const mcpServerSchema = z.object({ - name: z.string().min(1, 'Server name cannot be empty'), - enabled: z.boolean(), - command: z.string().min(1, 'Command cannot be empty'), - args: z.string(), - env: z - .array( - z.object({ - key: z.string().min(1, 'Variable name cannot be empty'), - value: z.string(), - }) - ) - .optional(), -}); - -const formSchema = z.object({ - chat: z.object({ - model: z.string().optional(), - tools: z.array(z.object({ name: z.string().min(1, 'Tool name cannot be empty') })).optional(), - tool_format: z.nativeEnum(ToolFormat).nullable().optional(), - stream: z.boolean(), - interactive: z.boolean(), - workspace: z.string().min(1, 'Workspace directory is required'), - env: z - .array( - z.object({ key: z.string().min(1, 'Variable name cannot be empty'), value: z.string() }) - ) - .optional(), - }), - mcp: z.object({ - enabled: z.boolean(), - auto_start: z.boolean(), - servers: z.array(mcpServerSchema).optional(), - }), -}); - -type FormSchema = z.infer; - -const defaultMcpServer: z.infer = { - name: '', - enabled: true, - command: '', - args: '', - env: [], -}; - export const ConversationSettings: FC = ({ conversationId }) => { - const api = useApi(); - const conversation$ = conversations$.get(conversationId); - const chatConfig = use$(conversation$?.chatConfig); - - const [toolsOpen, setToolsOpen] = useState(false); - const [mcpOpen, setMcpOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - - console.log('chatConfig', chatConfig); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - chat: { - model: '', - tools: [], - tool_format: ToolFormat.MARKDOWN, - stream: true, - interactive: false, - workspace: '', - env: [], - }, - mcp: { - enabled: false, - auto_start: false, - servers: [], - }, - }, - }); + const { form, toolFields, envFields, serverFields, onSubmit, chatConfig } = + useConversationSettings(conversationId); const { handleSubmit, - reset, control, - register, - formState: { isDirty, isSubmitting, errors }, - getValues, + formState: { isDirty, isSubmitting }, } = form; - const { - fields: toolFields, - append: toolAppend, - remove: toolRemove, - } = useFieldArray({ - control, - name: 'chat.tools', - }); - - const { - fields: envFields, - append: envAppend, - remove: envRemove, - } = useFieldArray({ - control, - name: 'chat.env', - }); - - const { - fields: serverFields, - append: serverAppend, - remove: serverRemove, - update: serverUpdate, - } = useFieldArray({ - control, - name: 'mcp.servers', - }); - - const [newToolName, setNewToolName] = useState(''); - const [newEnvKey, setNewEnvKey] = useState(''); - const [newEnvValue, setNewEnvValue] = useState(''); - const [newServerEnvInputs, setNewServerEnvInputs] = useState< - Record - >({}); - - // Reset form when chatConfig loads or changes - useEffect(() => { - if (chatConfig) { - console.log('Resetting form with chatConfig:', chatConfig); - reset({ - chat: { - model: chatConfig.chat.model || '', - tools: chatConfig.chat.tools?.map((tool) => ({ name: tool })) || [], - tool_format: chatConfig.chat.tool_format || ToolFormat.MARKDOWN, - stream: chatConfig.chat.stream ?? true, - interactive: chatConfig.chat.interactive ?? false, - workspace: chatConfig.chat.workspace || '', - env: chatConfig.env - ? Object.entries(chatConfig.env).map(([key, value]) => ({ key, value })) - : [], - }, - mcp: { - enabled: chatConfig.mcp?.enabled ?? false, - auto_start: chatConfig.mcp?.auto_start ?? false, - servers: - chatConfig.mcp?.servers?.map((server) => ({ - name: server.name || '', - enabled: server.enabled ?? false, - command: server.command || '', - args: server.args?.join(', ') || '', - env: server.env - ? Object.entries(server.env).map(([key, value]) => ({ key, value })) - : [], - })) || [], - }, - }); - const initialServerEnvState: Record = {}; - (chatConfig.mcp?.servers || []).forEach((_, index) => { - initialServerEnvState[index] = { key: '', value: '' }; - }); - setNewServerEnvInputs(initialServerEnvState); - } - }, [chatConfig, reset]); - - // Load the chat config if it's not already loaded - useEffect(() => { - if (!chatConfig) { - api.getChatConfig(conversationId).then((config) => { - updateConversation(conversationId, { chatConfig: config }); - }); - } - }, [api, chatConfig, conversationId]); - - const onSubmit = async (values: FormSchema) => { - const originalConfig = chatConfig; - if (!originalConfig) { - console.error('Original chatConfig not found, cannot submit.'); - toast.error('Cannot save settings: Original configuration missing.'); - return; - } - - // Capture original tools for comparison later - const originalTools = originalConfig.chat.tools; - - const toolsStringArray = values.chat.tools?.map((tool) => tool.name); - const newTools = toolsStringArray?.length ? toolsStringArray : null; - const newEnv = - values.chat.env?.reduce( - (acc, { key, value }) => { - if (key.trim()) acc[key.trim()] = value; - return acc; - }, - {} as Record - ) || {}; - - const newMcpServers = values.mcp?.servers?.map((server) => ({ - name: server.name, - enabled: server.enabled, - command: server.command, - args: server.args - .split(',') - .map((arg) => arg.trim()) - .filter(Boolean), - env: - server.env?.reduce( - (acc, { key, value }) => { - if (key.trim()) acc[key.trim()] = value; - return acc; - }, - {} as Record - ) || {}, - })); - - const newConfig: ChatConfig = { - ...originalConfig, - chat: { - ...originalConfig.chat, - model: values.chat.model || null, - tools: newTools, - tool_format: values.chat.tool_format || null, - stream: values.chat.stream, - interactive: values.chat.interactive, - workspace: values.chat.workspace, - }, - env: newEnv, - mcp: { - enabled: values.mcp.enabled, - auto_start: values.mcp.auto_start, - servers: newMcpServers || [], - }, - }; - - console.log('Submitting new config:', JSON.stringify(newConfig, null, 2)); - - try { - // --- Attempt API Update --- - await api.updateChatConfig(conversationId, newConfig); - - // --- Success: Check if tools changed --- - const toolsChanged = - JSON.stringify(originalTools?.slice().sort()) !== - JSON.stringify(newConfig.chat.tools?.slice().sort()); - const mcpChanged = - originalConfig.mcp?.enabled !== newConfig.mcp?.enabled || - originalConfig.mcp?.auto_start !== newConfig.mcp?.auto_start; - const mcpServersChanged = - JSON.stringify(originalConfig.mcp?.servers?.slice().sort()) !== - JSON.stringify(newConfig.mcp?.servers?.slice().sort()); - - if (toolsChanged || mcpChanged || mcpServersChanged) { - console.log('Tools or MCP servers changed, reloading conversation data...'); - const conversationData = await api.getConversation(conversationId); - // Update with new conversation data *and* the new config - updateConversation(conversationId, { data: conversationData, chatConfig: newConfig }); - } else { - console.log('Tools unchanged, updating local config only.'); - // Only update the local config if tools didn't change - updateConversation(conversationId, { chatConfig: newConfig }); - } - toast.success('Settings updated successfully!'); - - reset({ - chat: { - model: newConfig.chat.model || '', - tools: newConfig.chat.tools?.map((tool) => ({ name: tool })) || [], - tool_format: newConfig.chat.tool_format || null, - stream: newConfig.chat.stream, - interactive: newConfig.chat.interactive, - workspace: newConfig.chat.workspace, - env: newConfig.env - ? Object.entries(newConfig.env).map(([key, value]) => ({ key, value })) - : [], - }, - mcp: { - enabled: newConfig.mcp.enabled, - auto_start: newConfig.mcp.auto_start, - servers: - newConfig.mcp.servers?.map((server) => ({ - name: server.name || '', - enabled: server.enabled ?? false, - command: server.command || '', - args: server.args?.join(', ') || '', - env: server.env - ? Object.entries(server.env).map(([key, value]) => ({ key, value })) - : [], - })) || [], - }, - }); - const initialServerEnvState: Record = {}; - (newConfig.mcp?.servers || []).forEach((_, index) => { - initialServerEnvState[index] = { key: '', value: '' }; - }); - setNewServerEnvInputs(initialServerEnvState); - } catch (error) { - console.error('Failed to update chat config:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Failed to update settings: ${errorMessage}`); - - reset({ - chat: { - model: originalConfig.chat.model || '', - tools: originalConfig.chat.tools?.map((tool) => ({ name: tool })) || [], - tool_format: originalConfig.chat.tool_format || ToolFormat.MARKDOWN, - stream: originalConfig.chat.stream ?? true, - interactive: originalConfig.chat.interactive ?? false, - workspace: originalConfig.chat.workspace || '', - env: originalConfig.env - ? Object.entries(originalConfig.env).map(([key, value]) => ({ key, value })) - : [], - }, - mcp: { - enabled: originalConfig.mcp?.enabled ?? false, - auto_start: originalConfig.mcp?.auto_start ?? false, - servers: - originalConfig.mcp?.servers?.map((server) => ({ - name: server.name || '', - enabled: server.enabled ?? false, - command: server.command || '', - args: server.args?.join(', ') || '', - env: server.env - ? Object.entries(server.env).map(([key, value]) => ({ key, value })) - : [], - })) || [], - }, - }); - const originalServerEnvState: Record = {}; - (originalConfig.mcp?.servers || []).forEach((_, index) => { - originalServerEnvState[index] = { key: '', value: '' }; - }); - setNewServerEnvInputs(originalServerEnvState); - } - }; - - // Handler for adding a new tool - const handleAddTool = () => { - const trimmedName = newToolName.trim(); - if (trimmedName) { - toolAppend({ name: trimmedName }); - setNewToolName(''); - } - }; - - // Handler for adding a new env var - const handleAddEnvVar = () => { - const trimmedKey = newEnvKey.trim(); - if (trimmedKey) { - envAppend({ key: trimmedKey, value: newEnvValue }); - setNewEnvKey(''); - setNewEnvValue(''); - } - }; - - const handleAddServer = () => { - serverAppend(defaultMcpServer); - setNewServerEnvInputs((prev) => ({ ...prev, [serverFields.length]: { key: '', value: '' } })); - }; - - const handleAddServerEnvVar = (serverIndex: number) => { - const inputState = newServerEnvInputs[serverIndex]; - if (inputState && inputState.key.trim()) { - const fieldArrayName = `mcp.servers.${serverIndex}.env` as const; - const currentServerEnv = getValues(fieldArrayName) || []; - serverUpdate(serverIndex, { - ...getValues(`mcp.servers.${serverIndex}`), - env: [...currentServerEnv, { key: inputState.key.trim(), value: inputState.value }], - }); - - setNewServerEnvInputs((prev) => ({ - ...prev, - [serverIndex]: { key: '', value: '' }, - })); - } - }; - - const handleServerEnvInputChange = ( - serverIndex: number, - field: 'key' | 'value', - value: string - ) => { - setNewServerEnvInputs((prev) => ({ - ...prev, - [serverIndex]: { - ...(prev[serverIndex] || { key: '', value: '' }), - [field]: value, - }, - })); - }; - - // Handler for removing a server-specific env var - const handleRemoveServerEnvVar = (serverIndex: number, envIndex: number) => { - const fieldName = `mcp.servers.${serverIndex}.env` as const; - const currentEnvVars = getValues(fieldName) || []; - const newEnvVars = currentEnvVars.filter((_, idx) => idx !== envIndex); - serverUpdate(serverIndex, { - ...getValues(`mcp.servers.${serverIndex}`), - env: newEnvVars, - }); - }; - return (
{chatConfig && ( @@ -534,423 +143,24 @@ export const ConversationSettings: FC = ({ conversati )} /> - {/* Env Vars Field Array Section */} - - Environment Variables -
- {envFields.map((field, index) => ( -
- - - -
- ))} -
-
- setNewEnvKey(e.target.value)} - disabled={isSubmitting} - className="w-1/3" - /> - setNewEnvValue(e.target.value)} - disabled={isSubmitting} - className="flex-grow" - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddEnvVar(); - } - }} - /> - -
- - Environment variables available to the agent and tools. - - {errors.chat?.env && ( - - {errors.chat.env.message || errors.chat.env.root?.message} - - )} -
- -

Tools

- - {/* Tool Format Field */} - ( - - Tool Format - - - - )} + {/* Environment Variables */} + - {/* Tools Field Array Section */} - - - -
- Enabled Tools - {toolsOpen ? ( - - ) : ( - - )} -
- - List of tools that the agent can use. - -
- -
- {toolFields.map((field, index) => ( -
- {field.name} - -
- ))} -
-
- setNewToolName(e.target.value)} - disabled={isSubmitting} - onKeyDown={(e) => { - // Optional: Add tool on Enter press - if (e.key === 'Enter') { - e.preventDefault(); - handleAddTool(); - } - }} - /> - -
-
-
-
- - {/* MCP Configuration Section */} -

MCP Configuration

- ( - -
- Enable MCP -
- - - -
- )} - /> + {/* Tools Configuration */} + - ( - -
- Auto-Start MCP Servers -
- - - -
- )} + {/* MCP Configuration */} + - - - -
- MCP Servers - {mcpOpen ? ( - - ) : ( - - )} -
- Add or remove MCP servers. -
- -
- {serverFields.map((serverField, serverIndex) => ( -
- - - ( - - Server Name - - - - - - )} - /> - ( - -
- Enabled -
- - - -
- )} - /> - ( - - Command - - - - - - )} - /> - ( - - Arguments (comma-separated) - - - - - - )} - /> - - - Server Environment Variables -
- {(getValues(`mcp.servers.${serverIndex}.env`) || []).map( - (_, envIndex) => ( -
- - - -
- ) - )} -
-
- - handleServerEnvInputChange(serverIndex, 'key', e.target.value) - } - disabled={isSubmitting} - className="w-1/3" - /> - - handleServerEnvInputChange(serverIndex, 'value', e.target.value) - } - disabled={isSubmitting} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddServerEnvVar(serverIndex); - } - }} - /> - -
- {errors.mcp?.servers?.[serverIndex]?.env && ( - Error in server environment variables. - )} -
-
- ))} -
- - - {' '} - Configure external processes managed by MCP.{' '} - - {errors.mcp?.servers && ( - - {errors.mcp.servers.message || errors.mcp.servers.root?.message} - - )} -
-
-
- {/* Danger Zone */}

Danger Zone

@@ -1003,7 +213,6 @@ export const ConversationSettings: FC = ({ conversati open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen} onDelete={() => { - // Handle post-deletion navigation or UI updates window.location.href = '/'; }} /> diff --git a/src/components/settings/EnvironmentVariables.tsx b/src/components/settings/EnvironmentVariables.tsx new file mode 100644 index 0000000..52e36a5 --- /dev/null +++ b/src/components/settings/EnvironmentVariables.tsx @@ -0,0 +1,106 @@ +import { Button } from '@/components/ui/button'; +import { FormDescription, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Plus, X } from 'lucide-react'; +import { useState } from 'react'; +import { type UseFieldArrayReturn, type UseFormReturn } from 'react-hook-form'; +import type { FormSchema } from '@/schemas/conversationSettings'; + +interface EnvironmentVariablesProps { + form: UseFormReturn; + fieldArray: UseFieldArrayReturn; + isSubmitting: boolean; + description?: string; + className?: string; +} + +export const EnvironmentVariables = ({ + form, + fieldArray, + isSubmitting, + description, + className, +}: EnvironmentVariablesProps) => { + const [newEnvKey, setNewEnvKey] = useState(''); + const [newEnvValue, setNewEnvValue] = useState(''); + const { fields, append, remove } = fieldArray; + + const handleAddEnvVar = () => { + const trimmedKey = newEnvKey.trim(); + if (trimmedKey) { + append({ key: trimmedKey, value: newEnvValue }); + setNewEnvKey(''); + setNewEnvValue(''); + } + }; + + return ( + + Environment Variables +
+ {fields.map((field, index) => ( +
+ + + +
+ ))} +
+
+ setNewEnvKey(e.target.value)} + disabled={isSubmitting} + className="w-1/3" + /> + setNewEnvValue(e.target.value)} + disabled={isSubmitting} + className="flex-grow" + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddEnvVar(); + } + }} + /> + +
+ {description && {description}} + {form.formState.errors.chat?.env && ( + {form.formState.errors.chat.env.message} + )} +
+ ); +}; diff --git a/src/components/settings/McpConfiguration.tsx b/src/components/settings/McpConfiguration.tsx new file mode 100644 index 0000000..dedcff7 --- /dev/null +++ b/src/components/settings/McpConfiguration.tsx @@ -0,0 +1,312 @@ +import { Button } from '@/components/ui/button'; +import { + FormDescription, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ChevronDown, ChevronRight, Plus, X } from 'lucide-react'; +import { useState } from 'react'; +import { type UseFieldArrayReturn, type UseFormReturn } from 'react-hook-form'; +import type { FormSchema } from '@/schemas/conversationSettings'; +import { defaultMcpServer } from '@/schemas/conversationSettings'; + +interface McpConfigurationProps { + form: UseFormReturn; + serverFields: UseFieldArrayReturn; + isSubmitting: boolean; +} + +export const McpConfiguration = ({ form, serverFields, isSubmitting }: McpConfigurationProps) => { + const [mcpOpen, setMcpOpen] = useState(false); + const [newServerEnvInputs, setNewServerEnvInputs] = useState< + Record + >({}); + const { fields, append, remove, update } = serverFields; + + const handleAddServer = () => { + append(defaultMcpServer); + setNewServerEnvInputs((prev) => ({ ...prev, [fields.length]: { key: '', value: '' } })); + }; + + const handleAddServerEnvVar = (serverIndex: number) => { + const inputState = newServerEnvInputs[serverIndex]; + if (inputState && inputState.key.trim()) { + const fieldArrayName = `mcp.servers.${serverIndex}.env` as const; + const currentServerEnv = form.getValues(fieldArrayName) || []; + update(serverIndex, { + ...form.getValues(`mcp.servers.${serverIndex}`), + env: [...currentServerEnv, { key: inputState.key.trim(), value: inputState.value }], + }); + + setNewServerEnvInputs((prev) => ({ + ...prev, + [serverIndex]: { key: '', value: '' }, + })); + } + }; + + const handleServerEnvInputChange = ( + serverIndex: number, + field: 'key' | 'value', + value: string + ) => { + setNewServerEnvInputs((prev) => ({ + ...prev, + [serverIndex]: { + ...(prev[serverIndex] || { key: '', value: '' }), + [field]: value, + }, + })); + }; + + const handleRemoveServerEnvVar = (serverIndex: number, envIndex: number) => { + const fieldName = `mcp.servers.${serverIndex}.env` as const; + const currentEnvVars = form.getValues(fieldName) || []; + const newEnvVars = currentEnvVars.filter((_, idx) => idx !== envIndex); + update(serverIndex, { + ...form.getValues(`mcp.servers.${serverIndex}`), + env: newEnvVars, + }); + }; + + return ( +
+

MCP Configuration

+ + ( + +
+ Enable MCP +
+ + + +
+ )} + /> + + ( + +
+ Auto-Start MCP Servers +
+ + + +
+ )} + /> + + + + +
+ MCP Servers + {mcpOpen ? : } +
+ Add or remove MCP servers. +
+ +
+ {fields.map((serverField, serverIndex) => ( +
+ + + ( + + Server Name + + + + + + )} + /> + + ( + +
+ Enabled +
+ + + +
+ )} + /> + + ( + + Command + + + + + + )} + /> + + ( + + Arguments (comma-separated) + + + + + + )} + /> + + + Server Environment Variables +
+ {(form.getValues(`mcp.servers.${serverIndex}.env`) || []).map( + (_, envIndex) => ( +
+ + + +
+ ) + )} +
+
+ + handleServerEnvInputChange(serverIndex, 'key', e.target.value) + } + disabled={isSubmitting} + className="w-1/3" + /> + + handleServerEnvInputChange(serverIndex, 'value', e.target.value) + } + disabled={isSubmitting} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddServerEnvVar(serverIndex); + } + }} + /> + +
+ {form.formState.errors.mcp?.servers?.[serverIndex]?.env && ( + Error in server environment variables. + )} +
+
+ ))} +
+ + Configure external processes managed by MCP. + {form.formState.errors.mcp?.servers && ( + + {form.formState.errors.mcp.servers.message || + form.formState.errors.mcp.servers.root?.message} + + )} +
+
+
+
+ ); +}; diff --git a/src/components/settings/ToolsConfiguration.tsx b/src/components/settings/ToolsConfiguration.tsx new file mode 100644 index 0000000..4af3a71 --- /dev/null +++ b/src/components/settings/ToolsConfiguration.tsx @@ -0,0 +1,137 @@ +import { Button } from '@/components/ui/button'; +import { + FormDescription, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ChevronDown, ChevronRight, X } from 'lucide-react'; +import { useState } from 'react'; +import { type UseFieldArrayReturn, type UseFormReturn } from 'react-hook-form'; +import type { FormSchema } from '@/schemas/conversationSettings'; +import { ToolFormat } from '@/types/api'; + +interface ToolsConfigurationProps { + form: UseFormReturn; + toolFields: UseFieldArrayReturn; + isSubmitting: boolean; +} + +export const ToolsConfiguration = ({ form, toolFields, isSubmitting }: ToolsConfigurationProps) => { + const [toolsOpen, setToolsOpen] = useState(false); + const [newToolName, setNewToolName] = useState(''); + const { fields, append, remove } = toolFields; + + const handleAddTool = () => { + const trimmedName = newToolName.trim(); + if (trimmedName) { + append({ name: trimmedName }); + setNewToolName(''); + } + }; + + return ( +
+

Tools

+ + ( + + Tool Format + + + + )} + /> + + + + +
+ Enabled Tools + {toolsOpen ? ( + + ) : ( + + )} +
+ + List of tools that the agent can use. + +
+ +
+ {fields.map((field, index) => ( +
+ {field.name} + +
+ ))} +
+
+ setNewToolName(e.target.value)} + disabled={isSubmitting} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTool(); + } + }} + /> + +
+
+
+
+
+ ); +}; diff --git a/src/hooks/useConversationSettings.ts b/src/hooks/useConversationSettings.ts new file mode 100644 index 0000000..3852230 --- /dev/null +++ b/src/hooks/useConversationSettings.ts @@ -0,0 +1,188 @@ +import { useApi } from '@/contexts/ApiContext'; +import { conversations$, updateConversation } from '@/stores/conversations'; +import { useForm, useFieldArray } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { formSchema, type FormSchema } from '@/schemas/conversationSettings'; +import { toast } from 'sonner'; +import type { ChatConfig } from '@/types/api'; +import { useEffect } from 'react'; +import { ToolFormat } from '@/types/api'; + +export const useConversationSettings = (conversationId: string) => { + const api = useApi(); + const conversation$ = conversations$.get(conversationId); + const chatConfig = conversation$?.chatConfig.get(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + chat: { + model: '', + tools: [], + tool_format: ToolFormat.MARKDOWN, + stream: true, + interactive: false, + workspace: '', + env: [], + }, + mcp: { + enabled: false, + auto_start: false, + servers: [], + }, + }, + }); + + const toolFields = useFieldArray({ + control: form.control, + name: 'chat.tools', + }); + + const envFields = useFieldArray({ + control: form.control, + name: 'chat.env', + }); + + const serverFields = useFieldArray({ + control: form.control, + name: 'mcp.servers', + }); + + // Reset form when chatConfig loads or changes + useEffect(() => { + if (chatConfig) { + console.log('Resetting form with chatConfig:', chatConfig); + form.reset({ + chat: { + model: chatConfig.chat.model || '', + tools: chatConfig.chat.tools?.map((tool) => ({ name: tool })) || [], + tool_format: chatConfig.chat.tool_format || ToolFormat.MARKDOWN, + stream: chatConfig.chat.stream ?? true, + interactive: chatConfig.chat.interactive ?? false, + workspace: chatConfig.chat.workspace || '', + env: chatConfig.env + ? Object.entries(chatConfig.env).map(([key, value]) => ({ key, value })) + : [], + }, + mcp: { + enabled: chatConfig.mcp?.enabled ?? false, + auto_start: chatConfig.mcp?.auto_start ?? false, + servers: + chatConfig.mcp?.servers?.map((server) => ({ + name: server.name || '', + enabled: server.enabled ?? false, + command: server.command || '', + args: server.args?.join(', ') || '', + env: server.env + ? Object.entries(server.env).map(([key, value]) => ({ key, value })) + : [], + })) || [], + }, + }); + } + }, [chatConfig, form]); + + // Load the chat config if it's not already loaded + useEffect(() => { + if (!chatConfig) { + api.getChatConfig(conversationId).then((config) => { + updateConversation(conversationId, { chatConfig: config }); + }); + } + }, [api, chatConfig, conversationId]); + + const onSubmit = async (values: FormSchema) => { + const originalConfig = chatConfig; + if (!originalConfig) { + console.error('Original chatConfig not found, cannot submit.'); + toast.error('Cannot save settings: Original configuration missing.'); + return; + } + + // Capture original tools for comparison later + const originalTools = originalConfig.chat.tools; + + const toolsStringArray = values.chat.tools?.map((tool) => tool.name); + const newTools = toolsStringArray?.length ? toolsStringArray : null; + const newEnv = + values.chat.env?.reduce( + (acc, { key, value }) => { + if (key.trim()) acc[key.trim()] = value; + return acc; + }, + {} as Record + ) || {}; + + const newMcpServers = values.mcp?.servers?.map((server) => ({ + name: server.name, + enabled: server.enabled, + command: server.command, + args: server.args + .split(',') + .map((arg) => arg.trim()) + .filter(Boolean), + env: + server.env?.reduce( + (acc, { key, value }) => { + if (key.trim()) acc[key.trim()] = value; + return acc; + }, + {} as Record + ) || {}, + })); + + const newConfig: ChatConfig = { + ...originalConfig, + chat: { + ...originalConfig.chat, + model: values.chat.model || null, + tools: newTools, + tool_format: values.chat.tool_format || null, + stream: values.chat.stream, + interactive: values.chat.interactive, + workspace: values.chat.workspace, + }, + env: newEnv, + mcp: { + enabled: values.mcp.enabled, + auto_start: values.mcp.auto_start, + servers: newMcpServers || [], + }, + }; + + try { + await api.updateChatConfig(conversationId, newConfig); + + const toolsChanged = + JSON.stringify(originalTools?.slice().sort()) !== + JSON.stringify(newConfig.chat.tools?.slice().sort()); + const mcpChanged = + originalConfig.mcp?.enabled !== newConfig.mcp?.enabled || + originalConfig.mcp?.auto_start !== newConfig.mcp?.auto_start; + const mcpServersChanged = + JSON.stringify(originalConfig.mcp?.servers?.slice().sort()) !== + JSON.stringify(newConfig.mcp?.servers?.slice().sort()); + + if (toolsChanged || mcpChanged || mcpServersChanged) { + const conversationData = await api.getConversation(conversationId); + updateConversation(conversationId, { data: conversationData, chatConfig: newConfig }); + } else { + updateConversation(conversationId, { chatConfig: newConfig }); + } + toast.success('Settings updated successfully!'); + } catch (error) { + console.error('Failed to update chat config:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to update settings: ${errorMessage}`); + } + }; + + return { + form, + toolFields, + envFields, + serverFields, + onSubmit, + chatConfig, + }; +}; diff --git a/src/schemas/conversationSettings.ts b/src/schemas/conversationSettings.ts new file mode 100644 index 0000000..eeb19f9 --- /dev/null +++ b/src/schemas/conversationSettings.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import { ToolFormat } from '@/types/api'; + +export const mcpServerSchema = z.object({ + name: z.string().min(1, 'Server name cannot be empty'), + enabled: z.boolean(), + command: z.string().min(1, 'Command cannot be empty'), + args: z.string(), + env: z + .array( + z.object({ + key: z.string().min(1, 'Variable name cannot be empty'), + value: z.string(), + }) + ) + .optional(), +}); + +export const formSchema = z.object({ + chat: z.object({ + model: z.string().optional(), + tools: z.array(z.object({ name: z.string().min(1, 'Tool name cannot be empty') })).optional(), + tool_format: z.nativeEnum(ToolFormat).nullable().optional(), + stream: z.boolean(), + interactive: z.boolean(), + workspace: z.string().min(1, 'Workspace directory is required'), + env: z + .array( + z.object({ key: z.string().min(1, 'Variable name cannot be empty'), value: z.string() }) + ) + .optional(), + }), + mcp: z.object({ + enabled: z.boolean(), + auto_start: z.boolean(), + servers: z.array(mcpServerSchema).optional(), + }), +}); + +export type FormSchema = z.infer; + +export const defaultMcpServer: z.infer = { + name: '', + enabled: true, + command: '', + args: '', + env: [], +};