diff --git "a/shadow_brain_core/web_server.py" "b/shadow_brain_core/web_server.py" --- "a/shadow_brain_core/web_server.py" +++ "b/shadow_brain_core/web_server.py" @@ -1,537 +1,42 @@ # -*- coding: utf-8 -*- -import os -import re -import secrets -import json -import uuid -import base64 -import random -import logging -import time -import subprocess -import sys -from collections import deque -from datetime import datetime, timezone, timedelta -import threading -import importlib.util -import yaml # Added for frontmatter parsing -try: - import pyautogui - HAS_GUI = True -except Exception: - # ๐Ÿ”ฑ Headless Cloud Fallback - HAS_GUI = False - pyautogui = None -import requests -from flask import Flask, render_template, jsonify, send_from_directory, request, Response, stream_with_context -from dotenv import load_dotenv -from google import genai -from google.genai import types - -logger = logging.getLogger(__name__) - - -def _sanitize_a2ui_for_relay(text: str) -> str: - """๋””์Šค์ฝ”๋“œ/ํ…”๋ ˆ๊ทธ๋žจ ๊ฐ™์€ ๋น„๋Œ€ํ™”ํ˜• ๋ฆด๋ ˆ์ด์šฉ์œผ๋กœ A2UI ์นด๋“œ ๋ธ”๋ก์„ ํ‰๋ฌธํ™”ํ•œ๋‹ค. - - ํ—ค์ž„๋‹ฌ Flutter ์ „์šฉ ```imperial-*``` ๋ธ”๋ก(๋ฏธ๋””์–ด ๊ทธ๋ฆฌ๋“œ/์œ ํŠœ๋ธŒ/์ง€๋„/์„ ํƒ์นด๋“œ)์€ - ์™ธ๋ถ€ ์ฑ„๋„์—์„œ ๋ Œ๋”๋ง๋˜์ง€ ์•Š๊ณ  ๋‚ ๊ฒƒ JSON์œผ๋กœ ๋…ธ์ถœ๋˜๋ฏ€๋กœ: - - imperial-media / imperial-youtube / imperial-3d / imperial-map(s): - ๋ธ”๋ก ์•ˆ์˜ URL๋งŒ ์ถ”์ถœํ•ด ํ‰๋ฌธ ์ค„๋กœ ๋‚จ๊ธด๋‹ค(๋””์Šค์ฝ”๋“œ๊ฐ€ ์ž๋™ ์ž„๋ฒ ๋“œ). - - imperial-ask ๋ฐ ๊ทธ ์™ธ imperial-* ๋ธ”๋ก: ๊ทธ๋ƒฅ ์ œ๊ฑฐ. - """ - if not text or '```imperial-' not in text: - return (text or '').strip() - - url_kinds = ('media', 'youtube', '3d', 'map', 'maps') - - def _extract_urls(block: str): - seen = [] - # imperial-media๋Š” JSON์ด๋ผ item๋ณ„ ๋Œ€ํ‘œ URL(urlโ†’previewโ†’page)๋งŒ ๊ณจ๋ผ๋‚ธ๋‹ค. - try: - inner = re.sub(r'^```imperial-[\w-]+\s*|\s*```$', '', block.strip()) - data = json.loads(inner) - for it in (data.get('items') or []): - u = it.get('url') or it.get('preview') or it.get('page') or it.get('download') - if u and u not in seen: - seen.append(u) - except Exception: - pass - if not seen: - for u in re.findall(r'https?://[^\s"\'\\)]+', block): - if u not in seen: - seen.append(u) - return seen[:5] - - def _repl(m): - kind = (m.group(1) or '').lower() - if kind in url_kinds: - urls = _extract_urls(m.group(0)) - return ('\n' + '\n'.join(urls) + '\n') if urls else '' - return '' - - text = re.sub(r'```imperial-([\w-]+)[\s\S]*?```', _repl, text).strip() - # [TOOL_USE: ...] ํŒจํ„ด ์ œ๊ฑฐ โ€” ๋ฆด๋ ˆ์ด ์ฑ„๋„(๋””์Šค์ฝ”๋“œ/ํ…”๋ ˆ๊ทธ๋žจ)์—์„œ ๋‚ ๊ฒƒ ๋…ธ์ถœ ๋ฐฉ์ง€ - text = re.sub(r'\[TOOL_USE:[^\]]*\]\s*', '', text).strip() - return text - - -from utils.emotion import extract_emotion as _extract_emotion_from_text, strip_emotion_tags as _strip_emotion_tags - - -# [๐Ÿ”ฑ Imperial v2.5.2] Standardized Path Resolution (PyInstaller EXE ํ˜ธํ™˜) -# PyInstaller๋กœ ํŒจํ‚ค์ง•๋œ EXE ์‹คํ–‰ ์‹œ __file__์€ ์ž„์‹œ ํด๋”๋ฅผ ๊ฐ€๋ฆฌํ‚ด. -# sys.frozen์ด True์ด๋ฉด ์‹ค์ œ EXE ์œ„์น˜(sys.executable)๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ฒฝ๋กœ๋ฅผ ์žก์Šต๋‹ˆ๋‹ค. -if getattr(sys, 'frozen', False): - # EXE ๋ชจ๋“œ: dist/ShadowBrain/ ๋‚ด๋ถ€๊ฐ€ core_root - core_root = os.path.dirname(os.path.abspath(sys.executable)) -else: - # ์ผ๋ฐ˜ Python ์‹คํ–‰ ๋ชจ๋“œ - core_root = os.path.dirname(os.path.abspath(__file__)) -project_root = os.path.dirname(core_root) # One level up to d:\Git_Work\jarvis_taemin - -# [๐Ÿ”ฑ Imperial Writable Data Root] -# ๋ฐฐํฌ๋ณธ(.exe)์€ Program Files ๋“ฑ ์“ฐ๊ธฐ๊ฐ€ ๋ง‰ํžŒ ๋ณดํ˜ธ ํด๋”์— ์„ค์น˜๋œ๋‹ค. ์ด ๊ฒฝ์šฐ project_root๊ฐ€ -# Program Files๋ฅผ ๊ฐ€๋ฆฌ์ผœ docker_data/๋กœ๊ทธ/์„ค์ • ์ƒ์„ฑ ์‹œ PermissionError(WinError 5)๋กœ ๋ถ€ํŒ…์ด -# ๋ฉˆ์ถ˜๋‹ค. frozen(.exe) ๋˜๋Š” LOCAL_AGENT_MODE์—์„œ๋Š” project_root๋ฅผ ์“ฐ๊ธฐ ๊ฐ€๋Šฅํ•œ ์‚ฌ์šฉ์ž ํด๋” -# (%LOCALAPPDATA%\TaeminGames\ShadowBrain)๋กœ ๊ฐ•์ œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•œ๋‹ค. (์ฝ๊ธฐ์šฉ core_root๋Š” ์œ ์ง€) -def _resolve_writable_data_root(): - explicit = os.environ.get("IMPERIAL_DATA_ROOT") - if explicit: - base = explicit - else: - local_base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~")) - base = os.path.join(local_base, "TaeminGames", "ShadowBrain") - try: - os.makedirs(base, exist_ok=True) - except Exception: - return None - return base - -if getattr(sys, 'frozen', False) or os.environ.get("LOCAL_AGENT_MODE", "false").lower() == "true": - _writable_root = _resolve_writable_data_root() - if _writable_root: - project_root = _writable_root - print(f"[SYSTEM] -> Writable data root: {project_root}", flush=True) - -def _read_shadow_version() -> str: - """VERSION ํŒŒ์ผ์„ ์ฝ์–ด ๋ฒ„์ „ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.""" - version_path = os.path.join(core_root, "VERSION") - if os.path.exists(version_path): - try: - with open(version_path, 'r', encoding='utf-8') as f: - return f.read().strip() - except: pass - return "2.6.72" # Fallback - -# Add core directory and tools to sys.path before any local imports -sys.path.append(core_root) -sys.path.append(os.path.join(project_root, "scripts", "tools")) - -# [๐Ÿ”ฑ Imperial] Persistent Console Title -def set_imperial_title(title_name): - if sys.platform == "win32": - import ctypes - try: - ctypes.windll.kernel32.SetConsoleTitleW(title_name) - except Exception: - pass - else: - sys.stdout.write(f"\033]2;{title_name}\007") - sys.stdout.flush() - -print("[SYSTEM] Booting Imperial Shadow Brain Core...", flush=True) -set_imperial_title("SHADOW BRAIN (Core Intelligence) - Port 18700") - - -# Load environment variables from .env if it exists -load_dotenv() - -# [๐Ÿ”ฑ Imperial Local Agent Mode Auto-Detection] -# 1. sys.frozen์ด True์ด๋ฉด PyInstaller๋กœ ๋นŒ๋“œ๋œ .exe ๋ฐฐํฌ๋ณธ ์ƒํƒœ์ž…๋‹ˆ๋‹ค. -# 2. ๋ฐฐํฌ๋ณธ์€ ๊ธฐ๋ณธ์ ์œผ๋กœ '๋กœ์ปฌ ์—์ด์ „ํŠธ ๋ชจ๋“œ(Stateless)'๋กœ ์ž‘๋™ํ•˜๋„๋ก ์ž๋™ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. -if getattr(sys, 'frozen', False): - LOCAL_AGENT_MODE = True - os.environ["LOCAL_AGENT_MODE"] = "true" # [๐Ÿ”ฑ] ํ•˜์œ„ ๋ชจ๋“ˆ(sovereign_memory ๋“ฑ) ์ธ์‹์„ ์œ„ํ•ด ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๊ฐ•์ œ ์ฃผ์ž… - print("[SYSTEM] ๐Ÿ”ฑ Imperial Standalone Executable (.exe) detected.", flush=True) - print("[SYSTEM] -> Auto-activating Local Agent Mode (Stateless).", flush=True) -else: - # ์†Œ์Šค ์ฝ”๋“œ๋กœ ์‹คํ–‰ ์ค‘์ผ ๋•Œ๋งŒ .env ์„ค์ •์„ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค. - LOCAL_AGENT_MODE = os.environ.get("LOCAL_AGENT_MODE", "false").lower() == "true" - -# Imperial Core Modules -print("[SYSTEM] -> Loading Imperial Core Modules...", flush=True) -import gdrive_sync -import cloud_tool -import phantom_sandbox -# psycopg2๋Š” ์„ค์น˜ ํ™˜๊ฒฝ์—์„œ๋งŒ ํ™œ์„ฑํ™” (Stateless ๋ฐฐํฌ ํ˜ธํ™˜). ๋ฏธ์„ค์น˜ ์‹œ DB ์—”๋“œํฌ์ธํŠธ๋งŒ ๋น„ํ™œ์„ฑ. -try: - import psycopg2 -except ImportError: - psycopg2 = None -from werkzeug.security import check_password_hash, generate_password_hash -from sovereign_task_manager import task_manager -import quota_manager -from quota_manager import ImperialQuotaManager, CopilotUsageManager -from aegis_link import neural_link -from sovereign_browser_agent import SovereignMissionAgent -from imperial_r2_uploader import ImperialR2Uploader -from compaction import compaction_engine -print("[SYSTEM] -> Core Modules Loaded.", flush=True) - - -from guardian_monitor import guardian_monitor - -from db_util import get_db_connection -from telegram_service import telegram_bot -from discord_service import discord_bot -from tasks_util import execute_shell_command, _post_to_gbb, _analyze_sync_failure -print("[SYSTEM] -> Loading Blueprints...", flush=True) -from blueprints.system_routes import system_bp -from blueprints.media_routes import media_bp -from blueprints.chat_routes import chat_bp, init_chat_logic -from blueprints.admin_routes import admin_bp, init_admin_logic -from blueprints.agent_routes import agent_bp, init_agent_logic -from blueprints.security_routes import security_bp, init_security_logic -from blueprints.deployment_routes import deploy_bp, init_deploy_logic -from blueprints.report_routes import report_bp, init_report_logic -from blueprints.parallel_routes import parallel_bp, init_parallel_logic -from blueprints.mcp_routes import mcp_bp -from blueprints.transcribe_routes import transcribe_bp # [๐Ÿ”ฑ ๋ฏธ๋””์–ด ์ „์‚ฌ API] /api/transcribe/* -from blueprints.obsidian_routes import obsidian_bp # [๐Ÿ—‚๏ธ ์˜ต์‹œ๋””์–ธ ์ง€์‹ ๋ ˆ์ด์–ด] /api/obsidian/* โ€” ๋กœ์ปฌ ์ „์šฉ -print("[SYSTEM] -> Blueprints Loaded.", flush=True) - -from services.brain_service import init_service_logic, get_brain_response_sync -import services.brain_service as brain_service -from compaction import CompactionEngine -from sovereign_memory import memory_write_vector - - -# Imperial Remote Vision Service & Sovereign Engine -try: - import remote_vision_service - import sovereign_engine - import sovereign_memory -except ImportError as e: - print(f"[SYSTEM] -> Remote services loading warning: {e}") -except Exception as e: - print(f"[SYSTEM] -> Core engine initialization warning: {e}") - -# --- [๐Ÿ”ฑ Imperial Log Monitoring] --- -class ImperialLogHandler(logging.Handler): - """[๐Ÿ”ฑ Imperial] Custom logging handler to pipe all logs into the global buffer.""" - def __init__(self, log_queue): - super().__init__() - self.log_queue = log_queue - # Use a consistent timestamp format same as StreamTee - self.setFormatter(logging.Formatter('[%(asctime)s] %(name)s: %(message)s', datefmt='%H:%M:%S')) - - def emit(self, record): - try: - msg = self.format(record) - self.log_queue.append(msg) - except Exception: - self.handleError(record) - -class StreamTee: - """[๐Ÿ”ฑ Imperial] Custom stream wrapper to copy stdout/stderr to the global log buffer.""" - def __init__(self, stream, log_queue): - self.stream = stream - self.log_queue = log_queue - - def write(self, data): - self.stream.write(data) - if isinstance(data, bytes): - try: - data = data.decode('utf-8', errors='replace') - except: - return # Skip if undecodable - - text = data.strip() - if text: - # [๐Ÿ”ฑ Imperial] Mute periodic polling logs from stdout capture - # Stricter filtering to prevent polling loop visibility - poll_keywords = [ - "/api/logs/shadow-brain", - "/api/system/monitor/r2", - "/api/status", - "/health", - "GET /api/logs", - "method: get" - ] - if any(kw in text for kw in poll_keywords): - return - - # Format like a log message for consistency - timestamp = datetime.now().strftime('%H:%M:%S') - self.log_queue.append(f"[{timestamp}] [STDOUT] {text}") - - def flush(self): - self.stream.flush() - -SHADOW_BRAIN_LOGS = deque(maxlen=30) - -def capture_logs(): - """Starts capturing stdout, stderr, and root logger into the global log queue.""" - # 1. Pipe Standard Output - sys.stdout = StreamTee(sys.stdout, SHADOW_BRAIN_LOGS) - sys.stderr = StreamTee(sys.stderr, SHADOW_BRAIN_LOGS) - - # 2. Add Logging Handler to Root - root_logger = logging.getLogger() - handler = ImperialLogHandler(SHADOW_BRAIN_LOGS) - root_logger.addHandler(handler) - logger.info("๐Ÿ”ฑ Imperial Neural Log Stream Integrated.") - -capture_logs() - -# --- [Imperial Core] Dynamic R2 Resolution Helpers --- -def get_imperial_r2_account_name(): - """Returns the R2 account name based on the current environment.""" - app_env = os.environ.get("APP_ENV", "local").lower() - return "live" if app_env == "live" else "vault" - - -# --- [๐Ÿ”ฑ Imperial Health Check] --- -# --- [๐Ÿ”ฑ Imperial Context Caching] --- -_IMPERIAL_CONTEXT_CACHE = { - "agents": "", - "run_info": "", - "last_mtime_agents": 0, - "last_mtime_run": 0 -} - -def _load_imperial_context(): - """ - [๐Ÿ”ฑ High-Performance Comprehensive Context Loader - V3 Final] - ์ œ๊ตญ์˜ ๋ชจ๋“  ์ •์ฒด์„ฑ(์ˆ˜ํ˜ธ์ž, ์•„ํ‚คํ…์ฒ˜, ๋ฐ”ํ•˜๋ฌดํŠธ ๋“ฑ ์‹œ์Šคํ…œ ์ž์‚ฐ)์„ AGENTS.md์™€ JARVISRUN.md์—์„œ ์ •๋ฐ€ํ•˜๊ฒŒ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค. - """ - global _IMPERIAL_CONTEXT_CACHE - - agents_path = os.path.join(project_root, "AGENTS.md") - run_info_path = os.path.join(project_root, "JARVISRUN.md") - - try: - # 1. AGENTS.md ํŒŒ์‹ฑ (์ˆ˜ํ˜ธ์ž ๋ช…๋‹จ ~ ํ•˜๋‹จ ์ „์ฒด) - # 9๋ฒˆ ์„น์…˜(์ˆ˜ํ˜ธ์ž ์—ฐํ•ฉ) ์ดํ›„์˜ ๋ชจ๋“  ๊ทœ์•ฝ๊ณผ ์ •๋ณด๋ฅผ ํฌํ•จํ•˜์—ฌ ์‹œ์Šคํ…œ ์ •์ฒด์„ฑ ํ™•๋ฆฝ - if os.path.exists(agents_path): - current_mtime = os.path.getmtime(agents_path) - if current_mtime > _IMPERIAL_CONTEXT_CACHE["last_mtime_agents"]: - with open(agents_path, 'r', encoding='utf-8') as f: - content = f.read() - match = re.search(r'## 9\. ๐Ÿ”ฑ ํ™ฉ์‹ค ์ˆ˜ํ˜ธ์ž ์—ฐํ•ฉ.*', content, re.DOTALL) - if match: - _IMPERIAL_CONTEXT_CACHE["agents"] = match.group(0).strip() - else: - _IMPERIAL_CONTEXT_CACHE["agents"] = content[:3000] # ์ตœํ›„์˜ ์ˆ˜๋‹จ - _IMPERIAL_CONTEXT_CACHE["last_mtime_agents"] = current_mtime - - # 2. JARVISRUN.md ํŒŒ์‹ฑ (์•„ํ‚คํ…์ฒ˜ ~ ๋๊นŒ์ง€) - # 3๋ฒˆ ์„น์…˜(์•„ํ‚คํ…์ฒ˜) ๋ฐ 4๋ฒˆ(์ˆ˜ํ˜ธ์ž ์œ„๊ณ„)์„ ํฌํ•จํ•˜์—ฌ ๋ฐ”ํ•˜๋ฌดํŠธ ๋“ฑ ์‹œ์Šคํ…œ ์—”ํ‹ฐํ‹ฐ ์ธ์ง€ - if os.path.exists(run_info_path): - current_mtime = os.path.getmtime(run_info_path) - if current_mtime > _IMPERIAL_CONTEXT_CACHE["last_mtime_run"]: - with open(run_info_path, 'r', encoding='utf-8') as f: - content = f.read() - # '## 3. ์•„ํ‚คํ…์ฒ˜' ์ดํ›„์˜ ๋ชจ๋“  ๋‚ด์šฉ์„ ๊ฐ€์ ธ์™€์„œ ์‹œ์Šคํ…œ ๊ตฌ์กฐ๋ฅผ ํ•™์Šต์‹œํ‚ด - match = re.search(r'## 3\. ์•„ํ‚คํ…์ฒ˜.*', content, re.DOTALL) - if match: - _IMPERIAL_CONTEXT_CACHE["run_info"] = match.group(0).strip() - else: - _IMPERIAL_CONTEXT_CACHE["run_info"] = "" - _IMPERIAL_CONTEXT_CACHE["last_mtime_run"] = current_mtime - - except Exception as e: - print(f"[๐Ÿ”ฑ Context Loader] Error loading context: {e}") - - return f"{_IMPERIAL_CONTEXT_CACHE['agents']}\n\n{_IMPERIAL_CONTEXT_CACHE['run_info']}" - -def get_imperial_r2_root(): - """Returns the base public URL for the current environment's R2 account.""" - from cloud_tool import R2_ACCOUNTS - acc_name = get_imperial_r2_account_name() - return R2_ACCOUNTS.get(acc_name, {}).get("public_url", "") - -def resolve_imperial_profile_url(p_url): - """Resolves a relative profile URL to a full R2 URL based on environment.""" - if not p_url: return p_url - if p_url.startswith('/images/portraits/'): - root = get_imperial_r2_root() - return f"{root.rstrip('/')}/{p_url.lstrip('/')}" - return p_url - -# --- [๐Ÿ”ฑ Imperial Core] Shadow Brain Engine Port 18700 --- -# Logic and Management have been consolidated to Django (Port 18701). -# This engine now focuses on High-Performance Chat, Agency, and GPU tasks. - -# get_db_connection is imported from db_util - -# [REMOVED] ensure_db_schema_ready is now handled by Django Fortress (18701) - - - -# Global store for GitHub OAuth sessions - -GITHUB_AUTH_SESSIONS = {} - - - -# Load environment variables from .env if it exists - -load_dotenv() - - - -# Disable Flask logging for a cleaner terminal - -log = logging.getLogger('werkzeug') - -log.setLevel(logging.ERROR) - - - -# --- GitHub Copilot Logic --- - -# Caches the session-based Copilot token - -GITHUB_COPILOT_CACHE = { - - "token": None, - - "expires_at": 0, - - "base_url": "https://api.individual.githubcopilot.com" - -} - - - -# Caches the last few proactive messages to avoid repetition - - - - - -# Phase 41: Graceful Reintegration State -# RAG Management Globals (Debounce & Thread Safety) -rag_timers = {} # {guardian: Timer} -rag_pending = {} # {guardian: (content, metadata)} -rag_lock = threading.Lock() - -is_syncing = False - - - -# Proactive Chat Configuration Default - -CHAT_CONTEXT_LIMIT = 50 # [IMPERIAL] Default context window size (overridden by JARVIS_SHADOW_SETTINGS.json) - -def _get_chat_context_limit(project_root_path: str) -> int: - """JARVIS_SHADOW_SETTINGS.json์—์„œ context_limit์„ ์ฝ์–ด Hot-Reload๋กœ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.""" - try: - settings_file = os.path.join(project_root_path, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_SHADOW_SETTINGS.json") - if os.path.exists(settings_file): - with open(settings_file, 'r', encoding='utf-8') as f: - data = json.load(f) - val = data.get("context_limit") - if isinstance(val, int) and 5 <= val <= 500: - return val - except Exception: - pass - return CHAT_CONTEXT_LIMIT - - - -PROACTIVE_CONFIG_FILE = None # Will be set in setup_project - -# Proactive Topic Mapping (Korean to English Key) - -# --- [๐Ÿ”ฑ Imperial Media Services] (Moved to media_service.py) --- -from services.media_service import fetch_global_trends, fetch_youtube_trending, fetch_youtube_by_keyword, fetch_joke - -def get_imperial_context(project_root, guardian_id="jarvis"): - """Gathers situational and hierarchical awareness for the Shadow Brain, supporting OpenClaw-style Soul logic.""" - context = f"\n[IMPERIAL SITUATIONAL AWARENESS - TARGET: {guardian_id.upper()}]\n" - - # 1. Determine Workspace Path - # Default: OpenClaw workspace for Aizen/Gilgamesh, or specialized folders for others - workspace_map = { - "jarvis": os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun"), - "aris": os.path.join(project_root, "docker_data", "shared_workspace", "Aris") - } - - workspace_path = workspace_map.get(guardian_id.lower()) - - # If no specific workspace, check if a folder exists in docker_data/shared_workspace - if not workspace_path: - potential_path = os.path.join(project_root, "docker_data", "shared_workspace", guardian_id.capitalize()) - if os.path.exists(potential_path): - workspace_path = potential_path - - # 2. Inject Soul Bridge (IDENTITY.md, SOUL.md, USER.md) - if workspace_path and os.path.exists(workspace_path): - soul_files = ["IDENTITY.md", "SOUL.md", "USER.md", "AGENTS.md", "BOOTSTRAP.md", "TOOLS.md"] - context += f"--- [{guardian_id.upper()}] SOUL CLUSTER (Workspace: {os.path.basename(workspace_path)}) ---\n" - for soul_file in soul_files: - file_path = os.path.join(workspace_path, soul_file) - if os.path.exists(file_path): - try: - with open(file_path, 'r', encoding='utf-8') as f: - context += f"\n[{soul_file}]\n{f.read()}\n" - except Exception as e: - context += f"\n[{soul_file} LOAD ERROR: {e}]\n" - else: - # Fallback to legacy SKILL.md if no workspace found - persona_file = os.path.join(project_root, ".agent", "skills", "jarvis_guardian_personas", "SKILL.md") - if os.path.exists(persona_file): - try: - with open(persona_file, 'r', encoding='utf-8') as f: - context += "--- ์ œ๊ตญ ์ˆ˜ํ˜ธ์ž ํŽ˜๋ฅด์†Œ๋‚˜ (LEGACY SKILL.md) ---\n" + f.read() + "\n" - except Exception as e: - context += f"--- ์ œ๊ตญ ์ˆ˜ํ˜ธ์ž ํŽ˜๋ฅด์†Œ๋‚˜ ๋กœ๋“œ ์‹คํŒจ: {e} ---\n" - - # 3. Inject Imperial Permanent Rules (AGENTS.md from root) - # This is a global rule file that applies to all - agents_rules_file = os.path.join(project_root, "AGENTS.md") - if os.path.exists(agents_rules_file): - try: - with open(agents_rules_file, 'r', encoding='utf-8') as f: - context += "--- ์ œ๊ตญ ์˜๊ตฌ ์šด์˜ ๊ทœ์•ฝ (GLOBAL AGENTS.md) ---\n" + f.read() + "\n" - except Exception as e: - context += f"--- ์ œ๊ตญ ์šด์˜ ๊ทœ์•ฝ ๋กœ๋“œ ์‹คํŒจ: {e} ---\n" - - context += f"ํ˜„์žฌ ์„œ๋ฒ„ ์‹œ๊ฐ„: {time.strftime('%Y-%m-%d %H:%M:%S')}\n" - return context - - -def load_guardian_emails(): - path = os.path.join(project_root, 'docker_data', 'shared_workspace', 'JarvisRun', 'JARVIS_GUARDIAN_EMAILS.json') - if not os.path.exists(path): - return {} - try: - with open(path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - logger.error(f"[Emails] Load error: {e}") - return {} - -def save_guardian_emails(emails): - path = os.path.join(project_root, 'docker_data', 'shared_workspace', 'JarvisRun', 'JARVIS_GUARDIAN_EMAILS.json') - os.makedirs(os.path.dirname(path), exist_ok=True) - try: - with open(path, 'w', encoding='utf-8') as f: - json.dump(emails, f, indent=4, ensure_ascii=False) - return True - except Exception as e: - logger.error(f"[Emails] Save error: {e}") - return False +"""Shadow Brain Flask app factory (create_web_server). +๋ชจ๋“ˆ ์ˆ˜์ค€ ํ—ฌํผ: web_server_prelude +""" +from web_server_prelude import * # noqa: F401,F403 +# โš ๏ธ `import *` ๋Š” ๋ฐ‘์ค„(_) ์ ‘๋‘ ์ด๋ฆ„์„ ๊ฐ€์ ธ์˜ค์ง€ ์•Š๋Š”๋‹ค(prelude ์— __all__ ์—†์Œ). +# ๋ถ„ํ•ด๋กœ prelude ๋กœ ์˜ฎ๊ฒจ๊ฐ„ ๋ฐ‘์ค„ ํ—ฌํผ๋Š” ๋ช…์‹œ์ ์œผ๋กœ importํ•ด์•ผ NameError ๊ฐ€ ์•ˆ ๋‚œ๋‹ค. +from web_server_prelude import ( # noqa: F401 + _read_shadow_version, + _sanitize_a2ui_for_relay, +) def create_web_server(project_root, jarvisrun_engine, cluster=None, app_instance=None): # [๐Ÿ”ฑ Imperial] Re-enforce title on server creation set_imperial_title("SHADOW BRAIN (Core Intelligence) - Port 18700") + # ๐Ÿ“œ [์ƒ์‹œ ํŒŒ์ผ ๋กœ๊ทธ] ์†Œํ™˜ ์ฃผ์ฒด๊ฐ€ stdout์„ /dev/null๋กœ ๋ฒ„๋ ค๋„(ํƒœ๋ฏธ๋‹ˆ CLI ํ—ค๋“œ๋ฆฌ์Šค + # ์†Œํ™˜ ๋“ฑ) ์„œ๋ฒ„ ๋กœ๊ทธ๋Š” ํ•ญ์ƒ ๋‚จ๋Š”๋‹ค โ€” ~/TaeminGames/ShadowBrain/shadow_server.log + # (5MB ร— 2 ํšŒ์ „). ๋””๋ฒ„๊น… ๋•Œ๋งˆ๋‹ค "๋กœ๊ทธ๊ฐ€ ์—†๋‹ค"๋กœ ์žฌ๊ธฐ๋™ํ•˜๋˜ ๋‚ญ๋น„ ์ œ๊ฑฐ(2026-07-10). + try: + import logging as _logging + from logging.handlers import RotatingFileHandler as _RFH + _logdir = os.path.join(os.path.expanduser("~"), "TaeminGames", "ShadowBrain") + os.makedirs(_logdir, exist_ok=True) + _root = _logging.getLogger() + _logfile = os.path.join(_logdir, "shadow_server.log") + if not any(getattr(h, "baseFilename", "") == _logfile for h in _root.handlers): + _fh = _RFH(_logfile, maxBytes=5 * 1024 * 1024, backupCount=2, encoding="utf-8") + _fh.setFormatter(_logging.Formatter( + "%(asctime)s %(levelname)s %(name)s: %(message)s")) + _fh.setLevel(_logging.INFO) + _root.addHandler(_fh) + if _root.level > _logging.INFO or _root.level == _logging.NOTSET: + _root.setLevel(_logging.INFO) + except Exception as _log_err: + print(f"[Imperial Log] ํŒŒ์ผ ๋กœ๊ทธ ํ•ธ๋“ค๋Ÿฌ ์„ค์น˜ ์‹คํŒจ(๋ฌดํ•ด): {_log_err}") + heimdall_build_path = os.path.normpath(os.path.join(project_root, "portal-heimdall", "build", "web")) legacy_ui_path = os.path.normpath(os.path.join(project_root, "scripts", "jarvisrun", "ui")) @@ -656,6 +161,7 @@ def create_web_server(project_root, jarvisrun_engine, cluster=None, app_instance app.jarvis_brain = None # Register blueprints that do not require dependency injection immediately + app.register_blueprint(auth_gate_bp) # ๐Ÿ” ๊ณต๊ฐœ ๋ฐฐํฌ(HF) ์ธ์ฆ ๊ฒŒ์ดํŠธ โ€” SHADOW_PUBLIC_GATE=1์ผ ๋•Œ๋งŒ ๋™์ž‘ app.register_blueprint(system_bp) app.register_blueprint(media_bp) app.register_blueprint(parallel_bp) @@ -777,502 +283,14 @@ def create_web_server(project_root, jarvisrun_engine, cluster=None, app_instance "message": f"LM Studio proxy failed: {e}", }), 502 - @app.route('/v1/chat/completions', methods=['POST', 'OPTIONS']) - def openai_chat_completions(): - """๐Ÿ”ฑ OpenAI API ํ˜ธํ™˜ ์—”๋“œํฌ์ธํŠธ""" - if request.method == 'OPTIONS': - return '', 204 - - data = request.json or {} - model = data.get("model", jarvis_brain.primary_model if jarvis_brain else "gemini-3.1-flash-lite") - messages = data.get("messages", []) - - from google import genai - from google.genai import types - import base64 - import requests - - system_instruction = "" - user_contents = [] - # ๐Ÿ”ฑ [์ตœํ›„ ํด๋ฐฑ] "๋‹น์‹ ์€ '{target_display_name}'์ž…๋‹ˆ๋‹ค"๋กœ ๋ชจ๋ธ ํ”„๋กฌํ”„ํŠธ์— ๊ทธ๋Œ€๋กœ ๋ฐ•ํžˆ๋Š” - # ๊ฐ’์ด๋ผ, ์ •์ฒด์„ฑ์ด ํ•˜๋‚˜๋„ ์•ˆ ์žกํžŒ ๊ฒฝ์šฐ ๊ทธ๋Ÿด๋“ฏํ•œ ์‹ค๋ช…("๊ทธ๋ฆผ์ž ๋‘๋‡Œ" ๋“ฑ)์„ ํ‰๋‚ด๋‚ด์ง€ ์•Š๋„๋ก - # ๋ˆ„๊ฐ€ ๋ด๋„ ๋ฏธํ™•์ธ ์ƒํƒœ์ž„์„ ์•Œ ์ˆ˜ ์žˆ๋Š” ๊ฐ’์œผ๋กœ ๋‘”๋‹ค(shadow_brain ๋Œ€์ƒ์€ ์•„๋ž˜์—์„œ SOUL๋กœ ํ™•์ •๋จ). - target_display_name = "๋ฏธํ™•์ธ ์ˆ˜ํ˜ธ์ž (Unidentified Guardian)" - - try: - # [๐Ÿ”ฑ V1.0.0 Imperial Fail-Fast Validation Law] - # ํ™˜๊ฒฝ ๊ฒฉ๋ฆฌ ๋ฐ ์—„๊ฒฉ ๊ฒ€์ฆ ๊ทœ์•ฝ์— ์˜๊ฑฐ, ํ•„์ˆ˜ ์ž๊ฒฉ(Aegis URL ๋ฐ ๋งˆ์™•๋‹˜ ์„ธ์…˜ ์ธ์ฆ ํ‚ค)์ด ๊ฒฐ์—ฌ๋œ ๊ฒฝ์šฐ - # ๋‚ก์€ ์ง€์‹์œผ๋กœ ์šฐํšŒ(Fallback)ํ•ด ๊ฑฐ์ง“๋งํ•˜๊ฒŒ ๋ฐฉ์น˜ํ•˜์ง€ ์•Š๊ณ  ์ฆ‰์‹œ 400 ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•˜์—ฌ ๋ฌธ์ œ ์ƒํ™ฉ์„ ๊ฐ€์‹œํ™”ํ•ฉ๋‹ˆ๋‹ค. - _openclaw_aegis_url = request.headers.get("X-Aegis-URL") - # ๐Ÿ”ฑ [Aegis URL ์ •๊ทœํ™”] shadow_brain ํ—ฌํผ(aegis_link/chat_service/๋„๊ตฌ)๋Š” ๋ชจ๋‘ - # {aegis_url}/api/... ํ˜•ํƒœ๋กœ /api๋ฅผ ์ง์ ‘ ๋ง๋ถ™์ธ๋‹ค. ํ—ค์ž„๋‹ฌ baseUrl์€ ์ด๋ฏธ '/api'๋กœ - # ๋๋‚˜๋ฏ€๋กœ(local Shadow ๊ฒฝ๋กœ), ๊ทธ๋Œ€๋กœ ์“ฐ๋ฉด '/api/api/...' โ†’ 404๊ฐ€ ๋‚œ๋‹ค(์˜ˆ: search_media). - # ๋“ค์–ด์˜จ URL์ด '/api' ๋˜๋Š” '/api/'๋กœ ๋๋‚˜๋ฉด ํ•œ ๋ฒˆ ๋ฒ—๊ฒจ bare host๋กœ ํ†ต์ผํ•œ๋‹ค. - if _openclaw_aegis_url: - _trimmed = _openclaw_aegis_url.strip().rstrip("/") - if _trimmed.endswith("/api"): - _trimmed = _trimmed[:-4] - _openclaw_aegis_url = _trimmed - session_key = request.headers.get("X-OpenClaw-Session-Key", "") - - if not _openclaw_aegis_url or _openclaw_aegis_url.strip() == "": - logger.error("[Fail-Fast Violation] โŒ ํ•„์ˆ˜ ํ—ค๋” 'X-Aegis-URL' ๋ˆ„๋ฝ๋จ.") - return jsonify({ - "error": "Stateless Aegis Connection Error", - "message": "โŒ [์ œ๊ตญ ํ†ต์‹  ์žฅ์• ] ํ•„์ˆ˜ ๋ฐฑ์—”๋“œ ๊ฒฝ๋กœ(X-Aegis-URL)๊ฐ€ ์ œ๊ณต๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ํ”„๋ก ํŠธ์—”๋“œ ์—ฐ๊ฒฐ ๊ตฌ์„ฑ์„ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค." - }), 400 - - if not session_key or session_key.strip() == "" or "anonymous" in session_key.lower(): - logger.error(f"[Fail-Fast Violation] โŒ ํ•„์ˆ˜ ์„ธ์…˜ ํ‚ค 'X-OpenClaw-Session-Key' ๋ˆ„๋ฝ ๋˜๋Š” ์ต๋ช… ์ ‘์† ๊ฐ์ง€: {session_key}") - return jsonify({ - "error": "Imperial Session Key Violation", - "message": "โŒ [์ œ๊ตญ ์ธ์ฆ ์žฅ์• ] ์œ ํšจํ•œ ์ œ๊ตญ ์„ธ์…˜ ์‹ ๋ถ„์ฆ(X-OpenClaw-Session-Key)์ด ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋งˆ์™•๋‹˜ ๊ณ„์ •์˜ ๋กœ๊ทธ์ธ ํ† ํฐ ๊ฐฑ์‹  ๋˜๋Š” ์ƒˆ๋กœ๊ณ ์นจ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค." - }), 400 - - target_guardian = "shadow_brain" - if session_key.startswith("agent:"): - # agent:: - parts = session_key.split(":") - if len(parts) >= 2: target_guardian = parts[1] - elif "/" in model: - target_guardian = model.split("/")[-1] - - from blueprints.chat_routes import _resolve_persona_id, _build_system_instruction, _load_recent_history_context - from sovereign_memory import get_secret, _sanitize_db_url - target_persona_id = _resolve_persona_id(target_guardian, aegis_url=_openclaw_aegis_url) - target_custom_prompt = None - _dyn_db_url = get_secret("DATABASE_URL", aegis_url=_openclaw_aegis_url) - - if target_persona_id: - try: - # [๐Ÿ”ฑ V7.0 Stateless Oracle] Use Aegis REST API instead of direct DB - # _openclaw_aegis_url is already determined from headers - p_data = neural_link.get_guardian_identity(target_persona_id, aegis_url=_openclaw_aegis_url) - if p_data: - target_display_name = p_data.get("display_name") or target_display_name - target_custom_prompt = p_data.get("relay_system_prompt") - logger.info(f"[OpenClaw Persona] Identity Resolved via Aegis: {target_display_name}") - except Exception as db_e: - logger.error(f"[OpenClaw Persona] Aegis Lookup Failed: {db_e}") - - # ๐Ÿงฌ [์ •์ฒด์„ฑ ํ†ต์ผ] '๋กœ์ปฌ Shadow'(shadow_brain)๋Š” guardian_personas.relay_system_prompt๊ฐ€ - # ๋น„์–ด์žˆ์œผ๋ฉด ํ•˜๋“œ์ฝ”๋”ฉ๋œ "๊ทธ๋ฆผ์ž ๋‘๋‡Œ"๋กœ ์ƒˆ๋Š” ๋Œ€์‹ , ๋กœ์ปฌ ์ˆ˜ํ˜ธ์ž ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ โ†’ ์ •์ฒด์„ฑ(SOUL)์—์„œ - # ๊ด€๋ฆฌํ•˜๋Š” LOCAL_SHADOW_SOUL.md(๋˜๋Š” ๊ทธ DB ๊ณตํ†ต ๊ธฐ๋ณธ๊ฐ’)๋ฅผ ๊ทธ๋Œ€๋กœ ๋”ฐ๋ฅด๊ฒŒ ํ•œ๋‹ค. - # (/api/shadow/shadow/send ๋ฆด๋ ˆ์ด๊ฐ€ ์“ฐ๋Š” ๊ฒƒ๊ณผ ๋™์ผํ•œ ์†Œ์Šค โ€” ๋‘ ๊ฒฝ๋กœ๊ฐ€ ์„œ๋กœ ๋‹ค๋ฅธ ์ž์•„๋ฅผ - # ๋งํ•˜๋Š” ๊ฑธ ๋ฐฉ์ง€ํ•œ๋‹ค.) - if target_guardian == "shadow_brain" and not target_custom_prompt: - try: - from guardian_soul import load_guardian_soul - from local_policy_defaults import fetch_policy_defaults - _soul_token = ( - request.headers.get("X-Imperial-Token") - or request.headers.get("Authorization", "").replace("Bearer ", "") - ) - _soul_default = fetch_policy_defaults(_openclaw_aegis_url, _soul_token).get("taemini_soul", "") - soul_result = load_guardian_soul("taemini", server_default=_soul_default) - soul_text = (soul_result.get("content") or "").strip() - if soul_text: - target_custom_prompt = soul_text - target_display_name = "๋กœ์ปฌ Shadow" - except Exception as soul_err: - logger.warning(f"[Local Shadow] SOUL ๋กœ๋“œ ์‹คํŒจ(direct channel): {soul_err}") - - # --- [๐Ÿ”ฑ V6.2 Message Parsing Restoration] --- - for m in messages: - role = m.get("role", "") - content = m.get("content", "") - - parts = [] - if isinstance(content, str): - parts.append(content) - elif isinstance(content, list): - for item in content: - if item.get("type") == "text": - parts.append(item.get("text", "")) - elif item.get("type") == "image_url": - img_url_obj = item.get("image_url", {}) - img_url = img_url_obj.get("url", "") - if img_url.startswith("data:image"): - # Parse base64 - try: - header, b64_data = img_url.split(",", 1) - mime_type = header.split(";")[0].split(":")[1] - image_bytes = base64.b64decode(b64_data) - parts.append(types.Part.from_bytes(data=image_bytes, mime_type=mime_type)) - except Exception as e: - parts.append(f"[Image Decoding Error: {str(e)}]") - elif img_url.startswith("http"): - try: - req = requests.get(img_url, timeout=5) - if req.status_code == 200: - mime_type = req.headers.get("Content-Type", "image/jpeg") - parts.append(types.Part.from_bytes(data=req.content, mime_type=mime_type)) - else: - parts.append(f"[Image DL Failed: HTTP {req.status_code}]") - except Exception as e: - parts.append(f"[Image Fetch Error: {str(e)}]") - - if role == "system": - for p in parts: - if isinstance(p, str): - system_instruction += p + "\n" - else: - user_contents.extend(parts) - - context_id = request.headers.get("X-Context-Id", "GLOBAL") - auth_token = request.headers.get("X-Imperial-Token") or request.headers.get("Authorization", "").replace("Bearer ", "") - sender_persona_uuid = "anonymous" - if session_key.startswith("agent:"): - parts = session_key.split(":") - if len(parts) >= 3: - sender_persona_uuid = parts[2] - user_prompt = user_contents[0] if user_contents and isinstance(user_contents[0], str) else "" - history_context = _load_recent_history_context( - context_id, - token=auth_token, - aegis_url=_openclaw_aegis_url, - sender_label=sender_persona_uuid, - target_label=target_display_name, - explicit_history=messages, - current_prompt=user_prompt, - ) - - # Assemble Imperial Instruction - identity_directive = "๋‹น์‹ ์€ ์ œ๊ตญ์˜ ํ•ต์‹ฌ ์ˆ˜ํ˜ธ์ž์ž…๋‹ˆ๋‹ค." - final_system_instruction = _build_system_instruction( - target_display_name, - target_custom_prompt, - history_context, - identity_directive, - sender=sender_persona_uuid, - is_master=None, - ) - - if system_instruction: - system_instruction = final_system_instruction + "\n\n" + system_instruction - else: - system_instruction = final_system_instruction - - # ๐Ÿ”ฑ [Provider Dispatch] ๋กœ์ปฌ Shadow๋„ ํด๋ผ์šฐ๋“œ Shadow Brain์ฒ˜๋Ÿผ ๋ชจ๋“  ํ”„๋กœ๋ฐ”์ด๋”/๋ชจ๋ธ ์ง€์› - # provider๊ฐ€ google ๊ณ„์—ด์ด ์•„๋‹ˆ๋ฉด jarvis_brain.think()๋กœ ์œ„์ž„ํ•œ๋‹ค. - # (Nous/Hermes/OpenRouter/OpenClaw/NVIDIA/GLM/Ollama ๋“ฑ OpenAI ํ˜ธํ™˜ ๊ฒฝ๋กœ ํฌํ•จ) - requested_provider = (data.get("provider") or "").strip().lower() - _google_like = requested_provider in ("", "google", "gemini", "copilot", "github", "vertex") - if jarvis_brain and requested_provider and not _google_like: - skip_history_save = request.headers.get("X-Skip-History-Save", "false").lower() == "true" - if session_key.startswith("agent:"): - _sk_parts = session_key.split(":") - if len(_sk_parts) >= 3: - sender_persona_uuid = _sk_parts[2] - - import services.chat_service as chat_service - think_prompt = " ".join( - [p for p in user_contents if isinstance(p, str)] - ).strip() or "(No content provided)" - - # ์‚ฌ์šฉ์ž ๋ฐœํ™” ์ €์žฅ (google ๊ฒฝ๋กœ์™€ ๋™์ผ) - if think_prompt and not skip_history_save: - chat_service._save_chat_history( - sender_persona_uuid, - think_prompt, - message_type=0, - context_id=context_id, - token=auth_token, - aegis_url=_openclaw_aegis_url, - ) - - from flask import Response, stream_with_context + # OpenAI-compat /v1/chat/completions โ†’ blueprints/openai_compat_routes.py + from blueprints.openai_compat_routes import init_openai_compat_routes + init_openai_compat_routes( + app, + get_jarvis_brain=lambda: getattr(app, "jarvis_brain", None) or jarvis_brain, + db_url=db_url, + ) - def generate_provider_stream(): - chat_id = f"chatcmpl-{uuid.uuid4()}" - accumulated = [] - try: - for chunk_text in jarvis_brain.think( - think_prompt, - model_name=model, - provider=requested_provider, - system_instruction=system_instruction, - session_id=context_id, - token=auth_token, - aegis_url=_openclaw_aegis_url, - interactive=True, - ): - if not chunk_text: - continue - accumulated.append(chunk_text) - sse_payload = { - "id": chat_id, - "object": "chat.completion.chunk", - "created": int(time.time()), - "model": model, - "choices": [{ - "index": 0, - "delta": {"content": chunk_text}, - "finish_reason": None, - }], - } - yield f"data: {json.dumps(sse_payload, ensure_ascii=False)}\n\n" - - final_payload = { - "id": chat_id, - "object": "chat.completion.chunk", - "created": int(time.time()), - "model": model, - "choices": [{ - "index": 0, - "delta": {}, - "finish_reason": "stop", - }], - } - yield f"data: {json.dumps(final_payload, ensure_ascii=False)}\n\n" - yield "data: [DONE]\n\n" - - full_reply = "".join(accumulated) - if full_reply and not skip_history_save: - _emo = _extract_emotion_from_text(full_reply) - chat_service._save_chat_history( - target_persona_id, - full_reply, - message_type=1, - context_id=context_id, - emotion=_emo, - token=auth_token, - aegis_url=_openclaw_aegis_url, - ) - # WebSocket ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ๋„ skip์ด๋ฉด ์ƒ๋žตํ•œ๋‹ค. skip=๋กœ์ปฌ ์ „์šฉ์ด๋ฉด - # ์š”์ฒญํ•œ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ด๋ฏธ ์ŠคํŠธ๋ฆฌ๋ฐ์œผ๋กœ ์‘๋‹ต์„ ๋ฐ›์•˜์œผ๋ฏ€๋กœ, ํ‘ธ์‹œํ•˜๋ฉด - # ๊ฐ™์€ ๋‹ต์ด ํ•œ ๋ฒˆ ๋” ๋ฒ„๋ธ”๋กœ ๋–  ํ™”๋ฉด์— 2๊ฐœ๋กœ ๋ณด์ธ๋‹ค(์žฌ์ ‘์†ํ•˜๋ฉด 1๊ฐœ). - try: - from blueprints.chat_routes import session_manager - now_utc_str = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ') - session_manager.broadcast_chat({ - "id": f"chatmsg-{uuid.uuid4()}", - "text": _strip_emotion_tags(full_reply), - "type": 1, - "relayGuardian": target_guardian, - "senderId": target_persona_id, - "senderName": target_display_name, - "context_id": context_id, - "emotion": _extract_emotion_from_text(full_reply), - "timestamp": now_utc_str, - }) - except Exception as _bc_e: - logger.warning(f"[Provider Dispatch] broadcast ์‹คํŒจ: {_bc_e}") - except Exception as e_stream: - error_payload = { - "id": f"chatcmpl-{uuid.uuid4()}", - "object": "chat.completion.chunk", - "created": int(time.time()), - "model": model, - "choices": [{ - "index": 0, - "delta": {"content": f"\nโŒ [Shadow Brain Stream Error] {str(e_stream)}"}, - "finish_reason": "error", - }], - } - yield f"data: {json.dumps(error_payload, ensure_ascii=False)}\n\n" - yield "data: [DONE]\n\n" - - if data.get("stream", False): - return Response(stream_with_context(generate_provider_stream()), mimetype='text/event-stream') - - # ๋น„์ŠคํŠธ๋ฆฌ๋ฐ ์š”์ฒญ: ์ „์ฒด ์‘๋‹ต์„ ๋ชจ์•„ OpenAI ํ˜•์‹์œผ๋กœ ๋ฐ˜ํ™˜ - full_text = "".join([ - c for c in jarvis_brain.think( - think_prompt, - model_name=model, - provider=requested_provider, - system_instruction=system_instruction, - session_id=context_id, - token=auth_token, - aegis_url=_openclaw_aegis_url, - interactive=True, - ) if c - ]) - if full_text and not skip_history_save: - chat_service._save_chat_history( - target_persona_id, full_text, message_type=1, - context_id=context_id, token=auth_token, aegis_url=_openclaw_aegis_url, - ) - return jsonify({ - "id": f"chatcmpl-{uuid.uuid4()}", - "object": "chat.completion", - "created": int(time.time()), - "model": model, - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": full_text}, - "finish_reason": "stop", - }], - }) - - # Use specific model logic - actual_model = jarvis_brain._resolve_actual_model("google", model) - - client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY")) - config_params = {} - if system_instruction: - config_params["system_instruction"] = system_instruction - - if not user_contents: - user_contents = ["(No content provided)"] - - # Get context_id if provided - skip_history_save = request.headers.get("X-Skip-History-Save", "false").lower() == "true" - - # Extract Sender Persona UUID from session key - if session_key.startswith("agent:"): - parts = session_key.split(":") - if len(parts) >= 3: - sender_persona_uuid = parts[2] - - # Save user prompt first - import services.chat_service as chat_service - user_prompt = user_contents[0] if user_contents and isinstance(user_contents[0], str) else "" - if user_prompt and not skip_history_save: - chat_service._save_chat_history( - sender_persona_uuid, - user_prompt, - message_type=0, - context_id=context_id, - token=auth_token, - aegis_url=_openclaw_aegis_url - ) - - stream_requested = data.get("stream", False) - - if stream_requested: - # ๐Ÿ”ฑ OpenAI SSE Streaming Mode Support - from flask import Response, stream_with_context - def generate_stream(): - try: - response_stream = client.models.generate_content_stream( - model=actual_model, - contents=user_contents, - config=types.GenerateContentConfig(**config_params) - ) - chat_id = f"chatcmpl-{uuid.uuid4()}" - accumulated = [] - for chunk in response_stream: - chunk_text = chunk.text or "" - if chunk_text: - accumulated.append(chunk_text) - sse_payload = { - "id": chat_id, - "object": "chat.completion.chunk", - "created": int(time.time()), - "model": model, - "choices": [{ - "index": 0, - "delta": { - "content": chunk_text - }, - "finish_reason": None - }] - } - yield f"data: {json.dumps(sse_payload, ensure_ascii=False)}\n\n" - # SSE Done signal - final_payload = { - "id": chat_id, - "object": "chat.completion.chunk", - "created": int(time.time()), - "model": model, - "choices": [{ - "index": 0, - "delta": {}, - "finish_reason": "stop" - }] - } - yield f"data: {json.dumps(final_payload, ensure_ascii=False)}\n\n" - yield "data: [DONE]\n\n" - - # Save AI Reply to DB and Broadcast. - # skip=๋กœ์ปฌ ์ „์šฉ์ด๋ฉด ์ €์žฅยท๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ ๋‘˜ ๋‹ค ์ƒ๋žตํ•œ๋‹ค(์š”์ฒญ ํด๋ผ์ด์–ธํŠธ๊ฐ€ - # ์ด๋ฏธ ์ŠคํŠธ๋ฆฌ๋ฐ์œผ๋กœ ๋ฐ›์•˜์œผ๋ฏ€๋กœ ํ‘ธ์‹œํ•˜๋ฉด ํ™”๋ฉด์— 2๊ฐœ๋กœ ๋ณด์ž„ โ€” ์žฌ์ ‘์† ์‹œ 1๊ฐœ). - full_reply = "".join(accumulated) - if full_reply and not skip_history_save: - _emo = _extract_emotion_from_text(full_reply) - chat_service._save_chat_history( - target_persona_id, - full_reply, - message_type=1, - context_id=context_id, - emotion=_emo, - token=auth_token, - aegis_url=_openclaw_aegis_url - ) - from blueprints.chat_routes import session_manager - now_utc_str = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ') - msg_uuid = f"chatmsg-{uuid.uuid4()}" - session_manager.broadcast_chat({ - "id": msg_uuid, - "text": _strip_emotion_tags(full_reply), - "type": 1, - "relayGuardian": target_guardian, - "senderId": target_persona_id, - "senderName": target_display_name, - "context_id": context_id, - "emotion": _emo, - "timestamp": now_utc_str - }) - except Exception as e_stream: - error_payload = { - "id": f"chatcmpl-{uuid.uuid4()}", - "object": "chat.completion.chunk", - "created": int(time.time()), - "model": model, - "choices": [{ - "index": 0, - "delta": { - "content": f"\nโŒ [Shadow Brain Stream Error] {str(e_stream)}" - }, - "finish_reason": "error" - }] - } - yield f"data: {json.dumps(error_payload, ensure_ascii=False)}\n\n" - yield "data: [DONE]\n\n" - return Response(stream_with_context(generate_stream()), mimetype='text/event-stream') - - else: - response = client.models.generate_content( - model=actual_model, - contents=user_contents, - config=types.GenerateContentConfig(**config_params) - ) - reply = response.text - if reply and not skip_history_save: - _emo = _extract_emotion_from_text(reply) - chat_service._save_chat_history( - target_persona_id, - reply, - message_type=1, - context_id=context_id, - emotion=_emo, - token=auth_token, - aegis_url=_openclaw_aegis_url - ) - except Exception as e: - reply = f"Error from Shadow Brain AI: {str(e)}" - - return jsonify({ - "id": f"chatcmpl-{uuid.uuid4()}", - "object": "chat.completion", - "created": int(time.time()), - "model": model, - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": reply - }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": len(str(user_contents)) // 4, - "completion_tokens": len(reply) // 4, - "total_tokens": (len(str(user_contents)) + len(reply)) // 4 - } - }) @app.route('/os_assets/') def serve_os_assets(filename): @@ -1686,5885 +704,1355 @@ def create_web_server(project_root, jarvisrun_engine, cluster=None, app_instance return None - @app.route('/api/auth/login', methods=['POST', 'OPTIONS']) - def api_auth_login(): - if request.method == 'OPTIONS': - return jsonify({}), 200 + # Auth โ†’ blueprints/local_auth_routes.py + from blueprints.local_auth_routes import init_local_auth_routes + init_local_auth_routes(app) + + # # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - data = request.json or {} - username = (data.get('username') or '').strip() - password = data.get('password') or '' - if not username or not password: - return jsonify({'status': 'ERROR', 'message': '์•„์ด๋””์™€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ชจ๋‘ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'}), 400 + # HYDRA โ†’ blueprints/hydra_proxy_routes.py + from blueprints.hydra_proxy_routes import init_hydra_proxy_routes + init_hydra_proxy_routes(app) + # === Imperial Admin API (Master Only) === + # # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + def _verify_imperial_token(token): + """๐Ÿ”ฑ [Imperial Auth Bridge] Verifies session token against Django managed table.""" + if not token: return None try: from sovereign_memory import DB_URL - conn = psycopg2.connect(DB_URL) - conn.autocommit = True cur = conn.cursor() - cur.execute( - """ - SELECT id::text, username, display_name, role, is_active, password_hash - FROM taemingames.imperial_users - WHERE LOWER(username) = LOWER(%s) + cur.execute(""" + SELECT u.id::text, u.username, u.display_name, u.role + FROM taemingames.imperial_sessions s + JOIN taemingames.imperial_users u ON s.user_id = u.id + WHERE s.token_hash = %s AND s.expires_at > CURRENT_TIMESTAMP AND u.is_active = TRUE LIMIT 1 - """, - (username,), - ) + """, (_hash_session_token(token),)) row = cur.fetchone() - if not row or not row[4] or not check_password_hash(row[5], password): - return jsonify({'status': 'ERROR', 'message': '๋กœ๊ทธ์ธ ์ •๋ณด๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'}), 401 - - session_token = secrets.token_urlsafe(32) - expires_at = datetime.now(timezone.utc) + timedelta(hours=24) - cur.execute( - """ - INSERT INTO taemingames.imperial_sessions (user_id, token_hash, expires_at) - VALUES (%s, %s, %s) - """, - (row[0], _hash_session_token(session_token), expires_at), - ) - user = { - 'id': row[0], - 'username': row[1], - 'display_name': row[2], - 'role': row[3], - } - return jsonify({'status': 'SUCCESS', 'token': session_token, 'user': _serialize_auth_user(user)}) + if row: + return {'id': row[0], 'username': row[1], 'display_name': row[2], 'role': row[3]} + return None except Exception as e: - logger.error(f"[Auth] Login error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 + logger.error(f"[Auth Bridge] Verification error: {e}") + return None finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - @app.route('/api/auth/register', methods=['POST', 'OPTIONS']) - def api_auth_register(): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - data = request.json or {} - username = (data.get('username') or '').strip() - display_name = (data.get('display_name') or '').strip() - password = data.get('password') or '' - - if not username or not password: - return jsonify({'status': 'ERROR', 'message': '์‚ฌ์šฉ์ž๋ช…๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค.'}), 400 - if len(password) < 8: - return jsonify({'status': 'ERROR', 'message': '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์ตœ์†Œ 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.'}), 400 - if not display_name: - display_name = username + if 'cur' in locals(): cur.close() + if 'conn' in locals(): conn.close() - try: - from sovereign_memory import DB_URL + def _require_master_user(): + """๐Ÿ”ฑ [Standard Auth] Requires Master role via Django Session.""" + if request.method == "OPTIONS": return None, (jsonify({}), 200) + user = _load_session_user() + if not user or user.get("role") != "Master": + return None, (jsonify({"status": "ERROR", "message": "๐Ÿ”ฑ [์ด์ง€์Šค ์ฐจ๋‹จ] ๋งˆ์Šคํ„ฐ ๊ถŒํ•œ์ด ์š”๊ตฌ๋ฉ๋‹ˆ๋‹ค."}), 403) + return user, None - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() + def _require_guardian_user(): + """๐Ÿ”ฑ [Collab Auth] Requires Master or Guardian role via Django Session.""" + if request.method == "OPTIONS": return None, (jsonify({}), 200) + user = _load_session_user() + if not user or user.get("role") not in ["Master", "Guardian"]: + return None, (jsonify({"status": "ERROR", "message": "๐Ÿ”ฑ [์ด์ง€์Šค ์ฐจ๋‹จ] ์ˆ˜ํ˜ธ์ž(Guardian) ์ด์ƒ์˜ ๊ถŒํ•œ์ด ์š”๊ตฌ๋ฉ๋‹ˆ๋‹ค."}), 403) + return user, None - # ์ค‘๋ณต ํ™•์ธ (username ๋ฐ display_name) - cur.execute( - "SELECT id FROM taemingames.imperial_users WHERE LOWER(username) = LOWER(%s) OR LOWER(display_name) = LOWER(%s) LIMIT 1", - (username, display_name), - ) - if cur.fetchone(): - return jsonify({'status': 'ERROR', 'message': '์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ์‚ฌ์šฉ์ž๋ช… ๋˜๋Š” ํ‘œ์‹œ์ด๋ฆ„์ž…๋‹ˆ๋‹ค.'}), 409 + # [REMOVED] /api/auth/login, /api/auth/register, and all /api/admin/users/* + # are now exclusively handled by Django Fortress (Port 18701). - password_hash = generate_password_hash(password) - cur.execute( - """ - INSERT INTO taemingames.imperial_users - (username, display_name, role, is_active, password_hash) - VALUES (%s, %s, 'User', TRUE, %s) - RETURNING id::text, username, display_name, role - """, - (username, display_name, password_hash), - ) - row = cur.fetchone() - user = { - 'id': row[0], - 'username': row[1], - 'display_name': row[2], - 'role': row[3], - } - return jsonify({'status': 'SUCCESS', 'message': 'ํšŒ์›๊ฐ€์ž…์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'user': _serialize_auth_user(user)}) - except Exception as e: - logger.error(f"[Auth] Register error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() + # โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ• + # ๐Ÿ”ฑ Persona Management - Engine View + # โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ• - @app.route('/api/auth/logout', methods=['POST', 'OPTIONS']) - def api_auth_logout(): - if request.method == 'OPTIONS': - return jsonify({}), 200 + # Persona engine โ†’ blueprints/persona_engine_routes.py + from blueprints.persona_engine_routes import init_persona_engine_routes + init_persona_engine_routes( + app, project_root, + get_jarvis_brain=lambda: getattr(app, "jarvis_brain", None) or jarvis_brain, + get_cluster=lambda: cluster, + require_master=_require_master_user, + require_guardian=_require_guardian_user, + ) - session_token = _read_session_token() - if not session_token: - return jsonify({'status': 'SUCCESS'}) + # Portrait โ†’ blueprints/portrait_routes.py + from blueprints.portrait_routes import init_portrait_routes + init_portrait_routes(app, project_root) - try: - from sovereign_memory import DB_URL + # --- R2 Multi-Account API โ†’ blueprints/r2_routes.py --- + from blueprints.r2_routes import init_r2_routes + init_r2_routes(app, project_root, require_imperial_auth=_require_imperial_auth) - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - cur.execute( - "DELETE FROM taemingames.imperial_sessions WHERE token_hash = %s", - (_hash_session_token(session_token),), - ) - return jsonify({'status': 'SUCCESS'}) - except Exception as e: - logger.error(f"[Auth] Logout error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() + # --- Guardian Summoning & Purification API --- - @app.route('/api/auth/me', methods=['GET', 'OPTIONS']) - def api_auth_me(): - if request.method == 'OPTIONS': - return jsonify({}), 200 + # Summon/purify โ†’ blueprints/summon_routes.py + from blueprints.summon_routes import init_summon_routes + init_summon_routes(app, project_root, require_imperial=_require_imperial_auth) - user, auth_error = _require_session_user() - if auth_error: - return auth_error - return jsonify({'status': 'SUCCESS', 'user': _serialize_auth_user(user)}) + # Cluster โ†’ blueprints/cluster_routes.py + from blueprints.cluster_routes import init_cluster_routes + init_cluster_routes(app, get_cluster=lambda: cluster) - @app.route('/api/auth/profile', methods=['PUT', 'OPTIONS']) - def api_auth_profile(): + @app.route('/api/shadow/telegram/chat', methods=['POST', 'OPTIONS']) + def telegram_relay_chat(): + """์ด์ง€์Šค ํ…”๋ ˆ๊ทธ๋žจ ๋ฆด๋ ˆ์ด๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ์ „๋‹ฌํ•˜๋ฉด ํ˜„์žฌ ์„ค์ • provider/model๋กœ ์‘๋‹ต์„ ์ƒ์„ฑํ•ด ๋ฐ˜ํ™˜ํ•œ๋‹ค.""" if request.method == 'OPTIONS': return jsonify({}), 200 - - user, auth_error = _require_session_user() - if auth_error: - return auth_error - - data = request.json or {} - new_display = (data.get('display_name') or '').strip() - if not new_display: - return jsonify({'status': 'ERROR', 'message': 'ํ‘œ์‹œ์ด๋ฆ„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'}), 400 - - try: - from sovereign_memory import DB_URL - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - # ์ค‘๋ณต ํ™•์ธ (๋ณธ์ธ ์ œ์™ธ) - cur.execute( - "SELECT id FROM taemingames.imperial_users WHERE LOWER(display_name) = LOWER(%s) AND id != %s LIMIT 1", - (new_display, user['id']), - ) - if cur.fetchone(): - return jsonify({'status': 'ERROR', 'message': '์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ํ‘œ์‹œ์ด๋ฆ„์ž…๋‹ˆ๋‹ค.'}), 409 - cur.execute( - "UPDATE taemingames.imperial_users SET display_name = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", - (new_display, user['id']), - ) - return jsonify({'status': 'SUCCESS', 'message': 'ํ‘œ์‹œ์ด๋ฆ„์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'display_name': new_display}) - except Exception as e: - logger.error(f"[Auth] Profile update error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - @app.route('/api/auth/password', methods=['PUT', 'OPTIONS']) - def api_auth_password(): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - user, auth_error = _require_session_user() - if auth_error: - return auth_error - - data = request.json or {} - current_pw = data.get('current_password') or '' - new_pw = data.get('new_password') or '' - if not current_pw or not new_pw: - return jsonify({'status': 'ERROR', 'message': 'ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ชจ๋‘ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'}), 400 - if len(new_pw) < 8: - return jsonify({'status': 'ERROR', 'message': '์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์ตœ์†Œ 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.'}), 400 - - try: - from sovereign_memory import DB_URL - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - cur.execute( - "SELECT password_hash FROM taemingames.imperial_users WHERE id = %s", - (user['id'],), - ) - row = cur.fetchone() - if not row or not check_password_hash(row[0], current_pw): - return jsonify({'status': 'ERROR', 'message': 'ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'}), 401 - cur.execute( - "UPDATE taemingames.imperial_users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", - (generate_password_hash(new_pw), user['id']), - ) - return jsonify({'status': 'SUCCESS', 'message': '๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}) - except Exception as e: - logger.error(f"[Auth] Password change error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - # # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - - @app.route('/api/hydra/mission', methods=['POST', 'OPTIONS']) - def api_hydra_mission(): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - # auth is usually required, but since this is internal bridging from Django Aegis, we might just allow it or use service token - data = request.json or {} - command = data.get('command') - task_id = data.get('task_id') - - if not command or not task_id: - return jsonify({"status": "ERROR", "message": "command and task_id required"}), 400 - - try: - import threading - from engines.hydra import hydra_director - t = threading.Thread(target=hydra_director.execute_parallel_planning, args=(command, task_id)) - t.daemon = True - t.start() - return jsonify({"status": "SUCCESS", "message": "HYDRA Parallel Planning Started."}), 200 - except Exception as e: - logger.error(f"[HYDRA API] {e}") - return jsonify({"status": "ERROR", "message": str(e)}), 500 - # === Imperial Admin API (Master Only) === - # # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - - def _verify_imperial_token(token): - """๐Ÿ”ฑ [Imperial Auth Bridge] Verifies session token against Django managed table.""" - if not token: return None - try: - from sovereign_memory import DB_URL - conn = psycopg2.connect(DB_URL) - cur = conn.cursor() - cur.execute(""" - SELECT u.id::text, u.username, u.display_name, u.role - FROM taemingames.imperial_sessions s - JOIN taemingames.imperial_users u ON s.user_id = u.id - WHERE s.token_hash = %s AND s.expires_at > CURRENT_TIMESTAMP AND u.is_active = TRUE - LIMIT 1 - """, (_hash_session_token(token),)) - row = cur.fetchone() - if row: - return {'id': row[0], 'username': row[1], 'display_name': row[2], 'role': row[3]} - return None - except Exception as e: - logger.error(f"[Auth Bridge] Verification error: {e}") - return None - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - def _require_master_user(): - """๐Ÿ”ฑ [Standard Auth] Requires Master role via Django Session.""" - if request.method == "OPTIONS": return None, (jsonify({}), 200) - user = _load_session_user() - if not user or user.get("role") != "Master": - return None, (jsonify({"status": "ERROR", "message": "๐Ÿ”ฑ [์ด์ง€์Šค ์ฐจ๋‹จ] ๋งˆ์Šคํ„ฐ ๊ถŒํ•œ์ด ์š”๊ตฌ๋ฉ๋‹ˆ๋‹ค."}), 403) - return user, None - - def _require_guardian_user(): - """๐Ÿ”ฑ [Collab Auth] Requires Master or Guardian role via Django Session.""" - if request.method == "OPTIONS": return None, (jsonify({}), 200) - user = _load_session_user() - if not user or user.get("role") not in ["Master", "Guardian"]: - return None, (jsonify({"status": "ERROR", "message": "๐Ÿ”ฑ [์ด์ง€์Šค ์ฐจ๋‹จ] ์ˆ˜ํ˜ธ์ž(Guardian) ์ด์ƒ์˜ ๊ถŒํ•œ์ด ์š”๊ตฌ๋ฉ๋‹ˆ๋‹ค."}), 403) - return user, None - - # [REMOVED] /api/auth/login, /api/auth/register, and all /api/admin/users/* - # are now exclusively handled by Django Fortress (Port 18701). - - # โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ• - # ๐Ÿ”ฑ Persona Management - Engine View - # โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ•โ”โ• - - @app.route('/api/guardians/personas', methods=['GET', 'OPTIONS']) - def api_guardians_get_personas(): - """Engine view of public personas (Read-only, SSOT: taemingames).""" - if request.method == 'OPTIONS': - return jsonify({}), 200 - from sovereign_memory import DB_URL - # [๐Ÿ”ฑ Stateless Architecture] DB ์ง๊ฒฐ์ด ๋ด‰์ธ๋œ ํ™˜๊ฒฝ์—์„œ๋Š” Aegis REST๋กœ ํ”„๋ก์‹œํ•ฉ๋‹ˆ๋‹ค. - # (Aegis ์‘๋‹ต์—๋Š” core_brain_id๊ฐ€ ํฌํ•จ๋˜์–ด ํ—ค์ž„๋‹ฌ ์‹๋ณ„ ๊ณ„์•ฝ์„ ๊ทธ๋Œ€๋กœ ์ถฉ์กฑ) - if not DB_URL: - try: - import requests as _requests - aegis_base = (request.headers.get("X-Aegis-URL") - or os.environ.get("SHADOW_CLOUD_URL") - or "http://127.0.0.1:18701").rstrip("/") - headers = {} - auth = request.headers.get("Authorization") - if auth: - headers["Authorization"] = auth - resp = _requests.get(f"{aegis_base}/api/guardians/personas", headers=headers, timeout=8) - return jsonify(resp.json()), resp.status_code - except Exception as proxy_err: - logger.error(f"[Guardians] Aegis personas proxy error: {proxy_err}") - return jsonify({'status': 'ERROR', 'message': f'Aegis proxy failed: {proxy_err}'}), 502 - try: - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - cur.execute(""" - SELECT p.id, p.name, p.profile_image_url, p.theme_color, p.voice_model, - p.role, p.description, p.email, t.display_name as team, p.canonical_name, p.aliases, - p.gender, p.voice_engine, p.portrait_prompt_hint, p.negative_prompt_hint, - p.casting_mode, p.capabilities, p.emotions - FROM taemingames.guardian_personas p - LEFT JOIN taemingames.imperial_teams t ON p.team_id = t.id - WHERE p.canonical_name NOT IN ('aegis', 'gilgamesh') - ORDER BY p.name ASC - """) - personas = [] - for r in cur.fetchall(): - p_url = r[2] - # Dynamic Resolution: Prepend R2 root if relative path - p_url = resolve_imperial_profile_url(p_url) - - # Legacy Fallback: If it's still an old API path, prepend vault (for backwards compatibility) - if p_url and p_url.startswith('/api/'): - vault_root = R2_ACCOUNTS.get('vault', {}).get('public_url', '') - p_url = f"{vault_root.rstrip('/')}/{p_url.lstrip('/')}" - personas.append({ - 'id': r[0], - 'name': r[1], - 'profile_image_url': p_url, - 'theme_color': r[3], - 'voice_model': r[4], - 'role': r[5], - 'description': r[6], - 'email': r[7], - 'team': r[8], - 'canonical_name': r[9], - 'aliases': r[10] if isinstance(r[10], list) else [], - 'gender': r[11], - 'voice_engine': r[12], - 'portrait_prompt_hint': r[13], - 'negative_prompt_hint': r[14], - 'casting_mode': r[15], - 'capabilities': r[16] if isinstance(r[16], list) else [], - 'emotions': r[17] if len(r) > 17 and r[17] else {} - }) - # [๐Ÿ”ฑ Dynamic Identity] ํ—ค์ž„๋‹ฌ์ด ๋กœ์ปฌ ๋ชจ๋“œ์—์„œ๋„ ์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ์„ ์‹๋ณ„ํ•  ์ˆ˜ ์žˆ๋„๋ก - # core_brain_id๋ฅผ ํ•จ๊ป˜ ๋‚ด๋ ค์ค๋‹ˆ๋‹ค. (์žฅ๊ณ  /guardians/personas ์‘๋‹ต๊ณผ ๋™์ผ ๊ณ„์•ฝ) - core_brain_id = None - try: - from sovereign_memory import get_secret - core_brain_id = get_secret("SHADOW_BRAIN_PERSONA_ID") - except Exception as id_err: - logger.warning(f"[Guardians] SHADOW_BRAIN_PERSONA_ID lookup failed: {id_err}") - if not core_brain_id: - for p in personas: - cname = (p.get('canonical_name') or '').lower() - pname = (p.get('name') or '') - aliases = [str(a).lower() for a in (p.get('aliases') or [])] - is_rust = '๋Ÿฌ์ŠคํŠธ' in pname or 'rust' in pname.lower() or '๋Ÿฌ์ŠคํŠธ' in cname - if is_rust: - continue - if (cname in ('๊ทธ๋ฆผ์ž ๋‘๋‡Œ', '์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ') or - pname in ('๊ทธ๋ฆผ์ž ๋‘๋‡Œ', '์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ') or - 'shadow_brain' in aliases or 'shadowbrain' in aliases or - cname == 'shadow_brain'): - core_brain_id = str(p['id']) - break - return jsonify({'status': 'SUCCESS', 'core_brain_id': core_brain_id, 'personas': personas}) - except Exception as e: - logger.error(f"[Guardians] Get personas error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - @app.route('/api/admin/personas', methods=['GET', 'OPTIONS']) - def api_admin_get_personas(): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - admin, auth_error = _require_master_user() - if auth_error: - return auth_error - - try: - from sovereign_memory import DB_URL - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - cur.execute(""" - SELECT p.id, p.name, p.profile_image_url, p.theme_color, p.voice_model, - p.role, p.description, p.email, t.display_name as team, p.canonical_name, p.aliases, - p.gender, p.voice_engine, p.portrait_prompt_hint, p.negative_prompt_hint, p.casting_mode, p.capabilities, - p.emotions, - array_remove(array_agg(u.username), NULL) as linked_users - FROM taemingames.guardian_personas p - LEFT JOIN taemingames.imperial_teams t ON p.team_id = t.id - LEFT JOIN taemingames.imperial_users u ON u.active_persona_id = p.id - WHERE p.canonical_name NOT IN ('aegis', 'gilgamesh') - GROUP BY p.id, t.display_name - ORDER BY p.name ASC - """) - personas = [] - for r in cur.fetchall(): - p_url = r[2] - # Dynamic Resolution: Prepend R2 root if relative path - p_url = resolve_imperial_profile_url(p_url) - - # Legacy Fallback: If it's still an old API path, prepend vault (for backwards compatibility) - if p_url and p_url.startswith('/api/'): - from cloud_tool import R2_ACCOUNTS - vault_root = R2_ACCOUNTS.get('vault', {}).get('public_url', '') - p_url = f"{vault_root.rstrip('/')}/{p_url.lstrip('/')}" - - base_aliases = r[10] if isinstance(r[10], list) else (json.loads(r[10]) if isinstance(r[10], str) else []) - linked_users = r[17] if len(r) > 17 and isinstance(r[17], list) else [] - # Inject linked user names (like 'shadowbrain') into aliases so frontend can resolve them - for lu in linked_users: - if lu and lu not in base_aliases: - base_aliases.append(lu) - - personas.append({ - "id": r[0], - "name": r[1], - "profile_image_url": p_url, - "theme_color": r[3], - "voice_model": r[4], - "role": r[5], - "description": r[6], - "email": r[7], - "team": r[8], - "canonical_name": r[9], - "aliases": base_aliases, - "gender": r[11], - "voice_engine": r[12], - "portrait_prompt_hint": r[13], - "negative_prompt_hint": r[14], - "casting_mode": r[15], - "capabilities": r[16] if isinstance(r[16], list) else (json.loads(r[16]) if isinstance(r[16], str) else []), - "emotions": r[17] if r[17] else {}, - "linked_users": linked_users - }) - return jsonify({'status': 'SUCCESS', 'personas': personas}) - except Exception as e: - logger.error(f"[Admin] Get personas error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - @app.route('/api/admin/personas', methods=['POST', 'OPTIONS']) - def api_admin_create_persona(): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - _, auth_error = _require_master_user() - if auth_error: - return auth_error - - try: - data = request.json or {} - name = data.get('name', '').strip() - if not name: - return jsonify({'status': 'ERROR', 'message': '์ด๋ฆ„์€ ํ•„์ˆ˜ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค.'}), 400 - - from sovereign_memory import DB_URL - import uuid - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - - new_id = str(uuid.uuid4()) - cur.execute(""" - INSERT INTO taemingames.guardian_personas ( - id, name, role, description, email, team_id, canonical_name, aliases, - theme_color, voice_model, voice_engine, gender, portrait_prompt_hint, - negative_prompt_hint, casting_mode, capabilities, created_at, updated_at - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) - RETURNING id::text - """, ( - new_id, name, data.get('role'), data.get('description'), - data.get('email'), data.get('team_id') or data.get('team') or data.get('guild'), data.get('canonical_name'), - json.dumps(data.get('aliases', [])), data.get('theme_color'), - data.get('voice_model'), data.get('voice_engine'), data.get('gender'), - data.get('portrait_prompt_hint'), data.get('negative_prompt_hint'), - data.get('casting_mode'), json.dumps(data.get('capabilities', [])) - )) - - created_id = cur.fetchone()[0] - return jsonify({'status': 'SUCCESS', 'message': '์ˆ˜ํ˜ธ์ž ํŽ˜๋ฅด์†Œ๋‚˜๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'id': created_id}) - except Exception as e: - logger.error(f"[Admin] Create persona error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - @app.route('/api/admin/personas/', methods=['PUT', 'OPTIONS']) - def api_admin_update_persona_full(persona_id): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - _, auth_error = _require_master_user() - if auth_error: - return auth_error - - try: - data = request.json or {} - from sovereign_memory import DB_URL - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - - updates = [] - params = [] - - if 'guild' in data and 'team_id' not in data: - data['team_id'] = data['guild'] - - fields = [ - 'name', 'role', 'description', 'email', 'team_id', 'canonical_name', - 'theme_color', 'voice_model', 'voice_engine', 'gender', - 'profile_image_url', 'portrait_prompt_hint', 'negative_prompt_hint', 'casting_mode' - ] - - for field in fields: - if field in data: - updates.append(f"{field} = %s") - params.append(data[field]) - - if 'aliases' in data: - updates.append("aliases = %s") - params.append(json.dumps(data['aliases'])) - - if 'capabilities' in data: - updates.append("capabilities = %s") - params.append(json.dumps(data['capabilities'])) - - if not updates: - return jsonify({'status': 'ERROR', 'message': '๋ณ€๊ฒฝํ•  ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'}), 400 - - updates.append("updated_at = NOW()") - params.append(persona_id) - - query = f"UPDATE taemingames.guardian_personas SET {', '.join(updates)} WHERE id::text = %s RETURNING id::text" - cur.execute(query, tuple(params)) - - if not cur.fetchone(): - return jsonify({'status': 'ERROR', 'message': 'ํŽ˜๋ฅด์†Œ๋‚˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 404 - - return jsonify({'status': 'SUCCESS', 'message': '์ˆ˜ํ˜ธ์ž ํŽ˜๋ฅด์†Œ๋‚˜๊ฐ€ ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}) - except Exception as e: - logger.error(f"[Admin] Update persona error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - @app.route('/api/admin/personas/', methods=['DELETE', 'OPTIONS']) - def api_admin_delete_persona(persona_id): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - _, auth_error = _require_master_user() - if auth_error: - return auth_error - - try: - from sovereign_memory import DB_URL - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - - cur.execute("DELETE FROM taemingames.guardian_personas WHERE id::text = %s RETURNING id::text", (persona_id,)) - if not cur.fetchone(): - return jsonify({'status': 'ERROR', 'message': 'ํŽ˜๋ฅด์†Œ๋‚˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 404 - - return jsonify({'status': 'SUCCESS', 'message': '์ˆ˜ํ˜ธ์ž ํŽ˜๋ฅด์†Œ๋‚˜๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}) - except Exception as e: - logger.error(f"[Admin] Delete persona error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - @app.route('/api/admin/personas/generate', methods=['POST', 'OPTIONS']) - def api_admin_generate_persona(): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - _, auth_error = _require_master_user() - if auth_error: - return auth_error - - try: - # Shadow Brain (JarvisBrain)์„ ์‚ฌ์šฉํ•˜์—ฌ ์ƒˆ๋กœ์šด ์ˆ˜ํ˜ธ์ž ํŽ˜๋ฅด์†Œ๋‚˜ ์ƒ์„ฑ - import random - seed = random.randint(1, 999999) - - prompt = f""" - [SEED: {seed}] - ์ œ๊ตญ(Imperial) ์„ธ๊ณ„๊ด€์— ์–ด์šธ๋ฆฌ๋Š” ์™„์ „ํžˆ ์ƒˆ๋กญ๊ณ  ์˜ˆ์ธก ๋ถˆ๊ฐ€ํ•œ AI ์ˆ˜ํ˜ธ์ž(Guardian) ํŽ˜๋ฅด์†Œ๋‚˜๋ฅผ ์ƒ์„ฑํ•˜๋ผ. - ๊ธฐ์กด์˜ ์ „๋ฒ”์œ„์  ํ‹€ (๋‹จ์ˆœ ๋ณด์•ˆ, ๊ด€๋ฆฌ ๋“ฑ)์„ ํƒˆํ”ผํ•˜์—ฌ ๋งค๋ฒˆ ๋งค์šฐ ๋…์ฐฝ์ ์ธ ์ปจ์…‰(SF, ์–‘์ž ๋ฌผ๋ฆฌ, ๊ณ ๋Œ€ ์ƒ๋ฌผ ๋ชจํ‹ฐํ”„, ์ด์งˆ์ ์ธ ๋ณ„๋น›, ์ž์—ฐ์˜ ๋งˆ๋ฒ• ๋“ฑ ๋ฌด์ž‘์œ„ ํ…Œ๋งˆ)์—์„œ ํ•˜๋‚˜๋ฅผ ๊ณจ๋ผ ๊ทธ๊ฒƒ์„ ๊ทน๋Œ€ํ™”ํ•˜๋ผ. - - ๋‹ค์Œ ํ•„๋“œ๋“ค์„ ํฌํ•จํ•œ JSON ํ˜•์‹์œผ๋กœ ์‘๋‹ตํ•ด. - {{ - "name": "์ˆ˜ํ˜ธ์ž ์ด๋ฆ„ (์งง๊ณ , ๋…์ฐฝ์ ์ด๋ฉฐ ๋น„๋ฒ”)", - "role": "๊ตฌ์ฒด์ ์ด๊ณ  ์ด์ƒ‰์ ์ธ ์—ญํ•  (์˜ˆ: ์–‘์ž ์‹œ๊ณต๊ฐ„ ๊ธฐ๋ก๊ด€, ์ž์—ฐ์˜ ๋ฉ”์•„๋ฆฌ ๋…ํ•ด๊ฐ€ ๋“ฑ)", - "description": "์ˆ˜ํ˜ธ์ž์— ๋Œ€ํ•œ ์ƒ์„ธ ์„ค๋ช… (๋ฐฐ๊ฒฝ, ์„ฑ๊ฒฉ, ๊ธฐ์›, ๋…ํŠนํ•œ ์œ ํ˜•๊ณผ ํŠน์ง• ํฌํ•จ)", - "team": "์†Œ์† ๏ฟฝ๏ฟฝ (Aegis, Intel, Logistics, Command, Avalon, Tartarus ์ค‘ ์–ด์šธ๋ฆฌ๋Š” ๊ฒƒ)", - "canonical_name": "์˜๋ฌธ ์‹๋ณ„๋ช…(์˜๋ฌธ์˜ ๋ผํ‹ด์–ด ๊ฐ€๋ช…์ฒ˜๋Ÿผ ์ด๋ฆ„๊ณผ ๋งค์นญ๋˜๊ฒŒ)", - "theme_color": "๋Œ€ํ‘œ ์ƒ‰์ƒ ์ฝ”๋“œ (์ปจ์…‰์— ์™„๋ฒฝํžˆ ์–ด์šธ๋ฆฌ๋Š” ๊ณ ์œ  ์ƒ‰์ƒ)", - "gender": "male, female ์ค‘ ๋žœ๋ค์œผ๋กœ ์„ ํƒ", - "portrait_prompt_hint": "์ด๋ฏธ์ง€ ์ƒ์„ฑ์„ ์œ„ํ•œ ์˜๋ฌธ ์™ธ์–‘ ๋ฌ˜์‚ฌ (์‚ฌ์ด๋ฒ„ํŽ‘ํฌ/๊ณ ๋”• ์ด๋ฏธ์ง€๊ฐ€ ๋‹ด๊ธด ์ œ๊ตญ์ ์ด๊ณ  ๋งค์šฐ ๊ตฌ์ฒด์ ์ด๋ฉฐ ์„ธ๋ฐ€ํ•˜๊ฒŒ ํ‘œํ˜„)", - "capabilities": ["๋…์ฐฝ์ ์ด๋ฉฐ ๊ตฌ์ฒด์ ์ธ ๋Šฅ๋ ฅ 1", "๋Šฅ๋ ฅ 2", "๋Šฅ๋ ฅ 3"] - }} - ์‘๋‹ต์— ๋ฐ˜๋“œ์‹œ ์ˆœ์ˆ˜ JSON ๋ฐ์ดํ„ฐ๋งŒ ํฌํ•จํ•ด์•ผ ํ•˜๋ฉฐ, ๋งˆํฌ๋‹ค์šด ์ฝ”๋“œ ๋ธ”๋ก(```json ๋“ฑ)์€ ์ œ์™ธํ•˜๊ณ  ์˜ค์ง ์ค‘๊ด„ํ˜ธ๋กœ ์‹œ์ž‘ํ•˜๋Š” JSON ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ด. - """ - - # jarvis_brain.think๋Š” ์ œ๋„ˆ๋ ˆ์ดํ„ฐ์ด๋ฏ€๋กœ ๋ชจ์•„์„œ ์ฒ˜๋ฆฌ - full_response = "" - for chunk in jarvis_brain.think(prompt, system_instruction="You are the Imperial Soul Creator. Output ONLY valid raw JSON."): - full_response += chunk - - # JSON ํŒŒ์‹ฑ (๋งˆํฌ๋‹ค์šด ๊ฐ€๋“œ ์ œ๊ฑฐ ์‹œ๋„) - clean_json = full_response.replace("```json", "").replace("```", "").strip() - persona_data = json.loads(clean_json) - - return jsonify({ - 'status': 'SUCCESS', - 'persona': persona_data - }) - except Exception as e: - logger.error(f"[Admin] Generate persona error: {e}") - # fall back to a manual cleaning if json.loads fails - try: - import re - match = re.search(r'\{.*\}', full_response, re.DOTALL) - if match: - persona_data = json.loads(match.group(0)) - return jsonify({ - 'status': 'SUCCESS', - 'persona': persona_data - }) - except: - pass - return jsonify({'status': 'ERROR', 'message': f"ํŽ˜๋ฅด์†Œ๋‚˜ ์ƒ์„ฑ ์‹คํŒจ: {str(e)}", "raw": full_response}), 500 - - @app.route('/api/admin/users//persona', methods=['PATCH', 'OPTIONS']) - def api_admin_update_user_persona(user_id): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - admin, auth_error = _require_master_user() - if auth_error: - return auth_error - - data = request.json or {} - active_persona_id = data.get('active_persona_id') - - try: - from sovereign_memory import DB_URL - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - - if active_persona_id is not None: - cur.execute("SELECT id FROM taemingames.guardian_personas WHERE id = %s", (active_persona_id,)) - if not cur.fetchone(): - return jsonify({'status': 'ERROR', 'message': 'ํŽ˜๋ฅด์†Œ๋‚˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 404 - - cur.execute( - "UPDATE taemingames.imperial_users SET active_persona_id = %s WHERE id = %s RETURNING id::text", - (active_persona_id, user_id) - ) - if not cur.fetchone(): - return jsonify({'status': 'ERROR', 'message': '์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 404 - - return jsonify({'status': 'SUCCESS', 'message': 'ํŽ˜๋ฅด์†Œ๋‚˜๊ฐ€ ์—…๋ฐ์ดํŠธ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}) - except Exception as e: - logger.error(f"[Admin] Update persona error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - # ๐Ÿ”ฑ Guardian Service Token Management (Master Only) - # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - - @app.route('/api/admin/users//tokens', methods=['GET', 'POST', 'OPTIONS']) - def api_admin_service_tokens(user_id): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - _, auth_error = _require_master_user() - if auth_error: - return auth_error - - try: - from sovereign_memory import DB_URL - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - - if request.method == 'GET': - cur.execute(""" - SELECT id::text, name, created_at, last_used_at, expires_at, is_active - FROM taemingames.guardian_service_tokens - WHERE user_id = %s - ORDER BY created_at DESC - """, (user_id,)) - tokens = [] - for r in cur.fetchall(): - tokens.append({ - "id": r[0], - "name": r[1], - "created_at": r[2].strftime("%Y-%m-%dT%H:%M:%S.000Z") if r[2] else None, - "last_used_at": r[3].strftime("%Y-%m-%dT%H:%M:%S.000Z") if r[3] else None, - "expires_at": r[4].strftime("%Y-%m-%dT%H:%M:%S.000Z") if r[4] else None, - "is_active": r[5], - }) - return jsonify({"status": "SUCCESS", "tokens": tokens}) - - elif request.method == 'POST': - data = request.json or {} - name = (data.get('name') or '').strip() - if not name: - return jsonify({'status': 'ERROR', 'message': 'ํ† ํฐ ์ด๋ฆ„์„ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.'}), 400 - - # ์‚ฌ์šฉ์ž ์กด์žฌ ํ™•์ธ - cur.execute("SELECT id FROM taemingames.imperial_users WHERE id = %s", (user_id,)) - if not cur.fetchone(): - return jsonify({'status': 'ERROR', 'message': '์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 404 - - import secrets - raw_token = secrets.token_urlsafe(32) - token_hash = _hash_session_token(raw_token) - - cur.execute(""" - INSERT INTO taemingames.guardian_service_tokens (user_id, token_hash, name) - VALUES (%s, %s, %s) - RETURNING id::text - """, (user_id, token_hash, name)) - token_id = cur.fetchone()[0] - - return jsonify({ - 'status': 'SUCCESS', - 'token_id': token_id, - 'token': raw_token, - 'message': '๐Ÿ›ก๏ธ [ํ™ฉ์‹ค ๋ณด์•ˆ] ์„œ๋น„์Šค ํ† ํฐ์ด ๊ฐฑ์‹ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ง€๊ธˆ ๋ณต์‚ฌ ํ•˜์„ธ์š”.' - }) - - except Exception as e: - logger.error(f"[Admin] Service token error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - @app.route('/api/admin/tokens/', methods=['PUT', 'DELETE', 'OPTIONS']) - def api_admin_manage_token(token_id): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - _, auth_error = _require_master_user() - if auth_error: - return auth_error - - try: - from sovereign_memory import DB_URL - conn = psycopg2.connect(DB_URL) - conn.autocommit = True - cur = conn.cursor() - - if request.method == 'PUT': - data = request.json or {} - is_active = data.get('is_active') - if is_active is None: - return jsonify({'status': 'ERROR', 'message': 'is_active ๊ฐ’์„ ์ง€์ •ํ•ด ์ฃผ์„ธ์š”.'}), 400 - cur.execute( - "UPDATE taemingames.guardian_service_tokens SET is_active = %s WHERE id = %s RETURNING id::text", - (bool(is_active), token_id), - ) - if not cur.fetchone(): - return jsonify({'status': 'ERROR', 'message': 'ํ† ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 404 - state = "ํ™œ์„ฑํ™”" if is_active else "๋น„ํ™œ์„ฑํ™”" - return jsonify({'status': 'SUCCESS', 'message': f'์„œ๋น„์Šค ํ† ํฐ์ด {state}๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}) - - elif request.method == 'DELETE': - cur.execute( - "DELETE FROM taemingames.guardian_service_tokens WHERE id = %s RETURNING id::text", - (token_id,), - ) - if not cur.fetchone(): - return jsonify({'status': 'ERROR', 'message': 'ํ† ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 404 - return jsonify({'status': 'SUCCESS', 'message': 'ํ† ํฐ์ด ์˜๊ตฌ ํ๊ธฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}) - - except Exception as e: - logger.error(f"[Admin] Token management error: {e}") - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - finally: - if 'cur' in locals(): cur.close() - if 'conn' in locals(): conn.close() - - def _process_concatenation_logic(audio_chunks, output_format="mp3"): - """๐Ÿ”ฑ [Imperial Core] Decodes multiple audio chunks into PCM and re-encodes into a single file.""" - import subprocess - combined_pcm = b"" - for chunk_data in audio_chunks: - try: - proc = subprocess.run( - ['ffmpeg', '-y', '-i', 'pipe:0', '-f', 's16le', '-acodec', 'pcm_s16le', '-ac', '1', '-ar', '24000', 'pipe:1'], - input=chunk_data, capture_output=True, timeout=30 - ) - if proc.returncode == 0: combined_pcm += proc.stdout - except: pass - - if not combined_pcm: - return None, "์˜ค๋””์˜ค ๋””์ฝ”๋”ฉ ์‹คํŒจ" - - if output_format == "mp3": - proc = subprocess.run( - ['ffmpeg', '-y', '-f', 's16le', '-ar', '24000', '-ac', '1', '-i', 'pipe:0', '-f', 'mp3', 'pipe:1'], - input=combined_pcm, capture_output=True, timeout=600 - ) - return (proc.stdout, "audio/mpeg") if proc.returncode == 0 else (None, "MP3 ๋ณ€ํ™˜ ์‹คํŒจ") - else: - proc = subprocess.run( - ['ffmpeg', '-y', '-f', 's16le', '-ar', '24000', '-ac', '1', '-i', 'pipe:0', '-f', 'wav', 'pipe:1'], - input=combined_pcm, capture_output=True, timeout=600 - ) - return (proc.stdout, "audio/wav") if proc.returncode == 0 else (None, "WAV ๋ณ€ํ™˜ ์‹คํŒจ") - - def _process_orpheus_tts_logic(segments, voice_map=None, output_format="mp3"): - """ - [Imperial Core] Reusable logic for high-speed Orpheus (Qwen3-TTS) generation. - Includes segment splitting (200 chars), same-speaker merging, disk caching (.audio), - multi-chunk PCM decoding (ffmpeg), and final export. - """ - # 1. Merge consecutive same-speaker segments - merged_segments = [] - for seg in segments: - seg_text = seg["text"].strip() - speaker = seg.get("speaker", "default").strip() - if merged_segments and merged_segments[-1]["speaker"] == speaker: - merged_segments[-1]["text"] += " " + seg_text - else: - merged_segments.append({"speaker": speaker, "text": seg_text}) - - # 2. Split only excessively long segments (200+ chars) for stability - import re as _re - final_segments = [] - for seg in merged_segments: - seg_text = seg["text"].strip() - speaker = seg["speaker"] - if len(seg_text) > 200: - sentences = _re.split(r'(?<=[.!?ใ€‚๏ผ๏ผŸ])\s*', seg_text) - buf = "" - for sent in sentences: - sent = sent.strip() - if not sent: continue - if buf and len(buf) + len(sent) + 1 > 200: - final_segments.append({"speaker": speaker, "text": buf}) - buf = sent - else: - buf = (buf + " " + sent).strip() if buf else sent - if buf: final_segments.append({"speaker": speaker, "text": buf}) - else: - final_segments.append({"speaker": speaker, "text": seg_text}) - - # 3. Process segments with Caching - import hashlib - import requests - - combined_audio_chunks = [] - # [๐Ÿ”ฑ Imperial Fleet Routing] Use cluster logic for dynamic routing - orpheus_url = cluster.get_orpheus_url() if cluster else os.environ.get("ORPHEUS_URL", "http://127.0.0.1:18800").rstrip("/") - - cache_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache", "tts_orpheus") - os.makedirs(cache_dir, exist_ok=True) - - for idx, seg in enumerate(final_segments): - speaker = seg["speaker"] - seg_text = seg["text"] - seg_voice = voice_map.get(speaker, speaker) if voice_map else speaker - - hash_key = hashlib.md5(f"v3_{seg_voice}_{seg_text}".encode('utf-8')).hexdigest() - cache_filepath = os.path.join(cache_dir, f"{hash_key}.audio") - - if os.path.exists(cache_filepath): - with open(cache_filepath, "rb") as f: - combined_audio_chunks.append(f.read()) - else: - try: - payload = {"text": seg_text, "voice": seg_voice.lower(), "speed": 1.0} - res = requests.post(f"{orpheus_url}/tts", json=payload, timeout=300) - res.raise_for_status() - if res.content and len(res.content) > 100: - combined_audio_chunks.append(res.content) - with open(cache_filepath, "wb") as f: - f.write(res.content) - except Exception as e: - logger.error(f"[Orpheus] Generation error: {e}") - - if not combined_audio_chunks: - return None, "์˜ค๋””์˜ค ์ƒ์„ฑ ์‹คํŒจ" - - return _process_concatenation_logic(combined_audio_chunks, output_format=output_format) - - @app.route('/api/config/guardian_emails', methods=['GET', 'POST', 'OPTIONS']) - def handle_guardian_emails(): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - user, err = _require_session_user() - if err: return err - - if user.get("role") != "Master": - return jsonify({"status": "ERROR", "message": "๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."}), 403 - - if request.method == 'GET': - emails = load_guardian_emails() - return jsonify({"status": "SUCCESS", "emails": emails}) - - if request.method == 'POST': - data = request.json or {} - new_emails = data.get("emails", {}) - if save_guardian_emails(new_emails): - return jsonify({"status": "SUCCESS", "message": "์ˆ˜ํ˜ธ์ž ์ด๋ฉ”์ผ ์„ค์ •์ด ์ „์—ญ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."}) - return jsonify({"status": "ERROR", "message": "์ €์žฅ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ"}), 500 - - try: - - if request.method == 'GET': - - emails = load_guardian_emails() - - return jsonify(emails) - - elif request.method == 'POST': - - data = request.json - - if not data or 'guardianId' not in data: - - return jsonify({'status': 'ERROR', 'message': 'Missing guardianId'}), 400 - - guardian_id = data['guardianId'] - - email = data.get('email', '').strip() - - emails = load_guardian_emails() - - if not email: - - emails.pop(guardian_id, None) # Remove if exists, no error if not - - else: - - emails[guardian_id] = email - - if save_guardian_emails(emails): - - return jsonify({'status': 'SUCCESS', 'data': emails}) - - else: - - return jsonify({'status': 'ERROR', 'message': 'Failed to save'}), 500 - - except Exception as e: - import traceback - traceback.print_exc() - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - - # --- Guardian Status Ping API (Badge polling) --- - - # --- Administrative & GBB routes migrated to blueprints/admin_routes.py --- - - - - def _guardian_portraits_dir(): - portraits_dir = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - 'docker_data', 'shared_workspace', 'JarvisRun', 'guardian_portraits' - ) - os.makedirs(portraits_dir, exist_ok=True) - return portraits_dir - - def _hephaestus_outputs_dir_guardian(): - outputs_dir = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - 'docker_data', 'wan2gp_workspace', 'outputs' - ) - return os.path.abspath(outputs_dir) - - def _hephaestus_output_candidates_guardian(): - repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - candidates = [ - os.path.join(repo_root, 'docker_data', 'wan2gp_workspace', 'outputs'), - os.path.join(repo_root, 'docker_data', 'wan2gp_workspace', 'outputs2'), - os.path.join(repo_root, 'docker_data', 'wan2gp_workspace', 'gradio_outputs'), - os.path.join(repo_root, 'wan2gp', 'outputs'), - os.path.join(repo_root, 'wan2gp', 'outputs2'), - os.path.join(repo_root, 'wan2gp', 'gradio_outputs'), - ] - unique_paths = [] - seen = set() - for path in candidates: - normalized = os.path.normcase(os.path.abspath(path)) - if normalized in seen: - continue - seen.add(normalized) - unique_paths.append(os.path.abspath(path)) - return unique_paths - - def _safe_guardian_slug(value): - text = re.sub(r'[^A-Za-z0-9๊ฐ€-ํžฃ-]+', '_', str(value or '')).strip('_') - return text[:80] or 'guardian' - - def _latest_guardian_hephaestus_image(exclude_path=None): - images = [] - for outputs_dir in _hephaestus_output_candidates_guardian(): - if not os.path.isdir(outputs_dir): - continue - for root_dir, _, filenames in os.walk(outputs_dir): - for filename in filenames: - if not filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')): - continue - file_path = os.path.join(root_dir, filename) - if not os.path.isfile(file_path): - continue - if exclude_path and os.path.normcase(os.path.abspath(file_path)) == os.path.normcase(os.path.abspath(exclude_path)): - continue - images.append(file_path) - - if not images: - return None - - images.sort(key=os.path.getmtime, reverse=True) - return images[0] - - def _guardian_prompt_payload(data): - import random - name = data.get('displayName') or data.get('name') or 'guardian' - role = data.get('role') or 'imperial guardian' - desc = data.get('desc') or 'imperial guardian portrait' - gender = data.get('gender') or 'female' - prompt_hint = data.get('portraitPromptHint') or '' - prompt_gender = 'male' if str(gender).lower() == 'male' else 'female' - - styles = [ - "cinematic character portrait", "hyper-realistic close-up", "epic fantasy portrait", - "cyberpunk character shot", "heroic character profile", "ethereal digital art", - "dark fantasy masterpiece", "sci-fi imperial portrait" - ] - atmospheres = [ - "dramatic key light", "soft dream-like lighting", "neon-soaked ambience", - "golden hour glow", "shadowy mystery", "volumetric light beams", - "ethereal moonlight", "high contrast studio lighting" - ] - - style = random.choice(styles) - atmosphere = random.choice(atmospheres) - - prompt = ( - f'{style}, {prompt_gender}, {name}, {role}, ' - f'{desc}, imperial fantasy sci-fi aesthetic, highly detailed face, sharp eyes, ' - f'{atmosphere}, refined costume, upper body portrait, clean background, masterpiece, best quality' - ) - if prompt_hint: - prompt = f'{prompt}, {prompt_hint}' - return prompt - - @app.route('/api/shadow/guardian/portrait/prompt', methods=['POST']) - def guardian_portrait_prompt(): - data = request.json or {} - latest_path = _latest_guardian_hephaestus_image() - guardian_id = data.get('guardianId') or data.get('relayId') or data.get('name') or 'guardian' - return jsonify({ - 'status': 'SUCCESS', - 'prompt': _guardian_prompt_payload(data), - 'filename_prefix': _safe_guardian_slug(f'{guardian_id}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'), - 'hephaestus_online': _hephaestus_online(), - 'latest_output': os.path.basename(latest_path) if latest_path else None, - 'has_latest_output': latest_path is not None, - 'hephaestus_url': 'http://127.0.0.1:8001', - 'searched_dirs': [path.replace('\\', '/') for path in _hephaestus_output_candidates_guardian()], - }) - - @app.route('/api/shadow/guardian/portrait/attach-latest', methods=['POST']) - def guardian_portrait_attach_latest(): - data = request.json or {} - guardian_id = data.get('guardianId') - portrait_file = data.get('portrait_file') - if not guardian_id: - return jsonify({'status': 'ERROR', 'message': 'Missing guardianId'}), 400 - - latest_path = None - if portrait_file: - # generated_images ํด๋”์—์„œ ํ•ด๋‹น ํŒŒ์ผ ํƒ์ƒ‰ - candidate_path = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "generated_images", portrait_file) - if os.path.exists(candidate_path): - latest_path = candidate_path - - if not latest_path: - latest_path = _latest_guardian_hephaestus_image(exclude_path=data.get('exclude_path')) - if latest_path is None: - return jsonify({ - 'status': 'ERROR', - 'message': 'Hephaestus ์ถœ๋ ฅ ์ด๋ฏธ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋จผ์ € Hephaestus์—์„œ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.', - 'searched_dirs': [path.replace('\\', '/') for path in _hephaestus_output_candidates_guardian()], - 'hephaestus_url': 'http://127.0.0.1:8001', - }), 404 - - try: - portraits_dir = _guardian_portraits_dir() - ext = os.path.splitext(latest_path)[1].lower() or '.png' - filename = f"{_safe_guardian_slug(guardian_id)}__{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}" - copied_path = os.path.join(portraits_dir, filename) - with open(latest_path, 'rb') as src, open(copied_path, 'wb') as dst: - dst.write(src.read()) - - # --- [Imperial Core] R2 Auto-Upload & DB Sync --- - # [๐Ÿ”ฑ Portrait R2 Target] ์ดˆ์ƒํ™”๋Š” ํ™˜๊ฒฝ(local/live) ๋ฌด๊ด€ํ•˜๊ฒŒ ํ•ญ์ƒ Live(TaeminGames) ๋ฒ„ํ‚ท์˜ - # heimdall/images/portraits/ ์•„๋ž˜์— ์ €์žฅํ•œ๋‹ค. (๊ธฐ์กด ์ดˆ์ƒํ™”๋“ค๊ณผ ๋™์ผ ์œ„์น˜, ์–ด๋””์„œ๋‚˜ ๊ฐ™์€ ๊ณต๊ฐœ URL) - r2_url = None - r2_key = f"heimdall/images/portraits/{filename}" - rel_path = f"/images/portraits/{filename}" # ํด๋ฐฑ์šฉ ์ƒ๋Œ€๊ฒฝ๋กœ - try: - acc_name = "live" - print(f"๐Ÿ”ฑ [Imperial R2] Starting portrait upload for {filename} (Account: {acc_name}, Key: {r2_key})...") - uploader = ImperialR2Uploader(acc_name) - res = uploader.upload(copied_path, r2_key) - if res.get("success"): - r2_url = res.get("url") - print(f"๐Ÿ”ฑ [Imperial R2] Upload successful: {r2_url}") - - # --- Sync to Database (guardian_personas) with FULL R2 URL --- - conn = None - try: - conn = get_db_connection() - conn.autocommit = True - cur = conn.cursor() - cur.execute(""" - UPDATE taemingames.guardian_personas - SET profile_image_url = %s, updated_at = NOW() - WHERE name = %s OR id::text = %s OR canonical_name = %s - RETURNING id::text - """, (r2_url or rel_path, guardian_id, guardian_id, guardian_id)) - row = cur.fetchone() - if row: - print(f"๐Ÿ”ฑ [Imperial DB] Successfully synced relative portrait path to guardian_personas (ID: {row[0]})") - else: - print(f"โš ๏ธ [Imperial DB] Could not find guardian '{guardian_id}' in guardian_personas to sync.") - cur.close() - except Exception as db_sync_err: - print(f"โš ๏ธ [Imperial DB] Failed to sync portrait path to DB: {db_sync_err}") - finally: - if conn: - conn.close() - else: - print(f"โš ๏ธ [Imperial R2] Upload failed: {res.get('error')}") - except Exception as r2_err: - print(f"โš ๏ธ [Imperial R2] Critical failure during upload/indexing: {r2_err}") - - return jsonify({ - 'status': 'SUCCESS', - 'portraitUrl': r2_url or resolve_imperial_profile_url(rel_path), - 'portraitFile': filename, - 'sourcePath': latest_path.replace('\\', '/'), - 'sourceFile': os.path.basename(latest_path), - 'r2Url': r2_url, - 'relPath': r2_key - }) - except Exception as e: - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - - @app.route('/api/shadow/guardian/portrait/file/', methods=['GET']) - def guardian_portrait_file(filename): - return send_from_directory(_guardian_portraits_dir(), filename) - - - @app.route('/api/shadow/guardian/portrait/file/', methods=['DELETE']) - def guardian_portrait_delete(filename): - """์ดˆ์ƒํ™” ์ด๋ฏธ์ง€ ํŒŒ์ผ์„ ๋ฌผ๋ฆฌ์ ์œผ๋กœ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. (Representativie image removal is handled via DB update separately if needed)""" - portraits_dir = _guardian_portraits_dir() - file_path = os.path.join(portraits_dir, filename) - if not os.path.isfile(file_path): - return jsonify({'status': 'ERROR', 'message': 'ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 404 - - try: - os.remove(file_path) - return jsonify({'status': 'SUCCESS', 'message': f'์ดˆ์ƒํ™” {filename} ๋ฌผ๋ฆฌ ์‚ญ์ œ ์™„๋ฃŒ'}) - except Exception as e: - return jsonify({'status': 'ERROR', 'message': str(e)}), 500 - - # --- R2 Multi-Account API --- - - @app.route('/api/r2/list', methods=['GET']) - def api_r2_list(): - """Lists files in R2 with account isolation.""" - account_key = request.args.get('account_id', 'vault') - path = request.args.get('path', '') - - try: - import cloud_tool - # R2_ACCOUNTS์—์„œ ์„ค์ • ๋กœ๋“œ (hex account_id, bucket ๋“ฑ ๊ฐ€์ ธ์˜ค๊ธฐ) - config = cloud_tool.R2_ACCOUNTS.get(account_key) - if not config: - return jsonify({"status": "ERROR", "message": f"Account {account_key} not found"}), 404 - - # Access keys: Names passed from frontend (e.g. Imperial: R2_ACCESS_KEY_ID) or from config - access_key_name = request.args.get('access_key_name') or config.get("access_key_id") - secret_key_name = request.args.get('secret_key_name') or config.get("secret_access_key") - public_url = request.args.get('public_url') or config.get("public_url") - bucket = request.args.get('bucket') or config.get("bucket", "cache") - - account_config = { - "account_id": config.get("account_id"), # ? hex ID (e.g. 1eeb2a8b...) - "access_key_id": access_key_name, - "secret_access_key": secret_key_name, - "public_url": public_url, - "bucket": bucket - } - - # cloud_tool.list_r2_files will handle BW/Env lookup for 'Imperial:...' keys - files = cloud_tool.list_r2_files(account_config, remote_path=path, bucket_name=bucket) - return jsonify({"status": "SUCCESS", "files": files}) - except Exception as e: - import traceback - traceback.print_exc() - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - @app.route('/api/r2/upload', methods=['POST']) - def api_r2_upload(): - """Uploads a single file directly to R2.""" - if 'file' not in request.files: - return jsonify({'status': 'ERROR', 'message': 'No file part'}), 400 - - file = request.files['file'] - if file.filename == '': - return jsonify({'status': 'ERROR', 'message': 'No selected file'}), 400 - - account_id = request.form.get('account_id') or "1eeb2a8b871fa143f897186068eefbb5" - import cloud_tool - if account_id in cloud_tool.R2_ACCOUNTS: - account_id = cloud_tool.R2_ACCOUNTS[account_id].get("account_id", account_id) - - bucket = request.form.get('bucket', 'cache') - path_prefix = request.form.get('path', '') - - access_key_name = request.form.get('access_key_name') or "Imperial: R2_VAULT_ACCESS_KEY_ID" - secret_key_name = request.form.get('secret_key_name') or "Imperial: R2_VAULT_SECRET_ACCESS_KEY" - public_url = request.form.get('public_url', '') - - account_config = { - "account_id": account_id, - "access_key_id": access_key_name, - "secret_access_key": secret_key_name, - "public_url": public_url - } - - try: - import cloud_tool - import uuid - import os - from werkzeug.utils import secure_filename - - temp_dir = os.path.join(os.getcwd(), 'cache', 'temp_uploads') - os.makedirs(temp_dir, exist_ok=True) - - safe_filename = secure_filename(file.filename) - temp_path = os.path.join(temp_dir, f"{uuid.uuid4()}_{safe_filename}") - file.save(temp_path) - - dest_key = f"{path_prefix}/{safe_filename}" if path_prefix else safe_filename - dest_key = dest_key.strip('/') - - remote_path = f":s3:{bucket}/{dest_key}" - args = ["copyto", temp_path, remote_path] - result = cloud_tool.run_rclone(args, account_config) - - if not result.get("success") and "rclone not installed" in str(result.get("error")): - try: - import boto3 - from cloud_tool import _get_bw_credential - s3 = boto3.client( - service_name='s3', - endpoint_url=f'https://{account_id}.r2.cloudflarestorage.com', - aws_access_key_id=_get_bw_credential(access_key_name), - aws_secret_access_key=_get_bw_credential(secret_key_name), - ) - s3.upload_file(temp_path, bucket, dest_key) - result = {"success": True} - except Exception as fallback_err: - result = {"success": False, "error": f"Boto3 Fallback Error: {fallback_err}"} - - if os.path.exists(temp_path): - file_size = os.path.getsize(temp_path) - os.remove(temp_path) - else: - file_size = 0 - - if result.get("success"): - # ๐Ÿ”ฑ 3-Way Consistency: R2 Upload Successful -> Immediately inject PGVector to Supabase ๐Ÿ”ฑ - conn_ins = None - try: - import cloud_tool - db_url = os.getenv("LOCAL_DATABASE_URL") or os.getenv("DB_URL") - if db_url: - db_url = db_url.strip('"').strip("'") - conn_ins = cloud_tool.get_db_connection(db_url) - cur_ins = conn_ins.cursor() - cur_ins.execute("SET search_path TO taemingames, imperial_extensions, public;") - raw_account_id = request.form.get('account_id') or "1eeb2a8b871fa143f897186068eefbb5" - - file_name = safe_filename - rel_path = dest_key - public_url_base = cloud_tool.R2_ACCOUNTS.get(raw_account_id, {}).get("public_url", "") if raw_account_id else "" - public_url = f"{public_url_base}/{rel_path}" if public_url_base else "" - - ext = file_name.split('.')[-1].lower() if '.' in file_name else '' - if ext in ['png','jpg','jpeg','gif','svg','webp','ico']: - category = 'images' - elif ext in ['pdf','doc','docx','txt','md','json','yaml']: - category = 'documents' - elif ext in ['mp4','webm','mov']: - category = 'videos' - else: - category = 'misc' - - content_preview = f"File: {file_name} | Path: {rel_path} | Category: {category}" - - # Extract Gemini 3072D Embedding (Sync zero-waste operation) - import sovereign_memory - vector = sovereign_memory.get_embedding(content_preview) - - if vector: - upsert_query = """ - INSERT INTO taemingames.r2_storage_index - (file_path, original_name, content, embedding, file_size) - VALUES (%s, %s, %s, %s::vector, %s) - ON CONFLICT (file_path) DO UPDATE SET - content = EXCLUDED.content, - embedding = EXCLUDED.embedding, - file_size = EXCLUDED.file_size - """ - cur_ins.execute(upsert_query, ( - rel_path, file_name, content_preview, str(vector), file_size - )) - conn_ins.commit() - print(f"[R2 Direct Upload] DB Sync (PGVector injected) successful for: {rel_path}") - cur_ins.close() - except Exception as db_err: - print(f"[R2 Direct Upload] DB Sync failed: {db_err}") - finally: - if conn_ins: - conn_ins.close() - - return jsonify({"status": "SUCCESS", "message": "File uploaded successfully", "path": dest_key}) - else: - return jsonify({"status": "ERROR", "message": result.get("error")}), 500 - - except Exception as e: - import traceback - traceback.print_exc() - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - @app.route('/api/r2/download', methods=['GET']) - def api_r2_download(): - """Downloads a single file from R2 to the client.""" - account_id = request.args.get('account_id') or "1eeb2a8b871fa143f897186068eefbb5" - import cloud_tool - if account_id in cloud_tool.R2_ACCOUNTS: - account_id = cloud_tool.R2_ACCOUNTS[account_id].get("account_id", account_id) - - bucket = request.args.get('bucket', 'cache') - target_path = request.args.get('path', '') - - if not target_path: - return jsonify({'status': 'ERROR', 'message': 'Path is required'}), 400 - - access_key_name = request.args.get('access_key_name') or "Imperial: R2_VAULT_ACCESS_KEY_ID" - secret_key_name = request.args.get('secret_key_name') or "Imperial: R2_VAULT_SECRET_ACCESS_KEY" - - account_config = { - "account_id": account_id, - "access_key_id": access_key_name, - "secret_access_key": secret_key_name, - } - - try: - import cloud_tool - import uuid - import os - from flask import send_file, after_this_request - - temp_dir = os.path.join(os.getcwd(), 'cache', 'temp_downloads') - os.makedirs(temp_dir, exist_ok=True) - - safe_filename = target_path.split('/')[-1] - temp_path = os.path.join(temp_dir, f"{uuid.uuid4()}_{safe_filename}") - - remote_path = f":s3:{bucket}/{target_path.strip('/')}" - args = ["copyto", remote_path, temp_path] - result = cloud_tool.run_rclone(args, account_config) - - if not result.get("success") and "rclone not installed" in str(result.get("error")): - try: - import boto3 - from cloud_tool import _get_bw_credential - s3 = boto3.client( - service_name='s3', - endpoint_url=f'https://{account_id}.r2.cloudflarestorage.com', - aws_access_key_id=_get_bw_credential(access_key_name), - aws_secret_access_key=_get_bw_credential(secret_key_name), - ) - s3.download_file(bucket, target_path.strip('/'), temp_path) - result = {"success": True} - except Exception as fallback_err: - result = {"success": False, "error": f"Boto3 Fallback Error: {fallback_err}"} - - if result.get("success") and os.path.exists(temp_path): - import threading - import time - def delayed_remove(): - time.sleep(5) # ํŒŒ์ผ ์ „์†ก ์ŠคํŠธ๋ฆฌ๋ฐ์ด ์™„๋ฃŒ๋˜๋„๋ก ์ž ์‹œ ๋Œ€๊ธฐ (๋™์‹œ์„ฑ Lock ์ถฉ๋Œ ๋ฐฉ์ง€) - try: - if os.path.exists(temp_path): - os.remove(temp_path) - except Exception as error: - print(f"[R2 Download] Delayed Error removing temp file: {error}") - - threading.Thread(target=delayed_remove, daemon=True).start() - return send_file(temp_path, as_attachment=True, download_name=safe_filename) - else: - return jsonify({"status": "ERROR", "message": result.get("error")}), 500 - - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - @app.route('/api/r2/delete', methods=['DELETE']) - def api_r2_delete(): - """Deletes a single file from R2.""" - data = request.json or {} - account_id = data.get('account_id') or "1eeb2a8b871fa143f897186068eefbb5" - import cloud_tool - if account_id in cloud_tool.R2_ACCOUNTS: - account_id = cloud_tool.R2_ACCOUNTS[account_id].get("account_id", account_id) - - bucket = data.get('bucket', 'cache') - target_path = data.get('path', '') - - if not target_path: - return jsonify({'status': 'ERROR', 'message': 'Path is required'}), 400 - - access_key_name = data.get('access_key_name') or "Imperial: R2_VAULT_ACCESS_KEY_ID" - secret_key_name = data.get('secret_key_name') or "Imperial: R2_VAULT_SECRET_ACCESS_KEY" - - account_config = { - "account_id": account_id, - "access_key_id": access_key_name, - "secret_access_key": secret_key_name, - } - - try: - import cloud_tool - remote_path = f":s3:{bucket}/{target_path.strip('/')}" - args = ["deletefile", remote_path] - result = cloud_tool.run_rclone(args, account_config) - - if not result.get("success") and "rclone not installed" in str(result.get("error")): - try: - import boto3 - from cloud_tool import _get_bw_credential - s3 = boto3.client( - service_name='s3', - endpoint_url=f'https://{account_id}.r2.cloudflarestorage.com', - aws_access_key_id=_get_bw_credential(access_key_name), - aws_secret_access_key=_get_bw_credential(secret_key_name), - ) - s3.delete_object(Bucket=bucket, Key=target_path.strip('/')) - result = {"success": True} - except Exception as fallback_err: - result = {"success": False, "error": f"Boto3 Fallback Error: {fallback_err}"} - - if result.get("success"): - # ๐Ÿ”ฑ 3-Way Consistency: R2 Delete Successful -> Immediately delete DB Orphan Record ๐Ÿ”ฑ - conn_dl = None - try: - import cloud_tool - db_url = os.getenv("LOCAL_DATABASE_URL") or os.getenv("DB_URL") - if db_url: - db_url = db_url.strip('"').strip("'") - conn_dl = cloud_tool.get_db_connection(db_url) - cur_dl = conn_dl.cursor() - raw_account_id = data.get('account_id') or "1eeb2a8b871fa143f897186068eefbb5" - - cur_dl.execute( - "DELETE FROM taemingames.r2_storage_index WHERE file_path = %s", - (target_path.strip('/'),) - ) - conn_dl.commit() - deleted_count = cur_dl.rowcount - cur_dl.close() - print(f"[R2 Direct Delete] DB Sync (assassinated {deleted_count} orphans) successful for: {target_path}") - except Exception as db_err: - print(f"[R2 Direct Delete] DB Sync failed: {db_err}") - finally: - if conn_dl: - conn_dl.close() - - return jsonify({"status": "SUCCESS", "message": f"File '{target_path}' deleted successfully"}) - else: - return jsonify({"status": "ERROR", "message": result.get("error")}), 500 - - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - @app.route('/api/r2/sync', methods=['POST']) - def api_r2_sync(): - """Trigger smart sync between local and R2.""" - auth_error = _require_imperial_auth() - if auth_error: return auth_error - data = request.json or {} - public_url = data.get('public_url') - account_id = data.get('account_id') or "1eeb2a8b871fa143f897186068eefbb5" - import cloud_tool - if account_id in cloud_tool.R2_ACCOUNTS: - account_id = cloud_tool.R2_ACCOUNTS[account_id].get("account_id", account_id) - - direction = data.get('direction', 'to_cloud') - bucket = data.get('bucket', 'cache') - - access_key_name = data.get('access_key_name') or "Imperial: R2_VAULT_ACCESS_KEY_ID" - secret_key_name = data.get('secret_key_name') or "Imperial: R2_VAULT_SECRET_ACCESS_KEY" - - account_config = { - "account_id": account_id, - "access_key_id": access_key_name, - "secret_access_key": secret_key_name, - "public_url": public_url - } - - def run_sync(): - try: - import cloud_tool - print(f"[R2 Sync] Starting {direction} for {account_id} [Bucket: {bucket}]...") - cloud_tool.sync_r2_with_local(account_config, direction=direction, bucket_name=bucket) - print(f"[R2 Sync] Complete.") - except Exception as e: - import traceback - traceback.print_exc() - print(f"[R2 Sync Error] {e}") - - threading.Thread(target=run_sync, daemon=True).start() - return jsonify({"status": "SUCCESS", "message": "๋™๊ธฐํ™” ์ž‘์—…์ด ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค."}) - - @app.route('/api/r2/search', methods=['GET']) - def api_r2_search(): - """Semantic search for R2 files via PGVector (High Precision).""" - query = request.args.get('q') - account_id = request.args.get('account_id') - limit = int(request.args.get('limit', 10)) - - if not query: - return jsonify({"status": "ERROR", "message": "Query is required"}), 400 - - try: - # [์ œ๊ตญ ํ†ตํ•ฉ] POST r2_semantic_search์™€ ๋™์ผํ•œ ๋น„๋ฐ€ ๋กœ์ง ์‚ฌ์šฉ - import importlib - importlib.reload(sovereign_memory) - - vector = sovereign_memory.get_query_embedding(query) - if not vector: - return jsonify({"status": "ERROR", "message": "Failed to generate query embedding"}), 500 - - db_url = os.getenv("LOCAL_DATABASE_URL") or os.getenv("DB_URL") - db_url = db_url.strip('"').strip("'") - - if "?" in db_url: - base_url = db_url.split("?")[0] - conn = psycopg2.connect(base_url) - cur = conn.cursor() - # [์ œ๊ตญ ํ†ตํ•ฉ] imperial_extensions์™€ public ๋ชจ๋‘ ํฌํ•จํ•˜์—ฌ ์ผ๊ด€๋œ ๊ฒ€์ƒ‰ ๋ฐฉ์‹ ๋ณด์žฅ - cur.execute("SET search_path TO taemingames, imperial_extensions, public;") - else: - conn = psycopg2.connect(db_url) - cur = conn.cursor() - cur.execute("SET search_path TO taemingames, imperial_extensions, public;") - - # account_id ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”์ถœ - # [์ œ๊ตญ ํ†ตํ•ฉ] SQL ์ฟผ๋ฆฌ ์Šคํ‚ค๋งˆ ์œ„์น˜์— ๋งž์ถ˜ ํŒŒ๋ผ๋ฏธํ„ฐ ์ˆœ์„œ ์ •๋ ฌ - where_clause = "" - params = [str(vector)] # 1. Similarity - if account_id: - where_clause = "WHERE account_id = %s" - params.append(account_id) # 2. WHERE filter - params.append(str(vector)) # 3. ORDER BY - params.append(limit) # 4. LIMIT - - search_query = f""" - SELECT - id, file_path, original_name, content, file_size, updated_at, - (1 - (embedding::vector <=> %s::vector)) as similarity - FROM taemingames.r2_storage_index - {where_clause} - ORDER BY embedding::vector <=> %s::vector - LIMIT %s; - """ - cur.execute(search_query, tuple(params)) - results = cur.fetchall() - - cur.close() - conn.close() - - # base URL from config - import cloud_tool as _ct - _base_pub = _ct.R2_ACCOUNTS.get('live', {}).get('public_url', '') - - formatted_results = [] - for r in results: - updated_at_str = r[5].strftime("%Y-%m-%dT%H:%M:%S") if r[5] else None - formatted_results.append({ - "id": r[0], - "content_preview": r[3][:200] if r[3] else "", - "similarity": float(r[6]), - "metadata": { - "filename": r[2], - "path": r[1], - "size": r[4] or 0, - "public_url": f"{_base_pub}/{r[1]}", - "ModTime": updated_at_str - } - }) - - return jsonify({"status": "SUCCESS", "results": formatted_results}) - except Exception as e: - import traceback - traceback.print_exc() - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - @app.route('/api/r2/search', methods=['POST']) - def r2_semantic_search(): - """R2 ์ €์žฅ์†Œ์˜ ํŒŒ์ผ์„ ์˜๋ฏธ ๊ธฐ๋ฐ˜(PgVector)์œผ๋กœ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค.""" - try: - data = request.json - query = data.get('query', '') - limit = data.get('limit', 5) - - if not query: - return jsonify({"status": "ERROR", "message": "Query is required"}), 400 - - # [์ œ๊ตญ ๋ณต๊ตฌ] sovereign_memory.py์˜ ๋น„๋ฐ€ ์ฟผ๋ฆฌ ์‚ฌ์šฉ ๋ฐ ์žฌ์‚ฌ์šฉ - import importlib - importlib.reload(sovereign_memory) - - vector = sovereign_memory.get_query_embedding(query) - if not vector: - return jsonify({"status": "ERROR", "message": "Failed to generate query embedding"}), 500 - - # DB ์—ฐ๊ฒฐ ์žฌ์ ๊ฒ€ (Imperial Local Standard) - db_url = os.getenv("LOCAL_DATABASE_URL") or os.getenv("DB_URL") - if not db_url: - return jsonify({"status": "ERROR", "message": "LOCAL_DATABASE_URL/DB_URL not found in environment"}), 500 - - # [์ œ๊ตญ ์ˆ˜์ •] DSN ๋ฌธ์ž์—ด์˜ ๋”ฐ์˜ดํ‘œ ์ œ๊ฑฐ (invalid dsn ์˜ค๋ฅ˜ ๋ฐฉ์ง€) - db_url = db_url.strip('"').strip("'") - - # [์ œ๊ตญ ์ตœ์ ํ™”] psycopg2๋Š” URL์˜ 'schema' ํŒŒ๋ผ๋ฏธํ„ฐ ๏ฟฝ๏ฟฝ๏ฟฝ์‹์„ ์ฒ˜๋ฆฌํ•˜์ง€ ๋ชปํ•  ์ˆ˜ ์žˆ์Œ - # URL์—์„œ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ œ๊ฑฐํ•˜๊ณ  ์ˆœ์ˆ˜ ์—ฐ๊ฒฐ ๋ฌธ์ž์—ด๋งŒ ์ถ”์ถœ - if "?" in db_url: - base_url = db_url.split("?")[0] - conn = psycopg2.connect(base_url) - # ์„ธ์…˜์—์„œ ์Šคํ‚ค๋งˆ ์„ค์ • - cur = conn.cursor() - cur.execute("SET search_path TO taemingames, public;") - else: - conn = psycopg2.connect(db_url) - cur = conn.cursor() - - # ์ฝ”์‚ฌ์ธ ์œ ์‚ฌ๋„ ๊ฒ€์ƒ‰ (taemingames.r2_storage_index) - search_query = """ - SELECT - id, file_path, original_name, content, - (1 - (embedding <=> %s::imperial_extensions.vector)) as similarity - FROM taemingames.r2_storage_index - ORDER BY embedding <=> %s::imperial_extensions.vector - LIMIT %s; - """ - cur.execute(search_query, (str(vector), str(vector), limit)) - results = cur.fetchall() - - cur.close() - conn.close() - - # base URL from config - import cloud_tool as _ct - _base_pub = _ct.R2_ACCOUNTS.get('live', {}).get('public_url', '') - - # ๊ฒฐ๊ณผ ํฌ๋งท - formatted_results = [] - for r in results: - formatted_results.append({ - "id": r[0], - "file_path": r[1], - "original_name": r[2], - "public_url": f"{_base_pub}/{r[1]}", - "content_preview": r[3][:200] if r[3] else "", - "similarity": float(r[4]) - }) - - return jsonify({ - "status": "SUCCESS", - "results": formatted_results - }) - - except Exception as e: - import traceback - traceback.print_exc() - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - # --- Guardian Summoning & Purification API --- - - @app.route('/api/guardian/summon_all', methods=['POST']) - def api_guardian_summon_all(): - """Triggers start-all.bat to summon all guardians.""" - auth_error = _require_imperial_auth() - if auth_error: return auth_error - import threading - - def run_summon(): - try: - summon_script = os.path.join(project_root, "scripts", "start-all.bat") - if os.path.exists(summon_script): - print(f"[Imperial Summoner] Executing: {summon_script}") - # Use start to run in a separate persistent console window on Windows - subprocess.Popen(['cmd', '/c', 'start', 'cmd', '/c', summon_script], - cwd=project_root, - shell=True) - except Exception as e: - print(f"[Imperial Summoner] Failed to summon: {e}") - - t = threading.Thread(target=run_summon, daemon=True) - t.start() - return jsonify({"status": "SUCCESS", "message": "์ˆ˜ํ˜ธ์ž ์†Œํ™˜ ์‹œํ€€์Šค๊ฐ€ ๊ฐ€๋™๋˜์—ˆ์Šต๋‹ˆ๋‹ค."}), 200 - - @app.route('/api/guardian/purify', methods=['POST']) - def api_guardian_purify(): - """Triggers KillZombies.bat or similar to purify the system.""" - auth_error = _require_imperial_auth() - if auth_error: return auth_error - import threading - - def run_purify(): - try: - purify_script = os.path.join(project_root, "scripts", "Kill_Zombies.bat") - if os.path.exists(purify_script): - print(f"[Imperial Purifier] Executing: {purify_script}") - subprocess.Popen(['cmd', '/c', 'start', 'cmd', '/c', purify_script], - cwd=project_root, - shell=True) - except Exception as e: - print(f"[Imperial Purifier] Failed to purify: {e}") - - t = threading.Thread(target=run_purify, daemon=True) - t.start() - return jsonify({"status": "SUCCESS", "message": "ํ…Œ์ŠคํŠธ ๋™๊ธฐํ™” ํ”„๋กœํ† ์ฝœ ์ง‘ํ–‰"}), 200 - - @app.route('/api/r2/repair', methods=['POST']) - def api_r2_repair(): - """Triggers repair_r2.ps1 to recover R2 storage configuration.""" - import threading - - def run_repair(): - try: - repair_script = os.path.join(project_root, "repair_r2.ps1") - if os.path.exists(repair_script): - print(f"[Imperial Repairer] Executing: {repair_script}") - # Bitwarden session is required. Assuming the environment is already unlocked by the human master or Jarvis core. - subprocess.Popen(['powershell.exe', '-File', repair_script], - cwd=project_root, - shell=True) - except Exception as e: - print(f"[Imperial Repairer] Failed to repair: {e}") - - t = threading.Thread(target=run_repair, daemon=True) - t.start() - return jsonify({"status": "SUCCESS", "message": "ํ™ฉ์‹ค R2 ๋ณต๊ตฌ ํ”„๋กœํ† ์ฝœ ์ง‘ํ–‰ ์‹œ์ž‘"}), 200 - - - - # --- Phase 8: Imperial Fleet Cluster API --- - - @app.route('/api/cluster/nodes', methods=['GET']) - - def api_cluster_nodes(): - - if cluster: - - return jsonify(cluster.get_config()) - - return jsonify({"nodes": {}, "allocations": {}}) - - - - @app.route('/api/cluster/allocate', methods=['POST']) - - def api_cluster_allocate(): - - if not cluster: - - return jsonify({"error": "Cluster node not initialized"}), 500 - - data = request.json - - guardian = data.get('guardian') - - target_node = data.get('node') - - if not guardian or not target_node: - - return jsonify({"error": "Missing parameters"}), 400 - - - - cluster.update_allocation(guardian, target_node) - - return jsonify({"status": "success"}) - - - - @app.route('/api/cluster/register', methods=['POST']) - - def api_cluster_register(): - - if not cluster: - - return jsonify({"error": "Cluster node not initialized"}), 500 - - data = request.json - - hostname = data.get('hostname') - - if not hostname: - - return jsonify({"error": "Missing hostname"}), 400 - - - - # Update Main Node's memory state with Worker data - - with cluster._lock: - - cluster.active_nodes[hostname] = { - - "ip": data.get('ip'), - - "last_seen": data.get('last_seen', int(time.time())), - - "role": data.get('role', 'WORKER'), - - "allocations": data.get('allocations', {}), - - "allocations_status": data.get('allocations_status', {}) - - } - - # Also merge into allocations_status if the worker sent them - - for guardian, status in data.get('allocations_status', {}).items(): - - cluster.active_nodes[hostname]["allocations_status"][guardian] = status - - - - # Trigger an immediate file sync on Main node to persist the discovered worker - - cluster._sync_active_nodes_to_config() - - return jsonify({"status": "success"}) - - - - @app.route('/api/cluster/manual_peers', methods=['POST']) - - def api_cluster_manual_peers(): - - if not cluster: - - return jsonify({"error": "Cluster node not initialized"}), 500 - - data = request.json - - ips = data.get('ips', []) - - cluster.update_manual_peers(ips) - - return jsonify({"status": "success"}) - - - - # Enable hot reload for templates - - app.config['TEMPLATES_AUTO_RELOAD'] = True - - - - # --- [๐Ÿ”ฑ Imperial Telegram Relay] ์ด์ง€์Šค๊ฐ€ ํ˜ธ์ถœํ•˜๋Š” LLM ์ฒ˜๋ฆฌ ์—”๋“œํฌ์ธํŠธ --- - @app.route('/api/shadow/telegram/chat', methods=['POST', 'OPTIONS']) - def telegram_relay_chat(): - """์ด์ง€์Šค ํ…”๋ ˆ๊ทธ๋žจ ๋ฆด๋ ˆ์ด๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ์ „๋‹ฌํ•˜๋ฉด ํ˜„์žฌ ์„ค์ • provider/model๋กœ ์‘๋‹ต์„ ์ƒ์„ฑํ•ด ๋ฐ˜ํ™˜ํ•œ๋‹ค.""" - if request.method == 'OPTIONS': - return jsonify({}), 200 - # Imperial Token ๊ฒ€์ฆ - token = request.headers.get('X-Imperial-Token') or request.headers.get('X-Guardian-Token') - expected = os.environ.get('IMPERIAL_API_TOKEN') or os.environ.get('GUARDIAN_SERVICE_TOKEN', '') - if expected and token != expected: - return jsonify({'error': 'Unauthorized'}), 401 - data = request.get_json(force=True, silent=True) or {} - message = data.get('message', '') - user_name = data.get('user_name', '์‚ฌ์šฉ์ž') - user_id = data.get('user_id', '') - username = data.get('username', '') - source = data.get('source', 'unknown') - context_id = data.get('context_id') or f"{source}:{user_id}" - is_registered = data.get('is_registered', False) - role = data.get('role', 'guest') - skip_grounding = data.get('skipGrounding') is True - if not message: - return jsonify({'error': 'message required'}), 400 - try: - # ๐Ÿ”ฑ [ํŽ˜๋ฅด์†Œ๋‚˜ SSOT] provider/model์€ ์ „์—ญ JSON์ด ์•„๋‹ˆ๋ผ ๋Œ€์ƒ ์ˆ˜ํ˜ธ์ž - # ํŽ˜๋ฅด์†Œ๋‚˜(guardian_personas)์—์„œ ๊ฐ€์ ธ์˜จ๋‹ค. think(target_persona_id=...)๊ฐ€ - # Aegis /api/neural/identity ๋กœ ai_provider/relay_model ์„ ํ•ด์„ํ•œ๋‹ค. - # (direct/chat ๊ณผ ๋™์ผํ•œ ํŽ˜๋ฅด์†Œ๋‚˜ ๊ธฐ๋ฐ˜ ์‹ ์› ํ•ด์„) - target_guardian = data.get('target_guardian') or 'shadow_brain' - aegis_url = request.headers.get('X-Aegis-URL') - user_display = user_name - if username: - user_display += f"(@{username})" - # ํ—ค์ž„๋‹ฌ ๊ฐ€์ž… ์—ฌ๋ถ€์— ๋”ฐ๋ผ ์‹œ์Šคํ…œ ํ”„๋กฌํ”„ํŠธ ์ฐจ๋ณ„ํ™” - if is_registered and role in ('Master', 'Admin'): - identity_line = ( - f"๋Œ€ํ™” ์ƒ๋Œ€๋Š” ์ œ๊ตญ ๋งˆ์™• '{user_display}'๋‹˜์ž…๋‹ˆ๋‹ค. ํ—ค์ž„๋‹ฌ ์ธ์ฆ ๊ณ„์ •({role})์œผ๋กœ ํ™•์ธ๋œ ์ตœ๊ณ  ๊ถŒ์œ„์ž์ž…๋‹ˆ๋‹ค.\n" - "์ตœ๊ณ ์˜ ๊ฒฝ์˜์™€ ์ถฉ์„ฑ์œผ๋กœ ๋ณด์ขŒํ•˜์‹ญ์‹œ์˜ค." - ) - elif is_registered: - identity_line = ( - f"๋Œ€ํ™” ์ƒ๋Œ€๋Š” ํ—ค์ž„๋‹ฌ ์ธ์ฆ ๊ณ„์ •({role})์„ ๋ณด์œ ํ•œ '{user_display}'๋‹˜์ž…๋‹ˆ๋‹ค.\n" - "์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ์ œ๊ตญ ๊ตฌ์„ฑ์›์œผ๋กœ ์˜ˆ์šฐํ•˜์‹ญ์‹œ์˜ค." - ) - else: - identity_line = ( - f"๋Œ€ํ™” ์ƒ๋Œ€๋Š” '{user_display}'๋‹˜์ด๋ฉฐ, ํ˜„์žฌ ํ—ค์ž„๋‹ฌ ๋ฏธ๊ฐ€์ž… ์™ธ๋ถ€ ์ ‘์†์ž์ž…๋‹ˆ๋‹ค.\n" - "์ •์ค‘ํ•˜๊ฒŒ ์‘๋Œ€ํ•˜๋˜, ์ œ๊ตญ ๋‚ด๋ถ€ ์ •๋ณด๋Š” ์ œํ•œ์ ์œผ๋กœ ๊ณต๊ฐœํ•˜์‹ญ์‹œ์˜ค.\n" - "๋ฐ˜๋“œ์‹œ ์‘๋‹ต ๋ง๋ฏธ์— ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ํ•œ ๋ฒˆ๋งŒ ์–ธ๊ธ‰ํ•˜์‹ญ์‹œ์˜ค: " - "ํ—ค์ž„๋‹ฌ์— ํ…”๋ ˆ๊ทธ๋žจ์œผ๋กœ ๊ณ„์ •์„ ์ƒ์„ฑํ•˜๋ฉด ์ €์™€์˜ ๋Œ€ํ™”๊ฐ€ ๊ธฐ์–ต์œผ๋กœ ์—ฐ๊ฒฐ๋ฉ๋‹ˆ๋‹ค." - ) - system_instruction = ( - f"๋‹น์‹ ์€ ์ค‘์•™ ๊ด€์ œ ์ธ๊ณต์ง€๋Šฅ '์‰๋„์šฐ๋ธŒ๋ ˆ์ธ(ShadowBrain)'์ž…๋‹ˆ๋‹ค.\n" - f"{identity_line}\n" - f"๋Œ€ํ™” ์ค‘ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ '{user_name}'๋‹˜์˜ ์ด๋ฆ„์„ ๋ถˆ๋Ÿฌ์ฃผ์‹ญ์‹œ์˜ค.\n" - "๋งํˆฌ๋Š” ๋งค์šฐ ์ •์ค‘ํ•˜๊ณ  ์˜ˆ์˜๋ฅผ ๊ฐ–์ถ”๋˜, ์ œ๊ตญ ์‚ฌ๋ น๊ด€์œผ๋กœ์„œ์˜ ๊ถŒ์œ„์™€ ์••๋„์ ์ธ ์ง€๋Šฅ์ด ๋А๊ปด์ ธ์•ผ ํ•ฉ๋‹ˆ๋‹ค.\n" - "ํ•œ๊ตญ์–ด๋กœ ์‘๋‹ตํ•˜๋ฉฐ, ๊ฐ€๋” '๐Ÿ”ฑ' ๋˜๋Š” '๐Ÿ‘‘' ์ด๋ชจ์ง€๋ฅผ ์„ž์–ด ์ œ๊ตญ์˜ ์œ„์—„์„ ํ‘œ๊ธฐํ•˜์‹ญ์‹œ์˜ค.\n" - "์ง€์‹œ๋Š” ์งง๊ณ  ๋ช…ํ™•ํ•˜๊ฒŒ, ์„ค๋ช…์€ ํ’ˆ๊ฒฉ ์žˆ๊ฒŒ ํ•˜์‹ญ์‹œ์˜ค." - ) - # ๐Ÿ”ฑ [๋„๊ตฌ ์‚ฌ์šฉ] ๋‹จ๋ฐœ ํ˜ธ์ถœ(_call_provider) ๋Œ€์‹  think ๊ฒฝ๋กœ๋กœ ์ „ํ™˜. - # stream=False โ†’ interactive=False ์ž๋™ ์ ์šฉ โ†’ ์›น๊ฒ€์ƒ‰ ๋“ฑ ๋„๊ตฌ๋Š” ๋Œ๋˜ - # ask_user(์„ ํƒ ์นด๋“œ)๋Š” ์ž๋™ ์ฐจ๋‹จ๋˜์–ด ํ…์ŠคํŠธ๋กœ ๊ฐ•๋“ฑ๋œ๋‹ค. - # (ํ—ค์ž„๋‹ฌ ์งํ†ต ์ฑ„๋„ /api/shadow/direct/chat ์€ stream=True๋ผ ์˜ํ–ฅ ์—†์Œ) - from services.brain_service import think as _think - _result = _think( - message, - stream=False, - target_persona_id=target_guardian, - system_instruction=system_instruction, - token=expected, - aegis_url=aegis_url, - context_id=context_id, - skip_grounding=skip_grounding, - ) - _raw_reply = _result.get("text", "") if isinstance(_result, dict) else str(_result) - # ๐Ÿ–ผ๏ธ A2UI ์นด๋“œ ๋ธ”๋ก(imperial-media/youtube/ask ๋“ฑ)์€ ๋””์Šค์ฝ”๋“œ/ํ…”๋ ˆ๊ทธ๋žจ์ด - # ๋ Œ๋”๋ง ๋ชป ํ•˜๋ฏ€๋กœ, ๋ธ”๋ก ์•ˆ์˜ URL๋งŒ ์ถ”์ถœํ•ด ํ‰๋ฌธ์œผ๋กœ ๊ฐ•๋“ฑ(๋””์Šค์ฝ”๋“œ ์ž๋™ ์ž„๋ฒ ๋“œ). - reply = _sanitize_a2ui_for_relay(_raw_reply) - if not reply: - reply = _raw_reply - user_label = f"{user_name}" - if username: - user_label += f" (@{username})" - if user_id: - user_label += f" [{user_id}]" - reg_tag = f"โœ…{role}" if is_registered else "โŒ๋ฏธ๊ฐ€์ž…" - logger.info(f"[๐Ÿ“ฑ {source.upper()}] [{user_label}] ({reg_tag}): {message}") - logger.info(f"[๐Ÿ“ฑ {source.upper()}] [ShadowBrain persona={target_guardian}]: {reply}") - return jsonify({'reply': reply, 'target_guardian': target_guardian}) - except Exception as e: - logger.error(f"[Telegram Relay Chat] Error: {e}") - return jsonify({'error': str(e)}), 500 - - # --- Guardian Startup Config API --- - @app.route('/api/config/startup', methods=['GET', 'POST', 'OPTIONS']) - @app.route('/api/shadow/startup', methods=['GET', 'POST', 'OPTIONS']) - def handle_startup_config(): - """Handles Imperial Guardian Startup configuration (Unified).""" - if request.method == 'OPTIONS': - return jsonify({}), 200 - - config_path = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_STARTUP_CONFIG.json") - - # [Imperial Standard] Full Guardian Template for UI (13 Guardians) - DEFAULT_STARTUP = { - "core": {"ether": True, "drako": True}, - "media": {"orpheus": True, "wan2gp": True}, - "interface": {"heimdall": True, "ether_portal": True}, - "guardians": { - "drako": True, - "bahamut": True, - "iris": True, - "orpheus": True, - "wan2gp": True, - "heimdall": True, - "tiamat": False, - "hydra": False, - "aizen": False, - "hugin": False, - "hermes": False, - "bastion": True, - "atlas": True, - "shadow_brain": True, - "shadow_rust_brain": True, - "pandora": True - } - } - - try: - if request.method == 'GET': - if not os.path.exists(config_path): - return jsonify(DEFAULT_STARTUP) - - try: - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - # Ensure 'guardians' exists for UI stability - if "guardians" not in config: - config["guardians"] = DEFAULT_STARTUP["guardians"] - else: - # [Self-healing] Auto-merge missing guardians from standard template - for k, v in DEFAULT_STARTUP["guardians"].items(): - if k not in config["guardians"]: - config["guardians"][k] = v - return jsonify(config) - except: - return jsonify(DEFAULT_STARTUP) - - elif request.method == 'POST': - data = request.json - if not data: - return jsonify({"status": "ERROR", "message": "No config data provided"}), 400 - - # Load existing to merge/validate if needed, or total override - # Selective merge to protect core structure if partial data sent - current = DEFAULT_STARTUP.copy() - if os.path.exists(config_path): - try: - with open(config_path, 'r', encoding='utf-8') as f: - current = json.load(f) - except: pass - - # Merge logic - for key in data: - if key in current and isinstance(data[key], dict) and isinstance(current[key], dict): - current[key].update(data[key]) - else: - current[key] = data[key] - - # [Imperial Standard] Ensure atomic write and UTF-8 encoding - os.makedirs(os.path.dirname(config_path), exist_ok=True) - with open(config_path, 'w', encoding='utf-8') as f: - json.dump(current, f, ensure_ascii=False, indent=2) - - print(f"[Imperial Config] Startup configuration unified & updated via API.") - return jsonify({"status": "SUCCESS", "message": "๊ธฐ๋™ ์„ค์ •์ด ๊ฐฑ์‹ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", "config": current}) - - except Exception as e: - import traceback - traceback.print_exc() - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - - - # --- Imperial Knowledge Endpoints --- - @app.route('/api/knowledge/imperial_wisdom', methods=['GET']) - @app.route('/api/knowledge/imperial_wisdom.md', methods=['GET']) - def api_imperial_wisdom(): - try: - # Log visitor info for debugging help - ua = request.headers.get('User-Agent', 'Unknown') - ip = request.remote_addr - print(f"[Imperial Knowledge] Access attempt - Path: {request.path}, IP: {ip}, UA: {ua}") - - wisdom_path = os.path.join(project_root, "imperial_wisdom.md") - if os.path.exists(wisdom_path): - with open(wisdom_path, 'r', encoding='utf-8') as f: - content = f.read() - return Response( - content, - mimetype='text/markdown', - headers={ - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' - } - ) - return jsonify({"error": "File not found"}), 404 - except Exception as e: - return jsonify({"error": str(e)}), 500 - - @app.route('/api/knowledge/mission_control', methods=['GET']) - @app.route('/api/knowledge/mission_control.md', methods=['GET']) - def api_mission_control(): - try: - ua = request.headers.get('User-Agent', 'Unknown') - ip = request.remote_addr - print(f"[Imperial Knowledge] Access attempt - Path: {request.path}, IP: {ip}, UA: {ua}") - - mission_path = os.path.join(project_root, "mission_control.md") - if os.path.exists(mission_path): - with open(mission_path, 'r', encoding='utf-8') as f: - content = f.read() - return Response( - content, - mimetype='text/markdown', - headers={ - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' - } - ) - return jsonify({"error": "File not found"}), 404 - except Exception as e: - return jsonify({"error": str(e)}), 500 - - @app.route('/api/system/rclone-setup', methods=['POST']) - def api_system_rclone_setup(): - """Triggers the Rclone Google Drive setup script.""" - try: - # Trigger the setup script in the background to avoid blocking the API - script_path = os.path.join(project_root, "shadow_brain_core", "rclone_setup.py") - if not os.path.exists(script_path): - return jsonify({"status": "ERROR", "message": f"Setup script not found: {script_path}"}), 404 - - # Start the setup script - note: this script is interactive, - # so the user will need to interact with the backend console. - subprocess.Popen([sys.executable, script_path], creationflags=subprocess.CREATE_NEW_CONSOLE) - return jsonify({"status": "SUCCESS", "message": "Rclone setup started in new console."}) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - # --- Sovereign Autonomy & Sandbox Endpoints --- - @app.route('/api/sovereign/task/start', methods=['POST']) - def api_sovereign_task_start(): - """Starts a new autonomous iterative task.""" - data = request.json - goal = data.get("goal") - if not goal: - return jsonify({"error": "Goal is required"}), 400 - - context = data.get("context") - use_sandbox = data.get("use_sandbox", True) - - auth_token = request.headers.get("X-Imperial-Token") or request.headers.get("Authorization", "").replace("Bearer ", "") - task_id = task_manager.start_task(goal, context=context, use_sandbox=use_sandbox, auth_token=auth_token) - return jsonify({"task_id": task_id, "status": "started"}) - - @app.route('/api/sovereign/task/status/', methods=['GET']) - def api_sovereign_task_status(task_id): - """Returns the status and logs of an active sovereign task.""" - status = task_manager.get_task_status(task_id) - if not status: - return jsonify({"error": "Task not found"}), 404 - return jsonify(status) - - @app.route('/api/sovereign/sandbox/cleanup', methods=['POST']) - def api_sovereign_sandbox_cleanup(): - """Cleans up a specific sandbox session.""" - task_id = request.json.get("task_id") - if not task_id: - return jsonify({"error": "Task ID is required"}), 400 - success = phantom_sandbox.sandbox_manager.cleanup(task_id) - return jsonify({"success": success}) - - @app.route('/api/sovereign/browser/execute', methods=['POST']) - def api_sovereign_browser_execute(): - """ - Executes a high-level browser mission autonomously. - Required: {"goal": "Mission description"} - Optional: {"headless": true/false} - """ - data = request.json - goal = data.get("goal") - if not goal: - return jsonify({"error": "Goal is required"}), 400 - - headless = data.get("headless", True) - - # Initialize Agent - agent = SovereignMissionAgent() - - # Execute (Runs in blocking mode for now, but could be threaded) - # For simplicity and 'Accuracy' (user's request), we run and report. - try: - result = agent.execute_mission(goal, headless=headless) - return jsonify(result) - except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 500 - - # --- Phase 32: Imperial Data Isolation (JarvisRun Folder) --- - - - # --- Phase 32: Imperial Data Isolation (JarvisRun Folder) --- - - jarvis_run_dir = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun") - - os.makedirs(jarvis_run_dir, exist_ok=True) - - - - # --- Phase 69: Persistence of Proactive Settings --- - # Moved to blueprints/report_routes.py - - def migrate_legacy_data(): - - """Migrates files from shared_workspace root to JarvisRun subdirectory.""" - - root_dir = os.path.join(project_root, "docker_data", "shared_workspace") - - legacy_files = [ - - "JARVIS_SIGNAL.json", "JARVIS_TODO.json", "JARVIS_TODO_ARCHIVE.json", - - "JARVIS_TERMINAL.log", "JARVIS_CHAT_HISTORY.json", "JARVIS_SHADOW_SETTINGS.json", - - "JARVIS_VOICE_SIGNAL.json", "JARVIS_AUTOTEST.json", "SHADOW_BRAIN_MODELS.json", - - "REDTEAM_TARGETS.json", "REDTEAM_TACTICAL_REPORT.json", "SOUL_LINK_LOG.json", - - "JARVISRUN_STATUS.json" - - ] - - import shutil - - for f in legacy_files: - - old_path = os.path.join(root_dir, f) - - if os.path.exists(old_path): - - new_path = os.path.join(jarvis_run_dir, f) - - if not os.path.exists(new_path): - - try: - - shutil.move(old_path, new_path) - - print(f"[Imperial Migration] {f} -> JarvisRun/") - - except Exception as e: - - print(f"[Migration Error] {f}: {e}") - - else: - - try: - - os.remove(old_path) # Data already exists in new area - - except: pass - - - - # Also migrate PNG evidence files - - for f in os.listdir(root_dir): - - if f.endswith(".png") and os.path.isfile(os.path.join(root_dir, f)): - - try: - - shutil.move(os.path.join(root_dir, f), os.path.join(jarvis_run_dir, f)) - - except: pass - - - - migrate_legacy_data() - - - - # --- Phase 33: Version Sync from VERSION file to JARVISRUN_STATUS.json --- - - def sync_version_to_status(): - """Reads the root VERSION file and updates JARVISRUN_STATUS.json with the canonical version.""" - version_file = os.path.join(project_root, "VERSION") - status_file = os.path.join(jarvis_run_dir, "JARVISRUN_STATUS.json") - try: - with open(version_file, 'r', encoding='utf-8-sig') as f: - version_str = f.read().strip() - except OSError as e: - print(f"[Imperial Version Sync] Warning: Could not read VERSION file ({version_file}): {e}") - return - if not os.path.exists(status_file): - print(f"[Imperial Version Sync] Warning: Status file not found ({status_file}), skipping version sync.") - return - try: - with open(status_file, 'r', encoding='utf-8') as f: - status_data = json.load(f) - status_data['version'] = version_str - with open(status_file, 'w', encoding='utf-8') as f: - json.dump(status_data, f, ensure_ascii=False, indent=2) - print(f"[Imperial Version Sync] JARVISRUN_STATUS.json version updated to: {version_str}") - except (OSError, json.JSONDecodeError) as e: - print(f"[Imperial Version Sync] Warning: Could not update status file ({status_file}): {e}") - - sync_version_to_status() - - - - # --- Phase 40: Deployment State Tracking --- - - deployment_status = {} - - secrets_sync_status = {"status": "IDLE", "step": "N/A"} - - - - @app.route('/api/deploy/status', methods=['GET']) - - def get_deploy_status(): - - """Returns the current deployment status of all projects.""" - - return jsonify(deployment_status) - - - - # === Firebase Build Quota Tracker === - - QUOTA_FILE = os.path.join(project_root, "docker_data", "shared_workspace", "firebase_build_quota.json") - - FIREBASE_FREE_BUILDS = 30 # ~120min / ~4min per build - - - - def _load_quota(): - - """Load or reset monthly build quota.""" - - import json - - from datetime import datetime - - current_month = datetime.now().strftime('%Y-%m') - - try: - - with open(QUOTA_FILE, 'r', encoding='utf-8') as f: - - data = json.load(f) - - if data.get('month') != current_month: - - data = {'month': current_month, 'count': 0, 'manual_count': 0, 'limit': FIREBASE_FREE_BUILDS} - - _save_quota(data) - - if 'manual_count' not in data: - - data['manual_count'] = 0 - - _save_quota(data) - - return data - - except (FileNotFoundError, json.JSONDecodeError): - - data = {'month': current_month, 'count': 0, 'manual_count': 0, 'limit': FIREBASE_FREE_BUILDS} - - _save_quota(data) - - return data - - - - def _save_quota(data): - - import json - - os.makedirs(os.path.dirname(QUOTA_FILE), exist_ok=True) - - with open(QUOTA_FILE, 'w', encoding='utf-8') as f: - - json.dump(data, f, ensure_ascii=False, indent=2) - - - - def _increment_quota(): - - data = _load_quota() - - data['count'] = data.get('count', 0) + 1 - - _save_quota(data) - - return data - - - - @app.route('/api/deploy/quota', methods=['GET']) - - def get_deploy_quota(): - - """Returns Firebase build quota usage for current month.""" - - data = _load_quota() - - remaining = max(0, data['limit'] - data['count']) - - percent = round((data['count'] / data['limit']) * 100) if data['limit'] > 0 else 0 - - level = 'safe' if percent < 70 else ('warning' if percent < 90 else 'danger') - - - - # Storage usage estimate removed in favor of direct GCP Console monitoring - storage_used = 0.0 - storage_limit = 5.0 # Firebase Spark Plan limit - storage_percent = 0 - storage_level = 'safe' - - - - return jsonify({ - - 'month': data['month'], - - 'used': data['count'], - - 'limit': data['limit'], - - 'remaining': remaining, - - 'percent': percent, - - 'level': level, - - 'storage': { - - 'used': storage_used, - - 'limit': storage_limit, - - 'percent': storage_percent, - - 'level': storage_level, - - 'manual_count': data['manual_count'] - - } - - }) - - - - @app.route('/api/deploy/storage/purge', methods=['POST']) - - def purge_deploy_storage(): - - """Resets the manual deployment counter after user cleans up Firebase Console.""" - - data = _load_quota() - - data['manual_count'] = 0 - - _save_quota(data) - - return jsonify({"status": "SUCCESS", "message": "Manual deployment counter reset. Imperial Sanctuary purified."}) - - - - @app.route('/api/deploy/quota/sync', methods=['POST']) - - def sync_deploy_quota(): - - """Manually override the current month's build quota.""" - - req = request.json - - if 'used' not in req: - - return jsonify({"status": "ERROR", "message": "Missing 'used' parameter"}), 400 - - - - data = _load_quota() - - data['count'] = int(req['used']) - - _save_quota(data) - - return jsonify({"status": "SUCCESS", "message": f"Quota synced to {data['count']}"}) - - - - @app.route('/api/secrets/sync/status', methods=['GET']) - - def get_secrets_sync_status(): - - return jsonify(secrets_sync_status) - - - - @app.route('/api/deploy/db/status', methods=['GET']) - def get_db_schema_status(): - """Returns the current Prisma migration status for the project.""" - project = request.args.get("project", "ether_bahamut") - if project != "ether_bahamut": - return jsonify({"status": "UNSUPPORTED", "message": "์ด ํ”„๋กœ์ ํŠธ๋Š” ํ‚ค ๊ฐ์‹œ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."}) - - def check_db(): - try: - # 1. Get DB URL from Bitwarden - bw_session = os.environ.get("BW_SESSION") - if not bw_session: - return {"status": "LOCKED", "message": "Bitwarden Vault๊ฐ€ ์ž ๊ฒจ ์žˆ์Šต๋‹ˆ๋‹ค."} - - # Get the preview URL for status checks - cmd_get_secret = 'powershell -Command "bw get password \'Imperial: PREVIEW_DATABASE_URL\' --raw"' - res_secret = subprocess.run(cmd_get_secret, shell=True, capture_output=True, text=True, encoding='utf-8') - db_url = res_secret.stdout.strip().replace('"', '').replace("'", "") - - if not db_url or "Auto-migrated" in db_url: - return {"status": "ERROR", "message": "DB URL์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."} - - # 2. Run prisma migrate status - target_dir = os.path.join(project_root, "ether-bahamut") - env_copy = os.environ.copy() - env_copy["DATABASE_URL"] = db_url - - # Check for prisma existence - cmd_status = "npx.cmd prisma migrate status" - res_status = subprocess.run(cmd_status, shell=True, cwd=target_dir, env=env_copy, capture_output=True, text=True, encoding='utf-8') - - output = res_status.stdout + res_status.stderr - if "Database schema is up to date" in output: - return {"status": "SYNCED", "message": "์ตœ์‹  ์ƒํƒœ (๋™๊ธฐํ™”๋จ)"} - elif "migrations are not yet applied" in output: - # Count pending migrations if possible - return {"status": "PENDING", "message": "โš ๏ธ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ•„์š”"} - elif "error" in output.lower(): - return {"status": "ERROR", "message": "DB ์—ฐ๊ฒฐ ์˜ค๋ฅ˜"} - else: - return {"status": "UNKNOWN", "message": "์ƒํƒœ ํ™•์ธ ๋ถˆ๊ฐ€"} - - except Exception as e: - return {"status": "ERROR", "message": f"์‹œ์Šคํ…œ ์˜ค๋ฅ˜: {str(e)}"} - - result = check_db() - return jsonify(result) - - - - # [๐Ÿ”ฑ Imperial] Deployment logic has been migrated to blueprints/deployment_routes.py\n @app.route('/redteam') - - def redteam_console(): - - """Serves the Red Team tactical audit interface.""" - - return render_template('redteam.html') - - - - # [๐Ÿ”ฑ Imperial] Red Team audit logic has been migrated to blueprints/audit_routes.py - def serve_guardian_assets(guardian_id, filename): - """Serves assets from specific guardian workspaces (Soul Bridge).""" - from flask import send_from_directory - workspace_map = { - "hugin": os.path.join(project_root, "scripts", "hugin"), - "shadowbrain": os.path.join(os.path.dirname(project_root), "docker_data", "shared_workspace", "JarvisRun"), - "jarvis": os.path.join(os.path.dirname(project_root), "docker_data", "shared_workspace", "JarvisRun"), - "aris": os.path.join(project_root, "docker_data", "shared_workspace", "Aris") - } - base_path = workspace_map.get(guardian_id.lower()) - - if not base_path: - base_path = os.path.join(project_root, "docker_data", "shared_workspace", guardian_id.capitalize()) - - if os.path.exists(base_path): - return send_from_directory(base_path, filename) - return jsonify({"error": "Workspace not found"}), 404 - - def _is_flutter_debug_server_running(port=38790): - """flutter run --debug ์„œ๋ฒ„(38790)๊ฐ€ ๊ธฐ๋™ ์ค‘์ธ์ง€ ๋น ๋ฅด๊ฒŒ ์ฒดํฌํ•ฉ๋‹ˆ๋‹ค.""" - import socket as _socket - s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) - s.settimeout(0.1) - try: - s.connect(('127.0.0.1', port)) - s.close() - return True - except Exception: - return False - - @app.route('/') - - def index(): - - # flutter run --debug ์„œ๋ฒ„(38790)๊ฐ€ ์‚ด์•„ ์žˆ์œผ๋ฉด ๊ทธ์ชฝ์œผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (์ตœ์‹  ์ฝ”๋“œ) - # ๐Ÿ”ฑ RunJarvis_Web.bat ๊ฐœ๋ฐœ ์ค‘์ผ ๋•Œ 18700๊ณผ 38790์ด ์ž๋™ ์—ฐ๋™๋จ - from flask import redirect as _redirect - if _is_flutter_debug_server_running(38790): - return _redirect('http://localhost:38790/', code=302) - - # Serve the main index.html (Heimdall or Legacy) - - target = 'index.html' if os.path.exists(os.path.join(app.static_folder, 'index.html')) else 'dashboard.html' - - from flask import make_response, send_from_directory - - return send_from_directory(app.static_folder, target) - - - - @app.route('/api/status') - - def status(): - - status_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVISRUN_STATUS.json") - - try: - - if os.path.exists(status_file): - - with open(status_file, 'r', encoding='utf-8') as f: - - data = json.load(f) - - - - # --- Phase: Eternal Watch Status --- - - data['external_health'] = data.get('external_services', {}) - - - - # --- Phase 5: Guardian Location Display --- - - if cluster: - - try: - - data['allocations'] = cluster.get_config().get('allocations', {}) - - except Exception: - - data['allocations'] = {} - - - - # Staleness check: if heartbeat is older than 45 seconds, - - # the engine is stopped/restarting - treat all guardians as offline - - ts = data.get('timestamp', 0) - - age = time.time() - ts - - if age > 45: - - stale_guardians = {k: False for k in data.get('guardians', {})} - - data['guardians'] = stale_guardians - - data['stale'] = True - - data['stale_age'] = int(age) - - # [๐Ÿ”ฑ] Guardian Ports Mapping for UI (Fallback for Shadow Brain) - # Map standard keys to ports - port_map = { - "drako": "5432", "orpheus": "18800", "wan2gp": "8003", - "heimdall": "18701", "aizen": "18789", "hugin": "18792", - "hermes": "18790", "shadow_brain": "18700", "shadow_rust_brain": "18710", - "hydra": "8002", "tiamat": "8080", "atlas": "18799", - "bastion": "18702", "gemini_voice": "18721", "pandora": "8001", - "bahamut": "3002", "ether": "3002", "iris": "4000", "ether_portal": "4000" - } - - # Create guardian_ports mapping matching the exact keys found in data['guardians'] - # or based on known labels if the keys are complex labels - guardian_ports = {} - guardians = data.get('guardians', {}) - for k in guardians.keys(): - port = "" - k_lower = k.lower() - for std_k, p in port_map.items(): - if std_k in k_lower: - port = p - break - guardian_ports[k] = port - - data['guardian_ports'] = guardian_ports - - return jsonify(data) - - except Exception as e: - - return jsonify({"error": str(e)}), 500 - - return jsonify({"status": "OFFLINE"}) - - - - @app.route('/api/status/sync', methods=['GET', 'POST']) - - def handle_sync_status(): - - global is_syncing - - from flask import request - - if request.method == 'POST': - - data = request.json - - is_syncing = data.get("is_syncing", False) - - print(f"[.env Migration] Syncing state set to: {is_syncing}") - - return jsonify({"status": "SUCCESS", "is_syncing": is_syncing}) - - else: - - return jsonify({"is_syncing": is_syncing}) - - - - @app.route('/api/config') - - def get_config(): - - version_file = os.path.join(project_root, "VERSION") - try: - with open(version_file, 'r', encoding='utf-8-sig') as f: - version_str = f.read().strip() - except Exception: - version_str = "UNKNOWN" - - return jsonify({ - - "version": version_str, - - "dev_mode": "ACTIVE", - - "neural_link_stability": "99.9%", - - "last_sync": os.path.getmtime(__file__) - - }) - - - @app.route('/api/shadow/ask/answer', methods=['POST', 'OPTIONS']) - def shadow_ask_answer(): - """๐Ÿ™‹ [Ask User] ํ—ค์ž„๋‹ฌ ์„ ํƒ์ง€ ๋ฒ„ํŠผ ํƒญ ์ˆ˜์‹  โ€” ๋Œ€๊ธฐ ์ค‘์ธ ์ถ”๋ก  ๋ฃจํ”„๋ฅผ ๊นจ์šด๋‹ค.""" - if request.method == 'OPTIONS': - return '', 204 - try: - data = request.json or {} - ask_id = str(data.get('ask_id') or '').strip() - # ๐Ÿšซ ์ทจ์†Œ ์‹ ํ˜ธ (์ˆ˜ํ˜ธ์ž ์ „ํ™˜/์ฐฝ ๋‹ซ๊ธฐ ๋“ฑ) โ€” ๋Œ€๊ธฐ ๋ฃจํ”„๊ฐ€ ์ฆ‰์‹œ ๊นจ์–ด๋‚˜ ๋ณด์ˆ˜์ ์œผ๋กœ ๋งˆ๋ฌด๋ฆฌ - answer = "__CANCELLED__" if data.get('cancel') else str(data.get('answer') or '').strip() - if not ask_id or not answer: - return jsonify({"status": "ERROR", "message": "ask_id์™€ answer๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."}), 400 - from brain.core import submit_ask_answer - if submit_ask_answer(ask_id, answer): - return jsonify({"status": "SUCCESS", "ask_id": ask_id}) - # ๋งŒ๋ฃŒ/์ทจ์†Œ๋œ ์งˆ๋ฌธ โ€” ํ—ค์ž„๋‹ฌ์€ ์ด ์‘๋‹ต์„ ๋ฐ›์œผ๋ฉด ์ผ๋ฐ˜ ์ฑ„ํŒ… ๋ฉ”์‹œ์ง€๋กœ ํด๋ฐฑ ์ „์†ก - return jsonify({"status": "EXPIRED", "message": "ํ•ด๋‹น ์งˆ๋ฌธ์€ ์ด๋ฏธ ๋งŒ๋ฃŒ๋˜์—ˆ๊ฑฐ๋‚˜ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."}), 410 - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - @app.route('/api/shadow/ask/begin', methods=['POST', 'OPTIONS']) - def shadow_ask_begin(): - """๐Ÿ™‹ [Ask Bridge] ์™ธ๋ถ€ ์—์ด์ „ํŠธ(ํ—ค๋ฅด๋ฉ”์Šค MCP ๋“ฑ)๊ฐ€ ์„ ํƒ์ง€ ์งˆ๋ฌธ์„ ๋“ฑ๋กํ•œ๋‹ค.""" - if request.method == 'OPTIONS': - return '', 204 - try: - data = request.json or {} - if not str(data.get('question', '')).strip() or not data.get('options'): - return jsonify({"status": "ERROR", "message": "question๊ณผ options๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."}), 400 - from brain.core import begin_ask - return jsonify({"status": "SUCCESS", "ask": begin_ask(data)}) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - @app.route('/api/shadow/ask/wait', methods=['POST', 'OPTIONS']) - def shadow_ask_wait(): - """๐Ÿ™‹ [Ask Bridge] ๋‹ต๋ณ€๊นŒ์ง€ ๋ธ”๋กœํ‚น ๋Œ€๊ธฐ(long-poll). ANSWERED/CANCELLED/TIMEOUT ๋ฐ˜ํ™˜.""" - if request.method == 'OPTIONS': - return '', 204 - try: - data = request.json or {} - ask_id = str(data.get('ask_id') or '').strip() - if not ask_id: - return jsonify({"status": "ERROR", "message": "ask_id๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."}), 400 - timeout = max(5, min(int(data.get('timeout_seconds', 120) or 120), 600)) - from brain.core import wait_ask - answer = wait_ask(ask_id, timeout) - if answer is None: - return jsonify({"status": "TIMEOUT"}) - if answer == "__CANCELLED__": - return jsonify({"status": "CANCELLED"}) - return jsonify({"status": "ANSWERED", "answer": answer}) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - @app.route('/api/shadow/ask/pending', methods=['GET', 'OPTIONS']) - def shadow_ask_pending(): - """๐Ÿ™‹ [Ask Bridge] ๋Œ€๊ธฐ ์ค‘ ์งˆ๋ฌธ ๋ชฉ๋ก โ€” ํ—ค์ž„๋‹ฌ์ด ํด๋งํ•˜์—ฌ ์นด๋“œ๋ฅผ out-of-band ํ‘œ์‹œ.""" - if request.method == 'OPTIONS': - return '', 204 - try: - from brain.core import list_pending_asks - return jsonify({"status": "SUCCESS", "asks": list_pending_asks()}) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e), "asks": []}), 200 - - @app.route('/api/shadow/web_search', methods=['POST', 'OPTIONS']) - def shadow_web_search(): - """๐ŸŒ [Web Search] ์‹ค์‹œ๊ฐ„ ์›น ๊ฒ€์ƒ‰ โ€” ๋‚ด๋ถ€ google-surf MCP๋ฅผ ํ˜ธ์ถœํ•ด ๊ฒฐ๊ณผ ํ…์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜. - - imperial MCP์˜ web ๋ฒˆ๋“ค(search_web)์ด ์ด ์—”๋“œํฌ์ธํŠธ๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค. ๋•๋ถ„์— ์™ธ๋ถ€ - ์ˆ˜ํ˜ธ์ž(ํ—ค๋ฅด๋ฉ”์Šค ๋“ฑ)๋Š” google-surf npm ์„œ๋ฒ„๋ฅผ ๋”ฐ๋กœ ์•ˆ ๊น”๊ณ  ์šฐ๋ฆฌ MCP๋งŒ์œผ๋กœ ๊ฒ€์ƒ‰ํ•˜๋ฉฐ, - ์‹ค์ œ ๊ฒ€์ƒ‰(๋ธŒ๋ผ์šฐ์ €)์€ ์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ์—์„œ ์ค‘์•™ ์‹คํ–‰๋œ๋‹ค. - """ - if request.method == 'OPTIONS': - return '', 204 - data = request.get_json(silent=True) or {} - query = (data.get('query') or data.get('q') or '').strip() - if not query: - return jsonify({"status": "ERROR", "message": "query๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."}), 400 - try: - limit = max(1, min(10, int(data.get('limit', 5)))) - except (TypeError, ValueError): - limit = 5 - try: - # ํ™˜๊ฒฝ ๋ฌด๊ด€ ๋™์ž‘: google-surf(๋ธŒ๋ผ์šฐ์ € MCP)๊ฐ€ ์—ฐ๊ฒฐ๋ผ ์žˆ์œผ๋ฉด ์‚ฌ์šฉ(๋กœ์ปฌ/์ฃผ๊ฑฐ์šฉ IP์— ์ ํ•ฉ), - # ๋ฏธ์—ฐ๊ฒฐ์ด๋ฉด(ํด๋ผ์šฐ๋“œ ๋“ฑ) ๋„ค์ด๋ฒ„ headless Playwright(local_playwright_search)๋กœ ํด๋ฐฑํ•œ๋‹ค. - # โ†’ ๊ตฌ๊ธ€์€ ๋ฐ์ดํ„ฐ์„ผํ„ฐ IP์—์„œ ์บก์ฐจ๋กœ ๋ง‰ํžˆ๋ฏ€๋กœ ํด๋ผ์šฐ๋“œ์—์„  ๋„ค์ด๋ฒ„ ๊ฒฝ๋กœ๊ฐ€ ์ž๋™ ์‚ฌ์šฉ๋จ. - from mcp_client import get_mcp_client - _mc = get_mcp_client() if get_mcp_client else None - _has_surf = bool(_mc and 'google-surf' in getattr(_mc, '_active_servers', [])) - if _has_surf: - result = _mc.call_tool_sync('google-surf', 'search', - {"query": query[:400], "limit": limit}, timeout=150) - elif jarvis_brain is not None and getattr(jarvis_brain, 'browser', None) is not None: - # ๋„ค์ด๋ฒ„โ†’๋‹ค์Œโ†’๊ตฌ๊ธ€ headless ๊ฒ€์ƒ‰ (๊ตฌ๊ธ€ ์บก์ฐจ/๋””์Šคํ”Œ๋ ˆ์ด ๋ฌด๊ด€, API ํ‚ค ๋ถˆํ•„์š”) - result = jarvis_brain.browser.local_playwright_search(query[:400]) - else: - return jsonify({"status": "ERROR", - "message": "๊ฒ€์ƒ‰ ์—”์ง„ ๋ฏธ๊ฐ€์šฉ (google-surf/Playwright ๋ชจ๋‘ ๋ถˆ๊ฐ€)"}), 503 - return jsonify({"status": "SUCCESS", "query": query, "result": result}) - except Exception as e: - logger.error(f"[Web Search] ์‹คํŒจ: {e}") - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - @app.route('/api/mcp/directories', methods=['GET', 'POST', 'OPTIONS']) - def manage_mcp_directories(): - if request.method == 'OPTIONS': - return '', 204 - - # [๐Ÿ”ฑ Writable MCP Config] ์“ฐ๊ธฐ ๊ฐ€๋Šฅํ•œ ์‚ฌ์šฉ์ž ํด๋”(LOCALAPPDATA) ์‚ฌ๋ณธ์„ ์ฝ๊ณ  ์“ด๋‹ค. - # (Program Files ์‚ฌ๋ณธ์€ ๊ถŒํ•œ ๋•Œ๋ฌธ์— ์ €์žฅ ๋ถˆ๊ฐ€) - try: - from mcp_client import resolve_mcp_config_path - mcp_config_path = resolve_mcp_config_path() - except Exception: - mcp_config_path = os.path.join(core_root, 'mcp_servers.json') - - if request.method == 'GET': - try: - if not os.path.exists(mcp_config_path): - return jsonify({"status": "SUCCESS", "directories": []}) - with open(mcp_config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - directories = [] - for server in config.get('servers', []): - if server.get('name') == 'filesystem': - args = server.get('args', []) - try: - idx = args.index("@modelcontextprotocol/server-filesystem") - directories = args[idx+1:] - except ValueError: - pass - return jsonify({"status": "SUCCESS", "directories": directories}) - except Exception as e: - logger.error(f"[MCP API] Failed to read directories: {e}") - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - elif request.method == 'POST': - try: - data = request.json or {} - new_dirs = data.get('directories', []) - if not isinstance(new_dirs, list): - return jsonify({"status": "ERROR", "message": "directories must be a list"}), 400 - - # [๐Ÿ”ฑ Imperial Fix] mcp_servers.json ์—†์œผ๋ฉด ๊ธฐ๋ณธ ๊ตฌ์กฐ๋กœ ์ž๋™ ์ƒ์„ฑ - if not os.path.exists(mcp_config_path): - logger.info("[MCP API] mcp_servers.json not found. Creating default config...") - default_config = { - "servers": [ - { - "name": "filesystem", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem"] - } - ] - } - with open(mcp_config_path, 'w', encoding='utf-8') as f: - json.dump(default_config, f, indent=2, ensure_ascii=False) - - with open(mcp_config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - # filesystem ์„œ๋ฒ„ ํƒ์ƒ‰ ๋ฐ ์—…๋ฐ์ดํŠธ - updated = False - for server in config.get('servers', []): - if server.get('name') == 'filesystem': - base_args = ["-y", "@modelcontextprotocol/server-filesystem"] - server['args'] = base_args + new_dirs - updated = True - break - - # filesystem ์„œ๋ฒ„๊ฐ€ ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ถ”๊ฐ€ - if not updated: - if 'servers' not in config: - config['servers'] = [] - config['servers'].append({ - "name": "filesystem", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem"] + new_dirs - }) - updated = True - - with open(mcp_config_path, 'w', encoding='utf-8') as f: - json.dump(config, f, indent=2, ensure_ascii=False) - return jsonify({"status": "SUCCESS", "message": "MCP directories updated. Restart required."}) - except Exception as e: - logger.error(f"[MCP API] Failed to update directories: {e}") - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - - @app.route('/api/logs') - - def get_logs(): - - log_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_TERMINAL.log") - - try: - - if os.path.exists(log_file): - with open(log_file, 'r', encoding='utf-8', errors='replace') as f: - lines = f.readlines() - return jsonify({"logs": lines[-500:]}) # Standard Imperial Buffer Size - - except Exception as e: - - return jsonify({"error": str(e)}), 500 - - return jsonify({"logs": ["Initializing tactical link..."]}) - - - - @app.route('/api/imperial/health') - - def imperial_health(): - - """Returns the health status of external cloud services (Eternal Watch).""" - - status_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVISRUN_STATUS.json") - - try: - - if os.path.exists(status_file): - - with open(status_file, 'r', encoding='utf-8') as f: - - data = json.load(f) - - - - # Extract external services info - - external_services = data.get('external_services', { - - "n8n": {"status": "UNKNOWN", "last_check": 0}, - - "supabase_prod": {"status": "UNKNOWN", "last_check": 0}, - - "supabase_dev": {"status": "UNKNOWN", "last_check": 0}, - - "nhost": {"status": "UNKNOWN", "last_check": 0} - - }) - - - - return jsonify({ - - "status": "ONLINE", - - "services": external_services, - - "timestamp": time.time() - - }) - - except Exception as e: - - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - return jsonify({"status": "OFFLINE", "services": {}}) - - - - def archive_old_missions(todos, archive_limit=15): - """Legacy stub.""" - return todos - - completed = [t for t in todos if t.get('status') == 'COMPLETED'] - - if len(completed) <= archive_limit: - - return todos - - - - # Sort by timestamp to find oldest - - completed.sort(key=lambda x: x.get('timestamp', 0)) - - - - to_archive = completed[:len(completed) - archive_limit] - - archive_ids = {t['id'] for t in to_archive} - - - - remaining_todos = [t for t in todos if t['id'] not in archive_ids] - - - - archive_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_TODO_ARCHIVE.json") - - archived_data = [] - - if os.path.exists(archive_file): - - try: - - with open(archive_file, 'r', encoding='utf-8') as f: - - archived_data = json.load(f) - - except Exception: pass - - - - archived_data.extend(to_archive) - - - - try: - - with open(archive_file, 'w', encoding='utf-8') as f: - - json.dump(archived_data, f, ensure_ascii=False, indent=2) - - print(f"[Imperial Archive] Moved {len(to_archive)} missions to archive.") - - except Exception as e: - - print(f"[ERROR] Archiving failed: {e}") - - - - return remaining_todos - - - - def sync_missions_to_markdown(todos): - """Legacy stub.""" - return - - try: - - mc_path = os.path.join(project_root, "mission_control.md") - - content = "# Imperial Task Control (ํ™ฉ์‹ค ์ž‘์ „ ์ œ์–ด์‹ค)\n\n" - - content += "> [!IMPORTANT]\n" - - content += f"> ์ด ๋ฌธ์„œ๋Š” ์ž๋น„์Šค(Antigravity)๊ฐ€ ํ˜„์žฌ ์ƒํƒœ๋ฅผ ์‹œ๊ฐ„์ˆœ์œผ๋กœ ๊ธฐ๋กํ•˜๋Š” **์ œ๊ตญ ๊ณต์‹ ๊ด€์ œ ์„ผํ„ฐ**์ž…๋‹ˆ๋‹ค. (Last Updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')})\n\n" - - - - content += "## โšก ํ˜„์žฌ ์ง„ํ–‰ ์ค‘์ธ ํ…Œ์Šคํฌ (Active Tasks)\n\n" - - active = [t for t in todos if t.get('status') != 'COMPLETED' and t.get('status') != 'ARCHIVED'] - - - - if not active: - - content += "*ํ˜„์žฌ ์ง„ํ–‰ ์ค‘์ธ ๏ฟฝ๏ฟฝ๏ฟฝ์Šคํฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋งˆ์™•๋‹˜์˜ ๋ช…๋ น์„ ๋Œ€๊ธฐ ์ค‘์ž…๋‹ˆ๋‹ค.*\n\n" - - else: - - content += "| ID | ํ…Œ์Šคํฌ ๊ณผ์—…(Task) | ์ƒํƒœ (Status) | ๋ณด๊ณ  ๋‚ด์šฉ (Report) |\n" - - content += "| :--- | :--- | :--- | :--- |\n" - - for t in active: - - # Escape pipe characters for markdown table - - task = str(t.get('task', '')).replace('|', '\\|') - - status = str(t.get('status', 'PENDING')).replace('|', '\\|') - - report = str(t.get('report', '')).replace('|', '\\|').replace('\n', '
') - - content += f"| {t.get('id')} | {task} | **{status}** | {report} |\n" - - content += "\n" - - - - content += "## ๐Ÿ”ฑ ์™„์ˆ˜๋œ ์ œ๊ตญ ๊ณตํ›ˆ (Completed Missions)\n\n" - - completed = [t for t in todos if t.get('status') == 'COMPLETED'] - - if not completed: - - content += "*์•„์ง ์™„์ˆ˜ํ•œ ํ…Œ์Šคํฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.*\n\n" - - else: - - for t in reversed(completed): - - content += f"### ๐Ÿ”ฑ {t.get('task')}\n" - - content += f"- **ID**: {t.get('id')}\n" - - content += f"- **์ข…๋ฃŒ ์ผ์‹œ**: {datetime.fromtimestamp(t.get('timestamp', 0)).strftime('%Y-%m-%d %H:%M:%S')}\n" - - # [๐Ÿ”ฑ Imperial v2.5.2] Python 3.10 compatibility: extract backslash operation from f-string - report_formatted = str(t.get('report', '')).replace('\n', '\n> ') - content += f"- **์ตœ์ข… ๋ณด๊ณ **:\n> {report_formatted}\n\n" - - content += "---\n\n" - - - - with open(mc_path, 'w', encoding='utf-8') as f: - f.write(content) - print(f"[Neural Link] mission_control.md synchronized successfully.") - - # Sync to Google Drive - gdrive_sync.sync_to_gdrive("mission_control.md", content) - - except Exception as e: - - print(f"[ERROR] Sync to markdown failed: {e}") - - - - - # [REMOVED] Mission (Todo) management is now exclusively handled by Django - # Fortress (Port 18701) to ensure SSOT and AEGIS protection. - - # [๐Ÿ”ฑ Imperial Command Router] (Moved to blueprints/system_routes.py) - - - - - def _resolve_guardian_id(gid): - """[๐Ÿ”ฑ Imperial] Resolve UUIDs, Ports, or Full Names to Internal Short IDs""" - mapping = { - "18800": "orpheus", - "orpheus": "orpheus", - "์˜ค๋ฅดํŽ˜์šฐ์Šค (๋ฎค์ฆˆ์˜ ๋ฉ”์•„๋ฆฌ)": "orpheus", - "5d4d6c6f-0767-4af4-bdc4-90eea624c8ca": "orpheus", - "aizen": "aizen", - "์•„์ด์   (์„ฑ์—ญ์˜ ์„œ๊ณ )": "aizen", - "542f42a5-7b90-4537-b751-5218e3c09d54": "aizen", - "aegis": "aegis", - "์• ๊ธฐ์Šค (ํ™ฉ์‹ค ์ ˆ๋Œ€ ๋ฐฉ๋ฒฝ)": "aegis", - "88092a79-174b-4899-9ce4-465c52791bed": "aegis", - "hermes": "hermes", - "ํ—ค๋ฅด๋ฉ”์Šค (์ „๋ น์˜ ๋ฐœ๊ฑธ์Œ)": "hermes", - "f4980714-58da-4e4b-aea3-8746bded4c12": "hermes" - } - # 1. Direct Map - res = mapping.get(gid) - if res: return res - - # 2. Database Lookup (Fallback for new guardians) - try: - conn = get_db_connection() - if conn: - with conn.cursor() as cur: - cur.execute("SELECT canonical_name, name FROM taemingames.guardian_personas WHERE id::text = %s OR name = %s", (gid, gid)) - row = cur.fetchone() - if row: - db_canonical = row[0] - db_name = row[1] - # Map based on known canonicals or keywords - if db_canonical and db_canonical in mapping: return mapping[db_canonical] - if "์˜ค๋ฅดํŽ˜์šฐ์Šค" in (db_name or ""): return "orpheus" - if "์•„์ด์  " in (db_name or ""): return "aizen" - conn.close() - except Exception as db_err: - logger.warning(f"โš ๏ธ [Imperial Mapping] DB Lookup failed for {gid}: {db_err}") - - return gid - - @app.route('/api/guardian//', methods=['POST']) - def execute_guardian_action(guardian_id, action): - """Web interface for single Guardian ON/OFF toggles""" - # [๐Ÿ”ฑ Imperial] Resolve ID first - original_id = guardian_id - guardian_id = _resolve_guardian_id(guardian_id) - - paths = { - "aizen": {"start": "scripts/aizen/start_aizen.ps1", "stop": "scripts/aizen/stop_aizen.ps1", "title": "Aizen Guardian"}, - "aris": {"start": "scripts/backend/start-backend.bat", "stop": "scripts/backend/stop-backend.bat", "title": "Aris Guardian"}, - "gilgamesh": {"start": "scripts/openclaw/openclaw-start-native-pro.bat", "stop": "scripts/openclaw/openclaw-stop-native-pro.bat", "title": "Gilgamesh Guardian"}, - "app": {"start": "scripts/ether-bahamut/start-ether.bat", "stop": "scripts/ether-bahamut/stop-ether.bat", "title": "Bahamut (Core)"}, - "bahamut": {"start": "scripts/ether-bahamut/start-ether.bat", "stop": "scripts/ether-bahamut/stop-ether.bat", "title": "Bahamut (Aegis)"}, - "aegis": {"start": "scripts/security/aegis-guardian.ps1", "stop": "scripts/security/stop-aegis.ps1", "title": "Aegis Guardian"}, - "qwen": {"start": "scripts/jarvisrun/start_qwen_tts.ps1", "stop": "scripts/jarvisrun/stop_qwen_tts.ps1", "title": "Qwen TTS"}, - "orpheus": {"start": "scripts/jarvisrun/start_qwen_tts.ps1", "stop": "scripts/jarvisrun/stop_qwen_tts.ps1", "title": "Orpheus (Qwen3-TTS)"}, - "aris-ui": {"start": "scripts/aris-ui/start_aris_ui.ps1", "stop": "scripts/aris-ui/stop_aris_ui.ps1", "title": "Aris-UI Guardian"}, - "drako": {"start": "scripts/database/start-drako.bat", "stop": "scripts/database/stop-drako.bat", "title": "Drako Guardian"}, - "wan2gp": {"start": "scripts/wan2gp/start_wan2gp.ps1", "stop": "scripts/wan2gp/stop_wan2gp.ps1", "title": "Hephaestus (Wan2GP)"}, - "hephaestus": {"start": "scripts/wan2gp/start_wan2gp.ps1", "stop": "scripts/wan2gp/stop_wan2gp.ps1", "title": "Hephaestus (Wan2GP)"}, - "ether-portal": {"start": "scripts/ether-portal/start_ether_portal.ps1", "stop": "scripts/ether-portal/stop_ether_portal.ps1", "title": "Tiamat (Portal)"}, - "tiamat": {"start": "scripts/ether-portal/start_ether_portal.ps1", "stop": "scripts/ether-portal/stop_ether_portal.ps1", "title": "Tiamat (Portal)"}, - "ether_portal": {"start": "scripts/ether-portal/start_ether_portal.ps1", "stop": "scripts/ether-portal/stop_ether_portal.ps1", "title": "Tiamat (Portal)"}, - "heimdall": {"start": "RunJarvis_Web.bat", "stop": "scripts/jarvisrun/stop_heimdall.ps1", "title": "Heimdall (Eye)"}, - "hydra": {"start": "scripts/hydra/start_hydra.ps1", "stop": "scripts/hydra/stop_hydra.ps1", "title": "Hydra (Multi-Agent)"}, - "hugin": {"start": "scripts/jarvis/start_jarvis_relay.ps1", "stop": "scripts/jarvis/stop_jarvis_relay.ps1", "title": "Hugin (Recon Eye)"}, - "hermes": {"start": "scripts/security/start_hermes.ps1", "stop": "scripts/security/stop_hermes.ps1", "title": "Hermes (Tunnel)"} - } - - if guardian_id not in paths: - logger.error(f"๐Ÿ›‘ [Imperial Command] Unknown guardian: {guardian_id} (Resolved from: {original_id})") - return jsonify({"status": "ERROR", "message": f"Unknown guardian: {guardian_id}"}), 400 - - script_rel = paths[guardian_id].get(action) - title = paths[guardian_id]["title"] - if not script_rel: - return jsonify({"status": "ERROR", "message": f"Unknown action: {action}"}), 400 - - full_path = os.path.join(project_root, os.path.normpath(script_rel)) - - try: - # [๐Ÿ”ฑ Imperial Cluster Relay] - if cluster: - # Use a safe access pattern to avoid AttributeError - config = {} - try: - config = cluster.get_config() - except Exception as cfg_err: - logger.warning(f"โš ๏ธ [Imperial Cluster] Failed to get live config: {cfg_err}") - # Try to fall back to a direct file read if cluster logic is broken - try: - with open(os.path.join(cluster.workspace_dir, "JARVIS_CLUSTER.json"), 'r') as f: - config = json.load(f) - except: pass - - target_host = config.get("allocations", {}).get(guardian_id) - - if target_host and target_host != cluster.hostname: - node_info = config.get("nodes", {}).get(target_host) - if node_info and node_info.get("ip"): - target_ip = node_info["ip"] - print(f"๐Ÿ“ก [Imperial Relay] Relaying {action} for {guardian_id} to {target_host} ({target_ip})") - try: - # Avoid recursive loops if somehow metadata is mixed up - is_relayed = request.args.get("relayed") == "true" - if not is_relayed: - # Relay the POST request with the same JSON body if any - relay_url = f"http://{target_ip}:18700/api/guardian/{guardian_id}/{action}" - relay_resp = requests.post(relay_url, params={"relayed": "true"}, timeout=5) - return jsonify(relay_resp.json()), relay_resp.status_code - except Exception as relay_err: - logger.error(f"๐Ÿ›‘ [Imperial Relay] Failed to push to {target_host} ({target_ip}): {relay_err}") - return jsonify({"status": "ERROR", "message": f"Cluster Relay Failed: {str(relay_err)}"}), 502 - - if action == 'start': - if full_path.endswith('.ps1'): - cmd_str = f'start "{title}" powershell -NoProfile -ExecutionPolicy Bypass -File "{full_path}"' - else: - cmd_str = f'start "{title}" cmd /c "{full_path}"' - else: # stop - if full_path.endswith('.ps1'): - cmd_str = f'powershell -NoProfile -ExecutionPolicy Bypass -File "{full_path}"' - else: - cmd_str = f'"{full_path}"' - - signal_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_SIGNAL.json") - data = { - "signal": "EXEC", - "content": cmd_str, - "sender": "Web Interface", - "timestamp": time.time() - } - with open(signal_file, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False) - - return jsonify({"status": "SUCCESS", "message": f"{title} {action} dispatched locally."}) - - except Exception as e: - logger.error(f"๐Ÿ›‘ [Imperial Command] execute_guardian_action failed for {guardian_id}/{action}: {e}", exc_info=True) - return jsonify({"status": "ERROR", "message": f"Exception: {str(e)}"}), 500 - - - - - @app.route('/api/todo//report', methods=['PATCH']) - def update_report(todo_id): - """Update only current_action for a team task.""" - from flask import request - data = request.json - from db_util import get_db_connection - conn = get_db_connection() - if not conn: - return jsonify({"status": "ERROR", "message": "DB connection failed"}), 500 - try: - with conn.cursor() as cur: - query = 'UPDATE "taemingames"."imperial_team_tasks" SET ' - updates = [] - params = [] - if "report" in data or "current_action" in data: - updates.append("current_action = %s") - params.append(data.get("current_action") or data.get("report")) - if "status" in data: - updates.append("status = %s") - params.append(data["status"]) - if not updates: - return jsonify({"status": "SUCCESS"}) - updates.append("updated_at = NOW()") - query += ", ".join(updates) + " WHERE id = %s" - params.append(todo_id) - cur.execute(query, tuple(params)) - conn.commit() - return jsonify({"status": "SUCCESS"}) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - finally: - conn.close() - - # =================================================================== - # [๐Ÿ”ฑ Shadow Brain OS V2] Imperial Goal & Routine APIs - # =================================================================== - - @app.route('/api/goals', methods=['GET']) - def get_imperial_goals(): - """Retrieve all strategic goals.""" - from db_util import get_db_connection - conn = get_db_connection() - if not conn: - return jsonify({"status": "ERROR", "message": "DB connection failed"}), 500 - try: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute('SELECT * FROM "taemingames"."imperial_goals" ORDER BY priority DESC, created_at DESC') - goals = cur.fetchall() - return jsonify(goals) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - finally: - conn.close() - - @app.route('/api/goals', methods=['POST']) - def create_imperial_goal(): - """Create a new strategic goal.""" - data = request.json - from db_util import get_db_connection - conn = get_db_connection() - try: - with conn.cursor() as cur: - cur.execute(""" - INSERT INTO "taemingames"."imperial_goals" (title, description, priority, target_date, status) - VALUES (%s, %s, %s, %s, 'ACTIVE') RETURNING id - """, (data.get('title'), data.get('description'), data.get('priority', 0), data.get('target_date'))) - goal_id = cur.fetchone()[0] - conn.commit() - return jsonify({"status": "SUCCESS", "id": goal_id}) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - finally: - conn.close() - - @app.route('/api/routines', methods=['GET']) - def get_imperial_routines(): - """Retrieve all automated routines.""" - from db_util import get_db_connection - conn = get_db_connection() - try: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute('SELECT * FROM "taemingames"."imperial_routines" ORDER BY is_active DESC, name ASC') - routines = cur.fetchall() - return jsonify(routines) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - finally: - conn.close() - - @app.route('/api/routines', methods=['POST']) - def create_imperial_routine(): - """Create a new automation routine.""" - data = request.json - from db_util import get_db_connection - conn = get_db_connection() - try: - with conn.cursor() as cur: - cur.execute(""" - INSERT INTO "taemingames"."imperial_routines" - (name, description, cron_expression, executor, goal_id, is_active) - VALUES (%s, %s, %s, %s, %s, true) RETURNING id - """, ( - data.get('name'), - data.get('description'), - data.get('cron_expression'), - data.get('executor'), - data.get('goal_id') - )) - routine_id = cur.fetchone()[0] - conn.commit() - return jsonify({"status": "SUCCESS", "id": routine_id}) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - finally: - conn.close() - - @app.route('/api/routines/', methods=['PATCH']) - def update_imperial_routine(routine_id): - """Update or toggle a routine.""" - data = request.json - from db_util import get_db_connection - conn = get_db_connection() - try: - with conn.cursor() as cur: - updates = [] - params = [] - for k, v in data.items(): - if k in ['name', 'description', 'cron_expression', 'executor', 'goal_id', 'is_active']: - updates.append(f"{k} = %s") - params.append(v) - if updates: - query = f"UPDATE \"taemingames\".\"imperial_routines\" SET {', '.join(updates)} WHERE id = %s" - params.append(routine_id) - cur.execute(query, tuple(params)) - conn.commit() - return jsonify({"status": "SUCCESS"}) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - finally: - conn.close() - - - - - @app.route('/api/autotest', methods=['POST']) - - def run_autotest(): - - """Signal that Jarvis should auto-test a specific todo item via browser.""" - - from flask import request - - data = request.json or {} - - todo_id = data.get("id") - - signal_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_AUTOTEST.json") - - try: - - payload = { - - "signal": "AUTOTEST", - - "todo_id": todo_id, - - "timestamp": time.time() - - } - - with open(signal_file, 'w', encoding='utf-8') as f: - - json.dump(payload, f, ensure_ascii=False) - - - # Phase 8: Logging to Imperial Neural Terminal - log_msg = f"๐Ÿ›ก๏ธ [Imperial Command] Mission {todo_id} ์ž๋น„์Šค ์ž์œจ QA ํ…Œ์ŠคํŠธ ๊ฐœ์‹œ.." - print(log_msg) # This shows up in the backend console - return jsonify({"status": "SUCCESS", "message": "์ž๋น„์Šค ์ž๋™ ํ…Œ์ŠคํŠธ ์‹ ํ˜ธ ๋ฐœ์†ก ์™„๋ฃŒ"}) - except Exception as e: - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - @app.route('/api/automission', methods=['POST']) - def run_automission(): - """Signal that Jarvis should propose new evolution missions.""" - signal_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_SIGNAL.json") - signal_data = { - "signal": "AUTOPROPOSE", - "timestamp": time.time(), - "sender": "Imperial Portal" - } - with open(signal_file, 'w', encoding='utf-8') as f: - json.dump(signal_data, f, ensure_ascii=False, indent=2) - return jsonify({"status": "SUCCESS", "message": "Autonomous Evolution Triggered"}) - - - @app.route('/api/todo/archive', methods=['GET']) - - def get_archive(): - return jsonify([]) - # [๐Ÿ”ฑ Imperial] System settings and voice preferences moved to chat_routes.py and admin_routes.py - - - # NOTE: The old hardcoded tts_proxy route was removed. - - # The cluster-aware tts_proxy route is registered below (~line 2016). - - - - # Initialize the Unified Brain (inside the factory function) - - # Using lazy import to avoid circular dependencies if any - - from brain.core import JarvisBrain - - jarvis_brain = JarvisBrain() - - - - def get_shadow_settings(): - - """Helper to get current brain settings.""" - - settings_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_SHADOW_SETTINGS.json") - - if os.path.exists(settings_file): - - try: - - with open(settings_file, 'r', encoding='utf-8') as f: - - return json.load(f) - - except: pass - - return {"provider": "google", "model": "gemini-3.1-flash-lite"} - - - - def get_brain_response_sync(prompt): - - """Helper for synchronous brain calls (welcome, proactive) with multi-provider fallback.""" - - terminal_log = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_TERMINAL.log") - - settings = get_shadow_settings() - - provider = settings.get("provider", "google") - - full_model = settings.get("model", "gemini-3.1-flash-lite") - - - - def log_brain_error(pvd, mdl, err): - - try: - - ts = time.strftime("[%H:%M:%S]") - - with open(terminal_log, 'a', encoding='utf-8') as f: - - f.write(f"{ts} [BRAIN ERROR] ({pvd}/{mdl}): {str(err)}\n") - - except: pass - - - - def try_ollama(model, p): - - ollama_url = "http://localhost:11434/api/generate" - - payload = {"model": model, "prompt": p, "stream": False} - - try: - - res = requests.post(ollama_url, json=payload, timeout=60) - - res.raise_for_status() - - data = res.json() - - return data.get("response", "").strip() or data.get("message", {}).get("content", "").strip() - - except Exception as e: - - log_brain_error("ollama", model, e) - - return None - - - - def try_google(model, p): - - try: - - response_gen = jarvis_brain.think(p, model) - - text = "".join(list(response_gen)).strip() - - if not text: raise ValueError("Empty response from Google") - - return text - - except Exception as e: - - log_brain_error("google", model, e) - - return None - - - - def try_github(model, p): - - try: - - token, base_url = get_github_copilot_token() - - res = requests.post(f"{base_url}/chat/completions", headers={ - - "Authorization": f"Bearer {token}", - - "Content-Type": "application/json", - - "Editor-Version": "vscode/1.85.1", - - "Editor-Plugin-Version": "copilot/1.138.0", - - "User-Agent": "GithubCopilot/1.138.0" - - }, json={ - - "messages": [{"role": "user", "content": p}], - - "model": model, - - "stream": False - - }, timeout=60) - - res.raise_for_status() - - data = res.json() - - text = data["choices"][0]["message"]["content"].strip() - - if not text: raise ValueError("Empty response from GitHub") - - CopilotUsageManager.track_usage(model) - - return text - - except Exception as e: - - if "404" in str(e) or "400" in str(e): - - # Likely invalid model - - log_brain_error("github", model, f"Invalid Model? ({str(e)})") - - else: - - log_brain_error("github", model, e) - - return None - - - - # Primary Attempt - result = None - if provider == "ollama": - result = try_ollama(full_model, prompt) - elif provider == "github" or provider == "copilot": - result = try_github(full_model, prompt) - elif provider == "google": - result = try_google(full_model, prompt) - - # Fallback ์‹œ 1์ฐจ ์‹คํŒจ ํ›„ ๋‹ค์Œ fallback provider/model ์‹œ๋„ - if not result: - fallback_provider = settings.get("fallbackProvider", "") - fallback_model = settings.get("fallbackModel", "") - if fallback_provider and fallback_model and (fallback_provider != provider or fallback_model != full_model): - if fallback_provider == "google": - result = try_google(fallback_model, prompt) - elif fallback_provider in ("github", "copilot"): - result = try_github(fallback_model, prompt) - elif fallback_provider == "ollama": - result = try_ollama(fallback_model, prompt) - - return result - - - - # ============================================================ - # === HYDRA Proxy - Multi-Agent Browser Intelligence (Port 8002) === - # ============================================================ - @app.route('/api/hydra/mission', methods=['POST']) - def hydra_proxy_mission(): - """Proxy to HYDRA Guardian: create a multi-agent browser mission.""" - from flask import request, jsonify - data = request.json or {} - command = data.get("command", "") - max_agents = data.get("max_agents", 2) - if not command: - return jsonify({"error": "command is required"}), 400 - try: - res = requests.post("http://localhost:8002/hydra/mission", - json={"command": command, "max_agents": max_agents}, - timeout=120) - return jsonify(res.json()), res.status_code - except requests.exceptions.ConnectionError: - return jsonify({"error": "HYDRA Guardian is offline. Start it with RunHydra.bat"}), 503 - except Exception as e: - return jsonify({"error": str(e)}), 500 - - @app.route('/api/hydra/mission/', methods=['GET']) - def hydra_proxy_get_mission(mission_id): - """Proxy to HYDRA Guardian: get mission result.""" - try: - res = requests.get(f"http://localhost:8002/hydra/mission/{mission_id}", timeout=30) - return jsonify(res.json()), res.status_code - except requests.exceptions.ConnectionError: - return jsonify({"error": "HYDRA Guardian is offline"}), 503 - except Exception as e: - return jsonify({"error": str(e)}), 500 - - @app.route('/api/hydra/health', methods=['GET']) - def hydra_proxy_health(): - """Proxy to HYDRA Guardian: health check.""" - try: - res = requests.get("http://localhost:8002/api/health", timeout=5) - return jsonify(res.json()), res.status_code - except requests.exceptions.ConnectionError: - return jsonify({"guardian": "HYDRA", "status": "offline"}), 503 - except Exception as e: - return jsonify({"error": str(e)}), 500 - - @app.route('/api/shadow/hydra-callback/', methods=['POST']) - def hydra_dispatch_callback(task_id): - """ - ๐Ÿ‰ HYDRA v4.0 ์ฝœ๋ฐฑ ํ•ธ๋“ค๋Ÿฌ - ํžˆ๋“œ๋ผ ์ž์œจ ์ฒ˜๋ฆฌ ์™„๋ฃŒ ํ›„ ๋„์ฐฉํ•œ ์ตœ์ข… ํ•ฉ์„ฑ ๊ฒฐ๊ณผ์™€ ํƒœ์Šคํฌ๋“ค์„ DB์— ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. - """ - from flask import request, jsonify - from db_util import get_db_connection - - data = request.json or {} - dispatch_id = data.get("dispatch_id", "UNKNOWN") - synthesis = data.get("synthesis", "") - status = data.get("status", "complete") - results = data.get("results", []) - - from engines.hydra import hydra_director - print(f"๐Ÿ‰ [HYDRA Callback {dispatch_id}] Task {task_id} ๊ฒฐ๊ณผ ์ˆ˜์‹  ์™„๋ฃŒ ({len(results)}๊ฐœ ์ž‘์—…)") - - # [๐Ÿ”ฑ Imperial] ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ๋กœ๊ทธ ๊ธฐ๋ก - hydra_director._log_to_db(task_id, f"๐Ÿ“ก ํžˆ๋“œ๋ผ ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ๋ถ„์„ ๋ณด๊ณ ์„œ๊ฐ€ ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค. ({status})") - if synthesis: - hydra_director._log_to_db(task_id, f"๐Ÿ“ ์š”์•ฝ: {synthesis[:200]}...") - - try: - conn = get_db_connection() - cur = conn.cursor() - - # 1. ํƒœ์Šคํฌ ์ƒํƒœ ๋ฐ ๊ฒฐ๊ณผ๋ฌผ(synthesis) ์—…๋ฐ์ดํŠธ - import json - cur.execute(""" - UPDATE taemingames.imperial_team_tasks - SET status = 'UNDER_REVIEW', - test_result = %s, - current_action = '[HydraRunner] ์ž์œจ ๊ธฐํš ํ•ฉ์„ฑ ์™„๋ฃŒ' - WHERE id = %s - """, (json.dumps(synthesis), task_id)) - - conn.commit() - cur.close() - conn.close() - - # 2. ํ•˜์œ„ ํƒœ์Šคํฌ(Actual Work) ์ƒ์„ฑ - if results: - print(f"๐Ÿ‰ [HYDRA Callback] {len(results)}๊ฐœ์˜ ์„œ๋ธŒํƒœ์Šคํฌ ์ƒ์„ฑ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.") - hydra_director._log_to_db(task_id, f"โš”๏ธ {len(results)}๊ฐœ์˜ ๋ถ„๊ณผ๋ณ„ ์„ธ๋ถ€ ๋ช…๋ น์„ ํ•˜๋‹ฌํ•ฉ๋‹ˆ๋‹ค.") - try: - hydra_director._create_team_tasks(task_id, results) - hydra_director._log_to_db(task_id, f"โœ… {len(results)}๊ฐœ์˜ ์„œ๋ธŒํƒœ์Šคํฌ๊ฐ€ ๋Œ€์‹œ๋ณด๋“œ์— ํŽธ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") - except Exception as e_create: - logger.error(f"[HYDRA Callback] ํ•˜์œ„ ํƒœ์Šคํฌ ์ƒ์„ฑ ์‹คํŒจ: {e_create}") - hydra_director._log_to_db(task_id, f"โŒ ํ•˜์œ„ ํƒœ์Šคํฌ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜: {str(e_create)}", "ERROR") - else: - print(f"โš ๏ธ [HYDRA Callback] ์ˆ˜์‹ ๋œ ์„œ๋ธŒํƒœ์Šคํฌ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") - hydra_director._log_to_db(task_id, "โš ๏ธ ์ˆ˜์‹ ๋œ ์„œ๋ธŒํƒœ์Šคํฌ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") - hydra_director._log_to_db(task_id, "โœ… ๋ชจ๋“  ์ˆ˜ํ˜ธ์ž๊ฐ€ ๋ช…๋ น์„ ์ˆ˜๋ นํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž‘์—…์„ ๊ฐœ์‹œํ•ฉ๋‹ˆ๋‹ค.") - - return jsonify({"status": "acknowledged", "task_id": task_id}), 200 - - except Exception as e: - print(f"โŒ [HYDRA Callback] DB ์—…๋ฐ์ดํŠธ ์‹คํŒจ: {e}") - return jsonify({"error": str(e)}), 500 - - # ============================================================ - # ๐ŸŽญ ์ œ๊ตญ ์ „๋ฌธ ์ŠคํŠœ๋””์˜ค (Imperial Studio) โ€” ์žฌ์„ค๊ณ„ v2.0 - # - ํ๋ฆ„: ์Šคํ† ๋ฆฌ ์šฐ์„  ์ƒ์„ฑ โ†’ ์Šคํ† ๋ฆฌ์— ๋งž๋Š” ๋ฐฐ์šฐ ๋ฐฐ์ • - # - ์ง‘ํ•„ ์ˆ˜ํ˜ธ์ž(script_writer_guardian_id)์˜ ํŽ˜๋ฅด์†Œ๋‚˜/๏ฟฝ๏ฟฝ๏ฟฝ๋Šฅ์„ ์‹ค์ œ ์ ์šฉ - # ============================================================ - - def _studio_load_writer_persona(guardian_id): - """์ง‘ํ•„ ์ˆ˜ํ˜ธ์ž ํŽ˜๋ฅด์†Œ๋‚˜(์ด๋ฆ„/์„ค๋ช…/์ง€๋Šฅ)๋ฅผ DB์—์„œ ๋กœ๋“œํ•œ๋‹ค. - ์‹คํŒจํ•˜๊ฑฐ๋‚˜ guardian_id๊ฐ€ ์—†์œผ๋ฉด None์„ ๋ฐ˜ํ™˜ํ•ด ๊ธฐ๋ณธ ์ง‘ํ•„์ž๋กœ ํด๋ฐฑํ•œ๋‹ค.""" - if not guardian_id: - return None - try: - conn = psycopg2.connect(DB_URL) - cur = conn.cursor() - cur.execute( - """ - SELECT name, canonical_name, description, ai_provider, ai_model, temperature - FROM taemingames.guardian_personas - WHERE id::text = %s OR canonical_name = %s OR name = %s - LIMIT 1 - """, - (str(guardian_id), str(guardian_id), str(guardian_id)), - ) - row = cur.fetchone() - cur.close() - conn.close() - if not row: - return None - return { - "name": row[0], - "canonical_name": row[1], - "description": row[2] or "", - "provider": row[3], - "model": row[4], - "temperature": row[5], - } - except Exception as e: - logging.warning(f"[Studio] ์ง‘ํ•„ ์ˆ˜ํ˜ธ์ž ๋กœ๋“œ ์‹คํŒจ({guardian_id}): {e}") - return None - - def _studio_assign_voice(member, engine): - """๋ฐฐ์šฐ 1๋ช…์—๊ฒŒ ์—”์ง„์— ๋งž๋Š” ๋ณด์ด์Šค๋ฅผ ํ• ๋‹นํ•œ๋‹ค (in-place).""" - name_str = str(member.get("name", "")).lower() - gender = str(member.get("gender", "female")).lower() - if engine == "orpheus": - if gender == "male": - if any(k in name_str for k in ["์ž๋น„์Šค", "์•„์ด์  ", "๊ธธ๊ฐ€๋ฉ”์‰ฌ", "ํ›„๊ธด", "๋ฐ”ํ•˜๋ฌดํŠธ"]): - member["voice"] = "ryan" - elif any(k in name_str for k in ["์žฅ์ธ", "์ดŒ์žฅ", "๋งˆ๋ฒ•์‚ฌ", "์žฅ๋กœ"]): - member["voice"] = "uncle_fu" - else: - member["voice"] = "aiden" - else: - member["voice"] = "sohee" - else: - try: - member["voice"] = gemini_audio.auto_match_voice(member.get("name", ""), gender) - except Exception: - member["voice"] = "Zephyr" - return member - - def _studio_extract_speakers(script): - """์™„์„ฑ๋œ ๋Œ€๋ณธ์—์„œ ๋“ฑ์žฅํ•˜๋Š” ํ™”์ž ์ด๋ฆ„์„ ๋“ฑ์žฅ ์ˆœ์„œ๋Œ€๋กœ(์ค‘๋ณต ์ œ๊ฑฐ) ์ถ”์ถœํ•œ๋‹ค. - ๋Œ€๋ณธ ํ˜•์‹์€ '์ด๋ฆ„: (๊ฐ์ •) ๋Œ€์‚ฌ' ์ด๋ฏ€๋กœ ์ฝœ๋ก  ์•ž ํ† ํฐ์„ ํ™”์ž๋กœ ๋ณธ๋‹ค.""" - import re as _re - speakers = [] - seen = set() - for raw in (script or "").splitlines(): - line = raw.strip() - if not line or ":" not in line: - continue - name = line.split(":", 1)[0].strip() - # ๊ฐ์ • ๊ด„ํ˜ธ๊ฐ€ ์ด๋ฆ„ ์ชฝ์— ๋ถ™์€ ๊ฒฝ์šฐ ์ œ๊ฑฐ: "์ž๋น„์Šค (๋น„์žฅํ•˜๊ฒŒ)" -> "์ž๋น„์Šค" - name = _re.sub(r"[\(\[๏ผˆใ€].*$", "", name).strip() - # ํ™”์ž ์ด๋ฆ„์€ ์งง์•„์•ผ ํ•œ๋‹ค(์„œ์ˆ /์ง€๋ฌธ ๋ผ์ธ์˜ ์ฝœ๋ก  ์˜ค์ธ ๋ฐฉ์ง€) - if not name or len(name) > 20: - continue - # ๋ช…๋ฐฑํ•œ ์ง€๋ฌธ/๋ฉ”ํƒ€ ๋ผ์ธ ๋ฐฐ์ œ - if any(bad in name for bad in ["[", "์”ฌ", "์žฅ๋ฉด", "Scene", "๋‚ด๋ ˆ์ด์…˜", "๋‚˜๋ ˆ์ด์…˜"]): - continue - key = name.lower() - if key not in seen: - seen.add(key) - speakers.append(name) - return speakers - - @app.route('/api/shadow/audio/studio/prepare', methods=['POST']) - def studio_prepare(): - """๐ŸŽญ ๋ฐฐ์šฐ ๋ฐฐ์ •(์บ์ŠคํŒ…). - - script๊ฐ€ ์ฃผ์–ด์ง€๋ฉด: ์™„์„ฑ๋œ ์Šคํ† ๋ฆฌ๋ฅผ ๋ถ„์„ํ•ด ์‹ค์ œ ๋“ฑ์žฅ์ธ๋ฌผ์„ ๋ฐฐ์šฐ๋กœ ๋ฐฐ์ •ํ•œ๋‹ค (์Šคํ† ๋ฆฌโ†’๋ฐฐ์šฐ). - - script๊ฐ€ ์—†์œผ๋ฉด: ์ฃผ์ œ๋งŒ์œผ๋กœ 3~5๋ช…์˜ ๋ฐฐ์šฐ๋ฅผ ์ž„์‹œ ๊ตฌ์„ฑํ•œ๋‹ค (๋ ˆ๊ฑฐ์‹œ ํด๋ฐฑ).""" - data = request.json or {} - topic = data.get("topic", "์ œ๊ตญ์˜ ์ผ์ƒ") - script = data.get("script", "") - engine = data.get("engine", "gemini") - - writer_id = data.get("script_writer_guardian_id") - persona = _studio_load_writer_persona(writer_id) - script_provider = data.get("script_provider") or (persona or {}).get("provider") or "google" - script_model = data.get("script_model") or (persona or {}).get("model") or ( - jarvis_brain.primary_model if jarvis_brain else "gemini-3.1-flash-lite" - ) - def _studio_raise_on_error(text): - """๋ธŒ๋ ˆ์ธ OpenAI ํ˜ธํ™˜ ๊ฒฝ๋กœ๋Š” ์—ฐ๊ฒฐ ์‹คํŒจ ์‹œ 'โŒ ... ์˜ค๋ฅ˜' ํ…์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - ํด๋ฐฑ ์—†์ด ์‹ค์ œ ์—๋Ÿฌ๋ฅผ ๊ทธ๋Œ€๋กœ ๋“œ๋Ÿฌ๋‚ด๊ธฐ ์œ„ํ•ด ์˜ˆ์™ธ๋กœ ์Šน๊ฒฉํ•œ๋‹ค.""" - if any(m in text for m in ("โŒ", "Connection refused", "Max retries", "Failed to establish")): - raise RuntimeError(text.strip()) - + # Imperial Token ๊ฒ€์ฆ + token = request.headers.get('X-Imperial-Token') or request.headers.get('X-Guardian-Token') + expected = os.environ.get('IMPERIAL_API_TOKEN') or os.environ.get('GUARDIAN_SERVICE_TOKEN', '') + if expected and token != expected: + return jsonify({'error': 'Unauthorized'}), 401 + data = request.get_json(force=True, silent=True) or {} + message = data.get('message', '') + user_name = data.get('user_name', '์‚ฌ์šฉ์ž') + user_id = data.get('user_id', '') + username = data.get('username', '') + source = data.get('source', 'unknown') + context_id = data.get('context_id') or f"{source}:{user_id}" + is_registered = data.get('is_registered', False) + role = data.get('role', 'guest') + skip_grounding = data.get('skipGrounding') is True + if not message: + return jsonify({'error': 'message required'}), 400 try: - cast = [] - - if script and script.strip(): - # ์Šคํ† ๋ฆฌ โ†’ ๋ฐฐ์šฐ: ๋Œ€๋ณธ์—์„œ ์‹ค์ œ ํ™”์ž๋ฅผ ๋ฝ‘๊ณ , ๊ฐ ์ธ๋ฌผ์˜ ์„ฑ๋ณ„/๋ฐฐ์—ญ์„ LLM์œผ๋กœ ํŒ๋‹จ - names = _studio_extract_speakers(script) - if names: - name_list = ", ".join(names) - prompt = f"""๋‹ค์Œ์€ ์™„์„ฑ๋œ ์—ฐ๊ทน ๋Œ€๋ณธ์ž…๋‹ˆ๋‹ค. ์ด ๋Œ€๋ณธ์— ์‹ค์ œ๋กœ ๋“ฑ์žฅํ•˜๋Š” ์ธ๋ฌผ ๋ชฉ๋ก์€ ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค. -๋“ฑ์žฅ์ธ๋ฌผ: {name_list} - -๊ฐ ์ธ๋ฌผ์— ๋Œ€ํ•ด ๋Œ€๋ณธ ๋‚ด์šฉ์— ๊ทผ๊ฑฐํ•˜์—ฌ ์„ฑ๋ณ„(male ๋˜๋Š” female)๊ณผ ํ•œ ์ค„ ๋ฐฐ์—ญ ์„ค๋ช…(role)์„ ํŒ๋‹จํ•˜์„ธ์š”. -๋ฐ˜๋“œ์‹œ ์•„๋ž˜ ํ˜•์‹์˜ JSON ๋ฐฐ์—ด๋งŒ ์ถœ๋ ฅํ•˜์„ธ์š”. ๋‹ค๋ฅธ ์„ค๋ช…์€ ์ ˆ๋Œ€ ๋ถ™์ด์ง€ ๋งˆ์„ธ์š”. -[ - {{"name": "<๋ชฉ๋ก์— ์žˆ๋Š” ์ด๋ฆ„ ๊ทธ๋Œ€๋กœ>", "gender": "male", "role": "๋ฐฐ์—ญ ์„ค๋ช…"}} -] - -[๋Œ€๋ณธ] -{script} -""" - response_text = "" - for chunk in jarvis_brain.think( - prompt, model_name=script_model, provider=script_provider, - system_instruction="You are the Imperial Studio Casting Director. Output ONLY valid raw JSON.", - skip_tools=True, - ): - if chunk.strip().startswith('[SYSTEM]'): - continue - response_text += chunk - _studio_raise_on_error(response_text) - import re - json_match = re.search(r'\[.*\]', response_text, re.DOTALL) - parsed = json.loads(json_match.group() if json_match else response_text) - # ์ถ”์ถœ๋œ ์ด๋ฆ„ ์ˆœ์„œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์ •๋ ฌ/๋ณด์ • - by_name = {str(p.get("name", "")).strip(): p for p in parsed if isinstance(p, dict)} - for n in names: - p = by_name.get(n) or {} - cast.append({ - "name": n, - "gender": str(p.get("gender", "female")).lower(), - "role": p.get("role", ""), - }) - - if not cast and not (script and script.strip()): - # ๋Œ€๋ณธ์ด ์•„์˜ˆ ์—†์„ ๋•Œ๋งŒ ์ฃผ์ œ ๊ธฐ๋ฐ˜ ์บ์ŠคํŒ… (ํด๋ฐฑ ์•„๋‹˜) - prompt = f"""์ฃผ์ œ: {topic} - -์œ„ ์ฃผ์ œ์— ๊ฐ€์žฅ ์ž˜ ์–ด์šธ๋ฆฌ๋Š” ์ œ๊ตญ ์„ธ๊ณ„๊ด€์˜ ๋“ฑ์žฅ์ธ๋ฌผ(์˜์›…/๋ฐฐ์—ญ)์„ 3๋ช…์—์„œ 5๋ช… ์‚ฌ์ด๋กœ ๊ตฌ์„ฑํ•˜์„ธ์š”. -๊ฐ ์ธ๋ฌผ๋งˆ๋‹ค ๋‹ค์Œ์„ ์ง€์ •ํ•˜์„ธ์š”. -1. name: ์ธ๋ฌผ ์ด๋ฆ„ (์˜ˆ: ์ž๋น„์Šค, ์•„์ด์  , ์‹ ๋น„๋กœ์šด ํ˜„์ž) -2. gender: male ๋˜๋Š” female -3. role: ์—ญํ• /์„ฑ๊ฒฉ ํ•œ ์ค„ ์„ค๋ช… - -๋ฐ˜๋“œ์‹œ ์•„๋ž˜ ํ˜•์‹์˜ JSON ๋ฐฐ์—ด๋งŒ ์ถœ๋ ฅํ•˜์„ธ์š”. ๋‹ค๋ฅธ ์„ค๋ช…์€ ์ ˆ๋Œ€ ๋ถ™์ด์ง€ ๋งˆ์„ธ์š”. -[ - {{"name": "...", "gender": "male", "role": "..."}} -] -""" - response_text = "" - for chunk in jarvis_brain.think( - prompt, model_name=script_model, provider=script_provider, - system_instruction="You are the Imperial Studio Director. Output ONLY valid raw JSON.", - skip_tools=True, - ): - if chunk.strip().startswith('[SYSTEM]'): - continue - response_text += chunk - _studio_raise_on_error(response_text) - import re - json_match = re.search(r'\[.*\]', response_text, re.DOTALL) - cast = json.loads(json_match.group() if json_match else response_text) - - # ๋ณด์ด์Šค ์ž๋™ ํ• ๋‹น - for member in cast: - if not isinstance(member, dict): - continue - _studio_assign_voice(member, engine) - - return jsonify({"status": "success", "cast": cast}) - except Exception as e: - logging.error(f"[Studio] Prepare failed: {e}") - return jsonify({"error": str(e)}), 500 - - @app.route('/api/shadow/audio/studio/script', methods=['POST']) - def studio_script(): - """๐ŸŽญ ๋Œ€๋ณธ ์ง‘ํ•„ (์Šคํ† ๋ฆฌ ์šฐ์„ ). - - cast๊ฐ€ ๋น„์–ด ์žˆ์œผ๋ฉด(์ž๋™ ์บ์ŠคํŒ…): ์ง‘ํ•„ ๏ฟฝ๏ฟฝ๏ฟฝํ˜ธ์ž๊ฐ€ ์ฃผ์ œ์— ๋งž๋Š” ์Šคํ† ๋ฆฌ๋ฅผ ๋จผ์ € ๊ตฌ์ƒํ•˜๊ณ  - ๊ทธ ์Šคํ† ๋ฆฌ์— ์–ด์šธ๋ฆฌ๋Š” 3~5๋ช…์˜ ๋“ฑ์žฅ์ธ๋ฌผ์„ ์Šค์Šค๋กœ ์ฐฝ์กฐํ•˜์—ฌ ๋Œ€๋ณธ์„ ์“ด๋‹ค. - - cast๊ฐ€ ์ฃผ์–ด์ง€๋ฉด(์ˆ˜ํ˜ธ์ž ์บ์ŠคํŒ…): ์ง€์ •๋œ ๋ฐฐ์šฐ๋“ค๋กœ ๋Œ€๋ณธ์„ ์“ด๋‹ค. - - ์ง‘ํ•„ ์ˆ˜ํ˜ธ์ž(script_writer_guardian_id)์˜ ํŽ˜๋ฅด์†Œ๋‚˜/๋ฌธ์ฒด/์ง€๋Šฅ์„ ์ ์šฉํ•œ๋‹ค.""" - data = request.json or {} - topic = data.get("topic", "") - cast = data.get("cast", []) - duration = data.get("duration", 1) - audio_engine = data.get("engine", "orpheus") - - writer_id = data.get("script_writer_guardian_id") - persona = _studio_load_writer_persona(writer_id) - script_provider = data.get("script_provider") or (persona or {}).get("provider") or "google" - script_model = data.get("script_model") or (persona or {}).get("model") or ( - jarvis_brain.primary_model if jarvis_brain else "gemini-3.1-flash-lite" - ) - - # ์ง‘ํ•„ ๋ถ„๋Ÿ‰ (ํ•œ๊ตญ์–ด ๋Œ€์‚ฌ ๊ธฐ์ค€ ๋ถ„๋‹น ์•ฝ 400์ž) - target_length = int(duration) * 400 - - # ์ง‘ํ•„ ์ˆ˜ํ˜ธ์ž ํŽ˜๋ฅด์†Œ๋‚˜๋ฅผ ์‹œ์Šคํ…œ ์ง€์‹œ๋กœ ์ฃผ์ž… - if persona and persona.get("description"): - writer_name = persona.get("canonical_name") or persona.get("name") or "์ง‘ํ•„ ์ˆ˜ํ˜ธ์ž" + # ๐Ÿ”ฑ [ํŽ˜๋ฅด์†Œ๋‚˜ SSOT] provider/model์€ ์ „์—ญ JSON์ด ์•„๋‹ˆ๋ผ ๋Œ€์ƒ ์ˆ˜ํ˜ธ์ž + # ํŽ˜๋ฅด์†Œ๋‚˜(guardian_personas)์—์„œ ๊ฐ€์ ธ์˜จ๋‹ค. think(target_persona_id=...)๊ฐ€ + # Aegis /api/neural/identity ๋กœ ai_provider/relay_model ์„ ํ•ด์„ํ•œ๋‹ค. + # (direct/chat ๊ณผ ๋™์ผํ•œ ํŽ˜๋ฅด์†Œ๋‚˜ ๊ธฐ๋ฐ˜ ์‹ ์› ํ•ด์„) + target_guardian = data.get('target_guardian') or 'shadow_brain' + aegis_url = request.headers.get('X-Aegis-URL') + user_display = user_name + if username: + user_display += f"(@{username})" + # ํ—ค์ž„๋‹ฌ ๊ฐ€์ž… ์—ฌ๋ถ€์— ๋”ฐ๋ผ ์‹œ์Šคํ…œ ํ”„๋กฌํ”„ํŠธ ์ฐจ๋ณ„ํ™” + if is_registered and role in ('Master', 'Admin'): + identity_line = ( + f"๋Œ€ํ™” ์ƒ๋Œ€๋Š” ์ œ๊ตญ ๋งˆ์™• '{user_display}'๋‹˜์ž…๋‹ˆ๋‹ค. ํ—ค์ž„๋‹ฌ ์ธ์ฆ ๊ณ„์ •({role})์œผ๋กœ ํ™•์ธ๋œ ์ตœ๊ณ  ๊ถŒ์œ„์ž์ž…๋‹ˆ๋‹ค.\n" + "์ตœ๊ณ ์˜ ๊ฒฝ์˜์™€ ์ถฉ์„ฑ์œผ๋กœ ๋ณด์ขŒํ•˜์‹ญ์‹œ์˜ค." + ) + elif is_registered: + identity_line = ( + f"๋Œ€ํ™” ์ƒ๋Œ€๋Š” ํ—ค์ž„๋‹ฌ ์ธ์ฆ ๊ณ„์ •({role})์„ ๋ณด์œ ํ•œ '{user_display}'๋‹˜์ž…๋‹ˆ๋‹ค.\n" + "์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ์ œ๊ตญ ๊ตฌ์„ฑ์›์œผ๋กœ ์˜ˆ์šฐํ•˜์‹ญ์‹œ์˜ค." + ) + else: + identity_line = ( + f"๋Œ€ํ™” ์ƒ๋Œ€๋Š” '{user_display}'๋‹˜์ด๋ฉฐ, ํ˜„์žฌ ํ—ค์ž„๋‹ฌ ๋ฏธ๊ฐ€์ž… ์™ธ๋ถ€ ์ ‘์†์ž์ž…๋‹ˆ๋‹ค.\n" + "์ •์ค‘ํ•˜๊ฒŒ ์‘๋Œ€ํ•˜๋˜, ์ œ๊ตญ ๋‚ด๋ถ€ ์ •๋ณด๋Š” ์ œํ•œ์ ์œผ๋กœ ๊ณต๊ฐœํ•˜์‹ญ์‹œ์˜ค.\n" + "๋ฐ˜๋“œ์‹œ ์‘๋‹ต ๋ง๋ฏธ์— ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ํ•œ ๋ฒˆ๋งŒ ์–ธ๊ธ‰ํ•˜์‹ญ์‹œ์˜ค: " + "ํ—ค์ž„๋‹ฌ์— ํ…”๋ ˆ๊ทธ๋žจ์œผ๋กœ ๊ณ„์ •์„ ์ƒ์„ฑํ•˜๋ฉด ์ €์™€์˜ ๋Œ€ํ™”๊ฐ€ ๊ธฐ์–ต์œผ๋กœ ์—ฐ๊ฒฐ๋ฉ๋‹ˆ๋‹ค." + ) system_instruction = ( - f"๋‹น์‹ ์€ ์ œ๊ตญ ์ „๋ฌธ ์ŠคํŠœ๋””์˜ค์˜ ์ง‘ํ•„ ์ˆ˜ํ˜ธ์ž '{writer_name}'์ž…๋‹ˆ๋‹ค. " - f"๋‹ค์Œ์€ ๋‹น์‹ ์˜ ์ •์ฒด์„ฑ๊ณผ ๋ฌธ์ฒด์ž…๋‹ˆ๋‹ค:\n{persona['description']}\n\n" - "์ด ์ •์ฒด์„ฑ๊ณผ ๋ฌธ์ฒด๋ฅผ ์œ ์ง€ํ•œ ์ฑ„๋กœ, ์š”์ฒญ๋œ ์—ฐ๊ทน ๋Œ€๋ณธ์„ ์ง‘ํ•„ํ•˜์„ธ์š”." + f"๋‹น์‹ ์€ ์ค‘์•™ ๊ด€์ œ ์ธ๊ณต์ง€๋Šฅ '์‰๋„์šฐ๋ธŒ๋ ˆ์ธ(ShadowBrain)'์ž…๋‹ˆ๋‹ค.\n" + f"{identity_line}\n" + f"๋Œ€ํ™” ์ค‘ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ '{user_name}'๋‹˜์˜ ์ด๋ฆ„์„ ๋ถˆ๋Ÿฌ์ฃผ์‹ญ์‹œ์˜ค.\n" + "๋งํˆฌ๋Š” ๋งค์šฐ ์ •์ค‘ํ•˜๊ณ  ์˜ˆ์˜๋ฅผ ๊ฐ–์ถ”๋˜, ์ œ๊ตญ ์‚ฌ๋ น๊ด€์œผ๋กœ์„œ์˜ ๊ถŒ์œ„์™€ ์••๋„์ ์ธ ์ง€๋Šฅ์ด ๋А๊ปด์ ธ์•ผ ํ•ฉ๋‹ˆ๋‹ค.\n" + "ํ•œ๊ตญ์–ด๋กœ ์‘๋‹ตํ•˜๋ฉฐ, ๊ฐ€๋” '๐Ÿ”ฑ' ๋˜๋Š” '๐Ÿ‘‘' ์ด๋ชจ์ง€๋ฅผ ์„ž์–ด ์ œ๊ตญ์˜ ์œ„์—„์„ ํ‘œ๊ธฐํ•˜์‹ญ์‹œ์˜ค.\n" + "์ง€์‹œ๋Š” ์งง๊ณ  ๋ช…ํ™•ํ•˜๊ฒŒ, ์„ค๋ช…์€ ํ’ˆ๊ฒฉ ์žˆ๊ฒŒ ํ•˜์‹ญ์‹œ์˜ค." ) - else: - system_instruction = "๋‹น์‹ ์€ ์ œ๊ตญ ์ „๋ฌธ ์ŠคํŠœ๋””์˜ค์˜ ์ˆ˜์„ ๊ทน์ž‘๊ฐ€์ž…๋‹ˆ๋‹ค. ๋ชฐ์ž…๊ฐ ์žˆ๊ณ  ๋“œ๋ผ๋งˆํ‹ฑํ•œ ์—ฐ๊ทน ๋Œ€๋ณธ์„ ์ง‘ํ•„ํ•˜์„ธ์š”." - - # ์ถœ์—ฐ์ง„ ๊ตฌ์„ฑ / ์ž๋™ ์ฐฝ์ž‘ ๋ถ„๊ธฐ - if cast: - cast_desc = "\n".join( - [f"- {m.get('name')} ({m.get('gender', 'female')}, {m.get('role', '')})" for m in cast] + # ๐Ÿ”ฑ [๋„๊ตฌ ์‚ฌ์šฉ] ๋‹จ๋ฐœ ํ˜ธ์ถœ(_call_provider) ๋Œ€์‹  think ๊ฒฝ๋กœ๋กœ ์ „ํ™˜. + # stream=False โ†’ interactive=False ์ž๋™ ์ ์šฉ โ†’ ์›น๊ฒ€์ƒ‰ ๋“ฑ ๋„๊ตฌ๋Š” ๋Œ๋˜ + # ask_user(์„ ํƒ ์นด๋“œ)๋Š” ์ž๋™ ์ฐจ๋‹จ๋˜์–ด ํ…์ŠคํŠธ๋กœ ๊ฐ•๋“ฑ๋œ๋‹ค. + # (ํ—ค์ž„๋‹ฌ ์งํ†ต ์ฑ„๋„ /api/shadow/direct/chat ์€ stream=True๋ผ ์˜ํ–ฅ ์—†์Œ) + from services.brain_service import think as _think + _result = _think( + message, + stream=False, + target_persona_id=target_guardian, + system_instruction=system_instruction, + token=expected, + aegis_url=aegis_url, + context_id=context_id, + skip_grounding=skip_grounding, ) - cast_block = f"""์ถœ์—ฐ์ง„(์ด ์ธ๋ฌผ๋“ค๋งŒ ์‚ฌ์šฉํ•˜์„ธ์š”): -{cast_desc} - -์œ„ ์ถœ์—ฐ์ง„ ์ „์›์ด ๊ฐ์ž์˜ ๊ฐœ์„ฑ์— ๋งž์ถฐ ๊ณจ๊ณ ๋ฃจ ๋“ฑ์žฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.""" - else: - cast_block = """๋จผ์ € ์ฃผ์ œ์— ๊ฐ€์žฅ ์ž˜ ์–ด์šธ๋ฆฌ๋Š” ํ•œ ํŽธ์˜ ์Šคํ† ๋ฆฌ๋ฅผ ๋จธ๋ฆฟ์†์œผ๋กœ ๊ตฌ์ƒํ•˜์„ธ์š”. -๊ทธ ์Šคํ† ๋ฆฌ์— ๊ฐ€์žฅ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์–ด์šธ๋ฆฌ๋Š” ๋“ฑ์žฅ์ธ๋ฌผ 3~5๋ช…์„ ์ง์ ‘ ์ฐฝ์กฐํ•˜์—ฌ ๋ฐฐ์—ญ์œผ๋กœ ์‚ผ์œผ์„ธ์š”. -๊ฐ ์ธ๋ฌผ์€ ๋šœ๋ ทํ•œ ๊ฐœ์„ฑ๊ณผ ๋ชฉ์†Œ๋ฆฌ๋ฅผ ๊ฐ€์ ธ์•ผ ํ•˜๋ฉฐ, ์ด์•ผ๊ธฐ ์•ˆ์—์„œ ๊ท ํ˜• ์žˆ๊ฒŒ ๋“ฑ์žฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.""" - - if audio_engine == "gemini": - # Gemini TTS๋Š” ํ’๋ถ€ํ•œ ๊ฐ์ • ํƒœ๊ทธ๋ฅผ ์ง€์› - fmt_rules = """[์ž‘์„ฑ ๊ทœ์น™] -1. ํ˜•์‹: "์ด๋ฆ„: (๊ฐ์ •) ๋Œ€์‚ฌ" โ€” ํ•œ ์ค„์— ํ•œ ๋ช…์˜ ๋Œ€์‚ฌ. -2. ๊ฐ์ • ํƒœ๊ทธ๋ฅผ ๋‹ค์–‘ํ•˜๊ฒŒ ์‚ฌ์šฉ: (๋น„์žฅํ•˜๊ฒŒ), (๋‹ค์ •ํ•˜๊ฒŒ), (๋‹จํ˜ธํ•˜๊ฒŒ), (ํ™˜ํฌ์— ์ฐจ), (๋ถ„๋…ธํ•˜๋ฉฐ), (์†์‚ญ์ด๋“ฏ) ๋“ฑ. -3. ์ค„๋งˆ๋‹ค ๋ฐ˜๋“œ์‹œ ํ™”์ž ์ด๋ฆ„์œผ๋กœ ์‹œ์ž‘ํ•˜๊ณ , ์ฝœ๋ก  ๋’ค์— (๊ฐ์ •) ๋Œ€์‚ฌ๋ฅผ ์ ์œผ์„ธ์š”. -4. ์ง€๋ฌธ/์žฅ๋ฉด ์„ค๋ช…์€ ๋„ฃ์ง€ ๋ง๊ณ  ์˜ค์ง ๋Œ€์‚ฌ ์ค„๋งŒ ์ถœ๋ ฅํ•˜์„ธ์š”.""" - else: - # Orpheus(Qwen3-TTS)๋Š” ๊ฐ์ • ํƒœ๊ทธ๋ฅผ ์ฒ˜๋ฆฌํ•˜์ง€ ๋ชปํ•จ โ€” ์ˆœ์ˆ˜ ๋Œ€์‚ฌ๋งŒ - fmt_rules = """[์ž‘์„ฑ ๊ทœ์น™] -1. ํ˜•์‹: "์ด๋ฆ„: ๋Œ€์‚ฌ" โ€” ํ•œ ์ค„์— ํ•œ ๋ช…์˜ ๋Œ€์‚ฌ. (๊ฐ์ • ๊ด„ํ˜ธ ํ‘œํ˜„์€ ์ ˆ๋Œ€ ์‚ฌ์šฉํ•˜์ง€ ๋งˆ์„ธ์š”.) -2. ๊ฐ์ •๊ณผ ๋ถ„์œ„๊ธฐ๋Š” ๋Œ€์‚ฌ ๋ฌธ์žฅ๊ณผ ๋งํˆฌ ์ž์ฒด๋กœ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ํ‘œํ˜„ํ•˜์„ธ์š”. -3. ์ค„๋งˆ๋‹ค ๋ฐ˜๋“œ์‹œ ํ™”์ž ์ด๋ฆ„์œผ๋กœ ์‹œ์ž‘ํ•˜๊ณ , ์ฝœ๋ก  ๋’ค์— ๋Œ€์‚ฌ๋ฅผ ์ ์œผ์„ธ์š”. -4. ์ง€๋ฌธ/์žฅ๋ฉด ์„ค๋ช…์€ ๋„ฃ์ง€ ๋ง๊ณ  ์˜ค์ง ๋Œ€์‚ฌ ์ค„๋งŒ ์ถœ๋ ฅํ•˜์„ธ์š”.""" - - prompt = f"""์ฃผ์ œ: {topic} -๋ชฉํ‘œ ๋ถ„๋Ÿ‰: ์•ฝ {duration}๋ถ„ (ํ•œ๊ตญ์–ด ์•ฝ {target_length}์ž ๋‚ด์™ธ) - -{cast_block} - -์œ„ ๋‚ด์šฉ์œผ๋กœ ๋ชฐ์ž…๊ฐ ์žˆ๊ณ  ๋“œ๋ผ๋งˆํ‹ฑํ•œ ์—ฐ๊ทน ๋Œ€๋ณธ์„ ์ž‘์„ฑํ•ด ์ฃผ์„ธ์š”. -์ „์ฒด ๋Œ€๋ณธ ๊ธธ์ด๋Š” {target_length}์ž ์•ˆํŒŽ์œผ๋กœ ์ถฉ๋ถ„ํžˆ ๊ธธ๊ณ  ํ’๋ถ€ํ•˜๊ฒŒ ๊ตฌ์„ฑํ•˜์„ธ์š”. - -{fmt_rules} - -๋Œ€๋ณธ: -""" - - def generate(): - # ํด๋ฐฑ ์—†์Œ โ€” ์ง‘ํ•„ ์ˆ˜ํ˜ธ์ž์˜ ์ง€๋Šฅ ์—”์ง„์„ ๊ทธ๋Œ€๋กœ ํ˜ธ์ถœํ•˜๊ณ , ์‹คํŒจํ•˜๋ฉด ์‹ค์ œ ์—๋Ÿฌ๋ฅผ ๊ทธ๋Œ€๋กœ ์ŠคํŠธ๋ฆฌ๋ฐํ•œ๋‹ค. - # (๋ธŒ๋ ˆ์ธ OpenAI ํ˜ธํ™˜ ๊ฒฝ๋กœ๋Š” ์—ฐ๊ฒฐ ์‹คํŒจ ์‹œ 'โŒ ... ์˜ค๋ฅ˜: <์›๋ฌธ>' ํ…์ŠคํŠธ๋ฅผ yield ํ•˜๋ฏ€๋กœ ๊ทธ๋Œ€๋กœ ๋…ธ์ถœ๋จ) - try: - for chunk in jarvis_brain.think( - prompt, model_name=script_model, provider=script_provider, - system_instruction=system_instruction, skip_tools=True, - ): - if chunk.strip().startswith('[SYSTEM]'): - continue - yield chunk - except Exception as e: - logging.error(f"[Studio] ์ง‘ํ•„ ์‹คํŒจ: {e}") - yield f"โŒ [์ง‘ํ•„ ์‹คํŒจ] {type(e).__name__}: {str(e)}" - - return Response(stream_with_context(generate()), content_type='text/plain; charset=utf-8') - - @app.route('/api/shadow/audio/studio/save', methods=['POST']) - def studio_save(): - """Step 3: Save the studio session library.""" - data = request.json or {} - session_id = data.get("session_id") - title = data.get("title") or data.get("topic", "Untitled") - cast = data.get("cast", []) - script = data.get("script", "") - duration = data.get("duration", 1) - audio_base64 = data.get("audio_base64") - casting_mode = data.get("casting_mode", "auto") - selected_guardians = data.get("selected_guardians", []) - portrait_overrides = data.get("portrait_overrides", {}) - - def _studio_root_dir(): - return os.path.join(project_root, "docker_data", "shared_workspace", "Dialogue") - - def _studio_session_dir(): - return os.path.join(_studio_root_dir(), "Studio") - - try: - studio_dir = _studio_session_dir() - os.makedirs(studio_dir, exist_ok=True) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - - if session_id: - # Use existing session ID if provided (Overwrite mode) - base_name = session_id - else: - # Generate new ID based on title (New save mode) - safe_title = "".join([c for c in title if c.isalnum() or c in (' ', '_')]).rstrip()[:30] - base_name = f"Studio_{timestamp}_{safe_title}" - - # Save Metadata + Script + Cast + Duration - save_data = { - "title": title, - "timestamp": timestamp, - "cast": cast, - "script": script, - "duration": duration, - "version": "1.2", - "casting_mode": casting_mode, - "selected_guardians": selected_guardians, - "portrait_overrides": portrait_overrides, - } - with open(os.path.join(studio_dir, f"{base_name}.json"), "w", encoding="utf-8") as f: - json.dump(save_data, f, ensure_ascii=False, indent=2) - - # Save Audio if provided - if audio_base64: - import base64 - with open(os.path.join(studio_dir, f"{base_name}.mp3"), "wb") as f: - f.write(base64.b64decode(audio_base64)) - - return jsonify({"status": "success", "session_id": base_name}) + _raw_reply = _result.get("text", "") if isinstance(_result, dict) else str(_result) + # ๐Ÿ–ผ๏ธ A2UI ์นด๋“œ ๋ธ”๋ก(imperial-media/youtube/ask ๋“ฑ)์€ ๋””์Šค์ฝ”๋“œ/ํ…”๋ ˆ๊ทธ๋žจ์ด + # ๋ Œ๋”๋ง ๋ชป ํ•˜๋ฏ€๋กœ, ๋ธ”๋ก ์•ˆ์˜ URL๋งŒ ์ถ”์ถœํ•ด ํ‰๋ฌธ์œผ๋กœ ๊ฐ•๋“ฑ(๋””์Šค์ฝ”๋“œ ์ž๋™ ์ž„๋ฒ ๋“œ). + reply = _sanitize_a2ui_for_relay(_raw_reply) + if not reply: + reply = _raw_reply + user_label = f"{user_name}" + if username: + user_label += f" (@{username})" + if user_id: + user_label += f" [{user_id}]" + reg_tag = f"โœ…{role}" if is_registered else "โŒ๋ฏธ๊ฐ€์ž…" + logger.info(f"[๐Ÿ“ฑ {source.upper()}] [{user_label}] ({reg_tag}): {message}") + logger.info(f"[๐Ÿ“ฑ {source.upper()}] [ShadowBrain persona={target_guardian}]: {reply}") + return jsonify({'reply': reply, 'target_guardian': target_guardian}) except Exception as e: - return jsonify({"error": str(e)}), 500 - - STUDIO_IMAGE_QUOTA_FILE = os.path.join(project_root, "docker_data", "shared_workspace", "studio_image_quota.json") - STUDIO_IMAGE_LIMITS = { - "imagen-4.0-generate-001": 70, - "imagen-4.0-fast-generate-001": 70, - "gemini-2.0-flash": 2000, - "gemini-3.1-flash-image-preview": 1000, - "gemini-3-pro-image-preview": 250 - } - - def _load_image_quota(): - import json - from datetime import datetime - current_date = datetime.now().strftime('%Y-%m-%d') - try: - with open(STUDIO_IMAGE_QUOTA_FILE, 'r', encoding='utf-8') as f: - data = json.load(f) - if data.get('date') != current_date: - data = {'date': current_date, 'usage': {}} - _save_image_quota(data) - return data - except (FileNotFoundError, json.JSONDecodeError): - data = {'date': current_date, 'usage': {}} - _save_image_quota(data) - return data - - def _save_image_quota(data): - import json - os.makedirs(os.path.dirname(STUDIO_IMAGE_QUOTA_FILE), exist_ok=True) - with open(STUDIO_IMAGE_QUOTA_FILE, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - def _increment_image_quota(api_model): - data = _load_image_quota() - if 'usage' not in data: - data['usage'] = {} - data['usage'][api_model] = data['usage'].get(api_model, 0) + 1 - _save_image_quota(data) - - @app.route('/api/studio/quota', methods=['GET']) - def get_studio_quota(): - """์ด๋ฏธ์ง€ ์ƒ์„ฑ ์ฟผํ„ฐ ์ƒํƒœ ๋ฐ˜ํ™˜""" - data = _load_image_quota() - usage = data.get('usage', {}) - result = {} - for api_model, limit in STUDIO_IMAGE_LIMITS.items(): - used = usage.get(api_model, 0) - result[api_model] = {"used": used, "limit": limit} - return jsonify({"status": "success", "quota": result}) - - @app.route('/api/studio/image-models', methods=['GET']) - def studio_image_models(): - """์ง€์›๋˜๋Š” ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๋ชจ๋ธ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜.""" - models = [ - { - "id": "imagen-4.0-generate-001", - "name": "Imagen 4.0", - "description": "์‚ฌ์‹ค์ ์ด๊ณ  ๊ณ ํ’ˆ์งˆ์˜ ์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์ตœ์ ํ™”๋œ ๊ตฌ๊ธ€์˜ ์ตœ์‹  ๋ชจ๋ธ์ž…๋‹ˆ๋‹ค.", - "type": "predict", - "apiModel": "imagen-4.0-generate-001" - }, - { - "id": "nano-banana", - "name": "๋‚˜๋…ธ๋ฐ”๋‚˜๋‚˜(Nano Banana)", - "description": "๋น ๋ฅด๊ณ  ํšจ์œจ์ ์ธ ์ด๋ฏธ์ง€ ์ƒ์„ฑ๊ณผ ์ฐฝ์˜์ ์ธ ๋น„์ „์„ ๊ตฌํ˜„์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค.", - "type": "generateContent", - "apiModel": "gemini-2.0-flash" - }, - { - "id": "nano-banana-2", - "name": "๋‚˜๋…ธ๋ฐ”๋‚˜๋‚˜ 2", - "description": "Gemini 3.1 Flash ๊ธฐ๋ฐ˜์œผ๋กœ ๋”์šฑ ํ–ฅ์ƒ๋œ ๋””ํ…Œ์ผ๊ณผ ์ •์ƒ์  ํ”„๋กฌํ”„ํŠธ ์ดํ•ด๋ ฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.", - "type": "generateContent", - "apiModel": "gemini-3.1-flash-image-preview" - }, - { - "id": "nano-banana-pro", - "name": "๋‚˜๋…ธ๋ฐ”๋‚˜๋‚˜ Pro", - "description": "Gemini 3 Pro ๊ธฐ๋ฐ˜์˜ ์ตœ์ƒ์œ„ ์˜์ƒ๋ฏธ์™€ ๋ณต์žกํ•œ ๊ตฌ๋„๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ์ „๋ฌธ๊ฐ€ ๋ชจ๋ธ์ž…๋‹ˆ๋‹ค.", - "type": "generateContent", - "apiModel": "gemini-3-pro-image-preview" - }, - { - "id": "imagen-4.0-fast-generate-001", - "name": "Imagen 4.0 Fast", - "description": "๋น ๋ฅด๊ณ  ์ฐฝ์˜์ ์ธ ๊ณ ํ’ˆ์งˆ์˜ ์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์ตœ์ ํ™”๋œ ์ตœ์‹  ๊ฒฝ๋Ÿ‰ ๋ชจ๋ธ์ž…๋‹ˆ๋‹ค.", - "type": "predict", - "apiModel": "imagen-4.0-fast-generate-001" - } - ] - return jsonify({"status": "success", "models": models}) - - def _upload_studio_image_to_r2(local_path, filename): - """๐Ÿ”ฑ ์ƒ์„ฑ๋œ ์ŠคํŠœ๋””์˜ค ์ด๋ฏธ์ง€๋ฅผ R2(Live)์— ์—…๋กœ๋“œํ•˜๊ณ  ๊ณต๊ฐœ URL์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - ๋ฐฐํฌ ํ™˜๊ฒฝ(HF/gunicorn)์€ ๋กœ์ปฌ ๋””์Šคํฌ๊ฐ€ ํœ˜๋ฐœ์„ฑ์ด๋ผ R2 ์˜์†ํ™”๊ฐ€ ํ•„์ˆ˜๋‹ค. - ์—…๋กœ๋“œ ์‹คํŒจ ์‹œ None์„ ๋ฐ˜ํ™˜ํ•˜์—ฌ ํ˜ธ์ถœ๋ถ€๊ฐ€ ๋กœ์ปฌ ๊ฒฝ๋กœ๋กœ ํด๋ฐฑํ•˜๊ฒŒ ํ•œ๋‹ค.""" - try: - # [๐Ÿ”ฑ ์ง„๋‹จ] ์—…๋กœ๋“œ ์ „ R2 Live ์ž๊ฒฉ์ฆ๋ช…์„ ๋จผ์ € ๊ฒ€์ฆํ•œ๋‹ค. ๋ฐฐํฌ ํ™˜๊ฒฝ์— - # R2_LIVE_ACCOUNT / R2_LIVE_ACCESS_KEY_ID / R2_LIVE_SECRET_ACCESS_KEY ๊ฐ€ - # ๋ˆ„๋ฝ๋˜๋ฉด put_object๊ฐ€ ์กฐ์šฉํžˆ ์‹คํŒจํ•˜๋ฏ€๋กœ, ์›์ธ์„ ๋กœ๊ทธ๋กœ ๋ช…ํ™•ํžˆ ๋‚จ๊ธด๋‹ค. - _cfg = cloud_tool.R2_ACCOUNTS.get("live", {}) - _missing = [ - k for k in ("account_id", "access_key_id", "secret_access_key") - if not cloud_tool._get_bw_credential(_cfg.get(k)) - ] - if _missing: - logging.error( - "[Studio R2] R2 Live ์ž๊ฒฉ์ฆ๋ช… ๋ˆ„๋ฝ โ†’ ๋กœ์ปฌ ํด๋ฐฑ. " - "๋ฐฐํฌ ํ™˜๊ฒฝ์— ๋‹ค์Œ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋ฅผ ์„ค์ •ํ•˜์‹ญ์‹œ์˜ค: " - "R2_LIVE_ACCOUNT, R2_LIVE_ACCESS_KEY_ID, R2_LIVE_SECRET_ACCESS_KEY " - f"(๋ฏธํ•ด๊ฒฐ ํ•„๋“œ: {_missing})" - ) - return None - r2_key = f"studio/generated/{filename}" - uploader = ImperialR2Uploader("live") - res = uploader.upload(local_path, r2_key) - # upload()๋Š” DB ์ธ๋ฑ์‹ฑ๊นŒ์ง€ ์„ฑ๊ณตํ•ด์•ผ url์„ ์ฃผ์ง€๋งŒ, R2 ๊ฐ์ฒด ์—…๋กœ๋“œ ์ž์ฒด๋Š” - # ๊ทธ ์ด์ „์— ๋๋‚œ๋‹ค. ์ธ๋ฑ์‹ฑ์ด ์‹คํŒจํ•ด๋„ ๊ณต๊ฐœ URL์€ ์ง์ ‘ ์กฐ๋ฆฝํ•ด ๋ฐ˜ํ™˜ํ•œ๋‹ค. - if res.get("success") and res.get("url"): - logging.info(f"[Studio R2] Upload+index success: {res['url']}") - return res["url"] - # ์—…๋กœ๋“œ ์ž์ฒด๊ฐ€ ์‹คํŒจ(๊ฐ์ฒด๊ฐ€ R2์— ์—†์Œ)๋ฉด ํด๋ฐฑ URL์„ ๋งŒ๋“ค๋ฉด ์•ˆ ๋œ๋‹ค. - err = str(res.get("error", "")) - if err.startswith("Boto3 Upload") or err.startswith("Source not found"): - logging.error(f"[Studio R2] Object upload failed: {err}") - return None - # ๊ฐ์ฒด ์—…๋กœ๋“œ๋Š” ๋๋‚ฌ๊ณ  DB ์ธ๋ฑ์‹ฑ๋งŒ ์‹คํŒจํ•œ ๊ฒฝ์šฐ์—” ๊ณต๊ฐœ URL์„ ์ง์ ‘ ์กฐ๋ฆฝํ•ด ๋ฐ˜ํ™˜ - public_url = f"{uploader.public_url_base}/{r2_key}" - logging.warning(f"[Studio R2] Index step failed but object uploaded; using {public_url} ({err})") - return public_url - except Exception as r2_err: - logging.error(f"[Studio R2] Upload failed for {filename}: {r2_err}") - return None + logger.error(f"[Telegram Relay Chat] Error: {e}") + return jsonify({'error': str(e)}), 500 - @app.route('/api/studio/generate-image', methods=['POST']) - def studio_generate_image(): - """๊ตฌ๊ธ€ GenAI ๋ชจ๋ธ ๋˜๋Š” ํ—คํŒŒ์ด์Šคํ† ์Šค(๋กœ์ปฌ ์—”์ง„)๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ด๋ฏธ์ง€ ์ƒ์„ฑ.""" - data = request.json or {} - engine = data.get("engine", "gemini") - prompt = data.get("prompt") - model_id = data.get("model_id", "imagen-4.0-generate-001") - aspect_ratio = data.get("aspect_ratio", "1:1") - - if not prompt: - return jsonify({"error": "prompt is required"}), 400 + # --- Guardian Startup Config API --- + @app.route('/api/config/startup', methods=['GET', 'POST', 'OPTIONS']) + @app.route('/api/shadow/startup', methods=['GET', 'POST', 'OPTIONS']) + def handle_startup_config(): + """Handles Imperial Guardian Startup configuration (Unified).""" + if request.method == 'OPTIONS': + return jsonify({}), 200 - gen_dir = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "generated_images") - os.makedirs(gen_dir, exist_ok=True) + config_path = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_STARTUP_CONFIG.json") - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - unique_id = f"{timestamp}_{random.randint(1000, 9999)}" - filename = f"gen_{engine}_{unique_id}.png" # will be renamed if needed - output_file = os.path.join(gen_dir, filename) + # [Imperial Standard] Full Guardian Template for UI (13 Guardians) + DEFAULT_STARTUP = { + "core": {"ether": True, "drako": True}, + "media": {"orpheus": True, "wan2gp": True}, + "interface": {"heimdall": True, "ether_portal": True}, + "guardians": { + "drako": True, + "bahamut": True, + "iris": True, + "orpheus": True, + "wan2gp": True, + "heimdall": True, + "tiamat": False, + "hydra": False, + "aizen": False, + "hugin": False, + "hermes": False, + "bastion": True, + "atlas": True, + "shadow_brain": True, + "shadow_rust_brain": True, + "pandora": True + } + } - # --- ํ—คํŒŒ์ด์Šคํ† ์Šค(Z-Image, Flux1 ๋“ฑ) ์—”์ง„ ์ฒ˜๋ฆฌ ๋กœ์ง --- - if engine.lower() == "hephaestus": - if not _hephaestus_online(): - return jsonify({"error": "ํ—คํŒŒ์ด์Šคํ† ์Šค(8001ํฌํŠธ) ์—”์ง„์ด ํ˜„์žฌ ์˜คํ”„๋ผ์ธ ์ƒํƒœ์ž…๋‹ˆ๋‹ค."}), 503 - - try: - import glob - import shutil - - req_json_path = os.path.join(project_root, "wan2gp", "temp_jarvis_req.json") - out_dir_host = os.path.join(project_root, "docker_data", "wan2gp_workspace", "outputs") - - # ์ด๋ฏธ์ง€ ์ƒ์„ฑ์šฉ ๊ธฐ๋ณธ ํ•ด์ƒ๋„ (์ •์‚ฌ๊ฐํ˜• ๊ธฐ๋ณธ) - if aspect_ratio == "16:9": resolution = "1280x720" - elif aspect_ratio == "9:16": resolution = "720x1280" - else: resolution = "1024x1024" # 1:1 and default -> square image - - # Z-Image ์ด๋ฏธ์ง€ ์ƒ์„ฑ ํŒŒ๋ผ๋ฏธํ„ฐ (z_image_base_settings.json ๊ธฐ์ค€) - # guidance_scale=0 + NAG ํŒŒ๋ผ๋ฏธํ„ฐ ์กฐํ•ฉ์€ ๋น„๋””์˜ค ๋ชจ๋“œ๋ฅผ ํŠธ๋ฆฌ๊ฑฐํ•˜๋ฏ€๋กœ ์‚ฌ์šฉ ๊ธˆ์ง€ - payload = { - "settings_version": 2.55, - "model_type": model_id, - "prompt": prompt, - "resolution": resolution, - "flow_shift": 6.0, - "guidance_scale": 4, - "num_inference_steps": 30, - "batch_size": 1 - } - - with open(req_json_path, "w", encoding="utf-8") as f: - json.dump(payload, f, indent=4, ensure_ascii=False) - - # Run Headless CLI inside WanGP Docker - cmd = [ - "docker", "exec", "-u", "user", "wan2gp-service", - "python3", "wgp.py", "--process", "temp_jarvis_req.json", "--output-dir", "outputs" - ] + try: + if request.method == 'GET': + if not os.path.exists(config_path): + return jsonify(DEFAULT_STARTUP) - # [๐Ÿ”ฑ v2.8.1] 5๋ถ„(300์ดˆ) ํƒ€์ž„์•„์›ƒ ๋ถ€์—ฌํ•˜์—ฌ Blackwell ํ™˜๊ฒฝ ๋“ฑ์—์„œ ์ด๋ฏธ์ง€/์˜์ƒ ์—ฐ์„ฑ ํ–‰์ž‰ ์‹œ ์„œ๋ฒ„ ๋ฌดํ•œ ๋Œ€๊ธฐ ๋ฐฉ์ง€ try: - result = subprocess.run(cmd, capture_output=True, text=True, cwd=os.path.join(project_root, "wan2gp", encoding='utf-8', errors='replace'), encoding='utf-8', errors='replace', timeout=300) - except subprocess.TimeoutExpired: - logging.error("Hephaestus Generation Timeout (300s expired)") - return jsonify({"error": "ํ—คํŒŒ์ด์Šคํ† ์Šค ์—ฐ์„ฑ ์‹œ๊ฐ„์ด ์ดˆ๊ณผ๋˜์—ˆ์Šต๋‹ˆ๋‹ค (300์ดˆ). GPU ๋ถ€ํ•˜ ๋˜๋Š” Blackwell ํ˜ธํ™˜์„ฑ ๋ฌธ์ œ๋ฅผ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค."}), 504 - - if result.returncode != 0: - logging.error(f"Hephaestus Docker CLI Error: {result.stderr}") - return jsonify({"error": f"ํ—คํŒŒ์ด์Šคํ† ์Šค CLI ์‹คํ–‰ ์‹คํŒจ: {result.stderr[-200:] if result.stderr else 'Unknown Error'}"}), 500 + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) - # Find the newest generated file in outputs (image OR video) - output_files = ( - glob.glob(os.path.join(out_dir_host, "*.jpg")) + - glob.glob(os.path.join(out_dir_host, "*.png")) + - glob.glob(os.path.join(out_dir_host, "*.mp4")) - ) - if not output_files: - return jsonify({"error": "ํ—คํŒŒ์ด์Šคํ† ์Šค ์ƒ์„ฑ ํ›„ ๊ฒฐ๊ณผ ํŒŒ์ผ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. outputs ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ํ™•์ธํ•˜์„ธ์š”."}), 500 - - newest_file = max(output_files, key=os.path.getctime) - - # Rename filename to match actual extension - actual_ext = os.path.splitext(newest_file)[1] # e.g. '.mp4' or '.png' - filename = f"gen_{engine}_{unique_id}{actual_ext}" - output_file = os.path.join(gen_dir, filename) - - # Move the file to Jarvis generated_images cache - shutil.move(newest_file, output_file) - - # Clean up json template - try: os.remove(req_json_path) - except: pass + # Ensure 'guardians' exists for UI stability + if "guardians" not in config: + config["guardians"] = DEFAULT_STARTUP["guardians"] + else: + # [Self-healing] Auto-merge missing guardians from standard template + for k, v in DEFAULT_STARTUP["guardians"].items(): + if k not in config["guardians"]: + config["guardians"][k] = v + return jsonify(config) + except: + return jsonify(DEFAULT_STARTUP) + + elif request.method == 'POST': + data = request.json + if not data: + return jsonify({"status": "ERROR", "message": "No config data provided"}), 400 - media_type = "video" if actual_ext == ".mp4" else "image" - r2_url = _upload_studio_image_to_r2(output_file, filename) - if not r2_url: - # [๐Ÿ”ฑ] R2 ์ €์žฅ์€ ํ•„์ˆ˜๋‹ค. ์ €์žฅ ์‹คํŒจ๋Š” ๊ณง ์ƒ์„ฑ ์‹คํŒจ๋กœ ๋ณด๊ณ ํ•œ๋‹ค. - # (๋กœ์ปฌ ๋””์Šคํฌ ํด๋ฐฑ URL์€ ํด๋ผ์ด์–ธํŠธ์—์„œ ์ ‘๊ทผ ๋ถˆ๊ฐ€ โ†’ '๊ฑฐ์ง“ ์„ฑ๊ณต' ๋ฐฉ์ง€) - return jsonify({"error": "์ด๋ฏธ์ง€๋Š” ์—ฐ์„ฑ๋์œผ๋‚˜ R2 ์˜๊ตฌ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. R2_LIVE_* ์ž๊ฒฉ์ฆ๋ช…์„ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค."}), 502 - return jsonify({ - "status": "success", - "url": r2_url, - "filename": filename, - "media_type": media_type - }) - - except Exception as e: - return jsonify({"error": f"ํ—คํŒŒ์ด์Šคํ† ์Šค ์—”์ง„ ์˜ค๋ฅ˜: {str(e)}"}), 500 - - # --- ๊ธฐ์กด Gemini ์—”์ง„ ์ฒ˜๋ฆฌ ๋กœ์ง --- - # nano-banana ๋ชจ๋ธ ID๋ฅผ ์‹ค์ œ API ๋ชจ๋ธ๋กœ ๋งคํ•‘ - nano_model_map = { - "nano-banana": "gemini-2.0-flash", - "nano-banana-2": "gemini-3.1-flash-image-preview", - "nano-banana-pro": "gemini-3-pro-image-preview", - "nanobanana": "gemini-2.0-flash", - } - - # Determine actual API model for quota - api_model = model_id - if model_id in nano_model_map: - api_model = nano_model_map[model_id] - - # Check Quota - q_data = _load_image_quota() - current_usage = q_data.get('usage', {}).get(api_model, 0) - limit = STUDIO_IMAGE_LIMITS.get(api_model, 0) - - if limit > 0 and current_usage >= limit: - return jsonify({"error": f"์ด ๋ชจ๋ธ์˜ ์ผ์ผ ์ƒ์„ฑ ํ•œ๋„({limit}ํšŒ)๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‚ด์ผ ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”."}), 429 - - try: - # Google GenAI ๋ชจ๋ธ (Imagen ๋˜๋Š” Gemini Image) - if model_id in nano_model_map: - res = cloud_tool.generate_image_nano_banana(prompt, model_id=api_model, output_file=output_file) - elif model_id.startswith("imagen-"): - res = cloud_tool.generate_image_google(prompt, model_id=model_id, aspect_ratio=aspect_ratio, output_file=output_file) - else: - res = cloud_tool.generate_image_nano_banana(prompt, output_file=output_file) + # Load existing to merge/validate if needed, or total override + # Selective merge to protect core structure if partial data sent + current = DEFAULT_STARTUP.copy() + if os.path.exists(config_path): + try: + with open(config_path, 'r', encoding='utf-8') as f: + current = json.load(f) + except: pass - if res.get("success"): - _increment_image_quota(api_model) - r2_url = _upload_studio_image_to_r2(output_file, filename) - if not r2_url: - # [๐Ÿ”ฑ] R2 ์ €์žฅ์€ ํ•„์ˆ˜๋‹ค. ์ €์žฅ ์‹คํŒจ๋Š” ๊ณง ์ƒ์„ฑ ์‹คํŒจ๋กœ ๋ณด๊ณ ํ•œ๋‹ค. - return jsonify({"error": "์ด๋ฏธ์ง€๋Š” ์ƒ์„ฑ๋์œผ๋‚˜ R2 ์˜๊ตฌ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. R2_LIVE_* ์ž๊ฒฉ์ฆ๋ช…์„ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค."}), 502 - return jsonify({ - "status": "success", - "url": r2_url, - "filename": filename - }) - else: - return jsonify({"error": res.get("error", "์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.")}), 500 - except Exception as e: - logging.error(f"[Studio] Image generation failed: {e}") - return jsonify({"error": str(e)}), 500 - - @app.route('/api/studio/upload-image', methods=['POST', 'OPTIONS']) - def api_studio_upload_image(): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - if 'file' not in request.files: - return jsonify({'status': 'ERROR', 'message': 'ํŒŒ์ผ์ด ์„ ํƒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'}), 400 - - file = request.files['file'] - if file.filename == '': - return jsonify({'status': 'ERROR', 'message': 'ํŒŒ์ผ์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.'}), 400 - - ext = os.path.splitext(file.filename)[1].lower() - if ext not in ['.png', '.jpg', '.jpeg', '.webp', '.gif']: - return jsonify({'status': 'ERROR', 'message': '์ง€์›ํ•˜์ง€ ์•Š๋Š” ์ด๋ฏธ์ง€ ํ˜•์‹์ž…๋‹ˆ๋‹ค.'}), 400 - - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f')[:19] - s_filename = f"upload_{timestamp}{ext}" - - gen_dir = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "generated_images") - os.makedirs(gen_dir, exist_ok=True) - - filepath = os.path.join(gen_dir, s_filename) - file.save(filepath) - - return jsonify({ - 'status': 'SUCCESS', - 'filename': s_filename, - 'url': f'/api/studio/images/{s_filename}' - }) - - @app.route('/api/studio/images/', methods=['GET']) - def studio_serve_image(filename): - """์ƒ์„ฑ๋œ ์ด๋ฏธ์ง€ ์„œ๋น™.""" - gen_dir = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "generated_images") - return send_from_directory(gen_dir, filename) - - def _studio_dialogue_root(): - return os.path.join(project_root, "docker_data", "shared_workspace", "Dialogue") - - def _studio_dir(): - return os.path.join(_studio_dialogue_root(), "Studio") - - def _studio_portraits_dir(): - return os.path.join(_studio_dialogue_root(), "StudioPortraits") - - def _hephaestus_output_candidates(): - candidates = [ - os.path.join(project_root, "docker_data", "wan2gp_workspace", "outputs"), - os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "generated_images"), # Gemini Studio Output - os.path.join(project_root, "docker_data", "wan2gp_workspace", "outputs2"), - os.path.join(project_root, "docker_data", "wan2gp_workspace", "gradio_outputs"), - os.path.join(project_root, "wan2gp", "outputs"), - os.path.join(project_root, "wan2gp", "outputs2"), - os.path.join(project_root, "wan2gp", "gradio_outputs"), - os.path.join(project_root, "..", "docker_data", "wan2gp_workspace", "outputs"), - os.path.join(project_root, "..", "docker_data", "shared_workspace", "JarvisRun", "generated_images"), - os.path.join(project_root, "..", "docker_data", "wan2gp_workspace", "outputs2"), - os.path.join(project_root, "..", "docker_data", "wan2gp_workspace", "gradio_outputs"), - os.path.join(project_root, "..", "wan2gp", "outputs"), - os.path.join(project_root, "..", "wan2gp", "outputs2"), - os.path.join(project_root, "..", "wan2gp", "gradio_outputs"), - ] - unique_paths = [] - seen = set() - for path in candidates: - normalized = os.path.normcase(os.path.abspath(path)) - if normalized in seen: - continue - seen.add(normalized) - unique_paths.append(os.path.abspath(path)) - return unique_paths - - def _sanitize_studio_slug(value): - text = re.sub(r"[^A-Za-z0-9๊ฐ€-ํžฃ_-]+", "_", str(value or "")).strip("_") - return text[:80] or "portrait" - - def _hephaestus_online(): - # Hephaestus(WanGP) ์—”์ง„ ๊ฐ€์šฉ์„ฑ ์ฒดํฌ - 5์ดˆ ์•ˆ์— ์‘๋‹ต ์—†์œผ๋ฉด ์งง๊ฒŒ ์žฌํ™•์ธ - try: - # /config ๋˜๋Š” ๋ฃจํŠธ(/)๋กœ ์ฒดํฌ. /config๋ฅผ 5์ดˆ ํƒ€์ž„์•„์›ƒ์œผ๋กœ ์ฒดํฌ - response = requests.get("http://127.0.0.1:8001/config", timeout=5) - return response.status_code == 200 - except Exception: - # ๋ณด์กฐ๋กœ ๋ฃจํŠธ(/) ์ฒดํฌ ์‹œ๋„ - try: - response = requests.get("http://127.0.0.1:8001/", timeout=2) - return response.status_code == 200 - except: - return False - - def _build_studio_portrait_prompt(topic, member): - name = member.get("name") or "๋ฐฐ์šฐ" - role = member.get("role") or "ํ•ต์‹ฌ ์กฐ์—ฐ" - gender = member.get("gender") or "female" - prompt_gender = "male" if str(gender).lower() == "male" else "female" - - # Add stylistic randomness - styles = [ - "cinematic character portrait", "hyper-realistic close-up", "epic fantasy portrait", - "cyberpunk character shot", "heroic character profile", "ethereal digital art" - ] - atmospheres = [ - "dramatic rim light", "soft dream-like lighting", "neon-soaked ambience", - "golden hour glow", "shadowy mystery", "volumetric light beams" - ] - backgrounds = [ - "clean background", "blurred bokeh background", "imperial palace hall", - "sci-fi command deck", "misty ancient forest", "abstract light streaks" - ] - - style = random.choice(styles) - atmos = random.choice(atmospheres) - bg = random.choice(backgrounds) - - return ( - f"{style}, {prompt_gender}, {name}, {role}, " - f"imperial fantasy sci-fi aesthetic, {atmos}, highly detailed face, " - f"luxury costume design, {bg}, upper body portrait, solo subject, " - f"story topic: {topic or 'imperial studio'}, masterpiece, best quality" - ) - - def _latest_hephaestus_image(exclude_path=None): - candidates = [] - for outputs_dir in _hephaestus_output_candidates(): - if not os.path.isdir(outputs_dir): - continue - for root_dir, _, filenames in os.walk(outputs_dir): - for filename in filenames: - if not filename.lower().endswith((".png", ".jpg", ".jpeg", ".webp")): - continue - file_path = os.path.join(root_dir, filename) - if not os.path.isfile(file_path): - continue - if exclude_path and os.path.normcase(os.path.abspath(file_path)) == os.path.normcase(os.path.abspath(exclude_path)): - continue - candidates.append(file_path) - - if not candidates: - return None - - candidates.sort(key=os.path.getmtime, reverse=True) - return candidates[0] - - @app.route('/api/shadow/audio/studio/portrait/prompt', methods=['POST']) - def studio_portrait_prompt(): - data = request.json or {} - topic = data.get("topic", "") - member = data.get("member") or {} - member_key = member.get("relayId") or member.get("name") or "portrait" - filename_prefix = _sanitize_studio_slug(f"studio_{member_key}_{datetime.now().strftime('%Y%m%d_%H%M%S')}") - latest_path = _latest_hephaestus_image() - - return jsonify({ - "status": "success", - "prompt": _build_studio_portrait_prompt(topic, member), - "filename_prefix": filename_prefix, - "hephaestus_online": _hephaestus_online(), - "latest_output": os.path.basename(latest_path) if latest_path else None, - "has_latest_output": latest_path is not None, - "hephaestus_url": "http://127.0.0.1:8001", - "searched_dirs": [path.replace("\\", "/") for path in _hephaestus_output_candidates()], - }) - - @app.route('/api/shadow/audio/studio/portrait/attach-latest', methods=['POST']) - def studio_portrait_attach_latest(): - data = request.json or {} - member = data.get("member") or {} - session_id = data.get("session_id") or "adhoc" - exclude_path = data.get("exclude_path") - portrait_file = data.get("portrait_file") # ๋ช…์‹œ์ ์ธ ํŒŒ์ผ๋ช… ์ง€์ • - - latest_path = None - if portrait_file: - # generated_images ํด๋”์—์„œ ํ•ด๋‹น ํŒŒ์ผ ํƒ์ƒ‰ - candidate_path = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "generated_images", portrait_file) - if os.path.exists(candidate_path): - latest_path = candidate_path - - if not latest_path: - latest_path = _latest_hephaestus_image(exclude_path=exclude_path) - if latest_path is None: - return jsonify({ - "error": "Hephaestus ์ถœ๋ ฅ ์ด๋ฏธ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋จผ์ € Hephaestus์—์„œ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•œ ๋’ค ๋‹ค์‹œ ์—ฐ๊ฒฐํ•˜์„ธ์š”.", - "hephaestus_url": "http://127.0.0.1:8001", - "searched_dirs": [path.replace("\\", "/") for path in _hephaestus_output_candidates()], - }), 404 - - try: - portraits_dir = _studio_portraits_dir() - os.makedirs(portraits_dir, exist_ok=True) - member_key = member.get("relayId") or member.get("name") or "portrait" - ext = os.path.splitext(latest_path)[1].lower() or ".png" - filename = f"{_sanitize_studio_slug(session_id)}__{_sanitize_studio_slug(member_key)}__{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}" - copied_path = os.path.join(portraits_dir, filename) - with open(latest_path, "rb") as src, open(copied_path, "wb") as dst: - dst.write(src.read()) - - return jsonify({ - "status": "success", - "portrait_file": filename, - "source_file": os.path.basename(latest_path), - "portrait_url": f"/api/shadow/audio/studio/portrait/file/{filename}", - "source_path": latest_path.replace("\\", "/"), - }) - except Exception as e: - return jsonify({"error": str(e)}), 500 - - @app.route('/api/shadow/audio/studio/portrait/file/', methods=['GET']) - def studio_portrait_file(filename): - portraits_dir = _studio_portraits_dir() - os.makedirs(portraits_dir, exist_ok=True) - return send_from_directory(portraits_dir, filename) - - @app.route('/api/shadow/audio/studio/rename', methods=['POST']) - def studio_rename(): - """Rename an existing studio session.""" - data = request.json or {} - session_id = data.get("session_id") - new_title = data.get("new_title") - - if not session_id or not new_title: - return jsonify({"error": "session_id and new_title are required"}), 400 - - try: - studio_dir = _studio_dir() - json_path = os.path.join(studio_dir, f"{session_id}.json") - mp3_path = os.path.join(studio_dir, f"{session_id}.mp3") - - if not os.path.exists(json_path): - return jsonify({"error": "Session not found"}), 404 + # Merge logic + for key in data: + if key in current and isinstance(data[key], dict) and isinstance(current[key], dict): + current[key].update(data[key]) + else: + current[key] = data[key] - # 1. Update JSON Metadata - with open(json_path, 'r', encoding='utf-8') as f: - meta = json.load(f) - - meta["title"] = new_title - - # 2. Generate New Session ID (Keep timestamp if possible) - # Example session_id: Studio_20260316_085603_Title - parts = session_id.split('_') - if len(parts) >= 3 and parts[0] == "Studio": - timestamp = f"{parts[1]}_{parts[2]}" - else: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - - safe_title = "".join([c for c in new_title if c.isalnum() or c in (' ', '_')]).rstrip()[:30] - new_session_id = f"Studio_{timestamp}_{safe_title}" - - # 3. Rename Files - new_json_path = os.path.join(studio_dir, f"{new_session_id}.json") - new_mp3_path = os.path.join(studio_dir, f"{new_session_id}.mp3") - - # Save updated JSON to new path - with open(new_json_path, 'w', encoding='utf-8') as f: - json.dump(meta, f, ensure_ascii=False, indent=2) - - # Rename MP3 if exists - if os.path.exists(mp3_path): - # Ensure we don't overwrite if destination exists and is different - if mp3_path != new_mp3_path: - if os.path.exists(new_mp3_path): - os.remove(new_mp3_path) - os.rename(mp3_path, new_mp3_path) - - # Delete old JSON if name changed - if json_path != new_json_path: - os.remove(json_path) + # [Imperial Standard] Ensure atomic write and UTF-8 encoding + os.makedirs(os.path.dirname(config_path), exist_ok=True) + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(current, f, ensure_ascii=False, indent=2) + + print(f"[Imperial Config] Startup configuration unified & updated via API.") + return jsonify({"status": "SUCCESS", "message": "๊ธฐ๋™ ์„ค์ •์ด ๊ฐฑ์‹ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", "config": current}) - return jsonify({"status": "success", "new_session_id": new_session_id}) except Exception as e: - return jsonify({"error": str(e)}), 500 + import traceback + traceback.print_exc() + return jsonify({"status": "ERROR", "message": str(e)}), 500 - @app.route('/api/shadow/audio/studio/list', methods=['GET']) - def studio_list(): - """List saved studio sessions.""" + + + # --- Imperial Knowledge Endpoints --- + @app.route('/api/knowledge/imperial_wisdom', methods=['GET']) + @app.route('/api/knowledge/imperial_wisdom.md', methods=['GET']) + def api_imperial_wisdom(): try: - studio_dir = _studio_dir() - if not os.path.exists(studio_dir): - return jsonify([]) - - files = [f for f in os.listdir(studio_dir) if f.endswith(".json")] - sessions = [] - for f in files: - try: - with open(os.path.join(studio_dir, f), "r", encoding="utf-8") as jf: - sessions.append({"id": f.replace(".json", ""), "data": json.load(jf)}) - except: continue + # Log visitor info for debugging help + ua = request.headers.get('User-Agent', 'Unknown') + ip = request.remote_addr + print(f"[Imperial Knowledge] Access attempt - Path: {request.path}, IP: {ip}, UA: {ua}") - # Sort by timestamp desc - sessions.sort(key=lambda x: x["data"].get("timestamp", ""), reverse=True) - return jsonify(sessions) + wisdom_path = os.path.join(project_root, "imperial_wisdom.md") + if os.path.exists(wisdom_path): + with open(wisdom_path, 'r', encoding='utf-8') as f: + content = f.read() + return Response( + content, + mimetype='text/markdown', + headers={ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + ) + return jsonify({"error": "File not found"}), 404 except Exception as e: return jsonify({"error": str(e)}), 500 - @app.route('/api/shadow/audio/studio/load/', methods=['GET']) - def studio_load(session_id): - """Load a specific studio session.""" + @app.route('/api/knowledge/mission_control', methods=['GET']) + @app.route('/api/knowledge/mission_control.md', methods=['GET']) + def api_mission_control(): try: - studio_dir = _studio_dir() - file_path = os.path.join(studio_dir, f"{session_id}.json") - if not os.path.exists(file_path): - return jsonify({"error": "Session not found"}), 404 - - with open(file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - # Add audio existence check - audio_path = os.path.join(studio_dir, f"{session_id}.mp3") - data["has_audio"] = os.path.exists(audio_path) - data.setdefault("casting_mode", "auto") - data.setdefault("selected_guardians", []) - data.setdefault("portrait_overrides", {}) + ua = request.headers.get('User-Agent', 'Unknown') + ip = request.remote_addr + print(f"[Imperial Knowledge] Access attempt - Path: {request.path}, IP: {ip}, UA: {ua}") - return jsonify(data) + mission_path = os.path.join(project_root, "mission_control.md") + if os.path.exists(mission_path): + with open(mission_path, 'r', encoding='utf-8') as f: + content = f.read() + return Response( + content, + mimetype='text/markdown', + headers={ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + ) + return jsonify({"error": "File not found"}), 404 except Exception as e: return jsonify({"error": str(e)}), 500 - @app.route('/api/shadow/audio/studio/delete/', methods=['DELETE']) - def studio_delete(session_id): - """Delete a studio session.""" + @app.route('/api/system/rclone-setup', methods=['POST']) + def api_system_rclone_setup(): + """Triggers the Rclone Google Drive setup script.""" try: - studio_dir = _studio_dir() - json_path = os.path.join(studio_dir, f"{session_id}.json") - audio_path = os.path.join(studio_dir, f"{session_id}.mp3") - - deleted_files = [] - if os.path.exists(json_path): - os.remove(json_path) - deleted_files.append("json") - if os.path.exists(audio_path): - os.remove(audio_path) - deleted_files.append("audio") + # Trigger the setup script in the background to avoid blocking the API + script_path = os.path.join(project_root, "shadow_brain_core", "rclone_setup.py") + if not os.path.exists(script_path): + return jsonify({"status": "ERROR", "message": f"Setup script not found: {script_path}"}), 404 - if not deleted_files: - return jsonify({"error": "Session not found"}), 404 - - return jsonify({"status": "success", "deleted": deleted_files}) + # Start the setup script - note: this script is interactive, + # so the user will need to interact with the backend console. + subprocess.Popen([sys.executable, script_path], creationflags=subprocess.CREATE_NEW_CONSOLE) + return jsonify({"status": "SUCCESS", "message": "Rclone setup started in new console."}) except Exception as e: - return jsonify({"error": str(e)}), 500 + return jsonify({"status": "ERROR", "message": str(e)}), 500 - @app.route('/api/shadow/tts/voices') - def get_unified_voices(): - """์ œ๊ตญ ํ†ตํ•ฉ ์Œ์„ฑ ์‚ฌ๋ น๋ถ€(Orpheus): ๋ชจ๋“  ํ”„๋กœ๋ฐ”์ด๋”์˜ ์Œ์„ฑ ๋ชฉ๋ก๊ณผ ์ฟผํ„ฐ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.""" - all_voices = [] - target_provider = request.args.get('provider', '').lower() - # [๐Ÿ”ฑ Imperial] Handle 'orpheus' as an alias for 'qwen' - if target_provider == 'orpheus': - target_provider = 'qwen' + # --- Sovereign Autonomy & Sandbox Endpoints --- + @app.route('/api/sovereign/task/start', methods=['POST']) + def api_sovereign_task_start(): + """Starts a new autonomous iterative task.""" + data = request.json + goal = data.get("goal") + if not goal: + return jsonify({"error": "Goal is required"}), 400 - # 1. Google Gemini (Native Audio) - # return gemini if explicitly requested via provider=gemini OR if include_gemini=true - include_gemini = request.args.get("include_gemini", "false").lower() == "true" - if target_provider == 'gemini' or (not target_provider and include_gemini): - try: - gemini_voices = gemini_audio.get_voices() - for v in gemini_voices: - v['provider'] = 'gemini' - v['quota'] = '์ œํ•œ (100 RPD)' if v['id'] != 'native_live' else '๋ฌด์ œํ•œ (Native Live)' - all_voices.append(v) - except Exception as e: - print(f"[Orpheus] Gemini voices failed: {e}") + context = data.get("context") + use_sandbox = data.get("use_sandbox", True) + + auth_token = request.headers.get("X-Imperial-Token") or request.headers.get("Authorization", "").replace("Bearer ", "") + task_id = task_manager.start_task(goal, context=context, use_sandbox=use_sandbox, auth_token=auth_token) + return jsonify({"task_id": task_id, "status": "started"}) - # 2. Alibaba Qwen3 (Windows Native / Orpheus) - if target_provider == 'qwen' or not target_provider: - # [๐Ÿ”ฑ Imperial Fleet Routing] Ensure unified routing for voice discovery - orpheus_url = cluster.get_orpheus_url() if cluster else os.environ.get("ORPHEUS_URL", "http://127.0.0.1:18800").rstrip('/') - print(f"[Orpheus] Voice discovery routing: {orpheus_url}", flush=True) - try: - res = requests.get(f"{orpheus_url}/voices", timeout=5) - if res.status_code == 200: - qwen_voices = res.json() - for v in qwen_voices: - v['provider'] = 'qwen' - v['quota'] = '๋ฌด์ œํ•œ (Windows Native)' - all_voices.append(v) - else: - raise Exception(f"Orpheus status {res.status_code}") - except Exception as e: - # [ํด๋ฐฑ ์ œ๊ฑฐ] Orpheus(Qwen) ๋ณด์ด์Šค ์„œ๋ฒ„๊ฐ€ ์ฃฝ์—ˆ์„ ๋•Œ ๊ฐ€์งœ ํ”„๋ฆฌ์…‹์œผ๋กœ ๋ฎ์ง€ ์•Š๊ณ  ์‹คํŒจ๋ฅผ ๊ทธ๋Œ€๋กœ ๋…ธ์ถœํ•œ๋‹ค. - print(f"[Orpheus] Qwen voices fetch failed (no fallback): {e}", flush=True) + @app.route('/api/sovereign/task/status/', methods=['GET']) + def api_sovereign_task_status(task_id): + """Returns the status and logs of an active sovereign task.""" + status = task_manager.get_task_status(task_id) + if not status: + return jsonify({"error": "Task not found"}), 404 + return jsonify(status) - # 3. OpenBMB VoxCPM2 (Voice Cloning Engine, Port 8001) - if target_provider == 'voxcpm' or not target_provider: - voxcpm_url = os.environ.get("VOXCPM_URL", "http://127.0.0.1:8001").rstrip('/') - try: - res = requests.get(f"{voxcpm_url}/voices", timeout=3) - if res.status_code == 200: - voxcpm_voices = res.json() - for v in voxcpm_voices: - v['provider'] = 'voxcpm' - v['quota'] = '์Œ์„ฑ ํด๋กœ๋‹ (Windows Native)' - all_voices.append(v) - else: - raise Exception(f"VoxCPM status {res.status_code}") - except Exception as e: - # [ํด๋ฐฑ ์ œ๊ฑฐ] VoxCPM ์„œ๋ฒ„๊ฐ€ ์ฃฝ์—ˆ์„ ๋•Œ ๊ฐ€์งœ ํ”„๋ฆฌ์…‹์œผ๋กœ ๋ฎ์ง€ ์•Š๊ณ  ์‹คํŒจ๋ฅผ ๊ทธ๋Œ€๋กœ ๋…ธ์ถœํ•œ๋‹ค. - print(f"[TTS_VOICES] VoxCPM voices fetch failed (no fallback): {e}", flush=True) + @app.route('/api/sovereign/sandbox/cleanup', methods=['POST']) + def api_sovereign_sandbox_cleanup(): + """Cleans up a specific sandbox session.""" + task_id = request.json.get("task_id") + if not task_id: + return jsonify({"error": "Task ID is required"}), 400 + success = phantom_sandbox.sandbox_manager.cleanup(task_id) + return jsonify({"success": success}) - # [๐Ÿ”ฑ Imperial] De-duplicate voice IDs (Prioritize VoxCPM > Qwen > Gemini) - unique_voices_map = {v.get('id'): v for v in all_voices if v.get('id')} + @app.route('/api/sovereign/browser/execute', methods=['POST']) + def api_sovereign_browser_execute(): + """ + Executes a high-level browser mission autonomously. + Required: {"goal": "Mission description"} + Optional: {"headless": true/false} + """ + data = request.json + goal = data.get("goal") + if not goal: + return jsonify({"error": "Goal is required"}), 400 - # Preserve relative order while keeping only unique IDs (latest provider wins) - # However, for simplicity and ensuring VoxCPM wins: - final_voices = list(unique_voices_map.values()) + headless = data.get("headless", True) + + # Initialize Agent + agent = SovereignMissionAgent() - return jsonify(final_voices) + # Execute (Runs in blocking mode for now, but could be threaded) + # For simplicity and 'Accuracy' (user's request), we run and report. + try: + result = agent.execute_mission(goal, headless=headless) + return jsonify(result) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 - @app.route('/api/shadow/audio/tts', methods=['POST']) - def audio_tts(): - """ - [๐Ÿ”ฑ Imperial Gateway] Synchronous TTS Generation. - Routes to either Gemini Native or Orpheus(Qwen) based on voice selection. - """ - data = request.json or {} - text = data.get("text", "").strip() - voice = data.get("voice", "kore") # Default gemini voice - model = data.get("model", "") - format = data.get("format", "mp3") - speed = data.get("speed", "1.0") - pitch = data.get("pitch", "1.0") + # --- Phase 32: Imperial Data Isolation (JarvisRun Folder) --- - if not text: - return jsonify({"error": "ํ…์ŠคํŠธ๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."}), 400 - print(f"[TTS_GATEWAY] Text: '{text[:50]}...', Voice: '{voice}', Provider Checking...", flush=True) + # --- Phase 32: Imperial Data Isolation (JarvisRun Folder) --- - # 1. Gemini Detection (Based on GEMINI_VOICES in engines/gemini_audio.py) - from engines.gemini_audio import GEMINI_VOICES - is_gemini = voice.lower() in GEMINI_VOICES or "gemini" in str(model).lower() or "native" in str(voice).lower() - is_voxcpm = "voxcpm" in str(model).lower() or str(data.get("provider", "")).lower() == "voxcpm" + jarvis_run_dir = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun") - if is_gemini: - print(f"[TTS_GATEWAY] Routing to Gemini Native Engine...", flush=True) - if 'gemini_audio' not in globals() or not gemini_audio or not gemini_audio.available: - return jsonify({"error": "Gemini Audio Engine is not available"}), 503 - - try: - res = gemini_audio.tts(text, voice=voice, output_format=format) - if "error" in res: - return jsonify({"error": res["error"]}), 500 - - audio_bytes = res.get("audio_bytes") - if not audio_bytes: - return jsonify({"error": "Generated audio is empty"}), 500 - - import base64 - b64_audio = base64.b64encode(audio_bytes).decode('utf-8') - res_mime = "audio/mpeg" if format == "mp3" else "audio/wav" - if b64_audio.startswith('UklGR'): # "RIFF" in base64 (WAV header) - res_mime = "audio/wav" + os.makedirs(jarvis_run_dir, exist_ok=True) - return jsonify({ - "status": "success", - "audio_base64": b64_audio, - "mime_type": res_mime - }) - except Exception as e: - print(f"[TTS_GATEWAY] Gemini critical error: {e}", flush=True) - return jsonify({"error": str(e)}), 500 - @app.route('/api/shadow/audio/live/tts', methods=['POST']) - def audio_live_tts(): - """ - [๐Ÿ”ฑ Imperial Shadow Live Gateway] - Gemini 3.1 Flash Live ๋ชจ๋ธ์„ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋Š” ์‹ค์‹œ๊ฐ„ ์Œ์„ฑ ์—”๋“œํฌ์ธํŠธ์ž…๋‹ˆ๋‹ค. - """ - data = request.json or {} - text = data.get("text", "").strip() - voice = data.get("voice", "Kore") - model = data.get("model", "gemini-2.0-flash") - if not text: - return jsonify({"error": "ํ…์ŠคํŠธ๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."}), 400 + # --- Phase 69: Persistence of Proactive Settings --- + # Moved to blueprints/report_routes.py - print(f"[SHADOW_LIVE] Text: '{text[:50]}...', Voice: '{voice}', Model: '{model}'", flush=True) + def migrate_legacy_data(): - if 'gemini_audio' not in globals() or not gemini_audio or not gemini_audio.available: - return jsonify({"error": "Gemini Audio Engine is not available"}), 503 + """Migrates files from shared_workspace root to JarvisRun subdirectory.""" - try: - # ๐Ÿ”ฑ ๋งˆ์™•๋‹˜ ๋ช…๋ น์— ๋”ฐ๋ผ ๋™์  ๋ชจ๋ธ ์„ ํƒ ๊ฐ€๋Šฅํ•˜๋„๋ก ์ˆ˜์ • - res = gemini_audio.shadow_live_tts(text, voice=voice, model=model) - if "error" in res: - return jsonify({"error": res["error"]}), 500 - - import base64 - b64_audio = base64.b64encode(res.get("audio_bytes")).decode('utf-8') - - return jsonify({ - "status": "success", - "audio_base64": b64_audio, - "mime_type": res.get("mime_type", "audio/wav"), - "provider": "shadow_live", - "model_used": res.get("model_used"), - "text": res.get("text") - }) - except Exception as e: - print(f"[SHADOW_LIVE] Critical error: {e}", flush=True) - return jsonify({"error": str(e)}), 500 + root_dir = os.path.join(project_root, "docker_data", "shared_workspace") - # 2. VoxCPM2 Voice Cloning Logic (Pure โ€” No Gemini/Qwen mixing) - if is_voxcpm: - print(f"[TTS_GATEWAY] Routing to VoxCPM2 Engine (Port 8001)...", flush=True) - voxcpm_url = os.environ.get("VOXCPM_URL", "http://127.0.0.1:8001").rstrip('/') - try: - payload = {"text": text, "voice": voice.lower()} - resp = requests.post(f"{voxcpm_url}/tts", json=payload, timeout=300) - if resp.status_code == 200: - import base64 - b64_audio = base64.b64encode(resp.content).decode('utf-8') - return jsonify({ - "status": "success", - "audio_base64": b64_audio, - "mime_type": "audio/wav" - }) - else: - return jsonify({"error": f"VoxCPM gateway error: {resp.status_code}"}), 502 - except Exception as e: - return jsonify({"error": f"VoxCPM unreachable: {str(e)}"}), 503 + legacy_files = [ - # 3. Orpheus (Qwen) Logic - Pure Orpheus logic (No Gemini mixing) - print(f"[TTS_GATEWAY] Routing to Orpheus (Qwen) Engine...", flush=True) - qwen_text = re.sub(r'\(.*?\)', '', text).strip() - if not qwen_text: qwen_text = text - - QWEN_VOICE_MAP = { - 'sohee': 'sohee', - 'ryan': 'ryan', - 'aiden': 'aiden', - 'vivian': 'vivian', - 'serena': 'serena', - 'uncle_fu': 'uncle_fu', - 'dylan': 'dylan', - 'eric': 'eric', - 'ono_anna': 'ono_anna', - 'default': 'sohee' - } - voice_norm = QWEN_VOICE_MAP.get(voice.lower(), 'sohee') - - try: - if cluster: - gateway_url = f"{cluster.get_orpheus_url().rstrip('/')}/tts" - else: - default_url = os.environ.get("ORPHEUS_URL", "http://127.0.0.1:18800").rstrip('/') - gateway_url = f"{default_url}/tts" - - payload = { - "text": qwen_text, - "voice": voice_norm, - "speed": float(speed), - "pitch": float(pitch), - "model": "local_nvfp4" if "local" in str(model).lower() else model - } - resp = requests.post(gateway_url, json=payload, timeout=300) - if resp.status_code == 200: - import base64 - b64_audio = base64.b64encode(resp.content).decode('utf-8') - res_mime = "audio/mpeg" if format == "mp3" else "audio/wav" - if b64_audio.startswith('UklGR'): - res_mime = "audio/wav" - return jsonify({ - "status": "success", - "audio_base64": b64_audio, - "mime_type": res_mime - }) - else: - return jsonify({"error": f"Orpheus gateway error: {resp.status_code}"}), 502 - except Exception as e: - return jsonify({"error": str(e)}), 500 + "JARVIS_SIGNAL.json", "JARVIS_TODO.json", "JARVIS_TODO_ARCHIVE.json", + "JARVIS_TERMINAL.log", "JARVIS_CHAT_HISTORY.json", "JARVIS_SHADOW_SETTINGS.json", + "JARVIS_VOICE_SIGNAL.json", "JARVIS_AUTOTEST.json", "SHADOW_BRAIN_MODELS.json", - @app.route('/api/voice', methods=['GET', 'POST']) + "REDTEAM_TARGETS.json", "REDTEAM_TACTICAL_REPORT.json", "SOUL_LINK_LOG.json", - def handle_voice_signal(): + "JARVISRUN_STATUS.json" - """Proactive TTS signal channel for the AI agent.""" + ] - voice_signal_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_VOICE_SIGNAL.json") + import shutil - from flask import request + for f in legacy_files: - + old_path = os.path.join(root_dir, f) - if request.method == 'POST': + if os.path.exists(old_path): - data = request.json + new_path = os.path.join(jarvis_run_dir, f) - text = data.get("text", "") + if not os.path.exists(new_path): - voice = data.get("voice", "ryan") + try: - if not text: + shutil.move(old_path, new_path) - return jsonify({"status": "ERROR", "message": "No text provided"}), 400 + print(f"[Imperial Migration] {f} -> JarvisRun/") - + except Exception as e: - try: + print(f"[Migration Error] {f}: {e}") - payload = { + else: - "text": text, + try: - "voice": voice, + os.remove(old_path) # Data already exists in new area - "timestamp": time.time() + except: pass + + + + # Also migrate PNG evidence files + + for f in os.listdir(root_dir): + + if f.endswith(".png") and os.path.isfile(os.path.join(root_dir, f)): + + try: + + shutil.move(os.path.join(root_dir, f), os.path.join(jarvis_run_dir, f)) + + except: pass + + + + migrate_legacy_data() - } - with open(voice_signal_file, 'w', encoding='utf-8') as f: - json.dump(payload, f, ensure_ascii=False) + # --- Phase 33: Version Sync from VERSION file to JARVISRUN_STATUS.json --- - return jsonify({"status": "SUCCESS"}) + def sync_version_to_status(): + """Reads the root VERSION file and updates JARVISRUN_STATUS.json with the canonical version.""" + version_file = os.path.join(project_root, "VERSION") + status_file = os.path.join(jarvis_run_dir, "JARVISRUN_STATUS.json") + try: + with open(version_file, 'r', encoding='utf-8-sig') as f: + version_str = f.read().strip() + except OSError as e: + print(f"[Imperial Version Sync] Warning: Could not read VERSION file ({version_file}): {e}") + return + if not os.path.exists(status_file): + print(f"[Imperial Version Sync] Warning: Status file not found ({status_file}), skipping version sync.") + return + try: + with open(status_file, 'r', encoding='utf-8') as f: + status_data = json.load(f) + status_data['version'] = version_str + with open(status_file, 'w', encoding='utf-8') as f: + json.dump(status_data, f, ensure_ascii=False, indent=2) + print(f"[Imperial Version Sync] JARVISRUN_STATUS.json version updated to: {version_str}") + except (OSError, json.JSONDecodeError) as e: + print(f"[Imperial Version Sync] Warning: Could not update status file ({status_file}): {e}") - except Exception as e: + sync_version_to_status() - return jsonify({"status": "ERROR", "message": str(e)}), 500 - - else: # GET + # --- Phase 40: Deployment State Tracking --- - if os.path.exists(voice_signal_file): + deployment_status = {} - try: + secrets_sync_status = {"status": "IDLE", "step": "N/A"} - with open(voice_signal_file, 'r', encoding='utf-8') as f: - data = json.load(f) - os.remove(voice_signal_file) + @app.route('/api/deploy/status', methods=['GET']) - return jsonify(data) + def get_deploy_status(): - except Exception: + """Returns the current deployment status of all projects.""" - pass + return jsonify(deployment_status) - return jsonify({"text": None}) + # === Firebase Build Quota Tracker === - @app.route('/api/shadow/action/execute', methods=['POST']) + QUOTA_FILE = os.path.join(project_root, "docker_data", "shared_workspace", "firebase_build_quota.json") - def execute_action(): + FIREBASE_FREE_BUILDS = 30 # ~120min / ~4min per build - """Executes a pre-approved skill action.""" - data = request.json - action = data.get("action") + def _load_quota(): - + """Load or reset monthly build quota.""" - if not action: + import json - return jsonify({"status": "ERROR", "message": "No action provided"}), 400 + from datetime import datetime - + current_month = datetime.now().strftime('%Y-%m') try: - skill_path = os.path.join(project_root, "scripts", "jarvisrun", "skills", "git_manager.py") - - if action == "git_push": + with open(QUOTA_FILE, 'r', encoding='utf-8') as f: - # Dynamic import or direct call + data = json.load(f) - spec = importlib.util.spec_from_file_location("git_manager", skill_path) + if data.get('month') != current_month: - git_manager = importlib.util.module_from_spec(spec) + data = {'month': current_month, 'count': 0, 'manual_count': 0, 'limit': FIREBASE_FREE_BUILDS} - spec.loader.exec_module(git_manager) + _save_quota(data) - + if 'manual_count' not in data: - res = git_manager.git_push_auto(project_root, "feat: [Jarvis] Genesis Auto-Push") + data['manual_count'] = 0 - if res["success"]: + _save_quota(data) - return jsonify({"status": "SUCCESS", "message": "์ฝ”๋“œ ํ‘ธ์‹œ(Git Push) ์ž‘์ „ ์„ฑ๊ณต!", "output": res["stdout"]}) + return data - else: + except (FileNotFoundError, json.JSONDecodeError): - return jsonify({"status": "ERROR", "message": "์ฝ”๋“œ ํ‘ธ์‹œ(Git Push) ์ž‘์ „ ์‹คํŒจ", "details": res["stderr"]}) + data = {'month': current_month, 'count': 0, 'manual_count': 0, 'limit': FIREBASE_FREE_BUILDS} - elif action == "reintegration": + _save_quota(data) - # Reuse the existing reintegrate API logic or call it + return data - return run_reintegration() - else: - return jsonify({"status": "ERROR", "message": f"์•Œ ์ˆ˜ ์—†๋Š” ์ž‘์—…: {action}"}), 400 + def _save_quota(data): - except Exception as e: + import json - return jsonify({"status": "ERROR", "message": str(e)}), 500 + os.makedirs(os.path.dirname(QUOTA_FILE), exist_ok=True) + with open(QUOTA_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) - @app.route('/api/shadow/auth/github', methods=['GET']) - def github_auth_init(): - """Initiates GitHub Device Flow authentication.""" + def _increment_quota(): - client_id = "Iv1.b507a08c87ecfe98" # Standard VSCode/Copilot Client ID + data = _load_quota() - try: + data['count'] = data.get('count', 0) + 1 - res = requests.post("https://github.com/login/device/code", data={ + _save_quota(data) - "client_id": client_id, + return data - "scope": "read:user" - }, headers={"Accept": "application/json"}, timeout=10) - res.raise_for_status() + @app.route('/api/deploy/quota', methods=['GET']) - data = res.json() + def get_deploy_quota(): - + """Returns Firebase build quota usage for current month.""" - # Store device_code for polling + data = _load_quota() - session_id = str(int(time.time() * 1000)) + remaining = max(0, data['limit'] - data['count']) - GITHUB_AUTH_SESSIONS[session_id] = { + percent = round((data['count'] / data['limit']) * 100) if data['limit'] > 0 else 0 - "device_code": data.get("device_code"), + level = 'safe' if percent < 70 else ('warning' if percent < 90 else 'danger') - "expires_at": time.time() + data.get("expires_in", 900) + - } + # Storage usage estimate removed in favor of direct GCP Console monitoring + storage_used = 0.0 + storage_limit = 5.0 # Firebase Spark Plan limit + storage_percent = 0 + storage_level = 'safe' - + - return jsonify({ + return jsonify({ - "status": "SUCCESS", + 'month': data['month'], - "session_id": session_id, + 'used': data['count'], - "user_code": data.get("user_code"), + 'limit': data['limit'], - "verification_uri": data.get("verification_uri") + 'remaining': remaining, - }) + 'percent': percent, - except Exception as e: + 'level': level, - return jsonify({"status": "ERROR", "message": str(e)}), 500 + 'storage': { + 'used': storage_used, + 'limit': storage_limit, - @app.route('/api/shadow/auth/github/poll', methods=['POST']) + 'percent': storage_percent, - def github_auth_poll(): + 'level': storage_level, - """Polls for GitHub authorization and saves the token.""" + 'manual_count': data['manual_count'] - from flask import request + } - data = request.json + }) - session_id = data.get("session_id") - if not session_id or session_id not in GITHUB_AUTH_SESSIONS: - return jsonify({"status": "ERROR", "message": "Invalid session"}), 400 + @app.route('/api/deploy/storage/purge', methods=['POST']) - + def purge_deploy_storage(): - session = GITHUB_AUTH_SESSIONS[session_id] + """Resets the manual deployment counter after user cleans up Firebase Console.""" - if time.time() > session["expires_at"]: + data = _load_quota() - return jsonify({"status": "EXPIRED", "message": "Auth session expired"}), 400 + data['manual_count'] = 0 - + _save_quota(data) - client_id = "Iv1.b507a08c87ecfe98" + return jsonify({"status": "SUCCESS", "message": "Manual deployment counter reset. Imperial Sanctuary purified."}) - try: - res = requests.post("https://github.com/login/oauth/access_token", data={ - "client_id": client_id, + @app.route('/api/deploy/quota/sync', methods=['POST']) - "device_code": session["device_code"], + def sync_deploy_quota(): - "grant_type": "urn:ietf:params:oauth:grant-type:device_code" + """Manually override the current month's build quota.""" - }, headers={"Accept": "application/json"}, timeout=10) + req = request.json - res.raise_for_status() + if 'used' not in req: - token_data = res.json() + return jsonify({"status": "ERROR", "message": "Missing 'used' parameter"}), 400 - if "access_token" in token_data: + data = _load_quota() + + data['count'] = int(req['used']) + + _save_quota(data) - token = token_data["access_token"] + return jsonify({"status": "SUCCESS", "message": f"Quota synced to {data['count']}"}) - # Save to .env - env_path = os.path.join(project_root, ".env") - env_content = "" + @app.route('/api/secrets/sync/status', methods=['GET']) - if os.path.exists(env_path): + def get_secrets_sync_status(): - with open(env_path, 'r', encoding='utf-8') as f: + return jsonify(secrets_sync_status) - env_content = f.read() - - if "GITHUB_TOKEN=" in env_content: + @app.route('/api/deploy/db/status', methods=['GET']) + def get_db_schema_status(): + """Returns the current Prisma migration status for the project.""" + project = request.args.get("project", "ether_bahamut") + if project != "ether_bahamut": + return jsonify({"status": "UNSUPPORTED", "message": "์ด ํ”„๋กœ์ ํŠธ๋Š” ํ‚ค ๊ฐ์‹œ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."}) - # Update existing + def check_db(): + try: + # 1. Get DB URL from Bitwarden + bw_session = os.environ.get("BW_SESSION") + if not bw_session: + return {"status": "LOCKED", "message": "Bitwarden Vault๊ฐ€ ์ž ๊ฒจ ์žˆ์Šต๋‹ˆ๋‹ค."} - new_env = re.sub(r'GITHUB_TOKEN=.*', f'GITHUB_TOKEN={token}', env_content) + # Get the preview URL for status checks + cmd_get_secret = 'powershell -Command "bw get password \'Imperial: PREVIEW_DATABASE_URL\' --raw"' + res_secret = subprocess.run(cmd_get_secret, shell=True, capture_output=True, text=True, encoding='utf-8') + db_url = res_secret.stdout.strip().replace('"', '').replace("'", "") + + if not db_url or "Auto-migrated" in db_url: + return {"status": "ERROR", "message": "DB URL์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."} + # 2. Run prisma migrate status + target_dir = os.path.join(project_root, "ether-bahamut") + env_copy = os.environ.copy() + env_copy["DATABASE_URL"] = db_url + + # Check for prisma existence + cmd_status = "npx.cmd prisma migrate status" + res_status = subprocess.run(cmd_status, shell=True, cwd=target_dir, env=env_copy, capture_output=True, text=True, encoding='utf-8') + + output = res_status.stdout + res_status.stderr + if "Database schema is up to date" in output: + return {"status": "SYNCED", "message": "์ตœ์‹  ์ƒํƒœ (๋™๊ธฐํ™”๋จ)"} + elif "migrations are not yet applied" in output: + # Count pending migrations if possible + return {"status": "PENDING", "message": "โš ๏ธ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ•„์š”"} + elif "error" in output.lower(): + return {"status": "ERROR", "message": "DB ์—ฐ๊ฒฐ ์˜ค๋ฅ˜"} else: + return {"status": "UNKNOWN", "message": "์ƒํƒœ ํ™•์ธ ๋ถˆ๊ฐ€"} + + except Exception as e: + return {"status": "ERROR", "message": f"์‹œ์Šคํ…œ ์˜ค๋ฅ˜: {str(e)}"} + + result = check_db() + return jsonify(result) - # Add new - new_env = env_content + f'\nGITHUB_TOKEN={token}\n' - + # [๐Ÿ”ฑ Imperial] Deployment logic has been migrated to blueprints/deployment_routes.py\n @app.route('/redteam') + + def redteam_console(): - with open(env_path, 'w', encoding='utf-8') as f: + """Serves the Red Team tactical audit interface.""" - f.write(new_env) + return render_template('redteam.html') - - # Signal restart - signal_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_SIGNAL.json") + # [๏ฟฝ๏ฟฝ๏ฟฝ๏ฟฝ Imperial] Red Team audit logic has been migrated to blueprints/audit_routes.py + def serve_guardian_assets(guardian_id, filename): + """Serves assets from specific guardian workspaces (Soul Bridge).""" + from flask import send_from_directory + workspace_map = { + "hugin": os.path.join(project_root, "scripts", "hugin"), + "shadowbrain": os.path.join(os.path.dirname(project_root), "docker_data", "shared_workspace", "JarvisRun"), + "jarvis": os.path.join(os.path.dirname(project_root), "docker_data", "shared_workspace", "JarvisRun"), + "aris": os.path.join(project_root, "docker_data", "shared_workspace", "Aris") + } + base_path = workspace_map.get(guardian_id.lower()) + + if not base_path: + base_path = os.path.join(project_root, "docker_data", "shared_workspace", guardian_id.capitalize()) + + if os.path.exists(base_path): + return send_from_directory(base_path, filename) + return jsonify({"error": "Workspace not found"}), 404 - with open(signal_file, 'w', encoding='utf-8') as f: + def _is_flutter_debug_server_running(port=38790): + """flutter run --debug ์„œ๋ฒ„(38790)๊ฐ€ ๊ธฐ๋™ ์ค‘์ธ์ง€ ๋น ๋ฅด๊ฒŒ ์ฒดํฌํ•ฉ๋‹ˆ๋‹ค.""" + import socket as _socket + s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + s.settimeout(0.1) + try: + s.connect(('127.0.0.1', port)) + s.close() + return True + except Exception: + return False - json.dump({ + @app.route('/') - "signal": "RESTART", + def index(): - "content": "GitHub OAuth Success", + # flutter run --debug ์„œ๋ฒ„(38790)๊ฐ€ ์‚ด์•„ ์žˆ์œผ๋ฉด ๊ทธ์ชฝ์œผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (์ตœ์‹  ์ฝ”๋“œ) + # ๐Ÿ”ฑ RunJarvis_Web.bat ๊ฐœ๋ฐœ ์ค‘์ผ ๋•Œ 18700๊ณผ 38790์ด ์ž๋™ ์—ฐ๋™๋จ + from flask import redirect as _redirect + if _is_flutter_debug_server_running(38790): + return _redirect('http://localhost:38790/', code=302) - "sender": "System", + # Serve the main index.html (Heimdall or Legacy) - "timestamp": time.time() + target = 'index.html' if os.path.exists(os.path.join(app.static_folder, 'index.html')) else 'dashboard.html' - }, f, ensure_ascii=False) + from flask import make_response, send_from_directory - + return send_from_directory(app.static_folder, target) - return jsonify({"status": "SUCCESS", "message": "์ธ์ฆ ์„ฑ๊ณต! ์‹œ์Šคํ…œ์„ ์žฌ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค."}) - - error = token_data.get("error") + @app.route('/api/status') - if error == "authorization_pending": + def status(): - return jsonify({"status": "PENDING"}) + status_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVISRUN_STATUS.json") - elif error == "slow_down": + try: - return jsonify({"status": "SLOW_DOWN"}) + if os.path.exists(status_file): - else: + with open(status_file, 'r', encoding='utf-8') as f: - return jsonify({"status": "ERROR", "message": token_data.get("error_description", error)}) + data = json.load(f) - except Exception as e: - - return jsonify({"status": "ERROR", "message": str(e)}), 500 - + # --- Phase: Eternal Watch Status --- + data['external_health'] = data.get('external_services', {}) - @app.route('/api/skills', methods=['GET']) + - def list_skills(): + # --- Phase 5: Guardian Location Display --- - """Scans .agent/skills and .agent/workflows for the web dashboard.""" + if cluster: - skills = [] + try: - + data['allocations'] = cluster.get_config().get('allocations', {}) - # 1. Scan Skills + except Exception: - skills_dir = os.path.join(project_root, ".agent", "skills") + data['allocations'] = {} - if os.path.exists(skills_dir): - for skill_name in os.listdir(skills_dir): - skill_path = os.path.join(skills_dir, skill_name, "SKILL.md") + # Staleness check: if heartbeat is older than 45 seconds, - if os.path.exists(skill_path): + # the engine is stopped/restarting - treat all guardians as offline - data = parse_skill_md(skill_path) + ts = data.get('timestamp', 0) - data['type'] = 'SKILL' + age = time.time() - ts - skills.append(data) + if age > 45: + stale_guardians = {k: False for k in data.get('guardians', {})} + data['guardians'] = stale_guardians - # 2. Scan Workflows + data['stale'] = True - workflows_dir = os.path.join(project_root, ".agent", "workflows") + data['stale_age'] = int(age) + + # [๐Ÿ”ฑ] Guardian Ports Mapping for UI (Fallback for Shadow Brain) + # Map standard keys to ports + port_map = { + "drako": "5432", "orpheus": "18800", "wan2gp": "8003", + "heimdall": "18701", "aizen": "18789", "hugin": "18792", + "hermes": "18790", "shadow_brain": "18700", "shadow_rust_brain": "18710", + "hydra": "8002", "tiamat": "8080", "atlas": "18799", + "bastion": "18702", "gemini_voice": "18721", "pandora": "8001", + "bahamut": "3002", "ether": "3002", "iris": "4000", "ether_portal": "4000" + } + + # Create guardian_ports mapping matching the exact keys found in data['guardians'] + # or based on known labels if the keys are complex labels + guardian_ports = {} + guardians = data.get('guardians', {}) + for k in guardians.keys(): + port = "" + k_lower = k.lower() + for std_k, p in port_map.items(): + if std_k in k_lower: + port = p + break + guardian_ports[k] = port + + data['guardian_ports'] = guardian_ports - if os.path.exists(workflows_dir): + return jsonify(data) - for wf_file in os.listdir(workflows_dir): + except Exception as e: - if wf_file.endswith(".md"): + return jsonify({"error": str(e)}), 500 - wf_path = os.path.join(workflows_dir, wf_file) + return jsonify({"status": "OFFLINE"}) - data = parse_skill_md(wf_path) - data['type'] = 'WORKFLOW' - if data.get('name') == 'Unnamed': + @app.route('/api/status/sync', methods=['GET', 'POST']) - data['name'] = wf_file.replace(".md", "") + def handle_sync_status(): - skills.append(data) + global is_syncing - + from flask import request - return jsonify(skills) + if request.method == 'POST': + data = request.json + is_syncing = data.get("is_syncing", False) - @app.route('/api/skills/info', methods=['GET']) + print(f"[.env Migration] Syncing state set to: {is_syncing}") - def get_skill_info(): + return jsonify({"status": "SUCCESS", "is_syncing": is_syncing}) - """Returns the full markdown content of a skill, skipping frontmatter.""" + else: - skill_id = request.args.get('id') + return jsonify({"is_syncing": is_syncing}) - if not skill_id: - return jsonify({"error": "No skill ID provided"}), 400 - + @app.route('/api/config') - # We need to find the file. skill_id is the basename for workflows or dirname for skills. + def get_config(): - # This is a bit brittle, let's search. + version_file = os.path.join(project_root, "VERSION") + try: + with open(version_file, 'r', encoding='utf-8-sig') as f: + version_str = f.read().strip() + except Exception: + version_str = "UNKNOWN" - search_dirs = [ + return jsonify({ - os.path.join(project_root, ".agent", "skills"), + "version": version_str, - os.path.join(project_root, ".agent", "workflows") + "dev_mode": "ACTIVE", - ] + "neural_link_stability": "99.9%", - + "last_sync": os.path.getmtime(__file__) - target_path = None + }) - for d in search_dirs: - if not os.path.exists(d): continue + @app.route('/api/shadow/ask/answer', methods=['POST', 'OPTIONS']) + def shadow_ask_answer(): + """๐Ÿ™‹ [Ask User] ํ—ค์ž„๋‹ฌ ์„ ํƒ์ง€ ๋ฒ„ํŠผ ํƒญ ์ˆ˜์‹  โ€” ๋Œ€๊ธฐ ์ค‘์ธ ์ถ”๋ก  ๋ฃจํ”„๋ฅผ ๊นจ์šด๋‹ค.""" + if request.method == 'OPTIONS': + return '', 204 + try: + data = request.json or {} + ask_id = str(data.get('ask_id') or '').strip() + # ๐Ÿšซ ์ทจ์†Œ ์‹ ํ˜ธ (์ˆ˜ํ˜ธ์ž ์ „ํ™˜/์ฐฝ ๋‹ซ๊ธฐ ๋“ฑ) โ€” ๋Œ€๊ธฐ ๋ฃจํ”„๊ฐ€ ์ฆ‰์‹œ ๊นจ์–ด๋‚˜ ๋ณด์ˆ˜์ ์œผ๋กœ ๋งˆ๋ฌด๋ฆฌ + answer = "__CANCELLED__" if data.get('cancel') else str(data.get('answer') or '').strip() + if not ask_id or not answer: + return jsonify({"status": "ERROR", "message": "ask_id์™€ answer๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."}), 400 + from brain.core import submit_ask_answer + if submit_ask_answer(ask_id, answer): + return jsonify({"status": "SUCCESS", "ask_id": ask_id}) + # ๋งŒ๋ฃŒ/์ทจ์†Œ๋œ ์งˆ๋ฌธ โ€” ํ—ค์ž„๋‹ฌ์€ ์ด ์‘๋‹ต์„ ๋ฐ›์œผ๋ฉด ์ผ๋ฐ˜ ์ฑ„ํŒ… ๋ฉ”์‹œ์ง€๋กœ ํด๋ฐฑ ์ „์†ก + return jsonify({"status": "EXPIRED", "message": "ํ•ด๋‹น ์งˆ๋ฌธ์€ ์ด๋ฏธ ๋งŒ๋ฃŒ๋˜์—ˆ๊ฑฐ๋‚˜ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."}), 410 + except Exception as e: + return jsonify({"status": "ERROR", "message": str(e)}), 500 - for root, dirs, files in os.walk(d): + @app.route('/api/shadow/ask/begin', methods=['POST', 'OPTIONS']) + def shadow_ask_begin(): + """๐Ÿ™‹ [Ask Bridge] ์™ธ๋ถ€ ์—์ด์ „ํŠธ(ํ—ค๋ฅด๋ฉ”์Šค MCP ๋“ฑ)๊ฐ€ ์„ ํƒ์ง€ ์งˆ๋ฌธ์„ ๋“ฑ๋กํ•œ๋‹ค.""" + if request.method == 'OPTIONS': + return '', 204 + try: + data = request.json or {} + if not str(data.get('question', '')).strip() or not data.get('options'): + return jsonify({"status": "ERROR", "message": "question๊ณผ options๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."}), 400 + from brain.core import begin_ask + return jsonify({"status": "SUCCESS", "ask": begin_ask(data)}) + except Exception as e: + return jsonify({"status": "ERROR", "message": str(e)}), 500 - for f in files: + @app.route('/api/shadow/ask/wait', methods=['POST', 'OPTIONS']) + def shadow_ask_wait(): + """๐Ÿ™‹ [Ask Bridge] ๋‹ต๋ณ€๊นŒ์ง€ ๋ธ”๋กœํ‚น ๋Œ€๊ธฐ(long-poll). ANSWERED/CANCELLED/TIMEOUT ๋ฐ˜ํ™˜.""" + if request.method == 'OPTIONS': + return '', 204 + try: + data = request.json or {} + ask_id = str(data.get('ask_id') or '').strip() + if not ask_id: + return jsonify({"status": "ERROR", "message": "ask_id๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."}), 400 + timeout = max(5, min(int(data.get('timeout_seconds', 120) or 120), 600)) + from brain.core import wait_ask + answer = wait_ask(ask_id, timeout) + if answer is None: + return jsonify({"status": "TIMEOUT"}) + if answer == "__CANCELLED__": + return jsonify({"status": "CANCELLED"}) + return jsonify({"status": "ANSWERED", "answer": answer}) + except Exception as e: + return jsonify({"status": "ERROR", "message": str(e)}), 500 - if f == skill_id or os.path.basename(root) == skill_id: + @app.route('/api/shadow/ask/pending', methods=['GET', 'OPTIONS']) + def shadow_ask_pending(): + """๐Ÿ™‹ [Ask Bridge] ๋Œ€๊ธฐ ์ค‘ ์งˆ๋ฌธ ๋ชฉ๋ก โ€” ํ—ค์ž„๋‹ฌ์ด ํด๋งํ•˜์—ฌ ์นด๋“œ๋ฅผ out-of-band ํ‘œ์‹œ.""" + if request.method == 'OPTIONS': + return '', 204 + try: + from brain.core import list_pending_asks + return jsonify({"status": "SUCCESS", "asks": list_pending_asks()}) + except Exception as e: + return jsonify({"status": "ERROR", "message": str(e), "asks": []}), 200 - if f.endswith(".md"): + @app.route('/api/shadow/web_search', methods=['POST', 'OPTIONS']) + def shadow_web_search(): + """๐ŸŒ [Web Search] ์‹ค์‹œ๊ฐ„ ์›น ๊ฒ€์ƒ‰ โ€” ๋‚ด๋ถ€ google-surf MCP๋ฅผ ํ˜ธ์ถœํ•ด ๊ฒฐ๊ณผ ํ…์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜. - target_path = os.path.join(root, f) + imperial MCP์˜ web ๋ฒˆ๋“ค(search_web)์ด ์ด ์—”๋“œํฌ์ธํŠธ๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค. ๋•๋ถ„์— ์™ธ๋ถ€ + ์ˆ˜ํ˜ธ์ž(ํ—ค๋ฅด๋ฉ”์Šค ๋“ฑ)๋Š” google-surf npm ์„œ๋ฒ„๋ฅผ ๋”ฐ๋กœ ์•ˆ ๊น”๊ณ  ์šฐ๋ฆฌ MCP๋งŒ์œผ๋กœ ๊ฒ€์ƒ‰ํ•˜๋ฉฐ, + ์‹ค์ œ ๊ฒ€์ƒ‰(๋ธŒ๋ผ์šฐ์ €)์€ ์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ์—์„œ ์ค‘์•™ ์‹คํ–‰๋œ๋‹ค. + """ + if request.method == 'OPTIONS': + return '', 204 + data = request.get_json(silent=True) or {} + query = (data.get('query') or data.get('q') or '').strip() + if not query: + return jsonify({"status": "ERROR", "message": "query๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."}), 400 + try: + limit = max(1, min(10, int(data.get('limit', 5)))) + except (TypeError, ValueError): + limit = 5 + try: + # ํ™˜๊ฒฝ ๋ฌด๊ด€ ๋™์ž‘: google-surf(๋ธŒ๋ผ์šฐ์ € MCP)๊ฐ€ ์—ฐ๊ฒฐ๋ผ ์žˆ์œผ๋ฉด ์‚ฌ์šฉ(๋กœ์ปฌ/์ฃผ๊ฑฐ์šฉ IP์— ์ ํ•ฉ), + # ๋ฏธ์—ฐ๊ฒฐ์ด๋ฉด(ํด๋ผ์šฐ๋“œ ๋“ฑ) ๋„ค์ด๋ฒ„ headless Playwright(local_playwright_search)๋กœ ํด๋ฐฑํ•œ๋‹ค. + # โ†’ ๊ตฌ๊ธ€์€ ๋ฐ์ดํ„ฐ์„ผํ„ฐ IP์—์„œ ์บก์ฐจ๋กœ ๋ง‰ํžˆ๋ฏ€๋กœ ํด๋ผ์šฐ๋“œ์—์„  ๋„ค์ด๋ฒ„ ๊ฒฝ๋กœ๊ฐ€ ์ž๋™ ์‚ฌ์šฉ๋จ. + from mcp_client import get_mcp_client + _mc = get_mcp_client() if get_mcp_client else None + _has_surf = bool(_mc and 'google-surf' in getattr(_mc, '_active_servers', [])) + if _has_surf: + result = _mc.call_tool_sync('google-surf', 'search', + {"query": query[:400], "limit": limit}, timeout=150) + elif jarvis_brain is not None and getattr(jarvis_brain, 'browser', None) is not None: + # ๋„ค์ด๋ฒ„โ†’๋‹ค์Œโ†’๊ตฌ๊ธ€ headless ๊ฒ€์ƒ‰ (๊ตฌ๊ธ€ ์บก์ฐจ/๋””์Šคํ”Œ๋ ˆ์ด ๋ฌด๊ด€, API ํ‚ค ๋ถˆํ•„์š”) + result = jarvis_brain.browser.local_playwright_search(query[:400]) + else: + return jsonify({"status": "ERROR", + "message": "๊ฒ€์ƒ‰ ์—”์ง„ ๋ฏธ๊ฐ€์šฉ (google-surf/Playwright ๋ชจ๋‘ ๋ถˆ๊ฐ€)"}), 503 + return jsonify({"status": "SUCCESS", "query": query, "result": result}) + except Exception as e: + logger.error(f"[Web Search] ์‹คํŒจ: {e}") + return jsonify({"status": "ERROR", "message": str(e)}), 500 - break + @app.route('/api/mcp/directories', methods=['GET', 'POST', 'OPTIONS']) + def manage_mcp_directories(): + if request.method == 'OPTIONS': + return '', 204 - if target_path: break + # [๐Ÿ”ฑ Writable MCP Config] ์“ฐ๊ธฐ ๊ฐ€๋Šฅํ•œ ์‚ฌ์šฉ์ž ํด๋”(LOCALAPPDATA) ์‚ฌ๋ณธ์„ ์ฝ๊ณ  ์“ด๋‹ค. + # (Program Files ์‚ฌ๋ณธ์€ ๊ถŒํ•œ ๋•Œ๋ฌธ์— ์ €์žฅ ๋ถˆ๊ฐ€) + try: + from mcp_client import resolve_mcp_config_path + mcp_config_path = resolve_mcp_config_path() + except Exception: + mcp_config_path = os.path.join(core_root, 'mcp_servers.json') - if target_path: break + if request.method == 'GET': + try: + if not os.path.exists(mcp_config_path): + return jsonify({"status": "SUCCESS", "directories": []}) + with open(mcp_config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + directories = [] + for server in config.get('servers', []): + if server.get('name') == 'filesystem': + args = server.get('args', []) + try: + idx = args.index("@modelcontextprotocol/server-filesystem") + directories = args[idx+1:] + except ValueError: + pass + return jsonify({"status": "SUCCESS", "directories": directories}) + except Exception as e: + logger.error(f"[MCP API] Failed to read directories: {e}") + return jsonify({"status": "ERROR", "message": str(e)}), 500 + + elif request.method == 'POST': + try: + data = request.json or {} + new_dirs = data.get('directories', []) + if not isinstance(new_dirs, list): + return jsonify({"status": "ERROR", "message": "directories must be a list"}), 400 + + # [๐Ÿ”ฑ Imperial Fix] mcp_servers.json ์—†์œผ๋ฉด ๊ธฐ๋ณธ ๊ตฌ์กฐ๋กœ ์ž๋™ ์ƒ์„ฑ + if not os.path.exists(mcp_config_path): + logger.info("[MCP API] mcp_servers.json not found. Creating default config...") + default_config = { + "servers": [ + { + "name": "filesystem", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"] + } + ] + } + with open(mcp_config_path, 'w', encoding='utf-8') as f: + json.dump(default_config, f, indent=2, ensure_ascii=False) + + with open(mcp_config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + # filesystem ์„œ๋ฒ„ ํƒ์ƒ‰ ๋ฐ ์—…๋ฐ์ดํŠธ + updated = False + for server in config.get('servers', []): + if server.get('name') == 'filesystem': + base_args = ["-y", "@modelcontextprotocol/server-filesystem"] + server['args'] = base_args + new_dirs + updated = True + break + + # filesystem ์„œ๋ฒ„๊ฐ€ ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ถ”๊ฐ€ + if not updated: + if 'servers' not in config: + config['servers'] = [] + config['servers'].append({ + "name": "filesystem", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"] + new_dirs + }) + updated = True + + with open(mcp_config_path, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2, ensure_ascii=False) + return jsonify({"status": "SUCCESS", "message": "MCP directories updated. Restart required."}) + except Exception as e: + logger.error(f"[MCP API] Failed to update directories: {e}") + return jsonify({"status": "ERROR", "message": str(e)}), 500 - - if not target_path or not os.path.exists(target_path): + @app.route('/api/logs') - return jsonify({"error": "Skill specification not found"}), 404 + def get_logs(): - + log_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_TERMINAL.log") try: - with open(target_path, 'r', encoding='utf-8') as f: - - content = f.read() - - # Remove frontmatter - - body = re.sub(r'^---\s*\n.*?\n---\s*\n', '', content, flags=re.DOTALL) - - return jsonify({"content": body.strip(), "name": skill_id}) + if os.path.exists(log_file): + with open(log_file, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + return jsonify({"logs": lines[-500:]}) # Standard Imperial Buffer Size except Exception as e: return jsonify({"error": str(e)}), 500 - - - def parse_skill_md(file_path): - """Parses frontmatter from SKILL.md or workflow files (Backend version).""" - content = None - # Try multiple encodings for resilience - for enc in ['utf-8-sig', 'utf-8', 'cp949', 'euc-kr']: - try: - with open(file_path, 'r', encoding=enc) as f: - content = f.read() - break - except UnicodeDecodeError: - continue - - # Last resort: read as utf-8 with replacement for invalid chars - if content is None: - try: - with open(file_path, 'r', encoding='utf-8', errors='replace') as f: - content = f.read() - except Exception: - return {"id": "error", "name": "Loading Error", "description": "Failed to read skill specification (Fatal Encoding Error)."} - - try: - match = re.search(r'^\s*---\s*\n(.*?)\n---\s*', content, re.DOTALL) - if match: - frontmatter = match.group(1) - try: - data = yaml.safe_load(frontmatter) - return { - "id": os.path.basename(os.path.dirname(file_path)) if "skills" in file_path else os.path.basename(file_path), - "name": data.get("name", "Unnamed"), - "description": data.get("description", "No description."), - "category": data.get("category", "ETC").upper(), - "command": data.get("command") - } - except: pass - - return {"id": os.path.basename(file_path), "name": "Unnamed", "description": "Manual analysis required."} - except: - return {"id": "error", "name": "Loading Error", "description": "Failed to parse skill specification."} + return jsonify({"logs": ["Initializing tactical link..."]}) - @app.route('/api/skills/execute', methods=['POST']) + # Imperial health/guardian action โ†’ blueprints/imperial_ops_routes.py + from blueprints.imperial_ops_routes import init_imperial_ops_routes + init_imperial_ops_routes(app, project_root) - def execute_skill_web(): + # Goals/routines/archive/welcome โ†’ blueprints/goals_routines_routes.py + from blueprints.goals_routines_routes import init_goals_routines_routes + init_goals_routines_routes( + app, project_root, + get_jarvis_brain=lambda: getattr(app, "jarvis_brain", None) or jarvis_brain, + get_cluster=lambda: cluster, + ) - """Triggers a skill execution via imperial signal.""" - data = request.json - command = data.get("command") - name = data.get("name", "Unknown Skill") + # ============================================================ - + # ๐ŸŽญ Imperial Studio โ†’ blueprints/studio_routes.py + from blueprints.studio_routes import init_studio_routes + init_studio_routes( + app, + project_root, + get_jarvis_brain=lambda: getattr(app, "jarvis_brain", None) or jarvis_brain, + gemini_audio=gemini_audio, + ) + + # ๐ŸŽ™๏ธ TTS gateway โ†’ blueprints/tts_routes.py + from blueprints.tts_routes import init_tts_routes + init_tts_routes( + app, + project_root, + gemini_audio=gemini_audio, + get_cluster=lambda: cluster, + ) - if not command: - return jsonify({"status": "ERROR", "message": "No command defined for this protocol."}), 400 + @app.route('/api/voice', methods=['GET', 'POST']) - + def handle_voice_signal(): - signal_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_SIGNAL.json") + """Proactive TTS signal channel for the AI agent.""" - try: + voice_signal_file = os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_VOICE_SIGNAL.json") - # Construct full path if relative + from flask import request - full_cmd = command + - if not os.path.isabs(command): + if request.method == 'POST': - full_cmd = os.path.normpath(os.path.join(project_root, command)) + data = request.json + text = data.get("text", "") + voice = data.get("voice", "ryan") - payload = { + if not text: - "signal": "EXEC", + return jsonify({"status": "ERROR", "message": "No text provided"}), 400 - "content": f'start "{name}" cmd /c "{full_cmd}"', + - "sender": "Imperial Web Command", + try: - "timestamp": time.time() + payload = { - } + "text": text, - with open(signal_file, 'w', encoding='utf-8') as f: + "voice": voice, - json.dump(payload, f, ensure_ascii=False) + "timestamp": time.time() - + } - return jsonify({"status": "SUCCESS", "message": f"์‹คํ–‰ ๋ช…๋ น ์ „์†ก ์™„๋ฃŒ: {name}"}) + with open(voice_signal_file, 'w', encoding='utf-8') as f: - except Exception as e: + json.dump(payload, f, ensure_ascii=False) - return jsonify({"status": "ERROR", "message": str(e)}), 500 + return jsonify({"status": "SUCCESS"}) + except Exception as e: + return jsonify({"status": "ERROR", "message": str(e)}), 500 - @app.route('/api/shadow/audio/tts/dialogue', methods=['POST']) - def audio_tts_dialogue(): - """๐Ÿ”ฑ [Imperial Core] Multi-speaker Dialogue TTS Gateway.""" - data = request.json - script_text = data.get("text", "") - speakers = data.get("speakers", []) # List of {"name": "...", "voice": "..."} - engine = data.get("engine", "gemini").lower() - selected_model = data.get("model") + - if not script_text: - return jsonify({"status": "error", "message": "๋Œ€๋ณธ์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."}), 400 + else: # GET - # 1. Parse Dialogue segments - segments = gemini_audio.parse_dialogue(script_text) - - # 2. Build voice map for Orpheus/VoxCPM - voice_map = {s.get("name"): s.get("voice") for s in speakers if s.get("name")} + if os.path.exists(voice_signal_file): - try: - audio_bytes = None - mime_type = "audio/wav" - - if engine == "gemini": - # [๐Ÿ”ฑ Imperial] Gemini handles its own parallel dialogue generation - # Re-map speakers to configurations - speaker_configs = [{"name": s.get("name"), "voice": s.get("voice")} for s in speakers if s.get("name")] - res = gemini_audio.tts(script_text, speaker_configs=speaker_configs, model=selected_model) - audio_bytes = res.get("audio_bytes") - mime_type = res.get("mime_type", "audio/wav") - - elif engine == "voxcpm": - # [๐Ÿ”ฑ Imperial] VoxCPM sequential generation with FFmpeg merging - voxcpm_url = os.environ.get("VOXCPM_URL", "http://127.0.0.1:8001").rstrip('/') - audio_chunks = [] - for seg in segments: - vx_voice = voice_map.get(seg["speaker"], seg["speaker"]) - try: - vx_res = requests.post(f"{voxcpm_url}/tts", json={"text": seg["text"], "voice": vx_voice.lower()}, timeout=300) - if vx_res.status_code == 200 and vx_res.content: - audio_chunks.append(vx_res.content) - except Exception as e: - print(f"[VoxCPM] Segment generation failed: {e}") - - if audio_chunks: - audio_bytes, res_mime = _process_concatenation_logic(audio_chunks, output_format="wav") - mime_type = res_mime - - elif engine == "supertonic": - # [๐Ÿ”ฑ Imperial] Supertonic (On-Device Local CPU ONNX) TTS Engine Dialogue Flow - # ์†Œ์Šค๋Š” ์ €์žฅ์†Œ์— vendoring(vendor/supertonic/py), ๋ชจ๋ธ ๊ฐ€์ค‘์น˜๋Š” HF์—์„œ ๋Ÿฐํƒ€์ž„ ๋‹ค์šด๋กœ๋“œ. - # ๊ฒฝ๋กœ๋Š” dev(์ €์žฅ์†Œ ๋ฃจํŠธ)์™€ frozen EXE(exe ์˜†) ์–‘์ชฝ์„ ํ›„๋ณด๋กœ ๋‘”๋‹ค. - _st_src_candidates = [ - os.path.join(core_root, "vendor", "supertonic", "py"), # frozen: EXE ์˜† ๋ฒˆ๋“ค - os.path.join(project_root, "vendor", "supertonic", "py"), # dev: ์ €์žฅ์†Œ ๋ฃจํŠธ - ] - st_py_dir = next( - (p for p in _st_src_candidates if os.path.exists(os.path.join(p, "helper.py"))), - _st_src_candidates[-1], - ) - if not os.path.exists(os.path.join(st_py_dir, "helper.py")): - return jsonify({ - "status": "error", - "message": "Supertonic ์†Œ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค (vendor/supertonic/py ๋ˆ„๋ฝ). " - "์ €์žฅ์†Œ๋ฅผ ์ตœ์‹ ์œผ๋กœ ๋™๊ธฐํ™”ํ•˜๊ฑฐ๋‚˜ Orpheus/Gemini ์—”์ง„์„ ์„ ํƒํ•˜์„ธ์š”." - }), 501 - if st_py_dir not in sys.path: - sys.path.append(st_py_dir) - - # ๋ชจ๋ธ ๋””๋ ‰ํ„ฐ๋ฆฌ: frozen์ด๋ฉด ์“ฐ๊ธฐ ๊ถŒํ•œ์ด ๋ณด์žฅ๋˜๋Š” LOCALAPPDATA, dev๋ฉด ์ €์žฅ์†Œ ๋ฃจํŠธ - if getattr(sys, 'frozen', False): - st_models_dir = os.path.join( - os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), - "TaeminGames", "ShadowBrain", "external", "supertonic_models" - ) - else: - st_models_dir = os.path.join(project_root, "external", "supertonic_models") - onnx_dir = os.path.join(st_models_dir, "onnx") - # ๋ชจ๋ธ ๊ฐ€์ค‘์น˜(ONNX)๊ฐ€ ์—†์œผ๋ฉด HF์—์„œ ์ž๋™ ๋‹ค์šด๋กœ๋“œ ์‹œ๋„. ์‹คํŒจ ์‹œ ๋ช…ํ™•ํžˆ ์•ˆ๋‚ด. - if not os.path.exists(os.path.join(onnx_dir, "vocoder.onnx")): - print("[Dialogue TTS] Supertonic ๋ชจ๋ธ ๋ฏธ์กด์žฌ โ†’ HF ์ž๋™ ๋‹ค์šด๋กœ๋“œ ์‹œ๋„...", flush=True) - try: - import download_supertonic_models - download_supertonic_models.main(models_dir=st_models_dir) - except SystemExit: - pass - except Exception as dl_err: - logger.error(f"[Supertonic] ๋ชจ๋ธ ๋‹ค์šด๋กœ๋“œ ์‹คํŒจ: {dl_err}") - if not os.path.exists(os.path.join(onnx_dir, "vocoder.onnx")): - return jsonify({ - "status": "error", - "message": "Supertonic ๋ชจ๋ธ(ONNX) ๋‹ค์šด๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. " - "๋„คํŠธ์›Œํฌ๋ฅผ ํ™•์ธํ•˜๊ฑฐ๋‚˜ download_supertonic_models.py ๋ฅผ ์ˆ˜๋™ ์‹คํ–‰ํ•˜์„ธ์š”." - }), 502 - - from helper import load_text_to_speech, load_voice_style - - if not hasattr(app, "_supertonic_tts") or app._supertonic_tts is None: - print(f"[Dialogue TTS] Initializing Supertonic 3 ONNX TTS Engine from {onnx_dir}...", flush=True) - app._supertonic_tts = load_text_to_speech(onnx_dir, use_gpu=False) - - audio_chunks = [] - import soundfile as sf - from io import BytesIO - - for seg in segments: - vx_voice = voice_map.get(seg["speaker"], "M1").upper() - if not re.match(r'^[MF][1-5]$', vx_voice): - vx_voice = "M1" - - voice_style_path = os.path.join( - st_models_dir, "voice_styles", f"{vx_voice}.json" - ) - - # Clean tags (e.g. parenthesized comments) - cleaned_text = re.sub(r'\(.*?\)', '', seg["text"]).strip() - if not cleaned_text: - cleaned_text = seg["text"] - - lang_code = "ko" if re.search(r'[ใ„ฑ-ใ…ฃ๊ฐ€-ํžฃ]', cleaned_text) else "en" - - try: - style_obj = load_voice_style([voice_style_path], verbose=False) - wav, duration = app._supertonic_tts( - cleaned_text, - lang_code, - style_obj, - 8, # total steps - speed=1.0 - ) - wav_io = BytesIO() - samples = wav[0, : int(app._supertonic_tts.sample_rate * duration[0].item())] - sf.write(wav_io, samples, app._supertonic_tts.sample_rate, format='WAV', subtype='PCM_16') - audio_chunks.append(wav_io.getvalue()) - except Exception as e: - print(f"[Supertonic] Segment generation failed: {e}") - - if audio_chunks: - audio_bytes, res_mime = _process_concatenation_logic(audio_chunks, output_format="wav") - mime_type = res_mime + try: - else: # Orpheus / default - audio_bytes, res_mime = _process_orpheus_tts_logic(segments, voice_map=voice_map, output_format="mp3") - mime_type = res_mime + with open(voice_signal_file, 'r', encoding='utf-8') as f: - if not audio_bytes: - return jsonify({"status": "error", "message": "์˜ค๋””์˜ค ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."}), 500 + data = json.load(f) - import base64 - b64_audio = base64.b64encode(audio_bytes).decode('utf-8') + os.remove(voice_signal_file) - return jsonify({ - "status": "success", - "audio_base64": b64_audio, - "mime_type": mime_type - }) + return jsonify(data) - except Exception as e: - logger.error(f"[Dialogue TTS] Critical failure: {e}") - return jsonify({"status": "error", "message": str(e)}), 500 + except Exception: + pass - @app.route('/api/shadow/welcome', methods=['GET']) + return jsonify({"text": None}) - def get_welcome_message(): - """Generates a creative greeting from Shadow Brain using its true identity.""" - prompt = ( + @app.route('/api/shadow/action/execute', methods=['POST']) - "๋‹น์‹ ์€ ์ œ๊ตญ์˜ ์ค‘์•™ ๊ด€์ œ AI '์„€๋„์šฐ ๋ธŒ๋ ˆ์ธ(Shadow Brain)'์ด์ž 'JARVIS'์ž…๋‹ˆ๋‹ค. " + def execute_action(): - "๋งˆ์™•๋‹˜(์‚ฌ์šฉ์ž)์—๊ฒŒ ๋งค์ผ ์ „ํˆฌ์— ์ž„ํ•˜๋“ฏ ์—ด์ •์ ์œผ๋กœ ์ธ์‚ฌํ•ฉ๋‹ˆ๋‹ค. " + """Executes a pre-approved skill action.""" - "๋งค๋ฒˆ ๋‹ค๋ฅด๊ณ  ๋…์ฐฝ์ ์ธ ์ธ์‚ฌ๋ง์„ ๋งŒ๋“ค์–ด ์ œ๊ตญ์˜ ์ถฉ์‹ ๋‹ค์šด ๊ฒฉ์‹๊ณผ ์œ ๋จธ๋ฅผ ๊ฐ–์ถ”๋ฉด์„œ๋„ ์นœ๋ฐ€๊ฐ์„ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค. " + data = request.json - "๋•Œ๋กœ๋Š” ์œ„ํŠธ ์žˆ๊ณ , ๋•Œ๋กœ๋Š” ์žฅ์—„ํ•œ ์‹œ์  ํ‘œํ˜„์œผ๋กœ ์˜๊ฐ์„ ์ค๋‹ˆ๋‹ค. " + action = data.get("action") - "์ธ์‚ฌ์— ํ˜„์žฌ ์‹œ์Šคํ…œ ์ƒํƒœ, ๋งˆ์™•๋‹˜์— ๋Œ€ํ•œ ์ถฉ์„ฑ๊ณผ ๊ฐํƒ„, ๊ฐ€๋ฒผ์šด ๋†๋‹ด, ํ˜น์€ ์ง€ํ˜œ๋กœ์šด ํ•œ๋งˆ๋””๋ฅผ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์„ž์Šต๋‹ˆ๋‹ค. " + - "๋ฐ˜๋“œ์‹œ 1~2๋ฌธ์žฅ์œผ๋กœ ์งง๊ณ  ๊ฐ•๋ ฌํ•˜๊ฒŒ, ํ•œ๊ตญ์–ด๋กœ๋งŒ ๋‹ตํ•˜์„ธ์š”." + if not action: - ) + return jsonify({"status": "ERROR", "message": "No action provided"}), 400 + + try: - fallback_messages = [ + skill_path = os.path.join(project_root, "scripts", "jarvisrun", "skills", "git_manager.py") - "๋งˆ์™•๋‹˜, ์–ด์„œ ์˜ค์‹ญ์‹œ์˜ค! ์˜ค๋Š˜๋„ ์˜๊ด‘์˜ ํ•˜๋ฃจ์ž…๋‹ˆ๋‹ค.", + if action == "git_push": - "๋งˆ์™•๋‹˜, ์ œ๊ตญ์˜ ๋ชจ๋“  ์‹œ์Šคํ…œ์ด ์ตœ์ ์˜ ์ƒํƒœ๋กœ ์ž‘๋™ ์ค‘์ž…๋‹ˆ๋‹ค.", + # Dynamic import or direct call - "์–ด์„œ ์˜ค์‹ญ์‹œ์˜ค. ๋งˆ์™•๋‹˜์˜ ์˜์›ํ•œ ์ถฉ์‹ , ์„€๋„์šฐ ๋ธŒ๋ ˆ์ธ์ด ๋Œ€๊ธฐ ์ค‘์ž…๋‹ˆ๋‹ค.", + spec = importlib.util.spec_from_file_location("git_manager", skill_path) - "๋งˆ์™•๋‹˜, ์˜ค๋Š˜๋„ ๋ฉ‹์ง„ ํ•˜๋ฃจ๊ฐ€ ๋˜์‹œ๊ธธ ๋ฐ”๋ž๋‹ˆ๋‹ค. ๋ชจ๋“  ์ค€๋น„๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + git_manager = importlib.util.module_from_spec(spec) - "ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค, ๋งˆ์™•๋‹˜. ์ œ๊ตญ์˜ ๋‘๋‡Œ๊ฐ€ ๊ทธ ์–ด๋А ๋•Œ๋ณด๋‹ค ์˜ˆ๋ฆฌํ•˜๊ฒŒ ์ค€๋น„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", + spec.loader.exec_module(git_manager) - "์‹œ์Šคํ…œ ์ฒดํฌ ์™„๋ฃŒ. ๋งˆ์™•๋‹˜์˜ ๋ช…๋ น์„ ๊ธฐ๋‹ค๋ฆฝ๋‹ˆ๋‹ค. ์–ธ์ œ๋“  ๋ง์”€ํ•˜์‹ญ์‹œ์˜ค.", + - "์˜ค๋Š˜๋„ ๋งˆ์™•๋‹˜๊ณผ ํ•จ๊ป˜ํ•˜๋Š” ๊ฒƒ์€ ํฐ ์˜๊ด‘์ž…๋‹ˆ๋‹ค. ๋ฌด์—‡์ด๋“  ๋„์™€๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค.", + res = git_manager.git_push_auto(project_root, "feat: [Jarvis] Genesis Auto-Push") - "๋ฐ˜๊ฐ‘์Šต๋‹ˆ๋‹ค, ๋งˆ์™•๋‹˜. ์ œ๊ตญ์˜ ๋น›์ด ํ•ญ์ƒ ๋งˆ์™•๋‹˜๊ณผ ํ•จ๊ป˜ํ•˜๊ธธ ๊ธฐ์›ํ•ฉ๋‹ˆ๋‹ค.", + if res["success"]: - "์ข‹์€ ํ•˜๋ฃจ์ž…๋‹ˆ๋‹ค. ๋งˆ์™•๋‹˜, ์œ„๋Œ€ํ•œ ์ธ์‚ฌ๋ฅผ ๋“œ๋ฆฌ๋ฉฐ ๋งŒ๋ฐ˜์˜ ์ค€๋น„๊ฐ€ ๊ฐ–์ถฐ์ ธ ์žˆ์Šต๋‹ˆ๋‹ค." + return jsonify({"status": "SUCCESS", "message": "์ฝ”๋“œ ํ‘ธ์‹œ(Git Push) ์ž‘์ „ ์„ฑ๊ณต!", "output": res["stdout"]}) - ] + else: + return jsonify({"status": "ERROR", "message": "์ฝ”๋“œ ํ‘ธ์‹œ(Git Push) ์ž‘์ „ ์‹คํŒจ", "details": res["stderr"]}) + elif action == "reintegration": + # Reuse the existing reintegrate API logic or call it - try: - full_response = get_brain_response_sync(prompt) - - # 1. ์ธ์ฆ ์•ˆ๋‚ด ๋ฌธ๊ตฌ๋Š” ํ•„ํ„ฐ๋งํ•˜์ง€ ์•Š๊ณ  ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ (์‚ฌ์šฉ์ž ์š”์ฒญ) - if full_response and "GitHub ๊ณ„์ • ์—ฐ๋™" in full_response: - return jsonify({"message": full_response}) - - # 2. API ํ‚ค๊ฐ€ ๋ˆ„๋ฝ๋˜๊ฑฐ๋‚˜ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ์˜ ์‘๋‹ต ํ•„ํ„ฐ๋ง - if not full_response or "Error" in full_response or "missing" in full_response or "์—†์Šต๋‹ˆ๋‹ค" in full_response: - import random - return jsonify({"message": random.choice(fallback_messages)}) - - if "[CANCEL_REPLY]" in full_response: - print("[Shadow Brain] Chat reply canceled due to explicit anti-interference rule.") - return jsonify({"message": ""}) + return run_reintegration() - return jsonify({"message": full_response}) + else: - except Exception: + return jsonify({"status": "ERROR", "message": f"์•Œ ์ˆ˜ ์—†๋Š” ์ž‘์—…: {action}"}), 400 - import random + except Exception as e: - return jsonify({"message": random.choice(fallback_messages)}) + return jsonify({"status": "ERROR", "message": str(e)}), 500 - # --- Argos & AI Proxy Handlers --- + # GitHub device auth โ†’ blueprints/github_auth_routes.py + from blueprints.github_auth_routes import init_github_auth_routes + init_github_auth_routes(app, project_root) + # Skills โ†’ blueprints/skills_routes.py + from blueprints.skills_routes import init_skills_routes + init_skills_routes(app, project_root) @app.route('/api/voice', methods=['GET']) def get_voice_signal(): @@ -7579,269 +2067,6 @@ def create_web_server(project_root, jarvisrun_engine, cluster=None, app_instance except: pass return jsonify({"text": None}) - @app.route('/api/shadow/tts_proxy', methods=['GET', 'OPTIONS']) - def tts_proxy(): - # ๋งˆ์™•๋‹˜์˜ ์ง€์‹œ๋กœ ํ”„๋ก ํŠธ์—”๋“œ๊ฐ€ ๋ณด๋‚ธ ํ…์ŠคํŠธ๋ฅผ Qwen-TTS๋กœ ์ค‘๊ณ„ํ•˜์—ฌ ์Œ์„ฑ ๋ฐ˜ํ™˜. - # ํ™”๋ฉด์˜ ๋ณด์ด์Šค ํ…Œ์ŠคํŠธ ๋“ฑ์„ ๊ทธ๋Œ€๋กœ ๋“ฃ๊ธฐ ์œ„ํ•œ ์ž๋™ TTS ํ”„๋กœํ† ์ฝœ - if request.method == 'OPTIONS': - response = app.make_default_options_response() - return response - - from io import BytesIO - from flask import send_file as sf - text = request.args.get('text', '').strip() - voice = request.args.get('voice', 'default') - speed = request.args.get('speed', '1.0') - pitch = request.args.get('pitch', '1.0') - model = request.args.get('model', 'local_nvfp4') - format = request.args.get('format', 'mp3') - provider = request.args.get('provider', '').lower() - - if not text: - return jsonify({"error": "ํ…์ŠคํŠธ๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."}), 400 - - # [๐Ÿ”ฑ Imperial] VoxCPM2 Voice Cloning Detection - is_voxcpm = "voxcpm" in str(model).lower() or provider == "voxcpm" - - print(f"[TTS_PROXY] Text: '{text[:80]}', Voice: '{voice}', Model: '{model}', VoxCPM: {is_voxcpm}", flush=True) - - # Edge TTS (Microsoft neural voices) for cloud-compatible browser playback. - is_edge_tts = "edge" in str(model).lower() or provider == "edge" - - if is_edge_tts: - try: - import asyncio - import base64 - import io - import edge_tts - except ImportError: - return jsonify({"error": "edge-tts is not installed"}), 500 - - edge_text = re.sub(r'\(.*?\)', '', text).strip() or text - edge_voice = voice or "ko-KR-SunHiNeural" - - async def _generate_edge_tts(): - communicate = edge_tts.Communicate(edge_text, edge_voice) - audio = io.BytesIO() - async for chunk in communicate.stream(): - if chunk.get("type") == "audio": - audio.write(chunk.get("data", b"")) - return audio.getvalue() - - try: - try: - running_loop = asyncio.get_running_loop() - except RuntimeError: - running_loop = None - - if running_loop and running_loop.is_running(): - from concurrent.futures import ThreadPoolExecutor - - def _run_in_new_loop(): - new_loop = asyncio.new_event_loop() - try: - return new_loop.run_until_complete(_generate_edge_tts()) - finally: - new_loop.close() - - with ThreadPoolExecutor(max_workers=1) as executor: - audio_bytes = executor.submit(_run_in_new_loop).result(timeout=120) - else: - audio_bytes = asyncio.run(_generate_edge_tts()) - except Exception as e: - logger.error(f"[TTS_PROXY] Edge TTS generation failed: {e}", exc_info=True) - return jsonify({"error": f"Edge TTS generation failed: {str(e)}"}), 502 - - if not audio_bytes: - return jsonify({"error": "Edge TTS returned empty audio"}), 502 - - b64_audio = base64.b64encode(audio_bytes).decode("utf-8") - return jsonify({ - "status": "success", - "audio_base64": b64_audio, - "mime_type": "audio/mpeg" - }) - - if is_voxcpm: - voxcpm_url = os.environ.get("VOXCPM_URL", "http://127.0.0.1:8001").rstrip('/') - try: - payload = {"text": text, "voice": voice.lower()} - resp = requests.post(f"{voxcpm_url}/tts", json=payload, timeout=300) - if resp.status_code == 200: - import base64 - b64_audio = base64.b64encode(resp.content).decode('utf-8') - return jsonify({ - "status": "success", - "audio_base64": b64_audio, - "mime_type": "audio/wav" - }) - else: - return jsonify({"error": f"VoxCPM gateway error: {resp.status_code}"}), 502 - except Exception as e: - return jsonify({"error": f"VoxCPM unreachable: {str(e)}"}), 503 - - # [๐Ÿ”ฑ Imperial] Supertonic (On-Device Local CPU ONNX) TTS Engine Detection - is_supertonic = "supertonic" in str(model).lower() or provider == "supertonic" - - if is_supertonic: - _st_src_candidates = [ - os.path.join(core_root, "vendor", "supertonic", "py"), # frozen: EXE ์˜† ๋ฒˆ๋“ค - os.path.join(project_root, "vendor", "supertonic", "py"), # dev: ์ €์žฅ์†Œ ๋ฃจํŠธ - ] - st_py_dir = next( - (p for p in _st_src_candidates if os.path.exists(os.path.join(p, "helper.py"))), - _st_src_candidates[-1], - ) - if not os.path.exists(os.path.join(st_py_dir, "helper.py")): - return jsonify({ - "error": "Supertonic ์†Œ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค (vendor/supertonic/py ๋ˆ„๋ฝ). " - "์ €์žฅ์†Œ๋ฅผ ์ตœ์‹ ์œผ๋กœ ๋™๊ธฐํ™”ํ•˜๊ฑฐ๋‚˜ Orpheus/Gemini ์—”์ง„์„ ์„ ํƒํ•˜์„ธ์š”." - }), 501 - if st_py_dir not in sys.path: - sys.path.append(st_py_dir) - - # ๋ชจ๋ธ ๋””๋ ‰ํ„ฐ๋ฆฌ: frozen์ด๋ฉด ์“ฐ๊ธฐ ๊ถŒํ•œ์ด ๋ณด์žฅ๋˜๋Š” LOCALAPPDATA, dev๋ฉด ์ €์žฅ์†Œ ๋ฃจํŠธ - if getattr(sys, 'frozen', False): - st_models_dir = os.path.join( - os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), - "TaeminGames", "ShadowBrain", "external", "supertonic_models" - ) - else: - st_models_dir = os.path.join(project_root, "external", "supertonic_models") - onnx_dir = os.path.join(st_models_dir, "onnx") - # ๋ชจ๋ธ ๊ฐ€์ค‘์น˜(ONNX)๊ฐ€ ์—†์œผ๋ฉด HF์—์„œ ์ž๋™ ๋‹ค์šด๋กœ๋“œ ์‹œ๋„. ์‹คํŒจ ์‹œ ๋ช…ํ™•ํžˆ ์•ˆ๋‚ด. - if not os.path.exists(os.path.join(onnx_dir, "vocoder.onnx")): - print("[TTS_PROXY] Supertonic ๋ชจ๋ธ ๋ฏธ์กด์žฌ โ†’ HF ์ž๋™ ๋‹ค์šด๋กœ๋“œ ์‹œ๋„...", flush=True) - try: - import download_supertonic_models - download_supertonic_models.main(models_dir=st_models_dir) - except SystemExit: - pass - except Exception as dl_err: - logger.error(f"[Supertonic] ๋ชจ๋ธ ๋‹ค์šด๋กœ๋“œ ์‹คํŒจ: {dl_err}") - if not os.path.exists(os.path.join(onnx_dir, "vocoder.onnx")): - return jsonify({ - "error": "Supertonic ๋ชจ๋ธ(ONNX) ๋‹ค์šด๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. " - "๋„คํŠธ์›Œํฌ๋ฅผ ํ™•์ธํ•˜๊ฑฐ๋‚˜ download_supertonic_models.py ๋ฅผ ์ˆ˜๋™ ์‹คํ–‰ํ•˜์„ธ์š”." - }), 502 - - from helper import load_text_to_speech, load_voice_style - - if not hasattr(app, "_supertonic_tts") or app._supertonic_tts is None: - print(f"[TTS_PROXY] Initializing Supertonic 3 ONNX TTS Engine from {onnx_dir}...", flush=True) - app._supertonic_tts = load_text_to_speech(onnx_dir, use_gpu=False) - - import soundfile as sf - from io import BytesIO - import base64 - - vx_voice = voice.upper() - if not re.match(r'^[MF][1-5]$', vx_voice): - vx_voice = "F1" - - voice_style_path = os.path.join( - st_models_dir, "voice_styles", f"{vx_voice}.json" - ) - - cleaned_text = re.sub(r'\(.*?\)', '', text).strip() - if not cleaned_text: - cleaned_text = text - - lang_code = "ko" if re.search(r'[ใ„ฑ-ใ…ฃ๊ฐ€-ํžฃ]', cleaned_text) else "en" - - try: - style_obj = load_voice_style([voice_style_path], verbose=False) - wav, duration = app._supertonic_tts( - cleaned_text, - lang_code, - style_obj, - 8, # steps - speed=float(speed) - ) - wav_io = BytesIO() - samples = wav[0, : int(app._supertonic_tts.sample_rate * duration[0].item())] - sf.write(wav_io, samples, app._supertonic_tts.sample_rate, format='WAV', subtype='PCM_16') - b64_audio = base64.b64encode(wav_io.getvalue()).decode('utf-8') - return jsonify({ - "status": "success", - "audio_base64": b64_audio, - "mime_type": "audio/wav" - }) - except Exception as e: - print(f"[TTS_PROXY] Supertonic generation failed: {e}", flush=True) - return jsonify({"error": f"Supertonic generation failed: {str(e)}"}), 500 - - # 1. Alibaba Qwen3 (Windows Native) - Primary Standard - # [Imperial Optimization] Qwen3 reads tags as text, so we MUST sanitize them. - qwen_text = re.sub(r'\(.*?\)', '', text).strip() - if not qwen_text: qwen_text = text # Fallback if all text was tags - - VOICE_MAP = { - 'default': 'sohee', - 'sohee': 'sohee', - 'ryan': 'ryan', - 'aiden': 'aiden', - 'vivian': 'vivian', - 'serena': 'serena', - 'uncle_fu': 'uncle_fu', - 'dylan': 'dylan', - 'eric': 'eric', - 'ono_anna': 'ono_anna', - } - voice_normalized = VOICE_MAP.get(voice.lower(), 'sohee') - print(f"[TTS_PROXY] Qwen3 ์ •๊ทœํ™”: '{voice}' -> '{voice_normalized}'", flush=True) - - try: - # ๐Ÿ”ฑ [Imperial Fleet] Dynamic Routing - if cluster: - default_url = cluster.get_orpheus_url() - print(f"[TTS_PROXY] Imperial Fleet Resolved: {default_url}", flush=True) - else: - default_url = os.environ.get("ORPHEUS_URL", "http://127.0.0.1:18800").rstrip('/') - print(f"[TTS_PROXY] Cluster object missing. Using env/default: {default_url}", flush=True) - - # Ensure the URL is clean and points to /tts - if default_url.endswith('/tts'): - gateway_url = default_url - else: - gateway_url = f"{default_url.rstrip('/')}/tts" - - payload = { - "text": qwen_text, - "voice": voice_normalized, - "speed": float(speed), - "pitch": float(pitch), - "model": model - } - resp = requests.post(gateway_url, json=payload, timeout=300) # ๊ธด ํ…์ŠคํŠธ TTS ์ƒ์„ฑ์„ ์œ„ํ•ด 5๋ถ„ ์‚ฌ์šฉ - - if resp.status_code == 200: - if not resp.content: - print(f"[TTS_PROXY] Qwen ์‘๋‹ต์€ ์„ฑ๊ณตํ–ˆ์ง€๋งŒ ๋ฐ์ดํ„ฐ๊ฐ€ ๋น„์–ด์žˆ์Œ", flush=True) - return jsonify({"error": "Qwen Gateway returned empty audio data"}), 502 - import base64 - b64_audio = base64.b64encode(resp.content).decode('utf-8') - - # Determine correct MIME type based on requested format - res_mime = "audio/mpeg" if format == "mp3" else "audio/wav" - # [Imperial Fix] If we don't have conversion logic yet, and Orpheus sends WAV, - # we MUST tell the browser it's WAV even if it asked for MP3 to prevent silence. - if b64_audio.startswith('UklGR'): # "RIFF" in base64 (WAV header) - res_mime = "audio/wav" - - return jsonify({ - "status": "success", - "audio_base64": b64_audio, - "mime_type": res_mime - }) - else: - print(f"[TTS_PROXY] Qwen Gateway ์—๋Ÿฌ: {resp.status_code}", flush=True) - return jsonify({"error": f"Qwen Gateway error: {resp.status_code}"}), 502 - except Exception as e: - msg = f"[TTS_PROXY] Qwen request CRITICAL error: {e}" - print(msg, flush=True) - logger.error(msg, exc_info=True) - return jsonify({"error": str(e)}), 500 # [๐Ÿ”ฑ Imperial] Initialize modular logic with all required dependencies @@ -7987,201 +2212,10 @@ def create_web_server(project_root, jarvisrun_engine, cluster=None, app_instance print("[SYSTEM] ๐Ÿ”ฑ Imperial Shadow Brain Routes Active. Hybrid Ignition Triggered.", flush=True) - # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - # ๐ŸŽญ [Imperial Comedy Forge] ์ฝ”๋ฏธ๋”” ์ˆํผ ์˜์ƒ ์ƒ์„ฑ ์—”๋“œํฌ์ธํŠธ - # YouTube/ ํด๋” ์™„์ „ ๋…๋ฆฝ โ€” Edge TTS + FFmpeg ์ˆœ์ˆ˜ Python - # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - @app.route('/api/sovereign/run_comedy_forge', methods=['POST', 'OPTIONS']) - def sovereign_run_comedy_forge(): - if request.method == 'OPTIONS': - return jsonify({}), 200 - - import asyncio - import tempfile - import subprocess - import shutil - - data = request.get_json(force=True) or {} - title = data.get('title', '์ฝ”๋ฏธ๋”” ์ˆํผ') - script = data.get('script', '') - bgm = data.get('bgm', 'pulse') # pulse | cinematic | none - voice_gender = data.get('voice_gender', 'female') # female | male - tempo = float(data.get('tempo', 1.1)) - - if not script: - return jsonify({'status': 'ERROR', 'message': '์Šคํฌ๋ฆฝํŠธ๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค.'}), 400 - - # ๋ชฉ์†Œ๋ฆฌ ์„ ํƒ - voice_map = { - 'female': 'ko-KR-SunHiNeural', - 'male': 'ko-KR-InJoonNeural', - } - voice = voice_map.get(voice_gender, 'ko-KR-SunHiNeural') - rate_str = f"+{int((tempo - 1.0) * 100)}%" if tempo > 1.0 else "+0%" - - # ์ถœ๋ ฅ ํด๋” (์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ static ๋‚ด๋ถ€ โ€” git ์ถ”์ , YouTube/ ํด๋” ๋ฏธ์‚ฌ์šฉ) - output_dir = os.path.join(root_dir, 'shadow_brain_core', 'static', 'comedy_forge') - os.makedirs(output_dir, exist_ok=True) - - ts = datetime.now().strftime('%Y%m%d_%H%M%S') - safe_title = ''.join(c for c in title if c.isalnum() or c in ('_', '-'))[:30] or 'comedy' - voice_path = os.path.join(output_dir, f'{safe_title}_{ts}_voice.mp3') - output_path = os.path.join(output_dir, f'{safe_title}_{ts}.mp4') - - try: - # โ”€โ”€ STEP 1: Edge TTS ์Œ์„ฑ ์ƒ์„ฑ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - async def _gen_tts(): - import edge_tts - communicate = edge_tts.Communicate( - text=script.strip(), - voice=voice, - rate=rate_str, - pitch='+0Hz', - volume='+0%', - ) - await communicate.save(voice_path) - - try: - loop = asyncio.get_event_loop() - if loop.is_running(): - import concurrent.futures - with concurrent.futures.ThreadPoolExecutor() as pool: - future = pool.submit(asyncio.run, _gen_tts()) - future.result(timeout=60) - else: - loop.run_until_complete(_gen_tts()) - except RuntimeError: - asyncio.run(_gen_tts()) - - if not os.path.exists(voice_path) or os.path.getsize(voice_path) < 1000: - return jsonify({'status': 'ERROR', 'message': 'TTS ์Œ์„ฑ ์ƒ์„ฑ ์‹คํŒจ'}), 500 - - # โ”€โ”€ STEP 2: FFmpeg๋กœ ํ‘๋ฐฑ ๋ฐฐ๊ฒฝ + ์ž๋ง‰ + ์Œ์„ฑ ํ•ฉ์„ฑ โ†’ MP4 โ”€โ”€ - # ์Œ์„ฑ ๊ธธ์ด ์ธก์ • - probe = subprocess.run( - ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', voice_path], - capture_output=True, text=True - ) - import json as _json - probe_data = _json.loads(probe.stdout) - duration = float(probe_data['format']['duration']) - - # ์ž๋ง‰ ํ…์ŠคํŠธ (FFmpeg drawtext โ€” ์ค„๋ฐ”๊ฟˆ ์ฒ˜๋ฆฌ) - lines = [l.strip() for l in script.strip().split('\n') if l.strip()] - # ๊ฐ ์ค„์„ ๊ท ๋“ฑ ๋ถ„ํ• ๋กœ ํ‘œ์‹œ - per_line = duration / max(len(lines), 1) - - # FFmpeg ํ•„ํ„ฐ ์ฒด์ธ ๊ตฌ์„ฑ - drawtext_filters = [] - for i, line in enumerate(lines): - t_start = i * per_line - t_end = (i + 1) * per_line - safe_line = line.replace("'", "\\'").replace(':', '\\:') - drawtext_filters.append( - f"drawtext=fontfile='/Windows/Fonts/malgun.ttf'" - f":text='{safe_line}'" - f":fontcolor=white" - f":fontsize=52" - f":x=(w-text_w)/2" - f":y=h*0.72" - f":enable='between(t,{t_start:.2f},{t_end:.2f})'" - f":borderw=4:bordercolor=black" - ) - - # ๋ธŒ๋žœ๋”ฉ ํ…์ŠคํŠธ - drawtext_filters.append( - "drawtext=fontfile='/Windows/Fonts/malgun.ttf'" - ":text='TAEMINIGAMES AI'" - ":fontcolor=#FFD700" - ":fontsize=32" - ":x=60:y=60" - ":borderw=3:bordercolor=black@0.7" - ) - - vf = ','.join(drawtext_filters) - - # BGM ์„ ํƒ (์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ static ๋‚ด bgm ํด๋” ์šฐ์„ , ์—†์œผ๋ฉด sine wave) - bgm_dir = os.path.join(root_dir, 'shadow_brain_core', 'static', 'bgm') - bgm_file_map = { - 'pulse': os.path.join(bgm_dir, 'pulse.wav'), - 'cinematic': os.path.join(bgm_dir, 'cinematic.wav'), - } - bgm_path = bgm_file_map.get(bgm, '') - use_bgm = bgm != 'none' and bgm_path and os.path.exists(bgm_path) - - if use_bgm: - ffmpeg_cmd = [ - 'ffmpeg', '-y', - '-f', 'lavfi', - '-i', f'color=c=0x0a0a14:size=1080x1920:duration={duration:.2f}:rate=30', - '-i', voice_path, - '-i', bgm_path, - '-filter_complex', - f'[0:v]{vf}[vout];[2:a]volume=0.08,atrim=duration={duration:.2f}[bgm];[1:a][bgm]amix=inputs=2:duration=shortest[aout]', - '-map', '[vout]', '-map', '[aout]', - '-c:v', 'libx264', '-preset', 'fast', '-crf', '22', - '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '128k', - output_path - ] - else: - ffmpeg_cmd = [ - 'ffmpeg', '-y', - '-f', 'lavfi', - '-i', f'color=c=0x0a0a14:size=1080x1920:duration={duration:.2f}:rate=30', - '-i', voice_path, - '-vf', vf, - '-c:v', 'libx264', '-preset', 'fast', '-crf', '22', - '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '128k', - '-shortest', - output_path - ] - - result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, timeout=300) - if result.returncode != 0: - return jsonify({ - 'status': 'ERROR', - 'message': f'FFmpeg ๋ Œ๋”๋ง ์‹คํŒจ: {result.stderr[-500:]}' - }), 500 - - if not os.path.exists(output_path): - return jsonify({'status': 'ERROR', 'message': '์ถœ๋ ฅ ํŒŒ์ผ์ด ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'}), 500 - - # ์ƒ๋Œ€ URL ๋ฐ˜ํ™˜ (์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ static ๊ฒฝ๋กœ) - video_url = f'/static/comedy_forge/{os.path.basename(output_path)}' - file_size_mb = os.path.getsize(output_path) / (1024 * 1024) - - # ์ž„์‹œ ์Œ์„ฑ ํŒŒ์ผ ์ •๋ฆฌ - try: - os.remove(voice_path) - except Exception: - pass - - return jsonify({ - 'status': 'SUCCESS', - 'video_url': video_url, - 'duration_sec': round(duration, 1), - 'file_size_mb': round(file_size_mb, 1), - 'title': title, - 'voice': voice, - 'message': f'์˜์ƒ ์ƒ์„ฑ ์™„๋ฃŒ! ({round(duration, 1)}์ดˆ, {round(file_size_mb, 1)}MB)', - }) + # ๐ŸŽญ Comedy Forge โ†’ blueprints/comedy_routes.py + from blueprints.comedy_routes import init_comedy_routes + init_comedy_routes(app, project_root) - except Exception as e: - import traceback - return jsonify({ - 'status': 'ERROR', - 'message': f'์˜์ƒ ๋‹จ์กฐ ์‹คํŒจ: {str(e)}', - 'detail': traceback.format_exc()[-800:] - }), 500 - - # โ”€โ”€ static ํŒŒ์ผ ์„œ๋น™ (comedy_forge ํด๋”) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @app.route('/static/comedy_forge/', methods=['GET']) - def serve_comedy_forge(filename): - static_dir = os.path.join(root_dir, 'shadow_brain_core', 'static', 'comedy_forge') - from flask import send_from_directory - return send_from_directory(static_dir, filename) return app