Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions src/renderer/components/settings/agent/AgentIntegrationModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { Button, Flex, Stack, Text, TextInput, Textarea, Switch, Select, Group, Badge, Alert, ActionIcon } from '@mantine/core'
import { IconRobot, IconPlus, IconTrash } from '@tabler/icons-react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AdaptiveModal } from '@/components/common/AdaptiveModal'
import { ScalableIcon } from '@/components/common/ScalableIcon'

interface AgentPreset {
id: string
name: string
description: string
endpoint: string
defaultModel?: string
supportsTools?: boolean
}

const BUILTIN_AGENT_PRESETS: AgentPreset[] = [
{
id: 'hermes',
name: 'Hermes Agent',
description: 'Open-source AI agent framework with tool calling support',
endpoint: 'http://localhost:8080/v1',
defaultModel: 'hermes',
supportsTools: true,
},
{
id: 'openclaw',
name: 'OpenClaw',
description: 'Open-source AI assistant with extensible tool ecosystem',
endpoint: 'http://localhost:3000/v1',
defaultModel: 'openclaw',
supportsTools: true,
},
{
id: 'openai-compatible',
name: 'Generic OpenAI-compatible',
description: 'Any service implementing the OpenAI API format',
endpoint: 'http://localhost:8080/v1',
supportsTools: true,
},
]

interface AgentIntegrationModalProps {
opened: boolean
onClose: () => void
onAddProvider: (config: {
name: string
apiKey: string
endpoint: string
modelId: string
modelName: string
systemPrompt?: string
headers?: Record<string, string>
supportsTools?: boolean
}) => void
}

