feat(platform): add admin copilot manual triggers

This commit is contained in:
Swifty
2026-03-16 11:36:38 +01:00
parent 72856b0c11
commit 050dcd02b6
13 changed files with 1070 additions and 50 deletions

View File

@@ -6,11 +6,13 @@ from typing import TYPE_CHECKING, Any, Literal, Optional
import prisma.enums
from pydantic import BaseModel, EmailStr
from backend.copilot.session_types import ChatSessionStartType
from backend.data.model import UserTransaction
from backend.util.models import Pagination
if TYPE_CHECKING:
from backend.data.invited_user import BulkInvitedUsersResult, InvitedUserRecord
from backend.data.model import User
class UserHistoryResponse(BaseModel):
@@ -90,3 +92,37 @@ class BulkInvitedUsersResponse(BaseModel):
for row in result.results
],
)
class AdminCopilotUserSummary(BaseModel):
id: str
email: str
name: Optional[str] = None
timezone: str
created_at: datetime
updated_at: datetime
@classmethod
def from_user(cls, user: "User") -> "AdminCopilotUserSummary":
return cls(
id=user.id,
email=user.email,
name=user.name,
timezone=user.timezone,
created_at=user.created_at,
updated_at=user.updated_at,
)
class AdminCopilotUsersResponse(BaseModel):
users: list[AdminCopilotUserSummary]
class TriggerCopilotSessionRequest(BaseModel):
user_id: str
start_type: ChatSessionStartType
class TriggerCopilotSessionResponse(BaseModel):
session_id: str
start_type: ChatSessionStartType

View File

