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 += `| ${h} | `);
+ ['ID','Name','Description','Admin Only','Actions'].forEach(h => html += `${h} | `);
html += '
';
for (const p of profiles) {
html += `
@@ -612,6 +646,7 @@
+ |
`;
}
html += '
';
@@ -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)}
+
+
`;
+ 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))