diff --git a/admin/index.html b/admin/index.html index dd1921f..e735297 100644 --- a/admin/index.html +++ b/admin/index.html @@ -142,6 +142,38 @@ margin: 12px 0 8px; color: #fff; font-size: 14px; text-transform: capitalize; } + + /* Form fields inside drawer */ + #profile-form label { + display: block; + color: var(--text2); + font-size: 12px; + margin: 8px 0 4px; + } + #profile-form input[type="text"], + #profile-form input[type="number"], + #profile-form select, + #profile-form textarea { + width: 100%; + background: var(--bg3); + border: 1px solid var(--border2); + color: var(--text); + padding: 6px 10px; + font-size: 13px; + border-radius: 4px; + font-family: inherit; + } + #profile-form input[type="checkbox"] { + margin-right: 6px; + accent-color: var(--accent); + } + #profile-form textarea { + resize: vertical; + } + #profile-form section { + border-bottom: 1px solid var(--border); + padding-bottom: 8px; + } .value-cell { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -267,6 +299,8 @@ sessions(qs='') { return this.get('/admin/sessions' + (qs ? '?' + qs : '')); }, memory(qs='') { return this.get('/admin/memory' + (qs ? '?' + qs : '')); }, profiles() { return this.get('/admin/profiles'); }, + profile(id) { return this.get(`/admin/profiles/${id}`); }, + saveProfile(id, body) { return this.request('PUT', `/admin/profiles/${id}`, body); }, session(id){ return this.get(`/admin/sessions/${id}`); }, delSession(id){ return this.del(`/admin/sessions/${id}`); }, userSessions(id){ return this.get(`/admin/users/${id}/sessions`); }, @@ -599,7 +633,7 @@ function renderProfiles(profiles) { if (!profiles.length) return '
No profiles.
'; let html = ''; - ['ID','Name','Description','Admin Only'].forEach(h => html += ``); + ['ID','Name','Description','Admin Only','Actions'].forEach(h => html += ``); html += ''; for (const p of profiles) { html += ` @@ -612,6 +646,7 @@ + `; } html += '
${h}${h}
'; @@ -622,6 +657,147 @@ try { await api.updateProfile(id, value); loadProfiles(); } catch (err) { alert(err.message); } }; +// ── Profile Edit ─────────────────────────────────────────────────────────── +window.editProfile = async (id) => { + try { + const p = await api.profile(id); + showProfileDrawer(id, p); + } catch (err) { + alert(err.message); + } +}; + +function showProfileDrawer(id, p) { + const existing = document.querySelector('.drawer-overlay'); + if (existing) existing.remove(); + + const modelVal = Array.isArray(p.model) ? p.model.join('\n') : String(p.model || ''); + const toolsVal = Array.isArray(p.enabled_tools) ? p.enabled_tools.join('\n') : String(p.enabled_tools || ''); + const subagentToolsVal = Array.isArray(p.subagent_tools) ? p.subagent_tools.join('\n') : String(p.subagent_tools || ''); + const ctxVal = Array.isArray(p.context_providers) ? p.context_providers.join('\n') : String(p.context_providers || ''); + + const overlay = document.createElement('div'); + overlay.className = 'drawer-overlay'; + overlay.innerHTML = ` +
+

Edit Profile: ${esc(p.id)}

+
+ +

Basic

+ + + + +
+ +

Model & Generation

+ + + + + + + +
+ +

Thinking

+ + + + + + +
+ +

Planning

+ + + + + +
+ +

Sub-agent

+ + + + + +
+ +

Tools

+ + + + +
+ +

System Prompt

