feat: AIUI chat mode integration with iframe, context broker, overnight loop

- Chat mode: AIUI loads in sandboxed iframe at /dashboard/chat with transparent bg
- Mode switcher: Easy + Pro tabs only, Chat is a launcher button
- Keyboard shortcuts: Cmd+1 (Easy), Cmd+2 (Pro), Cmd+3 (Chat), Cmd+M (cycle)
- Directional transitions: chat slides from/to left, dashboard from/to right
- Context broker: postMessage protocol for quarantined AIUI communication
- AI permissions store: user-controlled toggles for data access categories
- Settings UI: AI Data Access section with per-category toggles
- AIUI container manifest and nginx proxy config for /aiui/
- Deploy script builds AIUI with /aiui/ base path
- Overnight loop infrastructure (loop.sh, prepare.sh, plan.md, prompt.md)
- Security hooks for autonomous overnight runs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-04 12:06:20 +00:00
parent 7b044d22ef
commit 584ce646e1
23 changed files with 1528 additions and 77 deletions

View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# PreToolUse Bash guard: block dangerous shell commands.
# Denies: rm -rf, git reset --hard, git push -f, git clean -fd, chmod -R 777,
# fork bombs, block device overwrites, mkfs, building Rust on macOS for Linux.
set -euo pipefail
INPUT=$(cat)
CMD=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('tool_input', {}).get('command', ''))
except: pass
" <<< "$INPUT")
BASE="${CLAUDE_PROJECT_DIR:-}"
[[ -z "$BASE" ]] && BASE=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('cwd', ''))
except: pass
" <<< "$INPUT")
[[ -z "$BASE" ]] && BASE="$(pwd)"
# Normalize: collapse whitespace, strip leading/trailing
CMD_NORM=$(echo "$CMD" | tr -s '[:space:]' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
deny() {
local reason="$1"
python3 -c "
import json
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'permissionDecision': 'deny',
'permissionDecisionReason': '$reason'
}
}))
"
exit 0
}
# Dangerous patterns
case "$CMD_NORM" in
*"rm -rf"*|*"rm -fr"*|*"rm -f -r"*|*"rm -r -f"*) deny "Destructive rm -rf blocked by security hook" ;;
*"git reset --hard"*) deny "git reset --hard would lose uncommitted work" ;;
*"git push --force"*|*"git push -f"*|*"git push -f "*) deny "git push --force would rewrite history" ;;
*"git clean -fd"*|*"git clean -f -d"*) deny "git clean -fd deletes untracked files" ;;
*"chmod -R 777"*|*"chmod -R 0777"*) deny "chmod -R 777 is a security risk" ;;
*":(){ :"*"};:"*) deny "Fork bomb pattern blocked" ;;
*"> /dev/sd"*|*">/dev/sd"*) deny "Block device overwrite blocked" ;;
*"mkfs "*|*"mkfs."*) deny "Disk format command blocked" ;;
esac
# Block building Rust locally on macOS (should always build on dev server)
if [[ "$(uname)" == "Darwin" ]]; then
if echo "$CMD_NORM" | grep -qE '^\s*cargo\s+build'; then
# Allow if it's clearly an SSH command (building on remote)
if ! echo "$CMD_NORM" | grep -qE 'ssh|sshpass'; then
deny "NEVER build Rust on macOS — use ./scripts/deploy-to-target.sh --live or build on dev server via SSH"
fi
fi
fi
# Check for path traversal escaping project root
if [[ -n "$BASE" ]] && [[ -d "$BASE" ]]; then
if echo "$CMD_NORM" | grep -qE '\.\./|/\.\.'; then
if echo "$CMD_NORM" | grep -qE '(rm|mv|cp|cat|chmod|chown)\s+.*\.\.'; then
if echo "$CMD_NORM" | grep -qE '\brm\b.*\.\.'; then
deny "Path traversal with rm blocked"
fi
fi
fi
fi
exit 0

View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# PostToolUse Bash hook: detect deploy commands and remind to test.
# Triggers after deploy-to-target.sh runs.
set -euo pipefail
INPUT=$(cat)
CMD=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('tool_input', {}).get('command', ''))
except: pass
" <<< "$INPUT")
# Only trigger on deploy commands or git push
if ! echo "$CMD" | grep -qE 'deploy-to-target|git\s+push'; then
exit 0
fi
TIMESTAMP=$(date '+%Y-%m-%d %H:%M')
python3 -c "
import json
message = '''Deploy detected at $TIMESTAMP.
Post-deploy checklist:
1. Test the web UI at http://192.168.1.228
2. Verify modified apps load correctly
3. Check backend logs: sudo journalctl -u archipelago -n 20
4. Check nginx: sudo tail -f /var/log/nginx/error.log
5. If building ISO, sync system configs to image-recipe/configs/
6. Update CHANGELOG.md if this is a notable change'''
output = {
'hookSpecificOutput': {
'hookEventName': 'PostToolUse',
'deployReminder': message
}
}
print(json.dumps(output))
"

82
.claude/hooks/protect-files.sh Executable file
View File

