Merge branch 'setup': self-deploying setup scripts for all sub-projects
Mac Mini M4 Pro is now the primary development machine. Scripts handle both macOS (launchd, Metal) and Linux (systemd, CUDA/ROCm). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
46
.env.example
Normal file
46
.env.example
Normal file
@@ -0,0 +1,46 @@
|
||||
# HomeAI — Shared Configuration Template
|
||||
# Copy to .env and fill in your values.
|
||||
# .env is gitignored — never commit it.
|
||||
|
||||
# ─── Data & Paths ──────────────────────────────────────────────────────────────
|
||||
DATA_DIR=${HOME}/homeai-data
|
||||
REPO_DIR=${HOME}/Projects/HomeAI
|
||||
|
||||
# ─── Network ───────────────────────────────────────────────────────────────────
|
||||
# Set to your machine's local IP (not 127.0.0.1)
|
||||
HOST_IP=192.168.1.100
|
||||
|
||||
# ─── P1: Infrastructure ────────────────────────────────────────────────────────
|
||||
# Pre-existing instances — set these to your actual URLs
|
||||
HA_URL=http://localhost:8123
|
||||
HA_TOKEN= # Generated in Home Assistant UI → Profile → Security
|
||||
PORTAINER_URL=https://localhost:9443
|
||||
GITEA_URL=http://localhost:3000
|
||||
|
||||
# Managed by homeai-infra docker-compose
|
||||
UPTIME_KUMA_URL=http://localhost:3001
|
||||
CODE_SERVER_URL=http://localhost:8090
|
||||
CODE_SERVER_PASS= # Set in homeai-infra/docker/.env
|
||||
N8N_URL=http://localhost:5678
|
||||
N8N_USER=admin
|
||||
N8N_PASS= # Set in homeai-infra/docker/.env
|
||||
|
||||
# ─── P2: LLM ───────────────────────────────────────────────────────────────────
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_API_URL=http://localhost:11434/v1
|
||||
OPEN_WEBUI_URL=http://localhost:3030
|
||||
OLLAMA_PRIMARY_MODEL=llama3.3:70b
|
||||
OLLAMA_FAST_MODEL=qwen2.5:7b
|
||||
|
||||
# ─── P3: Voice ─────────────────────────────────────────────────────────────────
|
||||
WYOMING_STT_URL=tcp://localhost:10300
|
||||
WYOMING_TTS_URL=tcp://localhost:10301
|
||||
|
||||
# ─── P4: Agent ─────────────────────────────────────────────────────────────────
|
||||
OPENCLAW_URL=http://localhost:8080
|
||||
|
||||
# ─── P7: Visual ────────────────────────────────────────────────────────────────
|
||||
VTUBE_WS_URL=ws://localhost:8001
|
||||
|
||||
# ─── P8: Images ────────────────────────────────────────────────────────────────
|
||||
COMFYUI_URL=http://localhost:8188
|
||||
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# Secrets — never commit
|
||||
.env
|
||||
.env.secrets
|
||||
.env.services
|
||||
**/secrets.yaml
|
||||
**/secrets.yml
|
||||
|
||||
# Docker volumes / data
|
||||
homeai-data/
|
||||
**/data/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.cache/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.localized
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Ollama model cache (large binaries)
|
||||
*.gguf
|
||||
*.safetensors
|
||||
*.bin
|
||||
*.ckpt
|
||||
*.pt
|
||||
|
||||
# ESPHome secrets
|
||||
homeai-esp32/esphome/secrets.yaml
|
||||
|
||||
# Generated
|
||||
homeai-llm/benchmark-results.md
|
||||
homeai-character/characters/*.json
|
||||
!homeai-character/characters/.gitkeep
|
||||
86
Makefile
Normal file
86
Makefile
Normal file
@@ -0,0 +1,86 @@
|
||||
# HomeAI Makefile — convenience wrapper around setup.sh
|
||||
# Run `make help` to see all targets.
|
||||
|
||||
SHELL := /bin/bash
|
||||
REPO_DIR := $(shell pwd)
|
||||
|
||||
.PHONY: help all status infra llm voice agent character esp32 visual images \
|
||||
up down restart logs ps clean
|
||||
|
||||
help:
|
||||
@echo ""
|
||||
@echo " HomeAI — Make targets"
|
||||
@echo ""
|
||||
@echo " Setup:"
|
||||
@echo " make all Run full setup (all phases)"
|
||||
@echo " make infra P1: Docker infra stack"
|
||||
@echo " make llm P2: Ollama + Open WebUI"
|
||||
@echo " make voice P3: STT / TTS / Wyoming"
|
||||
@echo " make agent P4: OpenClaw + skills"
|
||||
@echo " make character P5: Character Manager"
|
||||
@echo " make esp32 P6: ESPHome firmware"
|
||||
@echo " make visual P7: VTube Studio bridge"
|
||||
@echo " make images P8: ComfyUI"
|
||||
@echo ""
|
||||
@echo " Operations:"
|
||||
@echo " make status Show service health"
|
||||
@echo " make up Start all Docker services"
|
||||
@echo " make down Stop all Docker services"
|
||||
@echo " make restart Restart all Docker services"
|
||||
@echo " make logs Tail logs (all services)"
|
||||
@echo " make ps Show running containers"
|
||||
@echo " make clean Remove stopped containers"
|
||||
@echo ""
|
||||
|
||||
all:
|
||||
bash setup.sh all
|
||||
|
||||
status:
|
||||
bash setup.sh status
|
||||
|
||||
infra:
|
||||
bash setup.sh p1
|
||||
|
||||
llm:
|
||||
bash setup.sh p2
|
||||
|
||||
voice:
|
||||
bash setup.sh p3
|
||||
|
||||
agent:
|
||||
bash setup.sh p4
|
||||
|
||||
character:
|
||||
bash setup.sh p5
|
||||
|
||||
esp32:
|
||||
bash setup.sh p6
|
||||
|
||||
visual:
|
||||
bash setup.sh p7
|
||||
|
||||
images:
|
||||
bash setup.sh p8
|
||||
|
||||
# ─── Docker operations ─────────────────────────────────────────────────────────
|
||||
up:
|
||||
@cd homeai-infra && docker compose -f docker/docker-compose.yml up -d
|
||||
@cd homeai-llm && docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
down:
|
||||
@cd homeai-infra && docker compose -f docker/docker-compose.yml down || true
|
||||
@cd homeai-llm && docker compose -f docker/docker-compose.yml down || true
|
||||
|
||||
restart:
|
||||
@cd homeai-infra && docker compose -f docker/docker-compose.yml restart
|
||||
@cd homeai-llm && docker compose -f docker/docker-compose.yml restart
|
||||
|
||||
logs:
|
||||
@cd homeai-infra && docker compose -f docker/docker-compose.yml logs -f --tail=50
|
||||
|
||||
ps:
|
||||
@docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep homeai || \
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
clean:
|
||||
@docker container prune -f
|
||||
65
homeai-agent/setup.sh
Normal file
65
homeai-agent/setup.sh
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# homeai-agent/setup.sh — P4: OpenClaw agent + skills + mem0
|
||||
#
|
||||
# Components:
|
||||
# - OpenClaw — AI agent runtime (port 8080)
|
||||
# - skills/ — home_assistant, memory, weather, timer, music stubs
|
||||
# - mem0 — long-term memory (Chroma backend)
|
||||
# - n8n workflows — morning briefing, notification router, memory backup
|
||||
#
|
||||
# Prerequisites:
|
||||
# - P1 (homeai-infra) — Home Assistant running, HA_TOKEN set
|
||||
# - P2 (homeai-llm) — Ollama running with llama3.3:70b + nomic-embed-text
|
||||
# - P3 (homeai-voice) — Wyoming TTS running (for voice output)
|
||||
# - P5 (homeai-character) — aria.json character config exists
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
log_section "P4: Agent (OpenClaw + skills + mem0)"
|
||||
detect_platform
|
||||
|
||||
# ─── Prerequisite check ────────────────────────────────────────────────────────
|
||||
log_info "Checking prerequisites..."
|
||||
|
||||
for service in "http://localhost:11434:Ollama(P2)" "http://localhost:8123:HomeAssistant(P1)"; do
|
||||
url="${service%%:*}"; name="${service##*:}"
|
||||
if ! curl -sf "$url" -o /dev/null 2>/dev/null; then
|
||||
log_warn "$name not reachable at $url"
|
||||
fi
|
||||
done
|
||||
|
||||
load_env_services
|
||||
if [[ -z "${HA_TOKEN:-}" ]]; then
|
||||
log_warn "HA_TOKEN not set in ~/.env.services — needed for home_assistant skill"
|
||||
fi
|
||||
|
||||
# ─── TODO: Implementation ──────────────────────────────────────────────────────
|
||||
cat <<'EOF'
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ P4: homeai-agent — NOT YET IMPLEMENTED │
|
||||
│ │
|
||||
│ OPEN QUESTION: Which OpenClaw version/fork to use? │
|
||||
│ Decide before implementing. See homeai-agent/PLAN.md. │
|
||||
│ │
|
||||
│ Implementation steps: │
|
||||
│ 1. Install OpenClaw (pip install or git clone) │
|
||||
│ 2. Create ~/.openclaw/config.yaml from config/config.yaml.example │
|
||||
│ 3. Create skills: home_assistant, memory, weather, timer, music│
|
||||
│ 4. Install mem0 + Chroma backend │
|
||||
│ 5. Create systemd/launchd service for OpenClaw (port 8080) │
|
||||
│ 6. Import n8n workflows from workflows/ │
|
||||
│ 7. Smoke test: POST /chat "turn on living room lights" │
|
||||
│ │
|
||||
│ Interface contracts: │
|
||||
│ OPENCLAW_URL=http://localhost:8080 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
EOF
|
||||
|
||||
log_info "P4 is not yet implemented. See homeai-agent/PLAN.md for details."
|
||||
exit 0
|
||||
686
homeai-character/character-manager.jsx
Normal file
686
homeai-character/character-manager.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
homeai-character/setup.sh
Normal file
55
homeai-character/setup.sh
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# homeai-character/setup.sh — P5: Character Manager + persona JSON
|
||||
#
|
||||
# Components:
|
||||
# - character.schema.json — v1 character config schema
|
||||
# - aria.json — default character config
|
||||
# - Character Manager UI — Vite/React app for editing (dev server :5173)
|
||||
#
|
||||
# No hard runtime dependencies (can be developed standalone).
|
||||
# Output (aria.json) is consumed by P3, P4, P7.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
log_section "P5: Character Manager"
|
||||
detect_platform
|
||||
|
||||
# ─── Prerequisite check ────────────────────────────────────────────────────────
|
||||
log_info "Checking prerequisites..."
|
||||
|
||||
if ! command_exists node; then
|
||||
log_warn "Node.js not found — required for Character Manager UI"
|
||||
log_warn "Install: https://nodejs.org (v18+ recommended)"
|
||||
fi
|
||||
|
||||
# ─── TODO: Implementation ──────────────────────────────────────────────────────
|
||||
cat <<'EOF'
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ P5: homeai-character — NOT YET IMPLEMENTED │
|
||||
│ │
|
||||
│ Implementation steps: │
|
||||
│ 1. Create schema/character.schema.json (v1) │
|
||||
│ 2. Create characters/aria.json (default persona) │
|
||||
│ 3. Set up Vite/React project in src/ │
|
||||
│ 4. Extend character-manager.jsx with full UI │
|
||||
│ 5. Add schema validation (ajv) │
|
||||
│ 6. Add expression mapper UI for Live2D │
|
||||
│ 7. Wire export to ~/.openclaw/characters/ │
|
||||
│ │
|
||||
│ Dev server: │
|
||||
│ cd homeai-character && npm run dev → http://localhost:5173 │
|
||||
│ │
|
||||
│ Interface contracts: │
|
||||
│ Output: ~/.openclaw/characters/<name>.json │
|
||||
│ Schema: homeai-character/schema/character.schema.json │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
EOF
|
||||
|
||||
log_info "P5 is not yet implemented. See homeai-character/PLAN.md for details."
|
||||
exit 0
|
||||
76
homeai-esp32/setup.sh
Normal file
76
homeai-esp32/setup.sh
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# homeai-esp32/setup.sh — P6: ESPHome firmware for ESP32-S3-BOX-3
|
||||
#
|
||||
# Components:
|
||||
# - ESPHome — firmware build + flash tool
|
||||
# - base.yaml — shared device config
|
||||
# - voice.yaml — Wyoming Satellite + microWakeWord
|
||||
# - display.yaml — LVGL animated face
|
||||
# - Per-room configs — s3-box-living-room.yaml, etc.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - P1 (homeai-infra) — Home Assistant running
|
||||
# - P3 (homeai-voice) — Wyoming STT/TTS running (ports 10300/10301)
|
||||
# - Python 3.10+
|
||||
# - USB-C cable for first flash (subsequent updates via OTA)
|
||||
# - On Linux: ensure user is in the dialout group for USB access
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
log_section "P6: ESP32 Firmware (ESPHome)"
|
||||
detect_platform
|
||||
|
||||
# ─── Prerequisite check ────────────────────────────────────────────────────────
|
||||
log_info "Checking prerequisites..."
|
||||
|
||||
if ! command_exists python3; then
|
||||
log_warn "python3 not found — required for ESPHome"
|
||||
fi
|
||||
|
||||
if ! command_exists esphome; then
|
||||
log_info "ESPHome not installed. To install: pip install esphome"
|
||||
fi
|
||||
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
if ! groups "$USER" | grep -q dialout; then
|
||||
log_warn "User '$USER' not in 'dialout' group — USB flashing may fail."
|
||||
log_warn "Fix: sudo usermod -aG dialout $USER (then log out and back in)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check P3 dependency
|
||||
if ! curl -sf http://localhost:8123 -o /dev/null 2>/dev/null; then
|
||||
log_warn "Home Assistant (P1) not reachable — ESP32 units won't auto-discover"
|
||||
fi
|
||||
|
||||
# ─── TODO: Implementation ──────────────────────────────────────────────────────
|
||||
cat <<'EOF'
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ P6: homeai-esp32 — NOT YET IMPLEMENTED │
|
||||
│ │
|
||||
│ Implementation steps: │
|
||||
│ 1. pip install esphome │
|
||||
│ 2. Create esphome/secrets.yaml (gitignored) │
|
||||
│ 3. Create esphome/base.yaml (WiFi, API, OTA) │
|
||||
│ 4. Create esphome/voice.yaml (Wyoming Satellite, wakeword) │
|
||||
│ 5. Create esphome/display.yaml (LVGL face, 5 states) │
|
||||
│ 6. Create esphome/animations.yaml (face state scripts) │
|
||||
│ 7. Create per-room configs (s3-box-living-room.yaml, etc.) │
|
||||
│ 8. First flash via USB: esphome run esphome/<room>.yaml │
|
||||
│ 9. Subsequent OTA: esphome upload esphome/<room>.yaml │
|
||||
│ 10. Add to Home Assistant → assign Wyoming voice pipeline │
|
||||
│ │
|
||||
│ Quick flash (once esphome/ is ready): │
|
||||
│ esphome run esphome/s3-box-living-room.yaml │
|
||||
│ esphome logs esphome/s3-box-living-room.yaml │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
EOF
|
||||
|
||||
log_info "P6 is not yet implemented. See homeai-esp32/PLAN.md for details."
|
||||
exit 0
|
||||
65
homeai-images/setup.sh
Normal file
65
homeai-images/setup.sh
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# homeai-images/setup.sh — P8: ComfyUI + SDXL/Flux + character LoRA
|
||||
#
|
||||
# Components:
|
||||
# - ComfyUI — image generation UI + API (port 8188)
|
||||
# - SDXL, Flux.1 — base checkpoints
|
||||
# - ControlNet — pose/depth guidance
|
||||
# - aria LoRA — character-consistent fine-tune
|
||||
# - comfyui.py skill — OpenClaw integration
|
||||
# - workflows/ — saved workflows (quick, portrait, scene, upscale)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - P4 (homeai-agent) — OpenClaw running (for skill integration)
|
||||
# - Python 3.10+
|
||||
# - macOS: Metal GPU (MPS) — runs natively
|
||||
# - Linux: CUDA GPU recommended (NVIDIA); CPU is very slow for image gen
|
||||
#
|
||||
# ComfyUI runs NATIVELY (not Docker) for GPU acceleration.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
log_section "P8: Image Generation (ComfyUI)"
|
||||
detect_platform
|
||||
detect_gpu
|
||||
|
||||
log_info "GPU: ${GPU_TYPE}${GPU_INFO:+ — ${GPU_INFO}}"
|
||||
|
||||
if [[ "$GPU_TYPE" == "none" ]]; then
|
||||
log_warn "No GPU detected. ComfyUI will use CPU — image generation will be very slow."
|
||||
log_warn "On Linux: install CUDA drivers if you have an NVIDIA card."
|
||||
fi
|
||||
|
||||
# ─── TODO: Implementation ──────────────────────────────────────────────────────
|
||||
cat <<'EOF'
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ P8: homeai-images — NOT YET IMPLEMENTED │
|
||||
│ │
|
||||
│ Implementation steps: │
|
||||
│ 1. Clone ComfyUI: git clone https://github.com/comfyanonymous/ComfyUI ~/ComfyUI │
|
||||
│ 2. Create venv + install PyTorch (MPS for macOS, CUDA for Linux) │
|
||||
│ 3. scripts/download-models.sh — SDXL, Flux.1, VAE, ControlNet │
|
||||
│ 4. Create launchd/systemd service (port 8188) │
|
||||
│ 5. Create workflows/quick.json, portrait.json, scene.json │
|
||||
│ 6. Implement skills/comfyui.py OpenClaw integration │
|
||||
│ 7. (Later) Train aria LoRA with kohya_ss │
|
||||
│ │
|
||||
│ Model sizes (download bandwidth required): │
|
||||
│ SDXL base ~6.5 GB │
|
||||
│ Flux.1-dev ~24 GB │
|
||||
│ Flux.1-schnell ~24 GB │
|
||||
│ ControlNet ~1.5 GB each │
|
||||
│ │
|
||||
│ Interface contracts: │
|
||||
│ COMFYUI_URL=http://localhost:8188 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
EOF
|
||||
|
||||
log_info "P8 is not yet implemented. See homeai-images/PLAN.md for details."
|
||||
exit 0
|
||||
12
homeai-infra/docker/.env.example
Normal file
12
homeai-infra/docker/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# homeai-infra Docker secrets
|
||||
# Copy to .env — never commit .env
|
||||
|
||||
DATA_DIR=${HOME}/homeai-data
|
||||
|
||||
# ─── code-server ───────────────────────────────────────────────────────────────
|
||||
CODE_SERVER_PASSWORD=changeme123
|
||||
|
||||
# ─── n8n ───────────────────────────────────────────────────────────────────────
|
||||
N8N_BASIC_AUTH_USER=admin
|
||||
N8N_BASIC_AUTH_PASSWORD=changeme123
|
||||
N8N_ENCRYPTION_KEY=changeme_random_32_char_string
|
||||
91
homeai-infra/docker/docker-compose.yml
Normal file
91
homeai-infra/docker/docker-compose.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
# homeai-infra/docker/docker-compose.yml
|
||||
# P1 — Infrastructure services
|
||||
#
|
||||
# Provides: Uptime Kuma, code-server, n8n
|
||||
#
|
||||
# NOTE: Home Assistant, Portainer, and Gitea are pre-existing instances
|
||||
# and are NOT managed here. Point .env.services at their existing URLs.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker installed and running
|
||||
# - `homeai` Docker network exists (created by setup.sh)
|
||||
# - .env file present (copy from .env.example)
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker/docker-compose.yml up -d
|
||||
# docker compose -f docker/docker-compose.yml down
|
||||
|
||||
name: homeai-infra
|
||||
|
||||
# Linux compatibility: host.docker.internal:host-gateway resolves host IP
|
||||
# On macOS this is already defined; on Linux it maps to the Docker bridge gateway.
|
||||
x-host-gateway: &host-gateway
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
services:
|
||||
|
||||
# ─── Uptime Kuma ─────────────────────────────────────────────────────────────
|
||||
uptime-kuma:
|
||||
container_name: homeai-uptime-kuma
|
||||
image: louislam/uptime-kuma:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ${DATA_DIR:-~/homeai-data}/uptime-kuma:/app/data
|
||||
networks:
|
||||
- homeai
|
||||
labels:
|
||||
- homeai.service=uptime-kuma
|
||||
- homeai.url=http://localhost:3001
|
||||
|
||||
# ─── code-server (browser VS Code) ───────────────────────────────────────────
|
||||
code-server:
|
||||
container_name: homeai-code-server
|
||||
image: codercom/code-server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:8080" # Note: exposed on 8090 to avoid conflict with OpenClaw (8080)
|
||||
volumes:
|
||||
- ${DATA_DIR:-~/homeai-data}/code-server:/home/coder/.config
|
||||
- ${HOME}:/home/coder/host:rw # Mount home dir for file access
|
||||
environment:
|
||||
- PASSWORD=${CODE_SERVER_PASSWORD:-changeme123}
|
||||
<<: *host-gateway
|
||||
networks:
|
||||
- homeai
|
||||
labels:
|
||||
- homeai.service=code-server
|
||||
- homeai.url=http://localhost:8090
|
||||
|
||||
# ─── n8n (workflow automation) ───────────────────────────────────────────────
|
||||
n8n:
|
||||
container_name: homeai-n8n
|
||||
image: n8nio/n8n:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5678:5678"
|
||||
volumes:
|
||||
- ${DATA_DIR:-~/homeai-data}/n8n:/home/node/.n8n
|
||||
environment:
|
||||
- N8N_BASIC_AUTH_ACTIVE=true
|
||||
- N8N_BASIC_AUTH_USER=${N8N_BASIC_AUTH_USER:-admin}
|
||||
- N8N_BASIC_AUTH_PASSWORD=${N8N_BASIC_AUTH_PASSWORD:-changeme123}
|
||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:-changeme}
|
||||
- N8N_HOST=0.0.0.0
|
||||
- N8N_PORT=5678
|
||||
- N8N_PROTOCOL=http
|
||||
- WEBHOOK_URL=http://localhost:5678/
|
||||
<<: *host-gateway
|
||||
networks:
|
||||
- homeai
|
||||
labels:
|
||||
- homeai.service=n8n
|
||||
- homeai.url=http://localhost:5678
|
||||
|
||||
networks:
|
||||
homeai:
|
||||
external: true
|
||||
name: homeai
|
||||
135
homeai-infra/setup.sh
Normal file
135
homeai-infra/setup.sh
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# homeai-infra/setup.sh — P1: Infrastructure stack
|
||||
#
|
||||
# Installs Docker (if needed), creates the homeai network,
|
||||
# and starts new infrastructure services.
|
||||
#
|
||||
# Services started:
|
||||
# Uptime Kuma :3001
|
||||
# code-server :8090
|
||||
# n8n :5678
|
||||
#
|
||||
# NOTE: Home Assistant, Portainer, and Gitea are pre-existing instances.
|
||||
# Set their URLs in ~/.env.services manually.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
# shellcheck source=../scripts/common.sh
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
COMPOSE_FILE="${SCRIPT_DIR}/docker/docker-compose.yml"
|
||||
ENV_FILE="${SCRIPT_DIR}/docker/.env"
|
||||
ENV_EXAMPLE="${SCRIPT_DIR}/docker/.env.example"
|
||||
|
||||
# ─── Pre-flight ────────────────────────────────────────────────────────────────
|
||||
preflight() {
|
||||
log_section "P1 Preflight"
|
||||
detect_platform
|
||||
|
||||
# Ensure .env exists
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
if [[ -f "$ENV_EXAMPLE" ]]; then
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
log_warn "Created ${ENV_FILE} from .env.example"
|
||||
log_warn "Edit it now with real secrets, then re-run this script."
|
||||
echo ""
|
||||
echo " Secrets to set in ${ENV_FILE}:"
|
||||
echo " CODE_SERVER_PASSWORD — your password"
|
||||
echo " N8N_BASIC_AUTH_PASSWORD — your password"
|
||||
echo " N8N_ENCRYPTION_KEY — 32-char random string (run: openssl rand -hex 16)"
|
||||
echo ""
|
||||
if ! confirm "Secrets are still at defaults. Continue anyway? (not safe for production)"; then
|
||||
log_info "Aborting. Fill in ${ENV_FILE} and re-run."
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
die "No .env or .env.example found at ${SCRIPT_DIR}/docker/"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create data directory
|
||||
local data_dir
|
||||
load_env "$ENV_FILE"
|
||||
data_dir="${DATA_DIR:-${HOME}/homeai-data}"
|
||||
log_step "Data directory: ${data_dir}"
|
||||
mkdir -p \
|
||||
"${data_dir}/uptime-kuma" \
|
||||
"${data_dir}/code-server" \
|
||||
"${data_dir}/n8n"
|
||||
log_success "Data directories ready."
|
||||
}
|
||||
|
||||
# ─── Docker ────────────────────────────────────────────────────────────────────
|
||||
setup_docker() {
|
||||
log_section "Docker"
|
||||
install_docker
|
||||
ensure_docker_running
|
||||
ensure_docker_network homeai
|
||||
}
|
||||
|
||||
# ─── Services ──────────────────────────────────────────────────────────────────
|
||||
start_services() {
|
||||
log_section "Starting infra services"
|
||||
|
||||
log_step "Pulling latest images..."
|
||||
docker_compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" pull
|
||||
|
||||
log_step "Starting containers..."
|
||||
docker_compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d
|
||||
|
||||
log_success "Containers started."
|
||||
}
|
||||
|
||||
# ─── Health checks ─────────────────────────────────────────────────────────────
|
||||
health_check() {
|
||||
log_section "Health checks"
|
||||
|
||||
wait_for_http "http://localhost:3001" "Uptime Kuma" 60
|
||||
wait_for_http "http://localhost:5678" "n8n" 90
|
||||
wait_for_http "http://localhost:8090" "code-server" 60
|
||||
}
|
||||
|
||||
# ─── Register services ─────────────────────────────────────────────────────────
|
||||
register_services() {
|
||||
log_section "Writing service URLs"
|
||||
|
||||
write_env_service "UPTIME_KUMA_URL" "http://localhost:3001"
|
||||
write_env_service "CODE_SERVER_URL" "http://localhost:8090"
|
||||
write_env_service "N8N_URL" "http://localhost:5678"
|
||||
|
||||
log_warn "Set these in ~/.env.services manually (pre-existing instances):"
|
||||
log_warn " HA_URL, HA_TOKEN, PORTAINER_URL, GITEA_URL"
|
||||
|
||||
log_success "Service URLs written to ~/.env.services"
|
||||
}
|
||||
|
||||
# ─── Summary ───────────────────────────────────────────────────────────────────
|
||||
print_infra_summary() {
|
||||
print_summary "P1 Infrastructure — Ready" \
|
||||
"Uptime Kuma" "http://localhost:3001" \
|
||||
"code-server" "http://localhost:8090" \
|
||||
"n8n" "http://localhost:5678"
|
||||
|
||||
echo " Pre-existing services (not managed here):"
|
||||
echo " Home Assistant, Portainer, Gitea"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Add HA_URL, HA_TOKEN, PORTAINER_URL, GITEA_URL to ~/.env.services"
|
||||
echo " 2. Add Uptime Kuma monitors for all HomeAI services"
|
||||
echo " 3. Run: ./setup.sh p2 (Ollama + LLM)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── Main ──────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
preflight
|
||||
setup_docker
|
||||
start_services
|
||||
health_check
|
||||
register_services
|
||||
print_infra_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
7
homeai-llm/docker/.env.example
Normal file
7
homeai-llm/docker/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# homeai-llm Docker secrets
|
||||
# Copy to .env — never commit .env
|
||||
|
||||
DATA_DIR=${HOME}/homeai-data
|
||||
|
||||
# Open WebUI
|
||||
WEBUI_SECRET_KEY=changeme_random_32_char_string_here
|
||||
45
homeai-llm/docker/docker-compose.yml
Normal file
45
homeai-llm/docker/docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
# homeai-llm/docker/docker-compose.yml
|
||||
# P2 — Open WebUI
|
||||
#
|
||||
# Ollama runs NATIVELY (not in Docker) for GPU acceleration.
|
||||
# This compose file only starts the Open WebUI frontend.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Ollama installed and running on the host at port 11434
|
||||
# - `homeai` Docker network exists (created by P1 setup)
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
name: homeai-llm
|
||||
|
||||
services:
|
||||
|
||||
# ─── Open WebUI ──────────────────────────────────────────────────────────────
|
||||
open-webui:
|
||||
container_name: homeai-open-webui
|
||||
image: ghcr.io/open-webui/open-webui:main
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3030:8080" # Exposed on 3030 to avoid conflict with Gitea (3000)
|
||||
volumes:
|
||||
- ${DATA_DIR:-~/homeai-data}/open-webui:/app/backend/data
|
||||
environment:
|
||||
# Connect to Ollama on the host
|
||||
- OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||
- WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY:-changeme_random_32_char}
|
||||
- ENABLE_SIGNUP=true
|
||||
- DEFAULT_MODELS=llama3.3:70b
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # Linux compat
|
||||
networks:
|
||||
- homeai
|
||||
labels:
|
||||
- homeai.service=open-webui
|
||||
- homeai.url=http://localhost:3030
|
||||
|
||||
networks:
|
||||
homeai:
|
||||
external: true
|
||||
name: homeai
|
||||
37
homeai-llm/launchd/com.homeai.ollama.plist
Normal file
37
homeai-llm/launchd/com.homeai.ollama.plist
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.homeai.ollama</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/ollama</string>
|
||||
<string>serve</string>
|
||||
</array>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>OLLAMA_HOST</key>
|
||||
<string>0.0.0.0:11434</string>
|
||||
<!-- Metal GPU is used automatically on Apple Silicon; no env var needed -->
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/homeai-ollama.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/homeai-ollama-error.log</string>
|
||||
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>5</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
21
homeai-llm/ollama-models.txt
Normal file
21
homeai-llm/ollama-models.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
# Ollama model manifest
|
||||
# One model per line. Lines starting with # are ignored.
|
||||
# Format: <model>:<tag> or just <model> for latest
|
||||
#
|
||||
# Pull all models: bash scripts/pull-models.sh
|
||||
# Pull specific: ollama pull <model>
|
||||
|
||||
# ─── Primary (main conversation) ───────────────────────────────────────────────
|
||||
llama3.3:70b
|
||||
|
||||
# ─── Alternative primary ───────────────────────────────────────────────────────
|
||||
qwen2.5:72b
|
||||
|
||||
# ─── Fast / low-latency (voice pipeline, quick tasks) ─────────────────────────
|
||||
qwen2.5:7b
|
||||
|
||||
# ─── Code generation ───────────────────────────────────────────────────────────
|
||||
qwen2.5-coder:32b
|
||||
|
||||
# ─── Embeddings (mem0 memory store) ────────────────────────────────────────────
|
||||
nomic-embed-text
|
||||
88
homeai-llm/scripts/benchmark.sh
Normal file
88
homeai-llm/scripts/benchmark.sh
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/benchmark.sh — Benchmark Ollama model inference speed
|
||||
#
|
||||
# Measures tokens/sec for each installed model.
|
||||
# Results written to benchmark-results.md
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/benchmark.sh
|
||||
# bash scripts/benchmark.sh qwen2.5:7b # benchmark one model
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
RESULTS_FILE="${SCRIPT_DIR}/../benchmark-results.md"
|
||||
PROMPT="Tell me a short story about a robot who loves cooking. Keep it to exactly 200 words."
|
||||
|
||||
if ! command_exists ollama; then
|
||||
die "Ollama not found."
|
||||
fi
|
||||
|
||||
if ! curl -sf http://localhost:11434 -o /dev/null; then
|
||||
die "Ollama is not running."
|
||||
fi
|
||||
|
||||
benchmark_model() {
|
||||
local model="$1"
|
||||
log_step "Benchmarking $model..."
|
||||
|
||||
local start end elapsed
|
||||
start=$(date +%s%3N)
|
||||
|
||||
local response
|
||||
response=$(ollama run "$model" "$PROMPT" 2>&1) || {
|
||||
log_error "Model $model failed to run."
|
||||
echo "| $model | ERROR | — |"
|
||||
return
|
||||
}
|
||||
|
||||
end=$(date +%s%3N)
|
||||
elapsed=$(( (end - start) ))
|
||||
|
||||
local word_count
|
||||
word_count=$(echo "$response" | wc -w)
|
||||
local tokens_est=$(( word_count * 4 / 3 )) # rough estimate: 1 token ≈ 0.75 words
|
||||
local elapsed_sec
|
||||
elapsed_sec=$(echo "scale=1; $elapsed / 1000" | bc)
|
||||
local tps
|
||||
tps=$(echo "scale=1; $tokens_est / ($elapsed / 1000)" | bc 2>/dev/null || echo "?")
|
||||
|
||||
printf " %-30s %6s tok/s (%ss)\n" "$model" "$tps" "$elapsed_sec"
|
||||
echo "| \`$model\` | ${tps} tok/s | ${elapsed_sec}s |"
|
||||
}
|
||||
|
||||
log_section "Ollama Benchmark"
|
||||
log_info "Prompt: '$PROMPT'"
|
||||
echo ""
|
||||
|
||||
if [[ -n "${1:-}" ]]; then
|
||||
models=("$@")
|
||||
else
|
||||
# Get list of installed models
|
||||
mapfile -t models < <(ollama list 2>/dev/null | tail -n +2 | awk '{print $1}')
|
||||
fi
|
||||
|
||||
if [[ ${#models[@]} -eq 0 ]]; then
|
||||
die "No models installed. Run: bash scripts/pull-models.sh"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# Ollama Benchmark Results"
|
||||
echo "> Generated: $(date)"
|
||||
echo ""
|
||||
echo "| Model | Speed | Time for ~200 tok |"
|
||||
echo "|---|---|---|"
|
||||
} > "$RESULTS_FILE"
|
||||
|
||||
for model in "${models[@]}"; do
|
||||
benchmark_model "$model" | tee -a "$RESULTS_FILE"
|
||||
done
|
||||
|
||||
echo "" >> "$RESULTS_FILE"
|
||||
|
||||
log_success "Results written to $RESULTS_FILE"
|
||||
echo ""
|
||||
cat "$RESULTS_FILE"
|
||||
86
homeai-llm/scripts/pull-models.sh
Normal file
86
homeai-llm/scripts/pull-models.sh
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/pull-models.sh — Pull all Ollama models from the manifest
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/pull-models.sh # pull all models
|
||||
# bash scripts/pull-models.sh nomic-embed-text # pull specific model
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
MANIFEST="${SCRIPT_DIR}/../ollama-models.txt"
|
||||
|
||||
if ! command_exists ollama; then
|
||||
die "Ollama not found. Run: bash homeai-llm/setup.sh first."
|
||||
fi
|
||||
|
||||
if ! curl -sf http://localhost:11434 -o /dev/null; then
|
||||
die "Ollama is not running. Start it first."
|
||||
fi
|
||||
|
||||
# If a specific model is given as arg, just pull that
|
||||
if [[ $# -gt 0 ]]; then
|
||||
for model in "$@"; do
|
||||
log_info "Pulling $model..."
|
||||
ollama pull "$model"
|
||||
log_success "Pulled $model"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Pull all models from manifest
|
||||
log_section "Pulling Ollama models"
|
||||
|
||||
total=0; pulled=0; skipped=0; failed=0
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Skip comments and blank lines
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ -z "${line// }" ]] && continue
|
||||
|
||||
model="${line%% *}" # strip any trailing comment
|
||||
total=$((total + 1))
|
||||
|
||||
# Check if model is already present
|
||||
if ollama list 2>/dev/null | grep -q "^${model%%:*}"; then
|
||||
tag="${model##*:}"
|
||||
model_name="${model%%:*}"
|
||||
if [[ "$tag" != "$model_name" ]]; then
|
||||
# Has explicit tag — check exact match
|
||||
if ollama list 2>/dev/null | grep -q "^${model_name}.*${tag}"; then
|
||||
log_info "Already present: $model — skipping"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
else
|
||||
log_info "Already present: $model — skipping"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
log_step "Pulling $model..."
|
||||
if ollama pull "$model"; then
|
||||
log_success "Pulled $model"
|
||||
pulled=$((pulled + 1))
|
||||
else
|
||||
log_error "Failed to pull $model"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
|
||||
done < "$MANIFEST"
|
||||
|
||||
echo ""
|
||||
log_info "Pull complete: ${pulled} pulled, ${skipped} already present, ${failed} failed (of ${total} total)"
|
||||
|
||||
if [[ $failed -gt 0 ]]; then
|
||||
log_warn "Some models failed to pull. Check your internet connection and retry."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_info "Installed models:"
|
||||
ollama list
|
||||
227
homeai-llm/setup.sh
Normal file
227
homeai-llm/setup.sh
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# homeai-llm/setup.sh — P2: Ollama + Open WebUI
|
||||
#
|
||||
# Installs Ollama natively (for GPU access), sets up auto-start,
|
||||
# pulls models from the manifest, and starts Open WebUI in Docker.
|
||||
#
|
||||
# GPU support:
|
||||
# Linux — CUDA (NVIDIA) or ROCm (AMD) or CPU fallback
|
||||
# macOS — Metal (automatic for Apple Silicon)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
# shellcheck source=../scripts/common.sh
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
COMPOSE_FILE="${SCRIPT_DIR}/docker/docker-compose.yml"
|
||||
ENV_FILE="${SCRIPT_DIR}/docker/.env"
|
||||
ENV_EXAMPLE="${SCRIPT_DIR}/docker/.env.example"
|
||||
MANIFEST="${SCRIPT_DIR}/ollama-models.txt"
|
||||
|
||||
# ─── Pre-flight ────────────────────────────────────────────────────────────────
|
||||
preflight() {
|
||||
log_section "P2 Preflight"
|
||||
detect_platform
|
||||
detect_gpu
|
||||
|
||||
# Check P1 dependency (homeai Docker network must exist)
|
||||
if ! docker network inspect homeai &>/dev/null 2>&1; then
|
||||
log_warn "Docker network 'homeai' not found. Has P1 been run?"
|
||||
log_warn "Run: ./setup.sh p1 first, or: docker network create homeai"
|
||||
if ! confirm "Create 'homeai' network now and continue?"; then
|
||||
die "Aborted. Run ./setup.sh p1 first."
|
||||
fi
|
||||
docker network create homeai
|
||||
fi
|
||||
|
||||
# Bootstrap .env for Open WebUI
|
||||
if [[ ! -f "$ENV_FILE" && -f "$ENV_EXAMPLE" ]]; then
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
log_warn "Created ${ENV_FILE} from .env.example"
|
||||
log_warn "Set WEBUI_SECRET_KEY in ${ENV_FILE} (run: openssl rand -hex 16)"
|
||||
fi
|
||||
|
||||
# Create data dir
|
||||
load_env "$ENV_FILE" 2>/dev/null || true
|
||||
local data_dir="${DATA_DIR:-${HOME}/homeai-data}"
|
||||
mkdir -p "${data_dir}/open-webui"
|
||||
}
|
||||
|
||||
# ─── Ollama Installation ───────────────────────────────────────────────────────
|
||||
install_ollama() {
|
||||
log_section "Ollama"
|
||||
|
||||
if command_exists ollama; then
|
||||
log_success "Ollama already installed: $(ollama --version 2>/dev/null || echo 'version unknown')"
|
||||
return
|
||||
fi
|
||||
|
||||
log_info "Installing Ollama..."
|
||||
|
||||
if [[ "$OS_TYPE" == "macos" ]]; then
|
||||
if command_exists brew; then
|
||||
brew install ollama
|
||||
else
|
||||
log_info "Downloading Ollama for macOS..."
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
fi
|
||||
else
|
||||
# Linux — official install script handles CUDA/ROCm detection
|
||||
log_info "Downloading and running Ollama installer..."
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
fi
|
||||
|
||||
if ! command_exists ollama; then
|
||||
die "Ollama installation failed. Check the output above."
|
||||
fi
|
||||
|
||||
log_success "Ollama installed: $(ollama --version 2>/dev/null || echo 'ok')"
|
||||
}
|
||||
|
||||
# ─── Ollama Service ────────────────────────────────────────────────────────────
|
||||
setup_ollama_service() {
|
||||
log_section "Ollama service"
|
||||
|
||||
# Check if already running
|
||||
if curl -sf http://localhost:11434 -o /dev/null 2>/dev/null; then
|
||||
log_success "Ollama is already running."
|
||||
return
|
||||
fi
|
||||
|
||||
install_service \
|
||||
"homeai-ollama" \
|
||||
"${SCRIPT_DIR}/systemd/homeai-ollama.service" \
|
||||
"${SCRIPT_DIR}/launchd/com.homeai.ollama.plist"
|
||||
|
||||
# Give it a few seconds to start
|
||||
log_step "Waiting for Ollama to start..."
|
||||
local i=0
|
||||
while ! curl -sf http://localhost:11434 -o /dev/null 2>/dev/null; do
|
||||
sleep 2; i=$((i + 2))
|
||||
if [[ $i -ge 30 ]]; then
|
||||
log_warn "Ollama did not start within 30s. Trying to start manually..."
|
||||
ollama serve &>/dev/null &
|
||||
sleep 5
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if curl -sf http://localhost:11434 -o /dev/null 2>/dev/null; then
|
||||
log_success "Ollama is running."
|
||||
else
|
||||
die "Ollama failed to start. Check: ollama serve"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── GPU Verification ──────────────────────────────────────────────────────────
|
||||
verify_gpu() {
|
||||
log_section "GPU verification"
|
||||
|
||||
local models_response
|
||||
models_response=$(curl -sf http://localhost:11434/api/tags 2>/dev/null || echo '{}')
|
||||
|
||||
case "$GPU_TYPE" in
|
||||
metal)
|
||||
log_success "Apple Silicon Metal GPU — inference will be fast."
|
||||
;;
|
||||
cuda)
|
||||
log_info "NVIDIA CUDA GPU detected: ${GPU_INFO:-unknown}"
|
||||
# Verify Ollama can see it
|
||||
if ollama run qwen2.5:7b "Say OK" &>/dev/null 2>&1; then
|
||||
log_success "CUDA inference verified."
|
||||
else
|
||||
log_warn "Could not verify CUDA inference. Ollama may fall back to CPU."
|
||||
fi
|
||||
;;
|
||||
rocm)
|
||||
log_info "AMD ROCm GPU detected: ${GPU_INFO:-unknown}"
|
||||
log_warn "ROCm support depends on your GPU and driver version."
|
||||
;;
|
||||
none)
|
||||
log_warn "No GPU detected — Ollama will use CPU."
|
||||
log_warn "70B parameter models will be very slow on CPU. Consider qwen2.5:7b for testing."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── Pull Models ───────────────────────────────────────────────────────────────
|
||||
pull_models() {
|
||||
log_section "Pulling models"
|
||||
|
||||
if [[ ! -f "$MANIFEST" ]]; then
|
||||
log_warn "No model manifest at $MANIFEST — skipping model pull."
|
||||
return
|
||||
fi
|
||||
|
||||
# On CPU-only, skip the big models and warn
|
||||
if [[ "$GPU_TYPE" == "none" ]]; then
|
||||
log_warn "CPU-only mode: skipping 70B models (too slow). Pulling small models only."
|
||||
log_warn "Edit $MANIFEST to select which models to pull, then run:"
|
||||
log_warn " bash ${SCRIPT_DIR}/scripts/pull-models.sh"
|
||||
log_warn "Pulling only: qwen2.5:7b and nomic-embed-text"
|
||||
ollama pull qwen2.5:7b
|
||||
ollama pull nomic-embed-text
|
||||
return
|
||||
fi
|
||||
|
||||
bash "${SCRIPT_DIR}/scripts/pull-models.sh"
|
||||
}
|
||||
|
||||
# ─── Open WebUI ────────────────────────────────────────────────────────────────
|
||||
start_open_webui() {
|
||||
log_section "Open WebUI"
|
||||
|
||||
ensure_docker_running
|
||||
|
||||
log_step "Pulling Open WebUI image..."
|
||||
docker_compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" pull
|
||||
|
||||
log_step "Starting Open WebUI..."
|
||||
docker_compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d
|
||||
|
||||
wait_for_http "http://localhost:3030" "Open WebUI" 90
|
||||
}
|
||||
|
||||
# ─── Register services ─────────────────────────────────────────────────────────
|
||||
register_services() {
|
||||
write_env_service "OLLAMA_URL" "http://localhost:11434"
|
||||
write_env_service "OLLAMA_API_URL" "http://localhost:11434/v1"
|
||||
write_env_service "OPEN_WEBUI_URL" "http://localhost:3030"
|
||||
log_success "Service URLs written to ~/.env.services"
|
||||
}
|
||||
|
||||
# ─── Summary ───────────────────────────────────────────────────────────────────
|
||||
print_llm_summary() {
|
||||
local model_list
|
||||
model_list=$(ollama list 2>/dev/null | tail -n +2 | awk '{print $1}' | tr '\n' ', ' | sed 's/,$//')
|
||||
|
||||
print_summary "P2 LLM — Ready" \
|
||||
"Ollama API" "http://localhost:11434" \
|
||||
"OpenAI compat" "http://localhost:11434/v1" \
|
||||
"Open WebUI" "http://localhost:3030" \
|
||||
"GPU" "${GPU_TYPE}" \
|
||||
"Models" "${model_list:-none pulled yet}"
|
||||
|
||||
echo " Next steps:"
|
||||
echo " 1. Open http://localhost:3030 and create your admin account"
|
||||
echo " 2. Test a chat with $OLLAMA_PRIMARY_MODEL"
|
||||
echo " 3. Run benchmark: bash ${SCRIPT_DIR}/scripts/benchmark.sh"
|
||||
echo " 4. Run: ./setup.sh p3 (Voice pipeline)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── Main ──────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
preflight
|
||||
install_ollama
|
||||
setup_ollama_service
|
||||
verify_gpu
|
||||
pull_models
|
||||
start_open_webui
|
||||
register_services
|
||||
print_llm_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
26
homeai-llm/systemd/homeai-ollama.service
Normal file
26
homeai-llm/systemd/homeai-ollama.service
Normal file
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Ollama AI inference server (HomeAI)
|
||||
Documentation=https://ollama.com
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%i
|
||||
ExecStart=/usr/local/bin/ollama serve
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Environment
|
||||
Environment=OLLAMA_HOST=0.0.0.0:11434
|
||||
Environment=OLLAMA_MODELS=/usr/share/ollama/.ollama/models
|
||||
|
||||
# Limits
|
||||
LimitNOFILE=65536
|
||||
|
||||
# CUDA GPU support
|
||||
# Uncomment and set if you have multiple GPUs:
|
||||
# Environment=CUDA_VISIBLE_DEVICES=0
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
60
homeai-visual/setup.sh
Normal file
60
homeai-visual/setup.sh
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# homeai-visual/setup.sh — P7: VTube Studio bridge + Live2D expressions
|
||||
#
|
||||
# Components:
|
||||
# - vtube_studio.py — WebSocket client skill for OpenClaw
|
||||
# - lipsync.py — amplitude-based lip sync
|
||||
# - auth.py — VTube Studio token management
|
||||
#
|
||||
# Prerequisites:
|
||||
# - P4 (homeai-agent) — OpenClaw running
|
||||
# - P5 (homeai-character) — aria.json with live2d_expressions set
|
||||
# - macOS: VTube Studio installed (Mac App Store)
|
||||
# - Linux: N/A — VTube Studio is macOS/Windows/iOS only
|
||||
# Linux dev can test the skill code but not the VTube Studio side
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
log_section "P7: VTube Studio Bridge"
|
||||
detect_platform
|
||||
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
log_warn "VTube Studio is not available on Linux."
|
||||
log_warn "This sub-project requires macOS (Mac Mini)."
|
||||
fi
|
||||
|
||||
# ─── TODO: Implementation ──────────────────────────────────────────────────────
|
||||
cat <<'EOF'
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ P7: homeai-visual — NOT YET IMPLEMENTED │
|
||||
│ │
|
||||
│ macOS only (VTube Studio is macOS/iOS/Windows) │
|
||||
│ │
|
||||
│ Implementation steps: │
|
||||
│ 1. Install VTube Studio from Mac App Store │
|
||||
│ 2. Enable WebSocket API in VTube Studio (Settings → port 8001) │
|
||||
│ 3. Source/purchase Live2D model │
|
||||
│ 4. Create expression hotkeys for 8 states │
|
||||
│ 5. Implement skills/vtube_studio.py (WebSocket client) │
|
||||
│ 6. Implement skills/lipsync.py (amplitude → MouthOpen param) │
|
||||
│ 7. Implement skills/auth.py (token request + persistence) │
|
||||
│ 8. Register vtube_studio skill with OpenClaw │
|
||||
│ 9. Update aria.json live2d_expressions with hotkey IDs │
|
||||
│ 10. Test all 8 expression states │
|
||||
│ │
|
||||
│ On Linux: implement Python skills, test WebSocket protocol │
|
||||
│ with a mock server before connecting to real VTube Studio. │
|
||||
│ │
|
||||
│ Interface contracts: │
|
||||
│ VTUBE_WS_URL=ws://localhost:8001 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
EOF
|
||||
|
||||
log_info "P7 is not yet implemented. See homeai-visual/PLAN.md for details."
|
||||
exit 0
|
||||
93
homeai-voice/setup.sh
Normal file
93
homeai-voice/setup.sh
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# homeai-voice/setup.sh — P3: Voice pipeline (STT / TTS / Wyoming)
|
||||
#
|
||||
# Components:
|
||||
# - Whisper.cpp — speech-to-text (Apple Silicon / CUDA optimised)
|
||||
# - wyoming-faster-whisper — Wyoming STT adapter (port 10300)
|
||||
# - Kokoro TTS — fast text-to-speech via ONNX
|
||||
# - wyoming-kokoro — Wyoming TTS adapter (port 10301)
|
||||
# - Chatterbox TTS — voice cloning (MPS / CUDA)
|
||||
# - openWakeWord — always-on wake word detection
|
||||
#
|
||||
# Prerequisites:
|
||||
# - P1 (homeai-infra) completed — Home Assistant running
|
||||
# - P2 (homeai-llm) completed — Ollama running
|
||||
# - Python 3.10+ installed
|
||||
# - macOS: Xcode Command Line Tools (for whisper.cpp compilation)
|
||||
# - Linux: build-essential, cmake
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
log_section "P3: Voice Pipeline"
|
||||
detect_platform
|
||||
|
||||
# ─── Prerequisite check ────────────────────────────────────────────────────────
|
||||
log_info "Checking prerequisites..."
|
||||
|
||||
prereq_ok=true
|
||||
|
||||
if ! curl -sf http://localhost:8123 -o /dev/null 2>/dev/null; then
|
||||
log_warn "Home Assistant (P1) not reachable at :8123"
|
||||
prereq_ok=false
|
||||
fi
|
||||
|
||||
if ! curl -sf http://localhost:11434 -o /dev/null 2>/dev/null; then
|
||||
log_warn "Ollama (P2) not reachable at :11434"
|
||||
prereq_ok=false
|
||||
fi
|
||||
|
||||
if ! command_exists python3; then
|
||||
log_warn "python3 not found — required for STT/TTS adapters"
|
||||
prereq_ok=false
|
||||
fi
|
||||
|
||||
if [[ "$prereq_ok" == "false" ]]; then
|
||||
log_warn "Prerequisites not met. Complete P1 and P2 first."
|
||||
fi
|
||||
|
||||
# ─── TODO: Implementation ──────────────────────────────────────────────────────
|
||||
cat <<'EOF'
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ P3: homeai-voice — NOT YET IMPLEMENTED │
|
||||
│ │
|
||||
│ Implementation steps (see homeai-voice/PLAN.md): │
|
||||
│ │
|
||||
│ 1. whisper/install.sh │
|
||||
│ - Clone + compile whisper.cpp (Metal/CUDA flags) │
|
||||
│ - Download models: large-v3, medium.en │
|
||||
│ - Install wyoming-faster-whisper Python package │
|
||||
│ │
|
||||
│ 2. tts/install-kokoro.sh │
|
||||
│ - pip install kokoro-onnx │
|
||||
│ - Install wyoming-kokoro adapter │
|
||||
│ │
|
||||
│ 3. tts/install-chatterbox.sh │
|
||||
│ - pip install chatterbox-tts │
|
||||
│ - Verify MPS (macOS) or CUDA (Linux) acceleration │
|
||||
│ │
|
||||
│ 4. wyoming/install.sh │
|
||||
│ - Install wyoming-openwakeword │
|
||||
│ - Configure wake word: hey_jarvis │
|
||||
│ │
|
||||
│ 5. scripts/launchd/ or systemd/ │
|
||||
│ - wyoming-stt (port 10300) │
|
||||
│ - wyoming-tts (port 10301) │
|
||||
│ - wakeword daemon │
|
||||
│ │
|
||||
│ 6. wyoming/test-pipeline.sh │
|
||||
│ - End-to-end smoke test │
|
||||
│ │
|
||||
│ Interface contracts: │
|
||||
│ WYOMING_STT_URL=tcp://localhost:10300 │
|
||||
│ WYOMING_TTS_URL=tcp://localhost:10301 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
EOF
|
||||
|
||||
log_info "P3 is not yet implemented. See homeai-voice/PLAN.md for details."
|
||||
exit 0
|
||||
331
scripts/common.sh
Normal file
331
scripts/common.sh
Normal file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/common.sh — Shared bash library for HomeAI setup scripts
|
||||
# Source this file: source "$(dirname "$0")/../scripts/common.sh"
|
||||
# Do NOT execute directly.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Colours ───────────────────────────────────────────────────────────────────
|
||||
if [[ -t 1 ]]; then
|
||||
RED='\033[0;31m'; YELLOW='\033[0;33m'; GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
else
|
||||
RED=''; YELLOW=''; GREEN=''; BLUE=''; CYAN=''; BOLD=''; RESET=''
|
||||
fi
|
||||
|
||||
# ─── Logging ───────────────────────────────────────────────────────────────────
|
||||
log_info() { echo -e "${BLUE}[INFO]${RESET} $*"; }
|
||||
log_success() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
|
||||
log_section() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}"; }
|
||||
log_step() { echo -e "${CYAN} →${RESET} $*"; }
|
||||
|
||||
die() { log_error "$*"; exit 1; }
|
||||
|
||||
# ─── OS & Architecture Detection ───────────────────────────────────────────────
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux*) OS_TYPE=linux ;;
|
||||
Darwin*) OS_TYPE=macos ;;
|
||||
*) die "Unsupported OS: $(uname -s)" ;;
|
||||
esac
|
||||
export OS_TYPE
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) ARCH=amd64 ;;
|
||||
arm64|aarch64) ARCH=arm64 ;;
|
||||
*) ARCH=unknown ;;
|
||||
esac
|
||||
export ARCH
|
||||
}
|
||||
|
||||
detect_distro() {
|
||||
DISTRO=unknown
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/os-release
|
||||
DISTRO="${ID:-unknown}"
|
||||
fi
|
||||
fi
|
||||
export DISTRO
|
||||
}
|
||||
|
||||
detect_platform() {
|
||||
detect_os
|
||||
detect_arch
|
||||
detect_distro
|
||||
log_info "Platform: ${OS_TYPE}/${ARCH} (${DISTRO})"
|
||||
}
|
||||
|
||||
# ─── GPU Detection ─────────────────────────────────────────────────────────────
|
||||
detect_gpu() {
|
||||
GPU_TYPE=none
|
||||
if [[ "$OS_TYPE" == "macos" && "$ARCH" == "arm64" ]]; then
|
||||
GPU_TYPE=metal
|
||||
elif command -v nvidia-smi &>/dev/null && nvidia-smi &>/dev/null; then
|
||||
GPU_TYPE=cuda
|
||||
GPU_INFO=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || true)
|
||||
elif command -v rocm-smi &>/dev/null; then
|
||||
GPU_TYPE=rocm
|
||||
GPU_INFO=$(rocm-smi --showproductname 2>/dev/null | head -1 || true)
|
||||
fi
|
||||
export GPU_TYPE GPU_INFO
|
||||
log_info "GPU: ${GPU_TYPE}${GPU_INFO:+ — ${GPU_INFO}}"
|
||||
}
|
||||
|
||||
# ─── Dependency Checking ───────────────────────────────────────────────────────
|
||||
require_command() {
|
||||
local cmd="$1"
|
||||
local hint="${2:-install $cmd}"
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
die "'$cmd' is required but not found. Hint: $hint"
|
||||
fi
|
||||
}
|
||||
|
||||
command_exists() { command -v "$1" &>/dev/null; }
|
||||
|
||||
require_min_version() {
|
||||
local cmd="$1" required="$2"
|
||||
local actual
|
||||
actual="$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)"
|
||||
if [[ "$(printf '%s\n' "$required" "$actual" | sort -V | head -1)" != "$required" ]]; then
|
||||
die "$cmd version $required+ required, found $actual"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Package Management ────────────────────────────────────────────────────────
|
||||
install_package() {
|
||||
local pkg="$1"
|
||||
log_step "Installing $pkg..."
|
||||
case "$OS_TYPE" in
|
||||
linux)
|
||||
case "$DISTRO" in
|
||||
ubuntu|debian|linuxmint|pop)
|
||||
sudo apt-get install -y -qq "$pkg" ;;
|
||||
fedora|rhel|centos|rocky|almalinux)
|
||||
sudo dnf install -y -q "$pkg" ;;
|
||||
arch|manjaro|endeavouros)
|
||||
sudo pacman -S --noconfirm --quiet "$pkg" ;;
|
||||
opensuse*)
|
||||
sudo zypper install -y -q "$pkg" ;;
|
||||
*)
|
||||
die "Unknown distro '$DISTRO' — install $pkg manually" ;;
|
||||
esac ;;
|
||||
macos)
|
||||
if ! command_exists brew; then
|
||||
die "Homebrew not found. Install from https://brew.sh"
|
||||
fi
|
||||
brew install "$pkg" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
update_package_index() {
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
case "$DISTRO" in
|
||||
ubuntu|debian|linuxmint|pop) sudo apt-get update -qq ;;
|
||||
fedora|rhel|centos|rocky|almalinux) sudo dnf check-update -q || true ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Docker ────────────────────────────────────────────────────────────────────
|
||||
check_docker_installed() {
|
||||
command_exists docker
|
||||
}
|
||||
|
||||
check_docker_running() {
|
||||
docker info &>/dev/null
|
||||
}
|
||||
|
||||
install_docker() {
|
||||
if check_docker_installed; then
|
||||
log_success "Docker already installed: $(docker --version)"
|
||||
return
|
||||
fi
|
||||
|
||||
log_info "Installing Docker..."
|
||||
|
||||
if [[ "$OS_TYPE" == "macos" ]]; then
|
||||
die "On macOS, install Docker Desktop manually: https://www.docker.com/products/docker-desktop/"
|
||||
fi
|
||||
|
||||
# Linux: use the official convenience script
|
||||
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
|
||||
sudo sh /tmp/get-docker.sh
|
||||
rm /tmp/get-docker.sh
|
||||
|
||||
# Add current user to docker group
|
||||
sudo usermod -aG docker "$USER"
|
||||
log_warn "Added $USER to docker group. Log out and back in (or run 'newgrp docker') for it to take effect."
|
||||
|
||||
# Start and enable Docker
|
||||
sudo systemctl enable --now docker
|
||||
log_success "Docker installed: $(docker --version)"
|
||||
}
|
||||
|
||||
ensure_docker_running() {
|
||||
if ! check_docker_running; then
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
log_info "Starting Docker..."
|
||||
sudo systemctl start docker
|
||||
sleep 2
|
||||
else
|
||||
die "Docker Desktop is not running. Start it from your Applications folder."
|
||||
fi
|
||||
fi
|
||||
if ! check_docker_running; then
|
||||
die "Docker is not running. Start Docker and retry."
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_docker_network() {
|
||||
local network="${1:-homeai}"
|
||||
if ! docker network inspect "$network" &>/dev/null; then
|
||||
log_step "Creating Docker network '$network'..."
|
||||
docker network create "$network"
|
||||
log_success "Network '$network' created."
|
||||
else
|
||||
log_info "Docker network '$network' already exists."
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Docker Compose ────────────────────────────────────────────────────────────
|
||||
# Handles both `docker compose` (v2) and `docker-compose` (v1)
|
||||
docker_compose() {
|
||||
if docker compose version &>/dev/null 2>&1; then
|
||||
docker compose "$@"
|
||||
elif command_exists docker-compose; then
|
||||
docker-compose "$@"
|
||||
else
|
||||
die "docker compose not found. Ensure Docker is up to date."
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Service Management ────────────────────────────────────────────────────────
|
||||
install_service() {
|
||||
# Usage: install_service <name> <systemd_unit_file> <launchd_plist_file>
|
||||
local name="$1"
|
||||
local systemd_file="$2"
|
||||
local launchd_file="$3"
|
||||
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
if [[ ! -f "$systemd_file" ]]; then
|
||||
log_warn "No systemd unit file at $systemd_file — skipping service install."
|
||||
return
|
||||
fi
|
||||
log_step "Installing systemd service: $name"
|
||||
sudo cp "$systemd_file" "/etc/systemd/system/${name}.service"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now "$name"
|
||||
log_success "Service '$name' enabled and started."
|
||||
|
||||
elif [[ "$OS_TYPE" == "macos" ]]; then
|
||||
if [[ ! -f "$launchd_file" ]]; then
|
||||
log_warn "No launchd plist at $launchd_file — skipping service install."
|
||||
return
|
||||
fi
|
||||
local plist_dest="${HOME}/Library/LaunchAgents/$(basename "$launchd_file")"
|
||||
log_step "Installing launchd agent: $name"
|
||||
cp "$launchd_file" "$plist_dest"
|
||||
launchctl load -w "$plist_dest"
|
||||
log_success "LaunchAgent '$name' installed and loaded."
|
||||
fi
|
||||
}
|
||||
|
||||
uninstall_service() {
|
||||
local name="$1"
|
||||
local plist_label="${2:-$name}"
|
||||
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
sudo systemctl disable --now "$name" 2>/dev/null || true
|
||||
sudo rm -f "/etc/systemd/system/${name}.service"
|
||||
sudo systemctl daemon-reload
|
||||
elif [[ "$OS_TYPE" == "macos" ]]; then
|
||||
local plist_path="${HOME}/Library/LaunchAgents/${plist_label}.plist"
|
||||
launchctl unload -w "$plist_path" 2>/dev/null || true
|
||||
rm -f "$plist_path"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Environment Files ─────────────────────────────────────────────────────────
|
||||
load_env() {
|
||||
local env_file="${1:-.env}"
|
||||
if [[ -f "$env_file" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
set -a; source "$env_file"; set +a
|
||||
fi
|
||||
}
|
||||
|
||||
load_env_services() {
|
||||
local services_file="${HOME}/.env.services"
|
||||
if [[ -f "$services_file" ]]; then
|
||||
set -a; source "$services_file"; set +a
|
||||
fi
|
||||
}
|
||||
|
||||
write_env_service() {
|
||||
# Append or update KEY=VALUE in ~/.env.services
|
||||
local key="$1" value="$2"
|
||||
local file="${HOME}/.env.services"
|
||||
touch "$file"
|
||||
if grep -q "^${key}=" "$file" 2>/dev/null; then
|
||||
# Update existing
|
||||
sed -i "s|^${key}=.*|${key}=${value}|" "$file"
|
||||
else
|
||||
echo "${key}=${value}" >> "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Bootstrap .env from .env.example if not present
|
||||
bootstrap_env() {
|
||||
local dir="${1:-.}"
|
||||
if [[ ! -f "${dir}/.env" && -f "${dir}/.env.example" ]]; then
|
||||
cp "${dir}/.env.example" "${dir}/.env"
|
||||
log_warn "Created ${dir}/.env from .env.example — fill in secrets before continuing."
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Network Helpers ───────────────────────────────────────────────────────────
|
||||
wait_for_http() {
|
||||
local url="$1" name="${2:-service}" timeout="${3:-60}"
|
||||
log_step "Waiting for $name at $url (up to ${timeout}s)..."
|
||||
local elapsed=0
|
||||
while ! curl -sf "$url" -o /dev/null; do
|
||||
sleep 3; elapsed=$((elapsed + 3))
|
||||
if [[ $elapsed -ge $timeout ]]; then
|
||||
log_warn "$name did not respond within ${timeout}s. It may still be starting."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
log_success "$name is up."
|
||||
}
|
||||
|
||||
check_port_free() {
|
||||
local port="$1"
|
||||
if ss -tlnp "sport = :${port}" 2>/dev/null | grep -q ":${port}"; then
|
||||
return 1 # port in use
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── Utility ───────────────────────────────────────────────────────────────────
|
||||
confirm() {
|
||||
local msg="${1:-Continue?}"
|
||||
read -rp "$(echo -e "${YELLOW}${msg} [y/N]${RESET} ")" response
|
||||
[[ "${response,,}" == "y" ]]
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
local title="$1"; shift
|
||||
echo ""
|
||||
log_section "$title"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
local key="$1" val="$2"; shift 2
|
||||
printf " ${BOLD}%-24s${RESET} %s\n" "$key" "$val"
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
140
setup.sh
Normal file
140
setup.sh
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup.sh — HomeAI root orchestrator
|
||||
#
|
||||
# Usage:
|
||||
# ./setup.sh all # run all phases in order
|
||||
# ./setup.sh p1 # infra only
|
||||
# ./setup.sh p2 # llm only
|
||||
# ./setup.sh p1 p2 # multiple phases
|
||||
# ./setup.sh status # show service health
|
||||
#
|
||||
# Phases: p1=infra p2=llm p3=voice p4=agent p5=character
|
||||
# p6=esp32 p7=visual p8=images
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/common.sh
|
||||
source "${REPO_DIR}/scripts/common.sh"
|
||||
|
||||
# ─── Phase definitions ─────────────────────────────────────────────────────────
|
||||
declare -A PHASE_NAME=(
|
||||
[p1]="homeai-infra (Docker stack)"
|
||||
[p2]="homeai-llm (Ollama + Open WebUI)"
|
||||
[p3]="homeai-voice (STT / TTS / Wyoming)"
|
||||
[p4]="homeai-agent (OpenClaw + skills + mem0)"
|
||||
[p5]="homeai-character (Character Manager UI)"
|
||||
[p6]="homeai-esp32 (ESPHome firmware)"
|
||||
[p7]="homeai-visual (VTube Studio bridge)"
|
||||
[p8]="homeai-images (ComfyUI workflows)"
|
||||
)
|
||||
|
||||
PHASE_ORDER=(p1 p2 p3 p4 p5 p6 p7 p8)
|
||||
|
||||
run_phase() {
|
||||
local phase="$1"
|
||||
local subdir="${REPO_DIR}/homeai-${phase#p}"
|
||||
|
||||
# Map phase ID to directory name
|
||||
case "$phase" in
|
||||
p1) subdir="${REPO_DIR}/homeai-infra" ;;
|
||||
p2) subdir="${REPO_DIR}/homeai-llm" ;;
|
||||
p3) subdir="${REPO_DIR}/homeai-voice" ;;
|
||||
p4) subdir="${REPO_DIR}/homeai-agent" ;;
|
||||
p5) subdir="${REPO_DIR}/homeai-character" ;;
|
||||
p6) subdir="${REPO_DIR}/homeai-esp32" ;;
|
||||
p7) subdir="${REPO_DIR}/homeai-visual" ;;
|
||||
p8) subdir="${REPO_DIR}/homeai-images" ;;
|
||||
*) die "Unknown phase: $phase" ;;
|
||||
esac
|
||||
|
||||
local setup_script="${subdir}/setup.sh"
|
||||
if [[ ! -f "$setup_script" ]]; then
|
||||
die "Setup script not found: $setup_script"
|
||||
fi
|
||||
|
||||
log_section "Phase ${phase^^}: ${PHASE_NAME[$phase]}"
|
||||
bash "$setup_script"
|
||||
}
|
||||
|
||||
show_status() {
|
||||
log_section "HomeAI Service Status"
|
||||
|
||||
load_env_services
|
||||
|
||||
local services=(
|
||||
"Home Assistant|${HA_URL:-http://localhost:8123}"
|
||||
"Portainer|${PORTAINER_URL:-https://localhost:9443}"
|
||||
"Uptime Kuma|${UPTIME_KUMA_URL:-http://localhost:3001}"
|
||||
"Gitea|${GITEA_URL:-http://localhost:3000}"
|
||||
"code-server|${CODE_SERVER_URL:-http://localhost:8090}"
|
||||
"n8n|${N8N_URL:-http://localhost:5678}"
|
||||
"Ollama|${OLLAMA_URL:-http://localhost:11434}"
|
||||
"Open WebUI|${OPEN_WEBUI_URL:-http://localhost:3030}"
|
||||
)
|
||||
|
||||
for entry in "${services[@]}"; do
|
||||
local name="${entry%%|*}"
|
||||
local url="${entry##*|}"
|
||||
if curl -sf --max-time 2 "$url" -o /dev/null 2>/dev/null; then
|
||||
printf " ${GREEN}●${RESET} %-20s %s\n" "$name" "$url"
|
||||
else
|
||||
printf " ${RED}○${RESET} %-20s %s\n" "$name" "$url"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo ""
|
||||
echo " Usage: $0 <phase...> | all | status"
|
||||
echo ""
|
||||
echo " Phases:"
|
||||
for p in "${PHASE_ORDER[@]}"; do
|
||||
printf " %-6s %s\n" "$p" "${PHASE_NAME[$p]}"
|
||||
done
|
||||
echo ""
|
||||
echo " Examples:"
|
||||
echo " $0 all # setup everything in order"
|
||||
echo " $0 p1 p2 # infra + llm only"
|
||||
echo " $0 status # check service health"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── Main ──────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
detect_platform
|
||||
|
||||
# Bootstrap root .env if missing
|
||||
bootstrap_env "${REPO_DIR}"
|
||||
|
||||
local phases=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
all) phases=("${PHASE_ORDER[@]}") ;;
|
||||
status) show_status; exit 0 ;;
|
||||
p[1-8]) phases+=("$arg") ;;
|
||||
help|-h|--help) usage; exit 0 ;;
|
||||
*) die "Unknown argument: $arg. Run '$0 help' for usage." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#phases[@]} -eq 0 ]]; then
|
||||
usage; exit 0
|
||||
fi
|
||||
|
||||
for phase in "${phases[@]}"; do
|
||||
run_phase "$phase"
|
||||
done
|
||||
|
||||
log_section "Setup complete"
|
||||
show_status
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user