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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -12,12 +12,13 @@ export class ApiError extends Error {
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const headers: Record<string, string> = isFormData
|
||||
? {} // Let browser set Content-Type with boundary for multipart
|
||||
: { "Content-Type": "application/json", ...options.headers as Record<string, string> };
|
||||
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: <T>(path: string, formData: FormData) =>
|
||||
request<T>(path, { method: "POST", body: formData }),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<CustomPrompt>("/prompts", { title, category, content }),
|
||||
|
||||
delete: (promptId: string) =>
|
||||
api.delete<{ deleted: string }>(`/prompts/${promptId}`),
|
||||
};
|
||||
@@ -31,6 +31,13 @@ export const queensApi = {
|
||||
updateProfile: (queenId: string, updates: Partial<QueenProfile>) =>
|
||||
api.patch<QueenProfile>(`/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<QueenSessionResult>(`/queen/${queenId}/session`, {
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-7 h-7 rounded-full bg-primary/15 flex items-center justify-center hover:bg-primary/25 transition-colors overflow-hidden"
|
||||
title="Profile settings"
|
||||
>
|
||||
{hasAvatar ? (
|
||||
<img src={url} alt="" className="w-full h-full object-cover" onError={() => setHasAvatar(false)} />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold text-primary">{initials || "U"}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
<ModelSwitcher
|
||||
onOpenSettings={() => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setSettingsSection("byok");
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border border-transparent hover:border-border/40"
|
||||
>
|
||||
<span className="max-w-[120px] truncate">{modelLabel}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
<UserAvatarButton
|
||||
initials={initials}
|
||||
avatarVersion={userAvatarVersion}
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<span className="text-[10px] font-bold text-primary">
|
||||
{initials || "U"}
|
||||
</span>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -101,6 +101,8 @@ interface ChatPanelProps {
|
||||
contextUsage?: Record<string, ContextUsageEntry>;
|
||||
/** 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 (
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center`}
|
||||
style={{
|
||||
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center overflow-hidden`}
|
||||
style={isQueen && queenAvatarUrl ? undefined : {
|
||||
backgroundColor: `${color}18`,
|
||||
border: `1.5px solid ${color}35`,
|
||||
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
|
||||
}}
|
||||
>
|
||||
{isQueen ? (
|
||||
<Crown className="w-4 h-4" style={{ color }} />
|
||||
<QueenAvatarIcon url={queenAvatarUrl ?? null} size={9} />
|
||||
) : (
|
||||
<Cpu className="w-3.5 h-3.5" style={{ color }} />
|
||||
)}
|
||||
@@ -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 <img src={url} alt="" className={`${dim} rounded-xl object-cover`} onError={() => setOk(false)} />;
|
||||
}
|
||||
return <Crown className={size === 9 ? "w-4 h-4" : "w-3.5 h-3.5"} style={{ color: queenColor }} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center`}
|
||||
style={{
|
||||
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center overflow-hidden`}
|
||||
style={isQueen && queenAvatarUrl ? undefined : {
|
||||
backgroundColor: `${color}18`,
|
||||
border: `1.5px solid ${color}35`,
|
||||
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
|
||||
}}
|
||||
>
|
||||
{isQueen ? (
|
||||
<Crown className="w-4 h-4" style={{ color }} />
|
||||
<QueenAvatarIcon url={queenAvatarUrl ?? null} size={9} />
|
||||
) : (
|
||||
<Cpu className="w-3.5 h-3.5" style={{ color }} />
|
||||
)}
|
||||
@@ -621,6 +637,7 @@ export default function ChatPanel({
|
||||
contextUsage,
|
||||
supportsImages = true,
|
||||
initialDraft,
|
||||
queenId,
|
||||
}: ChatPanelProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
|
||||
@@ -631,6 +648,7 @@ export default function ChatPanel({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const lastAppliedDraftRef = useRef<string | null | undefined>(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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -902,6 +921,7 @@ export default function ChatPanel({
|
||||
msg={msg}
|
||||
queenPhase={queenPhase}
|
||||
showQueenPhaseBadge={showQueenPhaseBadge}
|
||||
queenAvatarUrl={queenAvatarUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -911,14 +931,14 @@ export default function ChatPanel({
|
||||
{(isWaiting || (disabled && threadMessages.length === 0)) && (
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className="flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
className="flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center overflow-hidden"
|
||||
style={queenAvatarUrl ? undefined : {
|
||||
backgroundColor: `${queenColor}18`,
|
||||
border: `1.5px solid ${queenColor}35`,
|
||||
boxShadow: `0 0 12px ${queenColor}20`,
|
||||
}}
|
||||
>
|
||||
<Crown className="w-4 h-4" style={{ color: queenColor }} />
|
||||
<QueenAvatarIcon url={queenAvatarUrl} size={9} />
|
||||
</div>
|
||||
<div className="border border-primary/20 bg-primary/5 rounded-2xl rounded-tl-md px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(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) {
|
||||
</span>
|
||||
);
|
||||
|
||||
const hasAnyProvider = apiKeyProviders.length > 0 || availableSubscriptions.length > 0 || activeSubInfo;
|
||||
const hasAnyProvider = apiKeyProviders.length > 0 || detectedSubs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={() => { setOpen(!open); setError(null); }}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border border-transparent hover:border-border/40"
|
||||
>
|
||||
<span className="max-w-[120px] truncate">{shortLabel}</span>
|
||||
@@ -106,92 +112,98 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) {
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1.5 w-[260px] bg-card border border-border/60 rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="max-h-[320px] overflow-y-auto">
|
||||
{/* Active subscription */}
|
||||
{activeSubInfo && (
|
||||
<div className="px-3 py-2 bg-purple-500/5 border-b border-border/40">
|
||||
<p className="text-[10px] font-semibold text-purple-400/80 uppercase tracking-wider mb-1">
|
||||
Active Subscription
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-3 h-3 text-purple-400" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{activeSubInfo.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available subscriptions */}
|
||||
{availableSubscriptions.length > 0 && (
|
||||
<div>
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-purple-400/80 uppercase tracking-wider">
|
||||
Available Subscriptions
|
||||
</p>
|
||||
{availableSubscriptions.map((sub) => (
|
||||
<button
|
||||
key={sub.id}
|
||||
onClick={() => handleSelectSubscription(sub.id)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors text-foreground hover:bg-muted/30"
|
||||
>
|
||||
<span className="w-3" />
|
||||
<span className="truncate">{sub.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API key provider models */}
|
||||
{!hasAnyProvider ? (
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground">
|
||||
No providers available. Add an API key or subscription.
|
||||
</p>
|
||||
) : (
|
||||
apiKeyProviders.length > 0 && (
|
||||
<div>
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
|
||||
API Key Providers
|
||||
</p>
|
||||
{apiKeyProviders.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
|
||||
{provider.name}
|
||||
</p>
|
||||
{(availableModels[provider.id] || []).map(
|
||||
(model: ModelOption) => {
|
||||
const isActive =
|
||||
currentProvider === provider.id &&
|
||||
currentModel === model.id &&
|
||||
!activeSubscription;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleSelectApiKey(provider.id, model.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<Check className="w-3 h-3 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{model.label.split(" - ")[0]}
|
||||
</span>
|
||||
{model.recommended && recommendedIcon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
<>
|
||||
{/* Subscriptions */}
|
||||
{detectedSubs.length > 0 && (
|
||||
<div>
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
|
||||
Subscriptions
|
||||
</p>
|
||||
{detectedSubs.map((sub) => {
|
||||
const isActive = activeSubscription === sub.id;
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
onClick={() => handleSelectSubscription(sub.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<Check className="w-3 h-3 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<span className="truncate">{sub.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Keys */}
|
||||
{apiKeyProviders.length > 0 && (
|
||||
<div>
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
|
||||
API Keys
|
||||
</p>
|
||||
{apiKeyProviders.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<p className="px-3 pt-2 pb-0.5 text-xs font-medium text-foreground">
|
||||
{provider.name}
|
||||
</p>
|
||||
{(availableModels[provider.id] || []).map(
|
||||
(model: ModelOption) => {
|
||||
const isActive =
|
||||
currentProvider === provider.id &&
|
||||
currentModel === model.id &&
|
||||
!activeSubscription;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleSelectApiKey(provider.id, model.id)}
|
||||
className={`w-full text-left pl-8 pr-3 py-1.5 text-xs flex items-center gap-2 transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<Check className="w-3 h-3 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{model.label.split(" - ")[0]}
|
||||
</span>
|
||||
{model.recommended && recommendedIcon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation error */}
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-destructive/10 border-t border-border/40 flex items-start gap-2">
|
||||
<AlertCircle className="w-3 h-3 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer link */}
|
||||
{onOpenSettings && (
|
||||
<div className="border-t border-border/40">
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{children}</h4>
|
||||
{onEdit && (
|
||||
<button onClick={onEdit} className="p-0.5 rounded text-muted-foreground/40 hover:text-foreground" title="Edit">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<QueenProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Avatar state
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 = (
|
||||
<div className="relative group">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/15 flex items-center justify-center overflow-hidden">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setAvatarUrl(null)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl font-bold text-primary">{name.charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAvatarClick}
|
||||
disabled={uploadingAvatar}
|
||||
className="absolute inset-0 w-16 h-16 rounded-full flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||
title="Change photo"
|
||||
>
|
||||
{uploadingAvatar ? (
|
||||
<Loader2 className="w-4 h-4 text-white animate-spin" />
|
||||
) : (
|
||||
<Camera className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarUpload} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="w-[340px] flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto">
|
||||
<aside className="w-[340px] flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto overscroll-contain">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Crown className="w-4 h-4 text-primary" />
|
||||
QUEEN PROFILE
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<button onClick={onClose} className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -67,70 +166,93 @@ export default function QueenProfilePanel({
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Avatar + name + title */}
|
||||
<div className="flex flex-col items-center text-center mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/15 flex items-center justify-center mb-3">
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{title}</p>
|
||||
) : editing ? (
|
||||
/* ── Edit Mode ──────────────────────────────────────────── */
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Avatar */}
|
||||
<div className="flex justify-center mb-1">
|
||||
{avatarElement}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5 block">Name</label>
|
||||
<input type="text" value={editName} onChange={(e) => setEditName(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5 block">Title</label>
|
||||
<input type="text" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5 block">About</label>
|
||||
<textarea value={editSummary} onChange={(e) => setEditSummary(e.target.value)} rows={10} className={textareaCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5 block">Skills (comma-separated)</label>
|
||||
<textarea value={editSkills} onChange={(e) => setEditSkills(e.target.value)} rows={3} className={textareaCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5 block">Signature Achievement</label>
|
||||
<textarea value={editAchievement} onChange={(e) => setEditAchievement(e.target.value)} rows={5} className={textareaCls} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving || !editName.trim() || !editTitle.trim()}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button onClick={cancelEditing} disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── View Mode ──────────────────────────────────────────── */
|
||||
<>
|
||||
{/* Avatar + name + title */}
|
||||
<div className="flex flex-col items-center text-center mb-6 group relative">
|
||||
<div className="mb-3">
|
||||
{avatarElement}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">{name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{title}</p>
|
||||
<button onClick={startEditing}
|
||||
className="absolute top-0 right-0 p-1 rounded text-muted-foreground/40 hover:text-foreground opacity-0 group-hover:opacity-100" title="Edit name & title">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message button — hidden when already in this queen's PM */}
|
||||
{!alreadyInQueenPm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/queen/${queenId}`);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg border border-border/60 py-2.5 text-sm font-medium text-foreground hover:bg-muted/40 transition-colors mb-6"
|
||||
>
|
||||
<button onClick={() => { navigate(`/queen/${queenId}`); onClose(); }}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg border border-border/60 py-2.5 text-sm font-medium text-foreground hover:bg-muted/40 mb-6">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Message {name}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* About */}
|
||||
{profile?.summary && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
About
|
||||
</h4>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">
|
||||
{profile.summary}
|
||||
</p>
|
||||
<SectionHeader onEdit={startEditing}>About</SectionHeader>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">{profile.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Experience */}
|
||||
{profile?.experience && profile.experience.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Experience
|
||||
</h4>
|
||||
<SectionHeader onEdit={startEditing}>Experience</SectionHeader>
|
||||
<div className="space-y-3">
|
||||
{profile.experience.map((exp, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<Briefcase className="w-3.5 h-3.5 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{exp.role}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">{exp.role}</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{exp.details.map((d, j) => (
|
||||
<li
|
||||
key={j}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{d}
|
||||
</li>
|
||||
))}
|
||||
{exp.details.map((d, j) => <li key={j} className="text-xs text-muted-foreground">{d}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,54 +261,34 @@ export default function QueenProfilePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{profile?.skills && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Skills
|
||||
</h4>
|
||||
<SectionHeader onEdit={startEditing}>Skills</SectionHeader>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{profile.skills.split(",").map((skill, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 rounded-full bg-muted/60 text-xs text-muted-foreground"
|
||||
>
|
||||
{skill.trim()}
|
||||
</span>
|
||||
<span key={i} className="px-2 py-0.5 rounded-full bg-muted/60 text-xs text-muted-foreground">{skill.trim()}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signature achievement */}
|
||||
{profile?.signature_achievement && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Signature Achievement
|
||||
</h4>
|
||||
<SectionHeader onEdit={startEditing}>Signature Achievement</SectionHeader>
|
||||
<div className="flex items-start gap-2">
|
||||
<Award className="w-3.5 h-3.5 text-primary mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-foreground/80">
|
||||
{profile.signature_achievement}
|
||||
</p>
|
||||
<p className="text-sm text-foreground/80">{profile.signature_achievement}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned colonies */}
|
||||
{colonies.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Assigned Colonies
|
||||
</h4>
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Assigned Colonies</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{colonies.map((colony) => (
|
||||
<NavLink
|
||||
key={colony.id}
|
||||
to={`/colony/${colony.id}`}
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2 text-sm text-primary hover:bg-primary/[0.08] transition-colors"
|
||||
>
|
||||
<NavLink key={colony.id} to={`/colony/${colony.id}`} onClick={onClose}
|
||||
className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2 text-sm text-primary hover:bg-primary/[0.08]">
|
||||
<span className="font-medium">#{colony.id}</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</NavLink>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { X, Eye, EyeOff, Check, Pencil, ChevronDown, Zap, ThumbsUp, Loader2, AlertCircle } from "lucide-react";
|
||||
import { X, Eye, EyeOff, Check, Pencil, ChevronDown, Zap, ThumbsUp, Loader2, AlertCircle, Camera } from "lucide-react";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useModel, LLM_PROVIDERS } from "@/context/ModelContext";
|
||||
import { credentialsApi } from "@/api/credentials";
|
||||
import type { ModelOption } from "@/api/config";
|
||||
import { configApi, type ModelOption } from "@/api/config";
|
||||
import { compressImage } from "@/lib/image-utils";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -12,58 +13,54 @@ interface SettingsModalProps {
|
||||
initialSection?: "profile" | "byok";
|
||||
}
|
||||
|
||||
function ValidationBadge({ state }: { state: "validating" | { valid: boolean | null; message: string } | undefined }) {
|
||||
if (!state) return <StatusText icon={<Check className="w-3 h-3" />} color="green">Connected</StatusText>;
|
||||
if (state === "validating") return <StatusText icon={<Loader2 className="w-3 h-3 animate-spin" />} color="muted">Verifying...</StatusText>;
|
||||
if (state.valid === false) return <StatusText icon={<AlertCircle className="w-3 h-3" />} color="red" title={state.message}>Invalid key</StatusText>;
|
||||
if (state.valid === true) return <StatusText icon={<Check className="w-3 h-3" />} color="green">Verified</StatusText>;
|
||||
return <StatusText icon={<Check className="w-3 h-3" />} color="green">Connected</StatusText>;
|
||||
}
|
||||
|
||||
function StatusText({ icon, color, title, children }: { icon: React.ReactNode; color: "green" | "red" | "muted"; title?: string; children: React.ReactNode }) {
|
||||
const cls = color === "green" ? "text-green-500" : color === "red" ? "text-red-400" : "text-muted-foreground";
|
||||
return <span className={`flex items-center gap-1 text-xs font-medium ${cls}`} title={title}>{icon}{children}</span>;
|
||||
}
|
||||
|
||||
export default function SettingsModal({ open, onClose, initialSection }: SettingsModalProps) {
|
||||
const { userProfile, setUserProfile } = useColony();
|
||||
const { userProfile, setUserProfile, userAvatarVersion, bumpUserAvatar } = useColony();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
connectedProviders,
|
||||
availableModels,
|
||||
setModel,
|
||||
saveProviderKey,
|
||||
subscriptions,
|
||||
detectedSubscriptions,
|
||||
activeSubscription,
|
||||
activateSubscription,
|
||||
currentProvider, currentModel, connectedProviders, availableModels,
|
||||
setModel, saveProviderKey, subscriptions, detectedSubscriptions,
|
||||
activeSubscription, activateSubscription,
|
||||
} = useModel();
|
||||
|
||||
const [displayName, setDisplayName] = useState(userProfile.displayName);
|
||||
const [about, setAbout] = useState(userProfile.about);
|
||||
const [activeSection, setActiveSection] = useState<"profile" | "byok">(
|
||||
initialSection || "profile",
|
||||
);
|
||||
|
||||
// Key entry state
|
||||
const [activeSection, setActiveSection] = useState<"profile" | "byok">(initialSection || "profile");
|
||||
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
||||
const [keyInput, setKeyInput] = useState("");
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Validation state per provider: "validating" | {valid, message}
|
||||
const [validation, setValidation] = useState<
|
||||
Record<string, "validating" | { valid: boolean | null; message: string }>
|
||||
>({});
|
||||
|
||||
// Model selection state
|
||||
const [validation, setValidation] = useState<Record<string, "validating" | { valid: boolean | null; message: string }>>({});
|
||||
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
|
||||
|
||||
// Theme dropdown state
|
||||
const [themeDropdownOpen, setThemeDropdownOpen] = useState(false);
|
||||
const avatarUrl = `/api/config/profile/avatar?v=${userAvatarVersion}`;
|
||||
const [avatarFailed, setAvatarFailed] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const themeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeDropdownOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (themeDropdownRef.current && !themeDropdownRef.current.contains(e.target as Node)) {
|
||||
if (themeDropdownRef.current && !themeDropdownRef.current.contains(e.target as Node))
|
||||
setThemeDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [themeDropdownOpen]);
|
||||
|
||||
// Sync form fields when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDisplayName(userProfile.displayName);
|
||||
@@ -79,51 +76,47 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
e.target.value = "";
|
||||
setUploadingAvatar(true);
|
||||
try {
|
||||
const compressed = await compressImage(file);
|
||||
await configApi.uploadAvatar(compressed);
|
||||
bumpUserAvatar();
|
||||
setAvatarFailed(false);
|
||||
} catch {}
|
||||
setUploadingAvatar(false);
|
||||
};
|
||||
|
||||
const clearValidation = (providerId: string) => {
|
||||
setTimeout(() => setValidation((v) => { const next = { ...v }; delete next[providerId]; return next; }), 4000);
|
||||
};
|
||||
|
||||
const handleSaveKey = async (providerId: string) => {
|
||||
const trimmedKey = keyInput.trim();
|
||||
if (!trimmedKey) return;
|
||||
setSaving(true);
|
||||
setValidation((v) => ({ ...v, [providerId]: "validating" }));
|
||||
|
||||
// Validate first — only persist the key if validation passes or is inconclusive.
|
||||
const validateResult = await credentialsApi
|
||||
.validateKey(providerId, trimmedKey)
|
||||
.catch(() => ({ valid: null as boolean | null, message: "Could not verify key" }));
|
||||
|
||||
if (validateResult.valid === false) {
|
||||
// Key is definitively invalid — don't save it.
|
||||
setSaving(false);
|
||||
setValidation((v) => ({
|
||||
...v,
|
||||
[providerId]: { valid: false, message: validateResult.message },
|
||||
}));
|
||||
setTimeout(() => {
|
||||
setValidation((v) => {
|
||||
const next = { ...v };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
}, 4000);
|
||||
setValidation((v) => ({ ...v, [providerId]: { valid: false, message: validateResult.message } }));
|
||||
clearValidation(providerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation passed or was inconclusive — save the key.
|
||||
try {
|
||||
await saveProviderKey(providerId, trimmedKey);
|
||||
} catch (err) {
|
||||
console.error("Failed to save key:", err);
|
||||
} catch {
|
||||
setSaving(false);
|
||||
setValidation((v) => ({
|
||||
...v,
|
||||
[providerId]: { valid: false, message: "Failed to save key" },
|
||||
}));
|
||||
setTimeout(() => {
|
||||
setValidation((v) => {
|
||||
const next = { ...v };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
}, 4000);
|
||||
setValidation((v) => ({ ...v, [providerId]: { valid: false, message: "Failed to save key" } }));
|
||||
clearValidation(providerId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,128 +124,66 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
setEditingProvider(null);
|
||||
setKeyInput("");
|
||||
setShowKey(false);
|
||||
|
||||
setValidation((v) => ({
|
||||
...v,
|
||||
[providerId]: { valid: validateResult.valid, message: validateResult.message },
|
||||
}));
|
||||
|
||||
// Auto-clear validation result after 4s
|
||||
setTimeout(() => {
|
||||
setValidation((v) => {
|
||||
const next = { ...v };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
}, 4000);
|
||||
setValidation((v) => ({ ...v, [providerId]: { valid: validateResult.valid, message: validateResult.message } }));
|
||||
clearValidation(providerId);
|
||||
};
|
||||
|
||||
const handleSelectModel = async (provider: string, modelId: string) => {
|
||||
try {
|
||||
await setModel(provider, modelId);
|
||||
setModelDropdownOpen(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to set model:", err);
|
||||
}
|
||||
try { await setModel(provider, modelId); setModelDropdownOpen(false); } catch {}
|
||||
};
|
||||
|
||||
// Initials for avatar
|
||||
const initials = displayName
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
const handleActivateSubscription = async (subId: string) => {
|
||||
try { await activateSubscription(subId); } catch {}
|
||||
};
|
||||
|
||||
// Get human-readable model label
|
||||
const currentModelLabel = (() => {
|
||||
// Check subscription provider's models too
|
||||
const sub = activeSubscription
|
||||
? subscriptions.find((s) => s.id === activeSubscription)
|
||||
: null;
|
||||
const providerForModels = sub?.provider || currentProvider;
|
||||
const models = availableModels[providerForModels] || [];
|
||||
const m = models.find((m) => m.id === currentModel);
|
||||
return m?.label || currentModel || "Not configured";
|
||||
})();
|
||||
const initials = displayName.trim().split(/\s+/).map((w) => w[0]).join("").toUpperCase().slice(0, 2);
|
||||
|
||||
const currentProviderName = (() => {
|
||||
if (activeSubscription) {
|
||||
const sub = subscriptions.find((s) => s.id === activeSubscription);
|
||||
return sub?.name || currentProvider;
|
||||
}
|
||||
return LLM_PROVIDERS.find((p) => p.id === currentProvider)?.name || currentProvider;
|
||||
})();
|
||||
const activeSubInfo = activeSubscription ? subscriptions.find((s) => s.id === activeSubscription) : null;
|
||||
const providerForModels = activeSubInfo?.provider || currentProvider;
|
||||
const modelsForLabel = availableModels[providerForModels] || [];
|
||||
const currentModelLabel = modelsForLabel.find((m) => m.id === currentModel)?.label || currentModel || "Not configured";
|
||||
|
||||
const recommendedIcon = (
|
||||
<span
|
||||
className="group/recommend ml-auto relative inline-flex items-center justify-center rounded bg-primary/10 text-primary p-1 flex-shrink-0"
|
||||
aria-label="Recommended model"
|
||||
>
|
||||
<ThumbsUp className="w-3 h-3" />
|
||||
<span className="pointer-events-none absolute right-full mr-2 top-1/2 -translate-y-1/2 whitespace-nowrap rounded border border-border/60 bg-card px-2 py-1 text-[10px] font-medium text-foreground opacity-0 invisible group-hover/recommend:opacity-100 group-hover/recommend:visible transition-none shadow-sm">
|
||||
Recommended model
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
const currentProviderName = activeSubscription
|
||||
? (subscriptions.find((s) => s.id === activeSubscription)?.name || currentProvider)
|
||||
: (LLM_PROVIDERS.find((p) => p.id === currentProvider)?.name || currentProvider);
|
||||
|
||||
// Models available for selection (only API key providers - subscriptions use fixed models)
|
||||
const selectableProviders = LLM_PROVIDERS.filter(
|
||||
(p) => connectedProviders.has(p.id) && availableModels[p.id]?.length,
|
||||
);
|
||||
|
||||
const handleActivateSubscription = async (subId: string) => {
|
||||
try {
|
||||
await activateSubscription(subId);
|
||||
} catch (err) {
|
||||
console.error("Failed to activate subscription:", err);
|
||||
}
|
||||
const startEditing = (providerId: string) => {
|
||||
setEditingProvider(providerId);
|
||||
setKeyInput("");
|
||||
setShowKey(false);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingProvider(null);
|
||||
setKeyInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-card border border-border/60 rounded-2xl shadow-2xl w-full max-w-[720px] h-[520px] max-h-[80vh] flex overflow-hidden">
|
||||
{/* Sidebar nav */}
|
||||
{/* Sidebar */}
|
||||
<div className="w-[180px] flex-shrink-0 border-r border-border/40 py-6 px-3 flex flex-col gap-6">
|
||||
<h2 className="text-sm font-semibold text-foreground px-3">
|
||||
SETTINGS
|
||||
</h2>
|
||||
|
||||
<h2 className="text-sm font-semibold text-foreground px-3">SETTINGS</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider px-3 mb-1">
|
||||
Account
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider px-3 mb-1">Account</p>
|
||||
<button
|
||||
onClick={() => setActiveSection("profile")}
|
||||
className={`text-left text-sm px-3 py-1.5 rounded-md transition-colors ${
|
||||
activeSection === "profile"
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
className={`text-left text-sm px-3 py-1.5 rounded-md ${activeSection === "profile" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground hover:bg-muted/30"}`}
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider px-3 mb-1">
|
||||
System
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider px-3 mb-1">System</p>
|
||||
<button
|
||||
onClick={() => setActiveSection("byok")}
|
||||
className={`text-left text-sm px-3 py-1.5 rounded-md transition-colors ${
|
||||
activeSection === "byok"
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
className={`text-left text-sm px-3 py-1.5 rounded-md ${activeSection === "byok" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground hover:bg-muted/30"}`}
|
||||
>
|
||||
BYOK
|
||||
</button>
|
||||
@@ -261,89 +192,68 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<button onClick={onClose} className="absolute top-4 right-4 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide px-8 py-6 flex flex-col gap-6">
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-8 py-6 flex flex-col gap-6">
|
||||
{activeSection === "profile" && (
|
||||
<>
|
||||
{/* Display name */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
Display <span className="text-primary">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/15 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-bold text-primary">
|
||||
{initials || "?"}
|
||||
</span>
|
||||
<div className="relative group flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/15 flex items-center justify-center overflow-hidden">
|
||||
{!avatarFailed ? (
|
||||
<img src={avatarUrl} alt="" className="w-full h-full object-cover" onError={() => setAvatarFailed(true)} />
|
||||
) : (
|
||||
<span className="text-xs font-bold text-primary">{initials || "?"}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={uploadingAvatar}
|
||||
className="absolute inset-0 w-10 h-10 rounded-full flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||
title="Change photo"
|
||||
>
|
||||
{uploadingAvatar ? <Loader2 className="w-3.5 h-3.5 text-white animate-spin" /> : <Camera className="w-3.5 h-3.5 text-white" />}
|
||||
</button>
|
||||
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarUpload} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Display name"
|
||||
className="flex-1 bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
About
|
||||
</label>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">About</label>
|
||||
<textarea
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
placeholder="Tell people about yourself or your organization"
|
||||
rows={4}
|
||||
value={about} onChange={(e) => setAbout(e.target.value)}
|
||||
placeholder="Tell people about yourself or your organization" rows={4}
|
||||
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Theme
|
||||
</label>
|
||||
<label className="text-sm font-medium text-foreground">Theme</label>
|
||||
<div className="relative" ref={themeDropdownRef}>
|
||||
<button
|
||||
onClick={() => setThemeDropdownOpen(!themeDropdownOpen)}
|
||||
className="flex items-center gap-2 bg-muted/30 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<button onClick={() => setThemeDropdownOpen(!themeDropdownOpen)}
|
||||
className="flex items-center gap-2 bg-muted/30 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground hover:bg-muted/40">
|
||||
{theme === "light" ? "Light" : "Dark"}
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${
|
||||
themeDropdownOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-muted-foreground ${themeDropdownOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{themeDropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-card border border-border/60 rounded-lg shadow-xl z-10 min-w-[120px]">
|
||||
{(["light", "dark"] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => {
|
||||
setTheme(option);
|
||||
setThemeDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 transition-colors first:rounded-t-lg last:rounded-b-lg ${
|
||||
theme === option
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{theme === option && <Check className="w-3 h-3 flex-shrink-0" />}
|
||||
<span className={theme === option ? "" : "ml-5"}>
|
||||
{option === "light" ? "Light" : "Dark"}
|
||||
</span>
|
||||
<button key={option} onClick={() => { setTheme(option); setThemeDropdownOpen(false); }}
|
||||
className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 first:rounded-t-lg last:rounded-b-lg ${theme === option ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted/30"}`}>
|
||||
{theme === option ? <Check className="w-3 h-3 flex-shrink-0" /> : <span className="w-3" />}
|
||||
<span>{option === "light" ? "Light" : "Dark"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -351,79 +261,88 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end mt-auto pt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-5 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button onClick={handleSave} className="px-5 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90">Save</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeSection === "byok" && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Bring Your Own Key
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground">Bring Your Own Key</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Use your own API keys for hosted model providers. Your keys
|
||||
are encrypted and never shared.
|
||||
Use your own API keys for hosted model providers. Your keys are encrypted and never shared.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Active Model */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">Active Model</p>
|
||||
<div className="relative">
|
||||
<button onClick={() => setModelDropdownOpen(!modelDropdownOpen)}
|
||||
className="w-full flex items-center justify-between bg-muted/30 border border-border/50 rounded-lg px-4 py-3 text-left hover:bg-muted/40">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{currentModelLabel}</p>
|
||||
<p className="text-xs text-muted-foreground">{currentProviderName}</p>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-muted-foreground ${modelDropdownOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{modelDropdownOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border/60 rounded-lg shadow-xl z-10 max-h-[280px] overflow-y-auto overscroll-contain">
|
||||
{selectableProviders.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">Add an API key or enable a subscription to see available models.</p>
|
||||
) : selectableProviders.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<p className="px-4 pt-3 pb-0.5 text-sm font-medium text-foreground">{provider.name}</p>
|
||||
{(availableModels[provider.id] || []).map((model: ModelOption) => {
|
||||
const isActive = currentProvider === provider.id && currentModel === model.id && !activeSubscription;
|
||||
return (
|
||||
<button key={model.id} onClick={() => handleSelectModel(provider.id, model.id)}
|
||||
className={`w-full text-left pl-8 pr-4 py-2 text-sm flex items-center gap-2 ${isActive ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted/30"}`}>
|
||||
{isActive ? <Check className="w-3 h-3 flex-shrink-0" /> : <span className="w-3" />}
|
||||
<span>{model.label}</span>
|
||||
{model.recommended && (
|
||||
<span className="ml-auto inline-flex items-center justify-center rounded bg-primary/10 text-primary p-1 flex-shrink-0" title="Recommended">
|
||||
<ThumbsUp className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscriptions */}
|
||||
{subscriptions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">
|
||||
Subscriptions
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">Subscriptions</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{subscriptions.map((sub) => {
|
||||
const isDetected = detectedSubscriptions.has(sub.id);
|
||||
const isActive = activeSubscription === sub.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex items-center gap-3 py-2.5 px-2 rounded-lg hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-9 h-9 rounded-full bg-purple-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-4 h-4 text-purple-400" />
|
||||
<div key={sub.id} className="flex items-center gap-3 py-2.5 px-2 rounded-lg hover:bg-muted/20">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{sub.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{sub.description}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">{sub.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{sub.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Status / Action */}
|
||||
{isActive ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500 font-medium">
|
||||
<Check className="w-3 h-3" />
|
||||
Active
|
||||
</span>
|
||||
<StatusText icon={<Check className="w-3 h-3" />} color="green">Active</StatusText>
|
||||
) : isDetected ? (
|
||||
<button
|
||||
onClick={() => handleActivateSubscription(sub.id)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-purple-500/15 text-purple-400 border border-purple-500/30 hover:bg-purple-500/25 transition-colors"
|
||||
>
|
||||
<button onClick={() => handleActivateSubscription(sub.id)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25">
|
||||
Enable
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
Not detected
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/50">Not detected</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -432,147 +351,65 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM Providers */}
|
||||
{/* API Keys */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">
|
||||
API Key Providers
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">API Keys</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{LLM_PROVIDERS.map((provider) => {
|
||||
const isConnected = connectedProviders.has(provider.id);
|
||||
const isEditing = editingProvider === provider.id;
|
||||
const providerValidation = validation[provider.id];
|
||||
|
||||
return (
|
||||
<div key={provider.id}>
|
||||
<div className="flex items-center gap-3 py-2.5 px-2 rounded-lg hover:bg-muted/20 transition-colors">
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-3 py-2.5 px-2 rounded-lg hover:bg-muted/20">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{provider.initial}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-primary">{provider.initial}</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{provider.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{provider.description}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">{provider.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{provider.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
{isConnected && !isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{providerValidation === "validating" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground font-medium">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Verifying...
|
||||
</span>
|
||||
) : providerValidation && typeof providerValidation === "object" && providerValidation.valid === false ? (
|
||||
<span className="flex items-center gap-1 text-xs text-red-400 font-medium" title={providerValidation.message}>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Invalid key
|
||||
</span>
|
||||
) : providerValidation && typeof providerValidation === "object" && providerValidation.valid === true ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500 font-medium">
|
||||
<Check className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500 font-medium">
|
||||
<Check className="w-3 h-3" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingProvider(provider.id);
|
||||
setKeyInput("");
|
||||
setShowKey(false);
|
||||
}}
|
||||
className="p-1 rounded text-muted-foreground/40 hover:text-foreground transition-colors"
|
||||
title="Change key"
|
||||
>
|
||||
<ValidationBadge state={validation[provider.id]} />
|
||||
<button onClick={() => startEditing(provider.id)} className="p-1 rounded text-muted-foreground/40 hover:text-foreground" title="Change key">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : !isEditing ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingProvider(provider.id);
|
||||
setKeyInput("");
|
||||
setShowKey(false);
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<button onClick={() => startEditing(provider.id)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
Add Key
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Inline key entry */}
|
||||
{isEditing && (
|
||||
<div className="ml-12 mr-2 mb-2 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={keyInput}
|
||||
type={showKey ? "text" : "password"} value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
placeholder={`Enter ${provider.name} API key`}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveKey(provider.id);
|
||||
if (e.key === "Escape") {
|
||||
setEditingProvider(null);
|
||||
setKeyInput("");
|
||||
}
|
||||
}}
|
||||
placeholder={`Enter ${provider.name} API key`} autoFocus
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSaveKey(provider.id); if (e.key === "Escape") cancelEditing(); }}
|
||||
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 pr-9 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<button onClick={() => setShowKey(!showKey)} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-foreground">
|
||||
{showKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSaveKey(provider.id)}
|
||||
disabled={!keyInput.trim() || saving}
|
||||
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<button onClick={() => handleSaveKey(provider.id)} disabled={!keyInput.trim() || saving}
|
||||
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{saving ? "..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingProvider(null);
|
||||
setKeyInput("");
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={cancelEditing} className="px-3 py-2 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30">Cancel</button>
|
||||
</div>
|
||||
{/* Validation feedback inside editing mode */}
|
||||
{providerValidation === "validating" && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground font-medium">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Verifying...
|
||||
</span>
|
||||
{validation[provider.id] === "validating" && (
|
||||
<StatusText icon={<Loader2 className="w-3 h-3 animate-spin" />} color="muted">Verifying...</StatusText>
|
||||
)}
|
||||
{providerValidation && typeof providerValidation === "object" && providerValidation.valid === false && (
|
||||
<span className="flex items-center gap-1 text-xs text-red-400 font-medium">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{providerValidation.message}
|
||||
</span>
|
||||
{validation[provider.id] && typeof validation[provider.id] === "object" && (validation[provider.id] as { valid: boolean | null; message: string }).valid === false && (
|
||||
<StatusText icon={<AlertCircle className="w-3 h-3" />} color="red">
|
||||
{(validation[provider.id] as { message: string }).message}
|
||||
</StatusText>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -581,83 +418,6 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Model */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">
|
||||
Active Model
|
||||
</p>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setModelDropdownOpen(!modelDropdownOpen)}
|
||||
className="w-full flex items-center justify-between bg-muted/30 border border-border/50 rounded-lg px-4 py-3 text-left hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{currentModelLabel}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{currentProviderName}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted-foreground transition-transform ${
|
||||
modelDropdownOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{modelDropdownOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border/60 rounded-lg shadow-xl z-10 max-h-[280px] overflow-y-auto">
|
||||
{selectableProviders.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">
|
||||
Add an API key or enable a subscription to see available models.
|
||||
</p>
|
||||
) : (
|
||||
selectableProviders.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<p className="px-4 pt-3 pb-1 text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
|
||||
{provider.name}
|
||||
</p>
|
||||
{(availableModels[provider.id] || []).map(
|
||||
(model: ModelOption) => {
|
||||
const isActive =
|
||||
currentProvider === provider.id &&
|
||||
currentModel === model.id &&
|
||||
!activeSubscription;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() =>
|
||||
handleSelectModel(provider.id, model.id)
|
||||
}
|
||||
className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<Check className="w-3 h-3 flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={isActive ? "" : "ml-5"}
|
||||
>
|
||||
{model.label}
|
||||
</span>
|
||||
{model.recommended && recommendedIcon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import type { QueenProfileSummary } from "@/types/colony";
|
||||
|
||||
@@ -6,6 +7,9 @@ interface SidebarQueenItemProps {
|
||||
}
|
||||
|
||||
export default function SidebarQueenItem({ queen }: SidebarQueenItemProps) {
|
||||
const [hasAvatar, setHasAvatar] = useState(true);
|
||||
const avatarUrl = `/api/queen/${queen.id}/avatar`;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={`/queen/${queen.id}`}
|
||||
@@ -17,8 +21,12 @@ export default function SidebarQueenItem({ queen }: SidebarQueenItemProps) {
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/15 flex items-center justify-center text-[10px] font-bold text-primary">
|
||||
{queen.name.charAt(0)}
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/15 flex items-center justify-center overflow-hidden">
|
||||
{hasAvatar ? (
|
||||
<img src={avatarUrl} alt={queen.name} className="w-full h-full object-cover" onError={() => setHasAvatar(false)} />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold text-primary">{queen.name.charAt(0)}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
<span className="font-medium truncate">{queen.name}</span>
|
||||
|
||||
@@ -61,6 +61,9 @@ interface ColonyContextValue {
|
||||
deleteColony: (colonyId: string) => Promise<void>;
|
||||
/** Refresh colony data from the server */
|
||||
refresh: () => void;
|
||||
/** Cache-busting version for user avatar — bump after upload */
|
||||
userAvatarVersion: number;
|
||||
bumpUserAvatar: () => void;
|
||||
}
|
||||
|
||||
const ColonyContext = createContext<ColonyContextValue | null>(null);
|
||||
@@ -88,6 +91,9 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
|
||||
loadJson(LAST_VISIT_KEY, {}),
|
||||
);
|
||||
|
||||
const [userAvatarVersion, setUserAvatarVersion] = useState(0);
|
||||
const bumpUserAvatar = useCallback(() => setUserAvatarVersion((v) => v + 1), []);
|
||||
|
||||
const coloniesRef = useRef<Colony[]>(colonies);
|
||||
useEffect(() => {
|
||||
coloniesRef.current = colonies;
|
||||
@@ -264,9 +270,14 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
|
||||
// Optimistically remove from UI
|
||||
setColonies((prev) => prev.filter((c) => c.id !== colonyId));
|
||||
setQueens((prev) => prev.filter((q) => q.colonyId !== colonyId));
|
||||
// Delete on backend (fire-and-forget)
|
||||
agentsApi.deleteAgent(colony.agentPath).catch(() => {});
|
||||
}, []);
|
||||
// Delete on backend, then re-fetch to confirm it's gone
|
||||
try {
|
||||
await agentsApi.deleteAgent(colony.agentPath);
|
||||
} catch {
|
||||
// Deletion failed — re-fetch to restore the colony in the UI
|
||||
}
|
||||
fetchColonies();
|
||||
}, [fetchColonies]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
fetchColonies();
|
||||
@@ -312,6 +323,8 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
|
||||
markVisited,
|
||||
deleteColony,
|
||||
refresh,
|
||||
userAvatarVersion,
|
||||
bumpUserAvatar,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const MAX_IMAGE_SIZE = 512;
|
||||
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** Compress an image file using canvas. Returns a JPEG blob under 2 MB. */
|
||||
export async function compressImage(file: File): Promise<File> {
|
||||
if (file.size <= MAX_FILE_BYTES && (file.type === "image/jpeg" || file.type === "image/webp")) {
|
||||
return file;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > MAX_IMAGE_SIZE || height > MAX_IMAGE_SIZE) {
|
||||
const scale = MAX_IMAGE_SIZE / Math.max(width, height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return reject(new Error("Compression failed"));
|
||||
resolve(new File([blob], file.name.replace(/\.\w+$/, ".jpg"), { type: "image/jpeg" }));
|
||||
},
|
||||
"image/jpeg",
|
||||
0.85,
|
||||
);
|
||||
};
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { User } from "lucide-react";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
@@ -6,6 +6,25 @@ import type { QueenProfileSummary, Colony } from "@/types/colony";
|
||||
import { getColonyIcon } from "@/lib/colony-registry";
|
||||
import QueenProfilePanel from "@/components/QueenProfilePanel";
|
||||
|
||||
/* ── User avatar (CEO card) ──────────────────────────────────────────── */
|
||||
|
||||
function UserAvatar({ initials, avatarVersion }: { initials: string; avatarVersion: number }) {
|
||||
const [hasAvatar, setHasAvatar] = useState(true);
|
||||
const url = `/api/config/profile/avatar?v=${avatarVersion}`;
|
||||
useEffect(() => setHasAvatar(true), [avatarVersion]);
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-full bg-primary/15 mx-auto mb-3 flex items-center justify-center overflow-hidden">
|
||||
{hasAvatar ? (
|
||||
<img src={url} alt="" className="w-full h-full object-cover" onError={() => setHasAvatar(false)} />
|
||||
) : initials ? (
|
||||
<span className="text-sm font-bold text-primary">{initials}</span>
|
||||
) : (
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Colony tag (clickable link to colony chat) ───────────────────────── */
|
||||
|
||||
function ColonyTag({ colony }: { colony: Colony }) {
|
||||
@@ -23,6 +42,20 @@ function ColonyTag({ colony }: { colony: Colony }) {
|
||||
|
||||
/* ── Queen card in the org grid ───────────────────────────────────────── */
|
||||
|
||||
function QueenAvatar({ queenId, name, size = "w-11 h-11" }: { queenId: string; name: string; size?: string }) {
|
||||
const [hasAvatar, setHasAvatar] = useState(true);
|
||||
const url = `/api/queen/${queenId}/avatar`;
|
||||
return (
|
||||
<div className={`${size} rounded-full bg-primary/15 flex items-center justify-center overflow-hidden`}>
|
||||
{hasAvatar ? (
|
||||
<img src={url} alt={name} className="w-full h-full object-cover" onError={() => setHasAvatar(false)} />
|
||||
) : (
|
||||
<span className="text-sm font-bold text-primary">{name.charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueenCard({
|
||||
queen,
|
||||
colonies,
|
||||
@@ -48,10 +81,8 @@ function QueenCard({
|
||||
: "border-border/60 hover:border-primary/30 hover:bg-primary/[0.03]"
|
||||
}`}
|
||||
>
|
||||
<div className="w-11 h-11 rounded-full bg-primary/15 flex items-center justify-center mb-2.5">
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{queen.name.charAt(0)}
|
||||
</span>
|
||||
<div className="mb-2.5">
|
||||
<QueenAvatar queenId={queen.id} name={queen.name} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{queen.name}
|
||||
@@ -79,7 +110,7 @@ function QueenCard({
|
||||
/* ── Main org chart page ──────────────────────────────────────────────── */
|
||||
|
||||
export default function OrgChart() {
|
||||
const { queenProfiles, colonies, userProfile } = useColony();
|
||||
const { queenProfiles, colonies, userProfile, userAvatarVersion } = useColony();
|
||||
const [selectedQueenId, setSelectedQueenId] = useState<string | null>(null);
|
||||
|
||||
// Pan & zoom state
|
||||
@@ -172,15 +203,7 @@ export default function OrgChart() {
|
||||
<div className="min-w-max px-6 pt-16 pb-10 mx-auto flex flex-col items-center">
|
||||
{/* CEO card */}
|
||||
<div className="rounded-xl border border-border/60 bg-card px-8 py-5 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/15 mx-auto mb-3 flex items-center justify-center">
|
||||
{initials ? (
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{initials}
|
||||
</span>
|
||||
) : (
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<UserAvatar initials={initials} avatarVersion={userAvatarVersion} />
|
||||
<div className="font-semibold text-sm text-foreground">
|
||||
{userProfile.displayName || "You"}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Search, Copy, Check, Sparkles, MessageSquarePlus } from "lucide-react";
|
||||
import { prompts, promptCategories, categoryToQueen, queenNames } from "@/data/prompts";
|
||||
import { Search, Copy, Check, Sparkles, MessageSquarePlus, Plus, X, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { prompts, promptCategories, categoryToQueen, queenNames, type Prompt } from "@/data/prompts";
|
||||
import { promptsApi, type CustomPrompt } from "@/api/prompts";
|
||||
|
||||
function PromptCard({ prompt, onUse }: { prompt: typeof prompts[0]; onUse: (content: string, category: string) => void }) {
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
function PromptCard({
|
||||
prompt,
|
||||
onUse,
|
||||
onDelete,
|
||||
}: {
|
||||
prompt: Prompt | CustomPrompt;
|
||||
onUse: (content: string, category: string) => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const queenId = categoryToQueen[prompt.category];
|
||||
const queenName = queenNames[queenId] || "Queen";
|
||||
const isCustom = "custom" in prompt && prompt.custom;
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(prompt.content);
|
||||
@@ -15,27 +27,29 @@ function PromptCard({ prompt, onUse }: { prompt: typeof prompts[0]; onUse: (cont
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group rounded-lg border border-border/60 bg-card p-4 hover:border-primary/30 hover:shadow-sm transition-all">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<h3 className="text-sm font-medium text-foreground line-clamp-1">
|
||||
{prompt.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
title="Copy prompt"
|
||||
>
|
||||
<div className="group rounded-lg border border-border/60 bg-card p-4 hover:border-primary/30 hover:shadow-sm transition-all flex flex-col">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-foreground line-clamp-1">{prompt.title}</h3>
|
||||
{isCustom && (
|
||||
<span className="flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 text-primary">My Prompt</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100">
|
||||
<button onClick={handleCopy} className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60" title="Copy prompt">
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isCustom && onDelete && (
|
||||
<button onClick={onDelete} className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10" title="Delete prompt">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed mb-3">
|
||||
{prompt.content}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed mb-3 flex-1">{prompt.content}</p>
|
||||
<button
|
||||
onClick={() => onUse(prompt.content, prompt.category)}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.04] py-1.5 text-xs font-medium text-primary hover:bg-primary/[0.08] transition-colors"
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.04] py-1.5 text-xs font-medium text-primary hover:bg-primary/[0.08]"
|
||||
>
|
||||
<MessageSquarePlus className="w-3.5 h-3.5" />
|
||||
Ask {queenName}
|
||||
@@ -44,61 +58,158 @@ function PromptCard({ prompt, onUse }: { prompt: typeof prompts[0]; onUse: (cont
|
||||
);
|
||||
}
|
||||
|
||||
function AddPromptModal({ open, onClose, onSave }: { open: boolean; onClose: () => void; onSave: (title: string, category: string, content: string) => Promise<void> }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || !content.trim()) return;
|
||||
setSaving(true);
|
||||
await onSave(title.trim(), category.trim(), content.trim());
|
||||
setSaving(false);
|
||||
setTitle("");
|
||||
setCategory("");
|
||||
setContent("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative bg-card border border-border/60 rounded-2xl shadow-2xl w-full max-w-[520px] p-6">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-lg font-semibold text-foreground">Add Custom Prompt</h3>
|
||||
<button onClick={onClose} className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-1.5 block">Title <span className="text-primary">*</span></label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Weekly Report Generator"
|
||||
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-1.5 block">Category</label>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}
|
||||
className="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">
|
||||
<option value="">Custom</option>
|
||||
{promptCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-1.5 block">Prompt Content <span className="text-primary">*</span></label>
|
||||
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={8}
|
||||
placeholder="Enter your prompt..."
|
||||
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40 resize-none" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30">Cancel</button>
|
||||
<button onClick={handleSubmit} disabled={saving || !title.trim() || !content.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{saving ? "Saving..." : "Add Prompt"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PromptLibrary() {
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const inactiveCategoryClass =
|
||||
"bg-muted/60 text-foreground/75 hover:bg-muted/80 hover:text-foreground";
|
||||
const [page, setPage] = useState(0);
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [customPrompts, setCustomPrompts] = useState<CustomPrompt[]>([]);
|
||||
|
||||
const inactiveCategoryClass = "bg-muted/60 text-foreground/75 hover:bg-muted/80 hover:text-foreground";
|
||||
|
||||
useEffect(() => {
|
||||
promptsApi.list().then((r) => setCustomPrompts(r.prompts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Merge built-in + custom prompts
|
||||
const allPrompts = useMemo(() => [...customPrompts, ...prompts], [customPrompts]);
|
||||
|
||||
const filteredPrompts = useMemo(() => {
|
||||
let result = prompts;
|
||||
|
||||
if (selectedCategory) {
|
||||
let result = allPrompts;
|
||||
if (selectedCategory === "custom") {
|
||||
result = result.filter((p) => "custom" in p && p.custom);
|
||||
} else if (selectedCategory) {
|
||||
result = result.filter((p) => p.category === selectedCategory);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.title.toLowerCase().includes(query) ||
|
||||
p.content.toLowerCase().includes(query)
|
||||
(p) => p.title.toLowerCase().includes(query) || p.content.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [searchQuery, selectedCategory]);
|
||||
}, [allPrompts, searchQuery, selectedCategory]);
|
||||
|
||||
// Reset page when filters change
|
||||
useEffect(() => setPage(0), [searchQuery, selectedCategory]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredPrompts.length / PAGE_SIZE));
|
||||
const pagedPrompts = filteredPrompts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
const handleUsePrompt = (content: string, category: string) => {
|
||||
const queenId = categoryToQueen[category];
|
||||
if (!queenId) return;
|
||||
sessionStorage.setItem(`queenFirstMessage:${queenId}`, content);
|
||||
navigate(`/queen/${queenId}?new=1`);
|
||||
};
|
||||
|
||||
const handleAddPrompt = useCallback(async (title: string, category: string, content: string) => {
|
||||
const created = await promptsApi.create(title, category, content);
|
||||
setCustomPrompts((prev) => [created, ...prev]);
|
||||
}, []);
|
||||
|
||||
const handleDeletePrompt = useCallback(async (id: string) => {
|
||||
await promptsApi.delete(id);
|
||||
setCustomPrompts((prev) => prev.filter((p) => p.id !== id));
|
||||
}, []);
|
||||
|
||||
const customCount = customPrompts.length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border/60">
|
||||
<div className="flex items-baseline gap-3 mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
Prompt Library
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{prompts.length} prompts across {promptCategories.length} categories
|
||||
</span>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
Prompt Library
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{allPrompts.length} prompts across {promptCategories.length + (customCount > 0 ? 1 : 0)} categories
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => setAddModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Prompt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search prompts by title or content..."
|
||||
value={searchQuery}
|
||||
type="text" placeholder="Search prompts by title or content..." value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:border-primary/40 focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
@@ -108,28 +219,20 @@ export default function PromptLibrary() {
|
||||
{/* Category filter */}
|
||||
<div className="px-6 py-3 border-b border-border/60 bg-muted/20">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
selectedCategory === null
|
||||
? "bg-primary text-primary-foreground"
|
||||
: inactiveCategoryClass
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => setSelectedCategory(null)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium ${selectedCategory === null ? "bg-primary text-primary-foreground" : inactiveCategoryClass}`}>
|
||||
All Categories
|
||||
</button>
|
||||
{customCount > 0 && (
|
||||
<button onClick={() => setSelectedCategory("custom")}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium ${selectedCategory === "custom" ? "bg-primary text-primary-foreground" : inactiveCategoryClass}`}>
|
||||
My Prompts <span className="ml-1.5 opacity-60">({customCount})</span>
|
||||
</button>
|
||||
)}
|
||||
{promptCategories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setSelectedCategory(cat.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
selectedCategory === cat.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: inactiveCategoryClass
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
<span className="ml-1.5 opacity-60">({cat.count})</span>
|
||||
<button key={cat.id} onClick={() => setSelectedCategory(cat.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium ${selectedCategory === cat.id ? "bg-primary text-primary-foreground" : inactiveCategoryClass}`}>
|
||||
{cat.name} <span className="ml-1.5 opacity-60">({cat.count})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -137,23 +240,67 @@ export default function PromptLibrary() {
|
||||
|
||||
{/* Prompts grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{filteredPrompts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPrompts.map((prompt) => (
|
||||
<PromptCard key={prompt.id} prompt={prompt} onUse={handleUsePrompt} />
|
||||
{pagedPrompts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{pagedPrompts.map((prompt) => (
|
||||
<PromptCard
|
||||
key={typeof prompt.id === "string" ? prompt.id : `builtin-${prompt.id}`}
|
||||
prompt={prompt}
|
||||
onUse={handleUsePrompt}
|
||||
onDelete={"custom" in prompt && prompt.custom ? () => handleDeletePrompt(prompt.id as string) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Sparkles className="w-10 h-10 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No prompts found</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
Try adjusting your search or category filter
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">Try adjusting your search or category filter</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-3 border-t border-border/60 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, filteredPrompts.length)} of {filteredPrompts.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i)
|
||||
.filter((i) => i === 0 || i === totalPages - 1 || Math.abs(i - page) <= 1)
|
||||
.reduce<(number | "...")[]>((acc, i) => {
|
||||
if (acc.length > 0) {
|
||||
const last = acc[acc.length - 1];
|
||||
if (typeof last === "number" && i - last > 1) acc.push("...");
|
||||
}
|
||||
acc.push(i);
|
||||
return acc;
|
||||
}, [])
|
||||
.map((item, idx) =>
|
||||
item === "..." ? (
|
||||
<span key={`ellipsis-${idx}`} className="px-1 text-xs text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button key={item} onClick={() => setPage(item as number)}
|
||||
className={`min-w-[28px] h-7 rounded-md text-xs font-medium ${page === item ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/60"}`}>
|
||||
{(item as number) + 1}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddPromptModal open={addModalOpen} onClose={() => setAddModalOpen(false)} onSave={handleAddPrompt} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -825,6 +825,7 @@ export default function QueenDM() {
|
||||
}}
|
||||
supportsImages={true}
|
||||
initialDraft={initialDraft}
|
||||
queenId={queenId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user