export function AgentIntegrationModal({ opened, onClose, onAddProvider }: AgentIntegrationModalProps) {
const { t } = useTranslation()
const [selectedPreset, setSelectedPreset] = useState<string | null>(null)
const [agentName, setAgentName] = useState('')
const [endpoint, setEndpoint] = useState('')
const [apiKey, setApiKey] = useState('')
const [modelId, setModelId] = useState('')
const [systemPrompt, setSystemPrompt] = useState('')
const [customHeaders, setCustomHeaders] = useState<Array<{ key: string; value: string }>>([])
const [supportsTools, setSupportsTools] = useState(true)
const [error, setError] = useState<string | null>(null)

const handlePresetSelect = useCallback((presetId: string | null) => {
setSelectedPreset(presetId)
const preset = BUILTIN_AGENT_PRESETS.find(p => p.id === presetId)
if (preset) {
setAgentName(preset.name)
setEndpoint(preset.endpoint)
setModelId(preset.defaultModel || '')
setSupportsTools(preset.supportsTools ?? true)
}
}, [])

const handleAdd = useCallback(() => {
setError(null)
if (!agentName.trim()) { setError(t('Agent name is required')); return }
if (!endpoint.trim()) { setError(t('API endpoint is required')); return }
if (!modelId.trim()) { setError(t('Model ID is required')); return }
try { new URL(endpoint) } catch { setError(t('Invalid endpoint URL')); return }

const headers: Record<string, string> = {}
for (const h of customHeaders) {
if (h.key.trim() && h.value.trim()) headers[h.key.trim()] = h.value.trim()
}

onAddProvider({
name: agentName.trim(),
apiKey: apiKey.trim(),
endpoint: endpoint.trim(),
modelId: modelId.trim(),
modelName: agentName.trim(),
systemPrompt: systemPrompt.trim() || undefined,
headers: Object.keys(headers).length > 0 ? headers : undefined,
supportsTools,
})

setSelectedPreset(null); setAgentName(''); setEndpoint(''); setApiKey('')
setModelId(''); setSystemPrompt(''); setCustomHeaders([]); setSupportsTools(true)
onClose()
}, [agentName, endpoint, apiKey, modelId, systemPrompt, customHeaders, supportsTools, onAddProvider, onClose, t])

const handleClose = useCallback(() => {
setSelectedPreset(null); setAgentName(''); setEndpoint(''); setApiKey('')
setModelId(''); setSystemPrompt(''); setCustomHeaders([]); setSupportsTools(true)
setError(null); onClose()
}, [onClose])

return (
<AdaptiveModal opened={opened} onClose={handleClose} title={
<Flex align="center" gap="xs">
<ScalableIcon icon={IconRobot} size={20} />
<Text fw={600}>{t('Connect Agent')}</Text>
</Flex>
} centered size="lg">
<Stack gap="md">
<Text size="sm" c="chatbox-secondary">
{t('Connect a third-party AI agent that exposes an OpenAI-compatible API (e.g., Hermes, OpenClaw, or any compatible service).')}
</Text>

<Select
label={t('Quick Preset')}
placeholder={t('Choose a preset or configure manually')}
data={BUILTIN_AGENT_PRESETS.map(p => ({ value: p.id, label: `${p.name} — ${p.description}` }))}
value={selectedPreset}
onChange={handlePresetSelect}
clearable searchable
/>

<TextInput label={t('Agent Name')} placeholder={t('e.g. Hermes Agent, My Custom Agent')}
value={agentName} onChange={(e) => setAgentName(e.currentTarget.value)} required />

<TextInput label={t('API Endpoint')} placeholder="http://localhost:8080/v1"
value={endpoint} onChange={(e) => setEndpoint(e.currentTarget.value)} required
description={t("The base URL of the agent's OpenAI-compatible API")} />

<TextInput label={t('API Key')} placeholder={t('Optional — leave empty if not required')}
value={apiKey} onChange={(e) => setApiKey(e.currentTarget.value)} type="password" />

<TextInput label={t('Model ID')} placeholder={t('e.g. hermes, gpt-4, agent-model')}
value={modelId} onChange={(e) => setModelId(e.currentTarget.value)} required
description={t('The model identifier to use in API requests')} />

<Textarea label={t('System Prompt')} placeholder={t('Optional — default system prompt for this agent')}
value={systemPrompt} onChange={(e) => setSystemPrompt(e.currentTarget.value)} minRows={3} maxRows={8} />

<Switch label={t('Supports Tool Calling')}
description={t('Enable if this agent supports function/tool calling')}
checked={supportsTools} onChange={(e) => setSupportsTools(e.currentTarget.checked)} />

<Stack gap="xs">
<Flex justify="space-between" align="center">
<Text size="sm" fw={500}>{t('Custom Headers')}</Text>
<Button variant="subtle" size="compact-xs"
leftSection={<ScalableIcon icon={IconPlus} size={12} />}
onClick={() => setCustomHeaders(prev => [...prev, { key: '', value: '' }])}>
{t('Add')}
</Button>
</Flex>
{customHeaders.map((header, index) => (
<Flex key={index} gap="xs" align="center">
<TextInput placeholder="Header-Name" value={header.key}
onChange={(e) => setCustomHeaders(prev => prev.map((h, i) => i === index ? { ...h, key: e.currentTarget.value } : h))}
size="xs" style={{ flex: 1 }} />
<TextInput placeholder="value" value={header.value}
onChange={(e) => setCustomHeaders(prev => prev.map((h, i) => i === index ? { ...h, value: e.currentTarget.value } : h))}
size="xs" style={{ flex: 2 }} />
<ActionIcon variant="subtle" color="red" size="sm"
onClick={() => setCustomHeaders(prev => prev.filter((_, i) => i !== index))}>
<ScalableIcon icon={IconTrash} size={14} />
</ActionIcon>
</Flex>
))}
{customHeaders.length === 0 && (
<Text size="xs" c="chatbox-tertiary">{t('No custom headers configured')}</Text>
)}
</Stack>

{error && <Alert color="red" variant="light">{error}</Alert>}

<Group justify="flex-end">
<Button variant="default" onClick={handleClose}>{t('Cancel')}</Button>
<Button onClick={handleAdd} leftSection={<ScalableIcon icon={IconRobot} size={16} />}>
{t('Connect Agent')}
</Button>
</Group>
</Stack>
</AdaptiveModal>
)
}
133 changes: 133 additions & 0 deletions src/renderer/components/settings/agent/AgentList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Button, Flex, Stack, Text, Group, ActionIcon, Tooltip, Badge, Collapse } from '@mantine/core'
import { IconRobot, IconPlus, IconTrash, IconChevronDown, IconChevronRight, IconPlug } from '@tabler/icons-react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ScalableIcon } from '@/components/common/ScalableIcon'
import { AgentIntegrationModal } from './AgentIntegrationModal'