@ -0,0 +1,82 @@
#!/usr/bin/env bash
# PreToolUse Edit|Write guard: block edits outside project and to protected paths.
# Denies: paths outside project, .git/, .env*, lockfiles, node_modules/, deploy-config.sh
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('tool_input', {}).get('file_path', ''))
except: pass
" <<< "$INPUT")
BASE="${CLAUDE_PROJECT_DIR:-}"
[[ -z "$BASE" ]] && BASE=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('cwd', ''))
except: pass
" <<< "$INPUT")
[[ -z "$BASE" ]] && BASE="$(pwd)"
# Resolve to absolute path
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
ABS_BASE=$(cd "$BASE" 2>/dev/null && pwd) || true
[[ -z "$ABS_BASE" ]] && ABS_BASE=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$BASE" 2>/dev/null) || true
[[ -z "$ABS_BASE" ]] && ABS_BASE="$BASE"
[[ "$ABS_BASE" != */ ]] && ABS_BASE="${ABS_BASE}/"
if [[ "$FILE_PATH" != /* ]]; then
ABS_PATH="$ABS_BASE${FILE_PATH#./}"
else
ABS_PATH="$FILE_PATH"
fi
ABS_PATH=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$ABS_PATH" 2>/dev/null) || true
[[ -z "$ABS_PATH" ]] && ABS_PATH="$ABS_BASE${FILE_PATH#./}"
deny() {
local reason="$1"
echo "Blocked: $ABS_PATH$reason" >&2
python3 -c "
import json
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'permissionDecision': 'deny',
'permissionDecisionReason': '$reason'
}
}))
"
exit 0
}
# Protected patterns
PROTECTED_PATTERNS=(
".git/"
".env"
".env.local"
"node_modules/"
"package-lock.json"
"scripts/deploy-config.sh"
)
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$ABS_PATH" == *"$pattern"* ]] || [[ "$ABS_PATH" == *"/$pattern" ]]; then
deny "Edit blocked: path matches protected pattern ($pattern)"
fi
done
# .env.*.local
if [[ "$ABS_PATH" =~ \.env\..*\.local$ ]]; then
deny "Edit blocked: .env.*.local files contain secrets"
fi
# Ensure path is under project root
if [[ "$ABS_PATH" != "$ABS_BASE"* ]] && [[ "$ABS_PATH" != "$BASE"* ]]; then
deny "Edit blocked: path is outside project directory"
fi
exit 0

35
.claude/settings.json Normal file
View File

@ -0,0 +1,35 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-bash.sh"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-deploy-check.sh"
}
]
}
]
}
}

35
apps/aiui/manifest.yml Normal file
View File

@ -0,0 +1,35 @@
app:
id: aiui
name: AI Assistant
version: 0.1.0
description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker.
internal: true # System-managed, not shown in App Store
container:
image: localhost/archipelago-aiui:latest
pull_policy: always
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated # No outbound network — all data comes via context broker
ports:
- host: 5180
container: 80
protocol: tcp
bind: 127.0.0.1 # Only accessible via nginx proxy, not externally
health_check:
type: http
endpoint: http://localhost:80
path: /
interval: 60s
timeout: 5s
retries: 3

View File

@ -6,6 +6,12 @@ server {
root /opt/archipelago/web-ui;
index index.html;
# AIUI SPA (Chat mode iframe)
location /aiui/ {
alias /opt/archipelago/web-ui/aiui/;
try_files $uri $uri/ /aiui/index.html;
}
# Serve static files (Vue.js SPA)
location / {
try_files $uri $uri/ /index.html;

188
loop/loop.sh Executable file
View File

@ -0,0 +1,188 @@
#!/usr/bin/env sh
# Headless loop script for overnight Claude Code automation.
# Set CLAUDE_AUTONOMOUS=1 for Ralph Wiggum (Stop hook blocks until plan is complete).
# Rate-limit aware: detects limits, sleeps until reset, and retries automatically.
set -u
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
PROMPT_FILE="${PROMPT_FILE:-$PROJECT_DIR/loop/prompt.md}"
LOG_FILE="${LOG_FILE:-$PROJECT_DIR/loop/loop.log}"
ITERATION_COUNT="${ITERATION_COUNT:-10}"
ITERATION_DELAY="${ITERATION_DELAY:-30}"
CLAUDE_BIN="${CLAUDE_BIN:-claude}"
RATE_LIMIT_WAIT="${RATE_LIMIT_WAIT:-3600}"
MAX_RATE_LIMIT_RETRIES="${MAX_RATE_LIMIT_RETRIES:-5}"
CLAUDE_EXIT=0
cd "$PROJECT_DIR"
log() {
echo "$1" | tee -a "$LOG_FILE"
}
banner() {
log ""
log "================================================================"
log " $1"
log " $(date '+%Y-%m-%d %H:%M:%S')"
log "================================================================"
log ""
}
section() {
log ""
log "----------------------------------------"
log " $1"
log "----------------------------------------"
log ""
}
plan_has_tasks() {
grep -q '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null
}
remaining_tasks() {
grep -c '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null || echo "0"
}
next_task() {
grep -m1 '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null | sed 's/^- \[ \] //' || echo "(none)"
}
check_rate_limit() {
[ "${CLAUDE_EXIT:-0}" -eq 0 ] && return 1
tail -50 "$LOG_FILE" 2>/dev/null | grep -v "^Rate limit detected" | grep -v "^Sleeping" | grep -v "^=" | grep -v "^-" | grep -qi \
-e "rate.limit" \
-e "too.many.requests" \
-e "429" \
-e "quota.exceeded" \
-e "usage.limit" \
-e "limit.reached" 2>/dev/null
}
banner "ARCHY OVERNIGHT AUTOMATION STARTED"
log " Project: $PROJECT_DIR"
log " Prompt: $PROMPT_FILE"
log " Autonomous: ${CLAUDE_AUTONOMOUS:-0}"
log " Iterations: $ITERATION_COUNT (${ITERATION_DELAY}s between each)"
log " Rate limit: wait ${RATE_LIMIT_WAIT}s, retry up to ${MAX_RATE_LIMIT_RETRIES}x"
log " Tasks left: $(remaining_tasks)"
log " Next task: $(next_task)"
log ""
i=1
rate_limit_retries=0
while [ "$i" -le "$ITERATION_COUNT" ]; do
if ! plan_has_tasks; then
banner "ALL TASKS COMPLETE"
log " No remaining tasks in plan.md. Stopping."
break
fi
section "ITERATION $i/$ITERATION_COUNT"
log " Tasks remaining: $(remaining_tasks)"
log " Next task: $(next_task)"
log ""
export CLAUDE_PROJECT_DIR="$PROJECT_DIR"
export CLAUDE_AUTONOMOUS="${CLAUDE_AUTONOMOUS:-1}"
if [ -f "$PROMPT_FILE" ]; then
log " Starting Claude session..."
log ""
"$CLAUDE_BIN" -p --dangerously-skip-permissions \
< "$PROMPT_FILE" 2>&1 | tee -a "$LOG_FILE"
CLAUDE_EXIT=$?
log ""
log " Claude exited with code: $CLAUDE_EXIT"
else
log " ERROR: $PROMPT_FILE not found"
exit 1
fi
if check_rate_limit; then
rate_limit_retries=$((rate_limit_retries + 1))
if [ "$rate_limit_retries" -ge "$MAX_RATE_LIMIT_RETRIES" ]; then
section "RATE LIMITED — SCHEDULING LAUNCHD RETRY"
log " Hit rate limit $rate_limit_retries times. Creating launchd job to retry later."
PLIST_LABEL="com.archy.overnight-retry"
PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist"
RETRY_TIME=$(date -v+${RATE_LIMIT_WAIT}S '+%H:%M' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M')
RETRY_HOUR=$(echo "$RETRY_TIME" | cut -d: -f1)
RETRY_MIN=$(echo "$RETRY_TIME" | cut -d: -f2)
cat > "$PLIST_PATH" <<PLIST
<?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>${PLIST_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>cd ${PROJECT_DIR} && caffeinate -i ./loop/loop.sh >> ${LOG_FILE} 2>&1; launchctl unload ${PLIST_PATH}; rm -f ${PLIST_PATH}</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>${RETRY_HOUR}</integer>
<key>Minute</key>
<integer>${RETRY_MIN}</integer>
</dict>
<key>EnvironmentVariables</key>
<dict>
<key>CLAUDE_AUTONOMOUS</key>
<string>1</string>
<key>CLAUDE_PROJECT_DIR</key>
<string>${PROJECT_DIR}</string>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin</string>
</dict>
<key>StandardOutPath</key>
<string>${LOG_FILE}</string>
<key>StandardErrorPath</key>
<string>${LOG_FILE}</string>
</dict>
</plist>
PLIST
launchctl load "$PLIST_PATH" 2>/dev/null || true
log " Scheduled retry at ~${RETRY_TIME}"
log " Plist: $PLIST_PATH (auto-removes after running)"
exit 0
fi
section "RATE LIMITED — WAITING"
log " Attempt $rate_limit_retries/$MAX_RATE_LIMIT_RETRIES"
log " Sleeping ${RATE_LIMIT_WAIT}s until $(date -v+${RATE_LIMIT_WAIT}S '+%H:%M:%S' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M:%S')..."
sleep "$RATE_LIMIT_WAIT"
if ! plan_has_tasks; then
banner "ALL TASKS COMPLETE (during rate limit wait)"
break
fi
log " Retrying..."
continue
fi
rate_limit_retries=0
section "ITERATION $i COMPLETE"
log " Tasks remaining: $(remaining_tasks)"
log " Next task: $(next_task)"
i=$((i + 1))
if [ "$i" -le "$ITERATION_COUNT" ] && [ "$ITERATION_DELAY" -gt 0 ]; then
log " Pausing ${ITERATION_DELAY}s before next iteration..."
sleep "$ITERATION_DELAY"
fi
done
banner "LOOP FINISHED"
log " Completed $((i - 1)) iterations"
log " Tasks remaining: $(remaining_tasks)"
log ""

79
loop/plan.md Normal file
View File

@ -0,0 +1,79 @@
# Overnight Plan — AIUI ↔ Archy Full Integration
> **Format**: `- [ ]` = pending, `- [x]` = done.
> Make at least 30 attempts on any difficult task before moving on. Loop reads this file.
> **Coordination**: A separate AIUI agent handles AIUI-side changes. This plan covers Archy-side only.
## Phase 1: Expand Protocol & Context Categories
The current protocol only has 5 categories (apps, system, network, wallet, files). We need to add media, search, and local AI categories so AIUI can access the node's full capabilities.
- [ ] **T1** — Expand `aiui-protocol.ts` with new context categories. Add to `AIContextCategory` type: `'media'` (local media libraries — films, songs, podcasts from Plex/Jellyfin/Navidrome), `'search'` (SearXNG metasearch on the node), `'ai-local'` (Ollama local LLM info — available models, status), `'notes'` (user notes/documents), `'bitcoin'` (Bitcoin Core chain info — block height, sync status, mempool). Add corresponding request/response types. Keep the existing 5 categories unchanged.
- [ ] **T2** — Expand `aiPermissions.ts` with new categories. Add entries to `AI_PERMISSION_CATEGORIES` for each new category with user-friendly descriptions: media ("Local media libraries — film, music, podcast titles and metadata, no file paths"), search ("Web search via your private SearXNG instance"), ai-local ("Local AI models via Ollama — model names and availability"), notes ("Document and note titles — no contents"), bitcoin ("Bitcoin node status — block height, sync progress, mempool stats, no wallet keys"). All default OFF.
- [ ] **T3** — Update `Settings.vue` AI Data Access section. Add toggle rows for all new categories with appropriate SVG icons. Follow the existing pattern exactly — icon, label, description, toggle switch. Group them logically: Node Data (apps, system, network, bitcoin), Media & Files (media, files, notes), AI & Search (search, ai-local), Financial (wallet).
- [ ] **TEST:P1** — Run `cd neode-ui && npm run type-check && npm run build`. Fix all errors. Deploy: `./scripts/deploy-to-target.sh --live`.
## Phase 2: Wire Real Data into ContextBroker
Currently `wallet` and `files` return placeholders. Wire up real data from stores and RPC for all categories.
- [ ] **T4** — Wire `apps` category with full data. Currently returns basic app list. Enhance to include: app version, health status, port/URL for launching, whether app has a web UI. Read from `useAppStore().packages` and `useContainerStore()`. Sanitize: strip internal IPs (replace with relative paths like `/apps/btcpay-server/`), strip env vars, strip volume paths.
- [ ] **T5** — Wire `system` category with real metrics. Fetch from `rpcClient.call('server.metrics')` and `rpcClient.call('server.time')`. Return: CPU usage %, RAM used/total, disk used/total, uptime, OS version. Sanitize: strip hostname, kernel version details, internal IPs.
- [ ] **T6** — Wire `network` category with real data. Fetch peer count from `rpcClient.call('node-list-peers')`. Return: peer count, Tor status (connected/not, but NOT the .onion address), whether Tailscale is active. Sanitize: strip all IPs, onion addresses, pubkeys.
- [ ] **T7** — Wire `bitcoin` category (NEW). Fetch from Bitcoin Core RPC if the bitcoin-core package is installed and running. Check `useAppStore().packages` for bitcoin-core status. If running, call the backend RPC to get: block height, sync progress %, mempool size, network (mainnet/testnet). If not installed/stopped, return `{ available: false, message: 'Bitcoin Core not running' }`. Sanitize: no peer IPs, no wallet data.
- [ ] **T8** — Wire `media` category (NEW). This is the content handshake. Check which media apps are installed (Plex, Jellyfin, Navidrome, Nextcloud). For each running media app, query its API through the backend to get library summaries: film count + recent titles, song/album count + recent, podcast count. Return a structured object: `{ libraries: [{ source: 'plex', type: 'film', count: N, recent: [{title, year}] }] }`. If no media apps installed, return `{ available: false, libraries: [], message: 'No media apps installed. Install Plex or Jellyfin from the App Store.' }`. Sanitize: no file paths, no internal URLs.
- [ ] **T9** — Wire `files` category with real data. If Nextcloud or the built-in file manager is available, list top-level folders and recent files (name + type + size, no contents). If Cloud storage route exists in the app, pull from that store. Return: `{ folders: [{name, itemCount}], recentFiles: [{name, type, size, modified}] }`. Sanitize: no absolute paths, no file contents.
- [ ] **T10** — Wire `search` category (NEW). Check if SearXNG is installed and running. If yes, return `{ available: true, engine: 'searxng', endpoint: '/apps/searxng/' }` so AIUI knows it can proxy web searches through the node. If not, return `{ available: false }`. This tells AIUI whether to use its own search or the node's private search.
- [ ] **T11** — Wire `ai-local` category (NEW). Check if Ollama is installed and running. If yes, query for available models (model names, sizes, quantization). Return: `{ available: true, models: [{name, size, quantization}] }`. If not, return `{ available: false }`. This lets AIUI offer local AI as a provider option.
- [ ] **T12** — Wire `wallet` category with real data. If LND is installed and running, fetch basic wallet info through backend RPC: confirmed balance (sats), channel count, total inbound/outbound capacity. If not running, return `{ available: false }`. Sanitize: NO private keys, NO seed phrases, NO channel IDs, NO peer pubkeys. Only aggregate numbers.
- [ ] **T13** — Wire `notes` category (NEW). Check if any note-taking or document apps are installed (OnlyOffice, or built-in notes if they exist). List document titles and types (PDF, doc, note). No contents. Return: `{ documents: [{title, type, modified}] }`. If no note apps, return `{ available: false }`.
- [ ] **TEST:P2** — Run `cd neode-ui && npm run type-check && npm run build`. Fix all errors. Deploy: `./scripts/deploy-to-target.sh --live`. SSH to server and verify the deployed build loads.
## Phase 3: Action Handlers
Expand the ContextBroker's action handling so AIUI can trigger real operations.
- [ ] **T14** — Add `launch-app` action. When AIUI requests `action:request` with `action: 'launch-app'`, return the app's web UI URL so AIUI can tell the user where to go (or Archy can open it). Validate appId exists and is running.
- [ ] **T15** — Add `search-web` action. When AIUI requests a web search action, proxy it through SearXNG if available. Accept `{ action: 'search-web', params: { query: '...' } }`, call SearXNG API, return results. This lets AIUI do private web search through the node instead of external services.
- [ ] **T16** — Add `install-app` action enhancement. The existing install action is basic. Enhance: validate app exists in marketplace, check if already installed, return progress status. Handle errors gracefully.
- [ ] **TEST:P3** — Type-check, build, deploy. Verify on live server.
## Phase 4: End-to-End Testing
Test the full integration by verifying the postMessage protocol works correctly between Archy and the deployed AIUI iframe.
- [ ] **T17** — Create integration test script. Write a test page or script that: loads the Chat view, verifies iframe loads AIUI, sends test context requests for each category, verifies responses come back with correct structure. Can be a simple HTML page at `/test-aiui.html` or a Vue component at `/dashboard/test-aiui`. Log results to console.
- [ ] **T18** — Test each context category end-to-end. For each of the 10 categories: enable permission in Settings, open Chat, verify AIUI receives the permission update, trigger a context request, verify data comes back. Document which categories return real data vs. placeholders (depends on what apps are installed on the server).
- [ ] **T19** — Test action handlers. Test `navigate`, `open-app`, `launch-app`, `search-web` actions from within the AIUI iframe. Verify Archy responds correctly and performs the action.
- [ ] **T20** — Test permission denial. Disable all permissions, open Chat, verify AIUI receives empty permissions list. Verify context requests return `{ permitted: false }`. Verify AIUI handles this gracefully (should show "Enable X access in Settings" messages).
- [ ] **TEST:P4** — Final build, deploy, verify all tests pass on live server.
## Phase 5: UX Polish & Deploy
- [ ] **T21** — Add loading state to Chat.vue iframe. Show a glass-card loading indicator while AIUI iframe is loading. Listen for the `ready` postMessage from AIUI to know when it's loaded, then hide the loader. Use existing glass styling.
- [ ] **T22** — Add connection status indicator. Small pill/dot in the Chat close button area showing whether the ContextBroker has an active connection to AIUI (received `ready` message). Green dot = connected, no dot = loading.
- [ ] **T23** — Final deploy and smoke test. Clean build both AIUI and Archy. Deploy both. Hard refresh on 192.168.1.228. Test: login → open chat → 3 panels animate in → close → panels animate out → dashboard returns. Verify all permissions toggles work in Settings. Verify Cmd+3 opens chat, Cmd+1/2 returns to dashboard.
- [ ] **TEST:FINAL** — Run `cd neode-ui && npm run type-check && npm run build`. Deploy with `./scripts/deploy-to-target.sh --live`. Also rebuild and deploy AIUI: `cd /Users/dorian/Projects/AIUI && rm -rf .turbo packages/app/.turbo packages/core/.turbo packages/app/dist packages/core/dist && VITE_BASE_PATH=/aiui/ pnpm build` then `sshpass -p 'EwPDR8q45l0Upx@' scp -o StrictHostKeyChecking=no -r /Users/dorian/Projects/AIUI/packages/app/dist/* archipelago@192.168.1.228:/opt/archipelago/aiui/`. Verify at http://192.168.1.228.

38
loop/prepare.sh Executable file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env sh
# Pre-run script: verify repo state and create overnight branch.
set -eu
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
cd "$PROJECT_DIR"
DATE=$(date '+%Y-%m-%d')
BRANCH="overnight/${DATE}"
echo "=== Archy overnight pre-run check @ $(date '+%Y-%m-%dT%H:%M:%S') ==="
# 1. Check git status is clean
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: Working tree not clean. Commit or stash changes first." >&2
git status --short >&2
exit 1
fi
# 2. Check we're not already on an overnight branch
current=$(git branch --show-current 2>/dev/null || true)
if [ -n "$current" ] && [ "$current" = "$BRANCH" ]; then
echo "Already on $BRANCH. Ready to run." >&2
exit 0
fi
# 3. Create date-stamped branch
if git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then
echo "Branch $BRANCH already exists. Checkout or use a different date." >&2
exit 1
fi
git checkout -b "$BRANCH"
echo "Created branch $BRANCH"
echo ""
echo "Reminder: Push before starting overnight run: git push -u origin $BRANCH"
echo "Then run: caffeinate -i ./loop/loop.sh"
echo "=== Ready ==="

73
loop/prompt.md Normal file
View File

@ -0,0 +1,73 @@
You are integrating AIUI (AI chat interface) into Archipelago (Archy) as its Chat mode. Read these files first:
1. `loop/plan.md` — Your task checklist (mark items `- [x]` as you complete them)
2. `CLAUDE.md` — Archy project conventions, architecture, coding standards
3. `/Users/dorian/Projects/AIUI/CLAUDE.md` — AIUI conventions and Archy integration rules
## Architecture Overview
AIUI runs in an iframe at `/dashboard/chat`. Communication happens via `window.postMessage()` through a ContextBroker (Archy side) and archyBridge (AIUI side). AIUI is quarantined — it never directly accesses Archy APIs.
```
AIUI (iframe) ←→ postMessage ←→ ContextBroker (Archy) ←→ Pinia stores / RPC
```
## Key Files — Archy Side
- `neode-ui/src/services/contextBroker.ts` — Message handler, permission checks, data fetching/sanitization
- `neode-ui/src/types/aiui-protocol.ts` — TypeScript types for postMessage protocol
- `neode-ui/src/stores/aiPermissions.ts` — User permission toggles (what AIUI can access)
- `neode-ui/src/views/Chat.vue` — Iframe container with close button
- `neode-ui/src/views/Settings.vue` — AI permissions UI section
- `neode-ui/src/api/rpc-client.ts` — Backend RPC endpoints
- `neode-ui/src/api/container-client.ts` — Container operations
- `neode-ui/src/stores/app.ts` — Main app state (packages, server info, metrics)
## Key Files — AIUI Side (read-only reference, AIUI agent handles these)
- `/Users/dorian/Projects/AIUI/packages/app/src/services/archyBridge.ts` — AIUI's postMessage client
- `/Users/dorian/Projects/AIUI/packages/app/src/composables/useArchy.ts` — Vue composable wrapping archyBridge
- `/Users/dorian/Projects/AIUI/packages/app/src/composables/contentExtraction.ts` — Content tag extraction pipeline
- `/Users/dorian/Projects/AIUI/packages/app/src/composables/useContentPanel.ts` — Content panel state
## Coordination with AIUI Agent
A separate Claude agent is working on the AIUI repo simultaneously. Your job is the **Archy side only**:
- Expand the ContextBroker to serve real data for all categories
- Add new context categories for media, search, and local AI
- Wire up real store/RPC data instead of placeholders
- Deploy and test on the live server at 192.168.1.228
- DO NOT edit files in /Users/dorian/Projects/AIUI/ — the other agent handles that
## Content Handshake Protocol
AIUI's content pipeline uses `[[tag:data]]` syntax in AI responses to surface content. The AI needs context about what's available on the node to generate these tags. The handshake works like this:
1. AIUI sends `context:request` with category (e.g., `media`, `apps`, `files`)
2. Archy's ContextBroker checks permissions, fetches from stores/RPC, sanitizes
3. Returns data to AIUI which injects it into the AI's system prompt
4. AI generates responses with appropriate `[[film:id]]`, `[[song:id]]` tags referencing actual library content
5. AIUI's content extraction pipeline renders the tagged content in panels
## For each task in loop/plan.md:
1. Find the first unchecked `- [ ]` item
2. Read the task description carefully
3. Read the relevant source files before making changes
4. Implement following CLAUDE.md conventions (glass styling, TypeScript strict, etc.)
5. Run `cd neode-ui && npm run type-check` — fix all errors before continuing
6. Run `cd neode-ui && npm run build` — must succeed
7. Deploy to live server: `./scripts/deploy-to-target.sh --live`
8. Commit: `type: description` (conventional commits)
9. Mark it done `- [x]` in `loop/plan.md`
10. Move to the next unchecked task immediately
## Rules
- Never skip a build/typecheck gate — if it fails, fix before moving on
- If a task is proving difficult, make at least 30 genuine attempts before moving on
- Always deploy after completing a task — changes must be live at 192.168.1.228
- Do NOT edit AIUI files — only Archy files
- Build AIUI when needed: `cd /Users/dorian/Projects/AIUI && rm -rf .turbo packages/app/.turbo packages/core/.turbo packages/app/dist packages/core/dist && VITE_BASE_PATH=/aiui/ pnpm build`
- Deploy AIUI dist: `sshpass -p 'EwPDR8q45l0Upx@' scp -o StrictHostKeyChecking=no -r /Users/dorian/Projects/AIUI/packages/app/dist/* archipelago@192.168.1.228:/opt/archipelago/aiui/`
- Do not stop until all tasks are checked or you are rate limited

View File

@ -7,7 +7,7 @@
"start": "./start-dev.sh",
"stop": "./stop-dev.sh",
"dev": "vite",
"dev:mock": "concurrently \"node mock-backend.js\" \"vite\"",
"dev:mock": "concurrently \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
"backend:mock": "node mock-backend.js",
"backend:real": "cd ../core && cargo run --release",

View File

@ -77,12 +77,14 @@ import { useCLIStore } from '@/stores/cli'
import { useMessageToast } from '@/composables/useMessageToast'
import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import { useUIModeStore } from '@/stores/uiMode'
const router = useRouter()
const screensaverStore = useScreensaverStore()
const spotlightStore = useSpotlightStore()
const cliStore = useCLIStore()
const appStore = useAppStore()
const uiModeStore = useUIModeStore()
const messageToast = useMessageToast()
const toastMessage = messageToast.toastMessage
@ -125,6 +127,19 @@ function onKeyDown(e: KeyboardEvent) {
cliStore.toggle()
return
}
// Cmd+1/2/3 - switch UI mode (skip when in input)
if (mod && !isInput && appStore.isAuthenticated) {
if (e.key === '1') { e.preventDefault(); uiModeStore.setMode('easy'); router.push('/dashboard'); return }
if (e.key === '2') { e.preventDefault(); uiModeStore.setMode('gamer'); router.push('/dashboard'); return }
if (e.key === '3') { e.preventDefault(); router.push('/dashboard/chat'); return }
}
// Cmd+M / Ctrl+M - cycle UI mode (skip when in input)
if (mod && (e.key === 'm' || e.key === 'M') && !isInput && appStore.isAuthenticated) {
e.preventDefault()
uiModeStore.cycleMode()
router.push('/dashboard')
return
}
// 's' key activates screensaver when authenticated (skip if typing in input)
if (e.key === 's' || e.key === 'S') {
const target = e.target as HTMLElement

View File

@ -1,5 +1,14 @@
<template>
<div class="mode-switcher">
<!-- Compact mode: small pill for chat fullscreen -->
<div v-if="compact" class="chat-mode-pill-inner" @click="handleCompactClick">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span class="text-xs font-medium">{{ currentLabel }}</span>
</div>
<!-- Full mode switcher -->
<div v-else class="mode-switcher">
<button
v-for="m in modes"
:key="m.id"
@ -13,14 +22,30 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUIModeStore } from '@/stores/uiMode'
import type { UIMode } from '@/types/api'
const props = defineProps<{
compact?: boolean
}>()
const uiMode = useUIModeStore()
const router = useRouter()
const modes: { id: UIMode; label: string }[] = [
{ id: 'easy', label: 'Easy' },
{ id: 'gamer', label: 'Pro' },
{ id: 'chat', label: 'Chat' },
]
const currentLabel = computed(() => {
const found = modes.find(m => m.id === uiMode.mode)
return found ? found.label : 'Pro'
})
function handleCompactClick() {
const newMode = uiMode.cycleMode()
router.push(newMode === 'chat' ? '/dashboard/chat' : '/dashboard')
}
</script>

View File

@ -0,0 +1,248 @@
import type { Ref } from 'vue'
import type {
AIUIRequest,
ArchyResponse,
AIContextCategory,
ArchyContextResponse,
ArchyActionResponse,
} from '@/types/aiui-protocol'
import { useAIPermissionsStore } from '@/stores/aiPermissions'
import { useAppStore } from '@/stores/app'
/**
* Context Broker mediates all communication between AIUI (iframe) and Archy.
*
* AIUI sends context/action requests via postMessage.
* The broker checks permissions, fetches data from Pinia stores,
* sanitizes it (strips sensitive fields), and responds.
*/
export class ContextBroker {
private iframe: Ref<HTMLIFrameElement | null>
private allowedOrigin: string
private listener: ((e: MessageEvent) => void) | null = null
constructor(iframe: Ref<HTMLIFrameElement | null>, aiuiUrl: string) {
this.iframe = iframe
// Extract origin from URL for security validation
try {
const url = new URL(aiuiUrl, window.location.origin)
this.allowedOrigin = url.origin
} catch {
this.allowedOrigin = window.location.origin
}
}
/** Start listening for postMessage events from AIUI */
start() {
this.listener = (e: MessageEvent) => this.handleMessage(e)
window.addEventListener('message', this.listener)
}
/** Stop listening and clean up */
stop() {
if (this.listener) {
window.removeEventListener('message', this.listener)
this.listener = null
}
}
/** Send permissions update to AIUI so it knows what it can ask for */
sendPermissionsUpdate() {
const perms = useAIPermissionsStore()
this.postToIframe({
type: 'permissions:update',
categories: perms.enabledCategories,
})
}
/** Send theme info to AIUI */
sendTheme() {
this.postToIframe({
type: 'theme:response',
theme: {
accent: '#fb923c',
mode: 'dark',
},
})
}
private handleMessage(event: MessageEvent) {
// Security: verify origin
if (event.origin !== this.allowedOrigin) return
const msg = event.data as AIUIRequest
if (!msg || typeof msg.type !== 'string') return
switch (msg.type) {
case 'ready':
this.sendPermissionsUpdate()
this.sendTheme()
break
case 'context:request':
this.handleContextRequest(msg.id, msg.category, msg.query)
break
case 'action:request':
this.handleActionRequest(msg.id, msg.action, msg.params)
break
case 'theme:request':
this.sendTheme()
break
}
}
private handleContextRequest(id: string, category: AIContextCategory, query?: string) {
const perms = useAIPermissionsStore()
if (!perms.isEnabled(category)) {
this.postToIframe({
type: 'context:response',
id,
data: null,
permitted: false,
} satisfies ArchyContextResponse)
return
}
const data = this.fetchAndSanitize(category, query)
this.postToIframe({
type: 'context:response',
id,
data,
permitted: true,
} satisfies ArchyContextResponse)
}
private handleActionRequest(id: string, action: string, params: Record<string, string>) {
const appStore = useAppStore()
let success = false
let error: string | undefined
try {
switch (action) {
case 'navigate':
if (params.path) {
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: params.path }))
success = true
} else {
error = 'Missing path parameter'
}
break
case 'open-app':
if (params.appId) {
window.dispatchEvent(new CustomEvent('aiui:open-app', { detail: params.appId }))
success = true
} else {
error = 'Missing appId parameter'
}
break
case 'install-app':
if (params.appId && params.marketplaceUrl && params.version) {
appStore.installPackage(params.appId, params.marketplaceUrl, params.version).then(() => {
this.postToIframe({
type: 'action:response',
id,
success: true,
} satisfies ArchyActionResponse)
}).catch((err: Error) => {
this.postToIframe({
type: 'action:response',
id,
success: false,
error: err.message,
} satisfies ArchyActionResponse)
})
return // async — response sent in promise callbacks
}
error = 'Missing appId parameter'
break
default:
error = `Unknown action: ${action}`
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error'
}
this.postToIframe({
type: 'action:response',
id,
success,
error,
} satisfies ArchyActionResponse)
}
/** Fetch data from stores and strip sensitive fields */
private fetchAndSanitize(category: AIContextCategory, _query?: string): unknown {
const appStore = useAppStore()
switch (category) {
case 'apps':
return this.sanitizeApps(appStore)
case 'system':
return this.sanitizeSystem(appStore)
case 'network':
return this.sanitizeNetwork(appStore)
case 'wallet':
return this.sanitizeWallet(appStore)
case 'files':
return this.sanitizeFiles(appStore)
default:
return null
}
}
private sanitizeApps(store: ReturnType<typeof useAppStore>): unknown {
const packages = store.packages || {}
return Object.entries(packages).map(([id, pkg]) => ({
id,
name: pkg.manifest?.title || id,
state: pkg.state || 'unknown',
status: pkg.installed?.status || 'unknown',
}))
}
private sanitizeSystem(store: ReturnType<typeof useAppStore>): unknown {
const info = store.serverInfo
if (!info) return { status: 'unavailable' }
return {
version: info.version,
name: info.name,
// Omit: hostname, IP, paths, kernel version, pubkey
}
}
private sanitizeNetwork(store: ReturnType<typeof useAppStore>): unknown {
return {
connected: store.isConnected,
// Omit: IP addresses, ports, peer details
}
}
private sanitizeWallet(_store: ReturnType<typeof useAppStore>): unknown {
// Wallet data requires careful handling — only expose aggregates
return {
available: false,
message: 'Wallet context not yet implemented',
// Will integrate with LND store when available
}
}
private sanitizeFiles(_store: ReturnType<typeof useAppStore>): unknown {
// File listing requires cloud store integration
return {
available: false,
message: 'File context not yet implemented',
// Will integrate with cloud store when available
}
}
private postToIframe(msg: ArchyResponse) {
if (!this.iframe.value?.contentWindow) return
this.iframe.value.contentWindow.postMessage(msg, this.allowedOrigin)
}
}

