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>
This commit is contained in:
868
src/components/settings/AgentSettingsForm.tsx
Normal file
868
src/components/settings/AgentSettingsForm.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
"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's communication style based on Myers-Briggs personality dimensions.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-medium">Voice & 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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user