mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-13 08:14:58 -05:00
chore: fixes
This commit is contained in:
@@ -4,6 +4,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
from backend.api.features.library.db import add_generated_agent_image
|
||||
|
||||
from .agent_generator import (
|
||||
AgentGeneratorNotConfiguredError,
|
||||
@@ -317,6 +318,19 @@ class CreateAgentTool(BaseTool):
|
||||
agent_json, user_id
|
||||
)
|
||||
|
||||
# Generate image before returning so the URL is available in the response
|
||||
image_url: str | None = None
|
||||
try:
|
||||
updated_agent = await add_generated_agent_image(
|
||||
graph=created_graph,
|
||||
user_id=user_id,
|
||||
library_agent_id=library_agent.id,
|
||||
)
|
||||
if updated_agent and updated_agent.imageUrl:
|
||||
image_url = updated_agent.imageUrl
|
||||
except Exception as e:
|
||||
logger.warning(f"Image generation failed, continuing without: {e}")
|
||||
|
||||
return AgentSavedResponse(
|
||||
message=f"Agent '{created_graph.name}' has been saved to your library!",
|
||||
agent_id=created_graph.id,
|
||||
@@ -324,6 +338,7 @@ class CreateAgentTool(BaseTool):
|
||||
library_agent_id=library_agent.id,
|
||||
library_agent_link=f"/library/agents/{library_agent.id}",
|
||||
agent_page_link=f"/build?flowID={created_graph.id}",
|
||||
image_url=image_url,
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -277,6 +277,7 @@ class AgentSavedResponse(ToolResponseBase):
|
||||
library_agent_id: str
|
||||
library_agent_link: str
|
||||
agent_page_link: str # Link to the agent builder/editor page
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class ClarificationNeededResponse(ToolResponseBase):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getGetV2GetSessionQueryKey } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getV2GetSession } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import type { UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { convertChatSessionMessagesToUiMessages } from "../helpers/convertChatSessionToUiMessages";
|
||||
@@ -18,7 +17,7 @@ const POLL_INTERVAL_MS = 1_500;
|
||||
*/
|
||||
function hasOperatingTool(
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[],
|
||||
): boolean {
|
||||
) {
|
||||
for (const msg of messages) {
|
||||
for (const part of msg.parts) {
|
||||
if (!part.type.startsWith("tool-")) continue;
|
||||
@@ -55,6 +54,9 @@ function safeParse(value: string): unknown {
|
||||
*
|
||||
* When the session data shows the tool output has changed (e.g. to
|
||||
* agent_saved), it calls `setMessages` with the updated messages.
|
||||
*
|
||||
* Fetches session data directly (bypassing the shared React Query cache)
|
||||
* so that polling never triggers the hydration effect in useCopilotPage.
|
||||
*/
|
||||
export function useLongRunningToolPolling(
|
||||
sessionId: string | null,
|
||||
@@ -65,8 +67,6 @@ export function useLongRunningToolPolling(
|
||||
) => UIMessage<unknown, UIDataTypes, UITools>[],
|
||||
) => void,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const isPollingRef = useRef(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
@@ -74,58 +74,56 @@ export function useLongRunningToolPolling(
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
isPollingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
// Invalidate the query cache so the next fetch gets fresh data
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetSessionQueryKey(sessionId),
|
||||
});
|
||||
try {
|
||||
// Fetch directly instead of refetching the shared session query.
|
||||
// Using refetchQueries updated the React Query cache which triggered
|
||||
// the hydration effect in useCopilotPage, potentially overwriting
|
||||
// useChat's internal message state and breaking subsequent sends.
|
||||
const response = await getV2GetSession(sessionId);
|
||||
|
||||
// Fetch fresh session data
|
||||
const data = queryClient.getQueryData<{
|
||||
status: number;
|
||||
data: { messages?: unknown[] };
|
||||
}>(getGetV2GetSessionQueryKey(sessionId));
|
||||
if (response.status !== 200) return;
|
||||
|
||||
if (data?.status !== 200 || !data.data.messages) return;
|
||||
const data = response.data as { messages?: unknown[] };
|
||||
if (!data.messages) return;
|
||||
|
||||
const freshMessages = convertChatSessionMessagesToUiMessages(
|
||||
sessionId,
|
||||
data.data.messages,
|
||||
);
|
||||
const freshMessages = convertChatSessionMessagesToUiMessages(
|
||||
sessionId,
|
||||
data.messages,
|
||||
);
|
||||
|
||||
if (!freshMessages || freshMessages.length === 0) return;
|
||||
if (!freshMessages || freshMessages.length === 0) return;
|
||||
|
||||
// Only update if the fresh data no longer has operating tools
|
||||
// (meaning the long-running tool completed)
|
||||
if (!hasOperatingTool(freshMessages)) {
|
||||
setMessages(() => freshMessages);
|
||||
stopPolling();
|
||||
// Update when the long-running tool completed
|
||||
if (!hasOperatingTool(freshMessages)) {
|
||||
setMessages(() => freshMessages);
|
||||
stopPolling();
|
||||
}
|
||||
} catch {
|
||||
// Network error — ignore and retry on next interval
|
||||
}
|
||||
}, [sessionId, queryClient, setMessages, stopPolling]);
|
||||
}, [sessionId, setMessages, stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldPoll = hasOperatingTool(messages);
|
||||
|
||||
if (shouldPoll && !isPollingRef.current && sessionId) {
|
||||
isPollingRef.current = true;
|
||||
// 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);
|
||||
} else if (!shouldPoll && isPollingRef.current) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount or dependency change
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
stopPolling();
|
||||
};
|
||||
}, [messages, sessionId, poll, stopPolling]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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,61 @@ export function CreateAgentTool({ part }: Props) {
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isOperating && (
|
||||
<ContentGrid>
|
||||
<ProgressBar value={progress} className="max-w-[280px]" />
|
||||
<MiniGame />
|
||||
<ContentHint>
|
||||
This could take a few minutes, grab a coffee ☕
|
||||
This could take a few minutes — play while you wait!
|
||||
</ContentHint>
|
||||
</ContentGrid>
|
||||
)}
|
||||
|
||||
{isAgentSavedOutput(output) && (
|
||||
<ContentGrid>
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ContentLink href={output.library_agent_link}>
|
||||
Open in library
|
||||
</ContentLink>
|
||||
<ContentLink href={output.agent_page_link}>
|
||||
Open in builder
|
||||
</ContentLink>
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
|
||||
{output.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={output.image_url}
|
||||
alt={output.agent_name}
|
||||
className="mb-3 h-40 w-full rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<CheckFatIcon
|
||||
size={18}
|
||||
weight="regular"
|
||||
className="relative top-1 text-green-500"
|
||||
/>
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="text-blacks mb-2 text-[16px]"
|
||||
>
|
||||
{output.message}
|
||||
</Text>
|
||||
</div>
|
||||
<ContentCodeBlock>
|
||||
{truncateText(
|
||||
formatMaybeJson({ agent_id: output.agent_id }),
|
||||
800,
|
||||
)}
|
||||
</ContentCodeBlock>
|
||||
</ContentGrid>
|
||||
<div className="mt-3 flex flex-wrap gap-4">
|
||||
<Button variant="outline" size="small">
|
||||
<NextLink
|
||||
href={output.library_agent_link}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpenIcon size={14} weight="regular" />
|
||||
Open in library
|
||||
</NextLink>
|
||||
</Button>
|
||||
<Button variant="outline" size="small">
|
||||
<NextLink
|
||||
href={output.agent_page_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<PencilSimpleIcon size={14} weight="regular" />
|
||||
Open in builder
|
||||
</NextLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAgentPreviewOutput(output) && (
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useMiniGame } from "./useMiniGame";
|
||||
|
||||
export function MiniGame() {
|
||||
const { canvasRef } = useMiniGame();
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md bg-background text-foreground">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="block w-full"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
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 = "#E0F2F19C";
|
||||
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<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<GameState | null>(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() {
|
||||
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);
|
||||
window.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);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { canvasRef };
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
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();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
@@ -53,6 +56,25 @@ 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) => {
|
||||
|
||||
@@ -6642,7 +6642,11 @@
|
||||
"type": "string",
|
||||
"title": "Library Agent Link"
|
||||
},
|
||||
"agent_page_link": { "type": "string", "title": "Agent Page Link" }
|
||||
"agent_page_link": { "type": "string", "title": "Agent Page Link" },
|
||||
"image_url": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Image Url"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
Reference in New Issue
Block a user