'use client' import * as React from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Bot, BookOpen, History, Image as ImageIcon, FileText, Loader2, Lock, Paperclip, RefreshCcw, Save, ShieldAlert, ShieldOff, Trash2, Wand2, Wrench, } from 'lucide-react' import { useT } from '@open-mercato/shared/lib/i18n/context' import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert' import { Badge } from '@open-mercato/ui/primitives/badge' import { Button } from '@open-mercato/ui/primitives/button' import { Checkbox } from '@open-mercato/ui/primitives/checkbox' import { IconButton } from '@open-mercato/ui/primitives/icon-button' import { Input } from '@open-mercato/ui/primitives/input' import { Label } from '@open-mercato/ui/primitives/label' import { Radio, RadioGroup } from '@open-mercato/ui/primitives/radio' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@open-mercato/ui/primitives/select' import { StatusBadge, type StatusMap } from '@open-mercato/ui/primitives/status-badge' import { Switch } from '@open-mercato/ui/primitives/switch' import { Textarea } from '@open-mercato/ui/primitives/textarea' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@open-mercato/ui/primitives/tooltip' import { EmptyState } from '@open-mercato/ui/backend/EmptyState' import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall' import { flash } from '@open-mercato/ui/backend/FlashMessages' import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation' import { useAiShortcuts } from '@open-mercato/ui/ai' // The agent picker is deliberately duplicated between the playground and this // settings page. Duplicated markup is under the 50-line threshold, so extraction // stays deferred per the Step 4.6 brief. type AgentTool = { name: string displayName: string isMutation: boolean registered: boolean } type AgentSettings = { id: string moduleId: string label: string description: string systemPrompt: string executionMode: 'chat' | 'object' defaultProvider: string | null defaultModel: string | null defaultBaseUrl: string | null allowRuntimeModelOverride: boolean mutationPolicy: string readOnly: boolean maxSteps: number | null taskPlan?: { enabled?: boolean } allowedTools: string[] tools: AgentTool[] requiredFeatures: string[] acceptedMediaTypes: string[] hasOutputSchema: boolean } type AgentsResponse = { agents: AgentSettings[] total: number } type ProviderConfig = { id: string name: string defaultModel: string configured: boolean defaultModels: Array<{ id: string; name: string }> } type AgentResolution = { agentId: string moduleId: string allowRuntimeModelOverride: boolean codeDefaultProviderId: string | null codeDefaultModelId: string | null override: { providerId: string | null modelId: string | null baseURL: string | null updatedAt: string } | null runtimeOverrideAllowlist: { env: TenantAllowlist | null tenant: TenantAllowlist | null effective: EffectiveAllowlist envVarNames: { providers: string modelsByProvider: Record } } providerId: string modelId: string baseURL: string | null source: string } type TenantAllowlist = { allowedProviders: string[] | null allowedModelsByProvider: Record } type EffectiveAllowlist = { providers: string[] | null modelsByProvider: Record hasRestrictions: boolean tenantOverridesActive: boolean } type RuntimeSettingsResponse = { availableProviders: ProviderConfig[] agents: AgentResolution[] } const PROMPT_SECTION_IDS = [ 'role', 'scope', 'data', 'tools', 'attachments', 'mutationPolicy', 'responseStyle', 'overrides', ] as const type PromptSectionId = (typeof PROMPT_SECTION_IDS)[number] const mutationPolicyStatusMap: StatusMap = { 'read-only': 'neutral', 'confirm-required': 'warning', 'destructive-confirm-required': 'error', } const executionModeStatusMap: StatusMap<'chat' | 'object'> = { chat: 'info', object: 'success', } const mediaTypeIconMap: Record = { image: ImageIcon, pdf: FileText, file: Paperclip, } async function fetchAgents(): Promise { const { result, status } = await apiCallOrThrow( '/api/ai_assistant/ai/agents', { method: 'GET', credentials: 'include' }, { errorMessage: 'Failed to load agents' }, ) if (!result) throw new Error(`Failed to load agents (${status})`) return result } async function fetchRuntimeSettings(): Promise { const { result, status } = await apiCallOrThrow( '/api/ai_assistant/settings', { method: 'GET', credentials: 'include' }, { errorMessage: 'Failed to load runtime settings' }, ) if (!result) throw new Error(`Failed to load runtime settings (${status})`) return result } type OverrideVersion = { id: string agentId: string version: number sections: Record notes: string | null createdByUserId: string | null createdAt: string updatedAt: string } type OverrideResponse = { agentId: string override: OverrideVersion | null versions: OverrideVersion[] } async function fetchOverride(agentId: string): Promise { const { result, status } = await apiCallOrThrow( `/api/ai_assistant/ai/agents/${encodeURIComponent(agentId)}/prompt-override`, { method: 'GET', credentials: 'include' }, { errorMessage: 'Failed to load prompt override' }, ) if (!result) throw new Error(`Failed to load prompt override (${status})`) return result } type MutationPolicy = 'read-only' | 'confirm-required' | 'destructive-confirm-required' const MUTATION_POLICY_OPTIONS: MutationPolicy[] = [ 'read-only', 'destructive-confirm-required', 'confirm-required', ] // Higher number = less restrictive. Mirrors // `lib/agent-policy.ts#POLICY_RESTRICTIVENESS` — UI must match the server's // escalation guard so disabled options line up with 400 responses. const POLICY_RESTRICTIVENESS_UI: Record = { 'read-only': 0, 'destructive-confirm-required': 1, 'confirm-required': 2, } type MutationPolicyOverrideRow = { id: string agentId: string mutationPolicy: MutationPolicy notes: string | null createdByUserId: string | null createdAt: string updatedAt: string } type MutationPolicyResponse = { agentId: string codeDeclared: MutationPolicy override: MutationPolicyOverrideRow | null } async function fetchMutationPolicy(agentId: string): Promise { const { result, status } = await apiCallOrThrow( `/api/ai_assistant/ai/agents/${encodeURIComponent(agentId)}/mutation-policy`, { method: 'GET', credentials: 'include' }, { errorMessage: 'Failed to load mutation policy' }, ) if (!result) throw new Error(`Failed to load mutation policy (${status})`) return result } function SettingsLoading({ message }: { message: string }) { return (
{message}
) } function EmptyAgents() { const t = useT() return ( } title={t( 'ai_assistant.agents.empty.title', 'No AI agents are registered for your role yet.', )} description={t( 'ai_assistant.agents.empty.description', 'Declare agents inside `packages//src/modules//ai-agents.ts`, run `yarn generate`, and ensure the caller holds the agent\'s required features.', )} >
{t( 'ai_assistant.agents.empty.docLabel', 'See packages/ai-assistant/AGENTS.md for the agent definition reference.', )}
) } function PromptSectionEditor({ sectionId, defaultText, overrideText, override, onToggleOverride, onOverrideChange, onSaveShortcut, }: { sectionId: PromptSectionId defaultText: string overrideText: string override: boolean onToggleOverride: (next: boolean) => void onOverrideChange: (next: string) => void onSaveShortcut: () => void }) { const t = useT() const sectionLabel = t( `ai_assistant.agents.prompt.sections.${sectionId}`, sectionId.charAt(0).toUpperCase() + sectionId.slice(1), ) const textareaId = `ai-agent-prompt-${sectionId}` const textareaRef = React.useRef(null) const { handleKeyDown } = useAiShortcuts({ onSubmit: onSaveShortcut, onCancel: () => { textareaRef.current?.blur() }, }) return (
{sectionLabel} {override ? t( 'ai_assistant.agents.prompt.overrideModeLabel', 'Override mode — replaces the default when persistence lands.', ) : t( 'ai_assistant.agents.prompt.defaultModeLabel', 'Default — shipped with the agent definition.', )}
onToggleOverride(next)} aria-label={t('ai_assistant.agents.prompt.toggleOverride', 'Override')} data-ai-agent-prompt-toggle={sectionId} />
{override ? (