diff --git a/electron/api/routes/agents.ts b/electron/api/routes/agents.ts new file mode 100644 index 000000000..0444e2d1e --- /dev/null +++ b/electron/api/routes/agents.ts @@ -0,0 +1,107 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { + assignChannelToAgent, + clearChannelBinding, + createAgent, + deleteAgentConfig, + listAgentsSnapshot, + updateAgentName, +} from '../../utils/agent-config'; +import { deleteChannelConfig } from '../../utils/channel-config'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +function scheduleGatewayReload(ctx: HostApiContext, reason: string): void { + if (ctx.gatewayManager.getStatus().state !== 'stopped') { + ctx.gatewayManager.debouncedReload(); + return; + } + void reason; +} + +export async function handleAgentRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/agents' && req.method === 'GET') { + sendJson(res, 200, { success: true, ...(await listAgentsSnapshot()) }); + return true; + } + + if (url.pathname === '/api/agents' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ name: string }>(req); + const snapshot = await createAgent(body.name); + scheduleGatewayReload(ctx, 'create-agent'); + sendJson(res, 200, { success: true, ...snapshot }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/agents/') && req.method === 'PUT') { + const suffix = url.pathname.slice('/api/agents/'.length); + const parts = suffix.split('/').filter(Boolean); + + if (parts.length === 1) { + try { + const body = await parseJsonBody<{ name: string }>(req); + const agentId = decodeURIComponent(parts[0]); + const snapshot = await updateAgentName(agentId, body.name); + scheduleGatewayReload(ctx, 'update-agent'); + sendJson(res, 200, { success: true, ...snapshot }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (parts.length === 3 && parts[1] === 'channels') { + try { + const agentId = decodeURIComponent(parts[0]); + const channelType = decodeURIComponent(parts[2]); + const snapshot = await assignChannelToAgent(agentId, channelType); + scheduleGatewayReload(ctx, 'assign-channel'); + sendJson(res, 200, { success: true, ...snapshot }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + } + + if (url.pathname.startsWith('/api/agents/') && req.method === 'DELETE') { + const suffix = url.pathname.slice('/api/agents/'.length); + const parts = suffix.split('/').filter(Boolean); + + if (parts.length === 1) { + try { + const agentId = decodeURIComponent(parts[0]); + const snapshot = await deleteAgentConfig(agentId); + scheduleGatewayReload(ctx, 'delete-agent'); + sendJson(res, 200, { success: true, ...snapshot }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (parts.length === 3 && parts[1] === 'channels') { + try { + const channelType = decodeURIComponent(parts[2]); + await deleteChannelConfig(channelType); + const snapshot = await clearChannelBinding(channelType); + scheduleGatewayReload(ctx, 'remove-agent-channel'); + sendJson(res, 200, { success: true, ...snapshot }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + } + + return false; +} diff --git a/electron/api/server.ts b/electron/api/server.ts index 5fe1f3564..64523d086 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -6,6 +6,7 @@ import { handleAppRoutes } from './routes/app'; import { handleGatewayRoutes } from './routes/gateway'; import { handleSettingsRoutes } from './routes/settings'; import { handleProviderRoutes } from './routes/providers'; +import { handleAgentRoutes } from './routes/agents'; import { handleChannelRoutes } from './routes/channels'; import { handleLogRoutes } from './routes/logs'; import { handleUsageRoutes } from './routes/usage'; @@ -27,6 +28,7 @@ const routeHandlers: RouteHandler[] = [ handleGatewayRoutes, handleSettingsRoutes, handleProviderRoutes, + handleAgentRoutes, handleChannelRoutes, handleSkillRoutes, handleFileRoutes, diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 947833497..986e941be 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -51,6 +51,7 @@ import { } from '../services/providers/provider-runtime-sync'; import { validateApiKeyWithProvider } from '../services/providers/provider-validation'; import { appUpdater } from './updater'; +import { PORTS } from '../utils/config'; type AppRequest = { id?: string; @@ -141,8 +142,66 @@ export function registerIpcHandlers( registerFileHandlers(); } +type HostApiFetchRequest = { + path: string; + method?: string; + headers?: Record; + body?: unknown; +}; + function registerHostApiProxyHandlers(): void { - // Host API proxy handlers - currently disabled + ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => { + try { + const path = typeof request?.path === 'string' ? request.path : ''; + if (!path || !path.startsWith('/')) { + throw new Error(`Invalid host API path: ${String(request?.path)}`); + } + + const method = (request.method || 'GET').toUpperCase(); + const headers: Record = { ...(request.headers || {}) }; + let body: string | undefined; + + if (request.body !== undefined && request.body !== null) { + if (typeof request.body === 'string') { + body = request.body; + } else { + body = JSON.stringify(request.body); + if (!headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json'; + } + } + } + + const response = await proxyAwareFetch(`http://127.0.0.1:${PORTS.CLAWX_HOST_API}${path}`, { + method, + headers, + body, + }); + + const data: { status: number; ok: boolean; json?: unknown; text?: string } = { + status: response.status, + ok: response.ok, + }; + + if (response.status !== 204) { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + data.json = await response.json().catch(() => undefined); + } else { + data.text = await response.text().catch(() => ''); + } + } + + return { ok: true, data }; + } catch (error) { + return { + ok: false, + error: { + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }); } function mapAppErrorCode(error: unknown): AppErrorCode { diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts new file mode 100644 index 000000000..29a05515b --- /dev/null +++ b/electron/utils/agent-config.ts @@ -0,0 +1,452 @@ +import { access, copyFile, mkdir, readdir } from 'fs/promises'; +import { constants } from 'fs'; +import { join } from 'path'; +import { listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config'; +import { expandPath, getOpenClawConfigDir } from './paths'; +import * as logger from './logger'; + +const MAIN_AGENT_ID = 'main'; +const MAIN_AGENT_NAME = 'Main'; +const DEFAULT_WORKSPACE_PATH = '~/.openclaw/workspace'; +const AGENT_BOOTSTRAP_FILES = [ + 'AGENTS.md', + 'SOUL.md', + 'TOOLS.md', + 'USER.md', + 'IDENTITY.md', + 'HEARTBEAT.md', + 'BOOT.md', +]; +const AGENT_RUNTIME_FILES = [ + 'auth-profiles.json', + 'models.json', +]; + +interface AgentModelConfig { + primary?: string; + [key: string]: unknown; +} + +interface AgentDefaultsConfig { + workspace?: string; + model?: string | AgentModelConfig; + [key: string]: unknown; +} + +interface AgentListEntry extends Record { + id: string; + name?: string; + default?: boolean; + workspace?: string; + agentDir?: string; + model?: string | AgentModelConfig; +} + +interface AgentsConfig extends Record { + defaults?: AgentDefaultsConfig; + list?: AgentListEntry[]; +} + +interface BindingMatch extends Record { + channel?: string; +} + +interface BindingConfig extends Record { + agentId?: string; + match?: BindingMatch; +} + +interface AgentConfigDocument extends Record { + agents?: AgentsConfig; + bindings?: BindingConfig[]; +} + +export interface AgentSummary { + id: string; + name: string; + isDefault: boolean; + modelDisplay: string; + inheritedModel: boolean; + workspace: string; + agentDir: string; + channelTypes: string[]; +} + +export interface AgentsSnapshot { + agents: AgentSummary[]; + defaultAgentId: string; + configuredChannelTypes: string[]; + channelOwners: Record; +} + +function formatModelLabel(model: unknown): string | null { + if (typeof model === 'string' && model.trim()) { + const trimmed = model.trim(); + const parts = trimmed.split('/'); + return parts[parts.length - 1] || trimmed; + } + + if (model && typeof model === 'object') { + const primary = (model as AgentModelConfig).primary; + if (typeof primary === 'string' && primary.trim()) { + const parts = primary.trim().split('/'); + return parts[parts.length - 1] || primary.trim(); + } + } + + return null; +} + +function normalizeAgentName(name: string): string { + return name.trim() || 'Agent'; +} + +function slugifyAgentId(name: string): string { + const normalized = name + .normalize('NFKD') + .replace(/[^\w\s-]/g, '') + .toLowerCase() + .replace(/[_\s]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + if (!normalized) return 'agent'; + if (normalized === MAIN_AGENT_ID) return 'agent'; + return normalized; +} + +async function fileExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +async function ensureDir(path: string): Promise { + if (!(await fileExists(path))) { + await mkdir(path, { recursive: true }); + } +} + +function getDefaultWorkspacePath(config: AgentConfigDocument): string { + const defaults = (config.agents && typeof config.agents === 'object' + ? (config.agents as AgentsConfig).defaults + : undefined); + return typeof defaults?.workspace === 'string' && defaults.workspace.trim() + ? defaults.workspace + : DEFAULT_WORKSPACE_PATH; +} + +function getDefaultAgentDirPath(agentId: string): string { + return `~/.openclaw/agents/${agentId}/agent`; +} + +function createImplicitMainEntry(config: AgentConfigDocument): AgentListEntry { + return { + id: MAIN_AGENT_ID, + name: MAIN_AGENT_NAME, + default: true, + workspace: getDefaultWorkspacePath(config), + agentDir: getDefaultAgentDirPath(MAIN_AGENT_ID), + }; +} + +function normalizeAgentsConfig(config: AgentConfigDocument): { + agentsConfig: AgentsConfig; + entries: AgentListEntry[]; + defaultAgentId: string; + syntheticMain: boolean; +} { + const agentsConfig = (config.agents && typeof config.agents === 'object' + ? { ...(config.agents as AgentsConfig) } + : {}) as AgentsConfig; + const rawEntries = Array.isArray(agentsConfig.list) + ? agentsConfig.list.filter((entry): entry is AgentListEntry => ( + Boolean(entry) && typeof entry === 'object' && typeof entry.id === 'string' && entry.id.trim().length > 0 + )) + : []; + + if (rawEntries.length === 0) { + const main = createImplicitMainEntry(config); + return { + agentsConfig, + entries: [main], + defaultAgentId: MAIN_AGENT_ID, + syntheticMain: true, + }; + } + + const defaultEntry = rawEntries.find((entry) => entry.default) ?? rawEntries[0]; + return { + agentsConfig, + entries: rawEntries.map((entry) => ({ ...entry })), + defaultAgentId: defaultEntry.id, + syntheticMain: false, + }; +} + +function isSimpleChannelBinding(binding: unknown): binding is BindingConfig { + if (!binding || typeof binding !== 'object') return false; + const candidate = binding as BindingConfig; + if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false; + if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) return false; + const keys = Object.keys(candidate.match); + return keys.length === 1 && typeof candidate.match.channel === 'string' && Boolean(candidate.match.channel); +} + +/** Normalize agent ID for consistent comparison (bindings vs entries). */ +function normalizeAgentIdForBinding(id: string): string { + return (id ?? '').trim().toLowerCase() || ''; +} + +function getSimpleChannelBindingMap(bindings: unknown): Map { + const owners = new Map(); + if (!Array.isArray(bindings)) return owners; + + for (const binding of bindings) { + if (!isSimpleChannelBinding(binding)) continue; + const agentId = normalizeAgentIdForBinding(binding.agentId!); + if (agentId) owners.set(binding.match.channel!, agentId); + } + + return owners; +} + +function upsertBindingsForChannel( + bindings: unknown, + channelType: string, + agentId: string | null, +): BindingConfig[] | undefined { + const nextBindings = Array.isArray(bindings) + ? [...bindings as BindingConfig[]].filter((binding) => !( + isSimpleChannelBinding(binding) && binding.match.channel === channelType + )) + : []; + + if (agentId) { + nextBindings.push({ + agentId, + match: { channel: channelType }, + }); + } + + return nextBindings.length > 0 ? nextBindings : undefined; +} + +async function listExistingAgentIdsOnDisk(): Promise> { + const ids = new Set(); + const agentsDir = join(getOpenClawConfigDir(), 'agents'); + + try { + if (!(await fileExists(agentsDir))) return ids; + const entries = await readdir(agentsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) ids.add(entry.name); + } + } catch { + // ignore discovery failures + } + + return ids; +} + +async function copyBootstrapFiles(sourceWorkspace: string, targetWorkspace: string): Promise { + await ensureDir(targetWorkspace); + + for (const fileName of AGENT_BOOTSTRAP_FILES) { + const source = join(sourceWorkspace, fileName); + const target = join(targetWorkspace, fileName); + if (!(await fileExists(source)) || (await fileExists(target))) continue; + await copyFile(source, target); + } +} + +async function copyRuntimeFiles(sourceAgentDir: string, targetAgentDir: string): Promise { + await ensureDir(targetAgentDir); + + for (const fileName of AGENT_RUNTIME_FILES) { + const source = join(sourceAgentDir, fileName); + const target = join(targetAgentDir, fileName); + if (!(await fileExists(source)) || (await fileExists(target))) continue; + await copyFile(source, target); + } +} + +async function provisionAgentFilesystem(config: AgentConfigDocument, agent: AgentListEntry): Promise { + const { entries } = normalizeAgentsConfig(config); + const mainEntry = entries.find((entry) => entry.id === MAIN_AGENT_ID) ?? createImplicitMainEntry(config); + const sourceWorkspace = expandPath(mainEntry.workspace || getDefaultWorkspacePath(config)); + const targetWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`); + const sourceAgentDir = expandPath(mainEntry.agentDir || getDefaultAgentDirPath(MAIN_AGENT_ID)); + const targetAgentDir = expandPath(agent.agentDir || getDefaultAgentDirPath(agent.id)); + const targetSessionsDir = join(getOpenClawConfigDir(), 'agents', agent.id, 'sessions'); + + await ensureDir(targetWorkspace); + await ensureDir(targetAgentDir); + await ensureDir(targetSessionsDir); + + if (targetWorkspace !== sourceWorkspace) { + await copyBootstrapFiles(sourceWorkspace, targetWorkspace); + } + if (targetAgentDir !== sourceAgentDir) { + await copyRuntimeFiles(sourceAgentDir, targetAgentDir); + } +} + +async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise { + const { entries, defaultAgentId } = normalizeAgentsConfig(config); + const configuredChannels = await listConfiguredChannels(); + const explicitOwners = getSimpleChannelBindingMap(config.bindings); + const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId); + const channelOwners: Record = {}; + + for (const channelType of configuredChannels) { + channelOwners[channelType] = explicitOwners.get(channelType) || defaultAgentIdNorm; + } + + const defaultModelLabel = formatModelLabel((config.agents as AgentsConfig | undefined)?.defaults?.model); + const agents: AgentSummary[] = entries.map((entry) => { + const modelLabel = formatModelLabel(entry.model) || defaultModelLabel || 'Not configured'; + const inheritedModel = !formatModelLabel(entry.model) && Boolean(defaultModelLabel); + const entryIdNorm = normalizeAgentIdForBinding(entry.id); + return { + id: entry.id, + name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id), + isDefault: entry.id === defaultAgentId, + modelDisplay: modelLabel, + inheritedModel, + workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`), + agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id), + channelTypes: configuredChannels.filter((channelType) => channelOwners[channelType] === entryIdNorm), + }; + }); + + return { + agents, + defaultAgentId, + configuredChannelTypes: configuredChannels, + channelOwners, + }; +} + +export async function listAgentsSnapshot(): Promise { + const config = await readOpenClawConfig() as AgentConfigDocument; + return buildSnapshotFromConfig(config); +} + +export async function createAgent(name: string): Promise { + const config = await readOpenClawConfig() as AgentConfigDocument; + const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config); + const normalizedName = normalizeAgentName(name); + const existingIds = new Set(entries.map((entry) => entry.id)); + const diskIds = await listExistingAgentIdsOnDisk(); + let nextId = slugifyAgentId(normalizedName); + let suffix = 2; + + while (existingIds.has(nextId) || diskIds.has(nextId)) { + nextId = `${slugifyAgentId(normalizedName)}-${suffix}`; + suffix += 1; + } + + const nextEntries = syntheticMain ? [createImplicitMainEntry(config), ...entries.filter((entry, index) => index > 0)] : [...entries]; + const newAgent: AgentListEntry = { + id: nextId, + name: normalizedName, + workspace: `~/.openclaw/workspace-${nextId}`, + agentDir: getDefaultAgentDirPath(nextId), + }; + + if (!nextEntries.some((entry) => entry.id === MAIN_AGENT_ID) && syntheticMain) { + nextEntries.unshift(createImplicitMainEntry(config)); + } + nextEntries.push(newAgent); + + config.agents = { + ...agentsConfig, + list: nextEntries, + }; + + await provisionAgentFilesystem(config, newAgent); + await writeOpenClawConfig(config); + logger.info('Created agent config entry', { agentId: nextId }); + return buildSnapshotFromConfig(config); +} + +export async function updateAgentName(agentId: string, name: string): Promise { + const config = await readOpenClawConfig() as AgentConfigDocument; + const { agentsConfig, entries } = normalizeAgentsConfig(config); + const normalizedName = normalizeAgentName(name); + const index = entries.findIndex((entry) => entry.id === agentId); + if (index === -1) { + throw new Error(`Agent "${agentId}" not found`); + } + + entries[index] = { + ...entries[index], + name: normalizedName, + }; + + config.agents = { + ...agentsConfig, + list: entries, + }; + + await writeOpenClawConfig(config); + logger.info('Updated agent name', { agentId, name: normalizedName }); + return buildSnapshotFromConfig(config); +} + +export async function deleteAgentConfig(agentId: string): Promise { + if (agentId === MAIN_AGENT_ID) { + throw new Error('The main agent cannot be deleted'); + } + + const config = await readOpenClawConfig() as AgentConfigDocument; + const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config); + const nextEntries = entries.filter((entry) => entry.id !== agentId); + if (nextEntries.length === entries.length) { + throw new Error(`Agent "${agentId}" not found`); + } + + config.agents = { + ...agentsConfig, + list: nextEntries, + }; + config.bindings = Array.isArray(config.bindings) + ? config.bindings.filter((binding) => !(isSimpleChannelBinding(binding) && binding.agentId === agentId)) + : undefined; + + if (defaultAgentId === agentId && nextEntries.length > 0) { + nextEntries[0] = { + ...nextEntries[0], + default: true, + }; + } + + await writeOpenClawConfig(config); + logger.info('Deleted agent config entry', { agentId }); + return buildSnapshotFromConfig(config); +} + +export async function assignChannelToAgent(agentId: string, channelType: string): Promise { + const config = await readOpenClawConfig() as AgentConfigDocument; + const { entries } = normalizeAgentsConfig(config); + if (!entries.some((entry) => entry.id === agentId)) { + throw new Error(`Agent "${agentId}" not found`); + } + + config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId); + await writeOpenClawConfig(config); + logger.info('Assigned channel to agent', { agentId, channelType }); + return buildSnapshotFromConfig(config); +} + +export async function clearChannelBinding(channelType: string): Promise { + const config = await readOpenClawConfig() as AgentConfigDocument; + config.bindings = upsertBindingsForChannel(config.bindings, channelType, null); + await writeOpenClawConfig(config); + logger.info('Cleared simplified channel binding', { channelType }); + return buildSnapshotFromConfig(config); +} diff --git a/src/App.tsx b/src/App.tsx index a7b47ea19..267a1ad08 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { MainLayout } from './components/layout/MainLayout'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Models } from './pages/Models'; import { Chat } from './pages/Chat'; +import { Agents } from './pages/Agents'; import { Channels } from './pages/Channels'; import { Skills } from './pages/Skills'; import { Cron } from './pages/Cron'; @@ -165,6 +166,7 @@ function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/channels/ChannelConfigModal.tsx b/src/components/channels/ChannelConfigModal.tsx new file mode 100644 index 000000000..1add4262e --- /dev/null +++ b/src/components/channels/ChannelConfigModal.tsx @@ -0,0 +1,720 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + X, + Loader2, + QrCode, + ExternalLink, + BookOpen, + Eye, + EyeOff, + Check, + AlertCircle, + CheckCircle, + ShieldCheck, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Badge } from '@/components/ui/badge'; +import { useChannelsStore } from '@/stores/channels'; +import { useGatewayStore } from '@/stores/gateway'; +import { hostApiFetch } from '@/lib/host-api'; +import { subscribeHostEvent } from '@/lib/host-events'; +import { cn } from '@/lib/utils'; +import { + CHANNEL_ICONS, + CHANNEL_NAMES, + CHANNEL_META, + getPrimaryChannels, + type ChannelType, + type ChannelMeta, + type ChannelConfigField, +} from '@/types/channel'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import telegramIcon from '@/assets/channels/telegram.svg'; +import discordIcon from '@/assets/channels/discord.svg'; +import whatsappIcon from '@/assets/channels/whatsapp.svg'; +import dingtalkIcon from '@/assets/channels/dingtalk.svg'; +import feishuIcon from '@/assets/channels/feishu.svg'; +import wecomIcon from '@/assets/channels/wecom.svg'; +import qqIcon from '@/assets/channels/qq.svg'; + +interface ChannelConfigModalProps { + initialSelectedType?: ChannelType | null; + configuredTypes?: string[]; + showChannelName?: boolean; + allowExistingConfig?: boolean; + onClose: () => void; + onChannelSaved?: (channelType: ChannelType) => void | Promise; +} + +const inputClasses = 'h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-[#151514] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40'; +const labelClasses = 'text-[14px] text-foreground/80 font-bold'; +const outlineButtonClasses = 'h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground'; +const primaryButtonClasses = 'h-9 text-[13px] font-medium rounded-full px-4 shadow-none'; + +export function ChannelConfigModal({ + initialSelectedType = null, + configuredTypes = [], + showChannelName = true, + allowExistingConfig = true, + onClose, + onChannelSaved, +}: ChannelConfigModalProps) { + const { t } = useTranslation('channels'); + const { channels, addChannel, fetchChannels } = useChannelsStore(); + const [selectedType, setSelectedType] = useState(initialSelectedType); + const [configValues, setConfigValues] = useState>({}); + const [channelName, setChannelName] = useState(''); + const [connecting, setConnecting] = useState(false); + const [showSecrets, setShowSecrets] = useState>({}); + const [qrCode, setQrCode] = useState(null); + const [validating, setValidating] = useState(false); + const [loadingConfig, setLoadingConfig] = useState(false); + const [isExistingConfig, setIsExistingConfig] = useState(false); + const firstInputRef = useRef(null); + const [validationResult, setValidationResult] = useState<{ + valid: boolean; + errors: string[]; + warnings: string[]; + } | null>(null); + + const meta: ChannelMeta | null = selectedType ? CHANNEL_META[selectedType] : null; + + useEffect(() => { + setSelectedType(initialSelectedType); + }, [initialSelectedType]); + + useEffect(() => { + if (!selectedType) { + setConfigValues({}); + setChannelName(''); + setIsExistingConfig(false); + setValidationResult(null); + setQrCode(null); + setConnecting(false); + hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => {}); + return; + } + + const shouldLoadExistingConfig = allowExistingConfig && configuredTypes.includes(selectedType); + if (!shouldLoadExistingConfig) { + setConfigValues({}); + setIsExistingConfig(false); + setLoadingConfig(false); + setChannelName(showChannelName ? CHANNEL_NAMES[selectedType] : ''); + return; + } + + let cancelled = false; + setLoadingConfig(true); + setChannelName(showChannelName ? CHANNEL_NAMES[selectedType] : ''); + + (async () => { + try { + const result = await hostApiFetch<{ success: boolean; values?: Record }>( + `/api/channels/config/${encodeURIComponent(selectedType)}` + ); + if (cancelled) return; + + if (result.success && result.values && Object.keys(result.values).length > 0) { + setConfigValues(result.values); + setIsExistingConfig(true); + } else { + setConfigValues({}); + setIsExistingConfig(false); + } + } catch { + if (!cancelled) { + setConfigValues({}); + setIsExistingConfig(false); + } + } finally { + if (!cancelled) setLoadingConfig(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [allowExistingConfig, configuredTypes, selectedType, showChannelName]); + + useEffect(() => { + if (selectedType && !loadingConfig && showChannelName && firstInputRef.current) { + firstInputRef.current.focus(); + } + }, [selectedType, loadingConfig, showChannelName]); + + const finishSave = useCallback(async (channelType: ChannelType) => { + const displayName = showChannelName && channelName.trim() + ? channelName.trim() + : CHANNEL_NAMES[channelType]; + const existingChannel = channels.find((channel) => channel.type === channelType); + + if (!existingChannel) { + await addChannel({ + type: channelType, + name: displayName, + token: meta?.configFields[0]?.key ? configValues[meta.configFields[0].key] : undefined, + }); + } else { + await fetchChannels(); + } + + await onChannelSaved?.(channelType); + }, [addChannel, channelName, channels, configValues, fetchChannels, meta?.configFields, onChannelSaved, showChannelName]); + + useEffect(() => { + if (selectedType !== 'whatsapp') return; + + const onQr = (...args: unknown[]) => { + const data = args[0] as { qr: string; raw: string }; + void data.raw; + setQrCode(`data:image/png;base64,${data.qr}`); + }; + + const onSuccess = async (...args: unknown[]) => { + const data = args[0] as { accountId?: string } | undefined; + void data?.accountId; + toast.success(t('toast.whatsappConnected')); + try { + const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', { + method: 'POST', + body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true } }), + }); + if (!saveResult?.success) { + throw new Error(saveResult?.error || 'Failed to save WhatsApp config'); + } + + await finishSave('whatsapp'); + useGatewayStore.getState().restart().catch(console.error); + onClose(); + } catch (error) { + toast.error(t('toast.configFailed', { error: String(error) })); + setConnecting(false); + } + }; + + const onError = (...args: unknown[]) => { + const err = args[0] as string; + toast.error(t('toast.whatsappFailed', { error: err })); + setQrCode(null); + setConnecting(false); + }; + + const removeQrListener = subscribeHostEvent('channel:whatsapp-qr', onQr); + const removeSuccessListener = subscribeHostEvent('channel:whatsapp-success', onSuccess); + const removeErrorListener = subscribeHostEvent('channel:whatsapp-error', onError); + + return () => { + removeQrListener(); + removeSuccessListener(); + removeErrorListener(); + hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => {}); + }; + }, [selectedType, finishSave, onClose, t]); + + const handleValidate = async () => { + if (!selectedType) return; + + setValidating(true); + setValidationResult(null); + + try { + const result = await hostApiFetch<{ + success: boolean; + valid?: boolean; + errors?: string[]; + warnings?: string[]; + details?: Record; + }>('/api/channels/credentials/validate', { + method: 'POST', + body: JSON.stringify({ channelType: selectedType, config: configValues }), + }); + + const warnings = result.warnings || []; + if (result.valid && result.details) { + const details = result.details; + if (details.botUsername) warnings.push(`Bot: @${details.botUsername}`); + if (details.guildName) warnings.push(`Server: ${details.guildName}`); + if (details.channelName) warnings.push(`Channel: #${details.channelName}`); + } + + setValidationResult({ + valid: result.valid || false, + errors: result.errors || [], + warnings, + }); + } catch (error) { + setValidationResult({ + valid: false, + errors: [String(error)], + warnings: [], + }); + } finally { + setValidating(false); + } + }; + + const handleConnect = async () => { + if (!selectedType || !meta) return; + + setConnecting(true); + setValidationResult(null); + + try { + if (meta.connectionType === 'qr') { + await hostApiFetch('/api/channels/whatsapp/start', { + method: 'POST', + body: JSON.stringify({ accountId: 'default' }), + }); + return; + } + + if (meta.connectionType === 'token') { + const validationResponse = await hostApiFetch<{ + success: boolean; + valid?: boolean; + errors?: string[]; + warnings?: string[]; + details?: Record; + }>('/api/channels/credentials/validate', { + method: 'POST', + body: JSON.stringify({ channelType: selectedType, config: configValues }), + }); + + if (!validationResponse.valid) { + setValidationResult({ + valid: false, + errors: validationResponse.errors || ['Validation failed'], + warnings: validationResponse.warnings || [], + }); + setConnecting(false); + return; + } + + const warnings = validationResponse.warnings || []; + if (validationResponse.details) { + const details = validationResponse.details; + if (details.botUsername) warnings.push(`Bot: @${details.botUsername}`); + if (details.guildName) warnings.push(`Server: ${details.guildName}`); + if (details.channelName) warnings.push(`Channel: #${details.channelName}`); + } + + setValidationResult({ + valid: true, + errors: [], + warnings, + }); + } + + const config: Record = { ...configValues }; + const saveResult = await hostApiFetch<{ + success?: boolean; + error?: string; + warning?: string; + }>('/api/channels/config', { + method: 'POST', + body: JSON.stringify({ channelType: selectedType, config }), + }); + if (!saveResult?.success) { + throw new Error(saveResult?.error || 'Failed to save channel config'); + } + if (typeof saveResult.warning === 'string' && saveResult.warning) { + toast.warning(saveResult.warning); + } + + await finishSave(selectedType); + + toast.success(t('toast.channelSaved', { name: meta.name })); + toast.success(t('toast.channelConnecting', { name: meta.name })); + await new Promise((resolve) => setTimeout(resolve, 800)); + onClose(); + } catch (error) { + toast.error(t('toast.configFailed', { error: String(error) })); + setConnecting(false); + } + }; + + const openDocs = () => { + if (!meta?.docsUrl) return; + const url = t(meta.docsUrl); + try { + if (window.electron?.openExternal) { + window.electron.openExternal(url); + } else { + window.open(url, '_blank'); + } + } catch { + window.open(url, '_blank'); + } + }; + + const isFormValid = () => { + if (!meta) return false; + return meta.configFields + .filter((field) => field.required) + .every((field) => configValues[field.key]?.trim()); + }; + + const updateConfigValue = (key: string, value: string) => { + setConfigValues((prev) => ({ ...prev, [key]: value })); + }; + + const toggleSecretVisibility = (key: string) => { + setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + return ( +
+ event.stopPropagation()} + > + +
+ + {selectedType + ? isExistingConfig + ? t('dialog.updateTitle', { name: CHANNEL_NAMES[selectedType] }) + : t('dialog.configureTitle', { name: CHANNEL_NAMES[selectedType] }) + : t('dialog.addTitle')} + + + {selectedType && isExistingConfig + ? t('dialog.existingDesc') + : meta ? t(meta.description.replace('channels:', '')) : t('dialog.selectDesc')} + +
+ +
+ + {!selectedType ? ( +
+ {getPrimaryChannels().map((type) => { + const channelMeta = CHANNEL_META[type]; + const isConfigured = configuredTypes.includes(type); + return ( + + ); + })} +
+ ) : qrCode ? ( +
+
+ {qrCode.startsWith('data:image') ? ( + Scan QR Code + ) : ( +
+ +
+ )} +
+

+ {t('dialog.scanQR', { name: meta?.name })} +

+
+ +
+
+ ) : loadingConfig ? ( +
+ + {t('dialog.loadingConfig')} +
+ ) : ( +
+ {isExistingConfig && ( +
+ + {t('dialog.existingHint')} +
+ )} + +
+
+
+

{t('dialog.howToConnect')}

+

+ {meta ? t(meta.description.replace('channels:', '')) : ''} +

+
+ +
+
    + {meta?.instructions.map((instruction, index) => ( +
  1. {t(instruction)}
  2. + ))} +
+
+ + {showChannelName && ( +
+ + setChannelName(event.target.value)} + className={inputClasses} + /> +
+ )} + +
+ {meta?.configFields.map((field) => ( + updateConfigValue(field.key, value)} + showSecret={showSecrets[field.key] || false} + onToggleSecret={() => toggleSecretVisibility(field.key)} + /> + ))} +
+ + {validationResult && ( +
+
+ {validationResult.valid ? ( + + ) : ( + + )} +
+

+ {validationResult.valid ? t('dialog.credentialsVerified') : t('dialog.validationFailed')} +

+ {validationResult.errors.length > 0 && ( +
    + {validationResult.errors.map((err, index) => ( +
  • {err}
  • + ))} +
+ )} + {validationResult.valid && validationResult.warnings.length > 0 && ( +
+ {validationResult.warnings.map((info, index) => ( +

{info}

+ ))} +
+ )} + {!validationResult.valid && validationResult.warnings.length > 0 && ( +
+

{t('dialog.warnings')}

+
    + {validationResult.warnings.map((warn, index) => ( +
  • {warn}
  • + ))} +
+
+ )} +
+
+
+ )} + + + +
+ +
+ {meta?.connectionType === 'token' && ( + + )} + +
+
+
+ )} +
+
+
+ ); +} + +interface ConfigFieldProps { + field: ChannelConfigField; + value: string; + onChange: (value: string) => void; + showSecret: boolean; + onToggleSecret: () => void; +} + +function ChannelLogo({ type }: { type: ChannelType }) { + switch (type) { + case 'telegram': + return Telegram; + case 'discord': + return Discord; + case 'whatsapp': + return WhatsApp; + case 'dingtalk': + return DingTalk; + case 'feishu': + return Feishu; + case 'wecom': + return WeCom; + case 'qqbot': + return QQ; + default: + return {CHANNEL_ICONS[type] || '💬'}; + } +} + +function ConfigField({ field, value, onChange, showSecret, onToggleSecret }: ConfigFieldProps) { + const { t } = useTranslation('channels'); + const isPassword = field.type === 'password'; + + return ( +
+ +
+ onChange(event.target.value)} + className={inputClasses} + /> + {isPassword && ( + + )} +
+ {field.description && ( +

+ {t(field.description)} +

+ )} + {field.envVar && ( +

+ {t('dialog.envVar', { var: field.envVar })} +

+ )} +
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 1d5cb9dba..ee4ed654c 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { Network, + Bot, Puzzle, Clock, Settings as SettingsIcon, @@ -168,6 +169,7 @@ export function Sidebar() { const navItems = [ { to: '/models', icon: , label: t('sidebar.models') }, + { to: '/agents', icon: , label: t('sidebar.agents') }, { to: '/channels', icon: , label: t('sidebar.channels') }, { to: '/skills', icon: , label: t('sidebar.skills') }, { to: '/cron', icon: , label: t('sidebar.cronTasks') }, diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 84de778c3..5e0cc5253 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -7,6 +7,7 @@ import enSettings from './locales/en/settings.json'; import enDashboard from './locales/en/dashboard.json'; import enChat from './locales/en/chat.json'; import enChannels from './locales/en/channels.json'; +import enAgents from './locales/en/agents.json'; import enSkills from './locales/en/skills.json'; import enCron from './locales/en/cron.json'; import enSetup from './locales/en/setup.json'; @@ -17,6 +18,7 @@ import zhSettings from './locales/zh/settings.json'; import zhDashboard from './locales/zh/dashboard.json'; import zhChat from './locales/zh/chat.json'; import zhChannels from './locales/zh/channels.json'; +import zhAgents from './locales/zh/agents.json'; import zhSkills from './locales/zh/skills.json'; import zhCron from './locales/zh/cron.json'; import zhSetup from './locales/zh/setup.json'; @@ -27,6 +29,7 @@ import jaSettings from './locales/ja/settings.json'; import jaDashboard from './locales/ja/dashboard.json'; import jaChat from './locales/ja/chat.json'; import jaChannels from './locales/ja/channels.json'; +import jaAgents from './locales/ja/agents.json'; import jaSkills from './locales/ja/skills.json'; import jaCron from './locales/ja/cron.json'; import jaSetup from './locales/ja/setup.json'; @@ -46,6 +49,7 @@ const resources = { dashboard: enDashboard, chat: enChat, channels: enChannels, + agents: enAgents, skills: enSkills, cron: enCron, setup: enSetup, @@ -56,6 +60,7 @@ const resources = { dashboard: zhDashboard, chat: zhChat, channels: zhChannels, + agents: zhAgents, skills: zhSkills, cron: zhCron, setup: zhSetup, @@ -66,6 +71,7 @@ const resources = { dashboard: jaDashboard, chat: jaChat, channels: jaChannels, + agents: jaAgents, skills: jaSkills, cron: jaCron, setup: jaSetup, @@ -79,7 +85,7 @@ i18n lng: 'en', // will be overridden by settings store fallbackLng: 'en', defaultNS: 'common', - ns: ['common', 'settings', 'dashboard', 'chat', 'channels', 'skills', 'cron', 'setup'], + ns: ['common', 'settings', 'dashboard', 'chat', 'channels', 'agents', 'skills', 'cron', 'setup'], interpolation: { escapeValue: false, // React already escapes }, diff --git a/src/i18n/locales/en/agents.json b/src/i18n/locales/en/agents.json new file mode 100644 index 000000000..dfb09b4f4 --- /dev/null +++ b/src/i18n/locales/en/agents.json @@ -0,0 +1,51 @@ +{ + "title": "Agents", + "subtitle": "Manage your OpenClaw agents and their channel ownership", + "refresh": "Refresh", + "addAgent": "Add Agent", + "gatewayWarning": "Gateway service is not running. Agent/channel changes may take a moment to apply.", + "defaultBadge": "default", + "inherited": "inherited", + "none": "none", + "modelLine": "Model: {{model}}{{suffix}}", + "channelsLine": "Channels: {{channels}}", + "deleteAgent": "Delete Agent", + "settings": "Settings", + "creating": "Creating...", + "createDialog": { + "title": "Add Agent", + "description": "Create a new agent by name. ClawX will copy the main agent's workspace bootstrap files and runtime auth setup.", + "nameLabel": "Agent Name", + "namePlaceholder": "Coding Helper" + }, + "deleteDialog": { + "title": "Delete Agent", + "message": "Delete \"{{name}}\" from ClawX? Existing workspace and session files will be left on disk." + }, + "settingsDialog": { + "title": "{{name}} Settings", + "description": "Update the agent name and manage which channels belong to this agent.", + "nameLabel": "Agent Name", + "agentIdLabel": "Agent ID", + "modelLabel": "Model", + "channelsTitle": "Channels", + "channelsDescription": "Each channel type has a single ClawX configuration. Saving a configured channel here moves ownership to this agent.", + "addChannel": "Add Channel", + "noChannels": "No channels are assigned to this agent yet." + }, + "removeChannelDialog": { + "title": "Remove Channel", + "message": "Remove {{name}} from ClawX? This deletes the current channel configuration." + }, + "toast": { + "agentCreated": "Agent created", + "agentCreateFailed": "Failed to create agent: {{error}}", + "agentDeleted": "Agent deleted", + "agentUpdated": "Agent updated", + "agentUpdateFailed": "Failed to update agent: {{error}}", + "channelAssigned": "{{channel}} assigned to agent", + "channelAssignFailed": "Failed to assign channel: {{error}}", + "channelRemoved": "{{channel}} removed", + "channelRemoveFailed": "Failed to remove channel: {{error}}" + } +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index a0ed61c8a..5d821bdb4 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -4,6 +4,7 @@ "newChat": "New Chat", "cronTasks": "Cron Tasks", "skills": "Skills", + "agents": "Agents", "channels": "Channels", "dashboard": "Dashboard", "settings": "Settings", diff --git a/src/i18n/locales/ja/agents.json b/src/i18n/locales/ja/agents.json new file mode 100644 index 000000000..8ed8230ba --- /dev/null +++ b/src/i18n/locales/ja/agents.json @@ -0,0 +1,51 @@ +{ + "title": "Agents", + "subtitle": "OpenClaw エージェントとチャンネルの所属を管理します", + "refresh": "更新", + "addAgent": "Agent を追加", + "gatewayWarning": "Gateway サービスが停止しています。Agent または Channel の変更が反映されるまで少し時間がかかる場合があります。", + "defaultBadge": "default", + "inherited": "継承", + "none": "なし", + "modelLine": "Model: {{model}}{{suffix}}", + "channelsLine": "Channels: {{channels}}", + "deleteAgent": "Agent を削除", + "settings": "設定", + "creating": "作成中...", + "createDialog": { + "title": "Agent を追加", + "description": "名前だけで新しい Agent を作成できます。ClawX はメイン Agent のワークスペース初期ファイルと認証設定をコピーします。", + "nameLabel": "Agent 名", + "namePlaceholder": "Coding Helper" + }, + "deleteDialog": { + "title": "Agent を削除", + "message": "ClawX から「{{name}}」を削除しますか?既存のワークスペースとセッションファイルはディスク上に残ります。" + }, + "settingsDialog": { + "title": "{{name}} Settings", + "description": "Agent 名を更新し、この Agent に属する Channel を管理します。", + "nameLabel": "Agent 名", + "agentIdLabel": "Agent ID", + "modelLabel": "Model", + "channelsTitle": "Channels", + "channelsDescription": "各 Channel 種別は ClawX で 1 つだけ設定されます。ここで既存の Channel を保存すると、この Agent に所属が移動します。", + "addChannel": "Channel を追加", + "noChannels": "この Agent にはまだ Channel が割り当てられていません。" + }, + "removeChannelDialog": { + "title": "Channel を削除", + "message": "{{name}} を削除しますか?現在の Channel 設定も削除されます。" + }, + "toast": { + "agentCreated": "Agent を作成しました", + "agentCreateFailed": "Agent の作成に失敗しました: {{error}}", + "agentDeleted": "Agent を削除しました", + "agentUpdated": "Agent を更新しました", + "agentUpdateFailed": "Agent の更新に失敗しました: {{error}}", + "channelAssigned": "{{channel}} を Agent に割り当てました", + "channelAssignFailed": "Channel の割り当てに失敗しました: {{error}}", + "channelRemoved": "{{channel}} を削除しました", + "channelRemoveFailed": "Channel の削除に失敗しました: {{error}}" + } +} diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 6784a7425..04907952d 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -4,6 +4,7 @@ "newChat": "新しいチャット", "cronTasks": "定期タスク", "skills": "スキル", + "agents": "Agents", "channels": "チャンネル", "dashboard": "ダッシュボード", "settings": "設定", diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json new file mode 100644 index 000000000..44a53e57f --- /dev/null +++ b/src/i18n/locales/zh/agents.json @@ -0,0 +1,51 @@ +{ + "title": "Agents", + "subtitle": "管理 OpenClaw Agent 以及它们的 Channel 归属", + "refresh": "刷新", + "addAgent": "添加 Agent", + "gatewayWarning": "Gateway 服务未运行。Agent 或 Channel 变更可能需要一点时间生效。", + "defaultBadge": "默认", + "inherited": "继承", + "none": "无", + "modelLine": "Model: {{model}}{{suffix}}", + "channelsLine": "Channels: {{channels}}", + "deleteAgent": "删除 Agent", + "settings": "设置", + "creating": "创建中...", + "createDialog": { + "title": "添加 Agent", + "description": "只需输入名称即可创建新 Agent。ClawX 会复制主 Agent 的工作区引导文件和运行时认证配置。", + "nameLabel": "Agent 名称", + "namePlaceholder": "Coding Helper" + }, + "deleteDialog": { + "title": "删除 Agent", + "message": "确认从 ClawX 删除 “{{name}}”?已有的工作区和会话文件会保留在磁盘上。" + }, + "settingsDialog": { + "title": "{{name}} 设置", + "description": "更新 Agent 名称,并管理哪些 Channel 归属于这个 Agent。", + "nameLabel": "Agent 名称", + "agentIdLabel": "Agent ID", + "modelLabel": "Model", + "channelsTitle": "Channels", + "channelsDescription": "每种 Channel 类型在 ClawX 中只保留一份配置。在这里保存已配置的 Channel 会将归属切换到当前 Agent。", + "addChannel": "添加 Channel", + "noChannels": "这个 Agent 还没有分配任何 Channel。" + }, + "removeChannelDialog": { + "title": "移除 Channel", + "message": "确认移除 {{name}}?这会删除当前的 Channel 配置。" + }, + "toast": { + "agentCreated": "Agent 已创建", + "agentCreateFailed": "创建 Agent 失败:{{error}}", + "agentDeleted": "Agent 已删除", + "agentUpdated": "Agent 已更新", + "agentUpdateFailed": "更新 Agent 失败:{{error}}", + "channelAssigned": "{{channel}} 已分配给 Agent", + "channelAssignFailed": "分配 Channel 失败:{{error}}", + "channelRemoved": "{{channel}} 已移除", + "channelRemoveFailed": "移除 Channel 失败:{{error}}" + } +} diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 418ab75ff..eb73c05b9 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -4,6 +4,7 @@ "newChat": "新对话", "cronTasks": "定时任务", "skills": "技能", + "agents": "Agents", "channels": "频道", "dashboard": "仪表盘", "settings": "设置", diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx new file mode 100644 index 000000000..2d2c33ded --- /dev/null +++ b/src/pages/Agents/index.tsx @@ -0,0 +1,570 @@ +import { useEffect, useMemo, useState } from 'react'; +import { AlertCircle, Bot, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { StatusBadge } from '@/components/common/StatusBadge'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; +import { ChannelConfigModal } from '@/components/channels/ChannelConfigModal'; +import { useAgentsStore } from '@/stores/agents'; +import { useChannelsStore } from '@/stores/channels'; +import { useGatewayStore } from '@/stores/gateway'; +import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel'; +import type { AgentSummary } from '@/types/agent'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import telegramIcon from '@/assets/channels/telegram.svg'; +import discordIcon from '@/assets/channels/discord.svg'; +import whatsappIcon from '@/assets/channels/whatsapp.svg'; +import dingtalkIcon from '@/assets/channels/dingtalk.svg'; +import feishuIcon from '@/assets/channels/feishu.svg'; +import wecomIcon from '@/assets/channels/wecom.svg'; +import qqIcon from '@/assets/channels/qq.svg'; + +export function Agents() { + const { t } = useTranslation('agents'); + const gatewayStatus = useGatewayStore((state) => state.status); + const { + agents, + loading, + error, + fetchAgents, + createAgent, + deleteAgent, + } = useAgentsStore(); + const { channels, fetchChannels } = useChannelsStore(); + + const [showAddDialog, setShowAddDialog] = useState(false); + const [activeAgentId, setActiveAgentId] = useState(null); + const [agentToDelete, setAgentToDelete] = useState(null); + + useEffect(() => { + void Promise.all([fetchAgents(), fetchChannels()]); + }, [fetchAgents, fetchChannels]); + const activeAgent = useMemo( + () => agents.find((agent) => agent.id === activeAgentId) ?? null, + [activeAgentId, agents], + ); + const handleRefresh = () => { + void Promise.all([fetchAgents(), fetchChannels()]); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+
+

+ {t('title')} +

+

{t('subtitle')}

+
+
+ + +
+
+ +
+ {gatewayStatus.state !== 'running' && ( +
+ + + {t('gatewayWarning')} + +
+ )} + + {error && ( +
+ + + {error} + +
+ )} + +
+ {agents.map((agent) => ( + setActiveAgentId(agent.id)} + onDelete={() => setAgentToDelete(agent)} + /> + ))} +
+
+
+ + {showAddDialog && ( + setShowAddDialog(false)} + onCreate={async (name) => { + await createAgent(name); + setShowAddDialog(false); + toast.success(t('toast.agentCreated')); + }} + /> + )} + + {activeAgent && ( + setActiveAgentId(null)} + /> + )} + + { + if (!agentToDelete) return; + await deleteAgent(agentToDelete.id); + setAgentToDelete(null); + if (activeAgentId === agentToDelete.id) { + setActiveAgentId(null); + } + toast.success(t('toast.agentDeleted')); + }} + onCancel={() => setAgentToDelete(null)} + /> +
+ ); +} + +function AgentCard({ + agent, + onOpenSettings, + onDelete, +}: { + agent: AgentSummary; + onOpenSettings: () => void; + onDelete: () => void; +}) { + const { t } = useTranslation('agents'); + const channelsText = agent.channelTypes.length > 0 + ? agent.channelTypes.map((channelType) => CHANNEL_NAMES[channelType as ChannelType] || channelType).join(', ') + : t('none'); + + return ( +
+
+ +
+
+
+
+

{agent.name}

+ {agent.isDefault && ( + + {t('defaultBadge')} + + )} +
+
+ {!agent.isDefault && ( + + )} + +
+
+

+ {t('modelLine', { + model: agent.modelDisplay, + suffix: agent.inheritedModel ? ` (${t('inherited')})` : '', + })} +

+

+ {t('channelsLine', { channels: channelsText })} +

+
+
+ ); +} + +const inputClasses = 'h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-[#151514] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40'; +const labelClasses = 'text-[14px] text-foreground/80 font-bold'; + +function ChannelLogo({ type }: { type: ChannelType }) { + switch (type) { + case 'telegram': + return Telegram; + case 'discord': + return Discord; + case 'whatsapp': + return WhatsApp; + case 'dingtalk': + return DingTalk; + case 'feishu': + return Feishu; + case 'wecom': + return WeCom; + case 'qqbot': + return QQ; + default: + return {CHANNEL_ICONS[type] || '💬'}; + } +} + +function AddAgentDialog({ + onClose, + onCreate, +}: { + onClose: () => void; + onCreate: (name: string) => Promise; +}) { + const { t } = useTranslation('agents'); + const [name, setName] = useState(''); + const [saving, setSaving] = useState(false); + + const handleSubmit = async () => { + if (!name.trim()) return; + setSaving(true); + try { + await onCreate(name.trim()); + } catch (error) { + toast.error(t('toast.agentCreateFailed', { error: String(error) })); + setSaving(false); + return; + } + setSaving(false); + }; + + return ( +
+ + + + {t('createDialog.title')} + + + {t('createDialog.description')} + + + +
+ + setName(event.target.value)} + placeholder={t('createDialog.namePlaceholder')} + className={inputClasses} + /> +
+
+ + +
+
+
+
+ ); +} + +function AgentSettingsModal({ + agent, + channels, + onClose, +}: { + agent: AgentSummary; + channels: Array<{ type: string; name: string; status: 'connected' | 'connecting' | 'disconnected' | 'error'; error?: string }>; + onClose: () => void; +}) { + const { t } = useTranslation('agents'); + const { updateAgent, assignChannel, removeChannel } = useAgentsStore(); + const { fetchChannels } = useChannelsStore(); + const [name, setName] = useState(agent.name); + const [savingName, setSavingName] = useState(false); + const [showChannelModal, setShowChannelModal] = useState(false); + const [channelToRemove, setChannelToRemove] = useState(null); + + useEffect(() => { + setName(agent.name); + }, [agent.name]); + + const runtimeChannelsByType = useMemo( + () => Object.fromEntries(channels.map((channel) => [channel.type, channel])), + [channels], + ); + + const handleSaveName = async () => { + if (!name.trim() || name.trim() === agent.name) return; + setSavingName(true); + try { + await updateAgent(agent.id, name.trim()); + toast.success(t('toast.agentUpdated')); + } catch (error) { + toast.error(t('toast.agentUpdateFailed', { error: String(error) })); + } finally { + setSavingName(false); + } + }; + + const handleChannelSaved = async (channelType: ChannelType) => { + try { + await assignChannel(agent.id, channelType); + await fetchChannels(); + toast.success(t('toast.channelAssigned', { channel: CHANNEL_NAMES[channelType] || channelType })); + } catch (error) { + toast.error(t('toast.channelAssignFailed', { error: String(error) })); + throw error; + } + }; + + const assignedChannels = agent.channelTypes.map((channelType) => { + const runtimeChannel = runtimeChannelsByType[channelType]; + return { + channelType: channelType as ChannelType, + name: runtimeChannel?.name || CHANNEL_NAMES[channelType as ChannelType] || channelType, + status: runtimeChannel?.status || 'disconnected', + error: runtimeChannel?.error, + }; + }); + + return ( +
+ + +
+ + {t('settingsDialog.title', { name: agent.name })} + + + {t('settingsDialog.description')} + +
+ +
+ +
+
+ +
+ setName(event.target.value)} + readOnly={agent.isDefault} + className={inputClasses} + /> + {!agent.isDefault && ( + + )} +
+
+ +
+
+

+ {t('settingsDialog.agentIdLabel')} +

+

{agent.id}

+
+
+

+ {t('settingsDialog.modelLabel')} +

+

+ {agent.modelDisplay} + {agent.inheritedModel ? ` (${t('inherited')})` : ''} +

+
+
+
+ +
+
+
+

+ {t('settingsDialog.channelsTitle')} +

+

{t('settingsDialog.channelsDescription')}

+
+ +
+ + {assignedChannels.length === 0 ? ( +
+ {t('settingsDialog.noChannels')} +
+ ) : ( +
+ {assignedChannels.map((channel) => ( +
+
+
+ +
+
+

{channel.name}

+

+ {CHANNEL_NAMES[channel.channelType]} +

+ {channel.error && ( +

{channel.error}

+ )} +
+
+
+ + +
+
+ ))} +
+ )} +
+
+
+ + {showChannelModal && ( + setShowChannelModal(false)} + onChannelSaved={async (channelType) => { + await handleChannelSaved(channelType); + setShowChannelModal(false); + }} + /> + )} + + { + if (!channelToRemove) return; + try { + await removeChannel(agent.id, channelToRemove); + await fetchChannels(); + toast.success(t('toast.channelRemoved', { channel: CHANNEL_NAMES[channelToRemove] || channelToRemove })); + } catch (error) { + toast.error(t('toast.channelRemoveFailed', { error: String(error) })); + } finally { + setChannelToRemove(null); + } + }} + onCancel={() => setChannelToRemove(null)} + /> +
+ ); +} + +export default Agents; diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 92acc8994..89d6178d1 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -2,27 +2,9 @@ * Channels Page * Manage messaging channel connections with configuration UI */ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { - RefreshCw, - Trash2, - QrCode, - Loader2, - X, - ExternalLink, - BookOpen, - Eye, - EyeOff, - Check, - AlertCircle, - CheckCircle, - ShieldCheck, -} from 'lucide-react'; +import { useState, useEffect, useCallback } from 'react'; +import { RefreshCw, Trash2, AlertCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useChannelsStore } from '@/stores/channels'; @@ -30,7 +12,7 @@ import { useGatewayStore } from '@/stores/gateway'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; -import { invokeIpc } from '@/lib/api-client'; +import { ChannelConfigModal } from '@/components/channels/ChannelConfigModal'; import { cn } from '@/lib/utils'; import { CHANNEL_ICONS, @@ -39,10 +21,7 @@ import { getPrimaryChannels, type ChannelType, type Channel, - type ChannelMeta, - type ChannelConfigField, } from '@/types/channel'; -import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import telegramIcon from '@/assets/channels/telegram.svg'; @@ -63,12 +42,10 @@ export function Channels() { const [configuredTypes, setConfiguredTypes] = useState([]); const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null); - // Fetch channels on mount useEffect(() => { - fetchChannels(); + void fetchChannels(); }, [fetchChannels]); - // Fetch configured channel types from config file const fetchConfiguredTypes = useCallback(async () => { try { const result = await hostApiFetch<{ @@ -79,19 +56,21 @@ export function Channels() { setConfiguredTypes(result.channels); } } catch { - // ignore + // Ignore refresh errors here and keep the last known state. } }, []); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - void fetchConfiguredTypes(); + const timer = window.setTimeout(() => { + void fetchConfiguredTypes(); + }, 0); + return () => window.clearTimeout(timer); }, [fetchConfiguredTypes]); useEffect(() => { const unsubscribe = subscribeHostEvent('gateway:channel-status', () => { - fetchChannels(); - fetchConfiguredTypes(); + void fetchChannels(); + void fetchConfiguredTypes(); }); return () => { if (typeof unsubscribe === 'function') { @@ -100,9 +79,12 @@ export function Channels() { }; }, [fetchChannels, fetchConfiguredTypes]); - // Get channel types to display const displayedChannelTypes = getPrimaryChannels(); + const handleRefresh = () => { + void Promise.all([fetchChannels(), fetchConfiguredTypes()]); + }; + if (loading) { return (
@@ -116,9 +98,7 @@ export function Channels() { return (
- - {/* Header */} -
+

{t('title')} @@ -131,10 +111,7 @@ export function Channels() {

- {/* Content Area */} -
- {/* Gateway Warning */} +
{gatewayStatus.state !== 'running' && (
@@ -156,7 +131,6 @@ export function Channels() {
)} - {/* Error Display */} {error && (
@@ -166,13 +140,12 @@ export function Channels() {
)} - {/* Available Channels (Configured) */} {safeChannels.length > 0 && (

{t('availableChannels')}

-
+
{safeChannels.map((channel) => ( )} - {/* Supported Channels (Not yet configured) */}

{t('supportedChannels')} @@ -193,9 +165,8 @@ export function Channels() {
{displayedChannelTypes.map((type) => { const meta = CHANNEL_META[type]; - const isConfigured = safeChannels.some(c => c.type === type) || configuredTypes.includes(type); - - // Hide already configured channels from "Supported Channels" section + const isConfigured = safeChannels.some((channel) => channel.type === type) + || configuredTypes.includes(type); if (isConfigured) return null; return ( @@ -206,7 +177,7 @@ export function Channels() { setShowAddDialog(true); }} className={cn( - "group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5" + 'group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5' )} >
@@ -230,22 +201,19 @@ export function Channels() { })}
-

- {/* Add Channel Dialog */} {showAddDialog && ( - { setShowAddDialog(false); setSelectedChannelType(null); }} - onChannelAdded={() => { - fetchChannels(); - fetchConfiguredTypes(); + onChannelSaved={async () => { + await Promise.all([fetchChannels(), fetchConfiguredTypes()]); setShowAddDialog(false); setSelectedChannelType(null); }} @@ -262,8 +230,7 @@ export function Channels() { onConfirm={async () => { if (channelToDelete) { await deleteChannel(channelToDelete.id); - // Immediately update configuredTypes state so it disappears from available and appears in supported - const channelType = channelToDelete.id.split('-')[0]; + const [channelType] = channelToDelete.id.split('-'); setConfiguredTypes((prev) => prev.filter((type) => type !== channelType)); setChannelToDelete(null); } @@ -274,7 +241,6 @@ export function Channels() { ); } -// ==================== Channel Logo Component ==================== function ChannelLogo({ type }: { type: ChannelType }) { switch (type) { case 'telegram': @@ -296,8 +262,6 @@ function ChannelLogo({ type }: { type: ChannelType }) { } } -// ==================== Channel Card Component ==================== - interface ChannelCardProps { channel: Channel; onDelete: () => void; @@ -317,17 +281,23 @@ function ChannelCard({ channel, onDelete }: ChannelCardProps) {

{channel.name}

{meta?.isPlugin && ( - + {t('pluginBadge', 'Plugin')} )}
@@ -337,8 +307,8 @@ function ChannelCard({ channel, onDelete }: ChannelCardProps) { variant="ghost" size="icon" className="opacity-0 group-hover:opacity-100 h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all shrink-0 -mr-2" - onClick={(e) => { - e.stopPropagation(); + onClick={(event) => { + event.stopPropagation(); onDelete(); }} > @@ -360,613 +330,4 @@ function ChannelCard({ channel, onDelete }: ChannelCardProps) { ); } -// ==================== Add Channel Dialog ==================== - -interface AddChannelDialogProps { - selectedType: ChannelType | null; - onSelectType: (type: ChannelType | null) => void; - onClose: () => void; - onChannelAdded: () => void; -} - -function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) { - const { t } = useTranslation('channels'); - const { addChannel } = useChannelsStore(); - const [configValues, setConfigValues] = useState>({}); - const [channelName, setChannelName] = useState(''); - const [connecting, setConnecting] = useState(false); - const [showSecrets, setShowSecrets] = useState>({}); - const [qrCode, setQrCode] = useState(null); - const [validating, setValidating] = useState(false); - const [loadingConfig, setLoadingConfig] = useState(false); - const [isExistingConfig, setIsExistingConfig] = useState(false); - const firstInputRef = useRef(null); - const [validationResult, setValidationResult] = useState<{ - valid: boolean; - errors: string[]; - warnings: string[]; - } | null>(null); - - const meta: ChannelMeta | null = selectedType ? CHANNEL_META[selectedType] : null; - - // Load existing config when a channel type is selected - useEffect(() => { - if (!selectedType) { - setConfigValues({}); - setChannelName(''); - setIsExistingConfig(false); - setChannelName(''); - setIsExistingConfig(false); - // Ensure we clean up any pending QR session if switching away - hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); - return; - } - - let cancelled = false; - setLoadingConfig(true); - - (async () => { - try { - const result = await invokeIpc( - 'channel:getFormValues', - selectedType - ) as { success: boolean; values?: Record }; - - if (cancelled) return; - - if (result.success && result.values && Object.keys(result.values).length > 0) { - setConfigValues(result.values); - setIsExistingConfig(true); - } else { - setConfigValues({}); - setIsExistingConfig(false); - } - } catch { - if (!cancelled) { - setConfigValues({}); - setIsExistingConfig(false); - } - } finally { - if (!cancelled) setLoadingConfig(false); - } - })(); - - return () => { cancelled = true; }; - }, [selectedType]); - - // Focus first input when form is ready (avoids Windows focus loss after native dialogs) - useEffect(() => { - if (selectedType && !loadingConfig && firstInputRef.current) { - firstInputRef.current.focus(); - } - }, [selectedType, loadingConfig]); - - // Listen for WhatsApp QR events - useEffect(() => { - if (selectedType !== 'whatsapp') return; - - const onQr = (...args: unknown[]) => { - const data = args[0] as { qr: string; raw: string }; - setQrCode(`data:image/png;base64,${data.qr}`); - }; - - const onSuccess = async (...args: unknown[]) => { - const data = args[0] as { accountId?: string } | undefined; - toast.success(t('toast.whatsappConnected')); - const accountId = data?.accountId || channelName.trim() || 'default'; - try { - const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', { - method: 'POST', - body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true } }), - }); - if (!saveResult?.success) { - console.error('Failed to save WhatsApp config:', saveResult?.error); - } else { - console.info('Saved WhatsApp config for account:', accountId); - } - } catch (error) { - console.error('Failed to save WhatsApp config:', error); - } - // Register the channel locally so it shows up immediately - addChannel({ - type: 'whatsapp', - name: channelName || 'WhatsApp', - }).then(() => { - // Restart gateway to pick up the new session - useGatewayStore.getState().restart().catch(console.error); - onChannelAdded(); - }); - }; - - const onError = (...args: unknown[]) => { - const err = args[0] as string; - console.error('WhatsApp Login Error:', err); - toast.error(t('toast.whatsappFailed', { error: err })); - setQrCode(null); - setConnecting(false); - }; - - const removeQrListener = subscribeHostEvent('channel:whatsapp-qr', onQr); - const removeSuccessListener = subscribeHostEvent('channel:whatsapp-success', onSuccess); - const removeErrorListener = subscribeHostEvent('channel:whatsapp-error', onError); - - return () => { - if (typeof removeQrListener === 'function') removeQrListener(); - if (typeof removeSuccessListener === 'function') removeSuccessListener(); - if (typeof removeErrorListener === 'function') removeErrorListener(); - // Cancel when unmounting or switching types - hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); - }; - }, [selectedType, addChannel, channelName, onChannelAdded, t]); - - const handleValidate = async () => { - if (!selectedType) return; - - setValidating(true); - setValidationResult(null); - - try { - const result = await hostApiFetch<{ - success: boolean; - valid?: boolean; - errors?: string[]; - warnings?: string[]; - details?: Record; - }>('/api/channels/credentials/validate', { - method: 'POST', - body: JSON.stringify({ channelType: selectedType, config: configValues }), - }); - - const warnings = result.warnings || []; - if (result.valid && result.details) { - const details = result.details; - if (details.botUsername) warnings.push(`Bot: @${details.botUsername}`); - if (details.guildName) warnings.push(`Server: ${details.guildName}`); - if (details.channelName) warnings.push(`Channel: #${details.channelName}`); - } - - setValidationResult({ - valid: result.valid || false, - errors: result.errors || [], - warnings, - }); - } catch (error) { - setValidationResult({ - valid: false, - errors: [String(error)], - warnings: [], - }); - } finally { - setValidating(false); - } - }; - - - const handleConnect = async () => { - if (!selectedType || !meta) return; - - setConnecting(true); - setValidationResult(null); - - try { - // For QR-based channels, request QR code - if (meta.connectionType === 'qr') { - const accountId = channelName.trim() || 'default'; - await hostApiFetch('/api/channels/whatsapp/start', { - method: 'POST', - body: JSON.stringify({ accountId }), - }); - // The QR code will be set via event listener - return; - } - - // Step 1: Validate credentials against the actual service API - if (meta.connectionType === 'token') { - const validationResponse = await hostApiFetch<{ - success: boolean; - valid?: boolean; - errors?: string[]; - warnings?: string[]; - details?: Record; - }>('/api/channels/credentials/validate', { - method: 'POST', - body: JSON.stringify({ channelType: selectedType, config: configValues }), - }); - - if (!validationResponse.valid) { - setValidationResult({ - valid: false, - errors: validationResponse.errors || ['Validation failed'], - warnings: validationResponse.warnings || [], - }); - setConnecting(false); - return; - } - - // Show success details (bot name, guild name, etc.) as warnings/info - const warnings = validationResponse.warnings || []; - if (validationResponse.details) { - const details = validationResponse.details; - if (details.botUsername) { - warnings.push(`Bot: @${details.botUsername}`); - } - if (details.guildName) { - warnings.push(`Server: ${details.guildName}`); - } - if (details.channelName) { - warnings.push(`Channel: #${details.channelName}`); - } - } - - // Show validation success with details - setValidationResult({ - valid: true, - errors: [], - warnings, - }); - } - - // Step 2: Save channel configuration via IPC - const config: Record = { ...configValues }; - const saveResult = await hostApiFetch<{ - success?: boolean; - error?: string; - warning?: string; - pluginInstalled?: boolean; - }>('/api/channels/config', { - method: 'POST', - body: JSON.stringify({ channelType: selectedType, config }), - }); - if (!saveResult?.success) { - throw new Error(saveResult?.error || 'Failed to save channel config'); - } - if (typeof saveResult.warning === 'string' && saveResult.warning) { - toast.warning(saveResult.warning); - } - - // Step 3: Add a local channel entry for the UI - await addChannel({ - type: selectedType, - name: channelName || CHANNEL_NAMES[selectedType], - token: configValues[meta.configFields[0]?.key] || undefined, - }); - - toast.success(t('toast.channelSaved', { name: meta.name })); - - // Gateway restart is now handled server-side via debouncedRestart() - // inside the channel:saveConfig IPC handler, so we don't need to - // trigger it explicitly here. This avoids cascading restarts when - // multiple config changes happen in quick succession (e.g. during - // the setup wizard). - toast.success(t('toast.channelConnecting', { name: meta.name })); - - // Brief delay so user can see the success state before dialog closes - await new Promise((resolve) => setTimeout(resolve, 800)); - onChannelAdded(); - } catch (error) { - toast.error(t('toast.configFailed', { error })); - setConnecting(false); - } - }; - - const openDocs = () => { - if (meta?.docsUrl) { - const url = t(meta.docsUrl.replace('channels:', '')); - try { - if (window.electron?.openExternal) { - window.electron.openExternal(url); - } else { - // Fallback: open in new window - window.open(url, '_blank'); - } - } catch (error) { - console.error('Failed to open docs:', error); - // Fallback: open in new window - window.open(url, '_blank'); - } - } - }; - - - const isFormValid = () => { - if (!meta) return false; - - // Check all required fields are filled - return meta.configFields - .filter((field) => field.required) - .every((field) => configValues[field.key]?.trim()); - }; - - const updateConfigValue = (key: string, value: string) => { - setConfigValues((prev) => ({ ...prev, [key]: value })); - }; - - const toggleSecretVisibility = (key: string) => { - setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - return ( -
- - -
- - {selectedType - ? isExistingConfig - ? t('dialog.updateTitle', { name: CHANNEL_NAMES[selectedType] }) - : t('dialog.configureTitle', { name: CHANNEL_NAMES[selectedType] }) - : t('dialog.addTitle')} - - - {selectedType && isExistingConfig - ? t('dialog.existingDesc') - : meta ? t(meta.description.replace('channels:', '')) : t('dialog.selectDesc')} - -
- -
- - {!selectedType ? ( - // Channel type selection -
- {getPrimaryChannels().map((type) => { - const channelMeta = CHANNEL_META[type]; - return ( - - ); - })} -
- ) : qrCode ? ( - // QR Code display -
-
- {qrCode.startsWith('data:image') ? ( - Scan QR Code - ) : ( -
- -
- )} -
-

- {t('dialog.scanQR', { name: meta?.name })} -

-
- -
-
- ) : loadingConfig ? ( - // Loading saved config -
- - {t('dialog.loadingConfig')} -
- ) : ( - // Connection form -
- {/* Existing config hint */} - {isExistingConfig && ( -
- - {t('dialog.existingHint')} -
- )} - - {/* Instructions */} -
-
-

{t('dialog.howToConnect')}

- -
-
    - {meta?.instructions.map((instruction, i) => ( -
  1. {t(instruction.replace('channels:', ''))}
  2. - ))} -
-
- - {/* Channel name */} -
- - setChannelName(e.target.value)} - className="h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-[#151514] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40" - /> -
- - {/* Configuration fields */} - {meta?.configFields.map((field) => ( - updateConfigValue(field.key, value)} - showSecret={showSecrets[field.key] || false} - onToggleSecret={() => toggleSecretVisibility(field.key)} - /> - ))} - - {/* Validation Results */} - {validationResult && ( -
-
- {validationResult.valid ? ( - - ) : ( - - )} -
-

- {validationResult.valid ? t('dialog.credentialsVerified') : t('dialog.validationFailed')} -

- {validationResult.errors.length > 0 && ( -
    - {validationResult.errors.map((err, i) => ( -
  • {err}
  • - ))} -
- )} - {validationResult.valid && validationResult.warnings.length > 0 && ( -
- {validationResult.warnings.map((info, i) => ( -

{info}

- ))} -
- )} - {!validationResult.valid && validationResult.warnings.length > 0 && ( -
-

{t('dialog.warnings')}

-
    - {validationResult.warnings.map((warn, i) => ( -
  • {warn}
  • - ))} -
-
- )} -
-
-
- )} - - - -
-
- {/* Validation Button - Only for token-based channels for now */} - {meta?.connectionType === 'token' && ( - - )} - -
-
-
- )} -
-
-
- ); -} - -// ==================== Config Field Component ==================== - -interface ConfigFieldProps { - field: ChannelConfigField; - value: string; - onChange: (value: string) => void; - showSecret: boolean; - onToggleSecret: () => void; -} - -function ConfigField({ field, value, onChange, showSecret, onToggleSecret }: ConfigFieldProps) { - const { t } = useTranslation('channels'); - const isPassword = field.type === 'password'; - - return ( -
- -
- onChange(e.target.value)} - className="h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-[#151514] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40" - /> - {isPassword && ( - - )} -
- {field.description && ( -

- {t(field.description.replace('channels:', ''))} -

- )} - {field.envVar && ( -

- {t('dialog.envVar', { var: field.envVar })} -

- )} -
- ); -} - export default Channels; diff --git a/src/stores/agents.ts b/src/stores/agents.ts new file mode 100644 index 000000000..6533ce26f --- /dev/null +++ b/src/stores/agents.ts @@ -0,0 +1,126 @@ +import { create } from 'zustand'; +import { hostApiFetch } from '@/lib/host-api'; +import type { ChannelType } from '@/types/channel'; +import type { AgentSummary, AgentsSnapshot } from '@/types/agent'; + +interface AgentsState { + agents: AgentSummary[]; + defaultAgentId: string; + configuredChannelTypes: string[]; + channelOwners: Record; + loading: boolean; + error: string | null; + fetchAgents: () => Promise; + createAgent: (name: string) => Promise; + updateAgent: (agentId: string, name: string) => Promise; + deleteAgent: (agentId: string) => Promise; + assignChannel: (agentId: string, channelType: ChannelType) => Promise; + removeChannel: (agentId: string, channelType: ChannelType) => Promise; + clearError: () => void; +} + +function applySnapshot(snapshot: AgentsSnapshot | undefined) { + return snapshot ? { + agents: snapshot.agents, + defaultAgentId: snapshot.defaultAgentId, + configuredChannelTypes: snapshot.configuredChannelTypes, + channelOwners: snapshot.channelOwners, + } : {}; +} + +export const useAgentsStore = create((set) => ({ + agents: [], + defaultAgentId: 'main', + configuredChannelTypes: [], + channelOwners: {}, + loading: false, + error: null, + + fetchAgents: async () => { + set({ loading: true, error: null }); + try { + const snapshot = await hostApiFetch('/api/agents'); + set({ + ...applySnapshot(snapshot), + loading: false, + }); + } catch (error) { + set({ loading: false, error: String(error) }); + } + }, + + createAgent: async (name: string) => { + set({ error: null }); + try { + const snapshot = await hostApiFetch('/api/agents', { + method: 'POST', + body: JSON.stringify({ name }), + }); + set(applySnapshot(snapshot)); + } catch (error) { + set({ error: String(error) }); + throw error; + } + }, + + updateAgent: async (agentId: string, name: string) => { + set({ error: null }); + try { + const snapshot = await hostApiFetch( + `/api/agents/${encodeURIComponent(agentId)}`, + { + method: 'PUT', + body: JSON.stringify({ name }), + } + ); + set(applySnapshot(snapshot)); + } catch (error) { + set({ error: String(error) }); + throw error; + } + }, + + deleteAgent: async (agentId: string) => { + set({ error: null }); + try { + const snapshot = await hostApiFetch( + `/api/agents/${encodeURIComponent(agentId)}`, + { method: 'DELETE' } + ); + set(applySnapshot(snapshot)); + } catch (error) { + set({ error: String(error) }); + throw error; + } + }, + + assignChannel: async (agentId: string, channelType: ChannelType) => { + set({ error: null }); + try { + const snapshot = await hostApiFetch( + `/api/agents/${encodeURIComponent(agentId)}/channels/${encodeURIComponent(channelType)}`, + { method: 'PUT' } + ); + set(applySnapshot(snapshot)); + } catch (error) { + set({ error: String(error) }); + throw error; + } + }, + + removeChannel: async (agentId: string, channelType: ChannelType) => { + set({ error: null }); + try { + const snapshot = await hostApiFetch( + `/api/agents/${encodeURIComponent(agentId)}/channels/${encodeURIComponent(channelType)}`, + { method: 'DELETE' } + ); + set(applySnapshot(snapshot)); + } catch (error) { + set({ error: String(error) }); + throw error; + } + }, + + clearError: () => set({ error: null }), +})); diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 1ceab93ba..1d49182b3 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -87,6 +87,7 @@ interface ChatState { // Sessions sessions: ChatSession[]; currentSessionKey: string; + currentAgentId: string; /** First user message text per session key, used as display label */ sessionLabels: Record; /** Last message timestamp (ms) per session key, used for sorting */ @@ -657,6 +658,19 @@ function getCanonicalPrefixFromSessions(sessions: ChatSession[]): string | null return `${parts[0]}:${parts[1]}`; } +function getAgentIdFromSessionKey(sessionKey: string): string { + if (!sessionKey.startsWith('agent:')) return 'main'; + const parts = sessionKey.split(':'); + return parts[1] || 'main'; +} + +function getCanonicalPrefixFromSessionKey(sessionKey: string): string | null { + if (!sessionKey.startsWith('agent:')) return null; + const parts = sessionKey.split(':'); + if (parts.length < 2) return null; + return `${parts[0]}:${parts[1]}`; +} + function isToolOnlyMessage(message: RawMessage | undefined): boolean { if (!message) return false; if (isToolResultRole(message.role)) return true; @@ -923,6 +937,7 @@ export const useChatStore = create((set, get) => ({ sessions: [], currentSessionKey: DEFAULT_SESSION_KEY, + currentAgentId: 'main', sessionLabels: {}, sessionLastActivity: {}, @@ -964,7 +979,7 @@ export const useChatStore = create((set, get) => ({ return true; }); - const { currentSessionKey } = get(); + const { currentSessionKey, sessions: localSessions } = get(); let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY; if (!nextSessionKey.startsWith('agent:')) { const canonicalMatch = canonicalBySuffix.get(nextSessionKey); @@ -973,9 +988,10 @@ export const useChatStore = create((set, get) => ({ } } if (!dedupedSessions.find((s) => s.key === nextSessionKey) && dedupedSessions.length > 0) { - // Current session not found in the backend list - const isNewEmptySession = get().messages.length === 0; - if (!isNewEmptySession) { + // Preserve only locally-created pending sessions. On initial boot the + // default ghost key (`agent:main:main`) should yield to real history. + const hasLocalPendingSession = localSessions.some((session) => session.key === nextSessionKey); + if (!hasLocalPendingSession) { nextSessionKey = dedupedSessions[0].key; } } @@ -987,7 +1003,11 @@ export const useChatStore = create((set, get) => ({ ] : dedupedSessions; - set({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey }); + set({ + sessions: sessionsWithCurrent, + currentSessionKey: nextSessionKey, + currentAgentId: getAgentIdFromSessionKey(nextSessionKey), + }); if (currentSessionKey !== nextSessionKey) { get().loadHistory(); @@ -1038,6 +1058,7 @@ export const useChatStore = create((set, get) => ({ const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0; set((s) => ({ currentSessionKey: key, + currentAgentId: getAgentIdFromSessionKey(key), messages: [], streamingText: '', streamingMessage: null, @@ -1108,6 +1129,7 @@ export const useChatStore = create((set, get) => ({ lastUserMessageAt: null, pendingToolImages: [], currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY, + currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY), })); if (next) { get().loadHistory(); @@ -1128,13 +1150,16 @@ export const useChatStore = create((set, get) => ({ // NOTE: We intentionally do NOT call sessions.reset on the old session. // sessions.reset archives (renames) the session JSONL file, making old // conversation history inaccessible when the user switches back to it. - const { currentSessionKey, messages } = get(); + const { currentSessionKey, messages, sessions } = get(); const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0; - const prefix = getCanonicalPrefixFromSessions(get().sessions) ?? DEFAULT_CANONICAL_PREFIX; + const prefix = getCanonicalPrefixFromSessionKey(currentSessionKey) + ?? getCanonicalPrefixFromSessions(sessions) + ?? DEFAULT_CANONICAL_PREFIX; const newKey = `${prefix}:session-${Date.now()}`; const newSessionEntry: ChatSession = { key: newKey, displayName: newKey }; set((s) => ({ currentSessionKey: newKey, + currentAgentId: getAgentIdFromSessionKey(newKey), sessions: [ ...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions), newSessionEntry, diff --git a/src/types/agent.ts b/src/types/agent.ts new file mode 100644 index 000000000..eeadd9ef9 --- /dev/null +++ b/src/types/agent.ts @@ -0,0 +1,17 @@ +export interface AgentSummary { + id: string; + name: string; + isDefault: boolean; + modelDisplay: string; + inheritedModel: boolean; + workspace: string; + agentDir: string; + channelTypes: string[]; +} + +export interface AgentsSnapshot { + agents: AgentSummary[]; + defaultAgentId: string; + configuredChannelTypes: string[]; + channelOwners: Record; +}