export interface ConnectedAgent {
id: string
name: string
endpoint: string
modelId: string
apiKey?: string
systemPrompt?: string
headers?: Record<string, string>
supportsTools?: boolean
enabled?: boolean
}

interface AgentListProps {
agents: ConnectedAgent[]
onAdd: (agent: ConnectedAgent) => void
onRemove: (id: string) => void
onToggle: (id: string, enabled: boolean) => void
}

export function AgentList({ agents, onAdd, onRemove, onToggle }: AgentListProps) {
const { t } = useTranslation()
const [showAddModal, setShowAddModal] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)

const handleAdd = useCallback((config: {
name: string; apiKey: string; endpoint: string; modelId: string
modelName: string; systemPrompt?: string; headers?: Record<string, string>; supportsTools?: boolean
}) => {
onAdd({
id: `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: config.name, endpoint: config.endpoint, modelId: config.modelId,
apiKey: config.apiKey || undefined, systemPrompt: config.systemPrompt,
headers: config.headers, supportsTools: config.supportsTools, enabled: true,
})
}, [onAdd])

return (
<Stack gap="md">
<Flex justify="space-between" align="center">
<Flex align="center" gap="xs">
<ScalableIcon icon={IconRobot} size={18} />
<Text fw={600}>{t('Connected Agents')}</Text>
{agents.length > 0 && <Badge size="sm" variant="light">{agents.length}</Badge>}
</Flex>
<Button variant="light" size="compact-sm"
leftSection={<ScalableIcon icon={IconPlus} size={14} />}
onClick={() => setShowAddModal(true)}>
{t('Connect Agent')}
</Button>
</Flex>

<Text size="sm" c="chatbox-secondary">
{t('Connect third-party AI agents (Hermes, OpenClaw, etc.) that expose an OpenAI-compatible API.')}
</Text>

{agents.length === 0 ? (
<Flex direction="column" align="center" justify="center" py="xl" gap="md"
style={{ border: '1px dashed var(--mantine-color-chatbox-border-primary)', borderRadius: 8 }}>
<ScalableIcon icon={IconPlug} size={32} color="var(--mantine-color-chatbox-tertiary)" />
<Text size="sm" c="chatbox-tertiary">{t('No agents connected yet')}</Text>
<Button variant="light" size="compact-sm"
leftSection={<ScalableIcon icon={IconPlus} size={14} />}
onClick={() => setShowAddModal(true)}>
{t('Connect Your First Agent')}
</Button>
</Flex>
) : (
<Stack gap="xs">
{agents.map((agent) => {
const isExpanded = expandedId === agent.id
return (
<Stack key={agent.id} gap={0}
style={{ border: '1px solid var(--mantine-color-chatbox-border-primary)', borderRadius: 8, overflow: 'hidden' }}>
<Flex justify="space-between" align="center" p="sm"
style={{ cursor: 'pointer' }} onClick={() => setExpandedId(isExpanded ? null : agent.id)}>
<Flex align="center" gap="sm">
<ScalableIcon icon={isExpanded ? IconChevronDown : IconChevronRight} size={16}
color="var(--mantine-color-chatbox-tertiary)" />
<ScalableIcon icon={IconRobot} size={18} />
<Stack gap={0}>
<Text size="sm" fw={500}>{agent.name}</Text>
<Text size="xs" c="chatbox-tertiary" style={{ fontFamily: 'monospace' }}>{agent.modelId}</Text>
</Stack>
</Flex>
<Flex align="center" gap="xs">
{agent.supportsTools && <Badge size="xs" variant="light" color="green">Tools</Badge>}
<Badge size="xs" variant="light" color={agent.enabled ? 'blue' : 'gray'}>
{agent.enabled ? t('Active') : t('Disabled')}
</Badge>
<Tooltip label={t('Remove')}>
<ActionIcon variant="subtle" color="red" size="sm"
onClick={(e) => { e.stopPropagation(); onRemove(agent.id) }}>
<ScalableIcon icon={IconTrash} size={14} />
</ActionIcon>
</Tooltip>
</Flex>
</Flex>
<Collapse in={isExpanded}>
<Stack gap="xs" px="sm" pb="sm"
style={{ borderTop: '1px solid var(--mantine-color-chatbox-border-secondary)' }}>
<Flex gap="md" pt="sm">
<Text size="xs" fw={500} w={80} c="chatbox-secondary">{t('Endpoint')}:</Text>
<Text size="xs" style={{ fontFamily: 'monospace' }}>{agent.endpoint}</Text>
</Flex>
<Flex gap="md">
<Text size="xs" fw={500} w={80} c="chatbox-secondary">{t('Model')}:</Text>
<Text size="xs" style={{ fontFamily: 'monospace' }}>{agent.modelId}</Text>
</Flex>
{agent.systemPrompt && (
<Flex gap="md">
<Text size="xs" fw={500} w={80} c="chatbox-secondary">{t('System Prompt')}:</Text>
<Text size="xs" lineClamp={3}>{agent.systemPrompt}</Text>
</Flex>
)}
</Stack>
</Collapse>
</Stack>
)
})}
</Stack>
)}

<AgentIntegrationModal opened={showAddModal} onClose={() => setShowAddModal(false)} onAddProvider={handleAdd} />
</Stack>
)
}
42 changes: 42 additions & 0 deletions src/renderer/components/settings/agent/AgentSettingsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Stack, Text, Divider } from '@mantine/core'
import { useTranslation } from 'react-i18next'
import { AgentList, type ConnectedAgent } from '@/components/settings/agent/AgentList'

interface AgentSettingsSectionProps {
agents: ConnectedAgent[]
onAgentsChange: (agents: ConnectedAgent[]) => void
}

export function AgentSettingsSection({ agents, onAgentsChange }: AgentSettingsSectionProps) {
const { t } = useTranslation()

const handleAdd = (agent: ConnectedAgent) => onAgentsChange([...agents, agent])
const handleRemove = (id: string) => onAgentsChange(agents.filter(a => a.id !== id))
const handleToggle = (id: string, enabled: boolean) => onAgentsChange(agents.map(a => a.id === id ? { ...a, enabled } : a))

return (
<Stack gap="lg">
<Stack gap="xs">
<Text size="lg" fw={600}>{t('Agent Integration')}</Text>
<Text size="sm" c="chatbox-secondary">
{t('Connect third-party AI agents and assistants that expose an OpenAI-compatible API. Supports Hermes, OpenClaw, LangChain agents, and any other compatible service.')}
</Text>
</Stack>
<AgentList agents={agents} onAdd={handleAdd} onRemove={handleRemove} onToggle={handleToggle} />
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>{t('Supported Agent Frameworks')}</Text>
<Text size="xs" c="chatbox-tertiary">
{t('Any agent that exposes an OpenAI-compatible /v1/chat/completions endpoint can be connected. This includes:')}
</Text>
<Stack gap={2} pl="md">
<Text size="xs" c="chatbox-secondary">• Hermes — Tool-calling agent framework</Text>
<Text size="xs" c="chatbox-secondary">• OpenClaw — Open-source AI assistant</Text>
<Text size="xs" c="chatbox-secondary">• LangChain agents — Via LangServe or custom endpoints</Text>
<Text size="xs" c="chatbox-secondary">• AutoGPT / AgentGPT — With API adapter</Text>
<Text size="xs" c="chatbox-secondary">• Any custom agent with OpenAI-compatible API</Text>
</Stack>
</Stack>
</Stack>
)
}
Loading