feat(ui): redesign config pages with grouped tabs & visual emphasis

Reorganize main config page from 6 tabs (General/Quota/Agents/Sessions/
Tools/Advanced) to purpose-driven tabs (Server/Behavior/AI Defaults/
Quota/Tools/Integrations).

Key changes:
- Behavior tab highlights important settings (tool_status, block_reply,
  intent_classify, injection_action, scrub_credentials) with rich visual
  treatment: colored icons, hint text, and conditional info boxes
- Split monolithic gateway/tools sections into focused sub-cards
- Agent config tab: grouped sections with ConfigGroupHeader + StickySaveBar
- New shared components: FeatureSwitchGroup, ConfigGroupHeader, StickySaveBar
- i18n updates for all 3 locales (en/vi/zh)
This commit is contained in:
viettranx committed 2026-03-10 00:46:20 +07:00
1 parent 4df60649e5
commit baf645613e
21 files changed
+1297 -709

No files matched your search

@@ -0,0 +1,16 @@
interface ConfigGroupHeaderProps {
title: string;
description: string;
}
/** Section group header for organizing config sections into logical groups. */
export function ConfigGroupHeader({ title, description }: ConfigGroupHeaderProps) {
return (
<div className="space-y-0.5 pt-4 first:pt-0">
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h4>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
);
}
@@ -0,0 +1,93 @@
import type { LucideIcon } from "lucide-react";
import { Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export interface FeatureSwitchItem {
icon?: LucideIcon;
/** Accent class for icon color, e.g. "text-blue-500" */
iconClass?: string;
label: string;
/** Short description below the label explaining the impact */
hint: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
/** Contextual info message shown when the toggle is ON */
infoWhenOn?: string;
/** Accent classes for the info box (border + bg + text + dark variants) */
infoClass?: string;
}
interface FeatureSwitchGroupProps {
title: string;
description?: string;
items: FeatureSwitchItem[];
/** Subtle highlight border to draw attention */
highlight?: boolean;
}
export function FeatureSwitchGroup({
title,
description,
items,
highlight,
}: FeatureSwitchGroupProps) {
return (
<Card
className={cn(highlight && "border-primary/20 bg-primary/[0.02]")}
>
<CardHeader className="pb-3">
<CardTitle className="text-base">{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent className="space-y-0">
{items.map((item, i) => (
<div
key={i}
className={cn(
"py-4",
i < items.length - 1 && "border-b",
)}
>
{/* Header row: icon + label ... switch */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
{item.icon && (
<item.icon className={cn("h-4 w-4 shrink-0", item.iconClass ?? "text-muted-foreground")} />
)}
<div className="space-y-1">
<Label className="text-sm font-medium">{item.label}</Label>
<p className="text-xs text-muted-foreground">{item.hint}</p>
</div>
</div>
<Switch
checked={item.checked}
onCheckedChange={item.onCheckedChange}
className="shrink-0"
/>
</div>
{/* Conditional info box when enabled */}
{item.checked && item.infoWhenOn && (
<div className={cn(
"mt-3 flex items-start gap-2 rounded-md border px-3 py-2 text-xs",
item.infoClass ?? "border-primary/20 bg-primary/5 text-primary dark:bg-primary/10",
)}>
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{item.infoWhenOn}</span>
</div>
)}
</div>
))}
</CardContent>
</Card>
);
}
@@ -0,0 +1,45 @@
import { Save, Check, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
interface StickySaveBarProps {
onSave: () => void;
saving: boolean;
saved: boolean;
error?: string | null;
label?: string;
savingLabel?: string;
savedLabel?: string;
}
/** Sticky footer bar with save button, saving spinner, and success/error feedback. */
export function StickySaveBar({
onSave,
saving,
saved,
error,
label = "Save",
savingLabel = "Saving...",
savedLabel = "Saved",
}: StickySaveBarProps) {
return (
<div className="sticky bottom-0 z-10 -mx-4 mt-6 border-t bg-background/80 px-4 py-3 backdrop-blur-sm sm:-mx-6 sm:px-6">
{error && (
<div className="mb-2 flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<div className="flex items-center justify-end gap-2">
{saved && (
<span className="flex items-center gap-1 text-sm text-success">
<Check className="h-3.5 w-3.5" /> {savedLabel}
</span>
)}
<Button onClick={onSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? savingLabel : label}
</Button>
</div>
</div>
);
}
+10
View File
@@ -236,6 +236,16 @@
"retrying": "Retrying...",
"done": "done"
},
"configGroups": {
"core": "Core",
"coreDesc": "Essential settings that directly affect agent behavior",
"capabilities": "Capabilities",
"capabilitiesDesc": "Features the agent can use",
"performance": "Performance",
"performanceDesc": "Context management and execution environment",
"advanced": "Quality & Advanced",
"advancedDesc": "Quality assurance and custom configuration"
},
"configSections": {
"subagents": {
"title": "Subagents",
+10
View File
@@ -236,6 +236,16 @@
"retrying": "Đang thử lại...",
"done": "xong"
},
"configGroups": {
"core": "Cốt lõi",
"coreDesc": "Cài đặt thiết yếu ảnh hưởng trực tiếp đến hành vi agent",
"capabilities": "Khả năng",
"capabilitiesDesc": "Các tính năng agent có thể sử dụng",
"performance": "Hiệu suất",
"performanceDesc": "Quản lý ngữ cảnh và môi trường thực thi",
"advanced": "Chất lượng & Nâng cao",
"advancedDesc": "Kiểm tra chất lượng và cấu hình tùy chỉnh"
},
"configSections": {
"subagents": {
"title": "Agent con",
+10
View File
@@ -236,6 +236,16 @@
"retrying": "重试中...",
"done": "完成"
},
"configGroups": {
"core": "核心",
"coreDesc": "直接影响Agent行为的基本设置",
"capabilities": "能力",
"capabilitiesDesc": "Agent可以使用的功能",
"performance": "性能",
"performanceDesc": "上下文管理和执行环境",
"advanced": "质量与高级",
"advancedDesc": "质量保证和自定义配置"
},
"configSections": {
"subagents": {
"title": "子Agent",
@@ -1,8 +1,7 @@
import { useState } from "react";
import { Save, Check, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
import type {
AgentData,
SubagentsConfig,
@@ -107,85 +106,94 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
};
return (
<div className="max-w-4xl space-y-6">
<SubagentsSection
enabled={subEnabled}
value={sub}
onToggle={(v: boolean) => { setSubEnabled(v); if (!v) setSub({}); }}
onChange={setSub}
/>
<Separator />
<ToolPolicySection
enabled={toolsEnabled}
value={tools}
onToggle={(v: boolean) => { setToolsEnabled(v); if (!v) setTools({}); }}
onChange={setTools}
/>
<Separator />
<CompactionSection
enabled={compEnabled}
value={comp}
onToggle={(v: boolean) => { setCompEnabled(v); if (!v) setComp({}); }}
onChange={setComp}
/>
<Separator />
<ContextPruningSection
enabled={pruneEnabled}
value={prune}
onToggle={(v: boolean) => { setPruneEnabled(v); if (!v) setPrune({}); }}
onChange={setPrune}
/>
<Separator />
<SandboxSection
enabled={sbEnabled}
value={sb}
onToggle={(v: boolean) => { setSbEnabled(v); if (!v) setSb({}); }}
onChange={setSb}
/>
<Separator />
<MemorySection
enabled={memEnabled}
value={mem}
onToggle={(v: boolean) => { setMemEnabled(v); if (!v) setMem({}); }}
onChange={setMem}
/>
<Separator />
<div className="max-w-4xl space-y-4">
{/* Core */}
<ThinkingSection
value={thinkingLevel}
onChange={setThinkingLevel}
/>
<Separator />
<QualityGatesSection
enabled={qgEnabled}
value={qualityGates}
onToggle={(v: boolean) => { setQgEnabled(v); if (!v) setQualityGates([]); }}
onChange={setQualityGates}
/>
<Separator />
<OtherConfigSection
enabled={otherEnabled}
value={otherJson}
onToggle={(v: boolean) => { setOtherEnabled(v); if (!v) setOtherJson("{}"); }}
onChange={setOtherJson}
/>
{saveError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-2">
{saved && (
<span className="flex items-center gap-1 text-sm text-success">
<Check className="h-3.5 w-3.5" /> {t("config.saved")}
</span>
)}
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? t("config.saving") : t("config.saveConfig")}
</Button>
{/* Capabilities */}
<ConfigGroupHeader
title={t("configGroups.capabilities")}
description={t("configGroups.capabilitiesDesc")}
/>
<div className="space-y-4">
<MemorySection
enabled={memEnabled}
value={mem}
onToggle={(v: boolean) => { setMemEnabled(v); if (!v) setMem({}); }}
onChange={setMem}
/>
<SubagentsSection
enabled={subEnabled}
value={sub}
onToggle={(v: boolean) => { setSubEnabled(v); if (!v) setSub({}); }}
onChange={setSub}
/>
<ToolPolicySection
enabled={toolsEnabled}
value={tools}
onToggle={(v: boolean) => { setToolsEnabled(v); if (!v) setTools({}); }}
onChange={setTools}
/>
</div>
{/* Performance */}
<ConfigGroupHeader
title={t("configGroups.performance")}
description={t("configGroups.performanceDesc")}
/>
<div className="space-y-4">
<CompactionSection
enabled={compEnabled}
value={comp}
onToggle={(v: boolean) => { setCompEnabled(v); if (!v) setComp({}); }}
onChange={setComp}
/>
<ContextPruningSection
enabled={pruneEnabled}
value={prune}
onToggle={(v: boolean) => { setPruneEnabled(v); if (!v) setPrune({}); }}
onChange={setPrune}
/>
<SandboxSection
enabled={sbEnabled}
value={sb}
onToggle={(v: boolean) => { setSbEnabled(v); if (!v) setSb({}); }}
onChange={setSb}
/>
</div>
{/* Quality & Advanced */}
<ConfigGroupHeader
title={t("configGroups.advanced")}
description={t("configGroups.advancedDesc")}
/>
<div className="space-y-4">
<QualityGatesSection
enabled={qgEnabled}
value={qualityGates}
onToggle={(v: boolean) => { setQgEnabled(v); if (!v) setQualityGates([]); }}
onChange={setQualityGates}
/>
<OtherConfigSection
enabled={otherEnabled}
value={otherJson}
onToggle={(v: boolean) => { setOtherEnabled(v); if (!v) setOtherJson("{}"); }}
onChange={setOtherJson}
/>
</div>
<StickySaveBar
onSave={handleSave}
saving={saving}
saved={saved}
error={saveError}
label={t("config.saveConfig")}
savingLabel={t("config.saving")}
savedLabel={t("config.saved")}
/>
</div>
);
}
+42 -30
View File
@@ -10,15 +10,17 @@ import { useConfig } from "./hooks/use-config";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useIsMobile } from "@/hooks/use-media-query";
import { GatewaySection } from "./sections/gateway-section";
import { AgentsDefaultsSection } from "./sections/agents-defaults-section";
import { ToolsSection } from "./sections/tools-section";
import { SessionsSection } from "./sections/sessions-section";
import { ServerSection } from "./sections/server-section";
import { BehaviorSection } from "./sections/behavior-section";
import { AiDefaultsSection } from "./sections/ai-defaults-section";
import { QuotaSection } from "./sections/quota-section";
import { ToolsProfileSection } from "./sections/tools-profile-section";
import { ToolsExecSection } from "./sections/tools-exec-section";
import { ToolsWebSection } from "./sections/tools-web-section";
import { TtsSection } from "./sections/tts-section";
import { CronSection } from "./sections/cron-section";
import { TelemetrySection } from "./sections/telemetry-section";
import { BindingsSection } from "./sections/bindings-section";
import { QuotaSection } from "./sections/quota-section";
export function ConfigPage() {
const { t } = useTranslation("config");
@@ -82,7 +84,7 @@ export function ConfigPage() {
<span>{t("warning")}</span>
</div>
<Tabs orientation={isMobile ? "horizontal" : "vertical"} defaultValue="general" className="mt-4 items-start">
<Tabs orientation={isMobile ? "horizontal" : "vertical"} defaultValue="server" className="mt-4 items-start">
<TabsList
variant={isMobile ? "default" : "line"}
className={isMobile
@@ -90,22 +92,38 @@ export function ConfigPage() {
: "w-44 shrink-0 sticky top-6 rounded-lg border bg-card p-3 shadow-sm"
}
>
<TabsTrigger value="general">{t("tabs.general")}</TabsTrigger>
<TabsTrigger value="server">{t("tabs.server")}</TabsTrigger>
<TabsTrigger value="behavior">{t("tabs.behavior")}</TabsTrigger>
<TabsTrigger value="aiDefaults">{t("tabs.aiDefaults")}</TabsTrigger>
<TabsTrigger value="quota">{t("tabs.quota")}</TabsTrigger>
<TabsTrigger value="agents">{t("tabs.agents")}</TabsTrigger>
<TabsTrigger value="sessions">{t("tabs.sessions")}</TabsTrigger>
<TabsTrigger value="tools">{t("tabs.tools")}</TabsTrigger>
<TabsTrigger value="advanced">{t("tabs.advanced")}</TabsTrigger>
<TabsTrigger value="integrations">{t("tabs.integrations")}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4">
<GatewaySection
<TabsContent value="server" className="space-y-4">
<ServerSection
data={config.gateway as any}
onSave={(v) => patch({ gateway: v })}
saving={saving}
/>
</TabsContent>
<TabsContent value="behavior" className="space-y-4">
<BehaviorSection
config={config as any}
onPatch={patch}
saving={saving}
/>
</TabsContent>
<TabsContent value="aiDefaults" className="space-y-4">
<AiDefaultsSection
data={config.agents as any}
onSave={(v) => patch({ agents: v })}
saving={saving}
/>
</TabsContent>
<TabsContent value="quota" className="space-y-4">
<QuotaSection
data={config.gateway as any}
@@ -114,31 +132,25 @@ export function ConfigPage() {
/>
</TabsContent>
<TabsContent value="agents" className="space-y-4">
<AgentsDefaultsSection
data={config.agents as any}
onSave={(v) => patch({ agents: v })}
saving={saving}
/>
</TabsContent>
<TabsContent value="sessions" className="space-y-4">
<SessionsSection
data={config.sessions as any}
onSave={(v) => patch({ sessions: v })}
saving={saving}
/>
</TabsContent>
<TabsContent value="tools" className="space-y-4">
<ToolsSection
<ToolsProfileSection
data={config.tools as any}
onSave={(v) => patch({ tools: v })}
saving={saving}
/>
<ToolsExecSection
data={config.tools as any}
onSave={(v) => patch({ tools: v })}
saving={saving}
/>
<ToolsWebSection
data={config.tools as any}
onSave={(v) => patch({ tools: v })}
saving={saving}
/>
</TabsContent>
<TabsContent value="advanced" className="space-y-4">
<TabsContent value="integrations" className="space-y-4">
<TtsSection data={config.tts as any} />
<CronSection
data={config.cron as any}
@@ -21,7 +21,7 @@ interface Props {
saving: boolean;
}
export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
export function AiDefaultsSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const [draft, setDraft] = useState<AgentsData>(data ?? DEFAULT);
const [dirty, setDirty] = useState(false);
@@ -76,7 +76,6 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
<CardDescription>{t("agents.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Core fields */}
<ProviderModelSelect
provider={defaults.provider ?? ""}
onProviderChange={(v) => updateDefaults({ provider: v })}
@@ -147,14 +146,6 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
</div>
</div>
<div className="flex items-center justify-between">
<InfoLabel tip={t("agents.intentClassifyTip")}>{t("agents.intentClassify")}</InfoLabel>
<Switch
checked={defaults.intent_classify !== false}
onCheckedChange={(v) => updateDefaults({ intent_classify: v })}
/>
</div>
{/* Collapsible sub-sections */}
<SubSection
title={t("agents.subagents.title")}
@@ -326,7 +317,7 @@ function Field({
}: {
label: string;
tip?: string;
value: any;
value: any; // eslint-disable-line @typescript-eslint/no-explicit-any
onChange: (v: string) => void;
placeholder?: string;
type?: string;
@@ -0,0 +1,70 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { InfoLabel } from "@/components/shared/info-label";
interface RateValues {
max_message_chars?: number;
rate_limit_rpm?: number;
inbound_debounce_ms?: number;
}
interface Props {
value: RateValues;
onChange: (v: RateValues) => void;
}
/** Input rate limiting and message size constraints. */
export function BehaviorRateCard({ value, onChange }: Props) {
const { t } = useTranslation("config");
const update = (patch: Partial<RateValues>) => onChange({ ...value, ...patch });
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("behavior.rateLimitTitle")}</CardTitle>
<CardDescription>{t("behavior.rateLimitDescription")}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.maxMessageCharsTip")}>{t("gateway.maxMessageChars")}</InfoLabel>
<Input
type="number"
value={value.max_message_chars ?? ""}
onChange={(e) => update({ max_message_chars: Number(e.target.value) })}
placeholder="32000"
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.rateLimitRpmTip")}>{t("gateway.rateLimitRpm")}</InfoLabel>
<Input
type="number"
value={value.rate_limit_rpm ?? ""}
onChange={(e) => update({ rate_limit_rpm: Number(e.target.value) })}
placeholder="20 (0 = disabled)"
min={0}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.inboundDebounceMsTip")}>{t("gateway.inboundDebounceMs")}</InfoLabel>
<Input
type="number"
value={value.inbound_debounce_ms ?? ""}
onChange={(e) => update({ inbound_debounce_ms: Number(e.target.value) })}
placeholder="1000 (-1 = disabled)"
min={-1}
/>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,113 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { BehaviorUxCard } from "./behavior-ux-card";
import { BehaviorRateCard } from "./behavior-rate-card";
import { BehaviorSessionsCard } from "./behavior-sessions-card";
import { BehaviorSecurityCard } from "./behavior-security-card";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface Props {
config: Record<string, any>;
onPatch: (updates: Record<string, unknown>) => Promise<void>;
saving: boolean;
}
/** State container for Behavior tab — composes 4 sub-cards, patches multiple config keys. */
export function BehaviorSection({ config, onPatch, saving }: Props) {
const { t } = useTranslation("config");
const gw = config.gateway ?? {};
const ag = config.agents?.defaults ?? {};
const tl = config.tools ?? {};
const ss = config.sessions ?? {};
// UX toggles (from gateway + agents.defaults)
const [ux, setUx] = useState({
tool_status: gw.tool_status !== false,
block_reply: gw.block_reply ?? false,
intent_classify: ag.intent_classify !== false,
});
// Rate limiting (from gateway)
const [rate, setRate] = useState<{ max_message_chars?: number; rate_limit_rpm?: number; inbound_debounce_ms?: number }>({
max_message_chars: gw.max_message_chars,
rate_limit_rpm: gw.rate_limit_rpm,
inbound_debounce_ms: gw.inbound_debounce_ms,
});
// Sessions
const [sessions, setSessions] = useState<{ scope?: string; dm_scope?: string }>({
scope: ss.scope,
dm_scope: ss.dm_scope,
});
// Security (from gateway + tools)
const [security, setSecurity] = useState<{ injection_action?: string; scrub_credentials?: boolean }>({
injection_action: gw.injection_action,
scrub_credentials: tl.scrub_credentials,
});
const [dirty, setDirty] = useState(false);
// Reset drafts when external config changes
useEffect(() => {
setUx({
tool_status: gw.tool_status !== false,
block_reply: gw.block_reply ?? false,
intent_classify: ag.intent_classify !== false,
});
setRate({
max_message_chars: gw.max_message_chars,
rate_limit_rpm: gw.rate_limit_rpm,
inbound_debounce_ms: gw.inbound_debounce_ms,
});
setSessions({ scope: ss.scope, dm_scope: ss.dm_scope });
setSecurity({
injection_action: gw.injection_action,
scrub_credentials: tl.scrub_credentials,
});
setDirty(false);
}, [config]); // eslint-disable-line react-hooks/exhaustive-deps
const markDirty = <T,>(setter: React.Dispatch<React.SetStateAction<T>>) =>
(v: T) => { setter(v); setDirty(true); };
const handleSave = () => {
onPatch({
gateway: {
...gw,
tool_status: ux.tool_status,
block_reply: ux.block_reply,
max_message_chars: rate.max_message_chars,
rate_limit_rpm: rate.rate_limit_rpm,
inbound_debounce_ms: rate.inbound_debounce_ms,
injection_action: security.injection_action,
},
agents: {
...config.agents,
defaults: { ...ag, intent_classify: ux.intent_classify },
},
tools: { ...tl, scrub_credentials: security.scrub_credentials },
sessions: { ...ss, ...sessions },
});
};
return (
<div className="space-y-4">
<BehaviorUxCard value={ux} onChange={markDirty(setUx)} />
<BehaviorRateCard value={rate} onChange={markDirty(setRate)} />
<BehaviorSessionsCard value={sessions} onChange={markDirty(setSessions)} />
<BehaviorSecurityCard value={security} onChange={markDirty(setSecurity)} />
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,118 @@
import { ShieldCheck, KeyRound, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface SecurityValues {
injection_action?: string;
scrub_credentials?: boolean;
}
interface Props {
value: SecurityValues;
onChange: (v: SecurityValues) => void;
}
/** Input security settings with visual emphasis on impact. */
export function BehaviorSecurityCard({ value, onChange }: Props) {
const { t } = useTranslation("config");
const action = value.injection_action ?? "warn";
const scrub = value.scrub_credentials !== false;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("behavior.securityTitle")}</CardTitle>
<CardDescription>{t("behavior.securityDescription")}</CardDescription>
</CardHeader>
<CardContent className="space-y-0">
{/* Injection Action */}
<div className="border-b py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div className="space-y-1">
<Label className="text-sm font-medium">{t("gateway.injectionAction")}</Label>
<p className="text-xs text-muted-foreground">{t("behavior.injectionActionHint")}</p>
</div>
</div>
<Select
value={action}
onValueChange={(v) => onChange({ ...value, injection_action: v })}
>
<SelectTrigger className="w-32 shrink-0"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="log">Log</SelectItem>
<SelectItem value="warn">Warn</SelectItem>
<SelectItem value="block">Block</SelectItem>
</SelectContent>
</Select>
</div>
{action === "block" && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("behavior.injectionBlockInfo")}</span>
</div>
)}
{action === "off" && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("behavior.injectionOffInfo")}</span>
</div>
)}
</div>
{/* Scrub Credentials */}
<div className="py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<KeyRound className={cn("h-4 w-4 shrink-0", scrub ? "text-emerald-500" : "text-red-500")} />
<div className="space-y-1">
<Label className="text-sm font-medium">{t("tools.scrubCredentials")}</Label>
<p className="text-xs text-muted-foreground">{t("behavior.scrubCredentialsHint")}</p>
</div>
</div>
<Switch
checked={scrub}
onCheckedChange={(v) => onChange({ ...value, scrub_credentials: v })}
className="shrink-0"
/>
</div>
{scrub && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("behavior.scrubCredentialsInfo")}</span>
</div>
)}
{!scrub && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("behavior.scrubCredentialsOffInfo")}</span>
</div>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,72 @@
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
interface SessionsValues {
scope?: string;
dm_scope?: string;
}
interface Props {
value: SessionsValues;
onChange: (v: SessionsValues) => void;
}
/** Session scoping settings (scope + DM scope). */
export function BehaviorSessionsCard({ value, onChange }: Props) {
const { t } = useTranslation("config");
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("behavior.sessionsTitle")}</CardTitle>
<CardDescription>{t("behavior.sessionsDescription")}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<InfoLabel tip={t("sessions.scopeTip")}>{t("sessions.scope")}</InfoLabel>
<Select
value={value.scope ?? "per-sender"}
onValueChange={(v) => onChange({ ...value, scope: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="per-sender">Per Sender</SelectItem>
<SelectItem value="global">Global</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("sessions.dmScopeTip")}>{t("sessions.dmScope")}</InfoLabel>
<Select
value={value.dm_scope ?? "per-channel-peer"}
onValueChange={(v) => onChange({ ...value, dm_scope: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="main">Main</SelectItem>
<SelectItem value="per-peer">Per Peer</SelectItem>
<SelectItem value="per-channel-peer">Per Channel Peer</SelectItem>
<SelectItem value="per-account-channel-peer">Per Account Channel Peer</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,62 @@
import { Eye, MessageSquareText, Brain } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FeatureSwitchGroup } from "@/components/shared/feature-switch-group";
import type { FeatureSwitchItem } from "@/components/shared/feature-switch-group";
interface UxValues {
tool_status: boolean;
block_reply: boolean;
intent_classify: boolean;
}
interface Props {
value: UxValues;
onChange: (v: UxValues) => void;
}
/** High-impact UX toggles with icon, hint, and contextual info. */
export function BehaviorUxCard({ value, onChange }: Props) {
const { t } = useTranslation("config");
const items: FeatureSwitchItem[] = [
{
icon: Eye,
iconClass: "text-blue-500",
label: t("gateway.toolStatus"),
hint: t("behavior.toolStatusHint"),
checked: value.tool_status !== false,
onCheckedChange: (v) => onChange({ ...value, tool_status: v }),
infoWhenOn: t("behavior.toolStatusInfo"),
infoClass: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300",
},
{
icon: MessageSquareText,
iconClass: "text-emerald-500",
label: t("gateway.blockReply"),
hint: t("behavior.blockReplyHint"),
checked: value.block_reply ?? false,
onCheckedChange: (v) => onChange({ ...value, block_reply: v }),
infoWhenOn: t("behavior.blockReplyInfo"),
infoClass: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300",
},
{
icon: Brain,
iconClass: "text-violet-500",
label: t("agents.intentClassify"),
hint: t("behavior.intentClassifyHint"),
checked: value.intent_classify !== false,
onCheckedChange: (v) => onChange({ ...value, intent_classify: v }),
infoWhenOn: t("behavior.intentClassifyInfo"),
infoClass: "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-800 dark:bg-violet-950/30 dark:text-violet-300",
},
];
return (
<FeatureSwitchGroup
title={t("behavior.uxTitle")}
description={t("behavior.uxDescription")}
items={items}
highlight
/>
);
}
@@ -1,191 +0,0 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
import { TagInput } from "@/components/shared/tag-input";
interface GatewayData {
host?: string;
port?: number;
token?: string;
owner_ids?: string[];
allowed_origins?: string[];
max_message_chars?: number;
rate_limit_rpm?: number;
injection_action?: string;
inbound_debounce_ms?: number;
block_reply?: boolean;
tool_status?: boolean;
}
const DEFAULT: GatewayData = {};
function isSecret(val: unknown): boolean {
return typeof val === "string" && val.includes("***");
}
interface Props {
data: GatewayData | undefined;
onSave: (value: GatewayData) => Promise<void>;
saving: boolean;
}
export function GatewaySection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const [draft, setDraft] = useState<GatewayData>(data ?? DEFAULT);
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(data ?? DEFAULT);
setDirty(false);
}, [data]);
const update = (patch: Partial<GatewayData>) => {
setDraft((prev) => ({ ...prev, ...patch }));
setDirty(true);
};
const handleSave = () => {
// Don't send masked secret fields back
const toSave = { ...draft };
if (isSecret(toSave.token)) {
delete toSave.token;
}
onSave(toSave);
};
if (!data) return null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("gateway.title")}</CardTitle>
<CardDescription>{t("gateway.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.hostTip")}>{t("gateway.host")}</InfoLabel>
<Input
value={draft.host ?? ""}
onChange={(e) => update({ host: e.target.value })}
placeholder="0.0.0.0"
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.portTip")}>{t("gateway.port")}</InfoLabel>
<Input
type="number"
value={draft.port ?? ""}
onChange={(e) => update({ port: Number(e.target.value) })}
placeholder="18790"
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.tokenTip")}>{t("gateway.token")}</InfoLabel>
<Input
type="password"
value={draft.token ?? ""}
disabled={isSecret(draft.token)}
readOnly={isSecret(draft.token)}
onChange={(e) => update({ token: e.target.value })}
/>
{isSecret(draft.token) && (
<p className="text-xs text-muted-foreground">{t("gateway.tokenManaged")}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.ownerIdsTip")}>{t("gateway.ownerIds")}</InfoLabel>
<TagInput
value={draft.owner_ids ?? []}
onChange={(v) => update({ owner_ids: v })}
placeholder={t("gateway.ownerIdsPlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.allowedOriginsTip")}>{t("gateway.allowedOrigins")}</InfoLabel>
<TagInput
value={draft.allowed_origins ?? []}
onChange={(v) => update({ allowed_origins: v })}
placeholder={t("gateway.allowedOriginsPlaceholder")}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.maxMessageCharsTip")}>{t("gateway.maxMessageChars")}</InfoLabel>
<Input
type="number"
value={draft.max_message_chars ?? ""}
onChange={(e) => update({ max_message_chars: Number(e.target.value) })}
placeholder="32000"
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.rateLimitRpmTip")}>{t("gateway.rateLimitRpm")}</InfoLabel>
<Input
type="number"
value={draft.rate_limit_rpm ?? ""}
onChange={(e) => update({ rate_limit_rpm: Number(e.target.value) })}
placeholder="20 (0 = disabled)"
min={0}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.inboundDebounceMsTip")}>{t("gateway.inboundDebounceMs")}</InfoLabel>
<Input
type="number"
value={draft.inbound_debounce_ms ?? ""}
onChange={(e) => update({ inbound_debounce_ms: Number(e.target.value) })}
placeholder="1000 (-1 = disabled)"
min={-1}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.injectionActionTip")}>{t("gateway.injectionAction")}</InfoLabel>
<Select value={draft.injection_action ?? "warn"} onValueChange={(v) => update({ injection_action: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="log">Log</SelectItem>
<SelectItem value="warn">Warn</SelectItem>
<SelectItem value="block">Block</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.blockReplyTip")}>{t("gateway.blockReply")}</InfoLabel>
<div className="flex items-center h-9">
<Switch checked={draft.block_reply ?? false} onCheckedChange={(v) => update({ block_reply: v })} />
</div>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.toolStatusTip")}>{t("gateway.toolStatus")}</InfoLabel>
<div className="flex items-center h-9">
<Switch checked={draft.tool_status !== false} onCheckedChange={(v) => update({ tool_status: v })} />
</div>
</div>
</div>
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,128 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { InfoLabel } from "@/components/shared/info-label";
import { TagInput } from "@/components/shared/tag-input";
interface ServerData {
host?: string;
port?: number;
token?: string;
owner_ids?: string[];
allowed_origins?: string[];
}
const DEFAULT: ServerData = {};
function isSecret(val: unknown): boolean {
return typeof val === "string" && val.includes("***");
}
interface Props {
data: ServerData | undefined;
onSave: (value: ServerData) => Promise<void>;
saving: boolean;
}
export function ServerSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const [draft, setDraft] = useState<ServerData>(data ?? DEFAULT);
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(data ?? DEFAULT);
setDirty(false);
}, [data]);
const update = (patch: Partial<ServerData>) => {
setDraft((prev) => ({ ...prev, ...patch }));
setDirty(true);
};
const handleSave = () => {
const toSave = { ...draft };
if (isSecret(toSave.token)) delete toSave.token;
onSave(toSave);
};
if (!data) return null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("server.title")}</CardTitle>
<CardDescription>{t("server.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.hostTip")}>{t("gateway.host")}</InfoLabel>
<Input
value={draft.host ?? ""}
onChange={(e) => update({ host: e.target.value })}
placeholder="0.0.0.0"
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.portTip")}>{t("gateway.port")}</InfoLabel>
<Input
type="number"
value={draft.port ?? ""}
onChange={(e) => update({ port: Number(e.target.value) })}
placeholder="18790"
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.tokenTip")}>{t("gateway.token")}</InfoLabel>
<Input
type="password"
value={draft.token ?? ""}
disabled={isSecret(draft.token)}
readOnly={isSecret(draft.token)}
onChange={(e) => update({ token: e.target.value })}
/>
{isSecret(draft.token) && (
<p className="text-xs text-muted-foreground">{t("gateway.tokenManaged")}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.ownerIdsTip")}>{t("gateway.ownerIds")}</InfoLabel>
<TagInput
value={draft.owner_ids ?? []}
onChange={(v) => update({ owner_ids: v })}
placeholder={t("gateway.ownerIdsPlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("gateway.allowedOriginsTip")}>{t("gateway.allowedOrigins")}</InfoLabel>
<TagInput
value={draft.allowed_origins ?? []}
onChange={(v) => update({ allowed_origins: v })}
placeholder={t("gateway.allowedOriginsPlaceholder")}
/>
</div>
</div>
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -1,87 +0,0 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
interface SessionsData {
storage?: string;
scope?: string;
dm_scope?: string;
main_key?: string;
}
const DEFAULT: SessionsData = {};
interface Props {
data: SessionsData | undefined;
onSave: (value: SessionsData) => Promise<void>;
saving: boolean;
}
export function SessionsSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const [draft, setDraft] = useState<SessionsData>(data ?? DEFAULT);
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(data ?? DEFAULT);
setDirty(false);
}, [data]);
const update = (patch: Partial<SessionsData>) => {
setDraft((prev) => ({ ...prev, ...patch }));
setDirty(true);
};
if (!data) return null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("sessions.title")}</CardTitle>
<CardDescription>{t("sessions.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid gap-1.5">
<InfoLabel tip={t("sessions.scopeTip")}>{t("sessions.scope")}</InfoLabel>
<Select value={draft.scope ?? "per-sender"} onValueChange={(v) => update({ scope: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="per-sender">Per Sender</SelectItem>
<SelectItem value="global">Global</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("sessions.dmScopeTip")}>{t("sessions.dmScope")}</InfoLabel>
<Select value={draft.dm_scope ?? "per-channel-peer"} onValueChange={(v) => update({ dm_scope: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="main">Main</SelectItem>
<SelectItem value="per-peer">Per Peer</SelectItem>
<SelectItem value="per-channel-peer">Per Channel Peer</SelectItem>
<SelectItem value="per-account-channel-peer">Per Account Channel Peer</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,110 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolsData = Record<string, any>;
interface Props {
data: ToolsData | undefined;
onSave: (value: ToolsData) => Promise<void>;
saving: boolean;
}
export function ToolsExecSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const [draft, setDraft] = useState<ToolsData>(data ?? {});
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(data ?? {});
setDirty(false);
}, [data]);
const updateNested = (section: string, patch: Record<string, any>) => {
setDraft((prev) => ({
...prev,
[section]: { ...(prev[section] ?? {}), ...patch },
}));
setDirty(true);
};
if (!data) return null;
const exec = draft.execApproval ?? {};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("tools.execApproval")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.execSecurityTip")}>{t("tools.execSecurity")}</InfoLabel>
<Select value={exec.security ?? "full"} onValueChange={(v) => updateNested("execApproval", { security: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="deny">Deny All</SelectItem>
<SelectItem value="allowlist">Allowlist</SelectItem>
<SelectItem value="full">Full (Allow All)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.execAskModeTip")}>{t("tools.execAskMode")}</InfoLabel>
<Select value={exec.ask ?? "off"} onValueChange={(v) => updateNested("execApproval", { ask: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="on-miss">On Miss</SelectItem>
<SelectItem value="always">Always</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{exec.security === "allowlist" && (
<div className="grid gap-1.5">
<Label>{t("tools.execAllowlistLabel")}</Label>
<Textarea
value={(exec.allowlist ?? []).join("\n")}
onChange={(e) =>
updateNested("execApproval", {
allowlist: e.target.value.split("\n").filter(Boolean),
})
}
className="min-h-[80px] font-mono text-xs"
placeholder="git *&#10;npm *&#10;ls *"
/>
</div>
)}
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,120 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
import { ToolNameSelect } from "@/components/shared/tool-name-select";
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolsData = Record<string, any>;
interface Props {
data: ToolsData | undefined;
onSave: (value: ToolsData) => Promise<void>;
saving: boolean;
}
export function ToolsProfileSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const [draft, setDraft] = useState<ToolsData>(data ?? {});
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(data ?? {});
setDirty(false);
}, [data]);
const update = (patch: Partial<ToolsData>) => {
setDraft((prev) => ({ ...prev, ...patch }));
setDirty(true);
};
if (!data) return null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("tools.title")}</CardTitle>
<CardDescription>{t("tools.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.profileTip")}>{t("tools.profile")}</InfoLabel>
<Select value={draft.profile ?? ""} onValueChange={(v) => update({ profile: v })}>
<SelectTrigger>
<SelectValue placeholder={t("tools.profilePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="minimal">Minimal</SelectItem>
<SelectItem value="coding">Coding</SelectItem>
<SelectItem value="messaging">Messaging</SelectItem>
<SelectItem value="full">Full</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.rateLimitPerHourTip")}>{t("tools.rateLimitPerHour")}</InfoLabel>
<Input
type="number"
value={draft.rate_limit_per_hour ?? ""}
onChange={(e) => update({ rate_limit_per_hour: Number(e.target.value) })}
placeholder="0 = disabled"
min={0}
/>
</div>
</div>
<div className="space-y-4">
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.allowTip")}>{t("tools.allow")}</InfoLabel>
<ToolNameSelect
value={draft.allow ?? []}
onChange={(v) => update({ allow: v })}
placeholder={t("tools.allowPlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.denyTip")}>{t("tools.deny")}</InfoLabel>
<ToolNameSelect
value={draft.deny ?? []}
onChange={(v) => update({ deny: v })}
placeholder={t("tools.denyPlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.alsoAllowTip")}>{t("tools.alsoAllow")}</InfoLabel>
<ToolNameSelect
value={draft.alsoAllow ?? []}
onChange={(v) => update({ alsoAllow: v })}
placeholder={t("tools.alsoAllowPlaceholder")}
/>
</div>
</div>
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -1,314 +0,0 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { InfoLabel } from "@/components/shared/info-label";
import { ToolNameSelect } from "@/components/shared/tool-name-select";
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolsData = Record<string, any>;
const DEFAULT: ToolsData = {};
interface Props {
data: ToolsData | undefined;
onSave: (value: ToolsData) => Promise<void>;
saving: boolean;
}
export function ToolsSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const [draft, setDraft] = useState<ToolsData>(data ?? DEFAULT);
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(data ?? DEFAULT);
setDirty(false);
}, [data]);
const update = (patch: Partial<ToolsData>) => {
setDraft((prev) => ({ ...prev, ...patch }));
setDirty(true);
};
const updateNested = (section: string, patch: Record<string, any>) => {
setDraft((prev) => ({
...prev,
[section]: { ...(prev[section] ?? {}), ...patch },
}));
setDirty(true);
};
if (!data) return null;
const exec = draft.execApproval ?? {};
const webFetch = draft.web_fetch ?? {};
const web = draft.web ?? {};
const brave = web.brave ?? {};
const ddg = web.duckduckgo ?? {};
const browser = draft.browser ?? {};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("tools.title")}</CardTitle>
<CardDescription>{t("tools.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Profile & Lists */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.profileTip")}>{t("tools.profile")}</InfoLabel>
<Select value={draft.profile ?? ""} onValueChange={(v) => update({ profile: v })}>
<SelectTrigger>
<SelectValue placeholder={t("tools.profilePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="minimal">Minimal</SelectItem>
<SelectItem value="coding">Coding</SelectItem>
<SelectItem value="messaging">Messaging</SelectItem>
<SelectItem value="full">Full</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.rateLimitPerHourTip")}>{t("tools.rateLimitPerHour")}</InfoLabel>
<Input
type="number"
value={draft.rate_limit_per_hour ?? ""}
onChange={(e) => update({ rate_limit_per_hour: Number(e.target.value) })}
placeholder="0 = disabled"
min={0}
/>
</div>
</div>
<div className="space-y-4">
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.allowTip")}>{t("tools.allow")}</InfoLabel>
<ToolNameSelect
value={draft.allow ?? []}
onChange={(v) => { update({ allow: v }); }}
placeholder={t("tools.allowPlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.denyTip")}>{t("tools.deny")}</InfoLabel>
<ToolNameSelect
value={draft.deny ?? []}
onChange={(v) => { update({ deny: v }); }}
placeholder={t("tools.denyPlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.alsoAllowTip")}>{t("tools.alsoAllow")}</InfoLabel>
<ToolNameSelect
value={draft.alsoAllow ?? []}
onChange={(v) => { update({ alsoAllow: v }); }}
placeholder={t("tools.alsoAllowPlaceholder")}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<InfoLabel tip={t("tools.scrubCredentialsTip")}>{t("tools.scrubCredentials")}</InfoLabel>
</div>
<Switch
checked={draft.scrub_credentials !== false}
onCheckedChange={(v) => update({ scrub_credentials: v })}
/>
</div>
<Separator />
{/* Exec Approval */}
<div>
<h4 className="mb-3 text-sm font-medium">{t("tools.execApproval")}</h4>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.execSecurityTip")}>{t("tools.execSecurity")}</InfoLabel>
<Select value={exec.security ?? "full"} onValueChange={(v) => updateNested("execApproval", { security: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">Deny All</SelectItem>
<SelectItem value="allowlist">Allowlist</SelectItem>
<SelectItem value="full">Full (Allow All)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.execAskModeTip")}>{t("tools.execAskMode")}</InfoLabel>
<Select value={exec.ask ?? "off"} onValueChange={(v) => updateNested("execApproval", { ask: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="on-miss">On Miss</SelectItem>
<SelectItem value="always">Always</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{exec.security === "allowlist" && (
<div className="mt-3 grid gap-1.5">
<Label>{t("tools.execAllowlistLabel")}</Label>
<Textarea
value={(exec.allowlist ?? []).join("\n")}
onChange={(e) =>
updateNested("execApproval", {
allowlist: e.target.value.split("\n").filter(Boolean),
})
}
className="min-h-[80px] font-mono text-xs"
placeholder="git *&#10;npm *&#10;ls *"
/>
</div>
)}
</div>
<Separator />
{/* Web Search */}
<div>
<h4 className="mb-3 text-sm font-medium">{t("tools.webSearch")}</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>DuckDuckGo</Label>
<Switch
checked={ddg.enabled !== false}
onCheckedChange={(v) => updateNested("web", { duckduckgo: { ...ddg, enabled: v } })}
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground">{t("tools.maxResults")}</Label>
<Input
type="number"
value={ddg.max_results ?? ""}
onChange={(e) => updateNested("web", { duckduckgo: { ...ddg, max_results: Number(e.target.value) } })}
placeholder="5"
min={1}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Brave Search</Label>
<Switch
checked={brave.enabled ?? false}
onCheckedChange={(v) => updateNested("web", { brave: { ...brave, enabled: v } })}
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground">{t("tools.maxResults")}</Label>
<Input
type="number"
value={brave.max_results ?? ""}
onChange={(e) => updateNested("web", { brave: { ...brave, max_results: Number(e.target.value) } })}
placeholder="5"
min={1}
/>
</div>
</div>
</div>
</div>
<Separator />
{/* Web Fetch */}
<div>
<h4 className="mb-3 text-sm font-medium">{t("tools.webFetch")}</h4>
<div className="grid gap-3">
<div className="grid gap-1.5 max-w-xs">
<InfoLabel tip={t("tools.webFetchPolicyTip")}>{t("tools.webFetchPolicy")}</InfoLabel>
<Select
value={webFetch.policy ?? "allow_all"}
onValueChange={(v) => updateNested("web_fetch", { ...webFetch, policy: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow_all">Allow All</SelectItem>
<SelectItem value="allowlist">Allowlist</SelectItem>
</SelectContent>
</Select>
</div>
{webFetch.policy === "allowlist" && (
<div className="grid gap-1.5">
<Label>{t("tools.allowedDomains")}</Label>
<Textarea
value={(webFetch.allowed_domains ?? []).join("\n")}
onChange={(e) =>
updateNested("web_fetch", {
...webFetch,
allowed_domains: e.target.value.split("\n").filter(Boolean),
})
}
className="min-h-[80px] font-mono text-xs"
placeholder={"github.com\n*.wikipedia.org\ndocs.google.com"}
/>
</div>
)}
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.blockedDomainsTip")}>{t("tools.blockedDomains")}</InfoLabel>
<Textarea
value={(webFetch.blocked_domains ?? []).join("\n")}
onChange={(e) =>
updateNested("web_fetch", {
...webFetch,
blocked_domains: e.target.value.split("\n").filter(Boolean),
})
}
className="min-h-[80px] font-mono text-xs"
placeholder={"ifconfig.co\nipinfo.io\n*.whatismyip.com"}
/>
</div>
</div>
</div>
<Separator />
{/* Browser */}
<div>
<h4 className="mb-3 text-sm font-medium">{t("tools.browser")}</h4>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Label>{t("tools.browserEnabled")}</Label>
<Switch
checked={browser.enabled !== false}
onCheckedChange={(v) => updateNested("browser", { enabled: v })}
/>
</div>
<div className="flex items-center gap-2">
<Label>{t("tools.browserHeadless")}</Label>
<Switch
checked={browser.headless !== false}
onCheckedChange={(v) => updateNested("browser", { headless: v })}
/>
</div>
</div>
</div>
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,192 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { InfoLabel } from "@/components/shared/info-label";
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolsData = Record<string, any>;
interface Props {
data: ToolsData | undefined;
onSave: (value: ToolsData) => Promise<void>;
saving: boolean;
}
export function ToolsWebSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const [draft, setDraft] = useState<ToolsData>(data ?? {});
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(data ?? {});
setDirty(false);
}, [data]);
const updateNested = (section: string, patch: Record<string, any>) => {
setDraft((prev) => ({
...prev,
[section]: { ...(prev[section] ?? {}), ...patch },
}));
setDirty(true);
};
if (!data) return null;
const web = draft.web ?? {};
const ddg = web.duckduckgo ?? {};
const brave = web.brave ?? {};
const webFetch = draft.web_fetch ?? {};
const browser = draft.browser ?? {};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("tools.webSearch")}</CardTitle>
<CardDescription>{t("tools.webFetch")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Web Search */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>DuckDuckGo</Label>
<Switch
checked={ddg.enabled !== false}
onCheckedChange={(v) => updateNested("web", { duckduckgo: { ...ddg, enabled: v } })}
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground">{t("tools.maxResults")}</Label>
<Input
type="number"
value={ddg.max_results ?? ""}
onChange={(e) => updateNested("web", { duckduckgo: { ...ddg, max_results: Number(e.target.value) } })}
placeholder="5"
min={1}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Brave Search</Label>
<Switch
checked={brave.enabled ?? false}
onCheckedChange={(v) => updateNested("web", { brave: { ...brave, enabled: v } })}
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground">{t("tools.maxResults")}</Label>
<Input
type="number"
value={brave.max_results ?? ""}
onChange={(e) => updateNested("web", { brave: { ...brave, max_results: Number(e.target.value) } })}
placeholder="5"
min={1}
/>
</div>
</div>
</div>
<Separator />
{/* Web Fetch */}
<div className="grid gap-3">
<div className="grid gap-1.5 max-w-xs">
<InfoLabel tip={t("tools.webFetchPolicyTip")}>{t("tools.webFetchPolicy")}</InfoLabel>
<Select
value={webFetch.policy ?? "allow_all"}
onValueChange={(v) => updateNested("web_fetch", { ...webFetch, policy: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="allow_all">Allow All</SelectItem>
<SelectItem value="allowlist">Allowlist</SelectItem>
</SelectContent>
</Select>
</div>
{webFetch.policy === "allowlist" && (
<div className="grid gap-1.5">
<Label>{t("tools.allowedDomains")}</Label>
<Textarea
value={(webFetch.allowed_domains ?? []).join("\n")}
onChange={(e) =>
updateNested("web_fetch", {
...webFetch,
allowed_domains: e.target.value.split("\n").filter(Boolean),
})
}
className="min-h-[80px] font-mono text-xs"
placeholder={"github.com\n*.wikipedia.org\ndocs.google.com"}
/>
</div>
)}
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.blockedDomainsTip")}>{t("tools.blockedDomains")}</InfoLabel>
<Textarea
value={(webFetch.blocked_domains ?? []).join("\n")}
onChange={(e) =>
updateNested("web_fetch", {
...webFetch,
blocked_domains: e.target.value.split("\n").filter(Boolean),
})
}
className="min-h-[80px] font-mono text-xs"
placeholder={"ifconfig.co\nipinfo.io\n*.whatismyip.com"}
/>
</div>
</div>
<Separator />
{/* Browser */}
<div>
<h4 className="mb-3 text-sm font-medium">{t("tools.browser")}</h4>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Label>{t("tools.browserEnabled")}</Label>
<Switch
checked={browser.enabled !== false}
onCheckedChange={(v) => updateNested("browser", { enabled: v })}
/>
</div>
<div className="flex items-center gap-2">
<Label>{t("tools.browserHeadless")}</Label>
<Switch
checked={browser.headless !== false}
onCheckedChange={(v) => updateNested("browser", { headless: v })}
/>
</div>
</div>
</div>
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}