diff --git a/ui/web/src/components/shared/config-group-header.tsx b/ui/web/src/components/shared/config-group-header.tsx new file mode 100644 index 00000000..6c707cc2 --- /dev/null +++ b/ui/web/src/components/shared/config-group-header.tsx @@ -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 ( +
+

+ {title} +

+

{description}

+
+ ); +} diff --git a/ui/web/src/components/shared/feature-switch-group.tsx b/ui/web/src/components/shared/feature-switch-group.tsx new file mode 100644 index 00000000..aabeb217 --- /dev/null +++ b/ui/web/src/components/shared/feature-switch-group.tsx @@ -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 ( + + + {title} + {description && {description}} + + + {items.map((item, i) => ( +
+ {/* Header row: icon + label ... switch */} +
+
+ {item.icon && ( + + )} +
+ +

{item.hint}

+
+
+ +
+ + {/* Conditional info box when enabled */} + {item.checked && item.infoWhenOn && ( +
+ + {item.infoWhenOn} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/ui/web/src/components/shared/sticky-save-bar.tsx b/ui/web/src/components/shared/sticky-save-bar.tsx new file mode 100644 index 00000000..9def77c9 --- /dev/null +++ b/ui/web/src/components/shared/sticky-save-bar.tsx @@ -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 ( +
+ {error && ( +
+ + {error} +
+ )} +
+ {saved && ( + + {savedLabel} + + )} + +
+
+ ); +} diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index bf3e257b..e9cbc515 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 60ff5d14..8c57f3e6 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -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", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index 772b0088..f88506f8 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -236,6 +236,16 @@ "retrying": "重试中...", "done": "完成" }, + "configGroups": { + "core": "核心", + "coreDesc": "直接影响Agent行为的基本设置", + "capabilities": "能力", + "capabilitiesDesc": "Agent可以使用的功能", + "performance": "性能", + "performanceDesc": "上下文管理和执行环境", + "advanced": "质量与高级", + "advancedDesc": "质量保证和自定义配置" + }, "configSections": { "subagents": { "title": "子Agent", diff --git a/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx index 24ce5ec4..6bc101be 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx @@ -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 ( -
- { setSubEnabled(v); if (!v) setSub({}); }} - onChange={setSub} - /> - - { setToolsEnabled(v); if (!v) setTools({}); }} - onChange={setTools} - /> - - { setCompEnabled(v); if (!v) setComp({}); }} - onChange={setComp} - /> - - { setPruneEnabled(v); if (!v) setPrune({}); }} - onChange={setPrune} - /> - - { setSbEnabled(v); if (!v) setSb({}); }} - onChange={setSb} - /> - - { setMemEnabled(v); if (!v) setMem({}); }} - onChange={setMem} - /> - +
+ {/* Core */} - - { setQgEnabled(v); if (!v) setQualityGates([]); }} - onChange={setQualityGates} - /> - - { setOtherEnabled(v); if (!v) setOtherJson("{}"); }} - onChange={setOtherJson} - /> - {saveError && ( -
- - {saveError} -
- )} -
- {saved && ( - - {t("config.saved")} - - )} - + {/* Capabilities */} + +
+ { setMemEnabled(v); if (!v) setMem({}); }} + onChange={setMem} + /> + { setSubEnabled(v); if (!v) setSub({}); }} + onChange={setSub} + /> + { setToolsEnabled(v); if (!v) setTools({}); }} + onChange={setTools} + />
+ + {/* Performance */} + +
+ { setCompEnabled(v); if (!v) setComp({}); }} + onChange={setComp} + /> + { setPruneEnabled(v); if (!v) setPrune({}); }} + onChange={setPrune} + /> + { setSbEnabled(v); if (!v) setSb({}); }} + onChange={setSb} + /> +
+ + {/* Quality & Advanced */} + +
+ { setQgEnabled(v); if (!v) setQualityGates([]); }} + onChange={setQualityGates} + /> + { setOtherEnabled(v); if (!v) setOtherJson("{}"); }} + onChange={setOtherJson} + /> +
+ +
); } diff --git a/ui/web/src/pages/config/config-page.tsx b/ui/web/src/pages/config/config-page.tsx index 31ff3b27..2370feeb 100644 --- a/ui/web/src/pages/config/config-page.tsx +++ b/ui/web/src/pages/config/config-page.tsx @@ -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() { {t("warning")}
- + - {t("tabs.general")} + {t("tabs.server")} + {t("tabs.behavior")} + {t("tabs.aiDefaults")} {t("tabs.quota")} - {t("tabs.agents")} - {t("tabs.sessions")} {t("tabs.tools")} - {t("tabs.advanced")} + {t("tabs.integrations")} - - + patch({ gateway: v })} saving={saving} /> + + + + + + patch({ agents: v })} + saving={saving} + /> + + - - patch({ agents: v })} - saving={saving} - /> - - - - patch({ sessions: v })} - saving={saving} - /> - - - patch({ tools: v })} + saving={saving} + /> + patch({ tools: v })} + saving={saving} + /> + patch({ tools: v })} saving={saving} /> - + (data ?? DEFAULT); const [dirty, setDirty] = useState(false); @@ -76,7 +76,6 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) { {t("agents.description")} - {/* Core fields */} updateDefaults({ provider: v })} @@ -147,14 +146,6 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
-
- {t("agents.intentClassify")} - updateDefaults({ intent_classify: v })} - /> -
- {/* Collapsible sub-sections */} void; placeholder?: string; type?: string; diff --git a/ui/web/src/pages/config/sections/behavior-rate-card.tsx b/ui/web/src/pages/config/sections/behavior-rate-card.tsx new file mode 100644 index 00000000..5c891733 --- /dev/null +++ b/ui/web/src/pages/config/sections/behavior-rate-card.tsx @@ -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) => onChange({ ...value, ...patch }); + + return ( + + + {t("behavior.rateLimitTitle")} + {t("behavior.rateLimitDescription")} + + +
+
+ {t("gateway.maxMessageChars")} + update({ max_message_chars: Number(e.target.value) })} + placeholder="32000" + /> +
+
+ {t("gateway.rateLimitRpm")} + update({ rate_limit_rpm: Number(e.target.value) })} + placeholder="20 (0 = disabled)" + min={0} + /> +
+
+ {t("gateway.inboundDebounceMs")} + update({ inbound_debounce_ms: Number(e.target.value) })} + placeholder="1000 (-1 = disabled)" + min={-1} + /> +
+
+
+
+ ); +} diff --git a/ui/web/src/pages/config/sections/behavior-section.tsx b/ui/web/src/pages/config/sections/behavior-section.tsx new file mode 100644 index 00000000..020b6b58 --- /dev/null +++ b/ui/web/src/pages/config/sections/behavior-section.tsx @@ -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; + onPatch: (updates: Record) => Promise; + 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 = (setter: React.Dispatch>) => + (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 ( +
+ + + + + + {dirty && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/web/src/pages/config/sections/behavior-security-card.tsx b/ui/web/src/pages/config/sections/behavior-security-card.tsx new file mode 100644 index 00000000..9689516f --- /dev/null +++ b/ui/web/src/pages/config/sections/behavior-security-card.tsx @@ -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 ( + + + {t("behavior.securityTitle")} + {t("behavior.securityDescription")} + + + {/* Injection Action */} +
+
+
+ +
+ +

{t("behavior.injectionActionHint")}

+
+
+ +
+ + {action === "block" && ( +
+ + {t("behavior.injectionBlockInfo")} +
+ )} + + {action === "off" && ( +
+ + {t("behavior.injectionOffInfo")} +
+ )} +
+ + {/* Scrub Credentials */} +
+
+
+ +
+ +

{t("behavior.scrubCredentialsHint")}

+
+
+ onChange({ ...value, scrub_credentials: v })} + className="shrink-0" + /> +
+ + {scrub && ( +
+ + {t("behavior.scrubCredentialsInfo")} +
+ )} + + {!scrub && ( +
+ + {t("behavior.scrubCredentialsOffInfo")} +
+ )} +
+
+
+ ); +} diff --git a/ui/web/src/pages/config/sections/behavior-sessions-card.tsx b/ui/web/src/pages/config/sections/behavior-sessions-card.tsx new file mode 100644 index 00000000..54187911 --- /dev/null +++ b/ui/web/src/pages/config/sections/behavior-sessions-card.tsx @@ -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 ( + + + {t("behavior.sessionsTitle")} + {t("behavior.sessionsDescription")} + + +
+
+ {t("sessions.scope")} + +
+
+ {t("sessions.dmScope")} + +
+
+
+
+ ); +} diff --git a/ui/web/src/pages/config/sections/behavior-ux-card.tsx b/ui/web/src/pages/config/sections/behavior-ux-card.tsx new file mode 100644 index 00000000..1e8c8948 --- /dev/null +++ b/ui/web/src/pages/config/sections/behavior-ux-card.tsx @@ -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 ( + + ); +} diff --git a/ui/web/src/pages/config/sections/gateway-section.tsx b/ui/web/src/pages/config/sections/gateway-section.tsx deleted file mode 100644 index b53c7d5c..00000000 --- a/ui/web/src/pages/config/sections/gateway-section.tsx +++ /dev/null @@ -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; - saving: boolean; -} - -export function GatewaySection({ data, onSave, saving }: Props) { - const { t } = useTranslation("config"); - const [draft, setDraft] = useState(data ?? DEFAULT); - const [dirty, setDirty] = useState(false); - - useEffect(() => { - setDraft(data ?? DEFAULT); - setDirty(false); - }, [data]); - - const update = (patch: Partial) => { - 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 ( - - - {t("gateway.title")} - {t("gateway.description")} - - -
-
- {t("gateway.host")} - update({ host: e.target.value })} - placeholder="0.0.0.0" - /> -
-
- {t("gateway.port")} - update({ port: Number(e.target.value) })} - placeholder="18790" - /> -
-
- {t("gateway.token")} - update({ token: e.target.value })} - /> - {isSecret(draft.token) && ( -

{t("gateway.tokenManaged")}

- )} -
-
- -
-
- {t("gateway.ownerIds")} - update({ owner_ids: v })} - placeholder={t("gateway.ownerIdsPlaceholder")} - /> -
-
- {t("gateway.allowedOrigins")} - update({ allowed_origins: v })} - placeholder={t("gateway.allowedOriginsPlaceholder")} - /> -
-
- -
-
- {t("gateway.maxMessageChars")} - update({ max_message_chars: Number(e.target.value) })} - placeholder="32000" - /> -
-
- {t("gateway.rateLimitRpm")} - update({ rate_limit_rpm: Number(e.target.value) })} - placeholder="20 (0 = disabled)" - min={0} - /> -
-
- {t("gateway.inboundDebounceMs")} - update({ inbound_debounce_ms: Number(e.target.value) })} - placeholder="1000 (-1 = disabled)" - min={-1} - /> -
-
- {t("gateway.injectionAction")} - -
-
- {t("gateway.blockReply")} -
- update({ block_reply: v })} /> -
-
-
- {t("gateway.toolStatus")} -
- update({ tool_status: v })} /> -
-
-
- - {dirty && ( -
- -
- )} -
-
- ); -} diff --git a/ui/web/src/pages/config/sections/server-section.tsx b/ui/web/src/pages/config/sections/server-section.tsx new file mode 100644 index 00000000..636675f0 --- /dev/null +++ b/ui/web/src/pages/config/sections/server-section.tsx @@ -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; + saving: boolean; +} + +export function ServerSection({ data, onSave, saving }: Props) { + const { t } = useTranslation("config"); + const [draft, setDraft] = useState(data ?? DEFAULT); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + setDraft(data ?? DEFAULT); + setDirty(false); + }, [data]); + + const update = (patch: Partial) => { + 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 ( + + + {t("server.title")} + {t("server.description")} + + +
+
+ {t("gateway.host")} + update({ host: e.target.value })} + placeholder="0.0.0.0" + /> +
+
+ {t("gateway.port")} + update({ port: Number(e.target.value) })} + placeholder="18790" + /> +
+
+ {t("gateway.token")} + update({ token: e.target.value })} + /> + {isSecret(draft.token) && ( +

{t("gateway.tokenManaged")}

+ )} +
+
+ +
+
+ {t("gateway.ownerIds")} + update({ owner_ids: v })} + placeholder={t("gateway.ownerIdsPlaceholder")} + /> +
+
+ {t("gateway.allowedOrigins")} + update({ allowed_origins: v })} + placeholder={t("gateway.allowedOriginsPlaceholder")} + /> +
+
+ + {dirty && ( +
+ +
+ )} +
+
+ ); +} diff --git a/ui/web/src/pages/config/sections/sessions-section.tsx b/ui/web/src/pages/config/sections/sessions-section.tsx deleted file mode 100644 index 21610cfd..00000000 --- a/ui/web/src/pages/config/sections/sessions-section.tsx +++ /dev/null @@ -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; - saving: boolean; -} - -export function SessionsSection({ data, onSave, saving }: Props) { - const { t } = useTranslation("config"); - const [draft, setDraft] = useState(data ?? DEFAULT); - const [dirty, setDirty] = useState(false); - - useEffect(() => { - setDraft(data ?? DEFAULT); - setDirty(false); - }, [data]); - - const update = (patch: Partial) => { - setDraft((prev) => ({ ...prev, ...patch })); - setDirty(true); - }; - - if (!data) return null; - - return ( - - - {t("sessions.title")} - {t("sessions.description")} - - -
-
- {t("sessions.scope")} - -
-
- {t("sessions.dmScope")} - -
-
- - {dirty && ( -
- -
- )} -
-
- ); -} diff --git a/ui/web/src/pages/config/sections/tools-exec-section.tsx b/ui/web/src/pages/config/sections/tools-exec-section.tsx new file mode 100644 index 00000000..afca666b --- /dev/null +++ b/ui/web/src/pages/config/sections/tools-exec-section.tsx @@ -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; + +interface Props { + data: ToolsData | undefined; + onSave: (value: ToolsData) => Promise; + saving: boolean; +} + +export function ToolsExecSection({ data, onSave, saving }: Props) { + const { t } = useTranslation("config"); + const [draft, setDraft] = useState(data ?? {}); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + setDraft(data ?? {}); + setDirty(false); + }, [data]); + + const updateNested = (section: string, patch: Record) => { + setDraft((prev) => ({ + ...prev, + [section]: { ...(prev[section] ?? {}), ...patch }, + })); + setDirty(true); + }; + + if (!data) return null; + + const exec = draft.execApproval ?? {}; + + return ( + + + {t("tools.execApproval")} + + +
+
+ {t("tools.execSecurity")} + +
+
+ {t("tools.execAskMode")} + +
+
+ + {exec.security === "allowlist" && ( +
+ +