+ + + +
+ +
+ + +
+
+
`; + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); + document.body.appendChild(overlay); +} + +window.saveProfileEdit = async (id) => { + const body = { + name: document.getElementById('pf-name').value, + description: document.getElementById('pf-description').value, + short_description: document.getElementById('pf-short_desc').value, + llm_backend: document.getElementById('pf-backend').value, + model: document.getElementById('pf-model').value.split('\n').map(s => s.trim()).filter(Boolean), + temperature: parseFloat(document.getElementById('pf-temp').value), + top_k: document.getElementById('pf-topk').value ? parseInt(document.getElementById('pf-topk').value, 10) : null, + top_p: document.getElementById('pf-topp').value ? parseFloat(document.getElementById('pf-topp').value) : null, + max_iterations: parseInt(document.getElementById('pf-max_iter').value, 10), + num_thread: document.getElementById('pf-num_thread').value ? parseInt(document.getElementById('pf-num_thread').value, 10) : null, + think_enabled: document.getElementById('pf-think').checked, + iteration_budget_enabled: document.getElementById('pf-budget').checked, + goal_anchoring_enabled: document.getElementById('pf-goal').checked, + goal_anchoring_interval: parseInt(document.getElementById('pf-goal_int').value, 10), + anti_stall_enabled: document.getElementById('pf-stall').checked, + anti_stall_threshold: parseInt(document.getElementById('pf-stall_thr').value, 10), + step_validation_enabled: document.getElementById('pf-validate').checked, + adaptive_replan_enabled: document.getElementById('pf-replan').checked, + planning_enabled: document.getElementById('pf-plan').checked, + planning_mandatory: document.getElementById('pf-plan_mand').checked, + planning_phase1_enabled: document.getElementById('pf-phase1').checked, + planning_phase2_enabled: document.getElementById('pf-phase2').checked, + planning_phase3_enabled: document.getElementById('pf-phase3').checked, + subagent_planning_enabled: document.getElementById('pf-sub_plan').checked, + subagent_think_enabled: (() => { + const v = document.getElementById('pf-sub_think').value; + return v === 'null' ? null : v === 'true'; + })(), + subagent_tools: document.getElementById('pf-sub_tools').value.split('\n').map(s => s.trim()).filter(Boolean), + enabled_tools: document.getElementById('pf-tools').value.split('\n').map(s => s.trim()).filter(Boolean), + context_providers: document.getElementById('pf-ctx').value.split('\n').map(s => s.trim()).filter(Boolean), + system_prompt: document.getElementById('pf-system').value, + subagent_system_prompt: document.getElementById('pf-sub_system').value, + }; + + try { + await api.saveProfile(id, body); + document.querySelector('.drawer-overlay')?.remove(); + loadProfiles(); + } catch (err) { + alert(err.message); + } +}; + // ── Drawer ───────────────────────────────────────────────────────────────── function showDrawer(title, content) { const existing = document.querySelector('.drawer-overlay'); diff --git a/navi/api/routes/admin.py b/navi/api/routes/admin.py index d44aee2..ff92885 100644 --- a/navi/api/routes/admin.py +++ b/navi/api/routes/admin.py @@ -281,3 +281,106 @@ admin_id=user.id, ) return {"ok": True} + + +@router.get("/profiles/{profile_id}") +async def admin_get_profile( + profile_id: str, + user: Annotated[User, Depends(require_permission("navi.profiles.manage"))], +): + """Return full profile configuration including system prompt.""" + from navi.api.deps import get_profile_registry + + try: + profile = get_profile_registry().get(profile_id) + except Exception: + raise HTTPException(status_code=404, detail="Profile not found") + + return { + "id": profile.id, + "name": profile.name, + "description": profile.description, + "short_description": profile.short_description, + "full_description": profile.full_description, + "system_prompt": profile.system_prompt, + "subagent_system_prompt": profile.subagent_system_prompt, + "llm_backend": profile.llm_backend, + "model": profile.model, + "temperature": profile.temperature, + "top_k": profile.top_k, + "top_p": profile.top_p, + "num_thread": profile.num_thread, + "max_iterations": profile.max_iterations, + "planning_enabled": profile.planning_enabled, + "planning_mandatory": profile.planning_mandatory, + "planning_phase1_enabled": profile.planning_phase1_enabled, + "planning_phase2_enabled": profile.planning_phase2_enabled, + "planning_phase3_enabled": profile.planning_phase3_enabled, + "think_enabled": profile.think_enabled, + "iteration_budget_enabled": profile.iteration_budget_enabled, + "goal_anchoring_enabled": profile.goal_anchoring_enabled, + "goal_anchoring_interval": profile.goal_anchoring_interval, + "anti_stall_enabled": profile.anti_stall_enabled, + "anti_stall_threshold": profile.anti_stall_threshold, + "step_validation_enabled": profile.step_validation_enabled, + "adaptive_replan_enabled": profile.adaptive_replan_enabled, + "subagent_tools": profile.subagent_tools, + "subagent_planning_enabled": profile.subagent_planning_enabled, + "subagent_think_enabled": profile.subagent_think_enabled, + "enabled_tools": profile.enabled_tools, + "context_providers": profile.context_providers, + "is_admin_only": getattr(profile, "is_admin_only", False), + } + + +@router.put("/profiles/{profile_id}") +async def admin_update_profile( + profile_id: str, + body: dict, + user: Annotated[User, Depends(require_permission("navi.profiles.manage"))], +): + """Update profile configuration on disk and in-memory. + + Accepts a partial update — only provided fields are modified. + Writes config.json and system_prompt.txt back to disk. + """ + from pathlib import Path + from navi.api.deps import get_profile_registry + from navi.profiles.base import AgentProfile + from navi.profiles.loader import save_profile_to_dir + + registry = get_profile_registry() + try: + old_profile = registry.get(profile_id) + except Exception: + raise HTTPException(status_code=404, detail="Profile not found") + + # Build updated profile from existing data + body overrides + updated_data = old_profile.model_dump() + updated_data.update(body) + + # Preserve fields that must not change via this endpoint + updated_data["id"] = profile_id + updated_data.setdefault("name", old_profile.name) + updated_data.setdefault("description", old_profile.description) + updated_data.setdefault("enabled_tools", old_profile.enabled_tools) + updated_data.setdefault("system_prompt", old_profile.system_prompt) + + try: + updated_profile = AgentProfile.model_validate(updated_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid profile data: {e}") from e + + # Preserve is_admin_only (not part of base model validation) + if hasattr(old_profile, "is_admin_only"): + updated_profile.is_admin_only = old_profile.is_admin_only + + # Write to disk + profiles_dir = Path(__file__).parent.parent.parent / "profiles" + save_profile_to_dir(updated_profile, profiles_dir) + + # Update in-memory registry + registry.update(updated_profile) + + log.info("admin.profile_updated", profile_id=profile_id, admin_id=user.id) + return {"ok": True} diff --git a/navi/core/registry.py b/navi/core/registry.py index 23ad6a1..6756e87 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -95,6 +95,12 @@ def all(self) -> list[AgentProfile]: return list(self._profiles.values()) + def update(self, profile: AgentProfile) -> None: + """Replace an existing profile in-memory.""" + if profile.id not in self._profiles: + raise ProfileNotFound(profile.id) + self._profiles[profile.id] = profile + class BackendRegistry: def __init__(self) -> None: diff --git a/navi/profiles/loader.py b/navi/profiles/loader.py index 823161e..1b33149 100644 --- a/navi/profiles/loader.py +++ b/navi/profiles/loader.py @@ -103,3 +103,64 @@ log.error("profile.loader.error", profile_dir=entry.name, error=str(exc)) return profiles + + +def save_profile_to_dir(profile: AgentProfile, profiles_dir: str | Path) -> None: + """Write a profile back to its directory on disk. + + Updates config.json and system_prompt.txt. Does not touch + subagent_system_prompt.txt unless the field is non-empty. + """ + base = Path(profiles_dir) + profile_dir = base / profile.id + profile_dir.mkdir(parents=True, exist_ok=True) + + config = { + "id": profile.id, + "name": profile.name, + "description": profile.description, + "short_description": profile.short_description, + "full_description": profile.full_description, + "llm_backend": profile.llm_backend, + "model": profile.model, + "temperature": profile.temperature, + "max_iterations": profile.max_iterations, + "top_k": profile.top_k, + "top_p": profile.top_p, + "num_thread": profile.num_thread, + "planning_enabled": profile.planning_enabled, + "planning_mandatory": profile.planning_mandatory, + "planning_phase1_enabled": profile.planning_phase1_enabled, + "planning_phase2_enabled": profile.planning_phase2_enabled, + "planning_phase3_enabled": profile.planning_phase3_enabled, + "think_enabled": profile.think_enabled, + "iteration_budget_enabled": profile.iteration_budget_enabled, + "goal_anchoring_enabled": profile.goal_anchoring_enabled, + "goal_anchoring_interval": profile.goal_anchoring_interval, + "anti_stall_enabled": profile.anti_stall_enabled, + "anti_stall_threshold": profile.anti_stall_threshold, + "step_validation_enabled": profile.step_validation_enabled, + "adaptive_replan_enabled": profile.adaptive_replan_enabled, + "subagent_tools": profile.subagent_tools, + "subagent_planning_enabled": profile.subagent_planning_enabled, + "subagent_think_enabled": profile.subagent_think_enabled, + "enabled_tools": profile.enabled_tools, + "context_providers": profile.context_providers, + } + + config_file = profile_dir / "config.json" + config_file.write_text( + json.dumps(config, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + prompt_file = profile_dir / "system_prompt.txt" + prompt_file.write_text(profile.system_prompt + "\n", encoding="utf-8") + + subagent_file = profile_dir / "subagent_system_prompt.txt" + if profile.subagent_system_prompt: + subagent_file.write_text(profile.subagent_system_prompt + "\n", encoding="utf-8") + elif subagent_file.exists(): + subagent_file.unlink() + + log.info("profile.saved", profile_id=profile.id, dir=str(profile_dir))