Merge pull request #41 from nesquena/feat/multi-profile-support

feat: Multi-Profile Support (Issue #28)
This commit is contained in:
Nathan Esquenazi
2026-04-03 11:10:43 -07:00
committed by GitHub
12 changed files with 702 additions and 36 deletions

View File

@@ -5,6 +5,51 @@
--- ---
## [v0.24] Sprint 22 -- Multi-Profile Support (Issue #28)
*April 3, 2026 | 415 tests*
### Features
- **Profile picker (topbar).** Purple-accented chip with SVG user icon. Click
to open dropdown listing all profiles with gateway status dots (green =
running), model info, and skill count. Click any profile to switch; "Manage
profiles" link opens the sidebar panel.
- **Profiles management panel.** New sidebar tab with full CRUD UI. Profile
cards show name, model/provider, skill count, API key status, and gateway
status badge. "Use" button switches profile, delete button removes non-default
profiles (with confirmation).
- **Profile creation.** "+ New profile" form with name validation (`[a-z0-9_-]`),
optional "clone config from active" checkbox. Wraps the CLI's
`hermes_cli.profiles.create_profile()`.
- **Profile deletion.** Confirm dialog. Auto-switches to default if deleting
the active profile. Blocked while agent is running.
- **Seamless profile switching.** No server restart. Profile switch updates
`HERMES_HOME`, patches module-level caches in hermes-agent's `skills_tool`
and `cron/jobs`, reloads `.env` API keys and `config.yaml`, refreshes the
model dropdown, skills, memory, and cron panels.
- **Per-session profile tracking.** `profile` field on Session records which
profile was active at creation. Backward-compatible (`null` for old sessions).
### Bug Fixes
- **Hardcoded `~/.hermes` paths.** Memory read/write and model discovery used
hardcoded paths. Now resolved through `get_active_hermes_home()`.
- **Module-level path caching.** hermes-agent modules snapshot `HERMES_HOME`
at import time. Profile switch now monkey-patches `SKILLS_DIR`, `CRON_DIR`,
`JOBS_FILE`, `OUTPUT_DIR` so they track the active profile.
### Architecture
- New `api/profiles.py`: profile state management wrapping `hermes_cli.profiles`.
Thread-safe (`_profile_lock`). Lazy imports avoid circular deps.
- `api/config.py`: module-level `cfg` replaced with reloadable `get_config()`
/ `reload_config()`. Dynamic `_get_config_path()` resolves through profile.
- `api/streaming.py`: `HERMES_HOME` added to env save/restore block.
- Profile switch blocked while agent streams are active.
- 5 new API endpoints: `GET /api/profiles`, `GET /api/profile/active`,
`POST /api/profile/switch`, `POST /api/profile/create`,
`POST /api/profile/delete`.
- Zero modifications to hermes-agent code.
---
## [v0.23] Sprint 21 -- Mobile Responsive + Docker ## [v0.23] Sprint 21 -- Mobile Responsive + Docker
*April 3, 2026 | 415 tests* *April 3, 2026 | 415 tests*
@@ -748,4 +793,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
--- ---
*Last updated: v0.23, April 3, 2026 | Tests: 415* *Last updated: v0.24, April 3, 2026 | Tests: 415*

View File

@@ -1,6 +1,6 @@
# Hermes Web UI -- Forward Sprint Plan # Hermes Web UI -- Forward Sprint Plan
> Current state: v0.23 | 415 tests | Daily driver ready > Current state: v0.24 | 415 tests | Daily driver ready
> This document plans the path from here to two targets: > This document plans the path from here to two targets:
> >
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
@@ -454,14 +454,60 @@ enables deployment beyond localhost. Both were achievable without new dependenci
--- ---
## Sprint 22 -- Multi-Profile Support (PLANNED, Issue #28) ## Sprint 22 -- Multi-Profile Support (COMPLETED, Issue #28)
**Theme:** Switch between Hermes agent profiles seamlessly. **Theme:** Switch between Hermes agent profiles seamlessly from the web UI.
**Why now:** Issue #28 requested full profile management in the UI. The CLI has
had comprehensive profile support since v0.6.0 — isolated instances with their
own config, skills, memory, cron, and API keys. The web UI was locked to a
single default profile, blocking multi-persona workflows.
### Track A: Bugs
- **Hardcoded `~/.hermes` paths.** Memory read/write in routes.py and model
discovery in config.py used hardcoded paths instead of the active profile's
directory. Fixed to resolve through `get_active_hermes_home()`.
- **Module-level cached paths.** hermes-agent's `skills_tool.py` and `cron/jobs.py`
snapshot `HERMES_HOME` at import time. Profile switch now monkey-patches these
cached variables (`SKILLS_DIR`, `CRON_DIR`, `JOBS_FILE`, `OUTPUT_DIR`).
### Track B: Features ### Track B: Features
- **Profile picker.** Sidebar or topbar dropdown to switch profiles. - **Profile picker (topbar).** Purple-accented chip with SVG user icon in the
- **Per-profile config.** Each profile has its own skills, memory, config.yaml. topbar. Click opens a dropdown listing all profiles with gateway status dots,
- **Seamless switching.** No restart required. model info, and skill count. Click to switch; "Manage profiles" link opens
the management panel.
- **Profiles sidebar panel.** New nav tab with full management UI. Cards show
each profile with model, provider, skill count, API key status, and gateway
badge. "Use" button to switch, delete button for non-default profiles.
- **Profile creation.** "+ New profile" form with name validation (lowercase
alphanumeric + hyphens), optional "clone config from active" checkbox. Wraps
`hermes_cli.profiles.create_profile()`.
- **Profile deletion.** Confirm dialog, auto-switches to default if deleting
the active profile. Blocked while agent is running.
- **Seamless switching.** No server restart required. Profile switch updates
`HERMES_HOME` env var, patches module-level caches, reloads `.env` API keys,
reloads `config.yaml`, and refreshes the model dropdown, skills, memory, and
cron panels.
- **Per-session profile tracking.** New `profile` field on Session records which
profile was active when the session was created. Backward-compatible (defaults
to `null` for old sessions).
### Track C: Architecture
- New `api/profiles.py` module (~200 lines): profile state management wrapping
`hermes_cli.profiles`. Thread-safe with `_profile_lock`. Lazy imports to
avoid circular dependencies.
- `api/config.py`: Replaced module-level `cfg` dict with reloadable
`get_config()`/`reload_config()`. Dynamic `_get_config_path()` resolves
through active profile.
- `api/streaming.py`: `HERMES_HOME` added to env save/restore block around
agent runs (alongside `TERMINAL_CWD`, `HERMES_EXEC_ASK`).
- Profile switch blocked while any agent stream is active (process-global
`HERMES_HOME` cannot be changed mid-run).
- Zero modifications to hermes-agent code required.
**Tests:** 0 new (profile management requires hermes-agent integration). Total: 415.
**Hermes CLI parity impact:** Very High (profile support is a major CLI feature)
**Claude parity impact:** Low (Claude has no profile concept)
--- ---
@@ -510,7 +556,8 @@ enables deployment beyond localhost. Both were achievable without new dependenci
| Slash commands | Done (Sprint 17) | | Slash commands | Done (Sprint 17) |
| Thinking/reasoning display | Done (Sprint 18) | | Thinking/reasoning display | Done (Sprint 18) |
| Auth / login | Done (Sprint 19) | | Auth / login | Done (Sprint 19) |
| Voice input | Sprint 20 | | Voice input | Done (Sprint 20) |
| Multi-profile support | Done (Sprint 22) |
| Subagent visibility | Deferred | | Subagent visibility | Deferred |
| Code execution (Jupyter) | Deferred | | Code execution (Jupyter) | Deferred |
| Toolset control | Deferred | | Toolset control | Deferred |
@@ -538,11 +585,11 @@ enables deployment beyond localhost. Both were achievable without new dependenci
| Mobile layout (basic) | Done (v0.16.1) | | Mobile layout (basic) | Done (v0.16.1) |
| Workspace tree view | Done (Sprint 18) | | Workspace tree view | Done (Sprint 18) |
| Slash commands | Done (Sprint 17) | | Slash commands | Done (Sprint 17) |
| Voice input | Sprint 20 | | Voice input | Done (Sprint 20) |
| TTS playback | Sprint 20 | | TTS playback | Deferred |
| Artifacts (HTML/SVG preview) | Deferred | | Artifacts (HTML/SVG preview) | Deferred |
| Code execution inline | Deferred | | Code execution inline | Deferred |
| Mobile-optimized layout | Sprint 21 | | Mobile-optimized layout | Done (Sprint 21) |
| Sharing / public URLs | Not planned (requires server infra) | | Sharing / public URLs | Not planned (requires server infra) |
| Claude-specific features | Not replicable (Projects AI, artifacts sync) | | Claude-specific features | Not replicable (Projects AI, artifacts sync) |
@@ -560,5 +607,5 @@ enables deployment beyond localhost. Both were achievable without new dependenci
--- ---
*Last updated: April 3, 2026* *Last updated: April 3, 2026*
*Current version: v0.23 | 415 tests* *Current version: v0.24 | 415 tests*
*Next sprint: Sprint 22 (Multi-Profile Support)* *Next sprint: Sprint 23 (Desktop Application)*

View File

@@ -134,17 +134,44 @@ if _AGENT_DIR is not None:
else: else:
_HERMES_FOUND = False _HERMES_FOUND = False
# ── Config file (optional YAML) ────────────────────────────────────────────── # ── Config file (reloadable -- supports profile switching) ──────────────────
CONFIG_PATH = Path(os.getenv( _cfg_cache = {}
'HERMES_CONFIG_PATH', _cfg_lock = threading.Lock()
str(HOME / '.hermes' / 'config.yaml')
)).expanduser()
try: def _get_config_path() -> Path:
import yaml as _yaml """Return config.yaml path for the active profile."""
cfg = _yaml.safe_load(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {} env_override = os.getenv('HERMES_CONFIG_PATH')
except Exception: if env_override:
cfg = {} return Path(env_override).expanduser()
try:
from api.profiles import get_active_hermes_home
return get_active_hermes_home() / 'config.yaml'
except ImportError:
return HOME / '.hermes' / 'config.yaml'
def get_config() -> dict:
"""Return the cached config dict, loading from disk if needed."""
if not _cfg_cache:
reload_config()
return _cfg_cache
def reload_config():
"""Reload config.yaml from the active profile's directory."""
with _cfg_lock:
_cfg_cache.clear()
config_path = _get_config_path()
try:
import yaml as _yaml
if config_path.exists():
loaded = _yaml.safe_load(config_path.read_text())
if isinstance(loaded, dict):
_cfg_cache.update(loaded)
except Exception:
pass
# Initial load
reload_config()
cfg = _cfg_cache # alias for backward compat with existing references
# ── Default workspace discovery ─────────────────────────────────────────────── # ── Default workspace discovery ───────────────────────────────────────────────
def _discover_default_workspace() -> Path: def _discover_default_workspace() -> Path:
@@ -183,7 +210,7 @@ def print_startup_config():
f' state dir : {STATE_DIR}', f' state dir : {STATE_DIR}',
f' workspace : {DEFAULT_WORKSPACE}', f' workspace : {DEFAULT_WORKSPACE}',
f' host:port : {HOST}:{PORT}', f' host:port : {HOST}:{PORT}',
f' config file : {CONFIG_PATH} {"(found)" if CONFIG_PATH.exists() else "(not found, using defaults)"}', f' config file : {_get_config_path()} {"(found)" if _get_config_path().exists() else "(not found, using defaults)"}',
'', '',
] ]
print('\n'.join(lines), flush=True) print('\n'.join(lines), flush=True)
@@ -234,11 +261,12 @@ MIME_MAP = {
} }
# ── Toolsets (from config.yaml or hardcoded default) ───────────────────────── # ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
CLI_TOOLSETS = cfg.get('platform_toolsets', {}).get('cli', [ _DEFAULT_TOOLSETS = [
'browser', 'clarify', 'code_execution', 'cronjob', 'delegation', 'file', 'browser', 'clarify', 'code_execution', 'cronjob', 'delegation', 'file',
'image_gen', 'memory', 'session_search', 'skills', 'terminal', 'todo', 'image_gen', 'memory', 'session_search', 'skills', 'terminal', 'todo',
'web', 'webhook', 'web', 'webhook',
]) ]
CLI_TOOLSETS = get_config().get('platform_toolsets', {}).get('cli', _DEFAULT_TOOLSETS)
# ── Model / provider discovery ─────────────────────────────────────────────── # ── Model / provider discovery ───────────────────────────────────────────────
@@ -396,7 +424,11 @@ def get_available_models() -> dict:
# 3. Try to read auth store for active provider (if hermes is installed) # 3. Try to read auth store for active provider (if hermes is installed)
if not active_provider: if not active_provider:
auth_store_path = HOME / '.hermes' / 'auth.json' try:
from api.profiles import get_active_hermes_home as _gah
auth_store_path = _gah() / 'auth.json'
except ImportError:
auth_store_path = HOME / '.hermes' / 'auth.json'
if auth_store_path.exists(): if auth_store_path.exists():
try: try:
import json as _j import json as _j
@@ -406,7 +438,11 @@ def get_available_models() -> dict:
pass pass
# 4. Check for API keys that imply available providers # 4. Check for API keys that imply available providers
hermes_env_path = HOME / '.hermes' / '.env' try:
from api.profiles import get_active_hermes_home as _gah2
hermes_env_path = _gah2() / '.env'
except ImportError:
hermes_env_path = HOME / '.hermes' / '.env'
env_keys = {} env_keys = {}
if hermes_env_path.exists(): if hermes_env_path.exists():
try: try:
@@ -655,3 +691,11 @@ if SETTINGS_FILE.exists():
# ── SESSIONS in-memory cache (LRU OrderedDict) ─────────────────────────────── # ── SESSIONS in-memory cache (LRU OrderedDict) ───────────────────────────────
SESSIONS: collections.OrderedDict = collections.OrderedDict() SESSIONS: collections.OrderedDict = collections.OrderedDict()
# ── Profile state initialisation ────────────────────────────────────────────
# Must run after all imports are resolved to correctly patch module-level caches
try:
from api.profiles import init_profile_state
init_profile_state()
except ImportError:
pass # hermes_cli not available -- default profile only

