mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
+2
-11
@@ -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 * npm * 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 * npm * 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user