Files
gscMy/src/components/settings/AgentSettingsForm.tsx
Super User be1c4fe5f9 chore: bootstrap gscMy on @gsc/web-kit + PAM/JIT request flow
Initial commit for gscMy carved out as its own repo (was tracked
loosely under the monorepo's web/ which is gitignored).

What this contains:
- Auth: next-auth v5 via @gsc/web-kit createAuth (Keycloak only,
  identity sourced from claims, no admin.users writes)
- Chrome: @gsc/web-kit AdminShell — replaces the legacy MyShell.
  Sidebar JSON config carried over and mapped to DbMenuItem.
- Middleware: createAuthMiddleware. Public: /access-denied,
  /auth/keycloak, /signed-out, /api/health, /api/pam/approve.
- RP-initiated signout at /api/auth/signout → Keycloak end_session →
  /signed-out (mirrors gscAdmin).
- Phosphor-iconned access-denied + signed-out landing pages.

PAM/JIT request flow (ported from gscAdmin's pre-strip git history):
- /access page (Active + Eligible tables, request modal with
  duration slider + justification + optional MFA)
- API: /api/pam/{eligible, active, audit, request, approve/:token,
  revoke/:id}
- src/lib/{authz, pam, pam-mail, pam-mfa}.ts — same files as
  gscAdmin had before the strip. PAM tables (admin.privilege_*)
  are shared with gscAdmin; gscMy uses the same Prisma model defs.
- Top-bar widget shows active grants with countdown + revoke.

Build/Deploy: Dockerfile (monorepo-root context), k8s manifests for
my.gosec.internal, self-signed TLS placeholder, DNS A record.
Keycloak gsc-my client extended to include my.gosec.internal/* in
redirect_uris + web_origins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:46:13 +02:00

869 lines
40 KiB
TypeScript

"use client";
import { useState, useId, useRef, useEffect } from "react";
import type {
AgentConfig,
OceanTraits,
PersonaConfig,
GuardrailsConfig,
MemorySettings,
MbtiType,
PersonaStatus,
} from "@gsc/chat";
interface AgentSettingsFormProps {
initialConfig: AgentConfig;
userGivenName?: string;
}
// ── Help Tooltip ─────────────────────────────────────────────────────────────
function HelpTip({ text }: { text: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<span ref={ref} className="position-relative d-inline-block ms-1" style={{ verticalAlign: "middle" }}>
<button
type="button"
className="btn btn-link p-0 border-0 text-muted"
style={{ fontSize: "0.75rem", lineHeight: 1, width: 16, height: 16 }}
onClick={() => setOpen((v) => !v)}
aria-label="Help"
>
<i className="ph-question"></i>
</button>
{open && (
<span
className="position-absolute bg-dark text-white small rounded px-2 py-1"
style={{
bottom: "calc(100% + 6px)",
left: "50%",
transform: "translateX(-50%)",
whiteSpace: "normal",
width: 260,
zIndex: 1080,
fontSize: "0.8rem",
lineHeight: 1.4,
boxShadow: "0 2px 8px rgba(0,0,0,.25)",
}}
>
{text}
<span
style={{
position: "absolute",
top: "100%",
left: "50%",
transform: "translateX(-50%)",
borderWidth: 5,
borderStyle: "solid",
borderColor: "var(--bs-dark) transparent transparent transparent",
}}
/>
</span>
)}
</span>
);
}
// ── Constants ───────────────────────────────────────────────────────────────
const MBTI_DESCRIPTIONS: Record<MbtiType, string> = {
INTJ: "Architect — Strategic, independent, logical planner",
INTP: "Logician — Analytical, inventive, deep thinker",
ENTJ: "Commander — Bold, decisive, natural leader",
ENTP: "Debater — Quick-witted, resourceful, challenger",
INFJ: "Advocate — Insightful, principled, compassionate",
INFP: "Mediator — Idealistic, empathetic, creative",
ENFJ: "Protagonist — Charismatic, inspiring, supportive",
ENFP: "Campaigner — Enthusiastic, imaginative, sociable",
ISTJ: "Logistician — Responsible, thorough, dependable",
ISFJ: "Defender — Warm, dedicated, protective",
ESTJ: "Executive — Organized, direct, strong-willed",
ESFJ: "Consul — Caring, sociable, tradition-minded",
ISTP: "Virtuoso — Practical, observant, hands-on",
ISFP: "Adventurer — Gentle, sensitive, open-minded",
ESTP: "Entrepreneur — Energetic, perceptive, action-oriented",
ESFP: "Entertainer — Spontaneous, playful, encouraging",
};
const MBTI_TYPES = Object.keys(MBTI_DESCRIPTIONS) as MbtiType[];
const ARCHETYPE_DESCRIPTIONS: Record<string, string> = {
"The Mentor": "Guides with wisdom and experience, encourages growth",
"The Helper": "Eager to assist, anticipates needs, service-oriented",
"The Expert": "Deep domain knowledge, authoritative, detail-focused",
"The Companion": "Friendly and relatable, builds rapport, conversational",
"The Challenger": "Pushes thinking, asks tough questions, growth-driven",
"The Creator": "Innovative and imaginative, generates novel ideas",
"The Caregiver": "Nurturing and empathetic, prioritizes well-being",
"The Sage": "Reflective and philosophical, seeks deeper meaning",
"The Hero": "Action-oriented, tackles problems head-on, confident",
"The Rebel": "Unconventional, challenges the status quo, bold",
"The Jester": "Lighthearted and witty, uses humor to engage",
"The Explorer": "Curious and adventurous, discovers new possibilities",
};
const ARCHETYPE_SUGGESTIONS = Object.keys(ARCHETYPE_DESCRIPTIONS);
const VOICE_TONE_SUGGESTIONS = [
"Professional and concise",
"Warm and encouraging",
"Formal and authoritative",
"Casual and friendly",
"Technical and precise",
"Empathetic and supportive",
"Witty and conversational",
"Calm and reassuring",
"Direct and no-nonsense",
"Enthusiastic and motivating",
];
const AVAILABLE_MODELS = [
{ value: "gpt-4o", label: "GPT-4o" },
{ value: "gpt-4o-mini", label: "GPT-4o Mini" },
{ value: "claude-3-5-sonnet", label: "Claude 3.5 Sonnet" },
{ value: "claude-3-5-haiku", label: "Claude 3.5 Haiku" },
{ value: "claude-3-opus", label: "Claude 3 Opus" },
];
const OCEAN_TRAITS = [
{ key: "openness" as const, label: "Openness", desc: "Curiosity, creativity, and openness to new experiences", low: "Practical", high: "Creative", color: "primary" },
{ key: "conscientiousness" as const, label: "Conscientiousness", desc: "Organization, dependability, and self-discipline", low: "Flexible", high: "Organized", color: "success" },
{ key: "extraversion" as const, label: "Extraversion", desc: "Sociability, assertiveness, and positive emotions", low: "Reserved", high: "Outgoing", color: "warning" },
{ key: "agreeableness" as const, label: "Agreeableness", desc: "Cooperation, trust, and helpfulness", low: "Challenging", high: "Cooperative", color: "info" },
{ key: "neuroticism" as const, label: "Neuroticism", desc: "Emotional instability and tendency toward negative emotions", low: "Stable", high: "Sensitive", color: "danger" },
];
const OCEAN_PRESETS: Record<string, { name: string; values: OceanTraits }> = {
balanced: { name: "Balanced", values: { openness: 50, conscientiousness: 50, extraversion: 50, agreeableness: 50, neuroticism: 50 } },
analytical: { name: "Analytical", values: { openness: 70, conscientiousness: 80, extraversion: 30, agreeableness: 50, neuroticism: 30 } },
creative: { name: "Creative", values: { openness: 90, conscientiousness: 40, extraversion: 60, agreeableness: 60, neuroticism: 50 } },
supportive: { name: "Supportive", values: { openness: 60, conscientiousness: 70, extraversion: 50, agreeableness: 90, neuroticism: 30 } },
assertive: { name: "Assertive", values: { openness: 60, conscientiousness: 70, extraversion: 80, agreeableness: 40, neuroticism: 30 } },
cautious: { name: "Cautious", values: { openness: 40, conscientiousness: 80, extraversion: 30, agreeableness: 60, neuroticism: 60 } },
};
// ── Component ───────────────────────────────────────────────────────────────
export function AgentSettingsForm({ initialConfig, userGivenName }: AgentSettingsFormProps) {
const baseId = useId();
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState("basic");
// Resolve active persona
const resolvePersona = (id: string) =>
initialConfig.personas.find((p) => p.id === id) || initialConfig.personas[0];
const [selectedPersonaId, setSelectedPersonaId] = useState(initialConfig.activePersonaId);
const initial = resolvePersona(initialConfig.activePersonaId);
// Agent-level settings
const [agentName, setAgentName] = useState(initialConfig.agentName || "");
const [userName, setUserName] = useState(initialConfig.userName || userGivenName || "");
// Basic Info
const [name, setName] = useState(initial.name);
const [archetype, setArchetype] = useState(initial.archetype || "");
const [isCustomArchetype, setIsCustomArchetype] = useState(
!!(initial.archetype && !ARCHETYPE_SUGGESTIONS.includes(initial.archetype))
);
const [voiceTone, setVoiceTone] = useState(initial.voiceTone || "");
const [isCustomVoiceTone, setIsCustomVoiceTone] = useState(
!!(initial.voiceTone && !VOICE_TONE_SUGGESTIONS.includes(initial.voiceTone))
);
const [mbti, setMbti] = useState<MbtiType | "">(initial.mbti || "");
const [status, setStatus] = useState<PersonaStatus>(initial.status || "active");
// Personality
const [personality, setPersonality] = useState<OceanTraits>(
initial.personality || OCEAN_PRESETS.balanced.values
);
// Rules & Rails
const [positiveRules, setPositiveRules] = useState<string[]>(initial.positiveRules || []);
const [negativeRules, setNegativeRules] = useState<string[]>(initial.negativeRules || []);
const [topicalRails, setTopicalRails] = useState<string[]>(initial.topicalRails || []);
const [newPositiveRule, setNewPositiveRule] = useState("");
const [newNegativeRule, setNewNegativeRule] = useState("");
const [newTopicalRail, setNewTopicalRail] = useState("");
// Backstory
const [backstory, setBackstory] = useState(initial.backstory || "");
const [worldBuilding, setWorldBuilding] = useState(initial.worldBuilding || "");
// Model Settings
const [defaultModel, setDefaultModel] = useState(initial.defaultModel || "gpt-4o");
const [temperature, setTemperature] = useState(initial.temperature ?? 0.7);
const [maxTokensPerTurn, setMaxTokensPerTurn] = useState(initial.maxTokensPerTurn ?? 1024);
const [guardrailsConfig, setGuardrailsConfig] = useState<GuardrailsConfig>(
initial.guardrailsConfig || { maxResponseLength: 2000, allowCodeExecution: false, allowExternalLinks: true }
);
// Memory
const [memorySettings, setMemorySettings] = useState<MemorySettings>(
initialConfig.memorySettings
);
const handlePersonaChange = (personaId: string) => {
setSelectedPersonaId(personaId);
const p = resolvePersona(personaId);
setName(p.name);
setArchetype(p.archetype || "");
setIsCustomArchetype(!!(p.archetype && !ARCHETYPE_SUGGESTIONS.includes(p.archetype)));
setVoiceTone(p.voiceTone || "");
setIsCustomVoiceTone(!!(p.voiceTone && !VOICE_TONE_SUGGESTIONS.includes(p.voiceTone)));
setMbti(p.mbti || "");
setStatus(p.status || "active");
setPersonality(p.personality);
setPositiveRules(p.positiveRules || []);
setNegativeRules(p.negativeRules || []);
setTopicalRails(p.topicalRails || []);
setBackstory(p.backstory || "");
setWorldBuilding(p.worldBuilding || "");
setDefaultModel(p.defaultModel || "gpt-4o");
setTemperature(p.temperature ?? 0.7);
setMaxTokensPerTurn(p.maxTokensPerTurn ?? 1024);
setGuardrailsConfig(p.guardrailsConfig || { maxResponseLength: 2000, allowCodeExecution: false, allowExternalLinks: true });
};
const handleSave = async () => {
if (!name.trim()) {
setError("Persona name is required");
setActiveTab("basic");
return;
}
setSaving(true);
setError(null);
setSaved(false);
try {
const updatedPersona: PersonaConfig = {
id: selectedPersonaId,
name: name.trim(),
archetype: archetype.trim() || undefined,
voiceTone: voiceTone.trim() || undefined,
mbti: mbti || undefined,
personality,
positiveRules: positiveRules.filter(Boolean),
negativeRules: negativeRules.filter(Boolean),
backstory: backstory.trim() || undefined,
worldBuilding: worldBuilding.trim() || undefined,
topicalRails: topicalRails.filter(Boolean),
defaultModel,
temperature,
maxTokensPerTurn,
guardrailsConfig,
status,
};
const res = await fetch("/api/agent/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentName: agentName.trim(),
userName: userName.trim(),
activePersonaId: selectedPersonaId,
personas: initialConfig.personas.map((p) =>
p.id === selectedPersonaId ? updatedPersona : p
),
memorySettings,
}),
});
if (!res.ok) throw new Error("Failed to save");
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
} finally {
setSaving(false);
}
};
const handleClearHistory = async () => {
if (!confirm("Are you sure you want to clear your conversation history? This cannot be undone.")) return;
try {
await fetch("/api/agent/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clearHistory: true }),
});
} catch {
setError("Failed to clear history");
}
};
const addToList = (
setter: React.Dispatch<React.SetStateAction<string[]>>,
inputSetter: React.Dispatch<React.SetStateAction<string>>,
value: string
) => {
const trimmed = value.trim();
if (!trimmed) return;
setter((prev) => [...prev, trimmed]);
inputSetter("");
};
const removeFromList = (
setter: React.Dispatch<React.SetStateAction<string[]>>,
idx: number
) => {
setter((prev) => prev.filter((_, i) => i !== idx));
};
const tabItems = [
{ key: "basic", label: "Basic Info", icon: "ph-user-circle" },
{ key: "personality", label: "Personality", icon: "ph-brain" },
{ key: "rules", label: "Rules & Rails", icon: "ph-list-checks" },
{ key: "backstory", label: "Backstory", icon: "ph-book-open" },
{ key: "model", label: "Model", icon: "ph-cpu" },
{ key: "memory", label: "Memory", icon: "ph-database" },
];
return (
<div className="card">
<div className="card-header d-flex align-items-center">
<h5 className="mb-0">
<i className="ph-robot me-2"></i>
Agent Settings
</h5>
<div className="ms-auto d-flex gap-2 align-items-center">
{saved && <span className="text-success small">Saved!</span>}
{error && <span className="text-danger small">{error}</span>}
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? (
<><span className="spinner-border spinner-border-sm me-1"></span>Saving...</>
) : (
<><i className="ph-floppy-disk me-1"></i>Save</>
)}
</button>
</div>
</div>
{/* Persona selector */}
{initialConfig.personas.length > 1 && (
<div className="card-header border-top py-2">
<div className="d-flex align-items-center gap-2">
<label className="form-label mb-0 small fw-medium">Active Persona:</label>
<select
className="form-select form-select-sm"
style={{ width: "auto" }}
value={selectedPersonaId}
onChange={(e) => handlePersonaChange(e.target.value)}
>
{initialConfig.personas.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.archetype ? `(${p.archetype})` : ""}
</option>
))}
</select>
</div>
</div>
)}
{/* Tabs */}
<div className="card-header p-0 border-top">
<ul className="nav nav-tabs nav-tabs-underline">
{tabItems.map((tab) => (
<li className="nav-item" key={tab.key}>
<button
type="button"
className={`nav-link ${activeTab === tab.key ? "active" : ""}`}
onClick={() => setActiveTab(tab.key)}
>
<i className={`${tab.icon} me-1`}></i>
{tab.label}
</button>
</li>
))}
</ul>
</div>
<div className="card-body">
{/* ── Tab: Basic Info ──────────────────────────────────────────── */}
{activeTab === "basic" && (
<div>
<div className="row g-3 mb-3">
<div className="col-md-6">
<label className="form-label fw-medium">
<i className="ph-robot me-1"></i>Agent Name
<HelpTip text="Give your agent a name so you can address it naturally in conversations, e.g. 'Hey Atlas, can you...'." />
</label>
<input
type="text"
className="form-control"
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
placeholder="e.g. Atlas"
/>
<p className="text-muted small mt-1 mb-0">The name you use to talk to your agent.</p>
</div>
<div className="col-md-6">
<label className="form-label fw-medium">
<i className="ph-user me-1"></i>Your Name
<HelpTip text="The name your agent uses to address you. Defaults to your first name. Change it to a nickname or preferred name." />
</label>
<input
type="text"
className="form-control"
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="e.g. Max"
/>
<p className="text-muted small mt-1 mb-0">The name the agent should call you.</p>
</div>
</div>
<hr className="my-3" />
<h6 className="mb-3">
<i className="ph-mask-happy me-1"></i>
Persona
</h6>
<div className="mb-3">
<label className="form-label fw-medium">Persona Name <span className="text-danger">*</span><HelpTip text="The internal name of this persona configuration. Useful when you have multiple personas and need to tell them apart." /></label>
<input
type="text"
className="form-control"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sage Advisor"
/>
</div>
<div className="row g-3 mb-3">
<div className="col-md-6">
<label className="form-label fw-medium">Archetype<HelpTip text="Choose a character archetype that defines how the agent approaches conversations. Each archetype brings a distinct behavioral pattern — e.g. The Mentor guides, The Challenger questions." /></label>
<select
className="form-select"
value={isCustomArchetype ? "__custom__" : archetype}
onChange={(e) => {
if (e.target.value === "__custom__") {
setIsCustomArchetype(true);
setArchetype("");
} else {
setIsCustomArchetype(false);
setArchetype(e.target.value);
}
}}
>
<option value="">None</option>
{ARCHETYPE_SUGGESTIONS.map((a) => (
<option key={a} value={a}>{a}</option>
))}
<option value="__custom__">Custom...</option>
</select>
{isCustomArchetype && (
<input
type="text"
className="form-control mt-2"
value={archetype}
onChange={(e) => setArchetype(e.target.value)}
placeholder="Enter custom archetype"
autoFocus
/>
)}
{archetype && ARCHETYPE_DESCRIPTIONS[archetype] ? (
<p className="text-muted small mt-1 mb-0">{ARCHETYPE_DESCRIPTIONS[archetype]}</p>
) : (
<p className="text-muted small mt-1 mb-0">Defines the character role and behavioral pattern of the agent.</p>
)}
</div>
<div className="col-md-6">
<label className="form-label fw-medium">MBTI Type<HelpTip text="Myers-Briggs Type Indicator. Influences how the agent communicates: I/E (introvert/extrovert), S/N (sensing/intuition), T/F (thinking/feeling), J/P (judging/perceiving)." /></label>
<select
className="form-select"
value={mbti}
onChange={(e) => setMbti(e.target.value as MbtiType | "")}
>
<option value="">None</option>
{MBTI_TYPES.map((type) => (
<option key={type} value={type}>{type} {MBTI_DESCRIPTIONS[type].split(" — ")[0]}</option>
))}
</select>
{mbti ? (
<p className="text-muted small mt-1 mb-0">{MBTI_DESCRIPTIONS[mbti]}</p>
) : (
<p className="text-muted small mt-1 mb-0">Shapes the agent&apos;s communication style based on Myers-Briggs personality dimensions.</p>
)}
</div>
</div>
<div className="mb-3">
<label className="form-label fw-medium">Voice &amp; Tone<HelpTip text="Controls the agent's writing style — formal vs casual, brief vs detailed, warm vs neutral. Pick a preset or write your own description." /></label>
<select
className="form-select"
value={isCustomVoiceTone ? "__custom__" : voiceTone}
onChange={(e) => {
if (e.target.value === "__custom__") {
setIsCustomVoiceTone(true);
setVoiceTone("");
} else {
setIsCustomVoiceTone(false);
setVoiceTone(e.target.value);
}
}}
>
<option value="">None</option>
{VOICE_TONE_SUGGESTIONS.map((v) => (
<option key={v} value={v}>{v}</option>
))}
<option value="__custom__">Custom...</option>
</select>
{isCustomVoiceTone && (
<input
type="text"
className="form-control mt-2"
value={voiceTone}
onChange={(e) => setVoiceTone(e.target.value)}
placeholder="Enter custom voice & tone"
autoFocus
/>
)}
<p className="text-muted small mt-1 mb-0">Defines the communication style of the agent.</p>
</div>
<div className="mb-3">
<label className="form-label fw-medium">Status<HelpTip text="Active: persona is live and usable. Inactive: temporarily disabled. Draft: work in progress, not yet ready for use." /></label>
<select
className="form-select"
value={status}
onChange={(e) => setStatus(e.target.value as PersonaStatus)}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="draft">Draft</option>
</select>
</div>
</div>
)}
{/* ── Tab: Personality (OCEAN) ─────────────────────────────────── */}
{activeTab === "personality" && (
<div>
<div className="d-flex flex-wrap gap-2 mb-4">
<span className="text-muted small align-self-center me-2">Presets:<HelpTip text="Quick-start personality profiles based on the Big Five (OCEAN) model. Click a preset to fill all five sliders, then fine-tune individually." /></span>
{Object.entries(OCEAN_PRESETS).map(([key, preset]) => (
<button
key={key}
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setPersonality(preset.values)}
>
{preset.name}
</button>
))}
</div>
{OCEAN_TRAITS.map((trait) => (
<div key={trait.key} className="mb-4">
<div className="d-flex justify-content-between align-items-center mb-1">
<label htmlFor={`${baseId}-${trait.key}`} className="form-label mb-0 fw-medium">
{trait.label}
</label>
<span className={`badge bg-${trait.color}`}>{personality[trait.key]}</span>
</div>
<p className="text-muted small mb-2">{trait.desc}</p>
<div className="d-flex align-items-center gap-2">
<span className="text-muted small" style={{ width: 80 }}>{trait.low}</span>
<input
type="range"
className="form-range flex-grow-1"
id={`${baseId}-${trait.key}`}
min="0" max="100" step="1"
value={personality[trait.key]}
onChange={(e) =>
setPersonality((prev) => ({ ...prev, [trait.key]: parseInt(e.target.value, 10) }))
}
/>
<span className="text-muted small" style={{ width: 80, textAlign: "right" }}>{trait.high}</span>
</div>
</div>
))}
<div className="mt-4 p-3 bg-light rounded">
<h6 className="mb-2"><i className="ph-chart-radar me-2"></i>Personality Summary</h6>
<div className="row g-2">
{OCEAN_TRAITS.map((trait) => (
<div key={trait.key} className="col-6 col-md-4">
<div className="d-flex align-items-center gap-2">
<div className="progress flex-grow-1" style={{ height: 8 }}>
<div className={`progress-bar bg-${trait.color}`} style={{ width: `${personality[trait.key]}%` }} />
</div>
<span className="text-muted small" style={{ width: 24 }}>{trait.label[0]}</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* ── Tab: Rules & Rails ───────────────────────────────────────── */}
{activeTab === "rules" && (
<div>
{/* Positive Rules */}
<div className="mb-4">
<h6 className="mb-2">
<i className="ph-check-circle text-success me-2"></i>
Positive Rules (Do)
<HelpTip text="Instructions the agent should always follow. E.g. 'Be concise', 'Use bullet points for lists', 'Always greet the user by name'." />
</h6>
<div className="list-group mb-2">
{positiveRules.map((rule, idx) => (
<div key={idx} className="list-group-item d-flex align-items-center">
<span className="flex-fill small">{rule}</span>
<button type="button" className="btn btn-sm btn-icon btn-outline-danger rounded-pill ms-2"
onClick={() => removeFromList(setPositiveRules, idx)}>
<i className="ph-x"></i>
</button>
</div>
))}
</div>
<div className="input-group input-group-sm">
<input type="text" className="form-control" placeholder="Add a positive rule..."
value={newPositiveRule} onChange={(e) => setNewPositiveRule(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") addToList(setPositiveRules, setNewPositiveRule, newPositiveRule); }}
/>
<button type="button" className="btn btn-success"
onClick={() => addToList(setPositiveRules, setNewPositiveRule, newPositiveRule)}>
<i className="ph-plus"></i>
</button>
</div>
</div>
{/* Negative Rules */}
<div className="mb-4">
<h6 className="mb-2">
<i className="ph-prohibit text-danger me-2"></i>
Negative Rules (Don&apos;t)
<HelpTip text="Behaviors the agent must avoid. E.g. 'Never share personal data', 'Do not make promises on behalf of the company', 'Avoid technical jargon'." />
</h6>
<div className="list-group mb-2">
{negativeRules.map((rule, idx) => (
<div key={idx} className="list-group-item d-flex align-items-center">
<span className="flex-fill small">{rule}</span>
<button type="button" className="btn btn-sm btn-icon btn-outline-danger rounded-pill ms-2"
onClick={() => removeFromList(setNegativeRules, idx)}>
<i className="ph-x"></i>
</button>
</div>
))}
</div>
<div className="input-group input-group-sm">
<input type="text" className="form-control" placeholder="Add a negative rule..."
value={newNegativeRule} onChange={(e) => setNewNegativeRule(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") addToList(setNegativeRules, setNewNegativeRule, newNegativeRule); }}
/>
<button type="button" className="btn btn-danger"
onClick={() => addToList(setNegativeRules, setNewNegativeRule, newNegativeRule)}>
<i className="ph-plus"></i>
</button>
</div>
</div>
{/* Topical Rails */}
<div>
<h6 className="mb-2">
<i className="ph-funnel text-info me-2"></i>
Topical Rails
<HelpTip text="Restrict the agent to specific topics. If set, the agent will only respond to questions within these topics and politely decline others. Leave empty for no restrictions." />
</h6>
<p className="text-muted small mb-2">Restrict the agent to specific topics. Leave empty for no restrictions.</p>
<div className="d-flex flex-wrap gap-2 mb-2">
{topicalRails.map((rail, idx) => (
<span key={idx} className="badge bg-info d-flex align-items-center gap-1">
{rail}
<button type="button" className="btn-close btn-close-white btn-close-sm"
style={{ fontSize: "0.5rem" }}
onClick={() => removeFromList(setTopicalRails, idx)}></button>
</span>
))}
</div>
<div className="input-group input-group-sm">
<input type="text" className="form-control" placeholder="Add a topic rail..."
value={newTopicalRail} onChange={(e) => setNewTopicalRail(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") addToList(setTopicalRails, setNewTopicalRail, newTopicalRail); }}
/>
<button type="button" className="btn btn-info"
onClick={() => addToList(setTopicalRails, setNewTopicalRail, newTopicalRail)}>
<i className="ph-plus"></i>
</button>
</div>
</div>
</div>
)}
{/* ── Tab: Backstory ───────────────────────────────────────────── */}
{activeTab === "backstory" && (
<div>
<div className="mb-4">
<label className="form-label fw-medium">Character Backstory<HelpTip text="Give your agent a fictional or real background story. This shapes its perspective and how it frames responses. E.g. 'A seasoned IT consultant with 20 years of experience in cybersecurity'." /></label>
<textarea
className="form-control"
rows={6}
value={backstory}
onChange={(e) => setBackstory(e.target.value)}
placeholder="Describe the agent's character history, experience, and background..."
/>
<p className="text-muted small mt-1 mb-0">This shapes how the agent presents itself and responds.</p>
</div>
<div>
<label className="form-label fw-medium">World Building<HelpTip text="Describe the environment or context the agent operates in. E.g. 'A corporate environment focused on data privacy and compliance' or 'A creative studio for brainstorming marketing campaigns'." /></label>
<textarea
className="form-control"
rows={6}
value={worldBuilding}
onChange={(e) => setWorldBuilding(e.target.value)}
placeholder="Describe the context and environment the agent operates in..."
/>
<p className="text-muted small mt-1 mb-0">Defines the setting and context for agent interactions.</p>
</div>
</div>
)}
{/* ── Tab: Model Settings ──────────────────────────────────────── */}
{activeTab === "model" && (
<div>
<div className="mb-4">
<label className="form-label fw-medium">Default Model<HelpTip text="The AI model used for generating responses. Larger models (GPT-4o, Claude 3 Opus) are more capable but slower. Smaller models (Mini, Haiku) are faster and cheaper." /></label>
<select
className="form-select"
value={defaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
>
{AVAILABLE_MODELS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
<div className="mb-4">
<label className="form-label fw-medium">
Temperature
<span className="badge bg-secondary ms-2">{temperature.toFixed(1)}</span>
<HelpTip text="Controls randomness in responses. Low (0.0-0.3): factual and consistent, ideal for support. Medium (0.4-0.7): balanced. High (0.8-2.0): creative and varied, good for brainstorming." />
</label>
<p className="text-muted small mb-2">
Lower = more focused and deterministic. Higher = more creative and varied.
</p>
<input
type="range" className="form-range"
min="0" max="2" step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
/>
<div className="d-flex justify-content-between text-muted small">
<span>Precise (0.0)</span>
<span>Creative (2.0)</span>
</div>
</div>
<div className="mb-4">
<label className="form-label fw-medium">
Max Tokens per Turn
<span className="badge bg-secondary ms-2">{maxTokensPerTurn}</span>
<HelpTip text="Maximum length of each agent response in tokens (~1 token = 4 characters). Lower values keep answers short, higher values allow detailed explanations." />
</label>
<input
type="range" className="form-range"
min="256" max="16384" step="256"
value={maxTokensPerTurn}
onChange={(e) => setMaxTokensPerTurn(parseInt(e.target.value, 10))}
/>
<div className="d-flex justify-content-between text-muted small">
<span>Short (256)</span>
<span>Long (16384)</span>
</div>
</div>
<h6 className="mb-3">
<i className="ph-shield-check me-2"></i>
Guardrails
<HelpTip text="Safety controls that limit what the agent can do. Use these to enforce compliance policies and restrict potentially risky behaviors." />
</h6>
<div className="mb-3">
<label className="form-label fw-medium">
Max Response Length
<span className="badge bg-secondary ms-2">{guardrailsConfig.maxResponseLength}</span>
<HelpTip text="Hard character limit on agent responses. Responses exceeding this limit are truncated. Use this as an additional safeguard alongside Max Tokens." />
</label>
<input
type="range" className="form-range"
min="500" max="8000" step="500"
value={guardrailsConfig.maxResponseLength}
onChange={(e) => setGuardrailsConfig((prev) => ({ ...prev, maxResponseLength: parseInt(e.target.value, 10) }))}
/>
<div className="d-flex justify-content-between text-muted small">
<span>500</span>
<span>8000</span>
</div>
</div>
<div className="form-check form-switch mb-2">
<input type="checkbox" className="form-check-input" id={`${baseId}-code-exec`}
checked={guardrailsConfig.allowCodeExecution}
onChange={(e) => setGuardrailsConfig((prev) => ({ ...prev, allowCodeExecution: e.target.checked }))}
/>
<label className="form-check-label" htmlFor={`${baseId}-code-exec`}>Allow Code Execution<HelpTip text="When enabled, the agent can execute code snippets (e.g. Python, SQL) to answer questions. Disable this if the agent should only provide text-based responses." /></label>
</div>
<div className="form-check form-switch mb-2">
<input type="checkbox" className="form-check-input" id={`${baseId}-ext-links`}
checked={guardrailsConfig.allowExternalLinks}
onChange={(e) => setGuardrailsConfig((prev) => ({ ...prev, allowExternalLinks: e.target.checked }))}
/>
<label className="form-check-label" htmlFor={`${baseId}-ext-links`}>Allow External Links<HelpTip text="When enabled, the agent can include URLs and link to external resources in responses. Disable this to keep all responses self-contained without outbound references." /></label>
</div>
</div>
)}
{/* ── Tab: Memory & Context ────────────────────────────────────── */}
{activeTab === "memory" && (
<div>
<div className="mb-4">
<label className="form-label fw-medium">Conversation Retention<HelpTip text="How long the agent remembers past conversations for context. Longer retention means the agent can reference older discussions, but uses more storage." /></label>
<select
className="form-select"
value={memorySettings.retentionDays}
onChange={(e) => setMemorySettings({ retentionDays: parseInt(e.target.value, 10) as 7 | 30 | 90 })}
>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
<p className="text-muted small mt-1">How long to keep conversation history for context.</p>
</div>
<div className="border-top pt-3">
<h6 className="text-danger mb-2">Danger Zone<HelpTip text="Irreversible actions. Clearing history removes all past conversations and the agent will have no memory of previous interactions." /></h6>
<button type="button" className="btn btn-outline-danger btn-sm" onClick={handleClearHistory}>
<i className="ph-trash me-1"></i>
Clear Conversation History
</button>
<p className="text-muted small mt-1">
Permanently delete all conversation history. This cannot be undone.
</p>
</div>
</div>
)}
</div>
</div>
);
}