View File

@@ -34,8 +34,8 @@ def _write_session_index():
class Session: class Session:
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, **kwargs): def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, profile=None, **kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None; self.profile = profile
@property @property
def path(self): return SESSION_DIR / f'{self.session_id}.json' def path(self): return SESSION_DIR / f'{self.session_id}.json'
def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index() def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index()
@@ -44,7 +44,7 @@ class Session:
p = SESSION_DIR / f'{sid}.json' p = SESSION_DIR / f'{sid}.json'
if not p.exists(): return None if not p.exists(): return None
return cls(**json.loads(p.read_text(encoding='utf-8'))) return cls(**json.loads(p.read_text(encoding='utf-8')))
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id} def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id, 'profile': self.profile}
def get_session(sid): def get_session(sid):
with LOCK: with LOCK:
@@ -63,7 +63,12 @@ def get_session(sid):
def new_session(workspace=None, model=None): def new_session(workspace=None, model=None):
# Use _cfg.DEFAULT_MODEL (not the import-time snapshot) so save_settings() changes take effect # Use _cfg.DEFAULT_MODEL (not the import-time snapshot) so save_settings() changes take effect
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL) try:
from api.profiles import get_active_profile_name
_profile = get_active_profile_name()
except ImportError:
_profile = None
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL, profile=_profile)
with LOCK: with LOCK:
SESSIONS[s.session_id] = s SESSIONS[s.session_id] = s
SESSIONS.move_to_end(s.session_id) SESSIONS.move_to_end(s.session_id)

