From d95aef76653733b8403e9a6dbec82a6bca9ca016 Mon Sep 17 00:00:00 2001 From: Ubbe Date: Fri, 13 Feb 2026 04:06:40 +0800 Subject: [PATCH] fix(copilot): stream timeout, long-running tool polling, and CreateAgent UI refresh (#12070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent generation completes on the backend but the UI does not update/refresh to show the result. ### Changes 🏗️ ![Uploading Screenshot 2026-02-13 at 00.44.54.png…]() - **Stream start timeout (12s):** If the backend doesn't begin streaming within 12 seconds of submitting a message, the stream is aborted and a destructive toast is shown to the user. - **Long-running tool polling:** Added `useLongRunningToolPolling` hook that polls the session endpoint every 1.5s while a tool output is in an operating state (`operation_started` / `operation_pending` / `operation_in_progress`). When the backend completes, messages are refreshed so the UI reflects the final result. - **CreateAgent UI improvements:** Replaced the orbit loader / progress bar with a mini-game, added expanded accordion for saved agents, and improved the saved-agent card with image, icons, and links that open in new tabs. - **Backend tweaks:** Added `image_url` to `CreateAgentToolOutput`, minor model/service updates for the dummy agent generator. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Send a message and verify the stream starts within 12s or a toast appears - [x] Trigger agent creation and verify the UI updates when the backend completes - [x] Verify the saved-agent card renders correctly with image, links, and icons --------- Co-authored-by: Otto Co-authored-by: Nicholas Tindle Co-authored-by: Claude Opus 4.6 --- .../chat/tools/agent_generator/dummy.py | 154 +++++ .../chat/tools/agent_generator/service.py | 57 +- .../backend/backend/util/settings.py | 4 + .../test/agent_generator/test_service.py | 1 + .../src/app/(platform)/copilot/hooks/Untitled | 10 - .../hooks/useLongRunningToolPolling.ts | 126 ++++ .../copilot/tools/CreateAgent/CreateAgent.tsx | 77 ++- .../components/MiniGame/MiniGame.tsx | 21 + .../components/MiniGame/useMiniGame.ts | 579 ++++++++++++++++++ .../app/(platform)/copilot/useCopilotPage.ts | 29 +- 10 files changed, 1019 insertions(+), 39 deletions(-) create mode 100644 autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/dummy.py delete mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/hooks/Untitled create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/hooks/useLongRunningToolPolling.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/components/MiniGame/MiniGame.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/components/MiniGame/useMiniGame.ts diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/dummy.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/dummy.py new file mode 100644 index 0000000000..cf0e76d3b3 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/dummy.py @@ -0,0 +1,154 @@ +"""Dummy Agent Generator for testing. + +Returns mock responses matching the format expected from the external service. +Enable via AGENTGENERATOR_USE_DUMMY=true in settings. + +WARNING: This is for testing only. Do not use in production. +""" + +import asyncio +import logging +import uuid +from typing import Any + +logger = logging.getLogger(__name__) + +# Dummy decomposition result (instructions type) +DUMMY_DECOMPOSITION_RESULT: dict[str, Any] = { + "type": "instructions", + "steps": [ + { + "description": "Get input from user", + "action": "input", + "block_name": "AgentInputBlock", + }, + { + "description": "Process the input", + "action": "process", + "block_name": "TextFormatterBlock", + }, + { + "description": "Return output to user", + "action": "output", + "block_name": "AgentOutputBlock", + }, + ], +} + +# Block IDs from backend/blocks/io.py +AGENT_INPUT_BLOCK_ID = "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b" +AGENT_OUTPUT_BLOCK_ID = "363ae599-353e-4804-937e-b2ee3cef3da4" + + +def _generate_dummy_agent_json() -> dict[str, Any]: + """Generate a minimal valid agent JSON for testing.""" + input_node_id = str(uuid.uuid4()) + output_node_id = str(uuid.uuid4()) + + return { + "id": str(uuid.uuid4()), + "version": 1, + "is_active": True, + "name": "Dummy Test Agent", + "description": "A dummy agent generated for testing purposes", + "nodes": [ + { + "id": input_node_id, + "block_id": AGENT_INPUT_BLOCK_ID, + "input_default": { + "name": "input", + "title": "Input", + "description": "Enter your input", + "placeholder_values": [], + }, + "metadata": {"position": {"x": 0, "y": 0}}, + }, + { + "id": output_node_id, + "block_id": AGENT_OUTPUT_BLOCK_ID, + "input_default": { + "name": "output", + "title": "Output", + "description": "Agent output", + "format": "{output}", + }, + "metadata": {"position": {"x": 400, "y": 0}}, + }, + ], + "links": [ + { + "id": str(uuid.uuid4()), + "source_id": input_node_id, + "sink_id": output_node_id, + "source_name": "result", + "sink_name": "value", + "is_static": False, + }, + ], + } + + +async def decompose_goal_dummy( + description: str, + context: str = "", + library_agents: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Return dummy decomposition result.""" + logger.info("Using dummy agent generator for decompose_goal") + return DUMMY_DECOMPOSITION_RESULT.copy() + + +async def generate_agent_dummy( + instructions: dict[str, Any], + library_agents: list[dict[str, Any]] | None = None, + operation_id: str | None = None, + task_id: str | None = None, +) -> dict[str, Any]: + """Return dummy agent JSON after a simulated delay.""" + logger.info("Using dummy agent generator for generate_agent (30s delay)") + await asyncio.sleep(30) + return _generate_dummy_agent_json() + + +async def generate_agent_patch_dummy( + update_request: str, + current_agent: dict[str, Any], + library_agents: list[dict[str, Any]] | None = None, + operation_id: str | None = None, + task_id: str | None = None, +) -> dict[str, Any]: + """Return dummy patched agent (returns the current agent with updated description).""" + logger.info("Using dummy agent generator for generate_agent_patch") + patched = current_agent.copy() + patched["description"] = ( + f"{current_agent.get('description', '')} (updated: {update_request})" + ) + return patched + + +async def customize_template_dummy( + template_agent: dict[str, Any], + modification_request: str, + context: str = "", +) -> dict[str, Any]: + """Return dummy customized template (returns template with updated description).""" + logger.info("Using dummy agent generator for customize_template") + customized = template_agent.copy() + customized["description"] = ( + f"{template_agent.get('description', '')} (customized: {modification_request})" + ) + return customized + + +async def get_blocks_dummy() -> list[dict[str, Any]]: + """Return dummy blocks list.""" + logger.info("Using dummy agent generator for get_blocks") + return [ + {"id": AGENT_INPUT_BLOCK_ID, "name": "AgentInputBlock"}, + {"id": AGENT_OUTPUT_BLOCK_ID, "name": "AgentOutputBlock"}, + ] + + +async def health_check_dummy() -> bool: + """Always returns healthy for dummy service.""" + return True diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py index 62411b4e1b..2b40c6d6f3 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py @@ -12,8 +12,19 @@ import httpx from backend.util.settings import Settings +from .dummy import ( + customize_template_dummy, + decompose_goal_dummy, + generate_agent_dummy, + generate_agent_patch_dummy, + get_blocks_dummy, + health_check_dummy, +) + logger = logging.getLogger(__name__) +_dummy_mode_warned = False + def _create_error_response( error_message: str, @@ -90,10 +101,26 @@ def _get_settings() -> Settings: return _settings -def is_external_service_configured() -> bool: - """Check if external Agent Generator service is configured.""" +def _is_dummy_mode() -> bool: + """Check if dummy mode is enabled for testing.""" + global _dummy_mode_warned settings = _get_settings() - return bool(settings.config.agentgenerator_host) + is_dummy = bool(settings.config.agentgenerator_use_dummy) + if is_dummy and not _dummy_mode_warned: + logger.warning( + "Agent Generator running in DUMMY MODE - returning mock responses. " + "Do not use in production!" + ) + _dummy_mode_warned = True + return is_dummy + + +def is_external_service_configured() -> bool: + """Check if external Agent Generator service is configured (or dummy mode).""" + settings = _get_settings() + return bool(settings.config.agentgenerator_host) or bool( + settings.config.agentgenerator_use_dummy + ) def _get_base_url() -> str: @@ -137,6 +164,9 @@ async def decompose_goal_external( - {"type": "error", "error": "...", "error_type": "..."} on error Or None on unexpected error """ + if _is_dummy_mode(): + return await decompose_goal_dummy(description, context, library_agents) + client = _get_client() if context: @@ -226,6 +256,11 @@ async def generate_agent_external( Returns: Agent JSON dict, {"status": "accepted"} for async, or error dict {"type": "error", ...} on error """ + if _is_dummy_mode(): + return await generate_agent_dummy( + instructions, library_agents, operation_id, task_id + ) + client = _get_client() # Build request payload @@ -297,6 +332,11 @@ async def generate_agent_patch_external( Returns: Updated agent JSON, clarifying questions dict, {"status": "accepted"} for async, or error dict on error """ + if _is_dummy_mode(): + return await generate_agent_patch_dummy( + update_request, current_agent, library_agents, operation_id, task_id + ) + client = _get_client() # Build request payload @@ -383,6 +423,11 @@ async def customize_template_external( Returns: Customized agent JSON, clarifying questions dict, or error dict on error """ + if _is_dummy_mode(): + return await customize_template_dummy( + template_agent, modification_request, context + ) + client = _get_client() request = modification_request @@ -445,6 +490,9 @@ async def get_blocks_external() -> list[dict[str, Any]] | None: Returns: List of block info dicts or None on error """ + if _is_dummy_mode(): + return await get_blocks_dummy() + client = _get_client() try: @@ -478,6 +526,9 @@ async def health_check() -> bool: if not is_external_service_configured(): return False + if _is_dummy_mode(): + return await health_check_dummy() + client = _get_client() try: diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index 50b7428160..48dadb88f1 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -368,6 +368,10 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): default=600, description="The timeout in seconds for Agent Generator service requests (includes retries for rate limits)", ) + agentgenerator_use_dummy: bool = Field( + default=False, + description="Use dummy agent generator responses for testing (bypasses external service)", + ) enable_example_blocks: bool = Field( default=False, diff --git a/autogpt_platform/backend/test/agent_generator/test_service.py b/autogpt_platform/backend/test/agent_generator/test_service.py index cc37c428c0..93c9b9dcc0 100644 --- a/autogpt_platform/backend/test/agent_generator/test_service.py +++ b/autogpt_platform/backend/test/agent_generator/test_service.py @@ -25,6 +25,7 @@ class TestServiceConfiguration: """Test that external service is not configured when host is empty.""" mock_settings = MagicMock() mock_settings.config.agentgenerator_host = "" + mock_settings.config.agentgenerator_use_dummy = False with patch.object(service, "_get_settings", return_value=mock_settings): assert service.is_external_service_configured() is False diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/hooks/Untitled b/autogpt_platform/frontend/src/app/(platform)/copilot/hooks/Untitled deleted file mode 100644 index 13769eb726..0000000000 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/hooks/Untitled +++ /dev/null @@ -1,10 +0,0 @@ -import { parseAsString, useQueryState } from "nuqs"; - -export function useCopilotSessionId() { - const [urlSessionId, setUrlSessionId] = useQueryState( - "sessionId", - parseAsString, - ); - - return { urlSessionId, setUrlSessionId }; -} \ No newline at end of file diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/hooks/useLongRunningToolPolling.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/hooks/useLongRunningToolPolling.ts new file mode 100644 index 0000000000..85ef6b2962 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/hooks/useLongRunningToolPolling.ts @@ -0,0 +1,126 @@ +import { getGetV2GetSessionQueryKey } from "@/app/api/__generated__/endpoints/chat/chat"; +import { useQueryClient } from "@tanstack/react-query"; +import type { UIDataTypes, UIMessage, UITools } from "ai"; +import { useCallback, useEffect, useRef } from "react"; +import { convertChatSessionMessagesToUiMessages } from "../helpers/convertChatSessionToUiMessages"; + +const OPERATING_TYPES = new Set([ + "operation_started", + "operation_pending", + "operation_in_progress", +]); + +const POLL_INTERVAL_MS = 1_500; + +/** + * Detects whether any message contains a tool part whose output indicates + * a long-running operation is still in progress. + */ +function hasOperatingTool( + messages: UIMessage[], +) { + for (const msg of messages) { + for (const part of msg.parts) { + if (!part.type.startsWith("tool-")) continue; + const toolPart = part as { output?: unknown }; + if (!toolPart.output) continue; + const output = + typeof toolPart.output === "string" + ? safeParse(toolPart.output) + : toolPart.output; + if ( + output && + typeof output === "object" && + "type" in output && + OPERATING_TYPES.has((output as { type: string }).type) + ) { + return true; + } + } + } + return false; +} + +function safeParse(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +/** + * Polls the session endpoint while any tool is in an "operating" state + * (operation_started / operation_pending / operation_in_progress). + * + * When the session data shows the tool output has changed (e.g. to + * agent_saved), it calls `setMessages` with the updated messages. + */ +export function useLongRunningToolPolling( + sessionId: string | null, + messages: UIMessage[], + setMessages: ( + updater: ( + prev: UIMessage[], + ) => UIMessage[], + ) => void, +) { + const queryClient = useQueryClient(); + const intervalRef = useRef | null>(null); + + const stopPolling = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const poll = useCallback(async () => { + if (!sessionId) return; + + // Invalidate the query cache so the next fetch gets fresh data + await queryClient.invalidateQueries({ + queryKey: getGetV2GetSessionQueryKey(sessionId), + }); + + // Fetch fresh session data + const data = queryClient.getQueryData<{ + status: number; + data: { messages?: unknown[] }; + }>(getGetV2GetSessionQueryKey(sessionId)); + + if (data?.status !== 200 || !data.data.messages) return; + + const freshMessages = convertChatSessionMessagesToUiMessages( + sessionId, + data.data.messages, + ); + + if (!freshMessages || freshMessages.length === 0) return; + + // Update when the long-running tool completed + if (!hasOperatingTool(freshMessages)) { + setMessages(() => freshMessages); + stopPolling(); + } + }, [sessionId, queryClient, setMessages, stopPolling]); + + useEffect(() => { + const shouldPoll = hasOperatingTool(messages); + + // Always clear any previous interval first so we never leak timers + // when the effect re-runs due to dependency changes (e.g. messages + // updating as the LLM streams text after the tool call). + stopPolling(); + + if (shouldPoll && sessionId) { + intervalRef.current = setInterval(() => { + poll(); + }, POLL_INTERVAL_MS); + } + + return () => { + stopPolling(); + }; + }, [messages, sessionId, poll, stopPolling]); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/CreateAgent.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/CreateAgent.tsx index 88b1c491d7..26977a207a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/CreateAgent.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/CreateAgent.tsx @@ -1,24 +1,30 @@ "use client"; -import { WarningDiamondIcon } from "@phosphor-icons/react"; +import { Button } from "@/components/atoms/Button/Button"; +import { Text } from "@/components/atoms/Text/Text"; +import { + BookOpenIcon, + CheckFatIcon, + PencilSimpleIcon, + WarningDiamondIcon, +} from "@phosphor-icons/react"; import type { ToolUIPart } from "ai"; +import NextLink from "next/link"; import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions"; import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation"; -import { ProgressBar } from "../../components/ProgressBar/ProgressBar"; import { ContentCardDescription, ContentCodeBlock, ContentGrid, ContentHint, - ContentLink, ContentMessage, } from "../../components/ToolAccordion/AccordionContent"; import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion"; -import { useAsymptoticProgress } from "../../hooks/useAsymptoticProgress"; import { ClarificationQuestionsCard, ClarifyingQuestion, } from "./components/ClarificationQuestionsCard"; +import { MiniGame } from "./components/MiniGame/MiniGame"; import { AccordionIcon, formatMaybeJson, @@ -52,7 +58,7 @@ function getAccordionMeta(output: CreateAgentToolOutput) { const icon = ; if (isAgentSavedOutput(output)) { - return { icon, title: output.agent_name }; + return { icon, title: output.agent_name, expanded: true }; } if (isAgentPreviewOutput(output)) { return { @@ -78,6 +84,7 @@ function getAccordionMeta(output: CreateAgentToolOutput) { return { icon, title: "Creating agent, this may take a few minutes. Sit back and relax.", + expanded: true, }; } return { @@ -107,8 +114,6 @@ export function CreateAgentTool({ part }: Props) { isOperationPendingOutput(output) || isOperationInProgressOutput(output)); - const progress = useAsymptoticProgress(isOperating); - const hasExpandableContent = part.state === "output-available" && !!output && @@ -152,31 +157,53 @@ export function CreateAgentTool({ part }: Props) { {isOperating && ( - + - This could take a few minutes, grab a coffee ☕ + This could take a few minutes — play while you wait! )} {isAgentSavedOutput(output) && ( - - {output.message} -
- - Open in library - - - Open in builder - +
+
+ + + {output.message} +
- - {truncateText( - formatMaybeJson({ agent_id: output.agent_id }), - 800, - )} - - +
+ + +
+
)} {isAgentPreviewOutput(output) && ( diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/components/MiniGame/MiniGame.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/components/MiniGame/MiniGame.tsx new file mode 100644 index 0000000000..53cfcf2731 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/components/MiniGame/MiniGame.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useMiniGame } from "./useMiniGame"; + +export function MiniGame() { + const { canvasRef } = useMiniGame(); + + return ( +
+ +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/components/MiniGame/useMiniGame.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/components/MiniGame/useMiniGame.ts new file mode 100644 index 0000000000..e91f1766ca --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/CreateAgent/components/MiniGame/useMiniGame.ts @@ -0,0 +1,579 @@ +import { useEffect, useRef } from "react"; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +const CANVAS_HEIGHT = 150; +const GRAVITY = 0.55; +const JUMP_FORCE = -9.5; +const BASE_SPEED = 3; +const SPEED_INCREMENT = 0.0008; +const SPAWN_MIN = 70; +const SPAWN_MAX = 130; +const CHAR_SIZE = 18; +const CHAR_X = 50; +const GROUND_PAD = 20; +const STORAGE_KEY = "copilot-minigame-highscore"; + +// Colors +const COLOR_BG = "#E8EAF6"; +const COLOR_CHAR = "#263238"; +const COLOR_BOSS = "#F50057"; + +// Boss +const BOSS_SIZE = 36; +const BOSS_ENTER_SPEED = 2; +const BOSS_LEAVE_SPEED = 3; +const BOSS_SHOOT_COOLDOWN = 90; +const BOSS_SHOTS_TO_EVADE = 5; +const BOSS_INTERVAL = 20; // every N score +const PROJ_SPEED = 4.5; +const PROJ_SIZE = 12; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface Obstacle { + x: number; + width: number; + height: number; + scored: boolean; +} + +interface Projectile { + x: number; + y: number; + speed: number; + evaded: boolean; + type: "low" | "high"; +} + +interface BossState { + phase: "inactive" | "entering" | "fighting" | "leaving"; + x: number; + targetX: number; + shotsEvaded: number; + cooldown: number; + projectiles: Projectile[]; + bob: number; +} + +interface GameState { + charY: number; + vy: number; + obstacles: Obstacle[]; + score: number; + highScore: number; + speed: number; + frame: number; + nextSpawn: number; + running: boolean; + over: boolean; + groundY: number; + boss: BossState; + bossThreshold: number; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function randInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function readHighScore(): number { + try { + return parseInt(localStorage.getItem(STORAGE_KEY) || "0", 10) || 0; + } catch { + return 0; + } +} + +function writeHighScore(score: number) { + try { + localStorage.setItem(STORAGE_KEY, String(score)); + } catch { + /* noop */ + } +} + +function makeBoss(): BossState { + return { + phase: "inactive", + x: 0, + targetX: 0, + shotsEvaded: 0, + cooldown: 0, + projectiles: [], + bob: 0, + }; +} + +function makeState(groundY: number): GameState { + return { + charY: groundY - CHAR_SIZE, + vy: 0, + obstacles: [], + score: 0, + highScore: readHighScore(), + speed: BASE_SPEED, + frame: 0, + nextSpawn: randInt(SPAWN_MIN, SPAWN_MAX), + running: false, + over: false, + groundY, + boss: makeBoss(), + bossThreshold: BOSS_INTERVAL, + }; +} + +function gameOver(s: GameState) { + s.running = false; + s.over = true; + if (s.score > s.highScore) { + s.highScore = s.score; + writeHighScore(s.score); + } +} + +/* ------------------------------------------------------------------ */ +/* Projectile collision — shared between fighting & leaving phases */ +/* ------------------------------------------------------------------ */ + +/** Returns true if the player died. */ +function tickProjectiles(s: GameState): boolean { + const boss = s.boss; + + for (const p of boss.projectiles) { + p.x -= p.speed; + + if (!p.evaded && p.x + PROJ_SIZE < CHAR_X) { + p.evaded = true; + boss.shotsEvaded++; + } + + // Collision + if ( + !p.evaded && + CHAR_X + CHAR_SIZE > p.x && + CHAR_X < p.x + PROJ_SIZE && + s.charY + CHAR_SIZE > p.y && + s.charY < p.y + PROJ_SIZE + ) { + gameOver(s); + return true; + } + } + + boss.projectiles = boss.projectiles.filter((p) => p.x + PROJ_SIZE > -20); + return false; +} + +/* ------------------------------------------------------------------ */ +/* Update */ +/* ------------------------------------------------------------------ */ + +function update(s: GameState, canvasWidth: number) { + if (!s.running) return; + + s.frame++; + + // Speed only ramps during regular play + if (s.boss.phase === "inactive") { + s.speed = BASE_SPEED + s.frame * SPEED_INCREMENT; + } + + // ---- Character physics (always active) ---- // + s.vy += GRAVITY; + s.charY += s.vy; + if (s.charY + CHAR_SIZE >= s.groundY) { + s.charY = s.groundY - CHAR_SIZE; + s.vy = 0; + } + + // ---- Trigger boss ---- // + if (s.boss.phase === "inactive" && s.score >= s.bossThreshold) { + s.boss.phase = "entering"; + s.boss.x = canvasWidth + 10; + s.boss.targetX = canvasWidth - BOSS_SIZE - 40; + s.boss.shotsEvaded = 0; + s.boss.cooldown = BOSS_SHOOT_COOLDOWN; + s.boss.projectiles = []; + s.obstacles = []; + } + + // ---- Boss: entering ---- // + if (s.boss.phase === "entering") { + s.boss.bob = Math.sin(s.frame * 0.05) * 3; + s.boss.x -= BOSS_ENTER_SPEED; + if (s.boss.x <= s.boss.targetX) { + s.boss.x = s.boss.targetX; + s.boss.phase = "fighting"; + } + return; // no obstacles while entering + } + + // ---- Boss: fighting ---- // + if (s.boss.phase === "fighting") { + s.boss.bob = Math.sin(s.frame * 0.05) * 3; + + // Shoot + s.boss.cooldown--; + if (s.boss.cooldown <= 0) { + const isLow = Math.random() < 0.5; + s.boss.projectiles.push({ + x: s.boss.x - PROJ_SIZE, + y: isLow ? s.groundY - 14 : s.groundY - 70, + speed: PROJ_SPEED, + evaded: false, + type: isLow ? "low" : "high", + }); + s.boss.cooldown = BOSS_SHOOT_COOLDOWN; + } + + if (tickProjectiles(s)) return; + + // Boss defeated? + if (s.boss.shotsEvaded >= BOSS_SHOTS_TO_EVADE) { + s.boss.phase = "leaving"; + s.score += 5; // bonus + s.bossThreshold = s.score + BOSS_INTERVAL; + } + return; + } + + // ---- Boss: leaving ---- // + if (s.boss.phase === "leaving") { + s.boss.bob = Math.sin(s.frame * 0.05) * 3; + s.boss.x += BOSS_LEAVE_SPEED; + + // Still check in-flight projectiles + if (tickProjectiles(s)) return; + + if (s.boss.x > canvasWidth + 50) { + s.boss = makeBoss(); + s.nextSpawn = s.frame + randInt(SPAWN_MIN / 2, SPAWN_MAX / 2); + } + return; + } + + // ---- Regular obstacle play ---- // + if (s.frame >= s.nextSpawn) { + s.obstacles.push({ + x: canvasWidth + 10, + width: randInt(10, 16), + height: randInt(20, 48), + scored: false, + }); + s.nextSpawn = s.frame + randInt(SPAWN_MIN, SPAWN_MAX); + } + + for (const o of s.obstacles) { + o.x -= s.speed; + if (!o.scored && o.x + o.width < CHAR_X) { + o.scored = true; + s.score++; + } + } + + s.obstacles = s.obstacles.filter((o) => o.x + o.width > -20); + + for (const o of s.obstacles) { + const oY = s.groundY - o.height; + if ( + CHAR_X + CHAR_SIZE > o.x && + CHAR_X < o.x + o.width && + s.charY + CHAR_SIZE > oY + ) { + gameOver(s); + return; + } + } +} + +/* ------------------------------------------------------------------ */ +/* Drawing */ +/* ------------------------------------------------------------------ */ + +function drawBoss(ctx: CanvasRenderingContext2D, s: GameState, bg: string) { + const bx = s.boss.x; + const by = s.groundY - BOSS_SIZE + s.boss.bob; + + // Body + ctx.save(); + ctx.fillStyle = COLOR_BOSS; + ctx.globalAlpha = 0.9; + ctx.beginPath(); + ctx.roundRect(bx, by, BOSS_SIZE, BOSS_SIZE, 4); + ctx.fill(); + ctx.restore(); + + // Eyes + ctx.save(); + ctx.fillStyle = bg; + const eyeY = by + 13; + ctx.beginPath(); + ctx.arc(bx + 10, eyeY, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(bx + 26, eyeY, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + // Angry eyebrows + ctx.save(); + ctx.strokeStyle = bg; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(bx + 5, eyeY - 7); + ctx.lineTo(bx + 14, eyeY - 4); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(bx + 31, eyeY - 7); + ctx.lineTo(bx + 22, eyeY - 4); + ctx.stroke(); + ctx.restore(); + + // Zigzag mouth + ctx.save(); + ctx.strokeStyle = bg; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(bx + 10, by + 27); + ctx.lineTo(bx + 14, by + 24); + ctx.lineTo(bx + 18, by + 27); + ctx.lineTo(bx + 22, by + 24); + ctx.lineTo(bx + 26, by + 27); + ctx.stroke(); + ctx.restore(); +} + +function drawProjectiles(ctx: CanvasRenderingContext2D, boss: BossState) { + ctx.save(); + ctx.fillStyle = COLOR_BOSS; + ctx.globalAlpha = 0.8; + for (const p of boss.projectiles) { + if (p.evaded) continue; + ctx.beginPath(); + ctx.arc( + p.x + PROJ_SIZE / 2, + p.y + PROJ_SIZE / 2, + PROJ_SIZE / 2, + 0, + Math.PI * 2, + ); + ctx.fill(); + } + ctx.restore(); +} + +function draw( + ctx: CanvasRenderingContext2D, + s: GameState, + w: number, + h: number, + fg: string, + started: boolean, +) { + ctx.fillStyle = COLOR_BG; + ctx.fillRect(0, 0, w, h); + + // Ground + ctx.save(); + ctx.strokeStyle = fg; + ctx.globalAlpha = 0.15; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, s.groundY); + ctx.lineTo(w, s.groundY); + ctx.stroke(); + ctx.restore(); + + // Character + ctx.save(); + ctx.fillStyle = COLOR_CHAR; + ctx.globalAlpha = 0.85; + ctx.beginPath(); + ctx.roundRect(CHAR_X, s.charY, CHAR_SIZE, CHAR_SIZE, 3); + ctx.fill(); + ctx.restore(); + + // Eyes + ctx.save(); + ctx.fillStyle = COLOR_BG; + ctx.beginPath(); + ctx.arc(CHAR_X + 6, s.charY + 7, 2.5, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(CHAR_X + 12, s.charY + 7, 2.5, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + // Obstacles + ctx.save(); + ctx.fillStyle = fg; + ctx.globalAlpha = 0.55; + for (const o of s.obstacles) { + ctx.fillRect(o.x, s.groundY - o.height, o.width, o.height); + } + ctx.restore(); + + // Boss + projectiles + if (s.boss.phase !== "inactive") { + drawBoss(ctx, s, COLOR_BG); + drawProjectiles(ctx, s.boss); + } + + // Score HUD + ctx.save(); + ctx.fillStyle = fg; + ctx.globalAlpha = 0.5; + ctx.font = "bold 11px monospace"; + ctx.textAlign = "right"; + ctx.fillText(`Score: ${s.score}`, w - 12, 20); + ctx.fillText(`Best: ${s.highScore}`, w - 12, 34); + if (s.boss.phase === "fighting") { + ctx.fillText( + `Evade: ${s.boss.shotsEvaded}/${BOSS_SHOTS_TO_EVADE}`, + w - 12, + 48, + ); + } + ctx.restore(); + + // Prompts + if (!started && !s.running && !s.over) { + ctx.save(); + ctx.fillStyle = fg; + ctx.globalAlpha = 0.5; + ctx.font = "12px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("Click or press Space to play while you wait", w / 2, h / 2); + ctx.restore(); + } + + if (s.over) { + ctx.save(); + ctx.fillStyle = fg; + ctx.globalAlpha = 0.7; + ctx.font = "bold 13px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("Game Over", w / 2, h / 2 - 8); + ctx.font = "11px sans-serif"; + ctx.fillText("Click or Space to restart", w / 2, h / 2 + 10); + ctx.restore(); + } +} + +/* ------------------------------------------------------------------ */ +/* Hook */ +/* ------------------------------------------------------------------ */ + +export function useMiniGame() { + const canvasRef = useRef(null); + const stateRef = useRef(null); + const rafRef = useRef(0); + const startedRef = useRef(false); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const container = canvas.parentElement; + if (container) { + canvas.width = container.clientWidth; + canvas.height = CANVAS_HEIGHT; + } + + const groundY = canvas.height - GROUND_PAD; + stateRef.current = makeState(groundY); + + const style = getComputedStyle(canvas); + let fg = style.color || "#71717a"; + + // -------------------------------------------------------------- // + // Jump // + // -------------------------------------------------------------- // + function jump() { + const s = stateRef.current; + if (!s) return; + + if (s.over) { + const hs = s.highScore; + const gy = s.groundY; + stateRef.current = makeState(gy); + stateRef.current.highScore = hs; + stateRef.current.running = true; + startedRef.current = true; + return; + } + + if (!s.running) { + s.running = true; + startedRef.current = true; + return; + } + + // Only jump when on the ground + if (s.charY + CHAR_SIZE >= s.groundY) { + s.vy = JUMP_FORCE; + } + } + + function onKey(e: KeyboardEvent) { + if (e.code === "Space" || e.key === " ") { + e.preventDefault(); + jump(); + } + } + + function onClick() { + canvas?.focus(); + jump(); + } + + // -------------------------------------------------------------- // + // Loop // + // -------------------------------------------------------------- // + function loop() { + const s = stateRef.current; + if (!canvas || !s) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + update(s, canvas.width); + draw(ctx, s, canvas.width, canvas.height, fg, startedRef.current); + rafRef.current = requestAnimationFrame(loop); + } + + rafRef.current = requestAnimationFrame(loop); + + canvas.addEventListener("click", onClick); + canvas.addEventListener("keydown", onKey); + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + canvas.width = entry.contentRect.width; + canvas.height = CANVAS_HEIGHT; + if (stateRef.current) { + stateRef.current.groundY = canvas.height - GROUND_PAD; + } + const cs = getComputedStyle(canvas); + fg = cs.color || fg; + } + }); + if (container) observer.observe(container); + + return () => { + cancelAnimationFrame(rafRef.current); + canvas.removeEventListener("click", onClick); + canvas.removeEventListener("keydown", onKey); + observer.disconnect(); + }; + }, []); + + return { canvasRef }; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index 3dbba6e790..28e9ba7cfb 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -1,10 +1,14 @@ import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat"; +import { toast } from "@/components/molecules/Toast/use-toast"; import { useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useChatSession } from "./useChatSession"; +import { useLongRunningToolPolling } from "./hooks/useLongRunningToolPolling"; + +const STREAM_START_TIMEOUT_MS = 12_000; export function useCopilotPage() { const { isUserLoading, isLoggedIn } = useSupabase(); @@ -52,6 +56,24 @@ export function useCopilotPage() { transport: transport ?? undefined, }); + // Abort the stream if the backend doesn't start sending data within 12s. + const stopRef = useRef(stop); + stopRef.current = stop; + useEffect(() => { + if (status !== "submitted") return; + + const timer = setTimeout(() => { + stopRef.current(); + toast({ + title: "Stream timed out", + description: "The server took too long to respond. Please try again.", + variant: "destructive", + }); + }, STREAM_START_TIMEOUT_MS); + + return () => clearTimeout(timer); + }, [status]); + useEffect(() => { if (!hydratedMessages || hydratedMessages.length === 0) return; setMessages((prev) => { @@ -60,6 +82,11 @@ export function useCopilotPage() { }); }, [hydratedMessages, setMessages]); + // Poll session endpoint when a long-running tool (create_agent, edit_agent) + // is in progress. When the backend completes, the session data will contain + // the final tool output — this hook detects the change and updates messages. + useLongRunningToolPolling(sessionId, messages, setMessages); + // Clear messages when session is null useEffect(() => { if (!sessionId) setMessages([]);