From 6e97191f214bee3f22c01760dde3dca26b0c427a Mon Sep 17 00:00:00 2001 From: Vincent Jiang Date: Fri, 17 Apr 2026 14:21:05 -0700 Subject: [PATCH] feat: UI/UX improvements across BYOK, org chart, profiles, and prompt library - BYOK: unified styling (remove purple, consistent grey headers), model selector opens settings modal directly, backend validates API keys before activation - Org chart: queen profiles are now editable (name, title, about, skills, achievement) with changes persisted to YAML - Avatars: upload profile pictures for queens and user with client-side compression, displayed across org chart, sidebar, chat, and header - Colony deletion: await backend delete and re-fetch to prevent ghost colonies - Prompt library: add pagination (24/page), custom prompt upload/delete with backend persistence - Settings modal: performance cleanup (remove backdrop-blur, reduce transitions) - Fix ensure_default_queens() overwriting user edits on every API call Co-Authored-By: Claude Opus 4.6 (1M context) --- core/framework/agents/queen/queen_profiles.py | 17 +- core/framework/llm/model_catalog.json | 35 + core/framework/server/app.py | 2 + core/framework/server/routes_config.py | 169 ++++- core/framework/server/routes_prompts.py | 88 +++ core/framework/server/routes_queens.py | 129 +++- core/frontend/src/api/client.ts | 11 +- core/frontend/src/api/config.ts | 6 + core/frontend/src/api/prompts.ts | 19 + core/frontend/src/api/queens.ts | 7 + core/frontend/src/components/AppHeader.tsx | 62 +- core/frontend/src/components/ChatPanel.tsx | 38 +- .../frontend/src/components/ModelSwitcher.tsx | 188 +++--- .../src/components/QueenProfilePanel.tsx | 294 ++++++--- .../frontend/src/components/SettingsModal.tsx | 618 ++++++------------ .../src/components/SidebarQueenItem.tsx | 12 +- core/frontend/src/context/ColonyContext.tsx | 19 +- core/frontend/src/lib/image-utils.ts | 39 ++ core/frontend/src/pages/org-chart.tsx | 53 +- core/frontend/src/pages/prompt-library.tsx | 287 ++++++-- core/frontend/src/pages/queen-dm.tsx | 1 + 21 files changed, 1350 insertions(+), 744 deletions(-) create mode 100644 core/framework/server/routes_prompts.py create mode 100644 core/frontend/src/api/prompts.ts create mode 100644 core/frontend/src/lib/image-utils.ts diff --git a/core/framework/agents/queen/queen_profiles.py b/core/framework/agents/queen/queen_profiles.py index bb535255..fe957b0f 100644 --- a/core/framework/agents/queen/queen_profiles.py +++ b/core/framework/agents/queen/queen_profiles.py @@ -1099,12 +1099,17 @@ def ensure_default_queens() -> None: Safe to call multiple times — skips any profile that already has a file. """ + created = 0 for queen_id, profile in DEFAULT_QUEENS.items(): queen_dir = QUEENS_DIR / queen_id profile_path = queen_dir / "profile.yaml" + if profile_path.exists(): + continue queen_dir.mkdir(parents=True, exist_ok=True) profile_path.write_text(yaml.safe_dump(profile, sort_keys=False, allow_unicode=True)) - logger.info("Queen profiles ensured at %s", QUEENS_DIR) + created += 1 + if created: + logger.info("Created %d default queen profile(s) at %s", created, QUEENS_DIR) def list_queens() -> list[dict[str, str]]: @@ -1143,6 +1148,10 @@ def load_queen_profile(queen_id: str) -> dict[str, Any]: def update_queen_profile(queen_id: str, updates: dict[str, Any]) -> dict[str, Any]: """Merge partial updates into an existing queen profile and persist. + Performs a shallow merge at the top level, but deep-merges dict values + (e.g. world_lore, hidden_background) so partial sub-field updates don't + clobber sibling keys. + Returns the full updated profile. Raises FileNotFoundError if the profile doesn't exist. """ @@ -1150,7 +1159,11 @@ def update_queen_profile(queen_id: str, updates: dict[str, Any]) -> dict[str, An if not profile_path.exists(): raise FileNotFoundError(f"Queen profile not found: {queen_id}") data = yaml.safe_load(profile_path.read_text()) - data.update(updates) + for key, value in updates.items(): + if isinstance(value, dict) and isinstance(data.get(key), dict): + data[key].update(value) + else: + data[key] = value profile_path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True)) return data diff --git a/core/framework/llm/model_catalog.json b/core/framework/llm/model_catalog.json index 2f50b865..1c1e6159 100644 --- a/core/framework/llm/model_catalog.json +++ b/core/framework/llm/model_catalog.json @@ -317,6 +317,41 @@ "recommended": false, "max_tokens": 32768, "max_context_tokens": 163840 + }, + { + "id": "qwen/qwen3.6-plus", + "label": "Qwen 3.6 Plus - Strong reasoning", + "recommended": false, + "max_tokens": 32768, + "max_context_tokens": 131072 + }, + { + "id": "z-ai/glm-5v-turbo", + "label": "GLM-5V Turbo - Vision capable", + "recommended": false, + "max_tokens": 16384, + "max_context_tokens": 128000 + }, + { + "id": "x-ai/grok-4.20", + "label": "Grok 4.20 - xAI flagship", + "recommended": false, + "max_tokens": 32768, + "max_context_tokens": 131072 + }, + { + "id": "xiaomi/mimo-v2-pro", + "label": "MiMo V2 Pro - Xiaomi multimodal", + "recommended": false, + "max_tokens": 16384, + "max_context_tokens": 65536 + }, + { + "id": "stepfun/step-3.5-flash", + "label": "Step 3.5 Flash - Fast inference", + "recommended": false, + "max_tokens": 32768, + "max_context_tokens": 128000 } ] } diff --git a/core/framework/server/app.py b/core/framework/server/app.py index 37c25a82..3f9b3d1e 100644 --- a/core/framework/server/app.py +++ b/core/framework/server/app.py @@ -295,6 +295,7 @@ def create_app(model: str | None = None) -> web.Application: from framework.server.routes_queens import register_routes as register_queen_routes from framework.server.routes_sessions import register_routes as register_session_routes from framework.server.routes_workers import register_routes as register_worker_routes + from framework.server.routes_prompts import register_routes as register_prompt_routes register_config_routes(app) register_credential_routes(app) @@ -305,6 +306,7 @@ def create_app(model: str | None = None) -> web.Application: register_worker_routes(app) register_log_routes(app) register_queen_routes(app) + register_prompt_routes(app) # Static file serving — Option C production mode # If frontend/dist/ exists, serve built frontend files on / diff --git a/core/framework/server/routes_config.py b/core/framework/server/routes_config.py index 8302501c..700495f4 100644 --- a/core/framework/server/routes_config.py +++ b/core/framework/server/routes_config.py @@ -6,6 +6,7 @@ Routes: - GET /api/config/models — curated provider→models list """ +import asyncio import json import logging import os @@ -301,6 +302,59 @@ def _hot_swap_sessions(request: web.Request, full_model: str, api_key: str | Non return swapped +async def _validate_provider_key( + provider: str, + api_key: str, + api_base: str | None = None, + model: str | None = None, +) -> dict: + """Validate an API key against the provider. Returns {"valid": bool, "message": str}. + + Runs the check in a thread pool to avoid blocking the event loop. + """ + from scripts.check_llm_key import PROVIDERS as CHECK_PROVIDERS + from scripts.check_llm_key import ( + check_anthropic_compatible, + check_minimax, + check_openai_compatible, + check_openrouter, + check_openrouter_model, + ) + + def _check() -> dict: + pid = provider.lower() + try: + # Subscription providers with custom api_base + if pid == "openrouter" and model: + return check_openrouter_model( + api_key, model=model, api_base=api_base or "https://openrouter.ai/api/v1" + ) + if api_base and pid == "minimax": + return check_minimax(api_key, api_base) + if api_base and pid == "openrouter": + return check_openrouter(api_key, api_base) + if api_base and pid == "kimi": + return check_anthropic_compatible( + api_key, api_base.rstrip("/") + "/v1/messages", "Kimi" + ) + if api_base and pid == "hive": + return check_anthropic_compatible( + api_key, api_base.rstrip("/") + "/v1/messages", "Hive" + ) + if api_base: + endpoint = api_base.rstrip("/") + "/models" + name = {"zai": "ZAI"}.get(pid, "Custom provider") + return check_openai_compatible(api_key, endpoint, name) + if pid in CHECK_PROVIDERS: + return CHECK_PROVIDERS[pid](api_key) + # No check available — assume valid + return {"valid": True, "message": f"No health check for {pid}"} + except Exception as exc: + return {"valid": None, "message": f"Validation error: {exc}"} + + return await asyncio.get_event_loop().run_in_executor(None, _check) + + # ------------------------------------------------------------------ # Handlers # ------------------------------------------------------------------ @@ -324,9 +378,12 @@ async def handle_get_llm_config(request: web.Request) -> web.Response: if _resolve_api_key(pid, request) is not None: connected.append(pid) - # Subscription detection + # Subscription detection — only include subscriptions whose tokens exist active_subscription = _get_active_subscription(llm) - detected_subscriptions = _detect_subscriptions() + detected_subscriptions = [ + sid for sid in _detect_subscriptions() + if _get_subscription_token(sid) + ] return web.json_response( { @@ -369,6 +426,21 @@ async def handle_update_llm_config(request: web.Request) -> web.Response: provider = sub["provider"] api_base = sub.get("api_base") + # Validate the subscription token before committing + token = _get_subscription_token(subscription_id) + if not token: + return web.json_response( + {"error": f"No credential found for {sub['name']}. Please check your subscription or API key."}, + status=400, + ) + + check = await _validate_provider_key(provider, token, api_base=api_base) + if check.get("valid") is False: + return web.json_response( + {"error": f"{sub['name']} key validation failed: {check.get('message', 'unknown error')}"}, + status=400, + ) + # Look up token limits from preset max_tokens: int | None = None max_context_tokens: int | None = None @@ -399,8 +471,7 @@ async def handle_update_llm_config(request: web.Request) -> web.Response: _write_config_atomic(config) - # Hot-swap with subscription token - token = _get_subscription_token(subscription_id) + # Hot-swap with subscription token (already validated above) full_model = f"{provider}/{model}" swapped = _hot_swap_sessions(request, full_model, api_key=token, api_base=api_base) @@ -430,15 +501,36 @@ async def handle_update_llm_config(request: web.Request) -> web.Response: if not provider or not model: return web.json_response({"error": "Both 'provider' and 'model' are required"}, status=400) - # Look up token limits from catalogue + # Verify model exists in the catalogue model_info = _find_model_info(provider, model) - max_tokens = model_info["max_tokens"] if model_info else 8192 - max_context_tokens = model_info["max_context_tokens"] if model_info else 120000 + if not model_info: + return web.json_response( + {"error": f"Model '{model}' is not available for provider '{provider}'."}, + status=400, + ) + + max_tokens = model_info["max_tokens"] + max_context_tokens = model_info["max_context_tokens"] # Determine env var and api_base env_var = PROVIDER_ENV_VARS.get(provider.lower(), "") api_base = _get_api_base_for_provider(provider) + # Validate the API key before committing + api_key = _resolve_api_key(provider, request) + if not api_key: + return web.json_response( + {"error": f"No API key found for {provider}. Please add one in Manage Keys."}, + status=400, + ) + + check = await _validate_provider_key(provider, api_key, api_base=api_base, model=model) + if check.get("valid") is False: + return web.json_response( + {"error": f"API key validation failed for {provider}: {check.get('message', 'unknown error')}"}, + status=400, + ) + # Update ~/.hive/configuration.json config = get_hive_config() llm_section = config.setdefault("llm", {}) @@ -458,8 +550,7 @@ async def handle_update_llm_config(request: web.Request) -> web.Response: _write_config_atomic(config) - # Hot-swap all running sessions - api_key = _resolve_api_key(provider, request) + # Hot-swap all running sessions (api_key already validated above) full_model = f"{provider}/{model}" swapped = _hot_swap_sessions(request, full_model, api_key=api_key, api_base=api_base) @@ -594,6 +685,64 @@ async def handle_get_models(request: web.Request) -> web.Response: return web.json_response({"models": MODELS_CATALOGUE}) +# ------------------------------------------------------------------ +# User avatar +# ------------------------------------------------------------------ + +MAX_AVATAR_BYTES = 2 * 1024 * 1024 # 2 MB +_ALLOWED_AVATAR_TYPES = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + + +async def handle_upload_user_avatar(request: web.Request) -> web.Response: + """POST /api/config/profile/avatar — upload user profile picture.""" + reader = await request.multipart() + field = await reader.next() + if field is None or field.name != "avatar": + return web.json_response({"error": "Expected a file field named 'avatar'"}, status=400) + + content_type = getattr(field, "content_type", None) or field.headers.get("Content-Type", "") + ext = _ALLOWED_AVATAR_TYPES.get(content_type) + if not ext: + return web.json_response( + {"error": f"Unsupported image type: {content_type}. Use JPEG, PNG, or WebP."}, + status=400, + ) + + data = bytearray() + while True: + chunk = await field.read_chunk(8192) + if not chunk: + break + data.extend(chunk) + if len(data) > MAX_AVATAR_BYTES: + return web.json_response({"error": "Image too large. Maximum size is 2 MB."}, status=400) + + if not data: + return web.json_response({"error": "Empty file"}, status=400) + + # Remove existing avatar files + for existing in HIVE_CONFIG_FILE.parent.glob("avatar.*"): + existing.unlink(missing_ok=True) + + avatar_path = HIVE_CONFIG_FILE.parent / f"avatar{ext}" + avatar_path.write_bytes(data) + logger.info("User avatar uploaded: %s (%d bytes)", avatar_path.name, len(data)) + return web.json_response({"avatar_url": "/api/config/profile/avatar"}) + + +async def handle_get_user_avatar(request: web.Request) -> web.Response: + """GET /api/config/profile/avatar — serve user profile picture.""" + for ext in _ALLOWED_AVATAR_TYPES.values(): + avatar_path = HIVE_CONFIG_FILE.parent / f"avatar{ext}" + if avatar_path.exists(): + return web.FileResponse(avatar_path, headers={"Cache-Control": "public, max-age=3600"}) + return web.json_response({"error": "No avatar found"}, status=404) + + # ------------------------------------------------------------------ # Route registration # ------------------------------------------------------------------ @@ -606,3 +755,5 @@ def register_routes(app: web.Application) -> None: app.router.add_get("/api/config/models", handle_get_models) app.router.add_get("/api/config/profile", handle_get_profile) app.router.add_put("/api/config/profile", handle_update_profile) + app.router.add_post("/api/config/profile/avatar", handle_upload_user_avatar) + app.router.add_get("/api/config/profile/avatar", handle_get_user_avatar) diff --git a/core/framework/server/routes_prompts.py b/core/framework/server/routes_prompts.py new file mode 100644 index 00000000..73e01527 --- /dev/null +++ b/core/framework/server/routes_prompts.py @@ -0,0 +1,88 @@ +"""Custom user prompts — CRUD for user-uploaded prompts. + +- GET /api/prompts — list all custom prompts +- POST /api/prompts — add a new custom prompt +- DELETE /api/prompts/{id} — delete a custom prompt +""" + +import json +import logging +import time +from pathlib import Path + +from aiohttp import web + +from framework.config import HIVE_HOME + +logger = logging.getLogger(__name__) + +CUSTOM_PROMPTS_FILE = HIVE_HOME / "custom_prompts.json" + + +def _load_custom_prompts() -> list[dict]: + if not CUSTOM_PROMPTS_FILE.exists(): + return [] + try: + data = json.loads(CUSTOM_PROMPTS_FILE.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def _save_custom_prompts(prompts: list[dict]) -> None: + CUSTOM_PROMPTS_FILE.parent.mkdir(parents=True, exist_ok=True) + CUSTOM_PROMPTS_FILE.write_text( + json.dumps(prompts, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + +async def handle_list_prompts(request: web.Request) -> web.Response: + """GET /api/prompts — list all custom prompts.""" + return web.json_response({"prompts": _load_custom_prompts()}) + + +async def handle_create_prompt(request: web.Request) -> web.Response: + """POST /api/prompts — add a new custom prompt.""" + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + title = (body.get("title") or "").strip() + category = (body.get("category") or "").strip() + content = (body.get("content") or "").strip() + + if not title or not content: + return web.json_response({"error": "Title and content are required"}, status=400) + + prompts = _load_custom_prompts() + new_prompt = { + "id": f"custom_{int(time.time() * 1000)}", + "title": title, + "category": category or "custom", + "content": content, + "custom": True, + } + prompts.append(new_prompt) + _save_custom_prompts(prompts) + logger.info("Custom prompt added: %s", title) + return web.json_response(new_prompt, status=201) + + +async def handle_delete_prompt(request: web.Request) -> web.Response: + """DELETE /api/prompts/{prompt_id} — delete a custom prompt.""" + prompt_id = request.match_info["prompt_id"] + prompts = _load_custom_prompts() + before = len(prompts) + prompts = [p for p in prompts if p.get("id") != prompt_id] + if len(prompts) == before: + return web.json_response({"error": "Prompt not found"}, status=404) + _save_custom_prompts(prompts) + return web.json_response({"deleted": prompt_id}) + + +def register_routes(app: web.Application) -> None: + app.router.add_get("/api/prompts", handle_list_prompts) + app.router.add_post("/api/prompts", handle_create_prompt) + app.router.add_delete("/api/prompts/{prompt_id}", handle_delete_prompt) diff --git a/core/framework/server/routes_queens.py b/core/framework/server/routes_queens.py index 4fc41a68..911ce058 100644 --- a/core/framework/server/routes_queens.py +++ b/core/framework/server/routes_queens.py @@ -3,6 +3,8 @@ - GET /api/queen/profiles -- list all queen profiles (id, name, title) - GET /api/queen/{queen_id}/profile -- get full queen profile - PATCH /api/queen/{queen_id}/profile -- update queen profile fields +- POST /api/queen/{queen_id}/avatar -- upload queen avatar image +- GET /api/queen/{queen_id}/avatar -- serve queen avatar image - POST /api/queen/{queen_id}/session -- get or create a persistent session for a queen - POST /api/queen/{queen_id}/session/select -- resume a specific session for a queen - POST /api/queen/{queen_id}/session/new -- create a fresh session for a queen @@ -10,6 +12,7 @@ import json import logging +from pathlib import Path from typing import Any from aiohttp import web @@ -177,6 +180,34 @@ async def handle_get_profile(request: web.Request) -> web.Response: return web.json_response({"id": queen_id, **api_profile}) +def _reverse_transform_for_yaml(body: dict) -> dict: + """Map API-format fields back to YAML profile fields. + + The API exposes a simplified view (summary, skills, signature_achievement) + that maps onto the underlying YAML structure (core_traits, hidden_background, + psychological_profile, world_lore, etc.). + """ + yaml_updates: dict[str, Any] = {} + + if "name" in body: + yaml_updates["name"] = body["name"] + if "title" in body: + yaml_updates["title"] = body["title"] + + if "summary" in body: + # Summary is displayed as core_traits + anti_stereotype joined by \n\n. + # Store the full text in core_traits for simplicity. + yaml_updates["core_traits"] = body["summary"] + + if "skills" in body: + yaml_updates["skills"] = body["skills"] + + if "signature_achievement" in body: + yaml_updates.setdefault("world_lore", {})["habitat"] = body["signature_achievement"] + + return yaml_updates + + async def handle_update_profile(request: web.Request) -> web.Response: """PATCH /api/queen/{queen_id}/profile — update queen profile fields.""" queen_id = request.match_info["queen_id"] @@ -186,11 +217,18 @@ async def handle_update_profile(request: web.Request) -> web.Response: return web.json_response({"error": "Invalid JSON body"}, status=400) if not isinstance(body, dict): return web.json_response({"error": "Body must be a JSON object"}, status=400) + + yaml_updates = _reverse_transform_for_yaml(body) + if not yaml_updates: + return web.json_response({"error": "No valid fields to update"}, status=400) + try: - updated = update_queen_profile(queen_id, body) + updated = update_queen_profile(queen_id, yaml_updates) except FileNotFoundError: return web.json_response({"error": f"Queen '{queen_id}' not found"}, status=404) - return web.json_response({"id": queen_id, **updated}) + + api_profile = _transform_profile_for_api(updated) + return web.json_response({"id": queen_id, **api_profile}) async def handle_queen_session(request: web.Request) -> web.Response: @@ -382,11 +420,98 @@ async def handle_new_queen_session(request: web.Request) -> web.Response: ) +MAX_AVATAR_BYTES = 2 * 1024 * 1024 # 2 MB max after compression +_ALLOWED_AVATAR_TYPES = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + + +async def handle_upload_avatar(request: web.Request) -> web.Response: + """POST /api/queen/{queen_id}/avatar — upload queen avatar image. + + Accepts multipart/form-data with a single file field named 'avatar'. + Stores as avatar.{ext} in the queen's profile directory. + """ + from framework.config import QUEENS_DIR + + queen_id = request.match_info["queen_id"] + queen_dir = QUEENS_DIR / queen_id + if not (queen_dir / "profile.yaml").exists(): + return web.json_response({"error": f"Queen '{queen_id}' not found"}, status=404) + + reader = await request.multipart() + field = await reader.next() + if field is None or field.name != "avatar": + return web.json_response({"error": "Expected a file field named 'avatar'"}, status=400) + + content_type = field.headers.get("Content-Type", "application/octet-stream") + # Also check by content_type from the field + if hasattr(field, "content_type"): + content_type = field.content_type or content_type + + ext = _ALLOWED_AVATAR_TYPES.get(content_type) + if not ext: + return web.json_response( + {"error": f"Unsupported image type: {content_type}. Use JPEG, PNG, or WebP."}, + status=400, + ) + + # Read the file data with size limit + data = bytearray() + while True: + chunk = await field.read_chunk(8192) + if not chunk: + break + data.extend(chunk) + if len(data) > MAX_AVATAR_BYTES: + return web.json_response( + {"error": f"Image too large. Maximum size is {MAX_AVATAR_BYTES // 1024 // 1024} MB."}, + status=400, + ) + + if not data: + return web.json_response({"error": "Empty file"}, status=400) + + # Remove any existing avatar files + for existing in queen_dir.glob("avatar.*"): + existing.unlink(missing_ok=True) + + # Write the new avatar + avatar_path = queen_dir / f"avatar{ext}" + avatar_path.write_bytes(data) + + logger.info("Avatar uploaded for queen %s: %s (%d bytes)", queen_id, avatar_path.name, len(data)) + return web.json_response({"avatar_url": f"/api/queen/{queen_id}/avatar"}) + + +async def handle_get_avatar(request: web.Request) -> web.Response: + """GET /api/queen/{queen_id}/avatar — serve queen avatar image.""" + from framework.config import QUEENS_DIR + + queen_id = request.match_info["queen_id"] + queen_dir = QUEENS_DIR / queen_id + + # Find avatar file with any supported extension + for ext in _ALLOWED_AVATAR_TYPES.values(): + avatar_path = queen_dir / f"avatar{ext}" + if avatar_path.exists(): + return web.FileResponse( + avatar_path, + headers={"Cache-Control": "public, max-age=3600"}, + ) + + return web.json_response({"error": "No avatar found"}, status=404) + + def register_routes(app: web.Application) -> None: """Register queen profile routes.""" app.router.add_get("/api/queen/profiles", handle_list_profiles) app.router.add_get("/api/queen/{queen_id}/profile", handle_get_profile) app.router.add_patch("/api/queen/{queen_id}/profile", handle_update_profile) + app.router.add_post("/api/queen/{queen_id}/avatar", handle_upload_avatar) + app.router.add_get("/api/queen/{queen_id}/avatar", handle_get_avatar) app.router.add_post("/api/queen/{queen_id}/session", handle_queen_session) app.router.add_post("/api/queen/{queen_id}/session/select", handle_select_queen_session) app.router.add_post("/api/queen/{queen_id}/session/new", handle_new_queen_session) diff --git a/core/frontend/src/api/client.ts b/core/frontend/src/api/client.ts index d9fed260..f042a5f8 100644 --- a/core/frontend/src/api/client.ts +++ b/core/frontend/src/api/client.ts @@ -12,12 +12,13 @@ export class ApiError extends Error { async function request(path: string, options: RequestInit = {}): Promise { const url = `${API_BASE}${path}`; + const isFormData = options.body instanceof FormData; + const headers: Record = isFormData + ? {} // Let browser set Content-Type with boundary for multipart + : { "Content-Type": "application/json", ...options.headers as Record }; const response = await fetch(url, { ...options, - headers: { - "Content-Type": "application/json", - ...options.headers, - }, + headers, }); if (!response.ok) { @@ -52,4 +53,6 @@ export const api = { method: "PATCH", body: body ? JSON.stringify(body) : undefined, }), + upload: (path: string, formData: FormData) => + request(path, { method: "POST", body: formData }), }; diff --git a/core/frontend/src/api/config.ts b/core/frontend/src/api/config.ts index f514a034..b8b4eb9a 100644 --- a/core/frontend/src/api/config.ts +++ b/core/frontend/src/api/config.ts @@ -64,4 +64,10 @@ export const configApi = { about, ...(theme ? { theme } : {}), }), + + uploadAvatar: (file: File) => { + const fd = new FormData(); + fd.append("avatar", file); + return api.upload<{ avatar_url: string }>("/config/profile/avatar", fd); + }, }; diff --git a/core/frontend/src/api/prompts.ts b/core/frontend/src/api/prompts.ts new file mode 100644 index 00000000..f655962c --- /dev/null +++ b/core/frontend/src/api/prompts.ts @@ -0,0 +1,19 @@ +import { api } from "./client"; + +export interface CustomPrompt { + id: string; + title: string; + category: string; + content: string; + custom: true; +} + +export const promptsApi = { + list: () => api.get<{ prompts: CustomPrompt[] }>("/prompts"), + + create: (title: string, category: string, content: string) => + api.post("/prompts", { title, category, content }), + + delete: (promptId: string) => + api.delete<{ deleted: string }>(`/prompts/${promptId}`), +}; diff --git a/core/frontend/src/api/queens.ts b/core/frontend/src/api/queens.ts index 575a702f..35e57dea 100644 --- a/core/frontend/src/api/queens.ts +++ b/core/frontend/src/api/queens.ts @@ -31,6 +31,13 @@ export const queensApi = { updateProfile: (queenId: string, updates: Partial) => api.patch(`/queen/${queenId}/profile`, updates), + /** Upload queen avatar image. */ + uploadAvatar: (queenId: string, file: File) => { + const fd = new FormData(); + fd.append("avatar", file); + return api.upload<{ avatar_url: string }>(`/queen/${queenId}/avatar`, fd); + }, + /** Get or create a persistent session for a queen. */ getOrCreateSession: (queenId: string, initialPrompt?: string, initialPhase?: string) => api.post(`/queen/${queenId}/session`, { diff --git a/core/frontend/src/components/AppHeader.tsx b/core/frontend/src/components/AppHeader.tsx index be5fe63b..2013f9df 100644 --- a/core/frontend/src/components/AppHeader.tsx +++ b/core/frontend/src/components/AppHeader.tsx @@ -1,11 +1,31 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useLocation } from "react-router-dom"; import { useColony } from "@/context/ColonyContext"; import { useHeaderActions } from "@/context/HeaderActionsContext"; +import { useModel } from "@/context/ModelContext"; import { getQueenForAgent } from "@/lib/colony-registry"; -import { Crown, KeyRound, Network } from "lucide-react"; +import { Crown, KeyRound, Network, ChevronDown } from "lucide-react"; import SettingsModal from "@/components/SettingsModal"; -import ModelSwitcher from "@/components/ModelSwitcher"; + +function UserAvatarButton({ initials, onClick, avatarVersion }: { initials: string; onClick: () => void; avatarVersion: number }) { + const [hasAvatar, setHasAvatar] = useState(true); + const url = `/api/config/profile/avatar?v=${avatarVersion}`; + // Reset hasAvatar when version changes (new upload) + useEffect(() => setHasAvatar(true), [avatarVersion]); + return ( + + ); +} interface AppHeaderProps { onOpenQueenProfile?: (queenId: string) => void; @@ -13,11 +33,23 @@ interface AppHeaderProps { export default function AppHeader({ onOpenQueenProfile }: AppHeaderProps) { const location = useLocation(); - const { colonies, queens, queenProfiles, userProfile } = useColony(); + const { colonies, queens, queenProfiles, userProfile, userAvatarVersion } = useColony(); const { actions } = useHeaderActions(); + const { currentModel, currentProvider, availableModels, activeSubscription, subscriptions } = useModel(); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState<"profile" | "byok">("profile"); + // Derive active model display label + const activeSubInfo = activeSubscription + ? subscriptions.find((s) => s.id === activeSubscription) + : null; + const modelsProvider = activeSubInfo?.provider || currentProvider; + const models = availableModels[modelsProvider] || []; + const currentModelInfo = models.find((m) => m.id === currentModel); + const modelLabel = currentModelInfo + ? currentModelInfo.label.split(" - ")[0] + : currentModel || "No model"; + // Derive page title + icon from current route const colonyMatch = location.pathname.match(/^\/colony\/(.+)/); const queenMatch = location.pathname.match(/^\/queen\/(.+)/); @@ -86,24 +118,24 @@ export default function AppHeader({ onOpenQueenProfile }: AppHeaderProps) { )}
{actions} - { + + { setSettingsSection("profile"); setSettingsOpen(true); }} - className="w-7 h-7 rounded-full bg-primary/15 flex items-center justify-center hover:bg-primary/25 transition-colors" - title="Profile settings" - > - - {initials || "U"} - - + />
diff --git a/core/frontend/src/components/ChatPanel.tsx b/core/frontend/src/components/ChatPanel.tsx index 2480a026..852e3d3a 100644 --- a/core/frontend/src/components/ChatPanel.tsx +++ b/core/frontend/src/components/ChatPanel.tsx @@ -101,6 +101,8 @@ interface ChatPanelProps { contextUsage?: Record; /** One-shot composer prefill. Applied to the textarea whenever the value changes. */ initialDraft?: string | null; + /** Queen ID — used to display the queen's avatar photo in messages */ + queenId?: string; } const queenColor = "hsl(45,95%,58%)"; @@ -300,10 +302,12 @@ function InlineAskUserBubble({ onSend, queenPhase, showQueenPhaseBadge = true, + queenAvatarUrl, }: { msg: ChatMessage; payload: AskUserInlinePayload; activeThread: string; + queenAvatarUrl?: string | null; onSend: ( message: string, thread: string, @@ -328,6 +332,7 @@ function InlineAskUserBubble({ msg={msg} queenPhase={queenPhase} showQueenPhaseBadge={showQueenPhaseBadge} + queenAvatarUrl={queenAvatarUrl} /> ); } @@ -355,15 +360,15 @@ function InlineAskUserBubble({ return (
{isQueen ? ( - + ) : ( )} @@ -421,15 +426,26 @@ function InlineAskUserBubble({ ); } +function QueenAvatarIcon({ url, size }: { url: string | null; size: number }) { + const [ok, setOk] = useState(!!url); + const dim = size === 9 ? "w-9 h-9" : "w-7 h-7"; + if (ok && url) { + return setOk(false)} />; + } + return ; +} + const MessageBubble = memo( function MessageBubble({ msg, queenPhase, showQueenPhaseBadge = true, + queenAvatarUrl, }: { msg: ChatMessage; queenPhase?: "planning" | "building" | "staging" | "running" | "independent"; showQueenPhaseBadge?: boolean; + queenAvatarUrl?: string | null; }) { const isUser = msg.type === "user"; const isQueen = msg.role === "queen"; @@ -532,15 +548,15 @@ const MessageBubble = memo( return (
{isQueen ? ( - + ) : ( )} @@ -621,6 +637,7 @@ export default function ChatPanel({ contextUsage, supportsImages = true, initialDraft, + queenId, }: ChatPanelProps) { const [input, setInput] = useState(""); const [pendingImages, setPendingImages] = useState([]); @@ -631,6 +648,7 @@ export default function ChatPanel({ const textareaRef = useRef(null); const fileInputRef = useRef(null); const lastAppliedDraftRef = useRef(undefined); + const queenAvatarUrl = queenId ? `/api/queen/${queenId}/avatar` : null; useEffect(() => { if (!initialDraft || initialDraft === lastAppliedDraftRef.current) return; @@ -892,6 +910,7 @@ export default function ChatPanel({ onSend={onSend} queenPhase={queenPhase} showQueenPhaseBadge={showQueenPhaseBadge} + queenAvatarUrl={queenAvatarUrl} />
); @@ -902,6 +921,7 @@ export default function ChatPanel({ msg={msg} queenPhase={queenPhase} showQueenPhaseBadge={showQueenPhaseBadge} + queenAvatarUrl={queenAvatarUrl} />
); @@ -911,14 +931,14 @@ export default function ChatPanel({ {(isWaiting || (disabled && threadMessages.length === 0)) && (
- +
diff --git a/core/frontend/src/components/ModelSwitcher.tsx b/core/frontend/src/components/ModelSwitcher.tsx index e234a703..9bfe5540 100644 --- a/core/frontend/src/components/ModelSwitcher.tsx +++ b/core/frontend/src/components/ModelSwitcher.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useEffect } from "react"; -import { ChevronDown, Check, Settings, ThumbsUp } from "lucide-react"; +import { ChevronDown, Check, Settings, ThumbsUp, AlertCircle } from "lucide-react"; import { useModel, LLM_PROVIDERS } from "@/context/ModelContext"; import type { ModelOption } from "@/api/config"; +import { ApiError } from "@/api/client"; interface ModelSwitcherProps { onOpenSettings?: () => void; @@ -22,6 +23,7 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) { } = useModel(); const [open, setOpen] = useState(false); + const [error, setError] = useState(null); const ref = useRef(null); // Close on click outside @@ -55,26 +57,30 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) { ); const handleSelectApiKey = async (provider: string, modelId: string) => { - setOpen(false); + setError(null); try { await setModel(provider, modelId); + setOpen(false); } catch (err) { - console.error("Failed to switch model:", err); + const msg = err instanceof ApiError ? err.message : "Failed to switch model"; + setError(msg); } }; const handleSelectSubscription = async (subscriptionId: string) => { - setOpen(false); + setError(null); try { await activateSubscription(subscriptionId); + setOpen(false); } catch (err) { - console.error("Failed to activate subscription:", err); + const msg = err instanceof ApiError ? err.message : "Failed to activate subscription"; + setError(msg); } }; - // Get detected but inactive subscriptions - const availableSubscriptions = subscriptions.filter( - (sub) => detectedSubscriptions.has(sub.id) && activeSubscription !== sub.id + // All detected subscriptions (active ones shown with checkmark) + const detectedSubs = subscriptions.filter( + (sub) => detectedSubscriptions.has(sub.id) ); const recommendedIcon = ( @@ -89,12 +95,12 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) { ); - const hasAnyProvider = apiKeyProviders.length > 0 || availableSubscriptions.length > 0 || activeSubInfo; + const hasAnyProvider = apiKeyProviders.length > 0 || detectedSubs.length > 0; return (
- ))} -
- )} - - {/* API key provider models */} {!hasAnyProvider ? (

No providers available. Add an API key or subscription.

) : ( - apiKeyProviders.length > 0 && ( -
-

- API Key Providers -

- {apiKeyProviders.map((provider) => ( -
-

- {provider.name} -

- {(availableModels[provider.id] || []).map( - (model: ModelOption) => { - const isActive = - currentProvider === provider.id && - currentModel === model.id && - !activeSubscription; - return ( - - ); - }, - )} -
- ))} -
- ) + <> + {/* Subscriptions */} + {detectedSubs.length > 0 && ( +
+

+ Subscriptions +

+ {detectedSubs.map((sub) => { + const isActive = activeSubscription === sub.id; + return ( + + ); + })} +
+ )} + + {/* API Keys */} + {apiKeyProviders.length > 0 && ( +
+

+ API Keys +

+ {apiKeyProviders.map((provider) => ( +
+

+ {provider.name} +

+ {(availableModels[provider.id] || []).map( + (model: ModelOption) => { + const isActive = + currentProvider === provider.id && + currentModel === model.id && + !activeSubscription; + return ( + + ); + }, + )} +
+ ))} +
+ )} + )}
+ {/* Validation error */} + {error && ( +
+ +

{error}

+
+ )} + {/* Footer link */} {onOpenSettings && (
diff --git a/core/frontend/src/components/QueenProfilePanel.tsx b/core/frontend/src/components/QueenProfilePanel.tsx index ae712699..6bc4d01b 100644 --- a/core/frontend/src/components/QueenProfilePanel.tsx +++ b/core/frontend/src/components/QueenProfilePanel.tsx @@ -1,15 +1,9 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { NavLink, useLocation, useNavigate } from "react-router-dom"; -import { - X, - MessageSquare, - Crown, - ChevronRight, - Briefcase, - Award, -} from "lucide-react"; +import { X, MessageSquare, Crown, ChevronRight, Briefcase, Award, Pencil, Check, Loader2, Camera } from "lucide-react"; import { useColony } from "@/context/ColonyContext"; import { queensApi, type QueenProfile } from "@/api/queens"; +import { compressImage } from "@/lib/image-utils"; import type { Colony } from "@/types/colony"; interface QueenProfilePanelProps { @@ -18,46 +12,151 @@ interface QueenProfilePanelProps { onClose: () => void; } -export default function QueenProfilePanel({ - queenId, - colonies, - onClose, -}: QueenProfilePanelProps) { +function SectionHeader({ children, onEdit }: { children: React.ReactNode; onEdit?: () => void }) { + return ( +
+

{children}

+ {onEdit && ( + + )} +
+ ); +} + +export default function QueenProfilePanel({ queenId, colonies, onClose }: QueenProfilePanelProps) { const navigate = useNavigate(); const location = useLocation(); - const { queenProfiles } = useColony(); + const { queenProfiles, refresh } = useColony(); const summary = queenProfiles.find((q) => q.id === queenId); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + + // Avatar state + const [avatarUrl, setAvatarUrl] = useState(null); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const fileInputRef = useRef(null); + + // Edit form state + const [editName, setEditName] = useState(""); + const [editTitle, setEditTitle] = useState(""); + const [editSummary, setEditSummary] = useState(""); + const [editSkills, setEditSkills] = useState(""); + const [editAchievement, setEditAchievement] = useState(""); - // Hide the "Message {name}" button when we're already in this queen's PM. const alreadyInQueenPm = location.pathname === `/queen/${queenId}`; useEffect(() => { setLoading(true); setProfile(null); - queensApi - .getProfile(queenId) - .then(setProfile) - .catch(() => {}) - .finally(() => setLoading(false)); + setEditing(false); + // Set avatar URL with cache buster + setAvatarUrl(`/api/queen/${queenId}/avatar?t=${Date.now()}`); + queensApi.getProfile(queenId).then(setProfile).catch(() => {}).finally(() => setLoading(false)); }, [queenId]); + const startEditing = () => { + if (!profile) return; + setEditName(profile.name); + setEditTitle(profile.title); + setEditSummary(profile.summary || ""); + setEditSkills(profile.skills || ""); + setEditAchievement(profile.signature_achievement || ""); + setEditing(true); + }; + + const cancelEditing = () => setEditing(false); + + const handleSave = async () => { + setSaving(true); + try { + const updated = await queensApi.updateProfile(queenId, { + name: editName.trim(), + title: editTitle.trim(), + summary: editSummary.trim(), + skills: editSkills.trim(), + signature_achievement: editAchievement.trim(), + }); + setProfile(updated); + setEditing(false); + refresh(); + } catch (err) { + console.error("Failed to save profile:", err); + } finally { + setSaving(false); + } + }; + + const handleAvatarClick = () => fileInputRef.current?.click(); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + // Reset input so same file can be re-selected + e.target.value = ""; + + if (!file.type.startsWith("image/")) return; + + setUploadingAvatar(true); + try { + const compressed = await compressImage(file); + await queensApi.uploadAvatar(queenId, compressed); + setAvatarUrl(`/api/queen/${queenId}/avatar?t=${Date.now()}`); + } catch (err) { + console.error("Failed to upload avatar:", err); + } finally { + setUploadingAvatar(false); + } + }; + const name = profile?.name ?? summary?.name ?? "Queen"; const title = profile?.title ?? summary?.title ?? ""; + const inputCls = "w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"; + const textareaCls = `${inputCls} resize-none`; + + const avatarElement = ( +
+
+ {avatarUrl ? ( + {name} setAvatarUrl(null)} + /> + ) : ( + {name.charAt(0)} + )} +
+ + +
+ ); + return ( -