Add self-deploying setup scripts for all sub-projects (P1-P8)

- Root setup.sh orchestrator with per-phase dispatch (./setup.sh p1..p8 | all | status)
- Makefile convenience targets (make infra, make llm, make status, etc.)
- scripts/common.sh: shared bash library for OS detection, Docker helpers,
  service management (launchd/systemd), package install, env management
- .env.example + .gitignore: shared config template and secret exclusions

P1 (homeai-infra): full implementation
- docker-compose.yml: Uptime Kuma, code-server, n8n
- Note: Home Assistant, Portainer, Gitea are pre-existing instances
- setup.sh: Docker install, homeai network, container health checks

P2 (homeai-llm): full implementation
- Ollama native install with CUDA/ROCm/Metal auto-detection
- launchd plist (macOS) + systemd service (Linux) for auto-start
- scripts/pull-models.sh: idempotent model puller from manifest
- scripts/benchmark.sh: tokens/sec measurement per model
- Open WebUI on port 3030 (avoids Gitea :3000 conflict)

P3-P8: working stubs with prerequisite checks and TODO sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-04 21:10:53 +00:00
parent 38247d7cc4
commit 7978eaea14
23 changed files with 2525 additions and 0 deletions

View File

@@ -0,0 +1,686 @@
import { useState, useEffect, useCallback } from "react";
const STORAGE_KEY = "ai-character-profiles";
const DEFAULT_MODELS = [
"llama3.3:70b", "qwen2.5:72b", "mistral-large", "llama3.1:8b",
"qwen2.5:14b", "gemma3:27b", "deepseek-r1:14b", "phi4:14b"
];
const TTS_MODELS = ["Kokoro", "Chatterbox", "F5-TTS", "Qwen3-TTS", "Piper"];
const STT_MODELS = ["Whisper Large-v3", "Whisper Medium", "Whisper Small", "Whisper Turbo"];
const IMAGE_MODELS = ["SDXL", "Flux.1-dev", "Flux.1-schnell", "SD 1.5", "Pony Diffusion"];
const PERSONALITY_TRAITS = [
"Warm", "Witty", "Calm", "Energetic", "Sarcastic", "Nurturing",
"Curious", "Playful", "Formal", "Casual", "Empathetic", "Direct",
"Creative", "Analytical", "Protective", "Mischievous"
];
const SPEAKING_STYLES = [
"Conversational", "Poetic", "Concise", "Verbose", "Academic",
"Informal", "Dramatic", "Deadpan", "Enthusiastic", "Measured"
];
const EMPTY_CHARACTER = {
id: null,
name: "",
tagline: "",
avatar: "",
accentColor: "#7c6fff",
personality: {
traits: [],
speakingStyle: "",
coreValues: "",
quirks: "",
backstory: "",
motivation: "",
},
prompts: {
systemPrompt: "",
wakeWordResponse: "",
fallbackResponse: "",
errorResponse: "",
customPrompts: [],
},
models: {
llm: "",
tts: "",
stt: "",
imageGen: "",
voiceCloneRef: "",
ttsSpeed: 1.0,
temperature: 0.7,
},
liveRepresentation: {
live2dModel: "",
idleExpression: "",
speakingExpression: "",
thinkingExpression: "",
happyExpression: "",
vtsTriggers: "",
},
userNotes: "",
createdAt: null,
updatedAt: null,
};
const TABS = ["Identity", "Personality", "Prompts", "Models", "Live2D", "Notes"];
const TAB_ICONS = {
Identity: "◈",
Personality: "◉",
Prompts: "◎",
Models: "⬡",
Live2D: "◇",
Notes: "▣",
};
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
function ColorPicker({ value, onChange }) {
const presets = [
"#7c6fff","#ff6b9d","#00d4aa","#ff9f43","#48dbfb",
"#ff6348","#a29bfe","#fd79a8","#55efc4","#fdcb6e"
];
return (
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
{presets.map(c => (
<button key={c} onClick={() => onChange(c)} style={{
width: 28, height: 28, borderRadius: "50%", background: c, border: value === c ? "3px solid #fff" : "3px solid transparent",
cursor: "pointer", outline: "none", boxShadow: value === c ? `0 0 0 2px ${c}` : "none", transition: "all 0.2s"
}} />
))}
<input type="color" value={value} onChange={e => onChange(e.target.value)}
style={{ width: 28, height: 28, borderRadius: "50%", border: "none", cursor: "pointer", background: "none", padding: 0 }} />
</div>
);
}
function TagSelector({ options, selected, onChange, max = 6 }) {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{options.map(opt => {
const active = selected.includes(opt);
return (
<button key={opt} onClick={() => {
if (active) onChange(selected.filter(s => s !== opt));
else if (selected.length < max) onChange([...selected, opt]);
}} style={{
padding: "5px 14px", borderRadius: 20, fontSize: 13, fontFamily: "inherit",
background: active ? "var(--accent)" : "rgba(255,255,255,0.06)",
color: active ? "#fff" : "rgba(255,255,255,0.55)",
border: active ? "1px solid var(--accent)" : "1px solid rgba(255,255,255,0.1)",
cursor: "pointer", transition: "all 0.18s", fontWeight: active ? 600 : 400,
}}>
{opt}
</button>
);
})}
</div>
);
}
function Field({ label, hint, children }) {
return (
<div style={{ marginBottom: 22 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", color: "rgba(255,255,255,0.45)", marginBottom: 6 }}>
{label}
</label>
{hint && <p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 8, marginTop: -2 }}>{hint}</p>}
{children}
</div>
);
}
function Input({ value, onChange, placeholder, type = "text" }) {
return (
<input type={type} value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder}
style={{
width: "100%", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 8, padding: "10px 14px", color: "#fff", fontSize: 14, fontFamily: "inherit",
outline: "none", boxSizing: "border-box", transition: "border-color 0.2s",
}}
onFocus={e => e.target.style.borderColor = "var(--accent)"}
onBlur={e => e.target.style.borderColor = "rgba(255,255,255,0.1)"}
/>
);
}
function Textarea({ value, onChange, placeholder, rows = 4 }) {
return (
<textarea value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder} rows={rows}
style={{
width: "100%", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 8, padding: "10px 14px", color: "#fff", fontSize: 14, fontFamily: "inherit",
outline: "none", boxSizing: "border-box", resize: "vertical", lineHeight: 1.6,
transition: "border-color 0.2s",
}}
onFocus={e => e.target.style.borderColor = "var(--accent)"}
onBlur={e => e.target.style.borderColor = "rgba(255,255,255,0.1)"}
/>
);
}
function Select({ value, onChange, options, placeholder }) {
return (
<select value={value} onChange={e => onChange(e.target.value)}
style={{
width: "100%", background: "rgba(20,20,35,0.95)", border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 8, padding: "10px 14px", color: value ? "#fff" : "rgba(255,255,255,0.35)",
fontSize: 14, fontFamily: "inherit", outline: "none", cursor: "pointer",
appearance: "none", backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='rgba(255,255,255,0.3)' stroke-width='2' fill='none'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat", backgroundPosition: "right 14px center",
}}>
<option value="">{placeholder || "Select..."}</option>
{options.map(o => <option key={o} value={o}>{o}</option>)}
</select>
);
}
function Slider({ value, onChange, min, max, step, label }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<input type="range" min={min} max={max} step={step} value={value}
onChange={e => onChange(parseFloat(e.target.value))}
style={{ flex: 1, accentColor: "var(--accent)", cursor: "pointer" }} />
<span style={{ fontSize: 14, color: "rgba(255,255,255,0.7)", minWidth: 38, textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
{value.toFixed(1)}
</span>
</div>
);
}
function CustomPromptsEditor({ prompts, onChange }) {
const add = () => onChange([...prompts, { trigger: "", response: "" }]);
const remove = i => onChange(prompts.filter((_, idx) => idx !== i));
const update = (i, field, val) => {
const next = [...prompts];
next[i] = { ...next[i], [field]: val };
onChange(next);
};
return (
<div>
{prompts.map((p, i) => (
<div key={i} style={{ background: "rgba(255,255,255,0.04)", borderRadius: 10, padding: 14, marginBottom: 10, position: "relative" }}>
<button onClick={() => remove(i)} style={{
position: "absolute", top: 10, right: 10, background: "rgba(255,80,80,0.15)",
border: "none", color: "#ff6b6b", borderRadius: 6, cursor: "pointer", padding: "2px 8px", fontSize: 12
}}></button>
<div style={{ marginBottom: 8 }}>
<Input value={p.trigger} onChange={v => update(i, "trigger", v)} placeholder="Trigger keyword or context..." />
</div>
<Textarea value={p.response} onChange={v => update(i, "response", v)} placeholder="Custom response or behaviour..." rows={2} />
</div>
))}
<button onClick={add} style={{
width: "100%", padding: "10px", background: "rgba(255,255,255,0.04)",
border: "1px dashed rgba(255,255,255,0.15)", borderRadius: 8, color: "rgba(255,255,255,0.45)",
cursor: "pointer", fontSize: 13, fontFamily: "inherit", transition: "all 0.2s"
}}
onMouseEnter={e => e.target.style.borderColor = "var(--accent)"}
onMouseLeave={e => e.target.style.borderColor = "rgba(255,255,255,0.15)"}
>+ Add Custom Prompt</button>
</div>
);
}
function CharacterCard({ character, active, onSelect, onDelete }) {
const initials = character.name ? character.name.slice(0, 2).toUpperCase() : "??";
return (
<div onClick={() => onSelect(character.id)} style={{
padding: "14px 16px", borderRadius: 12, cursor: "pointer", marginBottom: 8,
background: active ? `linear-gradient(135deg, ${character.accentColor}22, ${character.accentColor}11)` : "rgba(255,255,255,0.04)",
border: active ? `1px solid ${character.accentColor}66` : "1px solid rgba(255,255,255,0.07)",
transition: "all 0.2s", position: "relative",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{
width: 40, height: 40, borderRadius: "50%", background: `linear-gradient(135deg, ${character.accentColor}, ${character.accentColor}88)`,
display: "flex", alignItems: "center", justifyContent: "center", fontSize: 14, fontWeight: 800,
color: "#fff", flexShrink: 0, boxShadow: `0 4px 12px ${character.accentColor}44`
}}>{initials}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 15, color: "#fff", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{character.name || "Unnamed"}
</div>
{character.tagline && (
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.4)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{character.tagline}
</div>
)}
</div>
<button onClick={e => { e.stopPropagation(); onDelete(character.id); }} style={{
background: "none", border: "none", color: "rgba(255,255,255,0.2)", cursor: "pointer",
fontSize: 16, padding: "2px 6px", borderRadius: 4, transition: "color 0.15s", flexShrink: 0
}}
onMouseEnter={e => e.target.style.color = "#ff6b6b"}
onMouseLeave={e => e.target.style.color = "rgba(255,255,255,0.2)"}
>×</button>
</div>
{character.personality.traits.length > 0 && (
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginTop: 10 }}>
{character.personality.traits.slice(0, 3).map(t => (
<span key={t} style={{
fontSize: 10, padding: "2px 8px", borderRadius: 10, fontWeight: 600, letterSpacing: "0.04em",
background: `${character.accentColor}22`, color: character.accentColor, border: `1px solid ${character.accentColor}44`
}}>{t}</span>
))}
{character.personality.traits.length > 3 && (
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.3)", padding: "2px 4px" }}>+{character.personality.traits.length - 3}</span>
)}
</div>
)}
</div>
);
}
function ExportModal({ character, onClose }) {
const json = JSON.stringify(character, null, 2);
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(json);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.7)", zIndex: 100,
display: "flex", alignItems: "center", justifyContent: "center", padding: 24
}} onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
background: "#13131f", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 16,
padding: 28, width: "100%", maxWidth: 640, maxHeight: "80vh", display: "flex", flexDirection: "column"
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 18, color: "#fff" }}>Export Character</h3>
<button onClick={onClose} style={{ background: "none", border: "none", color: "rgba(255,255,255,0.4)", fontSize: 22, cursor: "pointer" }}>×</button>
</div>
<pre style={{
flex: 1, overflow: "auto", background: "rgba(0,0,0,0.3)", borderRadius: 10,
padding: 16, fontSize: 12, color: "rgba(255,255,255,0.7)", lineHeight: 1.6, margin: 0
}}>{json}</pre>
<button onClick={copy} style={{
marginTop: 16, padding: "12px", background: "var(--accent)", border: "none",
borderRadius: 10, color: "#fff", fontWeight: 700, fontSize: 14, cursor: "pointer",
fontFamily: "inherit", transition: "opacity 0.2s"
}}>{copied ? "✓ Copied!" : "Copy to Clipboard"}</button>
</div>
</div>
);
}
export default function CharacterManager() {
const [characters, setCharacters] = useState([]);
const [activeId, setActiveId] = useState(null);
const [activeTab, setActiveTab] = useState("Identity");
const [exportModal, setExportModal] = useState(false);
const [saved, setSaved] = useState(false);
// Load from storage
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
setCharacters(parsed);
if (parsed.length > 0) setActiveId(parsed[0].id);
}
} catch (e) {}
}, []);
// Save to storage
const saveToStorage = useCallback((chars) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(chars));
} catch (e) {}
}, []);
const activeCharacter = characters.find(c => c.id === activeId) || null;
const updateCharacter = (updater) => {
setCharacters(prev => {
const next = prev.map(c => c.id === activeId ? { ...updater(c), updatedAt: new Date().toISOString() } : c);
saveToStorage(next);
return next;
});
setSaved(true);
setTimeout(() => setSaved(false), 1500);
};
const createCharacter = () => {
const newChar = {
...JSON.parse(JSON.stringify(EMPTY_CHARACTER)),
id: generateId(),
accentColor: ["#7c6fff","#ff6b9d","#00d4aa","#ff9f43","#48dbfb"][Math.floor(Math.random() * 5)],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const next = [newChar, ...characters];
setCharacters(next);
setActiveId(newChar.id);
setActiveTab("Identity");
saveToStorage(next);
};
const deleteCharacter = (id) => {
const next = characters.filter(c => c.id !== id);
setCharacters(next);
saveToStorage(next);
if (activeId === id) setActiveId(next.length > 0 ? next[0].id : null);
};
const accentColor = activeCharacter?.accentColor || "#7c6fff";
const set = (path, value) => {
updateCharacter(c => {
const parts = path.split(".");
const next = JSON.parse(JSON.stringify(c));
let obj = next;
for (let i = 0; i < parts.length - 1; i++) obj = obj[parts[i]];
obj[parts[parts.length - 1]] = value;
return next;
});
};
const renderTab = () => {
if (!activeCharacter) return null;
const c = activeCharacter;
switch (activeTab) {
case "Identity":
return (
<div>
<Field label="Character Name">
<Input value={c.name} onChange={v => set("name", v)} placeholder="e.g. Aria, Nova, Echo..." />
</Field>
<Field label="Tagline" hint="A short phrase that captures their essence">
<Input value={c.tagline} onChange={v => set("tagline", v)} placeholder="e.g. Your curious, warm-hearted companion" />
</Field>
<Field label="Accent Color" hint="Used for UI theming and visual identity">
<ColorPicker value={c.accentColor} onChange={v => set("accentColor", v)} />
</Field>
<Field label="Live2D / Avatar Reference" hint="Filename or URL of the character's visual model">
<Input value={c.avatar} onChange={v => set("avatar", v)} placeholder="e.g. aria_v2.model3.json" />
</Field>
<Field label="Backstory" hint="Who are they? Where do they come from? Keep it rich.">
<Textarea value={c.personality.backstory} onChange={v => set("personality.backstory", v)}
placeholder="Write a detailed origin story, background, and personal history for this character..." rows={5} />
</Field>
<Field label="Core Motivation" hint="What drives them? What do they care about most?">
<Textarea value={c.personality.motivation} onChange={v => set("personality.motivation", v)}
placeholder="e.g. A deep desire to help and grow alongside their human companion..." rows={3} />
</Field>
</div>
);
case "Personality":
return (
<div>
<Field label="Personality Traits" hint={`Select up to 6 traits (${c.personality.traits.length}/6)`}>
<TagSelector options={PERSONALITY_TRAITS} selected={c.personality.traits}
onChange={v => set("personality.traits", v)} max={6} />
</Field>
<Field label="Speaking Style">
<TagSelector options={SPEAKING_STYLES} selected={c.personality.speakingStyle ? [c.personality.speakingStyle] : []}
onChange={v => set("personality.speakingStyle", v[v.length - 1] || "")} max={1} />
</Field>
<Field label="Core Values" hint="What principles guide their responses and behaviour?">
<Textarea value={c.personality.coreValues} onChange={v => set("personality.coreValues", v)}
placeholder="e.g. Honesty, kindness, intellectual curiosity, loyalty to their user..." rows={3} />
</Field>
<Field label="Quirks & Mannerisms" hint="Unique behavioural patterns, phrases, habits that make them feel real">
<Textarea value={c.personality.quirks} onChange={v => set("personality.quirks", v)}
placeholder="e.g. Tends to use nautical metaphors. Hums softly when thinking. Has strong opinions about tea..." rows={3} />
</Field>
</div>
);
case "Prompts":
return (
<div>
<Field label="System Prompt" hint="The core instruction set defining who this character is to the LLM">
<Textarea value={c.prompts.systemPrompt} onChange={v => set("prompts.systemPrompt", v)}
placeholder="You are [name], a [description]. Your personality is [traits]. You speak in a [style] manner. You care deeply about [values]..." rows={8} />
</Field>
<Field label="Wake Word Response" hint="First response when activated by wake word">
<Textarea value={c.prompts.wakeWordResponse} onChange={v => set("prompts.wakeWordResponse", v)}
placeholder="e.g. 'Yes? I'm here.' or 'Hmm? What do you need?'" rows={2} />
</Field>
<Field label="Fallback Response" hint="When the character doesn't understand or can't help">
<Textarea value={c.prompts.fallbackResponse} onChange={v => set("prompts.fallbackResponse", v)}
placeholder="e.g. 'I'm not sure I follow — could you say that differently?'" rows={2} />
</Field>
<Field label="Error Response" hint="When something goes wrong technically">
<Textarea value={c.prompts.errorResponse} onChange={v => set("prompts.errorResponse", v)}
placeholder="e.g. 'Something went wrong on my end. Give me a moment.'" rows={2} />
</Field>
<Field label="Custom Prompt Rules" hint="Context-specific overrides and triggers">
<CustomPromptsEditor prompts={c.prompts.customPrompts}
onChange={v => set("prompts.customPrompts", v)} />
</Field>
</div>
);
case "Models":
return (
<div>
<Field label="LLM (Language Model)" hint="Primary reasoning and conversation model via Ollama">
<Select value={c.models.llm} onChange={v => set("models.llm", v)} options={DEFAULT_MODELS} placeholder="Select LLM..." />
</Field>
<Field label="LLM Temperature" hint="Higher = more creative, lower = more focused">
<Slider value={c.models.temperature} onChange={v => set("models.temperature", v)} min={0} max={2} step={0.1} />
</Field>
<Field label="Text-to-Speech Engine">
<Select value={c.models.tts} onChange={v => set("models.tts", v)} options={TTS_MODELS} placeholder="Select TTS..." />
</Field>
<Field label="TTS Speed">
<Slider value={c.models.ttsSpeed} onChange={v => set("models.ttsSpeed", v)} min={0.5} max={2.0} step={0.1} />
</Field>
<Field label="Voice Clone Reference" hint="Path or filename of reference audio for voice cloning">
<Input value={c.models.voiceCloneRef} onChange={v => set("models.voiceCloneRef", v)} placeholder="e.g. /voices/aria_reference.wav" />
</Field>
<Field label="Speech-to-Text Engine">
<Select value={c.models.stt} onChange={v => set("models.stt", v)} options={STT_MODELS} placeholder="Select STT..." />
</Field>
<Field label="Image Generation Model" hint="Used when character generates images or self-portraits">
<Select value={c.models.imageGen} onChange={v => set("models.imageGen", v)} options={IMAGE_MODELS} placeholder="Select image model..." />
</Field>
</div>
);
case "Live2D":
return (
<div>
<Field label="Live2D Model File" hint="Path to .model3.json file, relative to VTube Studio models folder">
<Input value={c.liveRepresentation.live2dModel} onChange={v => set("liveRepresentation.live2dModel", v)} placeholder="e.g. Aria/aria.model3.json" />
</Field>
<Field label="Idle Expression" hint="VTube Studio expression name when listening/waiting">
<Input value={c.liveRepresentation.idleExpression} onChange={v => set("liveRepresentation.idleExpression", v)} placeholder="e.g. idle_blink" />
</Field>
<Field label="Speaking Expression" hint="Expression triggered when TTS audio is playing">
<Input value={c.liveRepresentation.speakingExpression} onChange={v => set("liveRepresentation.speakingExpression", v)} placeholder="e.g. talking_smile" />
</Field>
<Field label="Thinking Expression" hint="Triggered while LLM is processing a response">
<Input value={c.liveRepresentation.thinkingExpression} onChange={v => set("liveRepresentation.thinkingExpression", v)} placeholder="e.g. thinking_tilt" />
</Field>
<Field label="Happy / Positive Expression" hint="Triggered on positive sentiment responses">
<Input value={c.liveRepresentation.happyExpression} onChange={v => set("liveRepresentation.happyExpression", v)} placeholder="e.g. happy_bright" />
</Field>
<Field label="VTube Studio Custom Triggers" hint="Additional WebSocket API trigger mappings (JSON)">
<Textarea value={c.liveRepresentation.vtsTriggers} onChange={v => set("liveRepresentation.vtsTriggers", v)}
placeholder={'{\n "on_error": "expression_concerned",\n "on_wake": "expression_alert"\n}'} rows={5} />
</Field>
</div>
);
case "Notes":
return (
<div>
<Field label="Developer Notes" hint="Freeform notes, ideas, todos, and observations about this character">
<Textarea value={c.userNotes} onChange={v => set("userNotes", v)}
placeholder={"Ideas, observations, things to try...\n\n- Voice reference sounds slightly too formal, adjust Chatterbox guidance scale\n- Try adding more nautical metaphors to system prompt\n- Need to map 'confused' expression in VTS\n- Consider adding weather awareness skill"}
rows={16} />
</Field>
<div style={{ background: "rgba(255,255,255,0.03)", borderRadius: 10, padding: 16, fontSize: 12, color: "rgba(255,255,255,0.35)", lineHeight: 1.7 }}>
<div style={{ marginBottom: 4, fontWeight: 700, color: "rgba(255,255,255,0.45)", letterSpacing: "0.06em", textTransform: "uppercase", fontSize: 11 }}>Character Info</div>
<div>ID: <span style={{ color: "rgba(255,255,255,0.5)", fontFamily: "monospace" }}>{c.id}</span></div>
{c.createdAt && <div>Created: {new Date(c.createdAt).toLocaleString()}</div>}
{c.updatedAt && <div>Updated: {new Date(c.updatedAt).toLocaleString()}</div>}
</div>
</div>
);
default:
return null;
}
};
return (
<div style={{
"--accent": accentColor,
minHeight: "100vh",
background: "#0d0d18",
color: "#fff",
fontFamily: "'DM Sans', 'Segoe UI', system-ui, sans-serif",
display: "flex",
flexDirection: "column",
}}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&family=DM+Mono:wght@400;500&display=swap');
* { box-sizing: border-box; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
input::placeholder, textarea::placeholder { color: rgba(255,255,255,0.2); }
select option { background: #13131f; }
`}</style>
{/* Header */}
<div style={{
padding: "18px 28px", borderBottom: "1px solid rgba(255,255,255,0.06)",
display: "flex", alignItems: "center", justifyContent: "space-between",
background: "rgba(0,0,0,0.2)", backdropFilter: "blur(10px)",
position: "sticky", top: 0, zIndex: 10,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div style={{
width: 36, height: 36, borderRadius: 10,
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}88)`,
display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18,
boxShadow: `0 4px 16px ${accentColor}44`
}}></div>
<div>
<div style={{ fontWeight: 800, fontSize: 17, letterSpacing: "-0.01em" }}>Character Manager</div>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.35)" }}>AI Personality Configuration</div>
</div>
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
{saved && <span style={{ fontSize: 12, color: accentColor, fontWeight: 600 }}> Saved</span>}
{activeCharacter && (
<button onClick={() => setExportModal(true)} style={{
padding: "8px 16px", background: "rgba(255,255,255,0.07)", border: "1px solid rgba(255,255,255,0.12)",
borderRadius: 8, color: "rgba(255,255,255,0.7)", fontSize: 13, cursor: "pointer",
fontFamily: "inherit", fontWeight: 600, transition: "all 0.2s"
}}>Export JSON</button>
)}
</div>
</div>
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Sidebar */}
<div style={{
width: 260, borderRight: "1px solid rgba(255,255,255,0.06)",
display: "flex", flexDirection: "column", background: "rgba(0,0,0,0.15)",
flexShrink: 0,
}}>
<div style={{ padding: "16px 16px 8px" }}>
<button onClick={createCharacter} style={{
width: "100%", padding: "11px", background: `linear-gradient(135deg, ${accentColor}cc, ${accentColor}88)`,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, fontSize: 14,
cursor: "pointer", fontFamily: "inherit", transition: "opacity 0.2s",
boxShadow: `0 4px 16px ${accentColor}33`
}}>+ New Character</button>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px 16px" }}>
{characters.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px 16px", color: "rgba(255,255,255,0.2)", fontSize: 13, lineHeight: 1.6 }}>
No characters yet.<br />Create your first one above.
</div>
) : (
characters.map(c => (
<CharacterCard key={c.id} character={c} active={c.id === activeId}
onSelect={setActiveId} onDelete={deleteCharacter} />
))
)}
</div>
</div>
{/* Main editor */}
{activeCharacter ? (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Character header */}
<div style={{
padding: "20px 28px 0", borderBottom: "1px solid rgba(255,255,255,0.06)",
background: `linear-gradient(180deg, ${accentColor}0a 0%, transparent 100%)`,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 18 }}>
<div style={{
width: 52, height: 52, borderRadius: 16, flexShrink: 0,
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}66)`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 20, fontWeight: 800, boxShadow: `0 6px 20px ${accentColor}44`
}}>
{activeCharacter.name ? activeCharacter.name.slice(0, 2).toUpperCase() : "??"}
</div>
<div>
<div style={{ fontSize: 22, fontWeight: 800, letterSpacing: "-0.02em", lineHeight: 1.2 }}>
{activeCharacter.name || <span style={{ color: "rgba(255,255,255,0.25)" }}>Unnamed Character</span>}
</div>
{activeCharacter.tagline && (
<div style={{ fontSize: 14, color: "rgba(255,255,255,0.45)", marginTop: 2 }}>{activeCharacter.tagline}</div>
)}
</div>
</div>
{/* Tabs */}
<div style={{ display: "flex", gap: 2 }}>
{TABS.map(tab => (
<button key={tab} onClick={() => setActiveTab(tab)} style={{
padding: "9px 16px", background: "none", border: "none",
borderBottom: activeTab === tab ? `2px solid ${accentColor}` : "2px solid transparent",
color: activeTab === tab ? "#fff" : "rgba(255,255,255,0.4)",
fontSize: 13, fontWeight: activeTab === tab ? 700 : 500,
cursor: "pointer", fontFamily: "inherit", transition: "all 0.18s",
display: "flex", alignItems: "center", gap: 6,
}}>
<span style={{ fontSize: 11 }}>{TAB_ICONS[tab]}</span>{tab}
</button>
))}
</div>
</div>
{/* Tab content */}
<div style={{ flex: 1, overflowY: "auto", padding: "24px 28px" }}>
{renderTab()}
</div>
</div>
) : (
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
flexDirection: "column", gap: 16, color: "rgba(255,255,255,0.2)"
}}>
<div style={{ fontSize: 64, opacity: 0.3 }}></div>
<div style={{ fontSize: 16, fontWeight: 600 }}>No character selected</div>
<div style={{ fontSize: 13 }}>Create a new character to get started</div>
</div>
)}
</div>
{exportModal && activeCharacter && (
<ExportModal character={activeCharacter} onClose={() => setExportModal(false)} />
)}
</div>
);
}