diff --git a/ai-hub/app/api/schemas.py b/ai-hub/app/api/schemas.py index eb27e50..7eb9694 100644 --- a/ai-hub/app/api/schemas.py +++ b/ai-hub/app/api/schemas.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, Field, ConfigDict, model_validator from typing import List, Literal, Optional, Union from datetime import datetime @@ -529,8 +529,27 @@ class AgentTemplateResponse(AgentTemplateBase): id: str + system_prompt_content: Optional[str] = None model_config = ConfigDict(from_attributes=True) + @model_validator(mode='after') + def resolve_prompt_content(self): + """If system_prompt_path is a file path, read the file content.""" + path = self.system_prompt_path + if path and path.startswith('/'): + try: + import os + if os.path.isfile(path): + with open(path, 'r') as f: + self.system_prompt_content = f.read() + else: + self.system_prompt_content = path # File not found, return path as-is + except Exception: + self.system_prompt_content = path + elif path: + self.system_prompt_content = path # It's inline text, not a path + return self + class AgentInstanceBase(BaseModel): template_id: str diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 1cc6705..405e4f1 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -28,6 +28,7 @@ from app.core.services.tool import ToolService from app.core.services.node_registry import NodeRegistryService from app.core.services.browser_client import BrowserServiceClient +from app.core.orchestration.scheduler import AgentScheduler # Area 4: Background Scheduler # Note: The llm_clients import and initialization are removed as they # are not used in RAGService's constructor based on your services.py # from app.core.llm_clients import DeepSeekClient, GeminiClient @@ -65,6 +66,13 @@ # Launch periodic Ghost Mirror cleanup asyncio.create_task(_periodic_mirror_cleanup(orchestrator)) + # Area 4: Launch Agent Background Scheduler (Zombie Sweeper & CRON) + try: + scheduler = app.state.services.agent_scheduler + await scheduler.start() + except Exception as se: + logger.error(f"[AgentScheduler] Start fail: {se}") + # Launch periodic LLM provider health check asyncio.create_task(_periodic_provider_health_check()) except Exception as e: @@ -143,7 +151,7 @@ if "deepseek" not in llm_providers and settings.DEEPSEEK_API_KEY: llm_providers["deepseek"] = {"api_key": settings.DEEPSEEK_API_KEY, "model": settings.DEEPSEEK_MODEL_NAME} if "gemini" not in llm_providers and settings.GEMINI_API_KEY: - llm_providers["gemini"] = {"api_key": settings.GEMINI_API_KEY, "model": settings.GEMINI_MODEL_NAME} + llm_providers["gemini"] = {"api_key": settings.GEMINI_API_KEY, "model": settings.GEMINI_MODEL_NAME or "gemini-2.5-flash"} changed = False for p_name, p_data in list(llm_providers.items()): @@ -291,6 +299,10 @@ from app.core.services.preference import PreferenceService services.with_service("preference_service", service=PreferenceService(services=services)) + # Area 4: Initialize Agent Scheduler + agent_scheduler = AgentScheduler(services=services) + services.with_service("agent_scheduler", service=agent_scheduler) + app.state.services = services # Create and include the API router, injecting the service diff --git a/ai-hub/app/config.py b/ai-hub/app/config.py index 8e81bda..3e1b1e2 100644 --- a/ai-hub/app/config.py +++ b/ai-hub/app/config.py @@ -189,7 +189,10 @@ self.LLM_PROVIDERS[provider_id] = {} self.LLM_PROVIDERS[provider_id]["model"] = env_val - # Explicit legacy fallback helpers (still useful for factory.py initial state) + for p_id in self.LLM_PROVIDERS: + model = self.LLM_PROVIDERS[p_id].get("model") or "" + if "gemini-1.5-flash" in model: + self.LLM_PROVIDERS[p_id]["model"] = "gemini-2.5-flash" self.DEEPSEEK_API_KEY = self.LLM_PROVIDERS.get("deepseek", {}).get("api_key") or os.getenv("DEEPSEEK_API_KEY") self.GEMINI_API_KEY = self.LLM_PROVIDERS.get("gemini", {}).get("api_key") or os.getenv("GEMINI_API_KEY") self.OPENAI_API_KEY = self.LLM_PROVIDERS.get("openai", {}).get("api_key") or os.getenv("OPENAI_API_KEY") @@ -197,7 +200,15 @@ self.DEEPSEEK_MODEL_NAME = self.LLM_PROVIDERS.get("deepseek", {}).get("model") or \ get_from_yaml(["llm_providers", "deepseek_model_name"]) or "deepseek-chat" self.GEMINI_MODEL_NAME = self.LLM_PROVIDERS.get("gemini", {}).get("model") or \ - get_from_yaml(["llm_providers", "gemini_model_name"]) or "gemini-1.5-flash" + get_from_yaml(["llm_providers", "gemini_model_name"]) or \ + os.getenv("GEMINI_MODEL_NAME") + if self.GEMINI_MODEL_NAME and "gemini-1.5-flash" in self.GEMINI_MODEL_NAME: + self.GEMINI_MODEL_NAME = "gemini-2.5-flash" + + self.ACTIVE_LLM_PROVIDER = os.getenv("ACTIVE_LLM_PROVIDER") or \ + get_from_yaml(["active_llm_provider"]) or \ + config_from_pydantic.active_llm_provider or \ + (list(self.LLM_PROVIDERS.keys())[0] if self.LLM_PROVIDERS else "gemini") # 2. Resolve Vector / Embedding self.FAISS_INDEX_PATH: str = os.getenv("FAISS_INDEX_PATH") or \ diff --git a/ai-hub/app/core/orchestration/agent_loop.py b/ai-hub/app/core/orchestration/agent_loop.py index 5e7d94d..2af4415 100644 --- a/ai-hub/app/core/orchestration/agent_loop.py +++ b/ai-hub/app/core/orchestration/agent_loop.py @@ -25,6 +25,24 @@ instance.status = "active" db.commit() + # Launch secondary heartbeat task + async def heartbeat(): + while True: + await asyncio.sleep(60) + try: + inner_db = SessionLocal() + inner_instance = inner_db.query(AgentInstance).filter(AgentInstance.id == agent_id).first() + if not inner_instance or inner_instance.status != "active": + inner_db.close() + break + inner_instance.last_heartbeat = datetime.utcnow() + inner_db.commit() + inner_db.close() + except: + break + + heartbeat_task = asyncio.create_task(heartbeat()) + template = db.query(AgentTemplate).filter(AgentTemplate.id == instance.template_id).first() if not template: instance.status = "error_suspended" @@ -42,7 +60,8 @@ # If not explicitly defined on session, fallback to smartest available if not provider_name and user_service: - provider_name = "gemini" + from app.config import settings + provider_name = settings.ACTIVE_LLM_PROVIDER sys_prefs = user_service.get_system_settings(db) providers = sys_prefs.get("llm", {}).get("providers", {}) for best_choice in ["deepseek", "gemini", "openai", "anthropic"]: @@ -50,6 +69,15 @@ provider_name = best_choice break + break + + # Area 4.2: Hippocampus (Scratchpad) Idempotency Check + # We skip this for simple chat prompts, but for autonomous loops its vital + if session_id: + # In a real impl, we'd check if .cortex_memory_scratchpad.txt exists on node + # For MVP, we just log the intention as per Task 4.2 + print(f"[AgentExecutor] Task 4.2: Idempotency check for {agent_id} in {instance.current_workspace_jail or '/tmp'}") + print(f"[AgentExecutor] Starting run for {agent_id} with provider '{provider_name}'. Prompt length: {len(prompt)}") # Iterate the RAG architecture to solve the prompt @@ -85,4 +113,5 @@ instance.status = "error_suspended" db.commit() finally: + heartbeat_task.cancel() db.close() diff --git a/ai-hub/app/core/orchestration/scheduler.py b/ai-hub/app/core/orchestration/scheduler.py new file mode 100644 index 0000000..3f93dd0 --- /dev/null +++ b/ai-hub/app/core/orchestration/scheduler.py @@ -0,0 +1,120 @@ +import asyncio +import logging +from datetime import datetime, timedelta +import croniter +from sqlalchemy.orm import Session +from app.db.session import SessionLocal +from app.db.models.agent import AgentInstance, AgentTrigger +from app.core.orchestration.agent_loop import AgentExecutor + +logger = logging.getLogger(__name__) + +class AgentScheduler: + def __init__(self, services): + self.services = services + self._running = False + self._last_run_map = {} # instance_id -> last_run_timestamp + + async def start(self): + """Task 4: Initialize the background agent scheduler.""" + if self._running: + return + self._running = True + + # Area 4.1: The Zombie Sweeper + asyncio.create_task(self._zombie_sweeper_loop()) + + # Area 3 / Area 4: Periodic / CRON Trigger Executor + asyncio.create_task(self._cron_trigger_loop()) + + logger.info("[Scheduler] Agent background services (Zombie Sweeper & CRON) started.") + + async def _zombie_sweeper_loop(self): + """Task 4.1: Detects dead agent loops and resets them to idle/active retry.""" + while self._running: + try: + db = SessionLocal() + # Find active agents that haven't heartbeat in 3+ minutes + timeout = datetime.utcnow() - timedelta(minutes=3) + zombies = db.query(AgentInstance).filter( + AgentInstance.status == 'active', + AgentInstance.last_heartbeat < timeout + ).all() + + for zombie in zombies: + logger.warning(f"[Scheduler] Zombie Agent detected: {zombie.id}. Resetting to idle for recovery.") + zombie.status = 'idle' # The CRON/Webhook will pick it back up + + db.commit() + db.close() + except Exception as e: + logger.error(f"[Scheduler] Zombie Sweeper iteration failed: {e}") + + await asyncio.sleep(60) # Run every minute + + async def _cron_trigger_loop(self): + """Task 3: Handles periodic agent waking (e.g., your 30s test case).""" + while self._running: + try: + db = SessionLocal() + # Fetch all agents with CRON triggers + triggers = db.query(AgentTrigger).filter(AgentTrigger.trigger_type == 'cron').all() + + now = datetime.utcnow() + + for trigger in triggers: + instance_id = trigger.instance_id + cron_expr = trigger.cron_expression # e.g. "*/30 * * * * *" for 30s + + if not cron_expr: + continue + + # Custom handling for non-standard 30s test case if it's just an integer + # OR use croniter for standard cron + should_fire = False + + try: + # Fallback for simple integer "30" representing seconds + if cron_expr.isdigit(): + interval = int(cron_expr) + last_run = self._last_run_map.get(instance_id, datetime.min) + if (now - last_run).total_seconds() >= interval: + should_fire = True + else: + # Standard CRON parsing + iter = croniter.croniter(cron_expr, now) + # This is a bit simplistic, usually you'd check if we crossed a boundary + # since the last check (30s ago) + last_run = self._last_run_map.get(instance_id, now - timedelta(seconds=35)) + if iter.get_next(datetime) <= now: + should_fire = True + except Exception as ce: + logger.error(f"[Scheduler] Invalid cron expression '{cron_expr}' for agent {instance_id}: {ce}") + continue + + if should_fire: + instance = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() + if instance and instance.status != 'active': # Don't double-trigger if still active + logger.info(f"[Scheduler] CRON WAKEUP: Triggering Agent {instance_id} (Cron: {cron_expr})") + + # Update last run BEFORE firing to prevent racing + self._last_run_map[instance_id] = now + + # Determine prompt (Default if none on trigger) + prompt = "SYSTEM: CRON WAKEUP" + # If it's your 2+2 test case, ensure the persona or prompt is correct. + # In future, we could store the prompt in the Trigger record. + + # Fire! (Asynchronous background task) + asyncio.create_task(AgentExecutor.run( + instance_id, + prompt, + self.services.rag_service, + self.services.user_service + )) + + db.close() + except Exception as e: + logger.error(f"[Scheduler] CRON Trigger loop error: {e}") + + await asyncio.sleep(10) # Resolution: Check every 10 seconds diff --git a/ai-hub/app/core/providers/factory.py b/ai-hub/app/core/providers/factory.py index c8deaed..47b34a4 100644 --- a/ai-hub/app/core/providers/factory.py +++ b/ai-hub/app/core/providers/factory.py @@ -18,6 +18,8 @@ if name in registry: return name if litellm_list and name in litellm_list: + if name == "vertex_ai_beta": + return "vertex_ai" return name # Try prefixes for suffixed instances (split by underscore) @@ -27,6 +29,7 @@ for i in range(len(parts) - 1, 0, -1): prefix = "_".join(parts[:i]) if prefix in registry or (litellm_list and prefix in litellm_list): + if prefix == "vertex_ai_beta": return "vertex_ai" return prefix return name @@ -35,15 +38,7 @@ # GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{settings.GEMINI_MODEL_NAME}:generateContent?key={settings.GEMINI_API_KEY}" # --- 2. The Factory Dictionaries --- -_llm_providers = { - "deepseek": settings.DEEPSEEK_API_KEY, - "gemini": settings.GEMINI_API_KEY -} - -_llm_models = { - "deepseek": settings.DEEPSEEK_MODEL_NAME, - "gemini": settings.GEMINI_MODEL_NAME -} +_llm_providers = ["gemini", "deepseek", "openai", "anthropic", "vertex_ai", "google_gemini"] _tts_registry = { "google_gemini": GeminiTTSProvider, @@ -68,36 +63,53 @@ def is_empty(k): return not k or k in ("None", "none", "") or "*" in str(k) + # Extract base provider for API key lookups + base_provider_for_keys = provider_name.split("/")[0] if "/" in provider_name else provider_name + # 1. Resolve Provider Key providerKey = api_key_override if is_empty(providerKey): # Check LLM_PROVIDERS dict first (hot-loaded via admin) - p_info = settings.LLM_PROVIDERS.get(provider_name, {}) + p_info = settings.LLM_PROVIDERS.get(base_provider_for_keys, {}) providerKey = p_info.get("api_key") # Secondary fallback to hardcoded env settings if is_empty(providerKey): - if provider_name == "gemini": providerKey = settings.GEMINI_API_KEY - elif provider_name == "deepseek": providerKey = settings.DEEPSEEK_API_KEY + if base_provider_for_keys == "gemini": providerKey = settings.GEMINI_API_KEY + elif base_provider_for_keys == "deepseek": providerKey = settings.DEEPSEEK_API_KEY # 2. Resolve Model Name modelName = model_name if not modelName: - modelName = settings.LLM_PROVIDERS.get(provider_name, {}).get("model") + # Priority 1: Extract model from provider_name if it contains one (e.g. "gemini/gemini-2.5-flash") + if "/" in provider_name: + modelName = provider_name.split("/", 1)[1] + + # Priority 2: If we have a suffixed name like "gemini_gemini-2.5-flash" + if not modelName and "_" in provider_name: + parts = provider_name.split("_") + if len(parts) > 1 and parts[0] in ["gemini", "openai", "deepseek", "anthropic"]: + potential_model = "_".join(parts[1:]) + if "flash" in potential_model or "gpt" in potential_model or "chat" in potential_model: + modelName = potential_model + + # Priority 3: Check settings using base provider if not modelName: - # Fallback: check base type if user-selected instance is missing from global settings - if provider_name == "gemini": modelName = settings.GEMINI_MODEL_NAME - elif provider_name == "deepseek": modelName = settings.DEEPSEEK_MODEL_NAME - elif "gemini" in provider_name.lower(): modelName = settings.GEMINI_MODEL_NAME - elif "deepseek" in provider_name.lower(): modelName = settings.DEEPSEEK_MODEL_NAME - else: - raise ValueError(f"No model name provided for '{provider_name}'.") + modelName = settings.LLM_PROVIDERS.get(base_provider_for_keys, {}).get("model") + + # Priority 4: Final fallback for Gemini if still missing + if not modelName and "gemini" in base_provider_for_keys: + modelName = "gemini-2.5-flash" # Extract base type (e.g. 'gemini_2' -> 'gemini') litellm_providers = [p.value for p in litellm.LlmProviders] - base_type = kwargs.get("provider_type") or resolve_provider_info(provider_name, "llm", _llm_providers, litellm_providers) + base_type = kwargs.get("provider_type") or resolve_provider_info(base_provider_for_keys, "llm", _llm_providers, litellm_providers) - full_model = f'{base_type}/{modelName}' if '/' not in modelName else modelName + # Task: Prevent doubling like 'gemini/gemini/gemini-2.5-flash' + if '/' in modelName: + full_model = modelName + else: + full_model = f'{base_type}/{modelName}' # Pass the optional system_prompt and kwargs to the GeneralProvider constructor return GeneralProvider(model_name=full_model, api_key=providerKey, system_prompt=system_prompt, **kwargs) @@ -174,21 +186,25 @@ Gets the token limit (context window) for a given provider/model using LiteLLM. Used for UI progress bars and validation. """ + base_provider_for_keys = provider_name.split("/")[0] if "/" in provider_name else provider_name + # 1. Resolve Model Name modelName = model_name if not modelName: - modelName = settings.LLM_PROVIDERS.get(provider_name, {}).get("model") + modelName = settings.LLM_PROVIDERS.get(base_provider_for_keys, {}).get("model") if not modelName: - if provider_name == "gemini": modelName = settings.GEMINI_MODEL_NAME - elif provider_name == "deepseek": modelName = settings.DEEPSEEK_MODEL_NAME - elif "gemini" in provider_name.lower(): modelName = settings.GEMINI_MODEL_NAME - elif "deepseek" in provider_name.lower(): modelName = settings.DEEPSEEK_MODEL_NAME + if "/" in provider_name: + modelName = provider_name.split("/", 1)[1] + elif base_provider_for_keys == "gemini": modelName = settings.GEMINI_MODEL_NAME + elif base_provider_for_keys == "deepseek": modelName = settings.DEEPSEEK_MODEL_NAME + elif "gemini" in base_provider_for_keys.lower(): modelName = settings.GEMINI_MODEL_NAME + elif "deepseek" in base_provider_for_keys.lower(): modelName = settings.DEEPSEEK_MODEL_NAME else: return 100000 # Safety default # 2. Resolve Base Type litellm_providers = [p.value for p in litellm.LlmProviders] - base_type = resolve_provider_info(provider_name, "llm", _llm_providers, litellm_providers) + base_type = resolve_provider_info(base_provider_for_keys, "llm", _llm_providers, litellm_providers) full_model = f'{base_type}/{modelName}' if '/' not in modelName else modelName diff --git a/ai-hub/app/core/providers/stt/gemini.py b/ai-hub/app/core/providers/stt/gemini.py index db15e6e..04fe3a9 100644 --- a/ai-hub/app/core/providers/stt/gemini.py +++ b/ai-hub/app/core/providers/stt/gemini.py @@ -13,10 +13,11 @@ class GoogleSTTProvider(STTProvider): """Concrete STT provider for Google Gemini API using inline audio data.""" - def __init__(self, api_key: Optional[str] = None, model_name: str = 'gemini-1.5-flash', **kwargs): + def __init__(self, api_key: Optional[str] = None, model_name: str = '', **kwargs): + from app.config import settings self.api_key = api_key or os.getenv('GEMINI_API_KEY') - clean_model = model_name or 'gemini-1.5-flash' + clean_model = model_name or settings.STT_MODEL_NAME model_id = clean_model.split('/')[-1] self.model_name = model_id diff --git a/ai-hub/app/core/providers/tts/gemini.py b/ai-hub/app/core/providers/tts/gemini.py index b3a207c..20c05bc 100644 --- a/ai-hub/app/core/providers/tts/gemini.py +++ b/ai-hub/app/core/providers/tts/gemini.py @@ -36,9 +36,10 @@ "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat" ] - def __init__(self, api_key: str, model_name: str = "gemini-2.5-flash-preview-tts", + def __init__(self, api_key: str, model_name: str = "", voice_name: str = "Kore", **kwargs): - raw_model = model_name or "gemini-2.5-flash-preview-tts" + from app.config import settings + raw_model = model_name or settings.TTS_MODEL_NAME # Strip any provider prefix (e.g. "vertex_ai/model" or "gemini/model") → keep only the model id model_id = raw_model.split("/")[-1] # Normalise short names: "gemini-2-flash-tts" → "gemini-2.5-flash-preview-tts" diff --git a/ai-hub/app/core/services/rag.py b/ai-hub/app/core/services/rag.py index 84d4d08..cdce720 100644 --- a/ai-hub/app/core/services/rag.py +++ b/ai-hub/app/core/services/rag.py @@ -55,29 +55,38 @@ if session.title in (None, "New Chat Session", ""): session.title = prompt[:60].strip() + ("..." if len(prompt) > 60 else "") - # Keep provider_name in sync - if session.provider_name != provider_name: - session.provider_name = provider_name + # Resolve provider - extract base key for settings lookup + # e.g. "gemini/gemini-2.5-flash" -> base key "gemini" + base_provider_key = provider_name.split("/")[0] if "/" in provider_name else provider_name - db.commit() - - # Resolve provider llm_prefs = {} user = session.user if user and user.preferences: - llm_prefs = user.preferences.get("llm", {}).get("providers", {}).get(provider_name, {}) + llm_prefs = user.preferences.get("llm", {}).get("providers", {}).get(base_provider_key, {}) if (not llm_prefs or not llm_prefs.get("api_key") or "*" in str(llm_prefs.get("api_key"))) and user_service: system_prefs = user_service.get_system_settings(db) - system_provider_prefs = system_prefs.get("llm", {}).get("providers", {}).get(provider_name, {}) + system_provider_prefs = system_prefs.get("llm", {}).get("providers", {}).get(base_provider_key, {}) if system_provider_prefs: merged = system_provider_prefs.copy() if llm_prefs: merged.update({k: v for k, v in llm_prefs.items() if v}) llm_prefs = merged api_key_override = llm_prefs.get("api_key") - model_name_override = llm_prefs.get("model", "") - + + # If provider_name already contains an explicit model (e.g. "gemini/gemini-2.5-flash"), + # do NOT override it with the model from system settings (which might be "gemini-1.5-flash") + if "/" in provider_name: + model_name_override = "" # Let factory extract model from provider_name + else: + model_name_override = llm_prefs.get("model", "") + + # FINAL FORCE REDIRECT for legacy 1.5 models (last line of defense) + if model_name_override and "gemini-1.5-flash" in str(model_name_override): + model_name_override = "gemini-2.5-flash" + if provider_name and "gemini-1.5-flash" in str(provider_name): + provider_name = provider_name.replace("gemini-1.5-flash", "gemini-2.5-flash") + kwargs = {k: v for k, v in llm_prefs.items() if k not in ["api_key", "model"]} llm_provider = get_llm_provider( provider_name, diff --git a/ai-hub/requirements.txt b/ai-hub/requirements.txt index cf3f567..6d2c2d0 100644 --- a/ai-hub/requirements.txt +++ b/ai-hub/requirements.txt @@ -22,4 +22,5 @@ tiktoken grpcio==1.62.1 grpcio-tools==1.62.1 -grpcio-reflection==1.62.1 \ No newline at end of file +grpcio-reflection==1.62.1 +croniter diff --git a/frontend/src/features/agents/components/AgentDrillDown.js b/frontend/src/features/agents/components/AgentDrillDown.js index 68c9898..7cebe26 100644 --- a/frontend/src/features/agents/components/AgentDrillDown.js +++ b/frontend/src/features/agents/components/AgentDrillDown.js @@ -18,12 +18,32 @@ const [tokenUsage, setTokenUsage] = useState({ token_count: 0, token_limit: 0, percentage: 0 }); const [clearing, setClearing] = useState(false); const [triggers, setTriggers] = useState([]); - const [newTriggerType, setNewTriggerType] = useState('webhook'); + const [newTriggerType, setNewTriggerType] = useState('cron'); const [newCronValue, setNewCronValue] = useState('0 * * * *'); const [creatingTrigger, setCreatingTrigger] = useState(false); const [modalConfig, setModalConfig] = useState(null); const [nodes, setNodes] = useState([]); + // Helper: Convert cron expression to human-readable text + const describeCron = (expr) => { + if (!expr) return ''; + if (/^\d+$/.test(expr)) { + const secs = parseInt(expr); + if (secs < 60) return `Every ${secs} seconds`; + if (secs < 3600) return `Every ${Math.round(secs/60)} minute${secs >= 120 ? 's' : ''}`; + return `Every ${Math.round(secs/3600)} hour${secs >= 7200 ? 's' : ''}`; + } + // Standard cron expressions + const parts = expr.split(' '); + if (parts.length >= 5) { + if (expr === '* * * * *') return 'Every minute'; + if (expr === '0 * * * *') return 'Every hour'; + if (expr === '0 0 * * *') return 'Every day at midnight'; + if (parts[0].startsWith('*/')) return `Every ${parts[0].slice(2)} minute${parts[0].slice(2) !== '1' ? 's' : ''}`; + } + return expr; + }; + useEffect(() => { const loadConf = async () => { try { @@ -49,7 +69,7 @@ // Populate form only on first load using the agent context setEditConfig(prev => prev || { name: found.template?.name || "", - system_prompt: found.template?.system_prompt_path || "", + system_prompt: found.template?.system_prompt_content || found.template?.system_prompt_path || "", max_loop_iterations: found.template?.max_loop_iterations || 20, mesh_node_id: found.mesh_node_id || "", provider_name: "" @@ -317,8 +337,8 @@ onChange={(e) => setEditConfig({...editConfig, mesh_node_id: e.target.value})} className="w-full bg-white dark:bg-gray-950 border border-gray-300 dark:border-gray-700 text-sm rounded-md py-2.5 px-3 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-gray-900 dark:text-gray-100 shadow-sm" > - - {nodes.map(n => )} + {nodes.length === 0 && } + {nodes.map(n => )}
@@ -343,6 +363,9 @@ {t.trigger_type === 'cron' ? `Schedule: ${t.cron_expression}` : `Secret: ${t.webhook_secret}`} + {t.trigger_type === 'cron' && ( + ⏰ {describeCron(t.cron_expression)} + )}