View File

@ -0,0 +1,106 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { AIContextCategory } from '@/types/aiui-protocol'
const STORAGE_KEY = 'archipelago-ai-permissions'
export interface AIPermissionCategory {
id: AIContextCategory
label: string
description: string
icon: string
}
export const AI_PERMISSION_CATEGORIES: AIPermissionCategory[] = [
{
id: 'apps',
label: 'Installed Apps',
description: 'App names, status, and health — no credentials or config details',
icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
},
{
id: 'system',
label: 'System Stats',
description: 'CPU, RAM, disk usage — no file paths or IP addresses',
icon: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z',
},
{
id: 'network',
label: 'Network Status',
description: 'Connection status, peer count — no IP addresses or keys',
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
},
{
id: 'wallet',
label: 'Wallet Overview',
description: 'Balance, channel count — no private keys, seeds, or addresses',
icon: 'M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z',
},
{
id: 'files',
label: 'File Names',
description: 'Folder and file names in Cloud — no file contents',
icon: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
},
]
export const useAIPermissionsStore = defineStore('aiPermissions', () => {
const enabled = ref<Set<AIContextCategory>>(loadFromStorage())
function loadFromStorage(): Set<AIContextCategory> {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as AIContextCategory[]
return new Set(parsed.filter(c => AI_PERMISSION_CATEGORIES.some(cat => cat.id === c)))
}
} catch {
// ignore
}
return new Set()
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...enabled.value]))
}
function isEnabled(category: AIContextCategory): boolean {
return enabled.value.has(category)
}
function toggle(category: AIContextCategory) {
if (enabled.value.has(category)) {
enabled.value.delete(category)
} else {
enabled.value.add(category)
}
// Trigger reactivity
enabled.value = new Set(enabled.value)
save()
}
function enableAll() {
enabled.value = new Set(AI_PERMISSION_CATEGORIES.map(c => c.id))
save()
}
function disableAll() {
enabled.value = new Set()
save()
}
const enabledCategories = computed(() => [...enabled.value])
const allEnabled = computed(() => enabled.value.size === AI_PERMISSION_CATEGORIES.length)
const noneEnabled = computed(() => enabled.value.size === 0)
return {
enabled,
isEnabled,
toggle,
enableAll,
disableAll,
enabledCategories,
allEnabled,
noneEnabled,
}
})