246
api/profiles.py Normal file
View File

@@ -0,0 +1,246 @@
"""
Hermes Web UI -- Profile state management.
Wraps hermes_cli.profiles to provide profile switching for the web UI.
The web UI maintains a process-level "active profile" that determines which
HERMES_HOME directory is used for config, skills, memory, cron, and API keys.
Profile switches update os.environ['HERMES_HOME'] and monkey-patch module-level
cached paths in hermes-agent modules (skills_tool, cron/jobs) that snapshot
HERMES_HOME at import time.
"""
import json
import os
import threading
from pathlib import Path
# ── Module state ────────────────────────────────────────────────────────────
_active_profile = 'default'
_profile_lock = threading.Lock()
_DEFAULT_HERMES_HOME = Path.home() / '.hermes'
def _read_active_profile_file() -> str:
"""Read the sticky active profile from ~/.hermes/active_profile."""
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
if ap_file.exists():
try:
name = ap_file.read_text().strip()
if name:
return name
except Exception:
pass
return 'default'
# ── Public API ──────────────────────────────────────────────────────────────
def get_active_profile_name() -> str:
"""Return the currently active profile name."""
return _active_profile
def get_active_hermes_home() -> Path:
"""Return the HERMES_HOME path for the currently active profile."""
if _active_profile == 'default':
return _DEFAULT_HERMES_HOME
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile
if profile_dir.is_dir():
return profile_dir
return _DEFAULT_HERMES_HOME
def _set_hermes_home(home: Path):
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
os.environ['HERMES_HOME'] = str(home)
# Patch skills_tool module-level cache (snapshots HERMES_HOME at import)
try:
import tools.skills_tool as _sk
_sk.HERMES_HOME = home
_sk.SKILLS_DIR = home / 'skills'
except (ImportError, AttributeError):
pass
# Patch cron/jobs module-level cache
try:
import cron.jobs as _cj
_cj.HERMES_DIR = home
_cj.CRON_DIR = home / 'cron'
_cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'
_cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
except (ImportError, AttributeError):
pass
def _reload_dotenv(home: Path):
"""Load .env from the profile dir into os.environ (additive)."""
env_path = home / '.env'
if not env_path.exists():
return
try:
for line in env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
if k and v:
os.environ[k] = v
except Exception:
pass
def init_profile_state():
"""Initialize profile state at server startup.
Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches
module-level cached paths. Called once from config.py after imports.
"""
global _active_profile
_active_profile = _read_active_profile_file()
home = get_active_hermes_home()
_set_hermes_home(home)
_reload_dotenv(home)
def switch_profile(name: str) -> dict:
"""Switch the active profile.
Validates the profile exists, updates process state, patches module caches,
reloads .env, and reloads config.yaml.
Returns: {'profiles': [...], 'active': name}
Raises ValueError if profile doesn't exist or agent is busy.
"""
global _active_profile
# Import here to avoid circular import at module load
from api.config import STREAMS, STREAMS_LOCK, reload_config
# Block if agent is running
with STREAMS_LOCK:
if len(STREAMS) > 0:
raise RuntimeError(
'Cannot switch profiles while an agent is running. '
'Cancel or wait for it to finish.'
)
# Resolve profile directory
if name == 'default':
home = _DEFAULT_HERMES_HOME
else:
home = _DEFAULT_HERMES_HOME / 'profiles' / name
if not home.is_dir():
raise ValueError(f"Profile '{name}' does not exist.")
with _profile_lock:
_active_profile = name
_set_hermes_home(home)
_reload_dotenv(home)
# Write sticky default for CLI consistency
try:
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
ap_file.write_text(name if name != 'default' else '')
except Exception:
pass
# Reload config.yaml from the new profile
reload_config()
return {'profiles': list_profiles_api(), 'active': name}
def list_profiles_api() -> list:
"""List all profiles with metadata, serialized for JSON response."""
try:
from hermes_cli.profiles import list_profiles
infos = list_profiles()
except ImportError:
# hermes_cli not available -- return just the default
return [_default_profile_dict()]
active = _active_profile
result = []
for p in infos:
result.append({
'name': p.name,
'path': str(p.path),
'is_default': p.is_default,
'is_active': p.name == active,
'gateway_running': p.gateway_running,
'model': p.model,
'provider': p.provider,
'has_env': p.has_env,
'skill_count': p.skill_count,
})
return result
def _default_profile_dict() -> dict:
"""Fallback profile dict when hermes_cli is not importable."""
return {
'name': 'default',
'path': str(_DEFAULT_HERMES_HOME),
'is_default': True,
'is_active': True,
'gateway_running': False,
'model': None,
'provider': None,
'has_env': (_DEFAULT_HERMES_HOME / '.env').exists(),
'skill_count': 0,
}
def create_profile_api(name: str, clone_from: str = None,
clone_config: bool = False) -> dict:
"""Create a new profile. Returns the new profile info dict."""
try:
from hermes_cli.profiles import create_profile, validate_profile_name
except ImportError:
raise RuntimeError('Profile management requires hermes-agent to be installed.')
validate_profile_name(name)
create_profile(
name,
clone_from=clone_from,
clone_config=clone_config,
clone_all=False,
no_alias=True,
)
# Find and return the newly created profile info
for p in list_profiles_api():
if p['name'] == name:
return p
return {'name': name, 'path': str(_DEFAULT_HERMES_HOME / 'profiles' / name)}
def delete_profile_api(name: str) -> dict:
"""Delete a profile. Switches to default first if it's the active one."""
if name == 'default':
raise ValueError("Cannot delete the default profile.")
# If deleting the active profile, switch to default first
if _active_profile == name:
try:
switch_profile('default')
except RuntimeError:
raise RuntimeError(
f"Cannot delete active profile '{name}' while an agent is running. "
"Cancel or wait for it to finish."
)
try:
from hermes_cli.profiles import delete_profile
delete_profile(name, yes=True)
except ImportError:
# Manual fallback: just remove the directory
import shutil
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
if profile_dir.is_dir():
shutil.rmtree(str(profile_dir))
else:
raise ValueError(f"Profile '{name}' does not exist.")
return {'ok': True, 'name': name}

