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:
Vincent Jiang
2026-04-17 14:21:05 -07:00
parent c6b6a5a2f7
commit 6e97191f21
21 changed files with 1350 additions and 744 deletions
+15 -2
View File
@@ -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
+35
View File
@@ -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
}
]
}
+2
View File
@@ -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 /
+160 -9
View File
@@ -6,6 +6,7 @@ Routes:
- GET /api/config/models curated providermodels 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)
+88
View File
@@ -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)
+127 -2
View File
@@ -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)
+7 -4
View File
@@ -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 }),
};
+6
View File
@@ -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);
},
};
+19
View File
@@ -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}`),
};
+7
View File
@@ -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`, {
+47 -15
View File
@@ -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>
+29 -9
View File
@@ -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">
+100 -88
View File
@@ -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>
+189 -429
View File
@@ -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>
+16 -3
View File
@@ -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}
+39
View File
@@ -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);
});
}
+38 -15
View 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>
+217 -70
View File
@@ -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>
);
}
+1
View File
@@ -825,6 +825,7 @@ export default function QueenDM() {
}}
supportsImages={true}
initialDraft={initialDraft}
queenId={queenId}
/>
</div>