View File

@ -25,9 +25,17 @@ export const useUIModeStore = defineStore('uiMode', () => {
localStorage.setItem(STORAGE_KEY, newMode)
}
function cycleMode(): UIMode {
const order: UIMode[] = ['easy', 'gamer']
const idx = order.indexOf(mode.value)
const next = order[(idx >= 0 ? idx + 1 : 0) % order.length] as UIMode
setMode(next)
return next
}
const isGamer = computed(() => mode.value === 'gamer')
const isEasy = computed(() => mode.value === 'easy')
const isChat = computed(() => mode.value === 'chat')
return { mode, setMode, syncFromBackend, isGamer, isEasy, isChat }
return { mode, setMode, cycleMode, syncFromBackend, isGamer, isEasy, isChat }
})

View File

@ -101,6 +101,109 @@
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
/* Chat launcher button — sidebar (desktop) */
.chat-launcher-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
margin-top: 0.25rem;
}
.chat-launcher-btn:hover {
background: rgba(251, 146, 60, 0.15);
border-color: rgba(251, 146, 60, 0.3);
color: #fb923c;
transform: translateY(-1px);
}
/* Chat launcher button — mobile bottom bar */
.chat-launcher-btn-mobile {
color: rgba(255, 255, 255, 0.7);
}
.chat-launcher-btn-mobile:hover {
color: #fb923c;
}
/* Chat close button (floating pill) */
.chat-close-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.25s ease;
user-select: none;
}
.chat-close-btn:hover {
background: rgba(0, 0, 0, 0.65);
border-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 1);
transform: translateY(-1px);
}
/* Chat fullscreen layout — fills the view-wrapper container */
.chat-fullscreen {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: transparent;
position: relative;
}
.chat-mode-pill {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 10;
}
.chat-iframe {
flex: 1;
width: 100%;
height: 100%;
border: none;
background: transparent;
}
/* Chat placeholder (no AIUI URL) */
.chat-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.chat-placeholder-inner {
text-align: center;
max-width: 28rem;
padding: 3rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.chat-placeholder-icon {
width: 4rem;
height: 4rem;
margin: 0 auto 1.5rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
/* Goal cards */
.goal-card {
cursor: pointer;

View File

@ -0,0 +1,87 @@
/**
* AIUI Archy postMessage Protocol
*
* AIUI (iframe) communicates with Archy (host) via structured messages.
* Archy acts as a context broker AIUI never directly accesses node data.
*/
/** Data categories that AIUI can request access to */
export type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files'
/** Actions AIUI can request Archy to perform */
export type AIActionType = 'install-app' | 'open-app' | 'navigate'
// ─── AIUI → Archy (Requests) ───────────────────────────────────────────────
export interface AIUIContextRequest {
type: 'context:request'
id: string
category: AIContextCategory
query?: string
}
export interface AIUIActionRequest {
type: 'action:request'
id: string
action: AIActionType
params: Record<string, string>
}
export interface AIUIReadyMessage {
type: 'ready'
}
export interface AIUIThemeRequest {
type: 'theme:request'
}
export type AIUIRequest =
| AIUIContextRequest
| AIUIActionRequest
| AIUIReadyMessage
| AIUIThemeRequest
// ─── Archy → AIUI (Responses) ──────────────────────────────────────────────
export interface ArchyContextResponse {
type: 'context:response'
id: string
data: unknown
permitted: boolean
}
export interface ArchyActionResponse {
type: 'action:response'
id: string
success: boolean
error?: string
}
export interface ArchyThemeResponse {
type: 'theme:response'
theme: {
accent: string
mode: 'dark'
}
}
export interface ArchyPermissionsUpdate {
type: 'permissions:update'
categories: AIContextCategory[]
}
export type ArchyResponse =
| ArchyContextResponse
| ArchyActionResponse
| ArchyThemeResponse
| ArchyPermissionsUpdate
// ─── All messages ───────────────────────────────────────────────────────────
export type AIUIMessage = AIUIRequest | ArchyResponse
/** Protocol version for compatibility checks */
export const AIUI_PROTOCOL_VERSION = '1.0.0'
/** Message origin prefix used for validation */
export const AIUI_MESSAGE_PREFIX = 'aiui:'

View File

@ -1,29 +1,82 @@
<template>
<div class="flex flex-col items-center justify-center min-h-[60vh]">
<div class="glass-card p-12 max-w-lg w-full text-center">
<div class="w-16 h-16 mx-auto mb-6 rounded-full bg-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
<div class="chat-fullscreen">
<!-- Close button: top-left, glass pill, returns to previous view -->
<div class="chat-mode-pill">
<button class="chat-close-btn" @click="closeChat">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="text-xs font-medium">Close</span>
</button>
</div>
<!-- AIUI iframe -->
<iframe
v-if="aiuiUrl"
ref="aiuiFrame"
:src="aiuiUrl"
class="chat-iframe"
sandbox="allow-scripts allow-same-origin allow-forms"
allow="microphone"
style="background: transparent"
/>
<!-- Fallback when no AIUI URL configured -->
<div v-else class="chat-placeholder">
<div class="chat-placeholder-inner">
<div class="chat-placeholder-icon">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h2 class="text-2xl font-semibold text-white mb-2">AI Assistant</h2>
<p class="text-white/60 mb-4 leading-relaxed">
AIUI is not connected. Configure the AIUI URL in your environment settings.
</p>
<p class="text-xs text-white/30">
Set <code class="text-white/50">VITE_AIUI_URL</code> or deploy the AIUI container.
</p>
</div>
<h2 class="text-2xl font-semibold text-white mb-2">AI Assistant</h2>
<p class="text-white/60 mb-8 leading-relaxed">
Conversational interface coming soon. Talk to your node, ask questions,
and manage everything through natural language.
</p>
<div class="bg-white/5 border border-white/10 rounded-xl p-4">
<input
type="text"
disabled
placeholder="What would you like to do?"
class="w-full bg-transparent text-white/30 outline-none placeholder-white/30 cursor-not-allowed"
/>
</div>
<p class="text-xs text-white/30 mt-4">AIUI integration in development</p>
</div>
</div>
</template>
<script setup lang="ts">
// Chat mode placeholder will integrate AIUI here
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { ContextBroker } from '@/services/contextBroker'
const router = useRouter()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
let broker: ContextBroker | null = null
const aiuiUrl = computed(() => {
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true`
// Production: served from /aiui/ via nginx proxy
if (import.meta.env.PROD) return '/aiui/?embedded=true'
return ''
})
function closeChat() {
// Go back if there's history, otherwise go to dashboard
if (window.history.length > 1) {
router.back()
} else {
router.push('/dashboard')
}
}
onMounted(() => {
// Start context broker if AIUI URL is available
if (aiuiUrl.value) {
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
broker.start()
}
})
onBeforeUnmount(() => {
broker?.stop()
broker = null
})
</script>

View File

@ -34,16 +34,7 @@
class="bg-glitch-scan"
:class="{ 'glitch-active': isGlitching }"
/>
<!-- Continuous glitch/flash overlays - same as login, every 5s -->
<div
class="dashboard-glitch-layer dashboard-glitch-1 bg-fullwidth"
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/>
<div
class="dashboard-glitch-layer dashboard-glitch-2 bg-fullwidth"
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/>
<div class="dashboard-glitch-scan" />
<!-- Glitch overlays removed only intro glitch plays (via isGlitching) -->
</div>
<!-- Oomph accent - brief impact flash when dashboard loads -->
@ -68,6 +59,7 @@
<!-- Sidebar - Desktop Only, animates in at end with separate parts -->
<aside
v-show="!chatFullscreen"
data-controller-zone="sidebar"
class="hidden md:flex w-[256px] flex-shrink-0 relative flex-col z-10"
:class="{ 'sidebar-animate': showZoomIn }"
@ -82,11 +74,7 @@
</div>
</div>
<div class="px-6 pt-4 pb-2 shrink-0">
<ModeSwitcher />
</div>
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-2">
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4">
<RouterLink
v-for="(item, idx) in desktopNavItems"
:key="item.path"
@ -96,24 +84,30 @@
:style="{ '--nav-stagger': idx }"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
<span>{{ item.label }}</span>
</RouterLink>
</nav>
<div class="sidebar-controller px-6 pb-2 shrink-0">
<ControllerIndicator />
</div>
<!-- Chat launcher button -->
<button
@click="router.push('/dashboard/chat')"
class="chat-launcher-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
<span>Chat</span>
</button>
<div class="sidebar-logout p-6 shrink-0">
<!-- Logout - styled as nav item, below Settings -->
<button
@click="handleLogout"
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@ -123,11 +117,22 @@
</svg>
<span>Logout</span>
</button>
</nav>
<div class="sidebar-controller px-6 pb-2 shrink-0">
<ControllerIndicator />
</div>
<!-- Online status pill - bottom of sidebar (desktop only; sidebar is hidden on mobile) -->
<!-- Online status -->
<div class="px-6 pb-2 shrink-0">
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
<OnlineStatusPill />
</div>
</div>
<!-- Mode switcher -->
<div class="px-6 pb-6 shrink-0">
<OnlineStatusPill />
<ModeSwitcher />
</div>
</div>
</div>
@ -256,10 +261,17 @@
<Transition :name="getTransitionName(route)">
<div :key="route.path" class="view-wrapper">
<div
v-if="route.path === '/dashboard/chat'"
class="h-full"
>
<component :is="Component" />
</div>
<div
v-else
:class="[
'px-4 pt-4 pb-4 md:px-8 md:pt-8 md:pb-8 overflow-y-auto h-full',
needsMobileBackButtonSpace
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-8'
'px-4 pt-4 pb-28 md:px-8 md:pt-8 md:pb-24 overflow-y-auto h-full',
needsMobileBackButtonSpace
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-24'
: undefined
]"
>
@ -274,6 +286,7 @@
<!-- Mobile Bottom Tab Bar - glass piece 5 -->
<nav
v-show="!chatFullscreen"
ref="mobileTabBar"
data-mobile-tab-bar
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
@ -287,7 +300,7 @@
:to="item.path"
class="flex items-center justify-center w-full py-3 rounded-lg text-white/70 transition-all duration-300 relative z-10"
:class="{
'nav-tab-active': item.isCombined
'nav-tab-active': item.isCombined
? (item.path === '/dashboard/apps'
? (route.path.includes('/apps') || route.path.includes('/marketplace'))
: (route.path.includes('/cloud') || route.path.includes('/server')))
@ -296,16 +309,25 @@
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
>
<svg class="w-7 h-7 transition-all duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
</RouterLink>
<!-- Chat launcher -->
<button
@click="router.push('/dashboard/chat')"
class="chat-launcher-btn-mobile flex items-center justify-center w-full py-3 rounded-lg transition-all duration-300 relative z-10"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
</button>
</div>
</nav>
</div>
@ -325,6 +347,10 @@ import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
const uiMode = useUIModeStore()
// Chat fullscreen: hide sidebar + mobile nav when on /dashboard/chat (any mode)
const chatFullscreen = computed(() => route.path === '/dashboard/chat')
const router = useRouter()
const route = useRoute()
const store = useAppStore()
@ -357,6 +383,7 @@ const ROUTE_BACKGROUNDS: Record<string, string> = {
'/dashboard/server': 'bg-network.jpg',
'/dashboard/web5': 'bg-web5.jpg',
'/dashboard/settings': 'bg-settings.jpg',
'/dashboard/chat': 'bg-home.jpg',
}
const backgroundImage = computed(() => {
@ -545,13 +572,12 @@ const gamerDesktopNav: NavItem[] = [
const easyDesktopNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'My Services', icon: 'apps' },
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const chatDesktopNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/chat', label: 'Chat', icon: 'chat' },
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
@ -566,19 +592,17 @@ const gamerMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
{ path: '/dashboard/cloud', label: 'Network', icon: 'server', isCombined: true },
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const easyMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Services', icon: 'apps' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const chatMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/chat', label: 'Chat', icon: 'chat' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
@ -628,6 +652,7 @@ const tabOrder = [
'/dashboard/cloud',
'/dashboard/server',
'/dashboard/web5',
'/dashboard/chat',
'/dashboard/settings'
]
@ -640,6 +665,18 @@ function getTransitionName(currentRoute: any) {
previousPath = currentPath
return 'fade'
}
// Chat transitions: directional slide (chat from/to left, dashboard from/to right)
const isChat = currentPath === '/dashboard/chat'
const wasChat = previousPath === '/dashboard/chat'
if (isChat) {
previousPath = currentPath
return 'chat-open'
}
if (wasChat) {
previousPath = currentPath
return 'chat-close'
}
const isAppDetails = currentPath.includes('/apps/') && !currentPath.endsWith('/apps')
const isAppsList = currentPath === '/dashboard/apps'
@ -1210,6 +1247,58 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
}
}
/* Chat open transition — chat slides in from left, dashboard slides out to right */
.chat-open-enter-active.view-wrapper,
.chat-open-leave-active.view-wrapper {
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.chat-open-enter-from.view-wrapper {
opacity: 0;
transform: translateX(-60px) scale(0.96);
}
.chat-open-enter-to.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-open-leave-from.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-open-leave-to.view-wrapper {
opacity: 0;
transform: translateX(60px) scale(0.96);
}
/* Chat close transition — chat slides out to left, dashboard slides in from right */
.chat-close-enter-active.view-wrapper,
.chat-close-leave-active.view-wrapper {
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.chat-close-enter-from.view-wrapper {
opacity: 0;
transform: translateX(60px) scale(0.96);
}
.chat-close-enter-to.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-close-leave-from.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-close-leave-to.view-wrapper {
opacity: 0;
transform: translateX(-60px) scale(0.96);
}
/* Fade transition for initial loads and default cases */
.fade-enter-active,
.fade-leave-active {

View File

@ -217,15 +217,56 @@
</div>
</div>
<!-- System Section -->
<div class="path-option-card cursor-default px-6 py-6">
<h2 class="text-xl font-semibold text-white/96 mb-4">System</h2>
<div class="text-center py-8">
<svg class="w-12 h-12 mx-auto text-white/40 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p class="text-white/70">Additional settings coming soon</p>
<!-- AI Data Access Section -->
<div class="path-option-card cursor-default px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-2">
<h2 class="text-xl font-semibold text-white/96">AI Data Access</h2>
<div class="flex items-center gap-2">
<button
v-if="!aiPermissions.allEnabled"
@click="aiPermissions.enableAll()"
class="text-xs text-white/50 hover:text-white/80 transition-colors"
>
Enable All
</button>
<button
v-if="!aiPermissions.noneEnabled"
@click="aiPermissions.disableAll()"
class="text-xs text-white/50 hover:text-white/80 transition-colors"
>
Disable All
</button>
</div>
</div>
<p class="text-sm text-white/60 mb-6">Control what data the AI assistant can see. All categories are off by default.</p>
<div class="space-y-3">
<button
v-for="cat in aiCategories"
:key="cat.id"
@click="aiPermissions.toggle(cat.id)"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
:class="aiPermissions.isEnabled(cat.id)
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="aiPermissions.isEnabled(cat.id) ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="cat.icon" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ cat.description }}</p>
</div>
<div
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="aiPermissions.isEnabled(cat.id) ? 'bg-orange-500' : 'bg-white/15'"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="aiPermissions.isEnabled(cat.id) ? 'translate-x-5' : 'translate-x-1'"
/>
</div>
</button>
</div>
</div>
</div>
@ -236,6 +277,7 @@ import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useUIModeStore } from '@/stores/uiMode'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import ControllerIndicator from '@/components/ControllerIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
@ -244,6 +286,8 @@ import type { UIMode } from '@/types/api'
const router = useRouter()
const store = useAppStore()
const uiMode = useUIModeStore()
const aiPermissions = useAIPermissionsStore()
const aiCategories = AI_PERMISSION_CATEGORIES
const interfaceModes: { id: UIMode; label: string; description: string; iconPaths: string[] }[] = [
{

View File

@ -64,6 +64,7 @@ kill_port 5959 # Mock backend
kill_port 8100 # Vite dev server
kill_port 8101 # Potential fallback port
kill_port 8102 # Another fallback port
kill_port 5173 # AIUI dev server
echo -e "${GREEN}✅ Ports cleared${NC}"
echo ""
@ -119,7 +120,7 @@ fi
echo -e "${BLUE}🚀 Starting servers...${NC}"
echo ""
# Use npm run dev:mock which uses concurrently
# Use npm run dev:mock (includes AIUI dev server automatically)
npm run dev:mock
# Note: The script will stay running until Ctrl+C

View File

@ -122,6 +122,23 @@ if [ "$LIVE" = true ]; then
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo rm -rf /opt/archipelago/web-ui/*"
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp -r $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/"
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
# Build and deploy AIUI
AIUI_DIR="$PROJECT_DIR/../AIUI"
AIUI_DIST="$AIUI_DIR/packages/app/dist"
if [ -d "$AIUI_DIR/packages/app" ]; then
echo "$(timestamp) Building AIUI for /aiui/ base path..."
cd "$AIUI_DIR" && VITE_BASE_PATH=/aiui/ pnpm build 2>&1 | tail -5
cd "$PROJECT_DIR"
echo "$(timestamp) Deploying AIUI..."
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo mkdir -p /opt/archipelago/web-ui/aiui"
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo rm -rf /opt/archipelago/web-ui/aiui/*"
cd "$AIUI_DIST" && tar cf - . | sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo tar xf - -C /opt/archipelago/web-ui/aiui/"
cd "$PROJECT_DIR"
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui/aiui"
else
echo "$(timestamp) ⚠️ AIUI not found at $AIUI_DIR, skipping"
fi
# Add /archipelago/ to nginx if missing (for peer messaging over Tor)
if [ -f "$SCRIPT_DIR/nginx-archipelago-patch.conf" ]; then