View File

@@ -234,6 +234,15 @@ def handle_get(handler, parsed):
if parsed.path == '/api/memory': if parsed.path == '/api/memory':
return _handle_memory_read(handler) return _handle_memory_read(handler)
# ── Profile API (GET) ──
if parsed.path == '/api/profiles':
from api.profiles import list_profiles_api, get_active_profile_name
return j(handler, {'profiles': list_profiles_api(), 'active': get_active_profile_name()})
if parsed.path == '/api/profile/active':
from api.profiles import get_active_profile_name, get_active_hermes_home
return j(handler, {'name': get_active_profile_name(), 'path': str(get_active_hermes_home())})
return False # 404 return False # 404
@@ -372,6 +381,53 @@ def handle_post(handler, parsed):
if parsed.path == '/api/memory/write': if parsed.path == '/api/memory/write':
return _handle_memory_write(handler, body) return _handle_memory_write(handler, body)
# ── Profile API (POST) ──
if parsed.path == '/api/profile/switch':
name = body.get('name', '').strip()
if not name: return bad(handler, 'name is required')
try:
from api.profiles import switch_profile
result = switch_profile(name)
return j(handler, result)
except (ValueError, FileNotFoundError) as e:
return bad(handler, str(e), 404)
except RuntimeError as e:
return bad(handler, str(e), 409)
if parsed.path == '/api/profile/create':
name = body.get('name', '').strip()
if not name: return bad(handler, 'name is required')
import re as _re
if not _re.match(r'^[a-z0-9][a-z0-9_-]{0,63}$', name):
return bad(handler, 'Invalid profile name: lowercase letters, numbers, hyphens, underscores only')
clone_from = body.get('clone_from')
if clone_from is not None:
clone_from = str(clone_from).strip()
if not _re.match(r'^[a-z0-9][a-z0-9_-]{0,63}$', clone_from):
return bad(handler, 'Invalid clone_from name')
try:
from api.profiles import create_profile_api
result = create_profile_api(
name,
clone_from=clone_from,
clone_config=bool(body.get('clone_config', False)),
)
return j(handler, {'ok': True, 'profile': result})
except (ValueError, FileExistsError, RuntimeError) as e:
return bad(handler, str(e))
if parsed.path == '/api/profile/delete':
name = body.get('name', '').strip()
if not name: return bad(handler, 'name is required')
try:
from api.profiles import delete_profile_api
result = delete_profile_api(name)
return j(handler, result)
except (ValueError, FileNotFoundError) as e:
return bad(handler, str(e))
except RuntimeError as e:
return bad(handler, str(e), 409)
# ── Settings (POST) ── # ── Settings (POST) ──
if parsed.path == '/api/settings': if parsed.path == '/api/settings':
saved = save_settings(body) saved = save_settings(body)
@@ -731,7 +787,11 @@ def _handle_cron_recent(handler, parsed):
def _handle_memory_read(handler): def _handle_memory_read(handler):
mem_dir = Path.home() / '.hermes' / 'memories' try:
from api.profiles import get_active_hermes_home
mem_dir = get_active_hermes_home() / 'memories'
except ImportError:
mem_dir = Path.home() / '.hermes' / 'memories'
mem_file = mem_dir / 'MEMORY.md' mem_file = mem_dir / 'MEMORY.md'
user_file = mem_dir / 'USER.md' user_file = mem_dir / 'USER.md'
memory = mem_file.read_text(encoding='utf-8', errors='replace') if mem_file.exists() else '' memory = mem_file.read_text(encoding='utf-8', errors='replace') if mem_file.exists() else ''
@@ -1078,7 +1138,11 @@ def _handle_skill_delete(handler, body):
def _handle_memory_write(handler, body): def _handle_memory_write(handler, body):
try: require(body, 'section', 'content') try: require(body, 'section', 'content')
except ValueError as e: return bad(handler, str(e)) except ValueError as e: return bad(handler, str(e))
mem_dir = Path.home() / '.hermes' / 'memories' try:
from api.profiles import get_active_hermes_home
mem_dir = get_active_hermes_home() / 'memories'
except ImportError:
mem_dir = Path.home() / '.hermes' / 'memories'
mem_dir.mkdir(parents=True, exist_ok=True) mem_dir.mkdir(parents=True, exist_ok=True)
section = body['section'] section = body['section']
if section == 'memory': if section == 'memory':