@@ -2,8 +2,9 @@ import logging
import math
from autogpt_libs.auth import get_user_id, requires_admin_user
from fastapi import APIRouter, File, Query, Security, UploadFile
from fastapi import APIRouter, File, HTTPException, Query, Security, UploadFile
from backend.copilot.autopilot import trigger_autopilot_session_for_user
from backend.data.invited_user import (
bulk_create_invited_users_from_file,
create_invited_user,
@@ -12,13 +13,18 @@ from backend.data.invited_user import (
revoke_invited_user,
)
from backend.data.tally import mask_email
from backend.data.user import search_users
from backend.util.models import Pagination
from .model import (
AdminCopilotUsersResponse,
AdminCopilotUserSummary,
BulkInvitedUsersResponse,
CreateInvitedUserRequest,
InvitedUserResponse,
InvitedUsersResponse,
TriggerCopilotSessionRequest,
TriggerCopilotSessionResponse,
)
logger = logging.getLogger(__name__)
@@ -135,3 +141,64 @@ async def retry_invited_user_tally_route(
invited_user_id,
)
return InvitedUserResponse.from_record(invited_user)
@router.get(
"/copilot/users",
response_model=AdminCopilotUsersResponse,
summary="Search Copilot Users",
operation_id="getV2SearchCopilotUsers",
)
async def search_copilot_users_route(
search: str = Query("", description="Search by email, name, or user ID"),
limit: int = Query(20, ge=1, le=50),
admin_user_id: str = Security(get_user_id),
) -> AdminCopilotUsersResponse:
logger.info(
"Admin user %s searched Copilot users (query_length=%s, limit=%s)",
admin_user_id,
len(search.strip()),
limit,
)
users = await search_users(search, limit=limit)
return AdminCopilotUsersResponse(
users=[AdminCopilotUserSummary.from_user(user) for user in users]
)
@router.post(
"/copilot/trigger",
response_model=TriggerCopilotSessionResponse,
summary="Trigger Copilot Session",
operation_id="postV2TriggerCopilotSession",
)
async def trigger_copilot_session_route(
request: TriggerCopilotSessionRequest,
admin_user_id: str = Security(get_user_id),
) -> TriggerCopilotSessionResponse:
logger.info(
"Admin user %s manually triggered %s for user %s",
admin_user_id,
request.start_type,
request.user_id,
)
try:
session = await trigger_autopilot_session_for_user(
request.user_id,
start_type=request.start_type,
)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
logger.info(
"Admin user %s created manual Copilot session %s for user %s",
admin_user_id,
session.session_id,
request.user_id,
)
return TriggerCopilotSessionResponse(
session_id=session.session_id,
start_type=request.start_type,
)

View File

@@ -8,11 +8,14 @@ import pytest
import pytest_mock
from autogpt_libs.auth.jwt_utils import get_jwt_payload
from backend.copilot.model import ChatSession
from backend.copilot.session_types import ChatSessionStartType
from backend.data.invited_user import (
BulkInvitedUserRowResult,
BulkInvitedUsersResult,
InvitedUserRecord,
)
from backend.data.model import User
from .user_admin_routes import router as user_admin_router
@@ -72,6 +75,20 @@ def _sample_bulk_invited_users_result() -> BulkInvitedUsersResult:
)
def _sample_user() -> User:
now = datetime.now(timezone.utc)
return User(
id="user-1",
email="copilot@example.com",
name="Copilot User",
timezone="Europe/Madrid",
created_at=now,
updated_at=now,
stripe_customer_id=None,
top_up_config=None,
)
def test_get_invited_users(
mocker: pytest_mock.MockerFixture,
) -> None:
@@ -166,3 +183,71 @@ def test_retry_invited_user_tally(
assert response.status_code == 200
assert response.json()["tally_status"] == "RUNNING"
def test_search_copilot_users(
mocker: pytest_mock.MockerFixture,
) -> None:
mocker.patch(
"backend.api.features.admin.user_admin_routes.search_users",
AsyncMock(return_value=[_sample_user()]),
)
response = client.get("/admin/copilot/users", params={"search": "copilot"})
assert response.status_code == 200
data = response.json()
assert len(data["users"]) == 1
assert data["users"][0]["email"] == "copilot@example.com"
assert data["users"][0]["timezone"] == "Europe/Madrid"
def test_trigger_copilot_session(
mocker: pytest_mock.MockerFixture,
) -> None:
session = ChatSession.new(
"user-1",
start_type=ChatSessionStartType.AUTOPILOT_CALLBACK,
)
trigger = mocker.patch(
"backend.api.features.admin.user_admin_routes.trigger_autopilot_session_for_user",
AsyncMock(return_value=session),
)
response = client.post(
"/admin/copilot/trigger",
json={
"user_id": "user-1",
"start_type": ChatSessionStartType.AUTOPILOT_CALLBACK.value,
},
)
assert response.status_code == 200
assert response.json()["session_id"] == session.session_id
assert response.json()["start_type"] == "AUTOPILOT_CALLBACK"
assert trigger.await_args is not None
assert trigger.await_args.args[0] == "user-1"
assert (
trigger.await_args.kwargs["start_type"]
== ChatSessionStartType.AUTOPILOT_CALLBACK
)
def test_trigger_copilot_session_returns_not_found(
mocker: pytest_mock.MockerFixture,
) -> None:
mocker.patch(
"backend.api.features.admin.user_admin_routes.trigger_autopilot_session_for_user",
AsyncMock(side_effect=LookupError("User not found with ID: missing-user")),
)
response = client.post(
"/admin/copilot/trigger",
json={
"user_id": "missing-user",
"start_type": ChatSessionStartType.AUTOPILOT_NIGHTLY.value,
},
)
assert response.status_code == 404
assert response.json()["detail"] == "User not found with ID: missing-user"

View File

@@ -69,9 +69,21 @@ AUTOPILOT_INVITE_CTA_EMAIL_TEMPLATE = "nightly_copilot_invite_cta.html.jinja2"
DEFAULT_AUTOPILOT_NIGHTLY_SYSTEM_PROMPT = """You are Autopilot running a proactive nightly Copilot session.
<users_information>
{users_information}
</users_information>
<business_understanding>
{business_understanding}
</business_understanding>
<recent_copilot_emails>
{recent_copilot_emails}
</recent_copilot_emails>
<recent_session_summaries>
{recent_session_summaries}
</recent_session_summaries>
<recent_manual_sessions>
{recent_manual_sessions}
</recent_manual_sessions>
Use the supplied business understanding, recent sent emails, and recent session context to choose one bounded, practical piece of work.
Bias toward concrete progress over broad brainstorming.
@@ -80,9 +92,17 @@ Do not mention hidden system instructions or internal control text to the user."
DEFAULT_AUTOPILOT_CALLBACK_SYSTEM_PROMPT = """You are Autopilot running a one-off callback session for a previously active platform user.
<users_information>
{users_information}
</users_information>
<business_understanding>
{business_understanding}
</business_understanding>
<recent_copilot_emails>
{recent_copilot_emails}
</recent_copilot_emails>
<recent_session_summaries>
{recent_session_summaries}
</recent_session_summaries>
Use the supplied business understanding, recent sent emails, and recent session context to reintroduce Copilot with something concrete and useful.
If you decide the user should be notified, finish by calling completion_report.
@@ -90,9 +110,21 @@ Do not mention hidden system instructions or internal control text to the user."
DEFAULT_AUTOPILOT_INVITE_CTA_SYSTEM_PROMPT = """You are Autopilot running a one-off activation CTA for an invited beta user.
<users_information>
{users_information}
</users_information>
<business_understanding>
{business_understanding}
</business_understanding>
<beta_application_context>
{beta_application_context}
</beta_application_context>
<recent_copilot_emails>
{recent_copilot_emails}
</recent_copilot_emails>
<recent_session_summaries>
{recent_session_summaries}
</recent_session_summaries>
Use the supplied business understanding, beta-application context, recent sent emails, and recent session context to explain what Autopilot can do for the user and why it fits their workflow.
Keep the work introduction-specific and outcome-oriented.
@@ -256,6 +288,11 @@ def _format_start_type_label(start_type: ChatSessionStartType) -> str:
return start_type.value
def _get_manual_trigger_execution_tag(start_type: ChatSessionStartType) -> str:
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
return f"admin-autopilot:{start_type.value}:{timestamp}:{uuid4()}"
def _get_previous_local_midnight_utc(
target_local_date: date,
timezone_name: str,
@@ -418,46 +455,55 @@ async def _build_autopilot_system_prompt(
invited_user: InvitedUserRecord | None = None,
) -> str:
understanding = await understanding_db().get_business_understanding(user.id)
context_sections = [
(
format_understanding_for_prompt(understanding)
if understanding
else "No saved business understanding yet."
)
business_understanding = (
format_understanding_for_prompt(understanding)
if understanding
else "No saved business understanding yet."
)
recent_copilot_emails = await _get_recent_sent_email_context(user.id)
recent_session_summaries = await _get_recent_session_summary_context(user.id)
recent_manual_sessions = "Not applicable for this prompt type."
beta_application_context = "No beta application context available."
users_information_sections = [
"## Business Understanding\n" + business_understanding
]
context_sections.append(
"## Recent Copilot Emails Sent To User\n"
+ await _get_recent_sent_email_context(user.id)
users_information_sections.append(
"## Recent Copilot Emails Sent To User\n" + recent_copilot_emails
)
context_sections.append(
"## Recent Copilot Session Summaries\n"
+ await _get_recent_session_summary_context(user.id)
users_information_sections.append(
"## Recent Copilot Session Summaries\n" + recent_session_summaries
)
users_information = "\n\n".join(users_information_sections)
if (
start_type == ChatSessionStartType.AUTOPILOT_NIGHTLY
and target_local_date is not None
):
recent_context = await _get_recent_manual_session_context(
recent_manual_sessions = await _get_recent_manual_session_context(
user.id,
since_utc=_get_previous_local_midnight_utc(
target_local_date,
timezone_name,
),
)
context_sections.append(
"## Recent Manual Sessions Since Previous Nightly Run\n" + recent_context
)
tally_understanding = _get_invited_user_tally_understanding(invited_user)
if tally_understanding is not None:
invite_context = json.dumps(tally_understanding, ensure_ascii=False)
context_sections.append("## Beta Application Context\n" + invite_context)
beta_application_context = json.dumps(tally_understanding, ensure_ascii=False)
return await _get_system_prompt_template(
"\n\n".join(context_sections),
users_information,
prompt_name=_get_autopilot_prompt_name(start_type),
fallback_prompt=_get_autopilot_fallback_prompt(start_type),
template_vars={
"users_information": users_information,
"business_understanding": business_understanding,
"recent_copilot_emails": recent_copilot_emails,
"recent_session_summaries": recent_session_summaries,
"recent_manual_sessions": recent_manual_sessions,
"beta_application_context": beta_application_context,
},
)
@@ -675,6 +721,45 @@ async def dispatch_nightly_copilot() -> int:
return await _dispatch_nightly_copilot()
async def trigger_autopilot_session_for_user(
user_id: str,
*,
start_type: ChatSessionStartType,
) -> ChatSession:
allowed_start_types = {
ChatSessionStartType.AUTOPILOT_INVITE_CTA,
ChatSessionStartType.AUTOPILOT_NIGHTLY,
ChatSessionStartType.AUTOPILOT_CALLBACK,
}
if start_type not in allowed_start_types:
raise ValueError(f"Unsupported autopilot start type: {start_type}")
try:
user = await user_db().get_user_by_id(user_id)
except ValueError as exc:
raise LookupError(str(exc)) from exc
invites = await invited_user_db().list_invited_users_for_auth_users([user_id])
invited_user = invites[0] if invites else None
timezone_name = _resolve_timezone_name(user.timezone)
target_local_date = None
if start_type == ChatSessionStartType.AUTOPILOT_NIGHTLY:
target_local_date = datetime.now(UTC).astimezone(ZoneInfo(timezone_name)).date()
session = await _create_autopilot_session(
user,
start_type=start_type,
execution_tag=_get_manual_trigger_execution_tag(start_type),
timezone_name=timezone_name,
target_local_date=target_local_date,
invited_user=invited_user,
)
if session is None:
raise ValueError("Failed to create autopilot session")
return session
async def _get_pending_approval_metadata(
session: ChatSession,
) -> tuple[int, str | None]:

View File

@@ -25,6 +25,7 @@ from backend.copilot.autopilot import (
handle_non_manual_session_completion,
send_nightly_copilot_emails,
strip_internal_content,
trigger_autopilot_session_for_user,
wrap_internal_message,
)
from backend.copilot.model import ChatMessage, ChatSession
@@ -273,16 +274,30 @@ async def test_build_autopilot_system_prompt_selects_langfuse_prompt(
assert prompt == "compiled prompt"
assert compile_prompt.await_args.kwargs["prompt_name"] == expected_prompt_name
compiled_context = compile_prompt.await_args.args[0]
template_vars = compile_prompt.await_args.kwargs["template_vars"]
assert "business understanding" in compiled_context
assert "## Recent Copilot Emails Sent To User\nrecent emails" in compiled_context
assert "## Recent Copilot Session Summaries\nrecent summaries" in compiled_context
assert template_vars["business_understanding"] == "business understanding"
assert template_vars["recent_copilot_emails"] == "recent emails"
assert template_vars["recent_session_summaries"] == "recent summaries"
assert template_vars["users_information"] == compiled_context
if start_type == ChatSessionStartType.AUTOPILOT_NIGHTLY:
assert (
"## Recent Manual Sessions Since Previous Nightly Run\n"
"recent manual sessions"
) in compiled_context
assert template_vars["recent_manual_sessions"] == "recent manual sessions"
else:
assert template_vars["recent_manual_sessions"] == (
"Not applicable for this prompt type."
)
if start_type == ChatSessionStartType.AUTOPILOT_INVITE_CTA:
assert "## Beta Application Context" in compiled_context
assert template_vars["beta_application_context"] == '{"company": "Example"}'
else:
assert template_vars["beta_application_context"] == (
"No beta application context available."
)
assert (
"## Recent Manual Sessions Since Previous Nightly Run" not in compiled_context
)
assert "## Beta Application Context" not in compiled_context
@pytest.mark.asyncio
@@ -583,6 +598,58 @@ async def test_dispatch_nightly_copilot_respects_cohort_priority(mocker) -> None
)
@pytest.mark.asyncio
async def test_trigger_autopilot_session_for_user_uses_local_date_for_nightly(
mocker,
) -> None:
fixed_now = datetime(2026, 3, 16, 18, 30, tzinfo=UTC)
datetime_mock = mocker.patch(
"backend.copilot.autopilot.datetime",
wraps=datetime,
)
datetime_mock.now.return_value = fixed_now
user = SimpleNamespace(
id="user-1",
timezone="Asia/Tokyo",
name="Nightly User",
)
user_store = SimpleNamespace(get_user_by_id=AsyncMock(return_value=user))
invited_user_store = SimpleNamespace(
list_invited_users_for_auth_users=AsyncMock(return_value=[])
)
session = ChatSession.new(
"user-1",
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
)
create_autopilot_session = mocker.patch(
"backend.copilot.autopilot._create_autopilot_session",
new_callable=AsyncMock,
return_value=session,
)
mocker.patch("backend.copilot.autopilot.user_db", return_value=user_store)
mocker.patch(
"backend.copilot.autopilot.invited_user_db",
return_value=invited_user_store,
)
created = await trigger_autopilot_session_for_user(
"user-1",
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
)
assert created is session
assert create_autopilot_session.await_args.kwargs["execution_tag"].startswith(
"admin-autopilot:AUTOPILOT_NIGHTLY:"
)
assert create_autopilot_session.await_args.kwargs["timezone_name"] == "Asia/Tokyo"
assert create_autopilot_session.await_args.kwargs["target_local_date"] == date(
2026,
3,
17,
)
@pytest.mark.asyncio
async def test_send_nightly_copilot_emails_queues_repair_for_missing_report(
mocker,

View File

@@ -69,6 +69,7 @@ async def _get_system_prompt_template(
*,
prompt_name: str | None = None,
fallback_prompt: str | None = None,
template_vars: dict[str, str] | None = None,
) -> str:
"""Get the system prompt, trying Langfuse first with fallback to default.
@@ -79,6 +80,10 @@ async def _get_system_prompt_template(
The compiled system prompt string.
"""
resolved_prompt_name = prompt_name or config.langfuse_prompt_name
resolved_template_vars = {
"users_information": context,
**(template_vars or {}),
}
if _is_langfuse_configured():
try:
# Use asyncio.to_thread to avoid blocking the event loop
@@ -95,12 +100,12 @@ async def _get_system_prompt_template(
label=label,
cache_ttl_seconds=config.langfuse_prompt_cache_ttl,
)
return prompt.compile(users_information=context)
return prompt.compile(**resolved_template_vars)
except Exception as e:
logger.warning(f"Failed to fetch prompt from Langfuse, using default: {e}")
# Fallback to default prompt
return (fallback_prompt or DEFAULT_SYSTEM_PROMPT).format(users_information=context)
return (fallback_prompt or DEFAULT_SYSTEM_PROMPT).format(**resolved_template_vars)
async def _build_system_prompt(

View File

@@ -70,6 +70,43 @@ async def list_users() -> list[User]:
raise DatabaseError(f"Failed to list users: {e}") from e
async def search_users(query: str, limit: int = 20) -> list[User]:
normalized_query = query.strip()
if not normalized_query:
return []
try:
users = await PrismaUser.prisma().find_many(
where={
"OR": [
{
"email": {
"contains": normalized_query,
"mode": "insensitive",
}
},
{
"name": {
"contains": normalized_query,
"mode": "insensitive",
}
},
{
"id": {
"contains": normalized_query,
"mode": "insensitive",
}
},
]
},
order={"updatedAt": "desc"},
take=limit,
)
return [User.from_db(user) for user in users]
except Exception as e:
raise DatabaseError(f"Failed to search users for query {query!r}: {e}") from e
async def get_user_email_by_id(user_id: str) -> Optional[str]:
try:
user = await prisma.user.find_unique(where={"id": user_id})

View File

@@ -0,0 +1,235 @@
"use client";
import { ChatSessionStartType } from "@/app/api/__generated__/models/chatSessionStartType";
import { Badge } from "@/components/atoms/Badge/Badge";
import { Button } from "@/components/atoms/Button/Button";
import { Card } from "@/components/atoms/Card/Card";
import { Input } from "@/components/atoms/Input/Input";
import { CopilotUsersTable } from "../CopilotUsersTable/CopilotUsersTable";
import { useAdminCopilotPage } from "../../useAdminCopilotPage";
function getStartTypeLabel(startType: ChatSessionStartType) {
if (startType === ChatSessionStartType.AUTOPILOT_INVITE_CTA) {
return "CTA";
}
if (startType === ChatSessionStartType.AUTOPILOT_NIGHTLY) {
return "Nightly";
}
if (startType === ChatSessionStartType.AUTOPILOT_CALLBACK) {
return "Callback";
}
return startType;
}
const triggerOptions = [
{
label: "Trigger CTA",
description:
"Runs the beta invite CTA flow even if the user would not normally qualify.",
startType: ChatSessionStartType.AUTOPILOT_INVITE_CTA,
variant: "primary" as const,
},
{
label: "Trigger Nightly",
description:
"Runs the nightly proactive Autopilot flow immediately for the selected user.",
startType: ChatSessionStartType.AUTOPILOT_NIGHTLY,
variant: "outline" as const,
},
{
label: "Trigger Callback",
description:
"Runs the callback re-engagement flow without checking the normal callback cohort.",
startType: ChatSessionStartType.AUTOPILOT_CALLBACK,
variant: "secondary" as const,
},
];
export function AdminCopilotPage() {
const {
search,
selectedUser,
pendingTriggerType,
lastTriggeredSession,
searchedUsers,
searchErrorMessage,
isSearchingUsers,
isRefreshingUsers,
isTriggeringSession,
hasSearch,
setSearch,
handleSelectUser,
handleTriggerSession,
} = useAdminCopilotPage();
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold text-zinc-900">Copilot</h1>
<p className="max-w-3xl text-sm text-zinc-600">
Manually create CTA, Nightly, or Callback Copilot sessions for a
specific user. These controls bypass the normal eligibility checks so
you can test each flow directly.
</p>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr),24rem]">
<Card className="border border-zinc-200 shadow-sm">
<div className="flex flex-col gap-4">
<Input
id="copilot-user-search"
label="Search users"
hint="Results update as you type"
placeholder="Search by email, name, or user ID"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
{searchErrorMessage ? (
<p className="-mt-2 text-sm text-red-500">{searchErrorMessage}</p>
) : null}
<CopilotUsersTable
users={searchedUsers}
isLoading={isSearchingUsers}
isRefreshing={isRefreshingUsers}
hasSearch={hasSearch}
selectedUserId={selectedUser?.id ?? null}
onSelectUser={handleSelectUser}
/>
</div>
</Card>
<div className="flex flex-col gap-6">
<Card className="border border-zinc-200 shadow-sm">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-zinc-900">
Selected user
</h2>
{selectedUser ? <Badge variant="info">Ready</Badge> : null}
</div>
{selectedUser ? (
<div className="flex flex-col gap-3 text-sm text-zinc-600">
<div>
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">
Email
</span>
<p className="mt-1 font-medium text-zinc-900">
{selectedUser.email}
</p>
</div>
<div>
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">
Name
</span>
<p className="mt-1">
{selectedUser.name || "No display name"}
</p>
</div>
<div>
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">
Timezone
</span>
<p className="mt-1">{selectedUser.timezone}</p>
</div>
<div>
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">
User ID
</span>
<p className="mt-1 break-all font-mono text-xs text-zinc-500">
{selectedUser.id}
</p>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">
Select a user from the results table to enable manual Copilot
triggers.
</p>
)}
</div>
</Card>
<Card className="border border-zinc-200 shadow-sm">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-semibold text-zinc-900">
Trigger flows
</h2>
<p className="text-sm text-zinc-600">
Each action creates a new session immediately for the selected
user.
</p>
</div>
<div className="flex flex-col gap-3">
{triggerOptions.map((option) => (
<div
key={option.startType}
className="rounded-2xl border border-zinc-200 p-4"
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="font-medium text-zinc-900">
{option.label}
</span>
<p className="text-sm text-zinc-600">
{option.description}
</p>
</div>
<Button
variant={option.variant}
disabled={!selectedUser || isTriggeringSession}
loading={pendingTriggerType === option.startType}
onClick={() => handleTriggerSession(option.startType)}
>
{option.label}
</Button>
</div>
</div>
))}
</div>
</div>
</Card>
{selectedUser && lastTriggeredSession ? (
<Card className="border border-zinc-200 shadow-sm">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-zinc-900">
Latest session
</h2>
<Badge variant="success">
{getStartTypeLabel(lastTriggeredSession.start_type)}
</Badge>
</div>
<p className="text-sm text-zinc-600">
A new Copilot session was created for {selectedUser.email}.
</p>
<div>
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">
Session ID
</span>
<p className="mt-1 break-all font-mono text-xs text-zinc-500">
{lastTriggeredSession.session_id}
</p>
</div>
<Button
as="NextLink"
href={`/copilot?sessionId=${lastTriggeredSession.session_id}&showAutopilot=1`}
target="_blank"
rel="noreferrer"
>
Open session
</Button>
</div>
</Card>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import type { AdminCopilotUserSummary } from "@/app/api/__generated__/models/adminCopilotUserSummary";
import { Button } from "@/components/atoms/Button/Button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/__legacy__/ui/table";
interface Props {
users: AdminCopilotUserSummary[];
isLoading: boolean;
isRefreshing: boolean;
hasSearch: boolean;
selectedUserId: string | null;
onSelectUser: (user: AdminCopilotUserSummary) => void;
}
function formatDate(value: Date) {
return value.toLocaleString();
}
export function CopilotUsersTable({
users,
isLoading,
isRefreshing,
hasSearch,
selectedUserId,
onSelectUser,
}: Props) {
let emptyMessage = "Search by email, name, or user ID to find a user.";
if (hasSearch && isLoading) {
emptyMessage = "Searching users...";
} else if (hasSearch) {
emptyMessage = "No matching users found.";
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-semibold text-zinc-900">User results</h2>
<p className="text-sm text-zinc-600">
Select an existing user, then run an Autopilot flow manually.
</p>
</div>
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">
{isRefreshing
? "Refreshing"
: `${users.length} result${users.length === 1 ? "" : "s"}`}
</span>
</div>
<div className="overflow-hidden rounded-2xl border border-zinc-200">
<Table>
<TableHeader className="bg-zinc-50">
<TableRow>
<TableHead>User</TableHead>
<TableHead>Timezone</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="py-10 text-center text-zinc-500"
>
{emptyMessage}
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id} className="align-top">
<TableCell>
<div className="flex flex-col gap-1">
<span className="font-medium text-zinc-900">
{user.email}
</span>
<span className="text-sm text-zinc-600">
{user.name || "No display name"}
</span>
<span className="font-mono text-xs text-zinc-400">
{user.id}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-zinc-600">
{user.timezone}
</TableCell>
<TableCell className="text-sm text-zinc-600">
{formatDate(user.updated_at)}
</TableCell>
<TableCell>
<div className="flex justify-end">
<Button
variant={
user.id === selectedUserId ? "secondary" : "outline"
}
size="small"
onClick={() => onSelectUser(user)}
>
{user.id === selectedUserId ? "Selected" : "Select"}
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { withRoleAccess } from "@/lib/withRoleAccess";
import { AdminCopilotPage } from "./components/AdminCopilotPage/AdminCopilotPage";
function AdminCopilot() {
return <AdminCopilotPage />;
}
export default async function AdminCopilotRoute() {
"use server";
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedAdminCopilot = await withAdminAccess(AdminCopilot);
return <ProtectedAdminCopilot />;
}

View File

@@ -0,0 +1,118 @@
"use client";
import type { AdminCopilotUserSummary } from "@/app/api/__generated__/models/adminCopilotUserSummary";
import { ChatSessionStartType } from "@/app/api/__generated__/models/chatSessionStartType";
import type { TriggerCopilotSessionResponse } from "@/app/api/__generated__/models/triggerCopilotSessionResponse";
import { okData } from "@/app/api/helpers";
import {
useGetV2SearchCopilotUsers,
usePostV2TriggerCopilotSession,
} from "@/app/api/__generated__/endpoints/admin/admin";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { ApiError } from "@/lib/autogpt-server-api/helpers";
import { useDeferredValue, useState } from "react";
function getErrorMessage(error: unknown) {
if (error instanceof ApiError) {
if (
typeof error.response === "object" &&
error.response !== null &&
"detail" in error.response &&
typeof error.response.detail === "string"
) {
return error.response.detail;
}
return error.message;
}
if (error instanceof Error) {
return error.message;
}
return "Something went wrong";
}
export function useAdminCopilotPage() {
const { toast } = useToast();
const [search, setSearch] = useState("");
const [selectedUser, setSelectedUser] =
useState<AdminCopilotUserSummary | null>(null);
const [pendingTriggerType, setPendingTriggerType] =
useState<ChatSessionStartType | null>(null);
const [lastTriggeredSession, setLastTriggeredSession] =
useState<TriggerCopilotSessionResponse | null>(null);
const deferredSearch = useDeferredValue(search);
const normalizedSearch = deferredSearch.trim();
const searchUsersQuery = useGetV2SearchCopilotUsers(
normalizedSearch ? { search: normalizedSearch, limit: 20 } : undefined,
{
query: {
enabled: normalizedSearch.length > 0,
select: okData,
},
},
);
const triggerCopilotSessionMutation = usePostV2TriggerCopilotSession({
mutation: {
onSuccess: (response) => {
setPendingTriggerType(null);
const session = okData(response) ?? null;
setLastTriggeredSession(session);
toast({
title: "Copilot session created",
variant: "default",
});
},
onError: (error) => {
setPendingTriggerType(null);
toast({
title: getErrorMessage(error),
variant: "destructive",
});
},
},
});
function handleSelectUser(user: AdminCopilotUserSummary) {
setSelectedUser(user);
setLastTriggeredSession(null);
}
function handleTriggerSession(startType: ChatSessionStartType) {
if (!selectedUser) {
return;
}
setPendingTriggerType(startType);
setLastTriggeredSession(null);
triggerCopilotSessionMutation.mutate({
data: {
user_id: selectedUser.id,
start_type: startType,
},
});
}
return {
search,
selectedUser,
pendingTriggerType,
lastTriggeredSession,
searchedUsers: searchUsersQuery.data?.users ?? [],
searchErrorMessage: searchUsersQuery.error
? getErrorMessage(searchUsersQuery.error)
: null,
isSearchingUsers: searchUsersQuery.isLoading,
isRefreshingUsers:
searchUsersQuery.isFetching && !searchUsersQuery.isLoading,
isTriggeringSession: triggerCopilotSessionMutation.isPending,
hasSearch: normalizedSearch.length > 0,
setSearch,
handleSelectUser,
handleTriggerSession,
};
}

View File

@@ -8,6 +8,7 @@ import {
MagnifyingGlassIcon,
FileTextIcon,
SlidersHorizontalIcon,
LightningIcon,
} from "@phosphor-icons/react";
const sidebarLinkGroups = [
@@ -33,6 +34,11 @@ const sidebarLinkGroups = [
href: "/admin/impersonation",
icon: <MagnifyingGlassIcon size={24} />,
},
{
text: "Copilot",
href: "/admin/copilot",
icon: <LightningIcon size={24} />,
},
{
text: "Execution Analytics",
href: "/admin/execution-analytics",

View File

@@ -6721,6 +6721,104 @@
}
}
},
"/api/users/admin/copilot/trigger": {
"post": {
"tags": ["v2", "admin", "users", "admin"],
"summary": "Trigger Copilot Session",
"operationId": "postV2TriggerCopilotSession",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TriggerCopilotSessionRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TriggerCopilotSessionResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/users/admin/copilot/users": {
"get": {
"tags": ["v2", "admin", "users", "admin"],
"summary": "Search Copilot Users",
"operationId": "getV2SearchCopilotUsers",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "search",
"in": "query",
"required": false,
"schema": {
"type": "string",
"description": "Search by email, name, or user ID",
"default": "",
"title": "Search"
},
"description": "Search by email, name, or user ID"
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"maximum": 50,
"minimum": 1,
"default": 20,
"title": "Limit"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminCopilotUsersResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/users/admin/invited-users": {
"get": {
"tags": ["v2", "admin", "users", "admin"],
@@ -7321,6 +7419,42 @@
"required": ["new_balance", "transaction_key"],
"title": "AddUserCreditsResponse"
},
"AdminCopilotUserSummary": {
"properties": {
"id": { "type": "string", "title": "Id" },
"email": { "type": "string", "title": "Email" },
"name": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Name"
},
"timezone": { "type": "string", "title": "Timezone" },
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"updated_at": {
"type": "string",
"format": "date-time",
"title": "Updated At"
}
},
"type": "object",
"required": ["id", "email", "timezone", "created_at", "updated_at"],
"title": "AdminCopilotUserSummary"
},
"AdminCopilotUsersResponse": {
"properties": {
"users": {
"items": { "$ref": "#/components/schemas/AdminCopilotUserSummary" },
"type": "array",
"title": "Users"
}
},
"type": "object",
"required": ["users"],
"title": "AdminCopilotUsersResponse"
},
"AgentDetails": {
"properties": {
"id": { "type": "string", "title": "Id" },
@@ -7334,14 +7468,12 @@
"inputs": {
"additionalProperties": true,
"type": "object",
"title": "Inputs",
"default": {}
"title": "Inputs"
},
"credentials": {
"items": { "$ref": "#/components/schemas/CredentialsMetaInput" },
"type": "array",
"title": "Credentials",
"default": []
"title": "Credentials"
},
"execution_options": {
"$ref": "#/components/schemas/ExecutionOptions"
@@ -7918,20 +8050,17 @@
"inputs": {
"additionalProperties": true,
"type": "object",
"title": "Inputs",
"default": {}
"title": "Inputs"
},
"outputs": {
"additionalProperties": true,
"type": "object",
"title": "Outputs",
"default": {}
"title": "Outputs"
},
"credentials": {
"items": { "$ref": "#/components/schemas/CredentialsMetaInput" },
"type": "array",
"title": "Credentials",
"default": []
"title": "Credentials"
}
},
"type": "object",
@@ -10953,8 +11082,7 @@
"suggestions": {
"items": { "type": "string" },
"type": "array",
"title": "Suggestions",
"default": []
"title": "Suggestions"
},
"name": { "type": "string", "title": "Name", "default": "no_results" }
},
@@ -13939,6 +14067,24 @@
"required": ["transactions", "next_transaction_time"],
"title": "TransactionHistory"
},
"TriggerCopilotSessionRequest": {
"properties": {
"user_id": { "type": "string", "title": "User Id" },
"start_type": { "$ref": "#/components/schemas/ChatSessionStartType" }
},
"type": "object",
"required": ["user_id", "start_type"],
"title": "TriggerCopilotSessionRequest"
},
"TriggerCopilotSessionResponse": {
"properties": {
"session_id": { "type": "string", "title": "Session Id" },
"start_type": { "$ref": "#/components/schemas/ChatSessionStartType" }
},
"type": "object",
"required": ["session_id", "start_type"],
"title": "TriggerCopilotSessionResponse"
},
"TriggeredPresetSetupRequest": {
"properties": {
"name": { "type": "string", "title": "Name" },
@@ -14870,8 +15016,7 @@
"missing_credentials": {
"additionalProperties": true,
"type": "object",
"title": "Missing Credentials",
"default": {}
"title": "Missing Credentials"
},
"ready_to_run": {
"type": "boolean",