Merge remote-tracking branch 'origin/main' into fix/colony-skill-leak

This commit is contained in:
Richard Tang
2026-04-18 21:36:23 -07:00
33 changed files with 2754 additions and 839 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
+3 -3
View File
@@ -1672,7 +1672,7 @@ class AgentHost:
entry_point_id: str,
execution_id: str,
graph_id: str | None = None,
) -> bool:
) -> str:
"""
Cancel a running execution.
@@ -1682,11 +1682,11 @@ class AgentHost:
graph_id: Graph to search (defaults to active graph)
Returns:
True if cancelled, False if not found
Cancellation outcome from the stream.
"""
stream = self._resolve_stream(entry_point_id, graph_id)
if stream is None:
return False
return "not_found"
return await stream.cancel_execution(execution_id)
# === QUERY OPERATIONS ===
+70 -39
View File
@@ -16,7 +16,7 @@ from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from framework.host.event_bus import EventBus
from framework.host.shared_state import IsolationLevel, SharedBufferManager
@@ -48,6 +48,8 @@ class ExecutionAlreadyRunningError(RuntimeError):
logger = logging.getLogger(__name__)
CancelExecutionResult = Literal["cancelled", "cancelling", "not_found"]
class GraphScopedEventBus(EventBus):
"""Proxy that stamps ``graph_id`` on every published event.
@@ -130,7 +132,7 @@ class ExecutionContext:
run_id: str | None = None # Unique ID per trigger() invocation
started_at: datetime = field(default_factory=datetime.now)
completed_at: datetime | None = None
status: str = "pending" # pending, running, completed, failed, paused
status: str = "pending" # pending, running, cancelling, completed, failed, paused, cancelled
class ExecutionManager:
@@ -315,6 +317,22 @@ class ExecutionManager:
"""Return IDs of all currently active executions."""
return list(self._active_executions.keys())
def _get_blocking_execution_ids_locked(self) -> list[str]:
"""Return executions that still block a replacement from starting.
An execution continues to block replacement until its task has
terminated and the task's final cleanup has removed its bookkeeping.
This is intentional: a timed-out cancellation does not mean the old
task is harmless. If it is still alive, it can still write shared
session state, so letting a replacement start would guarantee
overlapping mutations on the same session.
"""
blocking_ids: list[str] = list(self._active_executions.keys())
for execution_id, task in self._execution_tasks.items():
if not task.done() and execution_id not in self._active_executions:
blocking_ids.append(execution_id)
return blocking_ids
@property
def agent_idle_seconds(self) -> float:
"""Seconds since the last agent activity (LLM call, tool call, node transition).
@@ -396,15 +414,22 @@ class ExecutionManager:
async def stop(self) -> None:
"""Stop the execution stream and cancel active executions."""
if not self._running:
return
async with self._lock:
if not self._running:
return
self._running = False
self._running = False
# Cancel all active executions
tasks_to_wait = []
for _, task in self._execution_tasks.items():
if not task.done():
# Cancel all active executions, but keep bookkeeping until each
# task reaches its own cleanup path.
tasks_to_wait: list[asyncio.Task] = []
for execution_id, task in self._execution_tasks.items():
if task.done():
continue
ctx = self._active_executions.get(execution_id)
if ctx is not None:
ctx.status = "cancelling"
self._cancel_reasons.setdefault(execution_id, "Execution cancelled")
task.cancel()
tasks_to_wait.append(task)
@@ -418,9 +443,6 @@ class ExecutionManager:
len(pending),
)
self._execution_tasks.clear()
self._active_executions.clear()
logger.info(f"ExecutionStream '{self.stream_id}' stopped")
# Emit stream stopped event
@@ -569,12 +591,16 @@ class ExecutionManager:
)
async with self._lock:
if not self._running:
raise RuntimeError(f"ExecutionStream '{self.stream_id}' is not running")
blocking_ids = self._get_blocking_execution_ids_locked()
if blocking_ids:
raise ExecutionAlreadyRunningError(self.stream_id, blocking_ids)
self._active_executions[execution_id] = ctx
self._completion_events[execution_id] = asyncio.Event()
# Start execution task
task = asyncio.create_task(self._run_execution(ctx))
self._execution_tasks[execution_id] = task
self._execution_tasks[execution_id] = asyncio.create_task(self._run_execution(ctx))
logger.debug(f"Queued execution {execution_id} for stream {self.stream_id}")
return execution_id
@@ -1183,7 +1209,7 @@ class ExecutionManager:
"""Get execution context."""
return self._active_executions.get(execution_id)
async def cancel_execution(self, execution_id: str, *, reason: str | None = None) -> bool:
async def cancel_execution(self, execution_id: str, *, reason: str | None = None) -> CancelExecutionResult:
"""
Cancel a running execution.
@@ -1194,33 +1220,38 @@ class ExecutionManager:
provided, defaults to "Execution cancelled".
Returns:
True if cancelled, False if not found
"cancelled" if the task fully exited within the grace period,
"cancelling" if cancellation was requested but the task is still
shutting down, or "not_found" if no active task exists.
"""
task = self._execution_tasks.get(execution_id)
if task and not task.done():
async with self._lock:
task = self._execution_tasks.get(execution_id)
if task is None or task.done():
return "not_found"
# Store the reason so the CancelledError handler can use it
# when emitting the pause/fail event.
self._cancel_reasons[execution_id] = reason or "Execution cancelled"
ctx = self._active_executions.get(execution_id)
if ctx is not None:
ctx.status = "cancelling"
task.cancel()
# Wait briefly for the task to finish. Don't block indefinitely —
# the task may be stuck in a long LLM API call that doesn't
# respond to cancellation quickly.
done, _ = await asyncio.wait({task}, timeout=5.0)
if not done:
# Task didn't finish within timeout — clean up bookkeeping now
# so the session doesn't think it still has running executions.
# The task will continue winding down in the background and its
# finally block will harmlessly pop already-removed keys.
logger.warning(
"Execution %s did not finish within cancel timeout; force-cleaning bookkeeping",
execution_id,
)
async with self._lock:
self._active_executions.pop(execution_id, None)
self._execution_tasks.pop(execution_id, None)
self._active_executors.pop(execution_id, None)
return True
return False
# Wait briefly for the task to finish. Don't block indefinitely —
# the task may be stuck in a long LLM API call that doesn't
# respond to cancellation quickly.
done, _ = await asyncio.wait({task}, timeout=5.0)
if not done:
# Keep bookkeeping in place until the task's own finally block runs.
# We intentionally do not add deferred cleanup keyed by execution_id
# here because resumed executions reuse the same id; a delayed pop
# could otherwise delete bookkeeping that belongs to the new run.
logger.warning(
"Execution %s did not finish within cancel timeout; leaving bookkeeping in place until task exit",
execution_id,
)
return "cancelling"
return "cancelled"
# === STATS AND MONITORING ===
+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
}
]
}
@@ -0,0 +1,180 @@
"""Regression tests for forced cancellation overlap in ExecutionStream."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from framework.host.event_bus import AgentEvent, EventBus, EventType
from framework.host.execution_manager import (
EntryPointSpec,
ExecutionAlreadyRunningError,
ExecutionManager,
)
from framework.orchestrator.edge import GraphSpec
from framework.orchestrator.goal import Goal
from framework.orchestrator.orchestrator import ExecutionResult
def _build_stream(tmp_path, *, event_bus: EventBus | None = None) -> ExecutionManager:
graph = GraphSpec(
id="test-graph",
goal_id="goal-1",
version="1.0.0",
entry_node="start",
entry_points={"start": "start"},
terminal_nodes=[],
pause_nodes=[],
nodes=[],
edges=[],
)
goal = Goal(id="goal-1", name="goal-1", description="test goal")
entry_spec = EntryPointSpec(
id="webhook",
name="Webhook",
entry_node="start",
trigger_type="webhook",
isolation_level="shared",
max_concurrent=1,
)
storage = SimpleNamespace(base_path=tmp_path)
stream = ExecutionManager(
stream_id="webhook",
entry_spec=entry_spec,
graph=graph,
goal=goal,
state_manager=MagicMock(),
storage=storage,
outcome_aggregator=MagicMock(),
event_bus=event_bus,
)
stream._running = True
return stream
def _install_blocking_executor(monkeypatch, release: asyncio.Event) -> None:
class BlockingExecutor:
def __init__(self, *args, **kwargs):
self.node_registry = {}
async def execute(self, *args, **kwargs):
while True:
try:
await release.wait()
break
except asyncio.CancelledError:
continue
return ExecutionResult(success=True, output={"ok": True})
monkeypatch.setattr("framework.host.execution_manager.Orchestrator", BlockingExecutor)
@pytest.mark.asyncio
async def test_forced_cancel_timeout_keeps_stream_locked_until_task_exit(tmp_path, monkeypatch):
event_bus = EventBus()
stream = _build_stream(tmp_path, event_bus=event_bus)
release = asyncio.Event()
_install_blocking_executor(monkeypatch, release)
started_events: list[AgentEvent] = []
first_started = asyncio.Event()
second_started = asyncio.Event()
async def on_started(event: AgentEvent) -> None:
started_events.append(event)
if len(started_events) == 1:
first_started.set()
elif len(started_events) == 2:
second_started.set()
event_bus.subscribe(
event_types=[EventType.EXECUTION_STARTED],
handler=on_started,
filter_stream="webhook",
)
async def immediate_timeout(_tasks, timeout=None):
return set(), set(_tasks)
execution_id = await stream.execute({}, session_state={"resume_session_id": "session-1"})
await asyncio.wait_for(first_started.wait(), timeout=1)
old_task = stream._execution_tasks[execution_id]
monkeypatch.setattr("framework.host.execution_manager.asyncio.wait", immediate_timeout)
try:
cancelled = await stream.cancel_execution(execution_id, reason="forced timeout")
assert cancelled == "cancelling"
assert execution_id in stream._execution_tasks
assert execution_id in stream._active_executions
assert execution_id in stream._completion_events
assert stream._active_executions[execution_id].status == "cancelling"
assert not old_task.done()
with pytest.raises(ExecutionAlreadyRunningError):
await stream.execute({}, session_state={"resume_session_id": execution_id})
assert len(started_events) == 1
release.set()
await asyncio.wait_for(old_task, timeout=1)
restarted_id = await stream.execute({}, session_state={"resume_session_id": execution_id})
assert restarted_id == execution_id
await asyncio.wait_for(second_started.wait(), timeout=1)
finally:
release.set()
await asyncio.gather(*stream._execution_tasks.values(), return_exceptions=True)
@pytest.mark.asyncio
async def test_repeated_forced_restarts_do_not_accumulate_parallel_tasks(tmp_path, monkeypatch):
event_bus = EventBus()
stream = _build_stream(tmp_path, event_bus=event_bus)
release = asyncio.Event()
_install_blocking_executor(monkeypatch, release)
started_events: list[AgentEvent] = []
first_started = asyncio.Event()
async def on_started(event: AgentEvent) -> None:
started_events.append(event)
first_started.set()
event_bus.subscribe(
event_types=[EventType.EXECUTION_STARTED],
handler=on_started,
filter_stream="webhook",
)
async def immediate_timeout(_tasks, timeout=None):
return set(), set(_tasks)
monkeypatch.setattr("framework.host.execution_manager.asyncio.wait", immediate_timeout)
execution_id = await stream.execute({}, session_state={"resume_session_id": "session-1"})
await asyncio.wait_for(first_started.wait(), timeout=1)
first_task = stream._execution_tasks[execution_id]
try:
assert await stream.cancel_execution(execution_id, reason="restart-1") == "cancelling"
with pytest.raises(ExecutionAlreadyRunningError):
await stream.execute({}, session_state={"resume_session_id": execution_id})
with pytest.raises(ExecutionAlreadyRunningError):
await stream.execute({}, session_state={"resume_session_id": execution_id})
assert len(started_events) == 1
assert list(stream._execution_tasks) == [execution_id]
assert stream._execution_tasks[execution_id] is first_task
assert not first_task.done()
finally:
release.set()
await asyncio.wait_for(first_task, timeout=1)
+8
View File
@@ -318,7 +318,11 @@ def create_app(model: str | None = None) -> web.Application:
from framework.server.routes_execution import register_routes as register_execution_routes
from framework.server.routes_logs import register_routes as register_log_routes
from framework.server.routes_messages import register_routes as register_message_routes
<<<<<<< HEAD
from framework.server.routes_colony_workers import register_routes as register_colony_worker_routes
=======
from framework.server.routes_prompts import register_routes as register_prompt_routes
>>>>>>> origin/main
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
@@ -332,7 +336,11 @@ def create_app(model: str | None = None) -> web.Application:
register_worker_routes(app)
register_log_routes(app)
register_queen_routes(app)
<<<<<<< HEAD
register_colony_worker_routes(app)
=======
register_prompt_routes(app)
>>>>>>> origin/main
# Static file serving — Option C production mode
# If frontend/dist/ exists, serve built frontend files on /
+151 -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,53 @@ 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,
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 +372,9 @@ 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 +417,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 +462,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 +492,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 +541,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 +676,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 +746,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)
+62 -20
View File
@@ -10,6 +10,7 @@ from aiohttp import web
from framework.agent_loop.conversation import LEGACY_RUN_ID
from framework.credentials.validation import validate_agent_credentials
from framework.host.execution_manager import ExecutionAlreadyRunningError
from framework.server.app import resolve_session, safe_path_segment, sessions_dir
from framework.server.routes_sessions import _credential_error_response
@@ -101,6 +102,17 @@ def _resolve_queen_only_tools() -> frozenset[str]:
return frozenset(derived | _QUEEN_LIFECYCLE_EXTRAS)
def _execution_already_running_response(exc: ExecutionAlreadyRunningError) -> web.Response:
return web.json_response(
{
"error": str(exc),
"stream_id": exc.stream_id,
"active_execution_ids": exc.active_ids,
},
status=409,
)
async def handle_trigger(request: web.Request) -> web.Response:
"""POST /api/sessions/{session_id}/trigger — start an execution.
@@ -142,11 +154,14 @@ async def handle_trigger(request: web.Request) -> web.Response:
if "resume_session_id" not in session_state:
session_state["resume_session_id"] = session.id
execution_id = await session.colony_runtime.trigger(
entry_point_id,
input_data,
session_state=session_state,
)
try:
execution_id = await session.colony_runtime.trigger(
entry_point_id,
input_data,
session_state=session_state,
)
except ExecutionAlreadyRunningError as exc:
return _execution_already_running_response(exc)
# Cancel queen's in-progress LLM turn so it picks up the phase change cleanly
if session.queen_executor:
@@ -435,11 +450,14 @@ async def handle_resume(request: web.Request) -> web.Response:
input_data = state.get("input_data", {})
execution_id = await session.colony_runtime.trigger(
entry_points[0].id,
input_data=input_data,
session_state=resume_session_state,
)
try:
execution_id = await session.colony_runtime.trigger(
entry_points[0].id,
input_data=input_data,
session_state=resume_session_state,
)
except ExecutionAlreadyRunningError as exc:
return _execution_already_running_response(exc)
return web.json_response(
{
@@ -466,6 +484,7 @@ async def handle_pause(request: web.Request) -> web.Response:
runtime = session.colony_runtime
cancelled = []
cancelling = []
for colony_id in runtime.list_graphs():
reg = runtime.get_graph_registration(colony_id)
@@ -482,24 +501,33 @@ async def handle_pause(request: web.Request) -> web.Response:
for exec_id in list(stream.active_execution_ids):
try:
ok = await stream.cancel_execution(exec_id, reason="Execution paused by user")
if ok:
outcome = await stream.cancel_execution(exec_id, reason="Execution paused by user")
if outcome == "cancelled":
cancelled.append(exec_id)
elif outcome == "cancelling":
cancelling.append(exec_id)
except Exception:
pass
# Pause timers so the next tick doesn't restart execution
runtime.pause_timers()
<<<<<<< HEAD
# Switch to reviewing — workers stopped, queen now helps the user
# interpret whatever they produced and decide next steps.
if session.phase_state is not None:
await session.phase_state.switch_to_reviewing(source="frontend")
=======
# Only switch to staging once every execution has actually stopped.
if session.phase_state is not None and not cancelling:
await session.phase_state.switch_to_staging(source="frontend")
>>>>>>> origin/main
return web.json_response(
{
"stopped": bool(cancelled),
"stopped": bool(cancelled) and not cancelling,
"cancelled": cancelled,
"cancelling": cancelling,
"timers_paused": True,
}
)
@@ -536,8 +564,9 @@ async def handle_stop(request: web.Request) -> web.Response:
if hasattr(node, "cancel_current_turn"):
node.cancel_current_turn()
cancelled = await stream.cancel_execution(execution_id, reason="Execution stopped by user")
if cancelled:
outcome = await stream.cancel_execution(execution_id, reason="Execution stopped by user")
if outcome == "cancelled":
# Cancel queen's in-progress LLM turn
if session.queen_executor:
node = session.queen_executor.node_registry.get("queen")
@@ -552,9 +581,19 @@ async def handle_stop(request: web.Request) -> web.Response:
return web.json_response(
{
"stopped": True,
"cancelling": False,
"execution_id": execution_id,
}
)
if outcome == "cancelling":
return web.json_response(
{
"stopped": False,
"cancelling": True,
"execution_id": execution_id,
},
status=202,
)
return web.json_response({"stopped": False, "error": "Execution not found"}, status=404)
@@ -597,11 +636,14 @@ async def handle_replay(request: web.Request) -> web.Response:
"run_id": _load_checkpoint_run_id(cp_path),
}
execution_id = await session.colony_runtime.trigger(
entry_points[0].id,
input_data={},
session_state=replay_session_state,
)
try:
execution_id = await session.colony_runtime.trigger(
entry_points[0].id,
input_data={},
session_state=replay_session_state,
)
except ExecutionAlreadyRunningError as exc:
return _execution_already_running_response(exc)
return web.json_response(
{
+87
View File
@@ -0,0 +1,87 @@
"""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 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)
+126 -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
@@ -166,6 +168,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"]
@@ -175,11 +205,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:
@@ -372,11 +409,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)
+35 -2
View File
@@ -14,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from aiohttp.test_utils import TestClient, TestServer
from framework.host.execution_manager import ExecutionAlreadyRunningError
from framework.host.triggers import TriggerDefinition
from framework.llm.model_catalog import get_models_catalogue
from framework.server import (
@@ -89,8 +90,8 @@ class MockStream:
_active_executors: dict = field(default_factory=dict)
active_execution_ids: set = field(default_factory=set)
async def cancel_execution(self, execution_id: str, reason: str | None = None) -> bool:
return execution_id in self._execution_tasks
async def cancel_execution(self, execution_id: str, reason: str | None = None) -> str:
return "cancelled" if execution_id in self._execution_tasks else "not_found"
@dataclass
@@ -780,6 +781,21 @@ class TestExecution:
data = await resp.json()
assert data["execution_id"] == "exec_test_123"
@pytest.mark.asyncio
async def test_trigger_returns_409_when_execution_still_running(self):
session = _make_session()
session.colony_runtime.trigger = AsyncMock(side_effect=ExecutionAlreadyRunningError("default", ["session-1"]))
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/sessions/test_agent/trigger",
json={"entry_point_id": "default", "input_data": {"msg": "hi"}},
)
assert resp.status == 409
data = await resp.json()
assert data["stream_id"] == "default"
assert data["active_execution_ids"] == ["session-1"]
@pytest.mark.asyncio
async def test_trigger_not_found(self):
app = create_app()
@@ -918,6 +934,7 @@ class TestExecution:
data = await resp.json()
assert data["stopped"] is False
assert data["cancelled"] == []
assert data["cancelling"] == []
assert data["timers_paused"] is True
@pytest.mark.asyncio
@@ -1027,6 +1044,22 @@ class TestStop:
assert resp.status == 200
data = await resp.json()
assert data["stopped"] is True
assert data["cancelling"] is False
@pytest.mark.asyncio
async def test_stop_returns_accepted_while_execution_is_still_cancelling(self):
session = _make_session()
session.colony_runtime._mock_streams["default"].cancel_execution = AsyncMock(return_value="cancelling")
app = _make_app_with_session(session)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/api/sessions/test_agent/stop",
json={"execution_id": "exec_abc"},
)
assert resp.status == 202
data = await resp.json()
assert data["stopped"] is False
assert data["cancelling"] is True
@pytest.mark.asyncio
async def test_stop_not_found(self):
File diff suppressed because it is too large Load Diff
+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\/(.+)/);
@@ -95,24 +127,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
@@ -118,6 +118,8 @@ interface ChatPanelProps {
* to tolerate pages that render the panel before the queen is
* resolved (e.g. new-chat bootstrap). */
queenProfileId?: string | null;
/** Queen ID — used to display the queen's avatar photo in messages */
queenId?: string;
}
const queenColor = "hsl(45,95%,58%)";
@@ -318,10 +320,12 @@ function InlineAskUserBubble({
queenPhase,
showQueenPhaseBadge = true,
queenProfileId,
queenAvatarUrl,
}: {
msg: ChatMessage;
payload: AskUserInlinePayload;
activeThread: string;
queenAvatarUrl?: string | null;
onSend: (
message: string,
thread: string,
@@ -348,6 +352,7 @@ function InlineAskUserBubble({
queenPhase={queenPhase}
showQueenPhaseBadge={showQueenPhaseBadge}
queenProfileId={queenProfileId}
queenAvatarUrl={queenAvatarUrl}
/>
);
}
@@ -396,8 +401,8 @@ 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${handleAvatarClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
style={{
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center overflow-hidden${handleAvatarClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
style={isQueen && queenAvatarUrl ? undefined : {
backgroundColor: `${color}18`,
border: `1.5px solid ${color}35`,
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
@@ -406,7 +411,7 @@ function InlineAskUserBubble({
title={avatarTitle}
>
{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 }} />
)}
@@ -461,17 +466,28 @@ 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,
queenProfileId,
queenAvatarUrl,
}: {
msg: ChatMessage;
queenPhase?: "independent" | "working" | "reviewing";
showQueenPhaseBadge?: boolean;
queenProfileId?: string | null;
queenAvatarUrl?: string | null;
}) {
const isUser = msg.type === "user";
const isQueen = msg.role === "queen";
@@ -605,8 +621,8 @@ 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${handleAvatarClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
style={{
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center overflow-hidden${handleAvatarClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
style={isQueen && queenAvatarUrl ? undefined : {
backgroundColor: `${color}18`,
border: `1.5px solid ${color}35`,
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
@@ -615,7 +631,7 @@ const MessageBubble = memo(
title={avatarTitle}
>
{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 }} />
)}
@@ -694,6 +710,7 @@ export default function ChatPanel({
supportsImages = true,
initialDraft,
queenProfileId,
queenId,
}: ChatPanelProps) {
const [input, setInput] = useState("");
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
@@ -704,6 +721,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;
@@ -1120,6 +1138,7 @@ export default function ChatPanel({
queenPhase={queenPhase}
showQueenPhaseBadge={showQueenPhaseBadge}
queenProfileId={queenProfileId}
queenAvatarUrl={queenAvatarUrl}
/>
</div>
);
@@ -1131,6 +1150,7 @@ export default function ChatPanel({
queenPhase={queenPhase}
showQueenPhaseBadge={showQueenPhaseBadge}
queenProfileId={queenProfileId}
queenAvatarUrl={queenAvatarUrl}
/>
</div>
);
@@ -1140,14 +1160,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,13 @@
<<<<<<< HEAD
import { useState, useEffect, useCallback, useRef } from "react";
=======
import { useState, useEffect, useRef } from "react";
>>>>>>> origin/main
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,34 +16,110 @@ 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 ?? "";
<<<<<<< HEAD
// ── Resizable width ──────────────────────────────────────────────────
const MIN_WIDTH = 280;
const MAX_WIDTH = 600;
@@ -89,16 +163,50 @@ export default function QueenProfilePanel({
onMouseDown={onDragStart}
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors z-10"
/>
=======
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 overscroll-contain">
>>>>>>> origin/main
{/* 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>
@@ -108,70 +216,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>
@@ -180,54 +311,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";
@@ -7,6 +8,9 @@ interface SidebarQueenItemProps {
}
export default function SidebarQueenItem({ queen, isActive }: SidebarQueenItemProps) {
const [hasAvatar, setHasAvatar] = useState(true);
const avatarUrl = `/api/queen/${queen.id}/avatar`;
return (
<NavLink
to={`/queen/${queen.id}`}
@@ -18,8 +22,14 @@ export default function SidebarQueenItem({ queen, isActive }: SidebarQueenItemPr
}`
}
>
<span className="relative 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="relative flex-shrink-0 w-6 h-6 rounded-full bg-primary/15 flex items-center justify-center">
<span className="w-full h-full rounded-full overflow-hidden flex items-center justify-center">
{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>
{isActive && (
<span
className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-500 ring-2 ring-sidebar-bg"
+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>
);
}
+4
View File
@@ -854,7 +854,11 @@ export default function QueenDM() {
}}
supportsImages={true}
initialDraft={initialDraft}
<<<<<<< HEAD
queenProfileId={queenId ?? null}
=======
queenId={queenId}
>>>>>>> origin/main
/>
</div>
+5 -2
View File
@@ -274,9 +274,12 @@ class TestReportToParent:
worker = colony.get_worker(worker_ids[0])
assert worker is not None
# Wait for the worker's background task to finish
# Wait for the worker to finish AND for the SUBAGENT_REPORT event
# to propagate. On Windows the event loop scheduling differs from
# POSIX, so a worker can be marked inactive a few ticks before the
# subscriber callback runs. Waiting on both avoids that race.
deadline = asyncio.get_event_loop().time() + 5.0
while worker.is_active and asyncio.get_event_loop().time() < deadline:
while (worker.is_active or len(reports) == 0) and asyncio.get_event_loop().time() < deadline:
await asyncio.sleep(0.05)
assert not worker.is_active, "Worker did not finish within timeout"
+5
View File
@@ -150,6 +150,11 @@ def test_openrouter_catalog_tracks_current_frontier_set():
"anthropic/claude-opus-4.6",
"google/gemini-3.1-pro-preview-customtools",
"deepseek/deepseek-v3.2",
"qwen/qwen3.6-plus",
"z-ai/glm-5v-turbo",
"x-ai/grok-4.20",
"xiaomi/mimo-v2-pro",
"stepfun/step-3.5-flash",
]
assert openrouter_models[0]["max_tokens"] == 128000
assert openrouter_models[0]["max_context_tokens"] == 922000
+1
View File
@@ -50,6 +50,7 @@ CONTENT_ROLES: frozenset[str] = frozenset(
"columnheader",
"gridcell",
"heading",
"img",
"listitem",
"main",
"navigation",
+1 -2
View File
@@ -187,8 +187,7 @@ def _resize_and_annotate(
)
except Exception:
logger.warning(
"Screenshot resize/annotate FAILED — returning original image. "
"css_width=%s, dpr=%s.",
"Screenshot resize/annotate FAILED — returning original image. css_width=%s, dpr=%s.",
css_width,
dpr,
exc_info=True,
+2 -8
View File
@@ -606,10 +606,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
if x > 1.5 or y > 1.5 or x < -0.1 or y < -0.1:
result = {
"ok": False,
"error": (
f"Coords ({x}, {y}) look like pixels. This tool expects "
"fractions 0..1 of the viewport."
),
"error": (f"Coords ({x}, {y}) look like pixels. This tool expects fractions 0..1 of the viewport."),
}
log_tool_call("browser_hover_coordinate", params, result=result)
return result
@@ -687,10 +684,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
if x > 1.5 or y > 1.5 or x < -0.1 or y < -0.1:
result = {
"ok": False,
"error": (
f"Coords ({x}, {y}) look like pixels. This tool expects "
"fractions 0..1 of the viewport."
),
"error": (f"Coords ({x}, {y}) look like pixels. This tool expects fractions 0..1 of the viewport."),
}
log_tool_call("browser_press_at", params, result=result)
return result
+3 -2
View File
@@ -45,8 +45,9 @@ class TestAnnotateSnapshot:
def test_skips_structural_roles(self):
annotated, ref_map = annotate_snapshot(SAMPLE_SNAPSHOT)
roles_in_map = {entry.role for entry in ref_map.values()}
# navigation, main, list, listitem, paragraph are structural — no refs
assert "navigation" not in roles_in_map
# main (unnamed), list, listitem (unnamed), paragraph are structural — no refs.
# Note: navigation is a landmark role and now gets a ref when named, so it
# is not asserted absent here.
assert "main" not in roles_in_map
assert "list" not in roles_in_map
assert "listitem" not in roles_in_map