View File

@@ -64,19 +64,30 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
put('cancel', {'message': 'Cancelled before start'}) put('cancel', {'message': 'Cancelled before start'})
return return
# Resolve profile home for this agent run (snapshot at start)
try:
from api.profiles import get_active_hermes_home
_profile_home = str(get_active_hermes_home())
except ImportError:
_profile_home = os.environ.get('HERMES_HOME', '')
_set_thread_env( _set_thread_env(
TERMINAL_CWD=str(s.workspace), TERMINAL_CWD=str(s.workspace),
HERMES_EXEC_ASK='1', HERMES_EXEC_ASK='1',
HERMES_SESSION_KEY=session_id, HERMES_SESSION_KEY=session_id,
HERMES_HOME=_profile_home,
) )
# Still set process-level env as fallback for tools that bypass thread-local # Still set process-level env as fallback for tools that bypass thread-local
with _agent_lock: with _agent_lock:
old_cwd = os.environ.get('TERMINAL_CWD') old_cwd = os.environ.get('TERMINAL_CWD')
old_exec_ask = os.environ.get('HERMES_EXEC_ASK') old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
old_session_key = os.environ.get('HERMES_SESSION_KEY') old_session_key = os.environ.get('HERMES_SESSION_KEY')
old_hermes_home = os.environ.get('HERMES_HOME')
os.environ['TERMINAL_CWD'] = str(s.workspace) os.environ['TERMINAL_CWD'] = str(s.workspace)
os.environ['HERMES_EXEC_ASK'] = '1' os.environ['HERMES_EXEC_ASK'] = '1'
os.environ['HERMES_SESSION_KEY'] = session_id os.environ['HERMES_SESSION_KEY'] = session_id
if _profile_home:
os.environ['HERMES_HOME'] = _profile_home
try: try:
def on_token(text): def on_token(text):
@@ -187,6 +198,8 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None) if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
else: os.environ['HERMES_SESSION_KEY'] = old_session_key else: os.environ['HERMES_SESSION_KEY'] = old_session_key
if old_hermes_home is None: os.environ.pop('HERMES_HOME', None)
else: os.environ['HERMES_HOME'] = old_hermes_home
except Exception as e: except Exception as e:
print('[webui] stream error:\n' + traceback.format_exc(), flush=True) print('[webui] stream error:\n' + traceback.format_exc(), flush=True)

View File

@@ -309,6 +309,11 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
(async()=>{ (async()=>{
// Load send key preference // Load send key preference
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';}catch(e){window._sendKey='enter';} try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';}catch(e){window._sendKey='enter';}
// Fetch active profile
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
// Update profile chip label immediately
const profileLabel=$('profileChipLabel');
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
// Fetch available models from server and populate dropdown dynamically // Fetch available models from server and populate dropdown dynamically
await populateModelDropdown(); await populateModelDropdown();
// Restore last-used model preference // Restore last-used model preference

View File

@@ -13,13 +13,14 @@
<body> <body>
<div class="layout"> <div class="layout">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.23</div></div></div> <div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.24</div></div></div>
<div class="sidebar-nav"> <div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button> <button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button> <button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills">&#129513;</button> <button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills">&#129513;</button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory">&#129504;</button> <button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory">&#129504;</button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces">&#128193;</button> <button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces">&#128193;</button>
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list">&#9989;</button> <button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list">&#9989;</button>
</div> </div>
<!-- Chat panel --> <!-- Chat panel -->
@@ -104,6 +105,26 @@
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)">Add and switch workspaces for your sessions.</div> <div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)">Add and switch workspaces for your sessions.</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div> <div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
</div> </div>
<!-- Profiles panel -->
<div class="panel-view" id="panelProfiles">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:11px;color:var(--muted)">Agent profiles</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ New profile</button>
</div>
<!-- Profile create form (hidden by default) -->
<div id="profileCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<input id="profileFormName" placeholder="Profile name (lowercase, a-z 0-9 hyphens)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);margin-bottom:8px;cursor:pointer">
<input type="checkbox" id="profileFormClone" style="accent-color:var(--accent)"> Clone config from active profile
</label>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitProfileCreate()">Create</button>
<button class="cron-btn" style="flex:1" onclick="toggleProfileForm()">Cancel</button>
</div>
<div id="profileFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="profilesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
</div>
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<div class="field-label" style="font-size:10px;letter-spacing:.07em;margin-bottom:4px">MODEL</div> <div class="field-label" style="font-size:10px;letter-spacing:.07em;margin-bottom:4px">MODEL</div>
<select id="modelSelect"> <select id="modelSelect">
@@ -148,6 +169,10 @@
</button> </button>
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div> <div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
<div class="topbar-chips"> <div class="topbar-chips">
<div id="profileChipWrap" style="position:relative">
<div class="chip profile-chip" id="profileChip" onclick="toggleProfileDropdown()" title="Switch profile" style="cursor:pointer"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span id="profileChipLabel">default</span> &#9662;</div>
<div class="profile-dropdown" id="profileDropdown"></div>
</div>
<div class="chip model" id="modelChip">GPT-5.4 Mini</div> <div class="chip model" id="modelChip">GPT-5.4 Mini</div>
<div id="wsChipWrap" style="position:relative"> <div id="wsChipWrap" style="position:relative">
<div class="chip ws-chip" id="wsChip" onclick="toggleWsDropdown()" title="Switch workspace" style="cursor:pointer">&#128193; test-workspace &#9662;</div> <div class="chip ws-chip" id="wsChip" onclick="toggleWsDropdown()" title="Switch workspace" style="cursor:pointer">&#128193; test-workspace &#9662;</div>

View File

@@ -14,6 +14,7 @@ async function switchPanel(name) {
if (name === 'skills') await loadSkills(); if (name === 'skills') await loadSkills();
if (name === 'memory') await loadMemory(); if (name === 'memory') await loadMemory();
if (name === 'workspaces') await loadWorkspacesPanel(); if (name === 'workspaces') await loadWorkspacesPanel();
if (name === 'profiles') await loadProfilesPanel();
if (name === 'todos') loadTodos(); if (name === 'todos') loadTodos();
} }
@@ -476,6 +477,7 @@ function toggleWsDropdown(){
const open=dd.classList.contains('open'); const open=dd.classList.contains('open');
if(open){closeWsDropdown();} if(open){closeWsDropdown();}
else{ else{
closeProfileDropdown(); // close profile dropdown if open
loadWorkspaceList().then(data=>{ loadWorkspaceList().then(data=>{
renderWorkspaceDropdown(data.workspaces, S.session?S.session.workspace:''); renderWorkspaceDropdown(data.workspaces, S.session?S.session.workspace:'');
dd.classList.add('open'); dd.classList.add('open');
@@ -561,6 +563,154 @@ async function switchToWorkspace(path,name){
}catch(e){setStatus('Switch failed: '+e.message);} }catch(e){setStatus('Switch failed: '+e.message);}
} }
// ── Profile panel + dropdown ──
let _profilesCache = null;
async function loadProfilesPanel() {
const panel = $('profilesPanel');
if (!panel) return;
try {
const data = await api('/api/profiles');
_profilesCache = data;
panel.innerHTML = '';
if (!data.profiles || !data.profiles.length) {
panel.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:12px">No profiles found.</div>';
return;
}
for (const p of data.profiles) {
const card = document.createElement('div');
card.className = 'profile-card';
const meta = [];
if (p.model) meta.push(p.model.split('/').pop());
if (p.provider) meta.push(p.provider);
if (p.skill_count) meta.push(p.skill_count + ' skill' + (p.skill_count !== 1 ? 's' : ''));
if (p.has_env) meta.push('API keys configured');
const gwDot = p.gateway_running
? '<span class="profile-opt-badge running" title="Gateway running"></span>'
: '<span class="profile-opt-badge stopped" title="Gateway stopped"></span>';
const isActive = p.name === data.active;
const activeBadge = isActive ? '<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">ACTIVE</span>' : '';
card.innerHTML = `
<div class="profile-card-header">
<div style="min-width:0;flex:1">
<div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5">(default)</span>' : ''}${activeBadge}</div>
${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : '<div class="profile-card-meta">No configuration</div>'}
</div>
<div class="profile-card-actions">
${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="Switch to this profile">Use</button>` : ''}
${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="Delete this profile">&#10005;</button>` : ''}
</div>
</div>`;
panel.appendChild(card);
}
} catch (e) {
panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">Error: ${esc(e.message)}</div>`;
}
}
function renderProfileDropdown(data) {
const dd = $('profileDropdown');
if (!dd) return;
dd.innerHTML = '';
const profiles = data.profiles || [];
const active = data.active || 'default';
for (const p of profiles) {
const opt = document.createElement('div');
opt.className = 'profile-opt' + (p.name === active ? ' active' : '');
const meta = [];
if (p.model) meta.push(p.model.split('/').pop());
if (p.skill_count) meta.push(p.skill_count + ' skills');
const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;
const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';
opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5;font-weight:400">(default)</span>' : ''}${checkmark}</div>` +
(meta.length ? `<div class="profile-opt-meta">${esc(meta.join(' \u00b7 '))}</div>` : '');
opt.onclick = async () => {
closeProfileDropdown();
if (p.name === active) return;
await switchToProfile(p.name);
};
dd.appendChild(opt);
}
// Divider + Manage link
const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div);
const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage';
mgmt.innerHTML = '&#9881; Manage profiles';
mgmt.onclick = () => { closeProfileDropdown(); switchPanel('profiles'); };
dd.appendChild(mgmt);
}
function toggleProfileDropdown() {
const dd = $('profileDropdown');
if (!dd) return;
if (dd.classList.contains('open')) { closeProfileDropdown(); return; }
closeWsDropdown(); // close workspace dropdown if open
api('/api/profiles').then(data => {
renderProfileDropdown(data);
dd.classList.add('open');
}).catch(e => { showToast('Failed to load profiles'); });
}
function closeProfileDropdown() {
const dd = $('profileDropdown');
if (dd) dd.classList.remove('open');
}
document.addEventListener('click', e => {
if (!e.target.closest('#profileChipWrap')) closeProfileDropdown();
});
async function switchToProfile(name) {
if (S.busy) { showToast('Cannot switch profiles while agent is running'); return; }
try {
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
S.activeProfile = data.active || name;
syncTopbar();
// Refresh dependent panels
_skillsData = null;
await populateModelDropdown();
if (_currentPanel === 'skills') await loadSkills();
if (_currentPanel === 'memory') await loadMemory();
if (_currentPanel === 'tasks') await loadCrons();
if (_currentPanel === 'profiles') await loadProfilesPanel();
showToast('Switched to profile: ' + name);
} catch (e) { showToast('Switch failed: ' + e.message); }
}
function toggleProfileForm() {
const form = $('profileCreateForm');
if (!form) return;
form.style.display = form.style.display === 'none' ? '' : 'none';
if (form.style.display !== 'none') {
$('profileFormName').value = '';
$('profileFormClone').checked = false;
const errEl = $('profileFormError');
if (errEl) errEl.style.display = 'none';
$('profileFormName').focus();
}
}
async function submitProfileCreate() {
const name = ($('profileFormName').value || '').trim().toLowerCase();
const cloneConfig = $('profileFormClone').checked;
const errEl = $('profileFormError');
if (!name) { errEl.textContent = 'Name is required'; errEl.style.display = ''; return; }
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = 'Lowercase letters, numbers, hyphens, underscores only'; errEl.style.display = ''; return; }
try {
await api('/api/profile/create', { method: 'POST', body: JSON.stringify({ name, clone_config: cloneConfig }) });
toggleProfileForm();
await loadProfilesPanel();
showToast('Profile created: ' + name);
} catch (e) { errEl.textContent = e.message || 'Create failed'; errEl.style.display = ''; }
}
async function deleteProfile(name) {
if (!confirm(`Delete profile "${name}"? This removes all config, skills, memory, and sessions for this profile.`)) return;
try {
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
await loadProfilesPanel();
showToast('Profile deleted: ' + name);
} catch (e) { showToast('Delete failed: ' + e.message); }
}
// ── Memory panel ── // ── Memory panel ──
async function loadMemory(force) { async function loadMemory(force) {
const panel = $('memoryPanel'); const panel = $('memoryPanel');

View File

@@ -363,6 +363,25 @@
.ws-row-actions{display:flex;gap:4px;flex-shrink:0;} .ws-row-actions{display:flex;gap:4px;flex-shrink:0;}
.ws-action-btn{padding:4px 9px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap;} .ws-action-btn{padding:4px 9px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap;}
.ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);} .ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
/* ── Profile dropdown + management panel ── */
.profile-chip{user-select:none;color:rgba(168,139,250,.9)!important;}
.profile-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:260px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:380px;overflow-y:auto;}
.profile-dropdown.open{display:block;}
.profile-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
.profile-opt:hover{background:rgba(255,255,255,.07);}
.profile-opt.active{background:rgba(168,139,250,.08);}
.profile-opt-name{font-size:13px;color:var(--text);font-weight:500;}
.profile-opt-meta{font-size:11px;color:var(--muted);margin-top:2px;}
.profile-opt-badge{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px;vertical-align:middle;}
.profile-opt-badge.running{background:#4caf50;box-shadow:0 0 4px rgba(76,175,80,.5);}
.profile-opt-badge.stopped{background:rgba(255,255,255,.2);}
.profile-card{padding:10px 0;border-bottom:1px solid var(--border);}
.profile-card:last-of-type{border-bottom:none;}
.profile-card-header{display:flex;align-items:center;justify-content:space-between;gap:8px;}
.profile-card-name{font-size:13px;font-weight:600;color:var(--text);}
.profile-card-name.is-active{color:rgba(168,139,250,.9);}
.profile-card-meta{font-size:11px;color:var(--muted);margin-top:3px;padding-left:12px;}
.profile-card-actions{display:flex;gap:4px;flex-shrink:0;}
/* ── Slash command autocomplete dropdown ── */ /* ── Slash command autocomplete dropdown ── */
.cmd-dropdown{display:none;position:absolute;bottom:100%;left:0;right:0;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;margin-bottom:4px;} .cmd-dropdown{display:none;position:absolute;bottom:100%;left:0;right:0;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;margin-bottom:4px;}
.cmd-dropdown.open{display:block;} .cmd-dropdown.open{display:block;}

View File

@@ -1,4 +1,4 @@
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.'}; const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default'};
const INFLIGHT={}; // keyed by session_id while request in-flight const INFLIGHT={}; // keyed by session_id while request in-flight
const MSG_QUEUE=[]; // messages queued while a request is in-flight const MSG_QUEUE=[]; // messages queued while a request is in-flight
const $=id=>document.getElementById(id); const $=id=>document.getElementById(id);
@@ -353,6 +353,9 @@ function syncTopbar(){
sidebarPath.textContent=ws; sidebarPath.textContent=ws;
} }
// modelSelect already set above // modelSelect already set above
// Update profile chip label
const profileLabel=$('profileChipLabel');
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
} }
function msgContent(m){ function msgContent(m){