Compare commits

..

37 Commits

Author SHA1 Message Date
Nicholas Tindle
3610be3e83 Merge branch 'dev' into ntindle/waitlist 2026-01-20 17:47:02 -06:00
Nicholas Tindle
9e1f7c9415 Merge branch 'dev' into ntindle/waitlist 2026-01-19 01:12:14 -06:00
Nicholas Tindle
0d03ebb43c fix: lint 2026-01-16 11:34:00 -06:00
Nicholas Tindle
1b37bd6da9 Merge branch 'dev' into ntindle/waitlist 2026-01-16 11:32:05 -06:00
Nicholas Tindle
db989a5eed fix: lint 2026-01-15 15:58:33 -06:00
Nicholas Tindle
e3a8c57a35 Merge branch 'dev' into ntindle/waitlist
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:54:38 -06:00
Nicholas Tindle
dfc8e53386 fix(backend): add assertions to fix type errors in waitlist admin functions
Prisma's update() returns T | None but we verify existence before updating,
so assert the result is not None to satisfy the type checker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:48:30 -06:00
Nicholas Tindle
b5b7e5da92 fix(backend): don't mark waitlist DONE if email-only users pending
The notify_waitlist_users_on_launch function was marking waitlists as
DONE after notifying registered users, but ignoring unaffiliatedEmailUsers
who haven't been notified yet. Since DONE waitlists are excluded from
future notification queries, those email users would never receive
notifications when that functionality is implemented.

Now the waitlist remains in an active state if there are pending
email-only signups that still need notifications.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:41:31 -06:00
Nicholas Tindle
07ea2c2ab7 fix(backend): check waitlist existence before update in update_waitlist_admin
Added find_unique check before update() call to properly return 404 when
waitlist doesn't exist, following the established pattern used in other
waitlist admin functions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:37:19 -06:00
Nicholas Tindle
9c873a0158 fix(backend): add exception handling to add_self_to_waitlist route
The public waitlist join route was missing exception handling, causing
500 errors for all failures. Now properly returns:
- 404 for waitlist not found
- 400 for closed/unavailable waitlists
- 500 for unexpected errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:30:54 -06:00
Nicholas Tindle
ed634db8f7 fix(backend): validate waitlist status enum at API boundary
Changed WaitlistUpdateRequest.status from str to the actual enum type.
Pydantic now validates the status value, returning 422 for invalid
values instead of a misleading 404 "Waitlist not found" error.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:26:26 -06:00
Nicholas Tindle
398197f3ea fix(frontend): add title attribute to YouTube iframe for accessibility
Screen readers need a title attribute on iframes to describe their
content. Added "YouTube video player" title to the embedded video.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:23:52 -06:00
Nicholas Tindle
b7df4cfdbf fix(backend): align migration FK with schema (SET NULL not CASCADE)
The migration had ON DELETE CASCADE for WaitlistEntry.storeListingId,
but the Prisma schema specifies onDelete: SetNull. This mismatch would
cause waitlist entries and all signup data to be deleted when a store
listing is removed, instead of just unlinking them.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:18:03 -06:00
Nicholas Tindle
5d8dd46759 fix(backend): align waitlist admin functions with established patterns
- delete_waitlist_admin: add find_unique check before update, raise
  ValueError if not found, add except ValueError: raise
- link_waitlist_to_listing_admin: add find_unique check for waitlist
  before update, remove dead code
- delete_waitlist route: add except ValueError: → 404, remove dead
  code bool check pattern

All waitlist admin functions now follow the consistent pattern:
1. find_unique to check existence
2. raise ValueError if not found
3. except ValueError: raise to bubble up
4. except Exception: raise DatabaseError

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:54:53 -06:00
Nicholas Tindle
f9518b6f8b fix(frontend): use generated query key for waitlist cache invalidation
The hardcoded query key string didn't match the actual generated key,
causing cache invalidation to fail after joining a waitlist. Now uses
the generated getGetV2GetWaitlistIdsTheCurrentUserHasJoinedQueryKey()
function for correct cache invalidation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:44:35 -06:00
Nicholas Tindle
205b220e90 fix(backend): filter out DONE/CANCELED waitlists before sending notifications
The notify_waitlist_users_on_launch function was not filtering by
waitlist status, which could cause duplicate notifications when an
agent is re-approved. Now excludes DONE and CANCELED waitlists,
consistent with get_waitlist() and add_user_to_waitlist().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:41:37 -06:00
Nicholas Tindle
29a232fcb4 fix(frontend): add URL validation and sandbox to video player
- Add getYouTubeVideoId() to extract video IDs from YouTube URLs
- Add isValidVideoUrl() to validate video URLs before rendering
- Create VideoPlayer component that:
  - Embeds YouTube videos via iframe with safe embed URL
  - Adds sandbox attribute to restrict iframe capabilities
  - Adds proper allow attributes for media playback
  - Falls back to native video element for valid non-YouTube URLs
  - Shows error state for invalid URLs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:29:10 -06:00
Nicholas Tindle
a53f261812 feat(frontend): add TODO warning for email-only waitlist notifications
Adds a warning banner on the admin waitlist page indicating that
notifications for email-only signups (non-logged-in users) have not
been implemented yet.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:05:21 -06:00
Nicholas Tindle
00a20f77be feat(backend): add waitlist_launch email notification template
The WAITLIST_LAUNCH notification type was referencing a template that
didn't exist, causing FileNotFoundError when trying to notify users
that an agent they waitlisted has launched.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:38:04 -06:00
Nicholas Tindle
4d49536a40 Discard changes to autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts 2026-01-12 15:28:37 -07:00
Nicholas Tindle
6028a2528c refactor(frontend): consolidate waitlist modals and align with Figma design
- Merge JoinWaitlistModal into WaitlistDetailModal for unified experience
- Add MediaCarousel component supporting videos and images with play overlay
- Update WaitlistCard styling to match Figma (rounded-large, line-clamp-5, zinc-800 button)
- Update success state with party emoji and Close button per Figma design
- Add sticky footer for buttons during modal scroll
- Support email input for non-logged-in users

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:27:09 -06:00
Nicholas Tindle
b31cd05675 fix(backend): correct typo in unaffiliatedEmailUsers field name
- Rename unafilliatedEmailUsers -> unaffiliatedEmailUsers in schema.prisma
- Update migration SQL to use correct column name
- Update all references in db.py and model.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:33:38 -06:00
Nicholas Tindle
128366772f refactor(backend): remove apscheduler tables from prisma schema
- Remove apscheduler_jobs and apscheduler_jobs_batched_notifications models
- Delete migration 20260107000001_add_apscheduler_tables
- Remove index rename statements from waitlist migration

APScheduler tables are managed at runtime by APScheduler itself and
should not be part of the Prisma schema.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:29:17 -06:00
Nicholas Tindle
764cdf17fe refactor(frontend): migrate waitlist admin components to generated API hooks
- Convert WaitlistTable to use generated React Query hooks directly
- Convert CreateWaitlistButton to use generated hooks
- Update WaitlistDetailModal to use generated types and design system Dialog
- Remove deprecated waitlist types from types.ts
- Remove deprecated waitlist methods from BackendAPI client
- Delete actions.ts server actions (no longer needed)
- Replace lucide-react icons with Phosphor icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:26:34 -06:00
Nicholas Tindle
1dd83b4cf8 fix(frontend): add text color to status badge fallback in WaitlistTable
Ensures unknown status values have readable text contrast by adding
text-gray-700 to the fallback className.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:09:44 -06:00
Nicholas Tindle
24a34f7ce5 Merge branch 'dev' into ntindle/waitlist 2026-01-12 14:08:48 -07:00
Nicholas Tindle
20fe2c3877 fix(backend): remove PII-exposing fields from public waitlist model
Remove `owner` (User type) and `storeListing` (StoreListingWithVersions)
fields from StoreWaitlistEntry. These fields were never populated but
exposed PII types (email, stripe_customer_id, etc.) in the OpenAPI schema.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 14:52:51 -06:00
Nicholas Tindle
738c7e2bef fix(platform): address remaining PR review feedback for waitlist
Backend fixes:
- Fix optional field clearing by using model_fields_set
- Re-fetch waitlist data after join operation
- Only mark waitlist as DONE if all notifications succeed
- Fix race condition in email removal with transaction
- Rename waitlist_id to waitlistId for naming consistency

Frontend fixes:
- Migrate useWaitlistSection to generated API hooks
- Migrate JoinWaitlistModal to design system + generated hooks
- Migrate WaitlistSignupsDialog to design system + generated hooks
- Replace lucide-react icons with Phosphor in WaitlistTable
- Add proper error state in WaitlistSignupsDialog
- Update waitlistId naming across components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 14:43:10 -06:00
Nicholas Tindle
9edfe0fb97 refactor(frontend): migrate EditWaitlistDialog to design system and generated API
- Replace legacy Dialog components with molecules/Dialog
- Replace legacy Input/Label/Textarea with atoms/Input
- Replace legacy Select with atoms/Select
- Replace @/lib/autogpt-server-api/types with @/app/api/__generated__/models
- Replace updateWaitlist action with usePutV2UpdateWaitlist hook
- Remove dependency on BackendAPI in favor of generated React Query hooks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:49:35 -07:00
Nicholas Tindle
4aabe71001 fix(platform): address PR review feedback for waitlist feature
Backend fixes:
- Fix creator_username null check in store URL construction
- Add embed=True to link_waitlist_to_listing endpoint body param
- Fix race condition in email list with transaction wrapper
- Replace str(e) with generic error messages in admin ValueError handlers
- Add validation requiring user_id or email in waitlist join
- Configure WAITLIST_LAUNCH in notification system (data type, queue, template, subject)
- Change StoreListing cascade delete to SetNull to preserve waitlist data

Frontend fixes:
- Escape internal quotes in CSV export for proper RFC 4180 compliance
- Remove incorrect 'use server' directive from page.tsx
- Replace lucide-react Check icon with Phosphor Icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:40:35 -07:00
Nicholas Tindle
b3999669f2 refactor(platform): simplify waitlist code and remove type duplication
- Backend: Extract _waitlist_to_store_entry helper to reduce duplication
- Backend: Use dict comprehension in update_waitlist_admin for cleaner code
- Frontend: Import types directly from shared types file instead of re-exporting
- Frontend: Remove redundant isMember check in WaitlistCard handleJoinClick

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:25:27 -07:00
Swifty
8c45a5ee98 Merge branch 'dev' into ntindle/waitlist 2026-01-08 12:38:46 +01:00
Nicholas Tindle
4b654c7e9f fix(frontend): Fix lint and type errors in waitlist admin components
- Remove unused WaitlistSignup import
- Change button size from "sm" to "small"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:48:53 -07:00
Nicholas Tindle
8d82e3b633 fix(backend): Use Prisma connect pattern for waitlist-listing relation
Use StoreListing relation with connect pattern instead of directly
setting storeListingId, which doesn't work with Prisma's typed update.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:01:18 -07:00
Nicholas Tindle
d4ecdb64ed feat(platform): Show "On the waitlist" status for joined users
- Add GET /api/store/waitlist/my-memberships endpoint to fetch user's joined waitlists
- Add get_user_waitlist_memberships() db function
- Update useWaitlistSection hook to fetch memberships when logged in
- Update WaitlistCard to show green "On the waitlist" button for members
- Update WaitlistDetailModal to show member status
- Add onSuccess callback to JoinWaitlistModal for optimistic UI updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:15:03 -07:00
Nicholas Tindle
a73fb8f114 feat(platform): Add waitlist feature with admin management and user notifications
Backend:
- Add waitlist admin API routes for CRUD operations
- Add admin functions for waitlist management (create, update, delete, list)
- Add WaitlistLaunchData notification type for user notifications
- Integrate waitlist notifications into store submission approval flow
- Auto-notify waitlist users when linked agent is approved

Frontend:
- Add admin waitlist management page with table, create/edit dialogs
- Add WaitlistSection component to marketplace homepage
- Add WaitlistCard, WaitlistDetailModal, JoinWaitlistModal components
- Add API client methods and types for waitlist operations

Database:
- Add WAITLIST_LAUNCH notification type enum
- Add baseline migration for APScheduler tables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:38:15 -07:00
Nicholas Tindle
2c60aa64ef wip: adding waitlist 2026-01-06 22:13:35 -07:00
221 changed files with 5628 additions and 8431 deletions

View File

@@ -16,32 +16,6 @@ See `docs/content/platform/getting-started.md` for setup instructions.
- Format Python code with `poetry run format`. - Format Python code with `poetry run format`.
- Format frontend code using `pnpm format`. - Format frontend code using `pnpm format`.
## Frontend guidelines:
See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference:
1. **Pages**: Create in `src/app/(platform)/feature-name/page.tsx`
- Add `usePageName.ts` hook for logic
- Put sub-components in local `components/` folder
2. **Components**: Structure as `ComponentName/ComponentName.tsx` + `useComponentName.ts` + `helpers.ts`
- Use design system components from `src/components/` (atoms, molecules, organisms)
- Never use `src/components/__legacy__/*`
3. **Data fetching**: Use generated API hooks from `@/app/api/__generated__/endpoints/`
- Regenerate with `pnpm generate:api`
- Pattern: `use{Method}{Version}{OperationName}`
4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only
5. **Testing**: Add Storybook stories for new components, Playwright for E2E
6. **Code conventions**: Function declarations (not arrow functions) for components/handlers
- Component props should be `interface Props { ... }` (not exported) unless the interface needs to be used outside the component
- Separate render logic from business logic (component.tsx + useComponent.ts + helpers.ts)
- Colocate state when possible and avoid creating large components, use sub-components ( local `/components` folder next to the parent component ) when sensible
- Avoid large hooks, abstract logic into `helpers.ts` files when sensible
- Use function declarations for components, arrow functions only for callbacks
- No barrel files or `index.ts` re-exports
- Do not use `useCallback` or `useMemo` unless strictly needed
- Avoid comments at all times unless the code is very complex
## Testing ## Testing
- Backend: `poetry run test` (runs pytest with a docker based postgres + prisma). - Backend: `poetry run test` (runs pytest with a docker based postgres + prisma).

View File

@@ -201,7 +201,7 @@ If you get any pushback or hit complex block conditions check the new_blocks gui
3. Write tests alongside the route file 3. Write tests alongside the route file
4. Run `poetry run test` to verify 4. Run `poetry run test` to verify
### Frontend guidelines: **Frontend feature development:**
See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference: See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference:
@@ -217,14 +217,6 @@ See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference:
4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only 4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only
5. **Testing**: Add Storybook stories for new components, Playwright for E2E 5. **Testing**: Add Storybook stories for new components, Playwright for E2E
6. **Code conventions**: Function declarations (not arrow functions) for components/handlers 6. **Code conventions**: Function declarations (not arrow functions) for components/handlers
- Component props should be `interface Props { ... }` (not exported) unless the interface needs to be used outside the component
- Separate render logic from business logic (component.tsx + useComponent.ts + helpers.ts)
- Colocate state when possible and avoid creating large components, use sub-components ( local `/components` folder next to the parent component ) when sensible
- Avoid large hooks, abstract logic into `helpers.ts` files when sensible
- Use function declarations for components, arrow functions only for callbacks
- No barrel files or `index.ts` re-exports
- Do not use `useCallback` or `useMemo` unless strictly needed
- Avoid comments at all times unless the code is very complex
### Security Implementation ### Security Implementation

View File

@@ -0,0 +1,251 @@
import logging
import autogpt_libs.auth
import fastapi
import fastapi.responses
import backend.api.features.store.db as store_db
import backend.api.features.store.model as store_model
logger = logging.getLogger(__name__)
router = fastapi.APIRouter(
prefix="/admin/waitlist",
tags=["store", "admin", "waitlist"],
dependencies=[fastapi.Security(autogpt_libs.auth.requires_admin_user)],
)
@router.post(
"",
summary="Create Waitlist",
response_model=store_model.WaitlistAdminResponse,
)
async def create_waitlist(
request: store_model.WaitlistCreateRequest,
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
):
"""
Create a new waitlist (admin only).
Args:
request: Waitlist creation details
user_id: Authenticated admin user creating the waitlist
Returns:
WaitlistAdminResponse with the created waitlist details
"""
try:
waitlist = await store_db.create_waitlist_admin(
admin_user_id=user_id,
data=request,
)
return waitlist
except Exception as e:
logger.exception("Error creating waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while creating the waitlist"},
)
@router.get(
"",
summary="List All Waitlists",
response_model=store_model.WaitlistAdminListResponse,
)
async def list_waitlists():
"""
Get all waitlists with admin details (admin only).
Returns:
WaitlistAdminListResponse with all waitlists
"""
try:
return await store_db.get_waitlists_admin()
except Exception as e:
logger.exception("Error listing waitlists: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while fetching waitlists"},
)
@router.get(
"/{waitlist_id}",
summary="Get Waitlist Details",
response_model=store_model.WaitlistAdminResponse,
)
async def get_waitlist(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Get a single waitlist with admin details (admin only).
Args:
waitlist_id: ID of the waitlist to retrieve
Returns:
WaitlistAdminResponse with waitlist details
"""
try:
return await store_db.get_waitlist_admin(waitlist_id)
except ValueError:
logger.warning("Waitlist not found: %s", waitlist_id)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error fetching waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while fetching the waitlist"},
)
@router.put(
"/{waitlist_id}",
summary="Update Waitlist",
response_model=store_model.WaitlistAdminResponse,
)
async def update_waitlist(
request: store_model.WaitlistUpdateRequest,
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Update a waitlist (admin only).
Args:
waitlist_id: ID of the waitlist to update
request: Fields to update
Returns:
WaitlistAdminResponse with updated waitlist details
"""
try:
return await store_db.update_waitlist_admin(waitlist_id, request)
except ValueError:
logger.warning("Waitlist not found for update: %s", waitlist_id)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error updating waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while updating the waitlist"},
)
@router.delete(
"/{waitlist_id}",
summary="Delete Waitlist",
)
async def delete_waitlist(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Soft delete a waitlist (admin only).
Args:
waitlist_id: ID of the waitlist to delete
Returns:
Success message
"""
try:
await store_db.delete_waitlist_admin(waitlist_id)
return {"message": "Waitlist deleted successfully"}
except ValueError:
logger.warning(f"Waitlist not found for deletion: {waitlist_id}")
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error deleting waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while deleting the waitlist"},
)
@router.get(
"/{waitlist_id}/signups",
summary="Get Waitlist Signups",
response_model=store_model.WaitlistSignupListResponse,
)
async def get_waitlist_signups(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Get all signups for a waitlist (admin only).
Args:
waitlist_id: ID of the waitlist
Returns:
WaitlistSignupListResponse with all signups
"""
try:
return await store_db.get_waitlist_signups_admin(waitlist_id)
except ValueError:
logger.warning("Waitlist not found for signups: %s", waitlist_id)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error fetching waitlist signups: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while fetching waitlist signups"},
)
@router.post(
"/{waitlist_id}/link",
summary="Link Waitlist to Store Listing",
response_model=store_model.WaitlistAdminResponse,
)
async def link_waitlist_to_listing(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
store_listing_id: str = fastapi.Body(
..., embed=True, description="The ID of the store listing"
),
):
"""
Link a waitlist to a store listing (admin only).
When the linked store listing is approved/published, waitlist users
will be automatically notified.
Args:
waitlist_id: ID of the waitlist
store_listing_id: ID of the store listing to link
Returns:
WaitlistAdminResponse with updated waitlist details
"""
try:
return await store_db.link_waitlist_to_listing_admin(
waitlist_id, store_listing_id
)
except ValueError:
logger.warning(
"Link failed - waitlist or listing not found: %s, %s",
waitlist_id,
store_listing_id,
)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist or store listing not found"},
)
except Exception as e:
logger.exception("Error linking waitlist to listing: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while linking the waitlist"},
)

View File

@@ -290,11 +290,6 @@ async def _cache_session(session: ChatSession) -> None:
await async_redis.setex(redis_key, config.session_ttl, session.model_dump_json()) await async_redis.setex(redis_key, config.session_ttl, session.model_dump_json())
async def cache_chat_session(session: ChatSession) -> None:
"""Cache a chat session without persisting to the database."""
await _cache_session(session)
async def _get_session_from_db(session_id: str) -> ChatSession | None: async def _get_session_from_db(session_id: str) -> ChatSession | None:
"""Get a chat session from the database.""" """Get a chat session from the database."""
prisma_session = await chat_db.get_chat_session(session_id) prisma_session = await chat_db.get_chat_session(session_id)

View File

@@ -172,12 +172,12 @@ async def get_session(
user_id: The optional authenticated user ID, or None for anonymous access. user_id: The optional authenticated user ID, or None for anonymous access.
Returns: Returns:
SessionDetailResponse: Details for the requested session, or None if not found. SessionDetailResponse: Details for the requested session; raises NotFoundError if not found.
""" """
session = await get_chat_session(session_id, user_id) session = await get_chat_session(session_id, user_id)
if not session: if not session:
raise NotFoundError(f"Session {session_id} not found.") raise NotFoundError(f"Session {session_id} not found")
messages = [message.model_dump() for message in session.messages] messages = [message.model_dump() for message in session.messages]
logger.info( logger.info(
@@ -222,8 +222,6 @@ async def stream_chat_post(
session = await _validate_and_get_session(session_id, user_id) session = await _validate_and_get_session(session_id, user_id)
async def event_generator() -> AsyncGenerator[str, None]: async def event_generator() -> AsyncGenerator[str, None]:
chunk_count = 0
first_chunk_type: str | None = None
async for chunk in chat_service.stream_chat_completion( async for chunk in chat_service.stream_chat_completion(
session_id, session_id,
request.message, request.message,
@@ -232,26 +230,7 @@ async def stream_chat_post(
session=session, # Pass pre-fetched session to avoid double-fetch session=session, # Pass pre-fetched session to avoid double-fetch
context=request.context, context=request.context,
): ):
if chunk_count < 3:
logger.info(
"Chat stream chunk",
extra={
"session_id": session_id,
"chunk_type": str(chunk.type),
},
)
if not first_chunk_type:
first_chunk_type = str(chunk.type)
chunk_count += 1
yield chunk.to_sse() yield chunk.to_sse()
logger.info(
"Chat stream completed",
extra={
"session_id": session_id,
"chunk_count": chunk_count,
"first_chunk_type": first_chunk_type,
},
)
# AI SDK protocol termination # AI SDK protocol termination
yield "data: [DONE]\n\n" yield "data: [DONE]\n\n"
@@ -296,8 +275,6 @@ async def stream_chat_get(
session = await _validate_and_get_session(session_id, user_id) session = await _validate_and_get_session(session_id, user_id)
async def event_generator() -> AsyncGenerator[str, None]: async def event_generator() -> AsyncGenerator[str, None]:
chunk_count = 0
first_chunk_type: str | None = None
async for chunk in chat_service.stream_chat_completion( async for chunk in chat_service.stream_chat_completion(
session_id, session_id,
message, message,
@@ -305,26 +282,7 @@ async def stream_chat_get(
user_id=user_id, user_id=user_id,
session=session, # Pass pre-fetched session to avoid double-fetch session=session, # Pass pre-fetched session to avoid double-fetch
): ):
if chunk_count < 3:
logger.info(
"Chat stream chunk",
extra={
"session_id": session_id,
"chunk_type": str(chunk.type),
},
)
if not first_chunk_type:
first_chunk_type = str(chunk.type)
chunk_count += 1
yield chunk.to_sse() yield chunk.to_sse()
logger.info(
"Chat stream completed",
extra={
"session_id": session_id,
"chunk_count": chunk_count,
"first_chunk_type": first_chunk_type,
},
)
# AI SDK protocol termination # AI SDK protocol termination
yield "data: [DONE]\n\n" yield "data: [DONE]\n\n"

View File

@@ -1,20 +1,12 @@
import asyncio import asyncio
import logging import logging
import time
from asyncio import CancelledError
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any from typing import Any
import orjson import orjson
from langfuse import get_client, propagate_attributes from langfuse import get_client, propagate_attributes
from langfuse.openai import openai # type: ignore from langfuse.openai import openai # type: ignore
from openai import ( from openai import APIConnectionError, APIError, APIStatusError, RateLimitError
APIConnectionError,
APIError,
APIStatusError,
PermissionDeniedError,
RateLimitError,
)
from openai.types.chat import ChatCompletionChunk, ChatCompletionToolParam from openai.types.chat import ChatCompletionChunk, ChatCompletionToolParam
from backend.data.understanding import ( from backend.data.understanding import (
@@ -29,7 +21,6 @@ from .model import (
ChatMessage, ChatMessage,
ChatSession, ChatSession,
Usage, Usage,
cache_chat_session,
get_chat_session, get_chat_session,
update_session_title, update_session_title,
upsert_chat_session, upsert_chat_session,
@@ -305,10 +296,6 @@ async def stream_chat_completion(
content="", content="",
) )
accumulated_tool_calls: list[dict[str, Any]] = [] accumulated_tool_calls: list[dict[str, Any]] = []
has_saved_assistant_message = False
has_appended_streaming_message = False
last_cache_time = 0.0
last_cache_content_len = 0
# Wrap main logic in try/finally to ensure Langfuse observations are always ended # Wrap main logic in try/finally to ensure Langfuse observations are always ended
has_yielded_end = False has_yielded_end = False
@@ -345,23 +332,6 @@ async def stream_chat_completion(
assert assistant_response.content is not None assert assistant_response.content is not None
assistant_response.content += delta assistant_response.content += delta
has_received_text = True has_received_text = True
if not has_appended_streaming_message:
session.messages.append(assistant_response)
has_appended_streaming_message = True
current_time = time.monotonic()
content_len = len(assistant_response.content)
if (
current_time - last_cache_time >= 1.0
and content_len > last_cache_content_len
):
try:
await cache_chat_session(session)
except Exception as e:
logger.warning(
f"Failed to cache partial session {session.session_id}: {e}"
)
last_cache_time = current_time
last_cache_content_len = content_len
yield chunk yield chunk
elif isinstance(chunk, StreamTextEnd): elif isinstance(chunk, StreamTextEnd):
# Emit text-end after text completes # Emit text-end after text completes
@@ -420,42 +390,10 @@ async def stream_chat_completion(
if has_received_text and not text_streaming_ended: if has_received_text and not text_streaming_ended:
yield StreamTextEnd(id=text_block_id) yield StreamTextEnd(id=text_block_id)
text_streaming_ended = True text_streaming_ended = True
# Save assistant message before yielding finish to ensure it's persisted
# even if client disconnects immediately after receiving StreamFinish
if not has_saved_assistant_message:
messages_to_save_early: list[ChatMessage] = []
if accumulated_tool_calls:
assistant_response.tool_calls = (
accumulated_tool_calls
)
if not has_appended_streaming_message and (
assistant_response.content
or assistant_response.tool_calls
):
messages_to_save_early.append(assistant_response)
messages_to_save_early.extend(tool_response_messages)
if messages_to_save_early:
session.messages.extend(messages_to_save_early)
logger.info(
f"Saving assistant message before StreamFinish: "
f"content_len={len(assistant_response.content or '')}, "
f"tool_calls={len(assistant_response.tool_calls or [])}, "
f"tool_responses={len(tool_response_messages)}"
)
if (
messages_to_save_early
or has_appended_streaming_message
):
await upsert_chat_session(session)
has_saved_assistant_message = True
has_yielded_end = True has_yielded_end = True
yield chunk yield chunk
elif isinstance(chunk, StreamError): elif isinstance(chunk, StreamError):
has_yielded_error = True has_yielded_error = True
yield chunk
elif isinstance(chunk, StreamUsage): elif isinstance(chunk, StreamUsage):
session.usage.append( session.usage.append(
Usage( Usage(
@@ -475,27 +413,6 @@ async def stream_chat_completion(
langfuse.update_current_trace(output=str(tool_response_messages)) langfuse.update_current_trace(output=str(tool_response_messages))
langfuse.update_current_span(output=str(tool_response_messages)) langfuse.update_current_span(output=str(tool_response_messages))
except CancelledError:
if not has_saved_assistant_message:
if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls
if assistant_response.content:
assistant_response.content = (
f"{assistant_response.content}\n\n[interrupted]"
)
else:
assistant_response.content = "[interrupted]"
if not has_appended_streaming_message:
session.messages.append(assistant_response)
if tool_response_messages:
session.messages.extend(tool_response_messages)
try:
await upsert_chat_session(session)
except Exception as e:
logger.warning(
f"Failed to save interrupted session {session.session_id}: {e}"
)
raise
except Exception as e: except Exception as e:
logger.error(f"Error during stream: {e!s}", exc_info=True) logger.error(f"Error during stream: {e!s}", exc_info=True)
@@ -517,18 +434,13 @@ async def stream_chat_completion(
# Add assistant message if it has content or tool calls # Add assistant message if it has content or tool calls
if accumulated_tool_calls: if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls assistant_response.tool_calls = accumulated_tool_calls
if not has_appended_streaming_message and ( if assistant_response.content or assistant_response.tool_calls:
assistant_response.content or assistant_response.tool_calls
):
messages_to_save.append(assistant_response) messages_to_save.append(assistant_response)
# Add tool response messages after assistant message # Add tool response messages after assistant message
messages_to_save.extend(tool_response_messages) messages_to_save.extend(tool_response_messages)
if not has_saved_assistant_message:
if messages_to_save:
session.messages.extend(messages_to_save) session.messages.extend(messages_to_save)
if messages_to_save or has_appended_streaming_message:
await upsert_chat_session(session) await upsert_chat_session(session)
if not has_yielded_error: if not has_yielded_error:
@@ -560,8 +472,6 @@ async def stream_chat_completion(
return # Exit after retry to avoid double-saving in finally block return # Exit after retry to avoid double-saving in finally block
# Normal completion path - save session and handle tool call continuation # Normal completion path - save session and handle tool call continuation
# Only save if we haven't already saved when StreamFinish was received
if not has_saved_assistant_message:
logger.info( logger.info(
f"Normal completion path: session={session.session_id}, " f"Normal completion path: session={session.session_id}, "
f"current message_count={len(session.messages)}" f"current message_count={len(session.messages)}"
@@ -576,9 +486,7 @@ async def stream_chat_completion(
logger.info( logger.info(
f"Added {len(accumulated_tool_calls)} tool calls to assistant message" f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
) )
if not has_appended_streaming_message and ( if assistant_response.content or assistant_response.tool_calls:
assistant_response.content or assistant_response.tool_calls
):
messages_to_save.append(assistant_response) messages_to_save.append(assistant_response)
logger.info( logger.info(
f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}" f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}"
@@ -591,18 +499,11 @@ async def stream_chat_completion(
f"total_to_save={len(messages_to_save)}" f"total_to_save={len(messages_to_save)}"
) )
if messages_to_save:
session.messages.extend(messages_to_save) session.messages.extend(messages_to_save)
logger.info( logger.info(
f"Extended session messages, new message_count={len(session.messages)}" f"Extended session messages, new message_count={len(session.messages)}"
) )
if messages_to_save or has_appended_streaming_message:
await upsert_chat_session(session) await upsert_chat_session(session)
else:
logger.info(
"Assistant message already saved when StreamFinish was received, "
"skipping duplicate save"
)
# If we did a tool call, stream the chat completion again to get the next response # If we did a tool call, stream the chat completion again to get the next response
if has_done_tool_call: if has_done_tool_call:
@@ -644,12 +545,6 @@ def _is_retryable_error(error: Exception) -> bool:
return False return False
def _is_region_blocked_error(error: Exception) -> bool:
if isinstance(error, PermissionDeniedError):
return "not available in your region" in str(error).lower()
return "not available in your region" in str(error).lower()
async def _stream_chat_chunks( async def _stream_chat_chunks(
session: ChatSession, session: ChatSession,
tools: list[ChatCompletionToolParam], tools: list[ChatCompletionToolParam],
@@ -842,18 +737,7 @@ async def _stream_chat_chunks(
f"Error in stream (not retrying): {e!s}", f"Error in stream (not retrying): {e!s}",
exc_info=True, exc_info=True,
) )
error_code = None error_response = StreamError(errorText=str(e))
error_text = str(e)
if _is_region_blocked_error(e):
error_code = "MODEL_NOT_AVAILABLE_REGION"
error_text = (
"This model is not available in your region. "
"Please connect via VPN and try again."
)
error_response = StreamError(
errorText=error_text,
code=error_code,
)
yield error_response yield error_response
yield StreamFinish() yield StreamFinish()
return return

View File

@@ -218,7 +218,6 @@ async def save_agent_to_library(
library_agents = await library_db.create_library_agent( library_agents = await library_db.create_library_agent(
graph=created_graph, graph=created_graph,
user_id=user_id, user_id=user_id,
sensitive_action_safe_mode=True,
create_library_agents_for_sub_graphs=False, create_library_agents_for_sub_graphs=False,
) )

View File

@@ -401,11 +401,27 @@ async def add_generated_agent_image(
) )
def _initialize_graph_settings(graph: graph_db.GraphModel) -> GraphSettings:
"""
Initialize GraphSettings based on graph content.
Args:
graph: The graph to analyze
Returns:
GraphSettings with appropriate human_in_the_loop_safe_mode value
"""
if graph.has_human_in_the_loop:
# Graph has HITL blocks - set safe mode to True by default
return GraphSettings(human_in_the_loop_safe_mode=True)
else:
# Graph has no HITL blocks - keep None
return GraphSettings(human_in_the_loop_safe_mode=None)
async def create_library_agent( async def create_library_agent(
graph: graph_db.GraphModel, graph: graph_db.GraphModel,
user_id: str, user_id: str,
hitl_safe_mode: bool = True,
sensitive_action_safe_mode: bool = False,
create_library_agents_for_sub_graphs: bool = True, create_library_agents_for_sub_graphs: bool = True,
) -> list[library_model.LibraryAgent]: ) -> list[library_model.LibraryAgent]:
""" """
@@ -414,8 +430,6 @@ async def create_library_agent(
Args: Args:
agent: The agent/Graph to add to the library. agent: The agent/Graph to add to the library.
user_id: The user to whom the agent will be added. user_id: The user to whom the agent will be added.
hitl_safe_mode: Whether HITL blocks require manual review (default True).
sensitive_action_safe_mode: Whether sensitive action blocks require review.
create_library_agents_for_sub_graphs: If True, creates LibraryAgent records for sub-graphs as well. create_library_agents_for_sub_graphs: If True, creates LibraryAgent records for sub-graphs as well.
Returns: Returns:
@@ -451,11 +465,7 @@ async def create_library_agent(
} }
}, },
settings=SafeJson( settings=SafeJson(
GraphSettings.from_graph( _initialize_graph_settings(graph_entry).model_dump()
graph_entry,
hitl_safe_mode=hitl_safe_mode,
sensitive_action_safe_mode=sensitive_action_safe_mode,
).model_dump()
), ),
), ),
include=library_agent_include( include=library_agent_include(
@@ -617,6 +627,33 @@ async def update_library_agent(
raise DatabaseError("Failed to update library agent") from e raise DatabaseError("Failed to update library agent") from e
async def update_library_agent_settings(
user_id: str,
agent_id: str,
settings: GraphSettings,
) -> library_model.LibraryAgent:
"""
Updates the settings for a specific LibraryAgent.
Args:
user_id: The owner of the LibraryAgent.
agent_id: The ID of the LibraryAgent to update.
settings: New GraphSettings to apply.
Returns:
The updated LibraryAgent.
Raises:
NotFoundError: If the specified LibraryAgent does not exist.
DatabaseError: If there's an error in the update operation.
"""
return await update_library_agent(
library_agent_id=agent_id,
user_id=user_id,
settings=settings,
)
async def delete_library_agent( async def delete_library_agent(
library_agent_id: str, user_id: str, soft_delete: bool = True library_agent_id: str, user_id: str, soft_delete: bool = True
) -> None: ) -> None:
@@ -801,7 +838,7 @@ async def add_store_agent_to_library(
"isCreatedByUser": False, "isCreatedByUser": False,
"useGraphIsActiveVersion": False, "useGraphIsActiveVersion": False,
"settings": SafeJson( "settings": SafeJson(
GraphSettings.from_graph(graph_model).model_dump() _initialize_graph_settings(graph_model).model_dump()
), ),
}, },
include=library_agent_include( include=library_agent_include(
@@ -1191,15 +1228,8 @@ async def fork_library_agent(
) )
new_graph = await on_graph_activate(new_graph, user_id=user_id) new_graph = await on_graph_activate(new_graph, user_id=user_id)
# Create a library agent for the new graph, preserving safe mode settings # Create a library agent for the new graph
return ( return (await create_library_agent(new_graph, user_id))[0]
await create_library_agent(
new_graph,
user_id,
hitl_safe_mode=original_agent.settings.human_in_the_loop_safe_mode,
sensitive_action_safe_mode=original_agent.settings.sensitive_action_safe_mode,
)
)[0]
except prisma.errors.PrismaError as e: except prisma.errors.PrismaError as e:
logger.error(f"Database error cloning library agent: {e}") logger.error(f"Database error cloning library agent: {e}")
raise DatabaseError("Failed to fork library agent") from e raise DatabaseError("Failed to fork library agent") from e

View File

@@ -73,12 +73,6 @@ class LibraryAgent(pydantic.BaseModel):
has_external_trigger: bool = pydantic.Field( has_external_trigger: bool = pydantic.Field(
description="Whether the agent has an external trigger (e.g. webhook) node" description="Whether the agent has an external trigger (e.g. webhook) node"
) )
has_human_in_the_loop: bool = pydantic.Field(
description="Whether the agent has human-in-the-loop blocks"
)
has_sensitive_action: bool = pydantic.Field(
description="Whether the agent has sensitive action blocks"
)
trigger_setup_info: Optional[GraphTriggerInfo] = None trigger_setup_info: Optional[GraphTriggerInfo] = None
# Indicates whether there's a new output (based on recent runs) # Indicates whether there's a new output (based on recent runs)
@@ -186,8 +180,6 @@ class LibraryAgent(pydantic.BaseModel):
graph.credentials_input_schema if sub_graphs is not None else None graph.credentials_input_schema if sub_graphs is not None else None
), ),
has_external_trigger=graph.has_external_trigger, has_external_trigger=graph.has_external_trigger,
has_human_in_the_loop=graph.has_human_in_the_loop,
has_sensitive_action=graph.has_sensitive_action,
trigger_setup_info=graph.trigger_setup_info, trigger_setup_info=graph.trigger_setup_info,
new_output=new_output, new_output=new_output,
can_access_graph=can_access_graph, can_access_graph=can_access_graph,

View File

@@ -52,8 +52,6 @@ async def test_get_library_agents_success(
output_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}},
credentials_input_schema={"type": "object", "properties": {}}, credentials_input_schema={"type": "object", "properties": {}},
has_external_trigger=False, has_external_trigger=False,
has_human_in_the_loop=False,
has_sensitive_action=False,
status=library_model.LibraryAgentStatus.COMPLETED, status=library_model.LibraryAgentStatus.COMPLETED,
recommended_schedule_cron=None, recommended_schedule_cron=None,
new_output=False, new_output=False,
@@ -77,8 +75,6 @@ async def test_get_library_agents_success(
output_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}},
credentials_input_schema={"type": "object", "properties": {}}, credentials_input_schema={"type": "object", "properties": {}},
has_external_trigger=False, has_external_trigger=False,
has_human_in_the_loop=False,
has_sensitive_action=False,
status=library_model.LibraryAgentStatus.COMPLETED, status=library_model.LibraryAgentStatus.COMPLETED,
recommended_schedule_cron=None, recommended_schedule_cron=None,
new_output=False, new_output=False,
@@ -154,8 +150,6 @@ async def test_get_favorite_library_agents_success(
output_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}},
credentials_input_schema={"type": "object", "properties": {}}, credentials_input_schema={"type": "object", "properties": {}},
has_external_trigger=False, has_external_trigger=False,
has_human_in_the_loop=False,
has_sensitive_action=False,
status=library_model.LibraryAgentStatus.COMPLETED, status=library_model.LibraryAgentStatus.COMPLETED,
recommended_schedule_cron=None, recommended_schedule_cron=None,
new_output=False, new_output=False,
@@ -224,8 +218,6 @@ def test_add_agent_to_library_success(
output_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}},
credentials_input_schema={"type": "object", "properties": {}}, credentials_input_schema={"type": "object", "properties": {}},
has_external_trigger=False, has_external_trigger=False,
has_human_in_the_loop=False,
has_sensitive_action=False,
status=library_model.LibraryAgentStatus.COMPLETED, status=library_model.LibraryAgentStatus.COMPLETED,
new_output=False, new_output=False,
can_access_graph=True, can_access_graph=True,

View File

@@ -22,6 +22,7 @@ from backend.data.notifications import (
AgentApprovalData, AgentApprovalData,
AgentRejectionData, AgentRejectionData,
NotificationEventModel, NotificationEventModel,
WaitlistLaunchData,
) )
from backend.notifications.notifications import queue_notification_async from backend.notifications.notifications import queue_notification_async
from backend.util.exceptions import DatabaseError from backend.util.exceptions import DatabaseError
@@ -1717,6 +1718,29 @@ async def review_store_submission(
# Don't fail the review process if email sending fails # Don't fail the review process if email sending fails
pass pass
# Notify waitlist users if this is an approval and has a linked waitlist
if is_approved and submission.StoreListing:
try:
frontend_base_url = (
settings.config.frontend_base_url
or settings.config.platform_base_url
)
store_agent = (
await prisma.models.StoreAgent.prisma().find_first_or_raise(
where={"storeListingVersionId": submission.id}
)
)
creator_username = store_agent.creator_username or "unknown"
store_url = f"{frontend_base_url}/marketplace/agent/{creator_username}/{store_agent.slug}"
await notify_waitlist_users_on_launch(
store_listing_id=submission.StoreListing.id,
agent_name=submission.name,
store_url=store_url,
)
except Exception as e:
logger.error(f"Failed to notify waitlist users on agent approval: {e}")
# Don't fail the approval process
# Convert to Pydantic model for consistency # Convert to Pydantic model for consistency
return store_model.StoreSubmission( return store_model.StoreSubmission(
listing_id=(submission.StoreListing.id if submission.StoreListing else ""), listing_id=(submission.StoreListing.id if submission.StoreListing else ""),
@@ -1964,3 +1988,552 @@ async def get_agent_as_admin(
) )
return graph return graph
def _waitlist_to_store_entry(
waitlist: prisma.models.WaitlistEntry,
) -> store_model.StoreWaitlistEntry:
"""Convert a WaitlistEntry to StoreWaitlistEntry for public display."""
return store_model.StoreWaitlistEntry(
waitlistId=waitlist.id,
slug=waitlist.slug,
name=waitlist.name,
subHeading=waitlist.subHeading,
videoUrl=waitlist.videoUrl,
agentOutputDemoUrl=waitlist.agentOutputDemoUrl,
imageUrls=waitlist.imageUrls or [],
description=waitlist.description,
categories=waitlist.categories,
)
async def get_waitlist() -> list[store_model.StoreWaitlistEntry]:
"""Get all active waitlists for public display."""
try:
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
where=prisma.types.WaitlistEntryWhereInput(isDeleted=False),
)
# Filter out closed/done waitlists and sort by votes (descending)
excluded_statuses = {
prisma.enums.WaitlistExternalStatus.CANCELED,
prisma.enums.WaitlistExternalStatus.DONE,
}
active_waitlists = [w for w in waitlists if w.status not in excluded_statuses]
sorted_list = sorted(active_waitlists, key=lambda x: x.votes, reverse=True)
return [_waitlist_to_store_entry(w) for w in sorted_list]
except Exception as e:
logger.error(f"Error fetching waitlists: {e}")
raise DatabaseError("Failed to fetch waitlists") from e
async def get_user_waitlist_memberships(user_id: str) -> list[str]:
"""Get all waitlist IDs that a user has joined."""
try:
user = await prisma.models.User.prisma().find_unique(
where={"id": user_id},
include={"joinedWaitlists": True},
)
if not user or not user.joinedWaitlists:
return []
return [w.id for w in user.joinedWaitlists]
except Exception as e:
logger.error(f"Error fetching user waitlist memberships: {e}")
raise DatabaseError("Failed to fetch waitlist memberships") from e
async def add_user_to_waitlist(
waitlist_id: str, user_id: str | None, email: str | None
) -> store_model.StoreWaitlistEntry:
"""
Add a user to a waitlist.
For logged-in users: connects via joinedUsers relation
For anonymous users: adds email to unaffiliatedEmailUsers array
"""
if not user_id and not email:
raise ValueError("Either user_id or email must be provided")
try:
# Find the waitlist
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
if waitlist.isDeleted:
raise ValueError(f"Waitlist {waitlist_id} is no longer available")
if waitlist.status in [
prisma.enums.WaitlistExternalStatus.CANCELED,
prisma.enums.WaitlistExternalStatus.DONE,
]:
raise ValueError(f"Waitlist {waitlist_id} is closed")
if user_id:
# Check if user already joined
joined_user_ids = [u.id for u in (waitlist.joinedUsers or [])]
if user_id in joined_user_ids:
# Already joined - return waitlist info
logger.debug(f"User {user_id} already joined waitlist {waitlist_id}")
else:
# Connect user to waitlist
await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"joinedUsers": {"connect": [{"id": user_id}]}},
)
logger.info(f"User {user_id} joined waitlist {waitlist_id}")
# If user was previously in email list, remove them
# Use transaction to prevent race conditions
if email:
async with transaction() as tx:
current_waitlist = await tx.waitlistentry.find_unique(
where={"id": waitlist_id}
)
if current_waitlist and email in (
current_waitlist.unaffiliatedEmailUsers or []
):
updated_emails: list[str] = [
e
for e in (current_waitlist.unaffiliatedEmailUsers or [])
if e != email
]
await tx.waitlistentry.update(
where={"id": waitlist_id},
data={"unaffiliatedEmailUsers": updated_emails},
)
elif email:
# Add email to unaffiliated list if not already present
# Use transaction to prevent race conditions with concurrent signups
async with transaction() as tx:
# Re-fetch within transaction to get latest state
current_waitlist = await tx.waitlistentry.find_unique(
where={"id": waitlist_id}
)
if current_waitlist:
current_emails: list[str] = list(
current_waitlist.unaffiliatedEmailUsers or []
)
if email not in current_emails:
current_emails.append(email)
await tx.waitlistentry.update(
where={"id": waitlist_id},
data={"unaffiliatedEmailUsers": current_emails},
)
logger.info(f"Email {email} added to waitlist {waitlist_id}")
else:
logger.debug(f"Email {email} already on waitlist {waitlist_id}")
# Re-fetch to return updated data
updated_waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id}
)
return _waitlist_to_store_entry(updated_waitlist or waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error adding user to waitlist: {e}")
raise DatabaseError("Failed to add user to waitlist") from e
# ============== Admin Waitlist Functions ==============
def _waitlist_to_admin_response(
waitlist: prisma.models.WaitlistEntry,
) -> store_model.WaitlistAdminResponse:
"""Convert a WaitlistEntry to WaitlistAdminResponse."""
joined_count = len(waitlist.joinedUsers) if waitlist.joinedUsers else 0
email_count = (
len(waitlist.unaffiliatedEmailUsers) if waitlist.unaffiliatedEmailUsers else 0
)
return store_model.WaitlistAdminResponse(
id=waitlist.id,
createdAt=waitlist.createdAt.isoformat() if waitlist.createdAt else "",
updatedAt=waitlist.updatedAt.isoformat() if waitlist.updatedAt else "",
slug=waitlist.slug,
name=waitlist.name,
subHeading=waitlist.subHeading,
description=waitlist.description,
categories=waitlist.categories,
imageUrls=waitlist.imageUrls or [],
videoUrl=waitlist.videoUrl,
agentOutputDemoUrl=waitlist.agentOutputDemoUrl,
status=waitlist.status or prisma.enums.WaitlistExternalStatus.NOT_STARTED,
votes=waitlist.votes,
signupCount=joined_count + email_count,
storeListingId=waitlist.storeListingId,
owningUserId=waitlist.owningUserId,
)
async def create_waitlist_admin(
admin_user_id: str,
data: store_model.WaitlistCreateRequest,
) -> store_model.WaitlistAdminResponse:
"""Create a new waitlist (admin only)."""
logger.info(f"Admin {admin_user_id} creating waitlist: {data.name}")
try:
waitlist = await prisma.models.WaitlistEntry.prisma().create(
data=prisma.types.WaitlistEntryCreateInput(
name=data.name,
slug=data.slug,
subHeading=data.subHeading,
description=data.description,
categories=data.categories,
imageUrls=data.imageUrls,
videoUrl=data.videoUrl,
agentOutputDemoUrl=data.agentOutputDemoUrl,
owningUserId=admin_user_id,
status=prisma.enums.WaitlistExternalStatus.NOT_STARTED,
),
include={"joinedUsers": True},
)
return _waitlist_to_admin_response(waitlist)
except Exception as e:
logger.error(f"Error creating waitlist: {e}")
raise DatabaseError("Failed to create waitlist") from e
async def get_waitlists_admin() -> store_model.WaitlistAdminListResponse:
"""Get all waitlists with admin details."""
try:
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
where=prisma.types.WaitlistEntryWhereInput(isDeleted=False),
include={"joinedUsers": True},
order={"createdAt": "desc"},
)
return store_model.WaitlistAdminListResponse(
waitlists=[_waitlist_to_admin_response(w) for w in waitlists],
totalCount=len(waitlists),
)
except Exception as e:
logger.error(f"Error fetching waitlists for admin: {e}")
raise DatabaseError("Failed to fetch waitlists") from e
async def get_waitlist_admin(
waitlist_id: str,
) -> store_model.WaitlistAdminResponse:
"""Get a single waitlist with admin details."""
try:
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
if waitlist.isDeleted:
raise ValueError(f"Waitlist {waitlist_id} has been deleted")
return _waitlist_to_admin_response(waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error fetching waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to fetch waitlist") from e
async def update_waitlist_admin(
waitlist_id: str,
data: store_model.WaitlistUpdateRequest,
) -> store_model.WaitlistAdminResponse:
"""Update a waitlist (admin only)."""
logger.info(f"Updating waitlist {waitlist_id}")
try:
# Check if waitlist exists first
existing = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id}
)
if not existing:
raise ValueError(f"Waitlist {waitlist_id} not found")
if existing.isDeleted:
raise ValueError(f"Waitlist {waitlist_id} has been deleted")
# Build update data from explicitly provided fields
# Use model_fields_set to allow clearing fields by setting them to None
field_mappings = {
"name": data.name,
"slug": data.slug,
"subHeading": data.subHeading,
"description": data.description,
"categories": data.categories,
"imageUrls": data.imageUrls,
"videoUrl": data.videoUrl,
"agentOutputDemoUrl": data.agentOutputDemoUrl,
"storeListingId": data.storeListingId,
}
update_data: dict[str, Any] = {
k: v for k, v in field_mappings.items() if k in data.model_fields_set
}
# Add status if provided (already validated as enum by Pydantic)
if "status" in data.model_fields_set and data.status is not None:
update_data["status"] = data.status
if not update_data:
# No updates, just return current data
return await get_waitlist_admin(waitlist_id)
waitlist = await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data=prisma.types.WaitlistEntryUpdateInput(**update_data),
include={"joinedUsers": True},
)
# We already verified existence above, so this should never be None
assert waitlist is not None
return _waitlist_to_admin_response(waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error updating waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to update waitlist") from e
async def delete_waitlist_admin(waitlist_id: str) -> None:
"""Soft delete a waitlist (admin only)."""
logger.info(f"Soft deleting waitlist {waitlist_id}")
try:
# Check if waitlist exists first
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
if waitlist.isDeleted:
raise ValueError(f"Waitlist {waitlist_id} has already been deleted")
await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"isDeleted": True},
)
except ValueError:
raise
except Exception as e:
logger.error(f"Error deleting waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to delete waitlist") from e
async def get_waitlist_signups_admin(
waitlist_id: str,
) -> store_model.WaitlistSignupListResponse:
"""Get all signups for a waitlist (admin only)."""
try:
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
signups: list[store_model.WaitlistSignup] = []
# Add user signups
for user in waitlist.joinedUsers or []:
signups.append(
store_model.WaitlistSignup(
type="user",
userId=user.id,
email=user.email,
username=user.name,
)
)
# Add email signups
for email in waitlist.unaffiliatedEmailUsers or []:
signups.append(
store_model.WaitlistSignup(
type="email",
email=email,
)
)
return store_model.WaitlistSignupListResponse(
waitlistId=waitlist_id,
signups=signups,
totalCount=len(signups),
)
except ValueError:
raise
except Exception as e:
logger.error(f"Error fetching signups for waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to fetch waitlist signups") from e
async def link_waitlist_to_listing_admin(
waitlist_id: str,
store_listing_id: str,
) -> store_model.WaitlistAdminResponse:
"""Link a waitlist to a store listing (admin only)."""
logger.info(f"Linking waitlist {waitlist_id} to listing {store_listing_id}")
try:
# Verify the waitlist exists
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id}
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
if waitlist.isDeleted:
raise ValueError(f"Waitlist {waitlist_id} has been deleted")
# Verify the store listing exists
listing = await prisma.models.StoreListing.prisma().find_unique(
where={"id": store_listing_id}
)
if not listing:
raise ValueError(f"Store listing {store_listing_id} not found")
updated_waitlist = await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"StoreListing": {"connect": {"id": store_listing_id}}},
include={"joinedUsers": True},
)
# We already verified existence above, so this should never be None
assert updated_waitlist is not None
return _waitlist_to_admin_response(updated_waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error linking waitlist to listing: {e}")
raise DatabaseError("Failed to link waitlist to listing") from e
async def notify_waitlist_users_on_launch(
store_listing_id: str,
agent_name: str,
store_url: str,
) -> int:
"""
Notify all users on waitlists linked to a store listing when the agent is launched.
Args:
store_listing_id: The ID of the store listing that was approved
agent_name: The name of the approved agent
store_url: The URL to the agent's store page
Returns:
The number of notifications sent
"""
logger.info(f"Notifying waitlist users for store listing {store_listing_id}")
try:
# Find all active waitlists linked to this store listing
# Exclude DONE and CANCELED to prevent duplicate notifications on re-approval
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
where={
"storeListingId": store_listing_id,
"isDeleted": False,
"status": {
"not_in": [
prisma.enums.WaitlistExternalStatus.DONE,
prisma.enums.WaitlistExternalStatus.CANCELED,
]
},
},
include={"joinedUsers": True},
)
if not waitlists:
logger.info(
f"No active waitlists found for store listing {store_listing_id}"
)
return 0
notification_count = 0
launched_at = datetime.now(tz=timezone.utc)
for waitlist in waitlists:
# Track notification results for this waitlist
users_to_notify = waitlist.joinedUsers or []
failed_user_ids: list[str] = []
# Notify registered users
for user in users_to_notify:
try:
notification_data = WaitlistLaunchData(
agent_name=agent_name,
waitlist_name=waitlist.name,
store_url=store_url,
launched_at=launched_at,
)
notification_event = NotificationEventModel[WaitlistLaunchData](
user_id=user.id,
type=prisma.enums.NotificationType.WAITLIST_LAUNCH,
data=notification_data,
)
await queue_notification_async(notification_event)
notification_count += 1
except Exception as e:
logger.error(
f"Failed to send waitlist launch notification to user {user.id}: {e}"
)
failed_user_ids.append(user.id)
# Note: For unaffiliated email users, you would need to send emails directly
# since they don't have user IDs for the notification system.
# This could be done via a separate email service.
# For now, we log these for potential manual follow-up or future implementation.
has_pending_email_users = bool(waitlist.unaffiliatedEmailUsers)
if has_pending_email_users:
logger.info(
f"Waitlist {waitlist.id} has {len(waitlist.unaffiliatedEmailUsers)} "
f"unaffiliated email users that need email notifications"
)
# Only mark waitlist as DONE if all registered user notifications succeeded
# AND there are no unaffiliated email users still waiting for notifications
if not failed_user_ids and not has_pending_email_users:
await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist.id},
data={"status": prisma.enums.WaitlistExternalStatus.DONE},
)
logger.info(f"Updated waitlist {waitlist.id} status to DONE")
elif failed_user_ids:
logger.warning(
f"Waitlist {waitlist.id} not marked as DONE due to "
f"{len(failed_user_ids)} failed notifications"
)
elif has_pending_email_users:
logger.warning(
f"Waitlist {waitlist.id} not marked as DONE due to "
f"{len(waitlist.unaffiliatedEmailUsers)} pending email-only users"
)
logger.info(
f"Sent {notification_count} waitlist launch notifications for store listing {store_listing_id}"
)
return notification_count
except Exception as e:
logger.error(
f"Error notifying waitlist users for store listing {store_listing_id}: {e}"
)
# Don't raise - we don't want to fail the approval process
return 0

View File

@@ -154,16 +154,16 @@ async def store_content_embedding(
# Upsert the embedding # Upsert the embedding
# WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT # WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT
# Use unqualified ::vector - pgvector is in search_path on all environments # Use {pgvector_schema}.vector for explicit pgvector type qualification
await execute_raw_with_schema( await execute_raw_with_schema(
""" """
INSERT INTO {schema_prefix}"UnifiedContentEmbedding" ( INSERT INTO {schema_prefix}"UnifiedContentEmbedding" (
"id", "contentType", "contentId", "userId", "embedding", "searchableText", "metadata", "createdAt", "updatedAt" "id", "contentType", "contentId", "userId", "embedding", "searchableText", "metadata", "createdAt", "updatedAt"
) )
VALUES (gen_random_uuid()::text, $1::{schema_prefix}"ContentType", $2, $3, $4::vector, $5, $6::jsonb, NOW(), NOW()) VALUES (gen_random_uuid()::text, $1::{schema_prefix}"ContentType", $2, $3, $4::{pgvector_schema}.vector, $5, $6::jsonb, NOW(), NOW())
ON CONFLICT ("contentType", "contentId", "userId") ON CONFLICT ("contentType", "contentId", "userId")
DO UPDATE SET DO UPDATE SET
"embedding" = $4::vector, "embedding" = $4::{pgvector_schema}.vector,
"searchableText" = $5, "searchableText" = $5,
"metadata" = $6::jsonb, "metadata" = $6::jsonb,
"updatedAt" = NOW() "updatedAt" = NOW()
@@ -879,7 +879,8 @@ async def semantic_search(
min_similarity_idx = len(params) + 1 min_similarity_idx = len(params) + 1
params.append(min_similarity) params.append(min_similarity)
# Use unqualified ::vector and <=> operator - pgvector is in search_path on all environments # Use regular string (not f-string) for template to preserve {schema_prefix} and {schema} placeholders
# Use OPERATOR({pgvector_schema}.<=>) for explicit operator schema qualification
sql = ( sql = (
""" """
SELECT SELECT
@@ -887,9 +888,9 @@ async def semantic_search(
"contentType" as content_type, "contentType" as content_type,
"searchableText" as searchable_text, "searchableText" as searchable_text,
metadata, metadata,
1 - (embedding <=> '""" 1 - (embedding OPERATOR({pgvector_schema}.<=>) '"""
+ embedding_str + embedding_str
+ """'::vector) as similarity + """'::{pgvector_schema}.vector) as similarity
FROM {schema_prefix}"UnifiedContentEmbedding" FROM {schema_prefix}"UnifiedContentEmbedding"
WHERE "contentType" IN (""" WHERE "contentType" IN ("""
+ content_type_placeholders + content_type_placeholders
@@ -897,9 +898,9 @@ async def semantic_search(
""" """
+ user_filter + user_filter
+ """ + """
AND 1 - (embedding <=> '""" AND 1 - (embedding OPERATOR({pgvector_schema}.<=>) '"""
+ embedding_str + embedding_str
+ """'::vector) >= $""" + """'::{pgvector_schema}.vector) >= $"""
+ str(min_similarity_idx) + str(min_similarity_idx)
+ """ + """
ORDER BY similarity DESC ORDER BY similarity DESC

View File

@@ -295,7 +295,7 @@ async def unified_hybrid_search(
FROM {{schema_prefix}}"UnifiedContentEmbedding" uce FROM {{schema_prefix}}"UnifiedContentEmbedding" uce
WHERE uce."contentType" = ANY({content_types_param}::{{schema_prefix}}"ContentType"[]) WHERE uce."contentType" = ANY({content_types_param}::{{schema_prefix}}"ContentType"[])
{user_filter} {user_filter}
ORDER BY uce.embedding <=> {embedding_param}::vector ORDER BY uce.embedding OPERATOR({{pgvector_schema}}.<=>) {embedding_param}::{{pgvector_schema}}.vector
LIMIT 200 LIMIT 200
) )
), ),
@@ -307,7 +307,7 @@ async def unified_hybrid_search(
uce.metadata, uce.metadata,
uce."updatedAt" as updated_at, uce."updatedAt" as updated_at,
-- Semantic score: cosine similarity (1 - distance) -- Semantic score: cosine similarity (1 - distance)
COALESCE(1 - (uce.embedding <=> {embedding_param}::vector), 0) as semantic_score, COALESCE(1 - (uce.embedding OPERATOR({{pgvector_schema}}.<=>) {embedding_param}::{{pgvector_schema}}.vector), 0) as semantic_score,
-- Lexical score: ts_rank_cd -- Lexical score: ts_rank_cd
COALESCE(ts_rank_cd(uce.search, plainto_tsquery('english', {query_param})), 0) as lexical_raw, COALESCE(ts_rank_cd(uce.search, plainto_tsquery('english', {query_param})), 0) as lexical_raw,
-- Category match from metadata -- Category match from metadata
@@ -583,7 +583,7 @@ async def hybrid_search(
WHERE uce."contentType" = 'STORE_AGENT'::{{schema_prefix}}"ContentType" WHERE uce."contentType" = 'STORE_AGENT'::{{schema_prefix}}"ContentType"
AND uce."userId" IS NULL AND uce."userId" IS NULL
AND {where_clause} AND {where_clause}
ORDER BY uce.embedding <=> {embedding_param}::vector ORDER BY uce.embedding OPERATOR({{pgvector_schema}}.<=>) {embedding_param}::{{pgvector_schema}}.vector
LIMIT 200 LIMIT 200
) uce ) uce
), ),
@@ -605,7 +605,7 @@ async def hybrid_search(
-- Searchable text for BM25 reranking -- Searchable text for BM25 reranking
COALESCE(sa.agent_name, '') || ' ' || COALESCE(sa.sub_heading, '') || ' ' || COALESCE(sa.description, '') as searchable_text, COALESCE(sa.agent_name, '') || ' ' || COALESCE(sa.sub_heading, '') || ' ' || COALESCE(sa.description, '') as searchable_text,
-- Semantic score -- Semantic score
COALESCE(1 - (uce.embedding <=> {embedding_param}::vector), 0) as semantic_score, COALESCE(1 - (uce.embedding OPERATOR({{pgvector_schema}}.<=>) {embedding_param}::{{pgvector_schema}}.vector), 0) as semantic_score,
-- Lexical score (raw, will normalize) -- Lexical score (raw, will normalize)
COALESCE(ts_rank_cd(uce.search, plainto_tsquery('english', {query_param})), 0) as lexical_raw, COALESCE(ts_rank_cd(uce.search, plainto_tsquery('english', {query_param})), 0) as lexical_raw,
-- Category match -- Category match

View File

@@ -223,6 +223,102 @@ class ReviewSubmissionRequest(pydantic.BaseModel):
internal_comments: str | None = None # Private admin notes internal_comments: str | None = None # Private admin notes
class StoreWaitlistEntry(pydantic.BaseModel):
"""Public waitlist entry - no PII fields exposed."""
waitlistId: str
slug: str
# Content fields
name: str
subHeading: str
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
imageUrls: list[str]
description: str
categories: list[str]
class StoreWaitlistsAllResponse(pydantic.BaseModel):
listings: list[StoreWaitlistEntry]
# Admin Waitlist Models
class WaitlistCreateRequest(pydantic.BaseModel):
"""Request model for creating a new waitlist."""
name: str
slug: str
subHeading: str
description: str
categories: list[str] = []
imageUrls: list[str] = []
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
class WaitlistUpdateRequest(pydantic.BaseModel):
"""Request model for updating a waitlist."""
name: str | None = None
slug: str | None = None
subHeading: str | None = None
description: str | None = None
categories: list[str] | None = None
imageUrls: list[str] | None = None
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
status: prisma.enums.WaitlistExternalStatus | None = None
storeListingId: str | None = None # Link to a store listing
class WaitlistAdminResponse(pydantic.BaseModel):
"""Admin response model with full waitlist details including internal data."""
id: str
createdAt: str
updatedAt: str
slug: str
name: str
subHeading: str
description: str
categories: list[str]
imageUrls: list[str]
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
status: prisma.enums.WaitlistExternalStatus
votes: int
signupCount: int # Total count of joinedUsers + unaffiliatedEmailUsers
storeListingId: str | None = None
owningUserId: str
class WaitlistSignup(pydantic.BaseModel):
"""Individual signup entry for a waitlist."""
type: str # "user" or "email"
userId: str | None = None
email: str | None = None
username: str | None = None # For user signups
class WaitlistSignupListResponse(pydantic.BaseModel):
"""Response model for listing waitlist signups."""
waitlistId: str
signups: list[WaitlistSignup]
totalCount: int
class WaitlistAdminListResponse(pydantic.BaseModel):
"""Response model for listing all waitlists (admin view)."""
waitlists: list[WaitlistAdminResponse]
totalCount: int
class UnifiedSearchResult(pydantic.BaseModel): class UnifiedSearchResult(pydantic.BaseModel):
"""A single result from unified hybrid search across all content types.""" """A single result from unified hybrid search across all content types."""

View File

@@ -8,6 +8,7 @@ import autogpt_libs.auth
import fastapi import fastapi
import fastapi.responses import fastapi.responses
import prisma.enums import prisma.enums
from autogpt_libs.auth.dependencies import get_optional_user_id
import backend.data.graph import backend.data.graph
import backend.util.json import backend.util.json
@@ -81,6 +82,74 @@ async def update_or_create_profile(
return updated_profile return updated_profile
##############################################
############## Waitlist Endpoints ############
##############################################
@router.get(
"/waitlist",
summary="Get the agent waitlist",
tags=["store", "public"],
response_model=store_model.StoreWaitlistsAllResponse,
)
async def get_waitlist():
"""
Get all active waitlists for public display.
"""
waitlists = await store_db.get_waitlist()
return store_model.StoreWaitlistsAllResponse(listings=waitlists)
@router.get(
"/waitlist/my-memberships",
summary="Get waitlist IDs the current user has joined",
tags=["store", "private"],
)
async def get_my_waitlist_memberships(
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
) -> list[str]:
"""Returns list of waitlist IDs the authenticated user has joined."""
return await store_db.get_user_waitlist_memberships(user_id)
@router.post(
path="/waitlist/{waitlist_id}/join",
summary="Add self to the agent waitlist",
tags=["store", "public"],
response_model=store_model.StoreWaitlistEntry,
)
async def add_self_to_waitlist(
user_id: str | None = fastapi.Security(get_optional_user_id),
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist to join"),
email: str | None = fastapi.Body(
default=None, embed=True, description="Email address for unauthenticated users"
),
):
"""
Add the current user to the agent waitlist.
"""
if not user_id and not email:
raise fastapi.HTTPException(
status_code=400,
detail="Either user authentication or email address is required",
)
try:
waitlist_entry = await store_db.add_user_to_waitlist(
waitlist_id=waitlist_id, user_id=user_id, email=email
)
return waitlist_entry
except ValueError as e:
error_msg = str(e)
if "not found" in error_msg:
raise fastapi.HTTPException(status_code=404, detail="Waitlist not found")
# Waitlist exists but is closed or unavailable
raise fastapi.HTTPException(status_code=400, detail=error_msg)
except Exception:
raise fastapi.HTTPException(
status_code=500, detail="An error occurred while joining the waitlist"
)
############################################## ##############################################
############### Agent Endpoints ############## ############### Agent Endpoints ##############
############################################## ##############################################

View File

@@ -761,8 +761,10 @@ async def create_new_graph(
graph.reassign_ids(user_id=user_id, reassign_graph_id=True) graph.reassign_ids(user_id=user_id, reassign_graph_id=True)
graph.validate_graph(for_run=False) graph.validate_graph(for_run=False)
# The return value of the create graph & library function is intentionally not used here,
# as the graph already valid and no sub-graphs are returned back.
await graph_db.create_graph(graph, user_id=user_id) await graph_db.create_graph(graph, user_id=user_id)
await library_db.create_library_agent(graph, user_id) await library_db.create_library_agent(graph, user_id=user_id)
activated_graph = await on_graph_activate(graph, user_id=user_id) activated_graph = await on_graph_activate(graph, user_id=user_id)
if create_graph.source == "builder": if create_graph.source == "builder":
@@ -886,19 +888,21 @@ async def set_graph_active_version(
async def _update_library_agent_version_and_settings( async def _update_library_agent_version_and_settings(
user_id: str, agent_graph: graph_db.GraphModel user_id: str, agent_graph: graph_db.GraphModel
) -> library_model.LibraryAgent: ) -> library_model.LibraryAgent:
# Keep the library agent up to date with the new active version
library = await library_db.update_agent_version_in_library( library = await library_db.update_agent_version_in_library(
user_id, agent_graph.id, agent_graph.version user_id, agent_graph.id, agent_graph.version
) )
updated_settings = GraphSettings.from_graph( # If the graph has HITL node, initialize the setting if it's not already set.
graph=agent_graph, if (
hitl_safe_mode=library.settings.human_in_the_loop_safe_mode, agent_graph.has_human_in_the_loop
sensitive_action_safe_mode=library.settings.sensitive_action_safe_mode, and library.settings.human_in_the_loop_safe_mode is None
) ):
if updated_settings != library.settings: await library_db.update_library_agent_settings(
library = await library_db.update_library_agent(
library_agent_id=library.id,
user_id=user_id, user_id=user_id,
settings=updated_settings, agent_id=library.id,
settings=library.settings.model_copy(
update={"human_in_the_loop_safe_mode": True}
),
) )
return library return library
@@ -915,18 +919,21 @@ async def update_graph_settings(
user_id: Annotated[str, Security(get_user_id)], user_id: Annotated[str, Security(get_user_id)],
) -> GraphSettings: ) -> GraphSettings:
"""Update graph settings for the user's library agent.""" """Update graph settings for the user's library agent."""
# Get the library agent for this graph
library_agent = await library_db.get_library_agent_by_graph_id( library_agent = await library_db.get_library_agent_by_graph_id(
graph_id=graph_id, user_id=user_id graph_id=graph_id, user_id=user_id
) )
if not library_agent: if not library_agent:
raise HTTPException(404, f"Graph #{graph_id} not found in user's library") raise HTTPException(404, f"Graph #{graph_id} not found in user's library")
updated_agent = await library_db.update_library_agent( # Update the library agent settings
library_agent_id=library_agent.id, updated_agent = await library_db.update_library_agent_settings(
user_id=user_id, user_id=user_id,
agent_id=library_agent.id,
settings=settings, settings=settings,
) )
# Return the updated settings
return GraphSettings.model_validate(updated_agent.settings) return GraphSettings.model_validate(updated_agent.settings)

View File

@@ -19,6 +19,7 @@ from prisma.errors import PrismaError
import backend.api.features.admin.credit_admin_routes import backend.api.features.admin.credit_admin_routes
import backend.api.features.admin.execution_analytics_routes import backend.api.features.admin.execution_analytics_routes
import backend.api.features.admin.store_admin_routes import backend.api.features.admin.store_admin_routes
import backend.api.features.admin.waitlist_admin_routes
import backend.api.features.builder import backend.api.features.builder
import backend.api.features.builder.routes import backend.api.features.builder.routes
import backend.api.features.chat.routes as chat_routes import backend.api.features.chat.routes as chat_routes
@@ -283,6 +284,11 @@ app.include_router(
tags=["v2", "admin"], tags=["v2", "admin"],
prefix="/api/store", prefix="/api/store",
) )
app.include_router(
backend.api.features.admin.waitlist_admin_routes.router,
tags=["v2", "admin"],
prefix="/api/store",
)
app.include_router( app.include_router(
backend.api.features.admin.credit_admin_routes.router, backend.api.features.admin.credit_admin_routes.router,
tags=["v2", "admin"], tags=["v2", "admin"],

View File

@@ -84,7 +84,7 @@ class HITLReviewHelper:
Exception: If review creation or status update fails Exception: If review creation or status update fails
""" """
# Skip review if safe mode is disabled - return auto-approved result # Skip review if safe mode is disabled - return auto-approved result
if not execution_context.human_in_the_loop_safe_mode: if not execution_context.safe_mode:
logger.info( logger.info(
f"Block {block_name} skipping review for node {node_exec_id} - safe mode disabled" f"Block {block_name} skipping review for node {node_exec_id} - safe mode disabled"
) )

View File

@@ -104,7 +104,7 @@ class HumanInTheLoopBlock(Block):
execution_context: ExecutionContext, execution_context: ExecutionContext,
**_kwargs, **_kwargs,
) -> BlockOutput: ) -> BlockOutput:
if not execution_context.human_in_the_loop_safe_mode: if not execution_context.safe_mode:
logger.info( logger.info(
f"HITL block skipping review for node {node_exec_id} - safe mode disabled" f"HITL block skipping review for node {node_exec_id} - safe mode disabled"
) )

View File

@@ -79,10 +79,6 @@ class ModelMetadata(NamedTuple):
provider: str provider: str
context_window: int context_window: int
max_output_tokens: int | None max_output_tokens: int | None
display_name: str
provider_name: str
creator_name: str
price_tier: Literal[1, 2, 3]
class LlmModelMeta(EnumMeta): class LlmModelMeta(EnumMeta):
@@ -175,26 +171,6 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
V0_1_5_LG = "v0-1.5-lg" V0_1_5_LG = "v0-1.5-lg"
V0_1_0_MD = "v0-1.0-md" V0_1_0_MD = "v0-1.0-md"
@classmethod
def __get_pydantic_json_schema__(cls, schema, handler):
json_schema = handler(schema)
llm_model_metadata = {}
for model in cls:
model_name = model.value
metadata = model.metadata
llm_model_metadata[model_name] = {
"creator": metadata.creator_name,
"creator_name": metadata.creator_name,
"title": metadata.display_name,
"provider": metadata.provider,
"provider_name": metadata.provider_name,
"name": model_name,
"price_tier": metadata.price_tier,
}
json_schema["llm_model"] = True
json_schema["llm_model_metadata"] = llm_model_metadata
return json_schema
@property @property
def metadata(self) -> ModelMetadata: def metadata(self) -> ModelMetadata:
return MODEL_METADATA[self] return MODEL_METADATA[self]
@@ -214,291 +190,119 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
MODEL_METADATA = { MODEL_METADATA = {
# https://platform.openai.com/docs/models # https://platform.openai.com/docs/models
LlmModel.O3: ModelMetadata("openai", 200000, 100000, "O3", "OpenAI", "OpenAI", 2), LlmModel.O3: ModelMetadata("openai", 200000, 100000),
LlmModel.O3_MINI: ModelMetadata( LlmModel.O3_MINI: ModelMetadata("openai", 200000, 100000), # o3-mini-2025-01-31
"openai", 200000, 100000, "O3 Mini", "OpenAI", "OpenAI", 1 LlmModel.O1: ModelMetadata("openai", 200000, 100000), # o1-2024-12-17
), # o3-mini-2025-01-31 LlmModel.O1_MINI: ModelMetadata("openai", 128000, 65536), # o1-mini-2024-09-12
LlmModel.O1: ModelMetadata(
"openai", 200000, 100000, "O1", "OpenAI", "OpenAI", 3
), # o1-2024-12-17
LlmModel.O1_MINI: ModelMetadata(
"openai", 128000, 65536, "O1 Mini", "OpenAI", "OpenAI", 2
), # o1-mini-2024-09-12
# GPT-5 models # GPT-5 models
LlmModel.GPT5_2: ModelMetadata( LlmModel.GPT5_2: ModelMetadata("openai", 400000, 128000),
"openai", 400000, 128000, "GPT-5.2", "OpenAI", "OpenAI", 3 LlmModel.GPT5_1: ModelMetadata("openai", 400000, 128000),
), LlmModel.GPT5: ModelMetadata("openai", 400000, 128000),
LlmModel.GPT5_1: ModelMetadata( LlmModel.GPT5_MINI: ModelMetadata("openai", 400000, 128000),
"openai", 400000, 128000, "GPT-5.1", "OpenAI", "OpenAI", 2 LlmModel.GPT5_NANO: ModelMetadata("openai", 400000, 128000),
), LlmModel.GPT5_CHAT: ModelMetadata("openai", 400000, 16384),
LlmModel.GPT5: ModelMetadata( LlmModel.GPT41: ModelMetadata("openai", 1047576, 32768),
"openai", 400000, 128000, "GPT-5", "OpenAI", "OpenAI", 1 LlmModel.GPT41_MINI: ModelMetadata("openai", 1047576, 32768),
),
LlmModel.GPT5_MINI: ModelMetadata(
"openai", 400000, 128000, "GPT-5 Mini", "OpenAI", "OpenAI", 1
),
LlmModel.GPT5_NANO: ModelMetadata(
"openai", 400000, 128000, "GPT-5 Nano", "OpenAI", "OpenAI", 1
),
LlmModel.GPT5_CHAT: ModelMetadata(
"openai", 400000, 16384, "GPT-5 Chat Latest", "OpenAI", "OpenAI", 2
),
LlmModel.GPT41: ModelMetadata(
"openai", 1047576, 32768, "GPT-4.1", "OpenAI", "OpenAI", 1
),
LlmModel.GPT41_MINI: ModelMetadata(
"openai", 1047576, 32768, "GPT-4.1 Mini", "OpenAI", "OpenAI", 1
),
LlmModel.GPT4O_MINI: ModelMetadata( LlmModel.GPT4O_MINI: ModelMetadata(
"openai", 128000, 16384, "GPT-4o Mini", "OpenAI", "OpenAI", 1 "openai", 128000, 16384
), # gpt-4o-mini-2024-07-18 ), # gpt-4o-mini-2024-07-18
LlmModel.GPT4O: ModelMetadata( LlmModel.GPT4O: ModelMetadata("openai", 128000, 16384), # gpt-4o-2024-08-06
"openai", 128000, 16384, "GPT-4o", "OpenAI", "OpenAI", 2
), # gpt-4o-2024-08-06
LlmModel.GPT4_TURBO: ModelMetadata( LlmModel.GPT4_TURBO: ModelMetadata(
"openai", 128000, 4096, "GPT-4 Turbo", "OpenAI", "OpenAI", 3 "openai", 128000, 4096
), # gpt-4-turbo-2024-04-09 ), # gpt-4-turbo-2024-04-09
LlmModel.GPT3_5_TURBO: ModelMetadata( LlmModel.GPT3_5_TURBO: ModelMetadata("openai", 16385, 4096), # gpt-3.5-turbo-0125
"openai", 16385, 4096, "GPT-3.5 Turbo", "OpenAI", "OpenAI", 1
), # gpt-3.5-turbo-0125
# https://docs.anthropic.com/en/docs/about-claude/models # https://docs.anthropic.com/en/docs/about-claude/models
LlmModel.CLAUDE_4_1_OPUS: ModelMetadata( LlmModel.CLAUDE_4_1_OPUS: ModelMetadata(
"anthropic", 200000, 32000, "Claude Opus 4.1", "Anthropic", "Anthropic", 3 "anthropic", 200000, 32000
), # claude-opus-4-1-20250805 ), # claude-opus-4-1-20250805
LlmModel.CLAUDE_4_OPUS: ModelMetadata( LlmModel.CLAUDE_4_OPUS: ModelMetadata(
"anthropic", 200000, 32000, "Claude Opus 4", "Anthropic", "Anthropic", 3 "anthropic", 200000, 32000
), # claude-4-opus-20250514 ), # claude-4-opus-20250514
LlmModel.CLAUDE_4_SONNET: ModelMetadata( LlmModel.CLAUDE_4_SONNET: ModelMetadata(
"anthropic", 200000, 64000, "Claude Sonnet 4", "Anthropic", "Anthropic", 2 "anthropic", 200000, 64000
), # claude-4-sonnet-20250514 ), # claude-4-sonnet-20250514
LlmModel.CLAUDE_4_5_OPUS: ModelMetadata( LlmModel.CLAUDE_4_5_OPUS: ModelMetadata(
"anthropic", 200000, 64000, "Claude Opus 4.5", "Anthropic", "Anthropic", 3 "anthropic", 200000, 64000
), # claude-opus-4-5-20251101 ), # claude-opus-4-5-20251101
LlmModel.CLAUDE_4_5_SONNET: ModelMetadata( LlmModel.CLAUDE_4_5_SONNET: ModelMetadata(
"anthropic", 200000, 64000, "Claude Sonnet 4.5", "Anthropic", "Anthropic", 3 "anthropic", 200000, 64000
), # claude-sonnet-4-5-20250929 ), # claude-sonnet-4-5-20250929
LlmModel.CLAUDE_4_5_HAIKU: ModelMetadata( LlmModel.CLAUDE_4_5_HAIKU: ModelMetadata(
"anthropic", 200000, 64000, "Claude Haiku 4.5", "Anthropic", "Anthropic", 2 "anthropic", 200000, 64000
), # claude-haiku-4-5-20251001 ), # claude-haiku-4-5-20251001
LlmModel.CLAUDE_3_7_SONNET: ModelMetadata( LlmModel.CLAUDE_3_7_SONNET: ModelMetadata(
"anthropic", 200000, 64000, "Claude 3.7 Sonnet", "Anthropic", "Anthropic", 2 "anthropic", 200000, 64000
), # claude-3-7-sonnet-20250219 ), # claude-3-7-sonnet-20250219
LlmModel.CLAUDE_3_HAIKU: ModelMetadata( LlmModel.CLAUDE_3_HAIKU: ModelMetadata(
"anthropic", 200000, 4096, "Claude 3 Haiku", "Anthropic", "Anthropic", 1 "anthropic", 200000, 4096
), # claude-3-haiku-20240307 ), # claude-3-haiku-20240307
# https://docs.aimlapi.com/api-overview/model-database/text-models # https://docs.aimlapi.com/api-overview/model-database/text-models
LlmModel.AIML_API_QWEN2_5_72B: ModelMetadata( LlmModel.AIML_API_QWEN2_5_72B: ModelMetadata("aiml_api", 32000, 8000),
"aiml_api", 32000, 8000, "Qwen 2.5 72B Instruct Turbo", "AI/ML", "Qwen", 1 LlmModel.AIML_API_LLAMA3_1_70B: ModelMetadata("aiml_api", 128000, 40000),
), LlmModel.AIML_API_LLAMA3_3_70B: ModelMetadata("aiml_api", 128000, None),
LlmModel.AIML_API_LLAMA3_1_70B: ModelMetadata( LlmModel.AIML_API_META_LLAMA_3_1_70B: ModelMetadata("aiml_api", 131000, 2000),
"aiml_api", LlmModel.AIML_API_LLAMA_3_2_3B: ModelMetadata("aiml_api", 128000, None),
128000,
40000,
"Llama 3.1 Nemotron 70B Instruct",
"AI/ML",
"Nvidia",
1,
),
LlmModel.AIML_API_LLAMA3_3_70B: ModelMetadata(
"aiml_api", 128000, None, "Llama 3.3 70B Instruct Turbo", "AI/ML", "Meta", 1
),
LlmModel.AIML_API_META_LLAMA_3_1_70B: ModelMetadata(
"aiml_api", 131000, 2000, "Llama 3.1 70B Instruct Turbo", "AI/ML", "Meta", 1
),
LlmModel.AIML_API_LLAMA_3_2_3B: ModelMetadata(
"aiml_api", 128000, None, "Llama 3.2 3B Instruct Turbo", "AI/ML", "Meta", 1
),
# https://console.groq.com/docs/models # https://console.groq.com/docs/models
LlmModel.LLAMA3_3_70B: ModelMetadata( LlmModel.LLAMA3_3_70B: ModelMetadata("groq", 128000, 32768),
"groq", 128000, 32768, "Llama 3.3 70B Versatile", "Groq", "Meta", 1 LlmModel.LLAMA3_1_8B: ModelMetadata("groq", 128000, 8192),
),
LlmModel.LLAMA3_1_8B: ModelMetadata(
"groq", 128000, 8192, "Llama 3.1 8B Instant", "Groq", "Meta", 1
),
# https://ollama.com/library # https://ollama.com/library
LlmModel.OLLAMA_LLAMA3_3: ModelMetadata( LlmModel.OLLAMA_LLAMA3_3: ModelMetadata("ollama", 8192, None),
"ollama", 8192, None, "Llama 3.3", "Ollama", "Meta", 1 LlmModel.OLLAMA_LLAMA3_2: ModelMetadata("ollama", 8192, None),
), LlmModel.OLLAMA_LLAMA3_8B: ModelMetadata("ollama", 8192, None),
LlmModel.OLLAMA_LLAMA3_2: ModelMetadata( LlmModel.OLLAMA_LLAMA3_405B: ModelMetadata("ollama", 8192, None),
"ollama", 8192, None, "Llama 3.2", "Ollama", "Meta", 1 LlmModel.OLLAMA_DOLPHIN: ModelMetadata("ollama", 32768, None),
),
LlmModel.OLLAMA_LLAMA3_8B: ModelMetadata(
"ollama", 8192, None, "Llama 3", "Ollama", "Meta", 1
),
LlmModel.OLLAMA_LLAMA3_405B: ModelMetadata(
"ollama", 8192, None, "Llama 3.1 405B", "Ollama", "Meta", 1
),
LlmModel.OLLAMA_DOLPHIN: ModelMetadata(
"ollama", 32768, None, "Dolphin Mistral Latest", "Ollama", "Mistral AI", 1
),
# https://openrouter.ai/models # https://openrouter.ai/models
LlmModel.GEMINI_2_5_PRO: ModelMetadata( LlmModel.GEMINI_2_5_PRO: ModelMetadata("open_router", 1050000, 8192),
"open_router", LlmModel.GEMINI_3_PRO_PREVIEW: ModelMetadata("open_router", 1048576, 65535),
1050000, LlmModel.GEMINI_2_5_FLASH: ModelMetadata("open_router", 1048576, 65535),
8192, LlmModel.GEMINI_2_0_FLASH: ModelMetadata("open_router", 1048576, 8192),
"Gemini 2.5 Pro Preview 03.25",
"OpenRouter",
"Google",
2,
),
LlmModel.GEMINI_3_PRO_PREVIEW: ModelMetadata(
"open_router", 1048576, 65535, "Gemini 3 Pro Preview", "OpenRouter", "Google", 2
),
LlmModel.GEMINI_2_5_FLASH: ModelMetadata(
"open_router", 1048576, 65535, "Gemini 2.5 Flash", "OpenRouter", "Google", 1
),
LlmModel.GEMINI_2_0_FLASH: ModelMetadata(
"open_router", 1048576, 8192, "Gemini 2.0 Flash 001", "OpenRouter", "Google", 1
),
LlmModel.GEMINI_2_5_FLASH_LITE_PREVIEW: ModelMetadata( LlmModel.GEMINI_2_5_FLASH_LITE_PREVIEW: ModelMetadata(
"open_router", "open_router", 1048576, 65535
1048576,
65535,
"Gemini 2.5 Flash Lite Preview 06.17",
"OpenRouter",
"Google",
1,
),
LlmModel.GEMINI_2_0_FLASH_LITE: ModelMetadata(
"open_router",
1048576,
8192,
"Gemini 2.0 Flash Lite 001",
"OpenRouter",
"Google",
1,
),
LlmModel.MISTRAL_NEMO: ModelMetadata(
"open_router", 128000, 4096, "Mistral Nemo", "OpenRouter", "Mistral AI", 1
),
LlmModel.COHERE_COMMAND_R_08_2024: ModelMetadata(
"open_router", 128000, 4096, "Command R 08.2024", "OpenRouter", "Cohere", 1
),
LlmModel.COHERE_COMMAND_R_PLUS_08_2024: ModelMetadata(
"open_router", 128000, 4096, "Command R Plus 08.2024", "OpenRouter", "Cohere", 2
),
LlmModel.DEEPSEEK_CHAT: ModelMetadata(
"open_router", 64000, 2048, "DeepSeek Chat", "OpenRouter", "DeepSeek", 1
),
LlmModel.DEEPSEEK_R1_0528: ModelMetadata(
"open_router", 163840, 163840, "DeepSeek R1 0528", "OpenRouter", "DeepSeek", 1
),
LlmModel.PERPLEXITY_SONAR: ModelMetadata(
"open_router", 127000, 8000, "Sonar", "OpenRouter", "Perplexity", 1
),
LlmModel.PERPLEXITY_SONAR_PRO: ModelMetadata(
"open_router", 200000, 8000, "Sonar Pro", "OpenRouter", "Perplexity", 2
), ),
LlmModel.GEMINI_2_0_FLASH_LITE: ModelMetadata("open_router", 1048576, 8192),
LlmModel.MISTRAL_NEMO: ModelMetadata("open_router", 128000, 4096),
LlmModel.COHERE_COMMAND_R_08_2024: ModelMetadata("open_router", 128000, 4096),
LlmModel.COHERE_COMMAND_R_PLUS_08_2024: ModelMetadata("open_router", 128000, 4096),
LlmModel.DEEPSEEK_CHAT: ModelMetadata("open_router", 64000, 2048),
LlmModel.DEEPSEEK_R1_0528: ModelMetadata("open_router", 163840, 163840),
LlmModel.PERPLEXITY_SONAR: ModelMetadata("open_router", 127000, 8000),
LlmModel.PERPLEXITY_SONAR_PRO: ModelMetadata("open_router", 200000, 8000),
LlmModel.PERPLEXITY_SONAR_DEEP_RESEARCH: ModelMetadata( LlmModel.PERPLEXITY_SONAR_DEEP_RESEARCH: ModelMetadata(
"open_router", "open_router",
128000, 128000,
16000, 16000,
"Sonar Deep Research",
"OpenRouter",
"Perplexity",
3,
), ),
LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_405B: ModelMetadata( LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_405B: ModelMetadata(
"open_router", "open_router", 131000, 4096
131000,
4096,
"Hermes 3 Llama 3.1 405B",
"OpenRouter",
"Nous Research",
1,
), ),
LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_70B: ModelMetadata( LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_70B: ModelMetadata(
"open_router", "open_router", 12288, 12288
12288,
12288,
"Hermes 3 Llama 3.1 70B",
"OpenRouter",
"Nous Research",
1,
),
LlmModel.OPENAI_GPT_OSS_120B: ModelMetadata(
"open_router", 131072, 131072, "GPT-OSS 120B", "OpenRouter", "OpenAI", 1
),
LlmModel.OPENAI_GPT_OSS_20B: ModelMetadata(
"open_router", 131072, 32768, "GPT-OSS 20B", "OpenRouter", "OpenAI", 1
),
LlmModel.AMAZON_NOVA_LITE_V1: ModelMetadata(
"open_router", 300000, 5120, "Nova Lite V1", "OpenRouter", "Amazon", 1
),
LlmModel.AMAZON_NOVA_MICRO_V1: ModelMetadata(
"open_router", 128000, 5120, "Nova Micro V1", "OpenRouter", "Amazon", 1
),
LlmModel.AMAZON_NOVA_PRO_V1: ModelMetadata(
"open_router", 300000, 5120, "Nova Pro V1", "OpenRouter", "Amazon", 1
),
LlmModel.MICROSOFT_WIZARDLM_2_8X22B: ModelMetadata(
"open_router", 65536, 4096, "WizardLM 2 8x22B", "OpenRouter", "Microsoft", 1
),
LlmModel.GRYPHE_MYTHOMAX_L2_13B: ModelMetadata(
"open_router", 4096, 4096, "MythoMax L2 13B", "OpenRouter", "Gryphe", 1
),
LlmModel.META_LLAMA_4_SCOUT: ModelMetadata(
"open_router", 131072, 131072, "Llama 4 Scout", "OpenRouter", "Meta", 1
),
LlmModel.META_LLAMA_4_MAVERICK: ModelMetadata(
"open_router", 1048576, 1000000, "Llama 4 Maverick", "OpenRouter", "Meta", 1
),
LlmModel.GROK_4: ModelMetadata(
"open_router", 256000, 256000, "Grok 4", "OpenRouter", "xAI", 3
),
LlmModel.GROK_4_FAST: ModelMetadata(
"open_router", 2000000, 30000, "Grok 4 Fast", "OpenRouter", "xAI", 1
),
LlmModel.GROK_4_1_FAST: ModelMetadata(
"open_router", 2000000, 30000, "Grok 4.1 Fast", "OpenRouter", "xAI", 1
),
LlmModel.GROK_CODE_FAST_1: ModelMetadata(
"open_router", 256000, 10000, "Grok Code Fast 1", "OpenRouter", "xAI", 1
),
LlmModel.KIMI_K2: ModelMetadata(
"open_router", 131000, 131000, "Kimi K2", "OpenRouter", "Moonshot AI", 1
),
LlmModel.QWEN3_235B_A22B_THINKING: ModelMetadata(
"open_router",
262144,
262144,
"Qwen 3 235B A22B Thinking 2507",
"OpenRouter",
"Qwen",
1,
),
LlmModel.QWEN3_CODER: ModelMetadata(
"open_router", 262144, 262144, "Qwen 3 Coder", "OpenRouter", "Qwen", 3
), ),
LlmModel.OPENAI_GPT_OSS_120B: ModelMetadata("open_router", 131072, 131072),
LlmModel.OPENAI_GPT_OSS_20B: ModelMetadata("open_router", 131072, 32768),
LlmModel.AMAZON_NOVA_LITE_V1: ModelMetadata("open_router", 300000, 5120),
LlmModel.AMAZON_NOVA_MICRO_V1: ModelMetadata("open_router", 128000, 5120),
LlmModel.AMAZON_NOVA_PRO_V1: ModelMetadata("open_router", 300000, 5120),
LlmModel.MICROSOFT_WIZARDLM_2_8X22B: ModelMetadata("open_router", 65536, 4096),
LlmModel.GRYPHE_MYTHOMAX_L2_13B: ModelMetadata("open_router", 4096, 4096),
LlmModel.META_LLAMA_4_SCOUT: ModelMetadata("open_router", 131072, 131072),
LlmModel.META_LLAMA_4_MAVERICK: ModelMetadata("open_router", 1048576, 1000000),
LlmModel.GROK_4: ModelMetadata("open_router", 256000, 256000),
LlmModel.GROK_4_FAST: ModelMetadata("open_router", 2000000, 30000),
LlmModel.GROK_4_1_FAST: ModelMetadata("open_router", 2000000, 30000),
LlmModel.GROK_CODE_FAST_1: ModelMetadata("open_router", 256000, 10000),
LlmModel.KIMI_K2: ModelMetadata("open_router", 131000, 131000),
LlmModel.QWEN3_235B_A22B_THINKING: ModelMetadata("open_router", 262144, 262144),
LlmModel.QWEN3_CODER: ModelMetadata("open_router", 262144, 262144),
# Llama API models # Llama API models
LlmModel.LLAMA_API_LLAMA_4_SCOUT: ModelMetadata( LlmModel.LLAMA_API_LLAMA_4_SCOUT: ModelMetadata("llama_api", 128000, 4028),
"llama_api", LlmModel.LLAMA_API_LLAMA4_MAVERICK: ModelMetadata("llama_api", 128000, 4028),
128000, LlmModel.LLAMA_API_LLAMA3_3_8B: ModelMetadata("llama_api", 128000, 4028),
4028, LlmModel.LLAMA_API_LLAMA3_3_70B: ModelMetadata("llama_api", 128000, 4028),
"Llama 4 Scout 17B 16E Instruct FP8",
"Llama API",
"Meta",
1,
),
LlmModel.LLAMA_API_LLAMA4_MAVERICK: ModelMetadata(
"llama_api",
128000,
4028,
"Llama 4 Maverick 17B 128E Instruct FP8",
"Llama API",
"Meta",
1,
),
LlmModel.LLAMA_API_LLAMA3_3_8B: ModelMetadata(
"llama_api", 128000, 4028, "Llama 3.3 8B Instruct", "Llama API", "Meta", 1
),
LlmModel.LLAMA_API_LLAMA3_3_70B: ModelMetadata(
"llama_api", 128000, 4028, "Llama 3.3 70B Instruct", "Llama API", "Meta", 1
),
# v0 by Vercel models # v0 by Vercel models
LlmModel.V0_1_5_MD: ModelMetadata("v0", 128000, 64000, "v0 1.5 MD", "V0", "V0", 1), LlmModel.V0_1_5_MD: ModelMetadata("v0", 128000, 64000),
LlmModel.V0_1_5_LG: ModelMetadata("v0", 512000, 64000, "v0 1.5 LG", "V0", "V0", 1), LlmModel.V0_1_5_LG: ModelMetadata("v0", 512000, 64000),
LlmModel.V0_1_0_MD: ModelMetadata("v0", 128000, 64000, "v0 1.0 MD", "V0", "V0", 1), LlmModel.V0_1_0_MD: ModelMetadata("v0", 128000, 64000),
} }
DEFAULT_LLM_MODEL = LlmModel.GPT5_2 DEFAULT_LLM_MODEL = LlmModel.GPT5_2

View File

@@ -242,7 +242,7 @@ async def test_smart_decision_maker_tracks_llm_stats():
outputs = {} outputs = {}
# Create execution context # Create execution context
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False) mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests # Create a mock execution processor for tests
@@ -343,7 +343,7 @@ async def test_smart_decision_maker_parameter_validation():
# Create execution context # Create execution context
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False) mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests # Create a mock execution processor for tests
@@ -409,7 +409,7 @@ async def test_smart_decision_maker_parameter_validation():
# Create execution context # Create execution context
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False) mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests # Create a mock execution processor for tests
@@ -471,7 +471,7 @@ async def test_smart_decision_maker_parameter_validation():
outputs = {} outputs = {}
# Create execution context # Create execution context
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False) mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests # Create a mock execution processor for tests
@@ -535,7 +535,7 @@ async def test_smart_decision_maker_parameter_validation():
outputs = {} outputs = {}
# Create execution context # Create execution context
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False) mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests # Create a mock execution processor for tests
@@ -658,7 +658,7 @@ async def test_smart_decision_maker_raw_response_conversion():
outputs = {} outputs = {}
# Create execution context # Create execution context
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False) mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests # Create a mock execution processor for tests
@@ -730,7 +730,7 @@ async def test_smart_decision_maker_raw_response_conversion():
outputs = {} outputs = {}
# Create execution context # Create execution context
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False) mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests # Create a mock execution processor for tests
@@ -786,7 +786,7 @@ async def test_smart_decision_maker_raw_response_conversion():
outputs = {} outputs = {}
# Create execution context # Create execution context
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False) mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests # Create a mock execution processor for tests
@@ -905,7 +905,7 @@ async def test_smart_decision_maker_agent_mode():
# Create a mock execution context # Create a mock execution context
mock_execution_context = ExecutionContext( mock_execution_context = ExecutionContext(
human_in_the_loop_safe_mode=False, safe_mode=False,
) )
# Create a mock execution processor for agent mode tests # Create a mock execution processor for agent mode tests
@@ -1027,7 +1027,7 @@ async def test_smart_decision_maker_traditional_mode_default():
# Create execution context # Create execution context
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False) mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests # Create a mock execution processor for tests

View File

@@ -386,7 +386,7 @@ async def test_output_yielding_with_dynamic_fields():
outputs = {} outputs = {}
from backend.data.execution import ExecutionContext from backend.data.execution import ExecutionContext
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False) mock_execution_context = ExecutionContext(safe_mode=False)
mock_execution_processor = MagicMock() mock_execution_processor = MagicMock()
async for output_name, output_value in block.run( async for output_name, output_value in block.run(
@@ -609,9 +609,7 @@ async def test_validation_errors_dont_pollute_conversation():
outputs = {} outputs = {}
from backend.data.execution import ExecutionContext from backend.data.execution import ExecutionContext
mock_execution_context = ExecutionContext( mock_execution_context = ExecutionContext(safe_mode=False)
human_in_the_loop_safe_mode=False
)
# Create a proper mock execution processor for agent mode # Create a proper mock execution processor for agent mode
from collections import defaultdict from collections import defaultdict

View File

@@ -474,7 +474,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
self.block_type = block_type self.block_type = block_type
self.webhook_config = webhook_config self.webhook_config = webhook_config
self.execution_stats: NodeExecutionStats = NodeExecutionStats() self.execution_stats: NodeExecutionStats = NodeExecutionStats()
self.is_sensitive_action: bool = False self.requires_human_review: bool = False
if self.webhook_config: if self.webhook_config:
if isinstance(self.webhook_config, BlockWebhookConfig): if isinstance(self.webhook_config, BlockWebhookConfig):
@@ -637,9 +637,8 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
- should_pause: True if execution should be paused for review - should_pause: True if execution should be paused for review
- input_data_to_use: The input data to use (may be modified by reviewer) - input_data_to_use: The input data to use (may be modified by reviewer)
""" """
if not ( # Skip review if not required or safe mode is disabled
self.is_sensitive_action and execution_context.sensitive_action_safe_mode if not self.requires_human_review or not execution_context.safe_mode:
):
return False, input_data return False, input_data
from backend.blocks.helpers.review import HITLReviewHelper from backend.blocks.helpers.review import HITLReviewHelper

View File

@@ -99,15 +99,10 @@ MODEL_COST: dict[LlmModel, int] = {
LlmModel.OPENAI_GPT_OSS_20B: 1, LlmModel.OPENAI_GPT_OSS_20B: 1,
LlmModel.GEMINI_2_5_PRO: 4, LlmModel.GEMINI_2_5_PRO: 4,
LlmModel.GEMINI_3_PRO_PREVIEW: 5, LlmModel.GEMINI_3_PRO_PREVIEW: 5,
LlmModel.GEMINI_2_5_FLASH: 1,
LlmModel.GEMINI_2_0_FLASH: 1,
LlmModel.GEMINI_2_5_FLASH_LITE_PREVIEW: 1,
LlmModel.GEMINI_2_0_FLASH_LITE: 1,
LlmModel.MISTRAL_NEMO: 1, LlmModel.MISTRAL_NEMO: 1,
LlmModel.COHERE_COMMAND_R_08_2024: 1, LlmModel.COHERE_COMMAND_R_08_2024: 1,
LlmModel.COHERE_COMMAND_R_PLUS_08_2024: 3, LlmModel.COHERE_COMMAND_R_PLUS_08_2024: 3,
LlmModel.DEEPSEEK_CHAT: 2, LlmModel.DEEPSEEK_CHAT: 2,
LlmModel.DEEPSEEK_R1_0528: 1,
LlmModel.PERPLEXITY_SONAR: 1, LlmModel.PERPLEXITY_SONAR: 1,
LlmModel.PERPLEXITY_SONAR_PRO: 5, LlmModel.PERPLEXITY_SONAR_PRO: 5,
LlmModel.PERPLEXITY_SONAR_DEEP_RESEARCH: 10, LlmModel.PERPLEXITY_SONAR_DEEP_RESEARCH: 10,
@@ -131,6 +126,11 @@ MODEL_COST: dict[LlmModel, int] = {
LlmModel.KIMI_K2: 1, LlmModel.KIMI_K2: 1,
LlmModel.QWEN3_235B_A22B_THINKING: 1, LlmModel.QWEN3_235B_A22B_THINKING: 1,
LlmModel.QWEN3_CODER: 9, LlmModel.QWEN3_CODER: 9,
LlmModel.GEMINI_2_5_FLASH: 1,
LlmModel.GEMINI_2_0_FLASH: 1,
LlmModel.GEMINI_2_5_FLASH_LITE_PREVIEW: 1,
LlmModel.GEMINI_2_0_FLASH_LITE: 1,
LlmModel.DEEPSEEK_R1_0528: 1,
# v0 by Vercel models # v0 by Vercel models
LlmModel.V0_1_5_MD: 1, LlmModel.V0_1_5_MD: 1,
LlmModel.V0_1_5_LG: 2, LlmModel.V0_1_5_LG: 2,

View File

@@ -121,14 +121,10 @@ async def _raw_with_schema(
Supports placeholders: Supports placeholders:
- {schema_prefix}: Table/type prefix (e.g., "platform".) - {schema_prefix}: Table/type prefix (e.g., "platform".)
- {schema}: Raw schema name for application tables (e.g., platform) - {schema}: Raw schema name for application tables (e.g., platform)
- {pgvector_schema}: Schema where pgvector is installed (defaults to "public")
Note on pgvector types:
Use unqualified ::vector and <=> operator in queries. PostgreSQL resolves
these via search_path, which includes the schema where pgvector is installed
on all environments (local, CI, dev).
Args: Args:
query_template: SQL query with {schema_prefix} and/or {schema} placeholders query_template: SQL query with {schema_prefix}, {schema}, and/or {pgvector_schema} placeholders
*args: Query parameters *args: Query parameters
execute: If False, executes SELECT query. If True, executes INSERT/UPDATE/DELETE. execute: If False, executes SELECT query. If True, executes INSERT/UPDATE/DELETE.
client: Optional Prisma client for transactions (only used when execute=True). client: Optional Prisma client for transactions (only used when execute=True).
@@ -139,16 +135,20 @@ async def _raw_with_schema(
Example with vector type: Example with vector type:
await execute_raw_with_schema( await execute_raw_with_schema(
'INSERT INTO {schema_prefix}"Embedding" (vec) VALUES ($1::vector)', 'INSERT INTO {schema_prefix}"Embedding" (vec) VALUES ($1::{pgvector_schema}.vector)',
embedding_data embedding_data
) )
""" """
schema = get_database_schema() schema = get_database_schema()
schema_prefix = f'"{schema}".' if schema != "public" else "" schema_prefix = f'"{schema}".' if schema != "public" else ""
# pgvector extension is typically installed in "public" schema
# On Supabase it may be in "extensions" but "public" is the common default
pgvector_schema = "public"
formatted_query = query_template.format( formatted_query = query_template.format(
schema_prefix=schema_prefix, schema_prefix=schema_prefix,
schema=schema, schema=schema,
pgvector_schema=pgvector_schema,
) )
import prisma as prisma_module import prisma as prisma_module

View File

@@ -103,18 +103,8 @@ class RedisEventBus(BaseRedisEventBus[M], ABC):
return redis.get_redis() return redis.get_redis()
def publish_event(self, event: M, channel_key: str): def publish_event(self, event: M, channel_key: str):
"""
Publish an event to Redis. Gracefully handles connection failures
by logging the error instead of raising exceptions.
"""
try:
message, full_channel_name = self._serialize_message(event, channel_key) message, full_channel_name = self._serialize_message(event, channel_key)
self.connection.publish(full_channel_name, message) self.connection.publish(full_channel_name, message)
except Exception:
logger.exception(
f"Failed to publish event to Redis channel {channel_key}. "
"Event bus operation will continue without Redis connectivity."
)
def listen_events(self, channel_key: str) -> Generator[M, None, None]: def listen_events(self, channel_key: str) -> Generator[M, None, None]:
pubsub, full_channel_name = self._get_pubsub_channel( pubsub, full_channel_name = self._get_pubsub_channel(
@@ -138,19 +128,9 @@ class AsyncRedisEventBus(BaseRedisEventBus[M], ABC):
return await redis.get_redis_async() return await redis.get_redis_async()
async def publish_event(self, event: M, channel_key: str): async def publish_event(self, event: M, channel_key: str):
"""
Publish an event to Redis. Gracefully handles connection failures
by logging the error instead of raising exceptions.
"""
try:
message, full_channel_name = self._serialize_message(event, channel_key) message, full_channel_name = self._serialize_message(event, channel_key)
connection = await self.connection connection = await self.connection
await connection.publish(full_channel_name, message) await connection.publish(full_channel_name, message)
except Exception:
logger.exception(
f"Failed to publish event to Redis channel {channel_key}. "
"Event bus operation will continue without Redis connectivity."
)
async def listen_events(self, channel_key: str) -> AsyncGenerator[M, None]: async def listen_events(self, channel_key: str) -> AsyncGenerator[M, None]:
pubsub, full_channel_name = self._get_pubsub_channel( pubsub, full_channel_name = self._get_pubsub_channel(

View File

@@ -1,56 +0,0 @@
"""
Tests for event_bus graceful degradation when Redis is unavailable.
"""
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import BaseModel
from backend.data.event_bus import AsyncRedisEventBus
class TestEvent(BaseModel):
"""Test event model."""
message: str
class TestNotificationBus(AsyncRedisEventBus[TestEvent]):
"""Test implementation of AsyncRedisEventBus."""
Model = TestEvent
@property
def event_bus_name(self) -> str:
return "test_event_bus"
@pytest.mark.asyncio
async def test_publish_event_handles_connection_failure_gracefully():
"""Test that publish_event logs exception instead of raising when Redis is unavailable."""
bus = TestNotificationBus()
event = TestEvent(message="test message")
# Mock get_redis_async to raise connection error
with patch(
"backend.data.event_bus.redis.get_redis_async",
side_effect=ConnectionError("Authentication required."),
):
# Should not raise exception
await bus.publish_event(event, "test_channel")
@pytest.mark.asyncio
async def test_publish_event_works_with_redis_available():
"""Test that publish_event works normally when Redis is available."""
bus = TestNotificationBus()
event = TestEvent(message="test message")
# Mock successful Redis connection
mock_redis = AsyncMock()
mock_redis.publish = AsyncMock()
with patch("backend.data.event_bus.redis.get_redis_async", return_value=mock_redis):
await bus.publish_event(event, "test_channel")
mock_redis.publish.assert_called_once()

View File

@@ -81,10 +81,7 @@ class ExecutionContext(BaseModel):
This includes information needed by blocks, sub-graphs, and execution management. This includes information needed by blocks, sub-graphs, and execution management.
""" """
model_config = {"extra": "ignore"} safe_mode: bool = True
human_in_the_loop_safe_mode: bool = True
sensitive_action_safe_mode: bool = False
user_timezone: str = "UTC" user_timezone: str = "UTC"
root_execution_id: Optional[str] = None root_execution_id: Optional[str] = None
parent_execution_id: Optional[str] = None parent_execution_id: Optional[str] = None

View File

@@ -3,7 +3,7 @@ import logging
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Annotated, Any, Literal, Optional, cast from typing import TYPE_CHECKING, Any, Literal, Optional, cast
from prisma.enums import SubmissionStatus from prisma.enums import SubmissionStatus
from prisma.models import ( from prisma.models import (
@@ -20,7 +20,7 @@ from prisma.types import (
AgentNodeLinkCreateInput, AgentNodeLinkCreateInput,
StoreListingVersionWhereInput, StoreListingVersionWhereInput,
) )
from pydantic import BaseModel, BeforeValidator, Field, create_model from pydantic import BaseModel, Field, create_model
from pydantic.fields import computed_field from pydantic.fields import computed_field
from backend.blocks.agent import AgentExecutorBlock from backend.blocks.agent import AgentExecutorBlock
@@ -62,31 +62,7 @@ logger = logging.getLogger(__name__)
class GraphSettings(BaseModel): class GraphSettings(BaseModel):
# Use Annotated with BeforeValidator to coerce None to default values. human_in_the_loop_safe_mode: bool | None = None
# This handles cases where the database has null values for these fields.
model_config = {"extra": "ignore"}
human_in_the_loop_safe_mode: Annotated[
bool, BeforeValidator(lambda v: v if v is not None else True)
] = True
sensitive_action_safe_mode: Annotated[
bool, BeforeValidator(lambda v: v if v is not None else False)
] = False
@classmethod
def from_graph(
cls,
graph: "GraphModel",
hitl_safe_mode: bool | None = None,
sensitive_action_safe_mode: bool = False,
) -> "GraphSettings":
# Default to True if not explicitly set
if hitl_safe_mode is None:
hitl_safe_mode = True
return cls(
human_in_the_loop_safe_mode=hitl_safe_mode,
sensitive_action_safe_mode=sensitive_action_safe_mode,
)
class Link(BaseDbModel): class Link(BaseDbModel):
@@ -268,14 +244,10 @@ class BaseGraph(BaseDbModel):
return any( return any(
node.block_id node.block_id
for node in self.nodes for node in self.nodes
if node.block.block_type == BlockType.HUMAN_IN_THE_LOOP if (
node.block.block_type == BlockType.HUMAN_IN_THE_LOOP
or node.block.requires_human_review
) )
@computed_field
@property
def has_sensitive_action(self) -> bool:
return any(
node.block_id for node in self.nodes if node.block.is_sensitive_action
) )
@property @property

View File

@@ -211,6 +211,22 @@ class AgentRejectionData(BaseNotificationData):
return value return value
class WaitlistLaunchData(BaseNotificationData):
"""Notification data for when an agent from a waitlist is launched."""
agent_name: str
waitlist_name: str
store_url: str
launched_at: datetime
@field_validator("launched_at")
@classmethod
def validate_timezone(cls, value: datetime):
if value.tzinfo is None:
raise ValueError("datetime must have timezone information")
return value
NotificationData = Annotated[ NotificationData = Annotated[
Union[ Union[
AgentRunData, AgentRunData,
@@ -223,6 +239,7 @@ NotificationData = Annotated[
DailySummaryData, DailySummaryData,
RefundRequestData, RefundRequestData,
BaseSummaryData, BaseSummaryData,
WaitlistLaunchData,
], ],
Field(discriminator="type"), Field(discriminator="type"),
] ]
@@ -273,6 +290,7 @@ def get_notif_data_type(
NotificationType.REFUND_PROCESSED: RefundRequestData, NotificationType.REFUND_PROCESSED: RefundRequestData,
NotificationType.AGENT_APPROVED: AgentApprovalData, NotificationType.AGENT_APPROVED: AgentApprovalData,
NotificationType.AGENT_REJECTED: AgentRejectionData, NotificationType.AGENT_REJECTED: AgentRejectionData,
NotificationType.WAITLIST_LAUNCH: WaitlistLaunchData,
}[notification_type] }[notification_type]
@@ -318,6 +336,7 @@ class NotificationTypeOverride:
NotificationType.REFUND_PROCESSED: QueueType.ADMIN, NotificationType.REFUND_PROCESSED: QueueType.ADMIN,
NotificationType.AGENT_APPROVED: QueueType.IMMEDIATE, NotificationType.AGENT_APPROVED: QueueType.IMMEDIATE,
NotificationType.AGENT_REJECTED: QueueType.IMMEDIATE, NotificationType.AGENT_REJECTED: QueueType.IMMEDIATE,
NotificationType.WAITLIST_LAUNCH: QueueType.IMMEDIATE,
} }
return BATCHING_RULES.get(self.notification_type, QueueType.IMMEDIATE) return BATCHING_RULES.get(self.notification_type, QueueType.IMMEDIATE)
@@ -337,6 +356,7 @@ class NotificationTypeOverride:
NotificationType.REFUND_PROCESSED: "refund_processed.html", NotificationType.REFUND_PROCESSED: "refund_processed.html",
NotificationType.AGENT_APPROVED: "agent_approved.html", NotificationType.AGENT_APPROVED: "agent_approved.html",
NotificationType.AGENT_REJECTED: "agent_rejected.html", NotificationType.AGENT_REJECTED: "agent_rejected.html",
NotificationType.WAITLIST_LAUNCH: "waitlist_launch.html",
}[self.notification_type] }[self.notification_type]
@property @property
@@ -354,6 +374,7 @@ class NotificationTypeOverride:
NotificationType.REFUND_PROCESSED: "Refund for ${{data.amount / 100}} to {{data.user_name}} has been processed", NotificationType.REFUND_PROCESSED: "Refund for ${{data.amount / 100}} to {{data.user_name}} has been processed",
NotificationType.AGENT_APPROVED: "🎉 Your agent '{{data.agent_name}}' has been approved!", NotificationType.AGENT_APPROVED: "🎉 Your agent '{{data.agent_name}}' has been approved!",
NotificationType.AGENT_REJECTED: "Your agent '{{data.agent_name}}' needs some updates", NotificationType.AGENT_REJECTED: "Your agent '{{data.agent_name}}' needs some updates",
NotificationType.WAITLIST_LAUNCH: "🚀 {{data.agent_name}} is now available!",
}[self.notification_type] }[self.notification_type]

View File

@@ -309,7 +309,7 @@ def ensure_embeddings_coverage():
# Process in batches until no more missing embeddings # Process in batches until no more missing embeddings
while True: while True:
result = db_client.backfill_missing_embeddings(batch_size=100) result = db_client.backfill_missing_embeddings(batch_size=10)
total_processed += result["processed"] total_processed += result["processed"]
total_success += result["success"] total_success += result["success"]

View File

@@ -873,8 +873,11 @@ async def add_graph_execution(
settings = await gdb.get_graph_settings(user_id=user_id, graph_id=graph_id) settings = await gdb.get_graph_settings(user_id=user_id, graph_id=graph_id)
execution_context = ExecutionContext( execution_context = ExecutionContext(
human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode, safe_mode=(
sensitive_action_safe_mode=settings.sensitive_action_safe_mode, settings.human_in_the_loop_safe_mode
if settings.human_in_the_loop_safe_mode is not None
else True
),
user_timezone=( user_timezone=(
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC" user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
), ),

View File

@@ -386,7 +386,6 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture):
mock_user.timezone = "UTC" mock_user.timezone = "UTC"
mock_settings = mocker.MagicMock() mock_settings = mocker.MagicMock()
mock_settings.human_in_the_loop_safe_mode = True mock_settings.human_in_the_loop_safe_mode = True
mock_settings.sensitive_action_safe_mode = False
mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user) mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user)
mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings) mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings)
@@ -652,7 +651,6 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture):
mock_user.timezone = "UTC" mock_user.timezone = "UTC"
mock_settings = mocker.MagicMock() mock_settings = mocker.MagicMock()
mock_settings.human_in_the_loop_safe_mode = True mock_settings.human_in_the_loop_safe_mode = True
mock_settings.sensitive_action_safe_mode = False
mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user) mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user)
mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings) mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings)

View File

@@ -0,0 +1,59 @@
{# Waitlist Launch Notification Email Template #}
{#
Template variables:
data.agent_name: the name of the launched agent
data.waitlist_name: the name of the waitlist the user joined
data.store_url: URL to view the agent in the store
data.launched_at: when the agent was launched
Subject: {{ data.agent_name }} is now available!
#}
{% block content %}
<h1 style="color: #7c3aed; font-size: 32px; font-weight: 700; margin: 0 0 24px 0; text-align: center;">
The wait is over!
</h1>
<p style="color: #586069; font-size: 18px; text-align: center; margin: 0 0 24px 0;">
<strong>'{{ data.agent_name }}'</strong> is now live in the AutoGPT Store!
</p>
<div style="height: 32px; background: transparent;"></div>
<div style="background: #f3e8ff; border: 1px solid #d8b4fe; border-radius: 8px; padding: 20px; margin: 0;">
<h3 style="color: #6b21a8; font-size: 16px; font-weight: 600; margin: 0 0 12px 0;">
You're one of the first to know!
</h3>
<p style="color: #6b21a8; margin: 0; font-size: 16px; line-height: 1.5;">
You signed up for the <strong>{{ data.waitlist_name }}</strong> waitlist, and we're excited to let you know that this agent is now ready for you to use.
</p>
</div>
<div style="height: 32px; background: transparent;"></div>
<div style="text-align: center; margin: 24px 0;">
<a href="{{ data.store_url }}" style="display: inline-block; background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%); color: white; text-decoration: none; padding: 14px 28px; border-radius: 6px; font-weight: 600; font-size: 16px;">
Get {{ data.agent_name }} Now
</a>
</div>
<div style="height: 32px; background: transparent;"></div>
<div style="background: #d1ecf1; border: 1px solid #bee5eb; border-radius: 8px; padding: 20px; margin: 0;">
<h3 style="color: #0c5460; font-size: 16px; font-weight: 600; margin: 0 0 12px 0;">
What can you do now?
</h3>
<ul style="color: #0c5460; margin: 0; padding-left: 18px; font-size: 16px; line-height: 1.6;">
<li>Visit the store to learn more about what this agent can do</li>
<li>Install and start using the agent right away</li>
<li>Share it with others who might find it useful</li>
</ul>
</div>
<div style="height: 32px; background: transparent;"></div>
<p style="color: #6a737d; font-size: 14px; text-align: center; margin: 24px 0;">
Thank you for helping us prioritize what to build! Your interest made this happen.
</p>
{% endblock %}

View File

@@ -0,0 +1,53 @@
-- CreateEnum
CREATE TYPE "WaitlistExternalStatus" AS ENUM ('DONE', 'NOT_STARTED', 'CANCELED', 'WORK_IN_PROGRESS');
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'WAITLIST_LAUNCH';
-- CreateTable
CREATE TABLE "WaitlistEntry" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"storeListingId" TEXT,
"owningUserId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"search" tsvector DEFAULT ''::tsvector,
"name" TEXT NOT NULL,
"subHeading" TEXT NOT NULL,
"videoUrl" TEXT,
"agentOutputDemoUrl" TEXT,
"imageUrls" TEXT[],
"description" TEXT NOT NULL,
"categories" TEXT[],
"status" "WaitlistExternalStatus" NOT NULL DEFAULT 'NOT_STARTED',
"votes" INTEGER NOT NULL DEFAULT 0,
"unaffiliatedEmailUsers" TEXT[] DEFAULT ARRAY[]::TEXT[],
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_joinedWaitlists" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "_joinedWaitlists_AB_unique" ON "_joinedWaitlists"("A", "B");
-- CreateIndex
CREATE INDEX "_joinedWaitlists_B_index" ON "_joinedWaitlists"("B");
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_storeListingId_fkey" FOREIGN KEY ("storeListingId") REFERENCES "StoreListing"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_owningUserId_fkey" FOREIGN KEY ("owningUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_joinedWaitlists" ADD CONSTRAINT "_joinedWaitlists_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_joinedWaitlists" ADD CONSTRAINT "_joinedWaitlists_B_fkey" FOREIGN KEY ("B") REFERENCES "WaitlistEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,10 +1,9 @@
-- CreateExtension -- CreateExtension
-- Supabase: pgvector must be enabled via Dashboard → Database → Extensions first -- Supabase: pgvector must be enabled via Dashboard → Database → Extensions first
-- Creates extension in current schema (determined by search_path from DATABASE_URL ?schema= param) -- Create in public schema so vector type is available across all schemas
-- This ensures vector type is in the same schema as tables, making ::vector work without explicit qualification
DO $$ DO $$
BEGIN BEGIN
CREATE EXTENSION IF NOT EXISTS "vector"; CREATE EXTENSION IF NOT EXISTS "vector" WITH SCHEMA "public";
EXCEPTION WHEN OTHERS THEN EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'vector extension not available or already exists, skipping'; RAISE NOTICE 'vector extension not available or already exists, skipping';
END $$; END $$;
@@ -20,7 +19,7 @@ CREATE TABLE "UnifiedContentEmbedding" (
"contentType" "ContentType" NOT NULL, "contentType" "ContentType" NOT NULL,
"contentId" TEXT NOT NULL, "contentId" TEXT NOT NULL,
"userId" TEXT, "userId" TEXT,
"embedding" vector(1536) NOT NULL, "embedding" public.vector(1536) NOT NULL,
"searchableText" TEXT NOT NULL, "searchableText" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}', "metadata" JSONB NOT NULL DEFAULT '{}',
@@ -46,4 +45,4 @@ CREATE UNIQUE INDEX "UnifiedContentEmbedding_contentType_contentId_userId_key" O
-- Uses cosine distance operator (<=>), which matches the query in hybrid_search.py -- Uses cosine distance operator (<=>), which matches the query in hybrid_search.py
-- Note: Drop first in case Prisma created a btree index (Prisma doesn't support HNSW) -- Note: Drop first in case Prisma created a btree index (Prisma doesn't support HNSW)
DROP INDEX IF EXISTS "UnifiedContentEmbedding_embedding_idx"; DROP INDEX IF EXISTS "UnifiedContentEmbedding_embedding_idx";
CREATE INDEX "UnifiedContentEmbedding_embedding_idx" ON "UnifiedContentEmbedding" USING hnsw ("embedding" vector_cosine_ops); CREATE INDEX "UnifiedContentEmbedding_embedding_idx" ON "UnifiedContentEmbedding" USING hnsw ("embedding" public.vector_cosine_ops);

View File

@@ -69,6 +69,10 @@ model User {
OAuthAuthorizationCodes OAuthAuthorizationCode[] OAuthAuthorizationCodes OAuthAuthorizationCode[]
OAuthAccessTokens OAuthAccessToken[] OAuthAccessTokens OAuthAccessToken[]
OAuthRefreshTokens OAuthRefreshToken[] OAuthRefreshTokens OAuthRefreshToken[]
// Waitlist relations
waitlistEntries WaitlistEntry[]
joinedWaitlists WaitlistEntry[] @relation("joinedWaitlists")
} }
enum OnboardingStep { enum OnboardingStep {
@@ -295,6 +299,7 @@ enum NotificationType {
REFUND_PROCESSED REFUND_PROCESSED
AGENT_APPROVED AGENT_APPROVED
AGENT_REJECTED AGENT_REJECTED
WAITLIST_LAUNCH
} }
model NotificationEvent { model NotificationEvent {
@@ -902,6 +907,7 @@ model StoreListing {
// Relations // Relations
Versions StoreListingVersion[] @relation("ListingVersions") Versions StoreListingVersion[] @relation("ListingVersions")
waitlistEntries WaitlistEntry[]
// Unique index on agentId to ensure only one listing per agent, regardless of number of versions the agent has. // Unique index on agentId to ensure only one listing per agent, regardless of number of versions the agent has.
@@unique([agentGraphId]) @@unique([agentGraphId])
@@ -1033,6 +1039,47 @@ model StoreListingReview {
@@index([reviewByUserId]) @@index([reviewByUserId])
} }
enum WaitlistExternalStatus {
DONE
NOT_STARTED
CANCELED
WORK_IN_PROGRESS
}
model WaitlistEntry {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
storeListingId String?
StoreListing StoreListing? @relation(fields: [storeListingId], references: [id], onDelete: SetNull)
owningUserId String
OwningUser User @relation(fields: [owningUserId], references: [id])
slug String
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
// Content fields
name String
subHeading String
videoUrl String?
agentOutputDemoUrl String?
imageUrls String[]
description String
categories String[]
//Waitlist specific fields
status WaitlistExternalStatus @default(NOT_STARTED)
votes Int @default(0) // Hide from frontend api
joinedUsers User[] @relation("joinedWaitlists")
// NOTE: DO NOT DOUBLE SEND TO THESE USERS, IF THEY HAVE SIGNED UP SINCE THEY MAY HAVE ALREADY RECEIVED AN EMAIL
// DOUBLE CHECK WHEN SENDING THAT THEY ARE NOT IN THE JOINED USERS LIST ALSO
unaffiliatedEmailUsers String[] @default([])
isDeleted Boolean @default(false)
}
enum SubmissionStatus { enum SubmissionStatus {
DRAFT // Being prepared, not yet submitted DRAFT // Being prepared, not yet submitted
PENDING // Submitted, awaiting review PENDING // Submitted, awaiting review

View File

@@ -366,12 +366,12 @@ def generate_block_markdown(
lines.append("") lines.append("")
# What it is (full description) # What it is (full description)
lines.append("### What it is") lines.append(f"### What it is")
lines.append(block.description or "No description available.") lines.append(block.description or "No description available.")
lines.append("") lines.append("")
# How it works (manual section) # How it works (manual section)
lines.append("### How it works") lines.append(f"### How it works")
how_it_works = manual_content.get( how_it_works = manual_content.get(
"how_it_works", "_Add technical explanation here._" "how_it_works", "_Add technical explanation here._"
) )
@@ -383,7 +383,7 @@ def generate_block_markdown(
# Inputs table (auto-generated) # Inputs table (auto-generated)
visible_inputs = [f for f in block.inputs if not f.hidden] visible_inputs = [f for f in block.inputs if not f.hidden]
if visible_inputs: if visible_inputs:
lines.append("### Inputs") lines.append(f"### Inputs")
lines.append("") lines.append("")
lines.append("| Input | Description | Type | Required |") lines.append("| Input | Description | Type | Required |")
lines.append("|-------|-------------|------|----------|") lines.append("|-------|-------------|------|----------|")
@@ -400,7 +400,7 @@ def generate_block_markdown(
# Outputs table (auto-generated) # Outputs table (auto-generated)
visible_outputs = [f for f in block.outputs if not f.hidden] visible_outputs = [f for f in block.outputs if not f.hidden]
if visible_outputs: if visible_outputs:
lines.append("### Outputs") lines.append(f"### Outputs")
lines.append("") lines.append("")
lines.append("| Output | Description | Type |") lines.append("| Output | Description | Type |")
lines.append("|--------|-------------|------|") lines.append("|--------|-------------|------|")
@@ -414,7 +414,7 @@ def generate_block_markdown(
lines.append("") lines.append("")
# Possible use case (manual section) # Possible use case (manual section)
lines.append("### Possible use case") lines.append(f"### Possible use case")
use_case = manual_content.get("use_case", "_Add practical use case examples here._") use_case = manual_content.get("use_case", "_Add practical use case examples here._")
lines.append("<!-- MANUAL: use_case -->") lines.append("<!-- MANUAL: use_case -->")
lines.append(use_case) lines.append(use_case)

View File

@@ -11,7 +11,6 @@
"forked_from_version": null, "forked_from_version": null,
"has_external_trigger": false, "has_external_trigger": false,
"has_human_in_the_loop": false, "has_human_in_the_loop": false,
"has_sensitive_action": false,
"id": "graph-123", "id": "graph-123",
"input_schema": { "input_schema": {
"properties": {}, "properties": {},

View File

@@ -11,7 +11,6 @@
"forked_from_version": null, "forked_from_version": null,
"has_external_trigger": false, "has_external_trigger": false,
"has_human_in_the_loop": false, "has_human_in_the_loop": false,
"has_sensitive_action": false,
"id": "graph-123", "id": "graph-123",
"input_schema": { "input_schema": {
"properties": {}, "properties": {},

View File

@@ -27,8 +27,6 @@
"properties": {} "properties": {}
}, },
"has_external_trigger": false, "has_external_trigger": false,
"has_human_in_the_loop": false,
"has_sensitive_action": false,
"trigger_setup_info": null, "trigger_setup_info": null,
"new_output": false, "new_output": false,
"can_access_graph": true, "can_access_graph": true,
@@ -36,8 +34,7 @@
"is_favorite": false, "is_favorite": false,
"recommended_schedule_cron": null, "recommended_schedule_cron": null,
"settings": { "settings": {
"human_in_the_loop_safe_mode": true, "human_in_the_loop_safe_mode": null
"sensitive_action_safe_mode": false
}, },
"marketplace_listing": null "marketplace_listing": null
}, },
@@ -68,8 +65,6 @@
"properties": {} "properties": {}
}, },
"has_external_trigger": false, "has_external_trigger": false,
"has_human_in_the_loop": false,
"has_sensitive_action": false,
"trigger_setup_info": null, "trigger_setup_info": null,
"new_output": false, "new_output": false,
"can_access_graph": false, "can_access_graph": false,
@@ -77,8 +72,7 @@
"is_favorite": false, "is_favorite": false,
"recommended_schedule_cron": null, "recommended_schedule_cron": null,
"settings": { "settings": {
"human_in_the_loop_safe_mode": true, "human_in_the_loop_safe_mode": null
"sensitive_action_safe_mode": false
}, },
"marketplace_listing": null "marketplace_listing": null
} }

View File

@@ -175,8 +175,6 @@ While server components and actions are cool and cutting-edge, they introduce a
- Prefer [React Query](https://tanstack.com/query/latest/docs/framework/react/overview) for server state, colocated near consumers (see [state colocation](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster)) - Prefer [React Query](https://tanstack.com/query/latest/docs/framework/react/overview) for server state, colocated near consumers (see [state colocation](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster))
- Co-locate UI state inside components/hooks; keep global state minimal - Co-locate UI state inside components/hooks; keep global state minimal
- Avoid `useMemo` and `useCallback` unless you have a measured performance issue
- Do not abuse `useEffect`; prefer state colocation and derive values directly when possible
### Styling and components ### Styling and components
@@ -551,48 +549,9 @@ Files:
Types: Types:
- Prefer `interface` for object shapes - Prefer `interface` for object shapes
- Component props should be `interface Props { ... }` (not exported) - Component props should be `interface Props { ... }`
- Only use specific exported names (e.g., `export interface MyComponentProps`) when the interface needs to be used outside the component
- Keep type definitions inline with the component - do not create separate `types.ts` files unless types are shared across multiple files
- Use precise types; avoid `any` and unsafe casts - Use precise types; avoid `any` and unsafe casts
**Props naming examples:**
```tsx
// ✅ Good - internal props, not exported
interface Props {
title: string;
onClose: () => void;
}
export function Modal({ title, onClose }: Props) {
// ...
}
// ✅ Good - exported when needed externally
export interface ModalProps {
title: string;
onClose: () => void;
}
export function Modal({ title, onClose }: ModalProps) {
// ...
}
// ❌ Bad - unnecessarily specific name for internal use
interface ModalComponentProps {
title: string;
onClose: () => void;
}
// ❌ Bad - separate types.ts file for single component
// types.ts
export interface ModalProps { ... }
// Modal.tsx
import type { ModalProps } from './types';
```
Parameters: Parameters:
- If more than one parameter is needed, pass a single `Args` object for clarity - If more than one parameter is needed, pass a single `Args` object for clarity

View File

@@ -16,12 +16,6 @@ export default defineConfig({
client: "react-query", client: "react-query",
httpClient: "fetch", httpClient: "fetch",
indexFiles: false, indexFiles: false,
mock: {
type: "msw",
baseUrl: "http://localhost:3000/api/proxy",
generateEachHttpStatus: true,
delay: 0,
},
override: { override: {
mutator: { mutator: {
path: "./mutators/custom-mutator.ts", path: "./mutators/custom-mutator.ts",

View File

@@ -15,8 +15,6 @@
"types": "tsc --noEmit", "types": "tsc --noEmit",
"test": "NEXT_PUBLIC_PW_TEST=true next build --turbo && playwright test", "test": "NEXT_PUBLIC_PW_TEST=true next build --turbo && playwright test",
"test-ui": "NEXT_PUBLIC_PW_TEST=true next build --turbo && playwright test --ui", "test-ui": "NEXT_PUBLIC_PW_TEST=true next build --turbo && playwright test --ui",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:no-build": "playwright test", "test:no-build": "playwright test",
"gentests": "playwright codegen http://localhost:3000", "gentests": "playwright codegen http://localhost:3000",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
@@ -120,7 +118,6 @@
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "4.1.2", "@chromatic-com/storybook": "4.1.2",
"happy-dom": "20.3.4",
"@opentelemetry/instrumentation": "0.209.0", "@opentelemetry/instrumentation": "0.209.0",
"@playwright/test": "1.56.1", "@playwright/test": "1.56.1",
"@storybook/addon-a11y": "9.1.5", "@storybook/addon-a11y": "9.1.5",
@@ -130,8 +127,6 @@
"@storybook/nextjs": "9.1.5", "@storybook/nextjs": "9.1.5",
"@tanstack/eslint-plugin-query": "5.91.2", "@tanstack/eslint-plugin-query": "5.91.2",
"@tanstack/react-query-devtools": "5.90.2", "@tanstack/react-query-devtools": "5.90.2",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/canvas-confetti": "1.9.0", "@types/canvas-confetti": "1.9.0",
"@types/lodash": "4.17.20", "@types/lodash": "4.17.20",
"@types/negotiator": "0.6.4", "@types/negotiator": "0.6.4",
@@ -140,7 +135,6 @@
"@types/react-dom": "18.3.5", "@types/react-dom": "18.3.5",
"@types/react-modal": "3.16.3", "@types/react-modal": "3.16.3",
"@types/react-window": "1.8.8", "@types/react-window": "1.8.8",
"@vitejs/plugin-react": "5.1.2",
"axe-playwright": "2.2.2", "axe-playwright": "2.2.2",
"chromatic": "13.3.3", "chromatic": "13.3.3",
"concurrently": "9.2.1", "concurrently": "9.2.1",
@@ -159,9 +153,7 @@
"require-in-the-middle": "8.0.1", "require-in-the-middle": "8.0.1",
"storybook": "9.1.5", "storybook": "9.1.5",
"tailwindcss": "3.4.17", "tailwindcss": "3.4.17",
"typescript": "5.9.3", "typescript": "5.9.3"
"vite-tsconfig-paths": "6.0.4",
"vitest": "4.0.17"
}, },
"msw": { "msw": {
"workerDirectory": [ "workerDirectory": [

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,58 +0,0 @@
"use client";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
const LOGOUT_REDIRECT_DELAY_MS = 400;
function wait(ms: number): Promise<void> {
return new Promise(function resolveAfterDelay(resolve) {
setTimeout(resolve, ms);
});
}
export default function LogoutPage() {
const { logOut } = useSupabase();
const { toast } = useToast();
const router = useRouter();
const hasStartedRef = useRef(false);
useEffect(
function handleLogoutEffect() {
if (hasStartedRef.current) return;
hasStartedRef.current = true;
async function runLogout() {
try {
await logOut();
} catch {
toast({
title: "Failed to log out. Redirecting to login.",
variant: "destructive",
});
} finally {
await wait(LOGOUT_REDIRECT_DELAY_MS);
router.replace("/login");
}
}
void runLogout();
},
[logOut, router, toast],
);
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="flex flex-col items-center justify-center gap-4 py-8">
<LoadingSpinner size="large" />
<Text variant="body" className="text-center">
Logging you out...
</Text>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { Sidebar } from "@/components/__legacy__/Sidebar"; import { Sidebar } from "@/components/__legacy__/Sidebar";
import { Users, DollarSign, UserSearch, FileText } from "lucide-react"; import { Users, DollarSign, UserSearch, FileText, Clock } from "lucide-react";
import { IconSliders } from "@/components/__legacy__/ui/icons"; import { IconSliders } from "@/components/__legacy__/ui/icons";
@@ -11,6 +11,11 @@ const sidebarLinkGroups = [
href: "/admin/marketplace", href: "/admin/marketplace",
icon: <Users className="h-6 w-6" />, icon: <Users className="h-6 w-6" />,
}, },
{
text: "Waitlist Management",
href: "/admin/waitlist",
icon: <Clock className="h-6 w-6" />,
},
{ {
text: "User Spending", text: "User Spending",
href: "/admin/spending", href: "/admin/spending",

View File

@@ -0,0 +1,217 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
usePostV2CreateWaitlist,
getGetV2ListAllWaitlistsQueryKey,
} from "@/app/api/__generated__/endpoints/admin/admin";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { Plus } from "@phosphor-icons/react";
export function CreateWaitlistButton() {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const queryClient = useQueryClient();
const createWaitlistMutation = usePostV2CreateWaitlist({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Success",
description: "Waitlist created successfully",
});
setOpen(false);
setFormData({
name: "",
slug: "",
subHeading: "",
description: "",
categories: "",
imageUrls: "",
videoUrl: "",
agentOutputDemoUrl: "",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListAllWaitlistsQueryKey(),
});
} else {
toast({
variant: "destructive",
title: "Error",
description: "Failed to create waitlist",
});
}
},
onError: (error) => {
console.error("Error creating waitlist:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to create waitlist",
});
},
},
});
const [formData, setFormData] = useState({
name: "",
slug: "",
subHeading: "",
description: "",
categories: "",
imageUrls: "",
videoUrl: "",
agentOutputDemoUrl: "",
});
function handleInputChange(id: string, value: string) {
setFormData((prev) => ({
...prev,
[id]: value,
}));
}
function generateSlug(name: string) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
createWaitlistMutation.mutate({
data: {
name: formData.name,
slug: formData.slug || generateSlug(formData.name),
subHeading: formData.subHeading,
description: formData.description,
categories: formData.categories
? formData.categories.split(",").map((c) => c.trim())
: [],
imageUrls: formData.imageUrls
? formData.imageUrls.split(",").map((u) => u.trim())
: [],
videoUrl: formData.videoUrl || null,
agentOutputDemoUrl: formData.agentOutputDemoUrl || null,
},
});
}
return (
<>
<Button onClick={() => setOpen(true)}>
<Plus size={16} className="mr-2" />
Create Waitlist
</Button>
<Dialog
title="Create New Waitlist"
controlled={{
isOpen: open,
set: async (isOpen) => setOpen(isOpen),
}}
onClose={() => setOpen(false)}
styling={{ maxWidth: "600px" }}
>
<Dialog.Content>
<p className="mb-4 text-sm text-zinc-500">
Create a new waitlist for an upcoming agent. Users can sign up to be
notified when it launches.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<Input
id="name"
label="Name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
placeholder="SEO Analysis Agent"
required
/>
<Input
id="slug"
label="Slug"
value={formData.slug}
onChange={(e) => handleInputChange("slug", e.target.value)}
placeholder="seo-analysis-agent (auto-generated if empty)"
/>
<Input
id="subHeading"
label="Subheading"
value={formData.subHeading}
onChange={(e) => handleInputChange("subHeading", e.target.value)}
placeholder="Analyze your website's SEO in minutes"
required
/>
<Input
id="description"
label="Description"
type="textarea"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="Detailed description of what this agent does..."
rows={4}
required
/>
<Input
id="categories"
label="Categories (comma-separated)"
value={formData.categories}
onChange={(e) => handleInputChange("categories", e.target.value)}
placeholder="SEO, Marketing, Analysis"
/>
<Input
id="imageUrls"
label="Image URLs (comma-separated)"
value={formData.imageUrls}
onChange={(e) => handleInputChange("imageUrls", e.target.value)}
placeholder="https://example.com/image1.jpg, https://example.com/image2.jpg"
/>
<Input
id="videoUrl"
label="Video URL (optional)"
value={formData.videoUrl}
onChange={(e) => handleInputChange("videoUrl", e.target.value)}
placeholder="https://youtube.com/watch?v=..."
/>
<Input
id="agentOutputDemoUrl"
label="Output Demo URL (optional)"
value={formData.agentOutputDemoUrl}
onChange={(e) =>
handleInputChange("agentOutputDemoUrl", e.target.value)
}
placeholder="https://example.com/demo-output.mp4"
/>
<Dialog.Footer>
<Button
type="button"
variant="secondary"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={createWaitlistMutation.isPending}>
Create Waitlist
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,221 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Select } from "@/components/atoms/Select/Select";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { usePutV2UpdateWaitlist } from "@/app/api/__generated__/endpoints/admin/admin";
import type { WaitlistAdminResponse } from "@/app/api/__generated__/models/waitlistAdminResponse";
import type { WaitlistUpdateRequest } from "@/app/api/__generated__/models/waitlistUpdateRequest";
import { WaitlistExternalStatus } from "@/app/api/__generated__/models/waitlistExternalStatus";
type EditWaitlistDialogProps = {
waitlist: WaitlistAdminResponse;
onClose: () => void;
onSave: () => void;
};
const STATUS_OPTIONS = [
{ value: WaitlistExternalStatus.NOT_STARTED, label: "Not Started" },
{ value: WaitlistExternalStatus.WORK_IN_PROGRESS, label: "Work In Progress" },
{ value: WaitlistExternalStatus.DONE, label: "Done" },
{ value: WaitlistExternalStatus.CANCELED, label: "Canceled" },
];
export function EditWaitlistDialog({
waitlist,
onClose,
onSave,
}: EditWaitlistDialogProps) {
const { toast } = useToast();
const updateWaitlistMutation = usePutV2UpdateWaitlist();
const [formData, setFormData] = useState({
name: waitlist.name,
slug: waitlist.slug,
subHeading: waitlist.subHeading,
description: waitlist.description,
categories: waitlist.categories.join(", "),
imageUrls: waitlist.imageUrls.join(", "),
videoUrl: waitlist.videoUrl || "",
agentOutputDemoUrl: waitlist.agentOutputDemoUrl || "",
status: waitlist.status,
storeListingId: waitlist.storeListingId || "",
});
function handleInputChange(id: string, value: string) {
setFormData((prev) => ({
...prev,
[id]: value,
}));
}
function handleStatusChange(value: string) {
setFormData((prev) => ({
...prev,
status: value as WaitlistExternalStatus,
}));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const updateData: WaitlistUpdateRequest = {
name: formData.name,
slug: formData.slug,
subHeading: formData.subHeading,
description: formData.description,
categories: formData.categories
? formData.categories.split(",").map((c) => c.trim())
: [],
imageUrls: formData.imageUrls
? formData.imageUrls.split(",").map((u) => u.trim())
: [],
videoUrl: formData.videoUrl || null,
agentOutputDemoUrl: formData.agentOutputDemoUrl || null,
status: formData.status,
storeListingId: formData.storeListingId || null,
};
updateWaitlistMutation.mutate(
{ waitlistId: waitlist.id, data: updateData },
{
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Success",
description: "Waitlist updated successfully",
});
onSave();
} else {
toast({
variant: "destructive",
title: "Error",
description: "Failed to update waitlist",
});
}
},
onError: () => {
toast({
variant: "destructive",
title: "Error",
description: "Failed to update waitlist",
});
},
},
);
}
return (
<Dialog
title="Edit Waitlist"
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "600px" }}
>
<Dialog.Content>
<p className="mb-4 text-sm text-zinc-500">
Update the waitlist details. Changes will be reflected immediately.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<Input
id="name"
label="Name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
required
/>
<Input
id="slug"
label="Slug"
value={formData.slug}
onChange={(e) => handleInputChange("slug", e.target.value)}
/>
<Input
id="subHeading"
label="Subheading"
value={formData.subHeading}
onChange={(e) => handleInputChange("subHeading", e.target.value)}
required
/>
<Input
id="description"
label="Description"
type="textarea"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
rows={4}
required
/>
<Select
id="status"
label="Status"
value={formData.status}
onValueChange={handleStatusChange}
options={STATUS_OPTIONS}
/>
<Input
id="categories"
label="Categories (comma-separated)"
value={formData.categories}
onChange={(e) => handleInputChange("categories", e.target.value)}
/>
<Input
id="imageUrls"
label="Image URLs (comma-separated)"
value={formData.imageUrls}
onChange={(e) => handleInputChange("imageUrls", e.target.value)}
/>
<Input
id="videoUrl"
label="Video URL"
value={formData.videoUrl}
onChange={(e) => handleInputChange("videoUrl", e.target.value)}
/>
<Input
id="agentOutputDemoUrl"
label="Output Demo URL"
value={formData.agentOutputDemoUrl}
onChange={(e) =>
handleInputChange("agentOutputDemoUrl", e.target.value)
}
/>
<Input
id="storeListingId"
label="Store Listing ID (for linking)"
value={formData.storeListingId}
onChange={(e) =>
handleInputChange("storeListingId", e.target.value)
}
placeholder="Leave empty if not linked"
/>
<Dialog.Footer>
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" loading={updateWaitlistMutation.isPending}>
Save Changes
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { User, Envelope, DownloadSimple } from "@phosphor-icons/react";
import { useGetV2GetWaitlistSignups } from "@/app/api/__generated__/endpoints/admin/admin";
type WaitlistSignupsDialogProps = {
waitlistId: string;
onClose: () => void;
};
export function WaitlistSignupsDialog({
waitlistId,
onClose,
}: WaitlistSignupsDialogProps) {
const {
data: signupsResponse,
isLoading,
isError,
} = useGetV2GetWaitlistSignups(waitlistId);
const signups = signupsResponse?.status === 200 ? signupsResponse.data : null;
function exportToCSV() {
if (!signups) return;
const headers = ["Type", "Email", "User ID", "Username"];
const rows = signups.signups.map((signup) => [
signup.type,
signup.email || "",
signup.userId || "",
signup.username || "",
]);
const escapeCell = (cell: string) => `"${cell.replace(/"/g, '""')}"`;
const csvContent = [
headers.join(","),
...rows.map((row) => row.map(escapeCell).join(",")),
].join("\n");
const blob = new Blob([csvContent], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `waitlist-${waitlistId}-signups.csv`;
a.click();
window.URL.revokeObjectURL(url);
}
function renderContent() {
if (isLoading) {
return <div className="py-10 text-center">Loading signups...</div>;
}
if (isError) {
return (
<div className="py-10 text-center text-red-500">
Failed to load signups. Please try again.
</div>
);
}
if (!signups || signups.signups.length === 0) {
return (
<div className="py-10 text-center text-gray-500">
No signups yet for this waitlist.
</div>
);
}
return (
<>
<div className="flex justify-end">
<Button variant="secondary" size="small" onClick={exportToCSV}>
<DownloadSimple className="mr-2 h-4 w-4" size={16} />
Export CSV
</Button>
</div>
<div className="max-h-[400px] overflow-y-auto rounded-md border">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">
Type
</th>
<th className="px-4 py-3 text-left text-sm font-medium">
Email / Username
</th>
<th className="px-4 py-3 text-left text-sm font-medium">
User ID
</th>
</tr>
</thead>
<tbody className="divide-y">
{signups.signups.map((signup, index) => (
<tr key={index}>
<td className="px-4 py-3">
{signup.type === "user" ? (
<span className="flex items-center gap-1 text-blue-600">
<User className="h-4 w-4" size={16} /> User
</span>
) : (
<span className="flex items-center gap-1 text-gray-600">
<Envelope className="h-4 w-4" size={16} /> Email
</span>
)}
</td>
<td className="px-4 py-3">
{signup.type === "user"
? signup.username || signup.email
: signup.email}
</td>
<td className="px-4 py-3 font-mono text-sm">
{signup.userId || "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
return (
<Dialog
title="Waitlist Signups"
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "700px" }}
>
<Dialog.Content>
<p className="mb-4 text-sm text-zinc-500">
{signups
? `${signups.totalCount} total signups`
: "Loading signups..."}
</p>
{renderContent()}
<Dialog.Footer>
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/__legacy__/ui/table";
import { Button } from "@/components/atoms/Button/Button";
import {
useGetV2ListAllWaitlists,
useDeleteV2DeleteWaitlist,
getGetV2ListAllWaitlistsQueryKey,
} from "@/app/api/__generated__/endpoints/admin/admin";
import type { WaitlistAdminResponse } from "@/app/api/__generated__/models/waitlistAdminResponse";
import { EditWaitlistDialog } from "./EditWaitlistDialog";
import { WaitlistSignupsDialog } from "./WaitlistSignupsDialog";
import { Trash, PencilSimple, Users, Link } from "@phosphor-icons/react";
import { useToast } from "@/components/molecules/Toast/use-toast";
export function WaitlistTable() {
const [editingWaitlist, setEditingWaitlist] =
useState<WaitlistAdminResponse | null>(null);
const [viewingSignups, setViewingSignups] = useState<string | null>(null);
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: response, isLoading, error } = useGetV2ListAllWaitlists();
const deleteWaitlistMutation = useDeleteV2DeleteWaitlist({
mutation: {
onSuccess: () => {
toast({
title: "Success",
description: "Waitlist deleted successfully",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListAllWaitlistsQueryKey(),
});
},
onError: (error) => {
console.error("Error deleting waitlist:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to delete waitlist",
});
},
},
});
function handleDelete(waitlistId: string) {
if (!confirm("Are you sure you want to delete this waitlist?")) return;
deleteWaitlistMutation.mutate({ waitlistId });
}
function handleWaitlistSaved() {
setEditingWaitlist(null);
queryClient.invalidateQueries({
queryKey: getGetV2ListAllWaitlistsQueryKey(),
});
}
function formatStatus(status: string) {
const statusColors: Record<string, string> = {
NOT_STARTED: "bg-gray-100 text-gray-800",
WORK_IN_PROGRESS: "bg-blue-100 text-blue-800",
DONE: "bg-green-100 text-green-800",
CANCELED: "bg-red-100 text-red-800",
};
return (
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${statusColors[status] || "bg-gray-100 text-gray-700"}`}
>
{status.replace(/_/g, " ")}
</span>
);
}
function formatDate(dateStr: string) {
if (!dateStr) return "-";
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(dateStr));
}
if (isLoading) {
return <div className="py-10 text-center">Loading waitlists...</div>;
}
if (error) {
return (
<div className="py-10 text-center text-red-500">
Error loading waitlists. Please try again.
</div>
);
}
const waitlists = response?.status === 200 ? response.data.waitlists : [];
if (waitlists.length === 0) {
return (
<div className="py-10 text-center text-gray-500">
No waitlists found. Create one to get started!
</div>
);
}
return (
<>
<div className="rounded-md border bg-white">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="font-medium">Name</TableHead>
<TableHead className="font-medium">Status</TableHead>
<TableHead className="font-medium">Signups</TableHead>
<TableHead className="font-medium">Votes</TableHead>
<TableHead className="font-medium">Created</TableHead>
<TableHead className="font-medium">Linked Agent</TableHead>
<TableHead className="font-medium">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{waitlists.map((waitlist) => (
<TableRow key={waitlist.id}>
<TableCell>
<div>
<div className="font-medium">{waitlist.name}</div>
<div className="text-sm text-gray-500">
{waitlist.subHeading}
</div>
</div>
</TableCell>
<TableCell>{formatStatus(waitlist.status)}</TableCell>
<TableCell>{waitlist.signupCount}</TableCell>
<TableCell>{waitlist.votes}</TableCell>
<TableCell>{formatDate(waitlist.createdAt)}</TableCell>
<TableCell>
{waitlist.storeListingId ? (
<span className="text-green-600">
<Link size={16} className="inline" /> Linked
</span>
) : (
<span className="text-gray-400">Not linked</span>
)}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="small"
onClick={() => setViewingSignups(waitlist.id)}
title="View signups"
>
<Users size={16} />
</Button>
<Button
variant="ghost"
size="small"
onClick={() => setEditingWaitlist(waitlist)}
title="Edit"
>
<PencilSimple size={16} />
</Button>
<Button
variant="ghost"
size="small"
onClick={() => handleDelete(waitlist.id)}
title="Delete"
disabled={deleteWaitlistMutation.isPending}
>
<Trash size={16} className="text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{editingWaitlist && (
<EditWaitlistDialog
waitlist={editingWaitlist}
onClose={() => setEditingWaitlist(null)}
onSave={handleWaitlistSaved}
/>
)}
{viewingSignups && (
<WaitlistSignupsDialog
waitlistId={viewingSignups}
onClose={() => setViewingSignups(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,52 @@
import { withRoleAccess } from "@/lib/withRoleAccess";
import { Suspense } from "react";
import { WaitlistTable } from "./components/WaitlistTable";
import { CreateWaitlistButton } from "./components/CreateWaitlistButton";
import { Warning } from "@phosphor-icons/react/dist/ssr";
function WaitlistDashboard() {
return (
<div className="mx-auto p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Waitlist Management</h1>
<p className="text-gray-500">
Manage upcoming agent waitlists and track signups
</p>
</div>
<CreateWaitlistButton />
</div>
<div className="flex items-start gap-3 rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-950">
<Warning
className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400"
weight="fill"
/>
<div className="text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium">TODO: Email-only signup notifications</p>
<p className="mt-1 text-amber-700 dark:text-amber-300">
Notifications for email-only signups (users who weren&apos;t
logged in) have not been implemented yet. Currently only
registered users will receive launch emails.
</p>
</div>
</div>
<Suspense
fallback={
<div className="py-10 text-center">Loading waitlists...</div>
}
>
<WaitlistTable />
</Suspense>
</div>
</div>
);
}
export default async function WaitlistDashboardPage() {
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedWaitlistDashboard = await withAdminAccess(WaitlistDashboard);
return <ProtectedWaitlistDashboard />;
}

View File

@@ -9,7 +9,7 @@ export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url); const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code"); const code = searchParams.get("code");
let next = "/"; let next = "/marketplace";
if (code) { if (code) {
const supabase = await getServerSupabase(); const supabase = await getServerSupabase();

View File

@@ -18,47 +18,51 @@ interface Props {
fullWidth?: boolean; fullWidth?: boolean;
} }
interface SafeModeButtonProps { export function FloatingSafeModeToggle({
isEnabled: boolean; graph,
label: string; className,
tooltipEnabled: string; fullWidth = false,
tooltipDisabled: string; }: Props) {
onToggle: () => void; const {
isPending: boolean; currentSafeMode,
fullWidth?: boolean; isPending,
shouldShowToggle,
isStateUndetermined,
handleToggle,
} = useAgentSafeMode(graph);
if (!shouldShowToggle || isStateUndetermined || isPending) {
return null;
} }
function SafeModeButton({
isEnabled,
label,
tooltipEnabled,
tooltipDisabled,
onToggle,
isPending,
fullWidth = false,
}: SafeModeButtonProps) {
return ( return (
<div className={cn("fixed z-50", className)}>
<Tooltip delayDuration={100}> <Tooltip delayDuration={100}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant={isEnabled ? "primary" : "outline"} variant={currentSafeMode! ? "primary" : "outline"}
key={graph.id}
size="small" size="small"
onClick={onToggle} title={
disabled={isPending} currentSafeMode!
className={cn("justify-start", fullWidth ? "w-full" : "")} ? "Safe Mode: ON. Human in the loop blocks require manual review"
: "Safe Mode: OFF. Human in the loop blocks proceed automatically"
}
onClick={handleToggle}
className={cn(fullWidth ? "w-full" : "")}
> >
{isEnabled ? ( {currentSafeMode! ? (
<> <>
<ShieldCheckIcon weight="bold" size={16} /> <ShieldCheckIcon weight="bold" size={16} />
<Text variant="body" className="text-zinc-200"> <Text variant="body" className="text-zinc-200">
{label}: ON Safe Mode: ON
</Text> </Text>
</> </>
) : ( ) : (
<> <>
<ShieldIcon weight="bold" size={16} /> <ShieldIcon weight="bold" size={16} />
<Text variant="body" className="text-zinc-600"> <Text variant="body" className="text-zinc-600">
{label}: OFF Safe Mode: OFF
</Text> </Text>
</> </>
)} )}
@@ -67,69 +71,16 @@ function SafeModeButton({
<TooltipContent> <TooltipContent>
<div className="text-center"> <div className="text-center">
<div className="font-medium"> <div className="font-medium">
{label}: {isEnabled ? "ON" : "OFF"} Safe Mode: {currentSafeMode! ? "ON" : "OFF"}
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{isEnabled ? tooltipEnabled : tooltipDisabled} {currentSafeMode!
? "Human in the loop blocks require manual review"
: "Human in the loop blocks proceed automatically"}
</div> </div>
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
);
}
export function FloatingSafeModeToggle({
graph,
className,
fullWidth = false,
}: Props) {
const {
currentHITLSafeMode,
showHITLToggle,
isHITLStateUndetermined,
handleHITLToggle,
currentSensitiveActionSafeMode,
showSensitiveActionToggle,
handleSensitiveActionToggle,
isPending,
shouldShowToggle,
} = useAgentSafeMode(graph);
if (!shouldShowToggle || isPending) {
return null;
}
const showHITL = showHITLToggle && !isHITLStateUndetermined;
const showSensitive = showSensitiveActionToggle;
if (!showHITL && !showSensitive) {
return null;
}
return (
<div className={cn("fixed z-50 flex flex-col gap-2", className)}>
{showHITL && (
<SafeModeButton
isEnabled={currentHITLSafeMode}
label="Human in the loop block approval"
tooltipEnabled="The agent will pause at human-in-the-loop blocks and wait for your approval"
tooltipDisabled="Human in the loop blocks will proceed automatically"
onToggle={handleHITLToggle}
isPending={isPending}
fullWidth={fullWidth}
/>
)}
{showSensitive && (
<SafeModeButton
isEnabled={currentSensitiveActionSafeMode}
label="Sensitive actions blocks approval"
tooltipEnabled="The agent will pause at sensitive action blocks and wait for your approval"
tooltipDisabled="Sensitive action blocks will proceed automatically"
onToggle={handleSensitiveActionToggle}
isPending={isPending}
fullWidth={fullWidth}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,134 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { List } from "@phosphor-icons/react";
import React, { useState } from "react";
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
import { ChatLoadingState } from "./components/ChatLoadingState/ChatLoadingState";
import { SessionsDrawer } from "./components/SessionsDrawer/SessionsDrawer";
import { useChat } from "./useChat";
export interface ChatProps {
className?: string;
headerTitle?: React.ReactNode;
showHeader?: boolean;
showSessionInfo?: boolean;
showNewChatButton?: boolean;
onNewChat?: () => void;
headerActions?: React.ReactNode;
}
export function Chat({
className,
headerTitle = "AutoGPT Copilot",
showHeader = true,
showSessionInfo = true,
showNewChatButton = true,
onNewChat,
headerActions,
}: ChatProps) {
const {
messages,
isLoading,
isCreating,
error,
sessionId,
createSession,
clearSession,
loadSession,
} = useChat();
const [isSessionsDrawerOpen, setIsSessionsDrawerOpen] = useState(false);
const handleNewChat = () => {
clearSession();
onNewChat?.();
};
const handleSelectSession = async (sessionId: string) => {
try {
await loadSession(sessionId);
} catch (err) {
console.error("Failed to load session:", err);
}
};
return (
<div className={cn("flex h-full flex-col", className)}>
{/* Header */}
{showHeader && (
<header className="shrink-0 border-t border-zinc-200 bg-white p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
aria-label="View sessions"
onClick={() => setIsSessionsDrawerOpen(true)}
className="flex size-8 items-center justify-center rounded hover:bg-zinc-100"
>
<List width="1.25rem" height="1.25rem" />
</button>
{typeof headerTitle === "string" ? (
<Text variant="h2" className="text-lg font-semibold">
{headerTitle}
</Text>
) : (
headerTitle
)}
</div>
<div className="flex items-center gap-3">
{showSessionInfo && sessionId && (
<>
{showNewChatButton && (
<Button
variant="outline"
size="small"
onClick={handleNewChat}
>
New Chat
</Button>
)}
</>
)}
{headerActions}
</div>
</div>
</header>
)}
{/* Main Content */}
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */}
{(isLoading || isCreating || (!sessionId && !error)) && (
<ChatLoadingState
message={isCreating ? "Creating session..." : "Loading..."}
/>
)}
{/* Error State */}
{error && !isLoading && (
<ChatErrorState error={error} onRetry={createSession} />
)}
{/* Session Content */}
{sessionId && !isLoading && !error && (
<ChatContainer
sessionId={sessionId}
initialMessages={messages}
className="flex-1"
/>
)}
</main>
{/* Sessions Drawer */}
<SessionsDrawer
isOpen={isSessionsDrawerOpen}
onClose={() => setIsSessionsDrawerOpen(false)}
onSelectSession={handleSelectSession}
currentSessionId={sessionId}
/>
</div>
);
}

View File

@@ -21,7 +21,7 @@ export function AuthPromptWidget({
message, message,
sessionId, sessionId,
agentInfo, agentInfo,
returnUrl = "/copilot/chat", returnUrl = "/chat",
className, className,
}: AuthPromptWidgetProps) { }: AuthPromptWidgetProps) {
const router = useRouter(); const router = useRouter();

View File

@@ -0,0 +1,88 @@
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
import { cn } from "@/lib/utils";
import { useCallback } from "react";
import { usePageContext } from "../../usePageContext";
import { ChatInput } from "../ChatInput/ChatInput";
import { MessageList } from "../MessageList/MessageList";
import { QuickActionsWelcome } from "../QuickActionsWelcome/QuickActionsWelcome";
import { useChatContainer } from "./useChatContainer";
export interface ChatContainerProps {
sessionId: string | null;
initialMessages: SessionDetailResponse["messages"];
className?: string;
}
export function ChatContainer({
sessionId,
initialMessages,
className,
}: ChatContainerProps) {
const { messages, streamingChunks, isStreaming, sendMessage } =
useChatContainer({
sessionId,
initialMessages,
});
const { capturePageContext } = usePageContext();
// Wrap sendMessage to automatically capture page context
const sendMessageWithContext = useCallback(
async (content: string, isUserMessage: boolean = true) => {
const context = capturePageContext();
await sendMessage(content, isUserMessage, context);
},
[sendMessage, capturePageContext],
);
const quickActions = [
"Find agents for social media management",
"Show me agents for content creation",
"Help me automate my business",
"What can you help me with?",
];
return (
<div
className={cn("flex h-full min-h-0 flex-col", className)}
style={{
backgroundColor: "#ffffff",
backgroundImage:
"radial-gradient(#e5e5e5 0.5px, transparent 0.5px), radial-gradient(#e5e5e5 0.5px, #ffffff 0.5px)",
backgroundSize: "20px 20px",
backgroundPosition: "0 0, 10px 10px",
}}
>
{/* Messages or Welcome Screen */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pb-24">
{messages.length === 0 ? (
<QuickActionsWelcome
title="Welcome to AutoGPT Copilot"
description="Start a conversation to discover and run AI agents."
actions={quickActions}
onActionClick={sendMessageWithContext}
disabled={isStreaming || !sessionId}
/>
) : (
<MessageList
messages={messages}
streamingChunks={streamingChunks}
isStreaming={isStreaming}
onSendMessage={sendMessageWithContext}
className="flex-1"
/>
)}
</div>
{/* Input - Always visible */}
<div className="fixed bottom-0 left-0 right-0 z-50 border-t border-zinc-200 bg-white p-4">
<ChatInput
onSend={sendMessageWithContext}
disabled={isStreaming || !sessionId}
placeholder={
sessionId ? "Type your message..." : "Creating session..."
}
/>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { StreamChunk } from "../../useChatStream"; import { StreamChunk } from "../../useChatStream";
import type { HandlerDependencies } from "./handlers"; import type { HandlerDependencies } from "./useChatContainer.handlers";
import { import {
handleError, handleError,
handleLoginNeeded, handleLoginNeeded,
@@ -9,30 +9,12 @@ import {
handleTextEnded, handleTextEnded,
handleToolCallStart, handleToolCallStart,
handleToolResponse, handleToolResponse,
isRegionBlockedError, } from "./useChatContainer.handlers";
} from "./handlers";
export function createStreamEventDispatcher( export function createStreamEventDispatcher(
deps: HandlerDependencies, deps: HandlerDependencies,
): (chunk: StreamChunk) => void { ): (chunk: StreamChunk) => void {
return function dispatchStreamEvent(chunk: StreamChunk): void { return function dispatchStreamEvent(chunk: StreamChunk): void {
if (
chunk.type === "text_chunk" ||
chunk.type === "tool_call_start" ||
chunk.type === "tool_response" ||
chunk.type === "login_needed" ||
chunk.type === "need_login" ||
chunk.type === "error"
) {
if (!deps.hasResponseRef.current) {
console.info("[ChatStream] First response chunk:", {
type: chunk.type,
sessionId: deps.sessionId,
});
}
deps.hasResponseRef.current = true;
}
switch (chunk.type) { switch (chunk.type) {
case "text_chunk": case "text_chunk":
handleTextChunk(chunk, deps); handleTextChunk(chunk, deps);
@@ -56,23 +38,15 @@ export function createStreamEventDispatcher(
break; break;
case "stream_end": case "stream_end":
console.info("[ChatStream] Stream ended:", {
sessionId: deps.sessionId,
hasResponse: deps.hasResponseRef.current,
chunkCount: deps.streamingChunksRef.current.length,
});
handleStreamEnd(chunk, deps); handleStreamEnd(chunk, deps);
break; break;
case "error": case "error":
const isRegionBlocked = isRegionBlockedError(chunk);
handleError(chunk, deps); handleError(chunk, deps);
// Show toast at dispatcher level to avoid circular dependencies // Show toast at dispatcher level to avoid circular dependencies
if (!isRegionBlocked) {
toast.error("Chat Error", { toast.error("Chat Error", {
description: chunk.message || chunk.content || "An error occurred", description: chunk.message || chunk.content || "An error occurred",
}); });
}
break; break;
case "usage": case "usage":

View File

@@ -1,33 +1,6 @@
import { SessionKey, sessionStorage } from "@/services/storage/session-storage";
import type { ToolResult } from "@/types/chat"; import type { ToolResult } from "@/types/chat";
import type { ChatMessageData } from "../ChatMessage/useChatMessage"; import type { ChatMessageData } from "../ChatMessage/useChatMessage";
export function hasSentInitialPrompt(sessionId: string): boolean {
try {
const sent = JSON.parse(
sessionStorage.get(SessionKey.CHAT_SENT_INITIAL_PROMPTS) || "{}",
);
return sent[sessionId] === true;
} catch {
return false;
}
}
export function markInitialPromptSent(sessionId: string): void {
try {
const sent = JSON.parse(
sessionStorage.get(SessionKey.CHAT_SENT_INITIAL_PROMPTS) || "{}",
);
sent[sessionId] = true;
sessionStorage.set(
SessionKey.CHAT_SENT_INITIAL_PROMPTS,
JSON.stringify(sent),
);
} catch {
// Ignore storage errors
}
}
export function removePageContext(content: string): string { export function removePageContext(content: string): string {
// Remove "Page URL: ..." pattern at start of line (case insensitive, handles various formats) // Remove "Page URL: ..." pattern at start of line (case insensitive, handles various formats)
let cleaned = content.replace(/^\s*Page URL:\s*[^\n\r]*/gim, ""); let cleaned = content.replace(/^\s*Page URL:\s*[^\n\r]*/gim, "");
@@ -234,22 +207,12 @@ export function parseToolResponse(
if (responseType === "setup_requirements") { if (responseType === "setup_requirements") {
return null; return null;
} }
if (responseType === "understanding_updated") {
return {
type: "tool_response",
toolId,
toolName,
result: (parsedResult || result) as ToolResult,
success: true,
timestamp: timestamp || new Date(),
};
}
} }
return { return {
type: "tool_response", type: "tool_response",
toolId, toolId,
toolName, toolName,
result: parsedResult ? (parsedResult as ToolResult) : result, result,
success: true, success: true,
timestamp: timestamp || new Date(), timestamp: timestamp || new Date(),
}; };

View File

@@ -7,30 +7,15 @@ import {
parseToolResponse, parseToolResponse,
} from "./helpers"; } from "./helpers";
function isToolCallMessage(
message: ChatMessageData,
): message is Extract<ChatMessageData, { type: "tool_call" }> {
return message.type === "tool_call";
}
export interface HandlerDependencies { export interface HandlerDependencies {
setHasTextChunks: Dispatch<SetStateAction<boolean>>; setHasTextChunks: Dispatch<SetStateAction<boolean>>;
setStreamingChunks: Dispatch<SetStateAction<string[]>>; setStreamingChunks: Dispatch<SetStateAction<string[]>>;
streamingChunksRef: MutableRefObject<string[]>; streamingChunksRef: MutableRefObject<string[]>;
hasResponseRef: MutableRefObject<boolean>;
setMessages: Dispatch<SetStateAction<ChatMessageData[]>>; setMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
setIsStreamingInitiated: Dispatch<SetStateAction<boolean>>; setIsStreamingInitiated: Dispatch<SetStateAction<boolean>>;
setIsRegionBlockedModalOpen: Dispatch<SetStateAction<boolean>>;
sessionId: string; sessionId: string;
} }
export function isRegionBlockedError(chunk: StreamChunk): boolean {
if (chunk.code === "MODEL_NOT_AVAILABLE_REGION") return true;
const message = chunk.message || chunk.content;
if (typeof message !== "string") return false;
return message.toLowerCase().includes("not available in your region");
}
export function handleTextChunk(chunk: StreamChunk, deps: HandlerDependencies) { export function handleTextChunk(chunk: StreamChunk, deps: HandlerDependencies) {
if (!chunk.content) return; if (!chunk.content) return;
deps.setHasTextChunks(true); deps.setHasTextChunks(true);
@@ -45,17 +30,16 @@ export function handleTextEnded(
_chunk: StreamChunk, _chunk: StreamChunk,
deps: HandlerDependencies, deps: HandlerDependencies,
) { ) {
console.log("[Text Ended] Saving streamed text as assistant message");
const completedText = deps.streamingChunksRef.current.join(""); const completedText = deps.streamingChunksRef.current.join("");
if (completedText.trim()) { if (completedText.trim()) {
deps.setMessages((prev) => {
const assistantMessage: ChatMessageData = { const assistantMessage: ChatMessageData = {
type: "message", type: "message",
role: "assistant", role: "assistant",
content: completedText, content: completedText,
timestamp: new Date(), timestamp: new Date(),
}; };
return [...prev, assistantMessage]; deps.setMessages((prev) => [...prev, assistantMessage]);
});
} }
deps.setStreamingChunks([]); deps.setStreamingChunks([]);
deps.streamingChunksRef.current = []; deps.streamingChunksRef.current = [];
@@ -66,45 +50,30 @@ export function handleToolCallStart(
chunk: StreamChunk, chunk: StreamChunk,
deps: HandlerDependencies, deps: HandlerDependencies,
) { ) {
const toolCallMessage: Extract<ChatMessageData, { type: "tool_call" }> = { const toolCallMessage: ChatMessageData = {
type: "tool_call", type: "tool_call",
toolId: chunk.tool_id || `tool-${Date.now()}-${chunk.idx || 0}`, toolId: chunk.tool_id || `tool-${Date.now()}-${chunk.idx || 0}`,
toolName: chunk.tool_name || "Executing", toolName: chunk.tool_name || "Executing...",
arguments: chunk.arguments || {}, arguments: chunk.arguments || {},
timestamp: new Date(), timestamp: new Date(),
}; };
deps.setMessages((prev) => [...prev, toolCallMessage]);
function updateToolCallMessages(prev: ChatMessageData[]) { console.log("[Tool Call Start]", {
const existingIndex = prev.findIndex(function findToolCallIndex(msg) { toolId: toolCallMessage.toolId,
return isToolCallMessage(msg) && msg.toolId === toolCallMessage.toolId; toolName: toolCallMessage.toolName,
timestamp: new Date().toISOString(),
}); });
if (existingIndex === -1) {
return [...prev, toolCallMessage];
}
const nextMessages = [...prev];
const existing = nextMessages[existingIndex];
if (!isToolCallMessage(existing)) return prev;
const nextArguments =
toolCallMessage.arguments &&
Object.keys(toolCallMessage.arguments).length > 0
? toolCallMessage.arguments
: existing.arguments;
nextMessages[existingIndex] = {
...existing,
toolName: toolCallMessage.toolName || existing.toolName,
arguments: nextArguments,
timestamp: toolCallMessage.timestamp,
};
return nextMessages;
}
deps.setMessages(updateToolCallMessages);
} }
export function handleToolResponse( export function handleToolResponse(
chunk: StreamChunk, chunk: StreamChunk,
deps: HandlerDependencies, deps: HandlerDependencies,
) { ) {
console.log("[Tool Response] Received:", {
toolId: chunk.tool_id,
toolName: chunk.tool_name,
timestamp: new Date().toISOString(),
});
let toolName = chunk.tool_name || "unknown"; let toolName = chunk.tool_name || "unknown";
if (!chunk.tool_name || chunk.tool_name === "unknown") { if (!chunk.tool_name || chunk.tool_name === "unknown") {
deps.setMessages((prev) => { deps.setMessages((prev) => {
@@ -158,15 +127,22 @@ export function handleToolResponse(
const toolCallIndex = prev.findIndex( const toolCallIndex = prev.findIndex(
(msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id, (msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
); );
const hasResponse = prev.some(
(msg) => msg.type === "tool_response" && msg.toolId === chunk.tool_id,
);
if (hasResponse) return prev;
if (toolCallIndex !== -1) { if (toolCallIndex !== -1) {
const newMessages = [...prev]; const newMessages = [...prev];
newMessages.splice(toolCallIndex + 1, 0, responseMessage); newMessages[toolCallIndex] = responseMessage;
console.log(
"[Tool Response] Replaced tool_call with matching tool_id:",
chunk.tool_id,
"at index:",
toolCallIndex,
);
return newMessages; return newMessages;
} }
console.warn(
"[Tool Response] No tool_call found with tool_id:",
chunk.tool_id,
"appending instead",
);
return [...prev, responseMessage]; return [...prev, responseMessage];
}); });
} }
@@ -191,38 +167,55 @@ export function handleStreamEnd(
deps: HandlerDependencies, deps: HandlerDependencies,
) { ) {
const completedContent = deps.streamingChunksRef.current.join(""); const completedContent = deps.streamingChunksRef.current.join("");
if (!completedContent.trim() && !deps.hasResponseRef.current) { // Only save message if there are uncommitted chunks
deps.setMessages((prev) => [ // (text_ended already saved if there were tool calls)
...prev,
{
type: "message",
role: "assistant",
content: "No response received. Please try again.",
timestamp: new Date(),
},
]);
}
if (completedContent.trim()) { if (completedContent.trim()) {
console.log(
"[Stream End] Saving remaining streamed text as assistant message",
);
const assistantMessage: ChatMessageData = { const assistantMessage: ChatMessageData = {
type: "message", type: "message",
role: "assistant", role: "assistant",
content: completedContent, content: completedContent,
timestamp: new Date(), timestamp: new Date(),
}; };
deps.setMessages((prev) => [...prev, assistantMessage]); deps.setMessages((prev) => {
const updated = [...prev, assistantMessage];
console.log("[Stream End] Final state:", {
localMessages: updated.map((m) => ({
type: m.type,
...(m.type === "message" && {
role: m.role,
contentLength: m.content.length,
}),
...(m.type === "tool_call" && {
toolId: m.toolId,
toolName: m.toolName,
}),
...(m.type === "tool_response" && {
toolId: m.toolId,
toolName: m.toolName,
success: m.success,
}),
})),
streamingChunks: deps.streamingChunksRef.current,
timestamp: new Date().toISOString(),
});
return updated;
});
} else {
console.log("[Stream End] No uncommitted chunks, message already saved");
} }
deps.setStreamingChunks([]); deps.setStreamingChunks([]);
deps.streamingChunksRef.current = []; deps.streamingChunksRef.current = [];
deps.setHasTextChunks(false); deps.setHasTextChunks(false);
deps.setIsStreamingInitiated(false); deps.setIsStreamingInitiated(false);
console.log("[Stream End] Stream complete, messages in local state");
} }
export function handleError(chunk: StreamChunk, deps: HandlerDependencies) { export function handleError(chunk: StreamChunk, deps: HandlerDependencies) {
const errorMessage = chunk.message || chunk.content || "An error occurred"; const errorMessage = chunk.message || chunk.content || "An error occurred";
console.error("Stream error:", errorMessage); console.error("Stream error:", errorMessage);
if (isRegionBlockedError(chunk)) {
deps.setIsRegionBlockedModalOpen(true);
}
deps.setIsStreamingInitiated(false); deps.setIsStreamingInitiated(false);
deps.setHasTextChunks(false); deps.setHasTextChunks(false);
deps.setStreamingChunks([]); deps.setStreamingChunks([]);

View File

@@ -1,17 +1,14 @@
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse"; import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useChatStream } from "../../useChatStream"; import { useChatStream } from "../../useChatStream";
import { usePageContext } from "../../usePageContext";
import type { ChatMessageData } from "../ChatMessage/useChatMessage"; import type { ChatMessageData } from "../ChatMessage/useChatMessage";
import { createStreamEventDispatcher } from "./createStreamEventDispatcher"; import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
import { import {
createUserMessage, createUserMessage,
filterAuthMessages, filterAuthMessages,
hasSentInitialPrompt,
isToolCallArray, isToolCallArray,
isValidMessage, isValidMessage,
markInitialPromptSent,
parseToolResponse, parseToolResponse,
removePageContext, removePageContext,
} from "./helpers"; } from "./helpers";
@@ -19,45 +16,20 @@ import {
interface Args { interface Args {
sessionId: string | null; sessionId: string | null;
initialMessages: SessionDetailResponse["messages"]; initialMessages: SessionDetailResponse["messages"];
initialPrompt?: string;
} }
export function useChatContainer({ export function useChatContainer({ sessionId, initialMessages }: Args) {
sessionId,
initialMessages,
initialPrompt,
}: Args) {
const [messages, setMessages] = useState<ChatMessageData[]>([]); const [messages, setMessages] = useState<ChatMessageData[]>([]);
const [streamingChunks, setStreamingChunks] = useState<string[]>([]); const [streamingChunks, setStreamingChunks] = useState<string[]>([]);
const [hasTextChunks, setHasTextChunks] = useState(false); const [hasTextChunks, setHasTextChunks] = useState(false);
const [isStreamingInitiated, setIsStreamingInitiated] = useState(false); const [isStreamingInitiated, setIsStreamingInitiated] = useState(false);
const [isRegionBlockedModalOpen, setIsRegionBlockedModalOpen] =
useState(false);
const hasResponseRef = useRef(false);
const streamingChunksRef = useRef<string[]>([]); const streamingChunksRef = useRef<string[]>([]);
const previousSessionIdRef = useRef<string | null>(null); const { error, sendMessage: sendStreamMessage } = useChatStream();
const {
error,
sendMessage: sendStreamMessage,
stopStreaming,
} = useChatStream();
const isStreaming = isStreamingInitiated || hasTextChunks; const isStreaming = isStreamingInitiated || hasTextChunks;
useEffect(() => {
if (sessionId !== previousSessionIdRef.current) {
stopStreaming(previousSessionIdRef.current ?? undefined, true);
previousSessionIdRef.current = sessionId;
setMessages([]);
setStreamingChunks([]);
streamingChunksRef.current = [];
setHasTextChunks(false);
setIsStreamingInitiated(false);
hasResponseRef.current = false;
}
}, [sessionId, stopStreaming]);
const allMessages = useMemo(() => { const allMessages = useMemo(() => {
const processedInitialMessages: ChatMessageData[] = []; const processedInitialMessages: ChatMessageData[] = [];
// Map to track tool calls by their ID so we can look up tool names for tool responses
const toolCallMap = new Map<string, string>(); const toolCallMap = new Map<string, string>();
for (const msg of initialMessages) { for (const msg of initialMessages) {
@@ -73,9 +45,13 @@ export function useChatContainer({
? new Date(msg.timestamp as string) ? new Date(msg.timestamp as string)
: undefined; : undefined;
// Remove page context from user messages when loading existing sessions
if (role === "user") { if (role === "user") {
content = removePageContext(content); content = removePageContext(content);
if (!content.trim()) continue; // Skip user messages that become empty after removing page context
if (!content.trim()) {
continue;
}
processedInitialMessages.push({ processedInitialMessages.push({
type: "message", type: "message",
role: "user", role: "user",
@@ -85,15 +61,19 @@ export function useChatContainer({
continue; continue;
} }
// Handle assistant messages first (before tool messages) to build tool call map
if (role === "assistant") { if (role === "assistant") {
// Strip <thinking> tags from content
content = content content = content
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "") .replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
.trim(); .trim();
// If assistant has tool calls, create tool_call messages for each
if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) { if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) {
for (const toolCall of toolCalls) { for (const toolCall of toolCalls) {
const toolName = toolCall.function.name; const toolName = toolCall.function.name;
const toolId = toolCall.id; const toolId = toolCall.id;
// Store tool name for later lookup
toolCallMap.set(toolId, toolName); toolCallMap.set(toolId, toolName);
try { try {
@@ -116,6 +96,7 @@ export function useChatContainer({
}); });
} }
} }
// Only add assistant message if there's content after stripping thinking tags
if (content.trim()) { if (content.trim()) {
processedInitialMessages.push({ processedInitialMessages.push({
type: "message", type: "message",
@@ -125,6 +106,7 @@ export function useChatContainer({
}); });
} }
} else if (content.trim()) { } else if (content.trim()) {
// Assistant message without tool calls, but with content
processedInitialMessages.push({ processedInitialMessages.push({
type: "message", type: "message",
role: "assistant", role: "assistant",
@@ -135,6 +117,7 @@ export function useChatContainer({
continue; continue;
} }
// Handle tool messages - look up tool name from tool call map
if (role === "tool") { if (role === "tool") {
const toolCallId = (msg.tool_call_id as string) || ""; const toolCallId = (msg.tool_call_id as string) || "";
const toolName = toolCallMap.get(toolCallId) || "unknown"; const toolName = toolCallMap.get(toolCallId) || "unknown";
@@ -150,6 +133,7 @@ export function useChatContainer({
continue; continue;
} }
// Handle other message types (system, etc.)
if (content.trim()) { if (content.trim()) {
processedInitialMessages.push({ processedInitialMessages.push({
type: "message", type: "message",
@@ -170,10 +154,9 @@ export function useChatContainer({
context?: { url: string; content: string }, context?: { url: string; content: string },
) { ) {
if (!sessionId) { if (!sessionId) {
console.error("[useChatContainer] Cannot send message: no session ID"); console.error("Cannot send message: no session ID");
return; return;
} }
setIsRegionBlockedModalOpen(false);
if (isUserMessage) { if (isUserMessage) {
const userMessage = createUserMessage(content); const userMessage = createUserMessage(content);
setMessages((prev) => [...filterAuthMessages(prev), userMessage]); setMessages((prev) => [...filterAuthMessages(prev), userMessage]);
@@ -184,19 +167,14 @@ export function useChatContainer({
streamingChunksRef.current = []; streamingChunksRef.current = [];
setHasTextChunks(false); setHasTextChunks(false);
setIsStreamingInitiated(true); setIsStreamingInitiated(true);
hasResponseRef.current = false;
const dispatcher = createStreamEventDispatcher({ const dispatcher = createStreamEventDispatcher({
setHasTextChunks, setHasTextChunks,
setStreamingChunks, setStreamingChunks,
streamingChunksRef, streamingChunksRef,
hasResponseRef,
setMessages, setMessages,
setIsRegionBlockedModalOpen,
sessionId, sessionId,
setIsStreamingInitiated, setIsStreamingInitiated,
}); });
try { try {
await sendStreamMessage( await sendStreamMessage(
sessionId, sessionId,
@@ -206,12 +184,8 @@ export function useChatContainer({
context, context,
); );
} catch (err) { } catch (err) {
console.error("[useChatContainer] Failed to send message:", err); console.error("Failed to send message:", err);
setIsStreamingInitiated(false); setIsStreamingInitiated(false);
// Don't show error toast for AbortError (expected during cleanup)
if (err instanceof Error && err.name === "AbortError") return;
const errorMessage = const errorMessage =
err instanceof Error ? err.message : "Failed to send message"; err instanceof Error ? err.message : "Failed to send message";
toast.error("Failed to send message", { toast.error("Failed to send message", {
@@ -222,63 +196,11 @@ export function useChatContainer({
[sessionId, sendStreamMessage], [sessionId, sendStreamMessage],
); );
const handleStopStreaming = useCallback(() => {
stopStreaming();
setStreamingChunks([]);
streamingChunksRef.current = [];
setHasTextChunks(false);
setIsStreamingInitiated(false);
}, [stopStreaming]);
const { capturePageContext } = usePageContext();
// Send initial prompt if provided (for new sessions from homepage)
useEffect(
function handleInitialPrompt() {
if (!initialPrompt || !sessionId) return;
if (initialMessages.length > 0) return;
if (hasSentInitialPrompt(sessionId)) return;
markInitialPromptSent(sessionId);
const context = capturePageContext();
sendMessage(initialPrompt, true, context);
},
[
initialPrompt,
sessionId,
initialMessages.length,
sendMessage,
capturePageContext,
],
);
async function sendMessageWithContext(
content: string,
isUserMessage: boolean = true,
) {
const context = capturePageContext();
await sendMessage(content, isUserMessage, context);
}
function handleRegionModalOpenChange(open: boolean) {
setIsRegionBlockedModalOpen(open);
}
function handleRegionModalClose() {
setIsRegionBlockedModalOpen(false);
}
return { return {
messages: allMessages, messages: allMessages,
streamingChunks, streamingChunks,
isStreaming, isStreaming,
error, error,
isRegionBlockedModalOpen,
setIsRegionBlockedModalOpen,
sendMessageWithContext,
handleRegionModalOpenChange,
handleRegionModalClose,
sendMessage, sendMessage,
stopStreaming: handleStopStreaming,
}; };
} }

View File

@@ -0,0 +1,64 @@
import { Input } from "@/components/atoms/Input/Input";
import { cn } from "@/lib/utils";
import { ArrowUpIcon } from "@phosphor-icons/react";
import { useChatInput } from "./useChatInput";
export interface ChatInputProps {
onSend: (message: string) => void;
disabled?: boolean;
placeholder?: string;
className?: string;
}
export function ChatInput({
onSend,
disabled = false,
placeholder = "Type your message...",
className,
}: ChatInputProps) {
const inputId = "chat-input";
const { value, setValue, handleKeyDown, handleSend } = useChatInput({
onSend,
disabled,
maxRows: 5,
inputId,
});
return (
<div className={cn("relative flex-1", className)}>
<Input
id={inputId}
label="Chat message input"
hideLabel
type="textarea"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
wrapperClassName="mb-0 relative"
className="pr-12"
/>
<span id="chat-input-hint" className="sr-only">
Press Enter to send, Shift+Enter for new line
</span>
<button
onClick={handleSend}
disabled={disabled || !value.trim()}
className={cn(
"absolute right-3 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full",
"border border-zinc-800 bg-zinc-800 text-white",
"hover:border-zinc-900 hover:bg-zinc-900",
"disabled:border-zinc-200 disabled:bg-zinc-200 disabled:text-white disabled:opacity-50",
"transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950",
"disabled:pointer-events-none",
)}
aria-label="Send message"
>
<ArrowUpIcon className="h-3 w-3" weight="bold" />
</button>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { KeyboardEvent, useCallback, useEffect, useState } from "react";
interface UseChatInputArgs {
onSend: (message: string) => void;
disabled?: boolean;
maxRows?: number;
inputId?: string;
}
export function useChatInput({
onSend,
disabled = false,
maxRows = 5,
inputId = "chat-input",
}: UseChatInputArgs) {
const [value, setValue] = useState("");
useEffect(() => {
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
if (!textarea) return;
textarea.style.height = "auto";
const lineHeight = parseInt(
window.getComputedStyle(textarea).lineHeight,
10,
);
const maxHeight = lineHeight * maxRows;
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
textarea.style.overflowY =
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
}, [value, maxRows, inputId]);
const handleSend = useCallback(() => {
if (disabled || !value.trim()) return;
onSend(value.trim());
setValue("");
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
if (textarea) {
textarea.style.height = "auto";
}
}, [value, onSend, disabled, inputId]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
// Shift+Enter allows default behavior (new line) - no need to handle explicitly
},
[handleSend],
);
return {
value,
setValue,
handleKeyDown,
handleSend,
};
}

View File

@@ -1,65 +1,48 @@
"use client"; "use client";
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { Button } from "@/components/atoms/Button/Button"; import { Button } from "@/components/atoms/Button/Button";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
ArrowsClockwiseIcon, ArrowClockwise,
CheckCircleIcon, CheckCircleIcon,
CheckIcon, CheckIcon,
CopyIcon, CopyIcon,
RobotIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { getToolActionPhrase } from "../../helpers";
import { AgentCarouselMessage } from "../AgentCarouselMessage/AgentCarouselMessage"; import { AgentCarouselMessage } from "../AgentCarouselMessage/AgentCarouselMessage";
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget"; import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget";
import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup"; import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup";
import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage"; import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage";
import { MarkdownContent } from "../MarkdownContent/MarkdownContent"; import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
import { MessageBubble } from "../MessageBubble/MessageBubble";
import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage"; import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage";
import { ToolCallMessage } from "../ToolCallMessage/ToolCallMessage"; import { ToolCallMessage } from "../ToolCallMessage/ToolCallMessage";
import { ToolResponseMessage } from "../ToolResponseMessage/ToolResponseMessage"; import { ToolResponseMessage } from "../ToolResponseMessage/ToolResponseMessage";
import { UserChatBubble } from "../UserChatBubble/UserChatBubble";
import { useChatMessage, type ChatMessageData } from "./useChatMessage"; import { useChatMessage, type ChatMessageData } from "./useChatMessage";
function stripInternalReasoning(content: string): string {
const cleaned = content.replace(
/<internal_reasoning>[\s\S]*?<\/internal_reasoning>/gi,
"",
);
return cleaned.replace(/\n{3,}/g, "\n\n").trim();
}
function getDisplayContent(message: ChatMessageData, isUser: boolean): string {
if (message.type !== "message") return "";
if (isUser) return message.content;
return stripInternalReasoning(message.content);
}
export interface ChatMessageProps { export interface ChatMessageProps {
message: ChatMessageData; message: ChatMessageData;
messages?: ChatMessageData[];
index?: number;
isStreaming?: boolean;
className?: string; className?: string;
onDismissLogin?: () => void; onDismissLogin?: () => void;
onDismissCredentials?: () => void; onDismissCredentials?: () => void;
onSendMessage?: (content: string, isUserMessage?: boolean) => void; onSendMessage?: (content: string, isUserMessage?: boolean) => void;
agentOutput?: ChatMessageData; agentOutput?: ChatMessageData;
isFinalMessage?: boolean;
} }
export function ChatMessage({ export function ChatMessage({
message, message,
messages = [],
index = -1,
isStreaming = false,
className, className,
onDismissCredentials, onDismissCredentials,
onSendMessage, onSendMessage,
agentOutput, agentOutput,
isFinalMessage = true,
}: ChatMessageProps) { }: ChatMessageProps) {
const { user } = useSupabase(); const { user } = useSupabase();
const router = useRouter(); const router = useRouter();
@@ -71,7 +54,14 @@ export function ChatMessage({
isLoginNeeded, isLoginNeeded,
isCredentialsNeeded, isCredentialsNeeded,
} = useChatMessage(message); } = useChatMessage(message);
const displayContent = getDisplayContent(message, isUser);
const { data: profile } = useGetV2GetUserProfile({
query: {
select: (res) => (res.status === 200 ? res.data : null),
enabled: isUser && !!user,
queryKey: ["/api/store/profile", user?.id],
},
});
const handleAllCredentialsComplete = useCallback( const handleAllCredentialsComplete = useCallback(
function handleAllCredentialsComplete() { function handleAllCredentialsComplete() {
@@ -97,25 +87,17 @@ export function ChatMessage({
} }
} }
const handleCopy = useCallback( const handleCopy = useCallback(async () => {
async function handleCopy() {
if (message.type !== "message") return; if (message.type !== "message") return;
if (!displayContent) return;
try { try {
await navigator.clipboard.writeText(displayContent); await navigator.clipboard.writeText(message.content);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch (error) { } catch (error) {
console.error("Failed to copy:", error); console.error("Failed to copy:", error);
} }
}, }, [message]);
[displayContent, message],
);
function isLongResponse(content: string): boolean {
return content.split("\n").length > 5;
}
const handleTryAgain = useCallback(() => { const handleTryAgain = useCallback(() => {
if (message.type !== "message" || !onSendMessage) return; if (message.type !== "message" || !onSendMessage) return;
@@ -187,45 +169,9 @@ export function ChatMessage({
// Render tool call messages // Render tool call messages
if (isToolCall && message.type === "tool_call") { if (isToolCall && message.type === "tool_call") {
// Check if this tool call is currently streaming
// A tool call is streaming if:
// 1. isStreaming is true
// 2. This is the last tool_call message
// 3. There's no tool_response for this tool call yet
const isToolCallStreaming =
isStreaming &&
index >= 0 &&
(() => {
// Find the last tool_call index
let lastToolCallIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].type === "tool_call") {
lastToolCallIndex = i;
break;
}
}
// Check if this is the last tool_call and there's no response yet
if (index === lastToolCallIndex) {
// Check if there's a tool_response for this tool call
const hasResponse = messages
.slice(index + 1)
.some(
(msg) =>
msg.type === "tool_response" && msg.toolId === message.toolId,
);
return !hasResponse;
}
return false;
})();
return ( return (
<div className={cn("px-4 py-2", className)}> <div className={cn("px-4 py-2", className)}>
<ToolCallMessage <ToolCallMessage toolName={message.toolName} />
toolId={message.toolId}
toolName={message.toolName}
arguments={message.arguments}
isStreaming={isToolCallStreaming}
/>
</div> </div>
); );
} }
@@ -272,11 +218,27 @@ export function ChatMessage({
// Render tool response messages (but skip agent_output if it's being rendered inside assistant message) // Render tool response messages (but skip agent_output if it's being rendered inside assistant message)
if (isToolResponse && message.type === "tool_response") { if (isToolResponse && message.type === "tool_response") {
// Check if this is an agent_output that should be rendered inside assistant message
if (message.result) {
let parsedResult: Record<string, unknown> | null = null;
try {
parsedResult =
typeof message.result === "string"
? JSON.parse(message.result)
: (message.result as Record<string, unknown>);
} catch {
parsedResult = null;
}
if (parsedResult?.type === "agent_output") {
// Skip rendering - this will be rendered inside the assistant message
return null;
}
}
return ( return (
<div className={cn("px-4 py-2", className)}> <div className={cn("px-4 py-2", className)}>
<ToolResponseMessage <ToolResponseMessage
toolId={message.toolId} toolName={getToolActionPhrase(message.toolName)}
toolName={message.toolName}
result={message.result} result={message.result}
/> />
</div> </div>
@@ -294,33 +256,40 @@ export function ChatMessage({
)} )}
> >
<div className="flex w-full max-w-3xl gap-3"> <div className="flex w-full max-w-3xl gap-3">
{!isUser && (
<div className="flex-shrink-0">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
<RobotIcon className="h-4 w-4 text-indigo-50" />
</div>
</div>
)}
<div <div
className={cn( className={cn(
"flex min-w-0 flex-1 flex-col", "flex min-w-0 flex-1 flex-col",
isUser && "items-end", isUser && "items-end",
)} )}
> >
{isUser ? ( <MessageBubble variant={isUser ? "user" : "assistant"}>
<UserChatBubble> <MarkdownContent content={message.content} />
<MarkdownContent content={displayContent} /> {agentOutput &&
</UserChatBubble> agentOutput.type === "tool_response" &&
) : ( !isUser && (
<AIChatBubble>
<MarkdownContent content={displayContent} />
{agentOutput && agentOutput.type === "tool_response" && (
<div className="mt-4"> <div className="mt-4">
<ToolResponseMessage <ToolResponseMessage
toolId={agentOutput.toolId} toolName={
toolName={agentOutput.toolName || "Agent Output"} agentOutput.toolName
? getToolActionPhrase(agentOutput.toolName)
: "Agent Output"
}
result={agentOutput.result} result={agentOutput.result}
/> />
</div> </div>
)} )}
</AIChatBubble> </MessageBubble>
)}
<div <div
className={cn( className={cn(
"flex gap-0", "mt-1 flex gap-1",
isUser ? "justify-end" : "justify-start", isUser ? "justify-end" : "justify-start",
)} )}
> >
@@ -331,10 +300,9 @@ export function ChatMessage({
onClick={handleTryAgain} onClick={handleTryAgain}
aria-label="Try again" aria-label="Try again"
> >
<ArrowsClockwiseIcon className="size-4 text-zinc-600" /> <ArrowClockwise className="size-3 text-neutral-500" />
</Button> </Button>
)} )}
{!isUser && isFinalMessage && isLongResponse(displayContent) && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -342,16 +310,29 @@ export function ChatMessage({
aria-label="Copy message" aria-label="Copy message"
> >
{copied ? ( {copied ? (
<CheckIcon className="size-4 text-green-600" /> <CheckIcon className="size-3 text-green-600" />
) : ( ) : (
<CopyIcon className="size-4 text-zinc-600" /> <CopyIcon className="size-3 text-neutral-500" />
)} )}
</Button> </Button>
</div>
</div>
{isUser && (
<div className="flex-shrink-0">
<Avatar className="h-7 w-7">
<AvatarImage
src={profile?.avatar_url ?? ""}
alt={profile?.username ?? "User"}
/>
<AvatarFallback className="rounded-lg bg-neutral-200 text-neutral-600">
{profile?.username?.charAt(0)?.toUpperCase() || "U"}
</AvatarFallback>
</Avatar>
</div>
)} )}
</div> </div>
</div> </div>
</div>
</div>
); );
} }

View File

@@ -13,9 +13,10 @@ export function MessageBubble({
className, className,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const userTheme = { const userTheme = {
bg: "bg-purple-100", bg: "bg-slate-900",
border: "border-purple-100", border: "border-slate-800",
text: "text-slate-900", gradient: "from-slate-900/30 via-slate-800/20 to-transparent",
text: "text-slate-50",
}; };
const assistantTheme = { const assistantTheme = {
@@ -39,7 +40,9 @@ export function MessageBubble({
)} )}
> >
{/* Gradient flare background */} {/* Gradient flare background */}
<div className={cn("absolute inset-0 bg-gradient-to-br")} /> <div
className={cn("absolute inset-0 bg-gradient-to-br", theme.gradient)}
/>
<div <div
className={cn( className={cn(
"relative z-10 transition-all duration-500 ease-in-out", "relative z-10 transition-all duration-500 ease-in-out",

View File

@@ -0,0 +1,121 @@
"use client";
import { cn } from "@/lib/utils";
import { ChatMessage } from "../ChatMessage/ChatMessage";
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
import { useMessageList } from "./useMessageList";
export interface MessageListProps {
messages: ChatMessageData[];
streamingChunks?: string[];
isStreaming?: boolean;
className?: string;
onStreamComplete?: () => void;
onSendMessage?: (content: string) => void;
}
export function MessageList({
messages,
streamingChunks = [],
isStreaming = false,
className,
onStreamComplete,
onSendMessage,
}: MessageListProps) {
const { messagesEndRef, messagesContainerRef } = useMessageList({
messageCount: messages.length,
isStreaming,
});
return (
<div
ref={messagesContainerRef}
className={cn(
"flex-1 overflow-y-auto",
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300",
className,
)}
>
<div className="mx-auto flex max-w-3xl flex-col py-4">
{/* Render all persisted messages */}
{messages.map((message, index) => {
// Check if current message is an agent_output tool_response
// and if previous message is an assistant message
let agentOutput: ChatMessageData | undefined;
if (message.type === "tool_response" && message.result) {
let parsedResult: Record<string, unknown> | null = null;
try {
parsedResult =
typeof message.result === "string"
? JSON.parse(message.result)
: (message.result as Record<string, unknown>);
} catch {
parsedResult = null;
}
if (parsedResult?.type === "agent_output") {
const prevMessage = messages[index - 1];
if (
prevMessage &&
prevMessage.type === "message" &&
prevMessage.role === "assistant"
) {
// This agent output will be rendered inside the previous assistant message
// Skip rendering this message separately
return null;
}
}
}
// Check if next message is an agent_output tool_response to include in current assistant message
if (message.type === "message" && message.role === "assistant") {
const nextMessage = messages[index + 1];
if (
nextMessage &&
nextMessage.type === "tool_response" &&
nextMessage.result
) {
let parsedResult: Record<string, unknown> | null = null;
try {
parsedResult =
typeof nextMessage.result === "string"
? JSON.parse(nextMessage.result)
: (nextMessage.result as Record<string, unknown>);
} catch {
parsedResult = null;
}
if (parsedResult?.type === "agent_output") {
agentOutput = nextMessage;
}
}
}
return (
<ChatMessage
key={index}
message={message}
onSendMessage={onSendMessage}
agentOutput={agentOutput}
/>
);
})}
{/* Render thinking message when streaming but no chunks yet */}
{isStreaming && streamingChunks.length === 0 && <ThinkingMessage />}
{/* Render streaming message if active */}
{isStreaming && streamingChunks.length > 0 && (
<StreamingMessage
chunks={streamingChunks}
onComplete={onStreamComplete}
/>
)}
{/* Invisible div to scroll to */}
<div ref={messagesEndRef} />
</div>
</div>
);
}

View File

@@ -81,9 +81,9 @@ export function SessionsDrawer({
</Text> </Text>
</div> </div>
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
<div className="flex h-full items-center justify-center"> <div className="flex items-center justify-center py-8">
<Text variant="body" className="text-zinc-500"> <Text variant="body" className="text-zinc-500">
You don&apos;t have previously started chats No sessions found
</Text> </Text>
</div> </div>
) : ( ) : (

View File

@@ -1,6 +1,7 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AIChatBubble } from "../AIChatBubble/AIChatBubble"; import { RobotIcon } from "@phosphor-icons/react";
import { MarkdownContent } from "../MarkdownContent/MarkdownContent"; import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
import { MessageBubble } from "../MessageBubble/MessageBubble";
import { useStreamingMessage } from "./useStreamingMessage"; import { useStreamingMessage } from "./useStreamingMessage";
export interface StreamingMessageProps { export interface StreamingMessageProps {
@@ -24,10 +25,16 @@ export function StreamingMessage({
)} )}
> >
<div className="flex w-full max-w-3xl gap-3"> <div className="flex w-full max-w-3xl gap-3">
<div className="flex-shrink-0">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-600">
<RobotIcon className="h-4 w-4 text-indigo-50" />
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<AIChatBubble> <MessageBubble variant="assistant">
<MarkdownContent content={displayText} /> <MarkdownContent content={displayText} />
</AIChatBubble> </MessageBubble>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RobotIcon } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AIChatBubble } from "../AIChatBubble/AIChatBubble"; import { MessageBubble } from "../MessageBubble/MessageBubble";
import { ChatLoader } from "../ChatLoader/ChatLoader";
export interface ThinkingMessageProps { export interface ThinkingMessageProps {
className?: string; className?: string;
@@ -34,11 +34,22 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
)} )}
> >
<div className="flex w-full max-w-3xl gap-3"> <div className="flex w-full max-w-3xl gap-3">
<div className="flex-shrink-0">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
<RobotIcon className="h-4 w-4 text-indigo-50" />
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<AIChatBubble> <MessageBubble variant="assistant">
<div className="transition-all duration-500 ease-in-out"> <div className="transition-all duration-500 ease-in-out">
{showSlowLoader ? ( {showSlowLoader ? (
<ChatLoader /> <div className="flex flex-col items-center gap-3 py-2">
<div className="loader" style={{ flexShrink: 0 }} />
<p className="text-sm text-slate-700">
Taking a bit longer to think, wait a moment please
</p>
</div>
) : ( ) : (
<span <span
className="inline-block bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-clip-text text-transparent" className="inline-block bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-clip-text text-transparent"
@@ -51,7 +62,7 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
</span> </span>
)} )}
</div> </div>
</AIChatBubble> </MessageBubble>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,24 @@
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { WrenchIcon } from "@phosphor-icons/react";
import { getToolActionPhrase } from "../../helpers";
export interface ToolCallMessageProps {
toolName: string;
className?: string;
}
export function ToolCallMessage({ toolName, className }: ToolCallMessageProps) {
return (
<div className={cn("flex items-center justify-center gap-2", className)}>
<WrenchIcon
size={14}
weight="bold"
className="flex-shrink-0 text-neutral-500"
/>
<Text variant="small" className="text-neutral-500">
{getToolActionPhrase(toolName)}...
</Text>
</div>
);
}

View File

@@ -0,0 +1,260 @@
import { Text } from "@/components/atoms/Text/Text";
import "@/components/contextual/OutputRenderers";
import {
globalRegistry,
OutputItem,
} from "@/components/contextual/OutputRenderers";
import { cn } from "@/lib/utils";
import type { ToolResult } from "@/types/chat";
import { WrenchIcon } from "@phosphor-icons/react";
import { getToolActionPhrase } from "../../helpers";
export interface ToolResponseMessageProps {
toolName: string;
result?: ToolResult;
success?: boolean;
className?: string;
}
export function ToolResponseMessage({
toolName,
result,
success: _success = true,
className,
}: ToolResponseMessageProps) {
if (!result) {
return (
<div className={cn("flex items-center justify-center gap-2", className)}>
<WrenchIcon
size={14}
weight="bold"
className="flex-shrink-0 text-neutral-500"
/>
<Text variant="small" className="text-neutral-500">
{getToolActionPhrase(toolName)}...
</Text>
</div>
);
}
let parsedResult: Record<string, unknown> | null = null;
try {
parsedResult =
typeof result === "string"
? JSON.parse(result)
: (result as Record<string, unknown>);
} catch {
parsedResult = null;
}
if (parsedResult && typeof parsedResult === "object") {
const responseType = parsedResult.type as string | undefined;
if (responseType === "agent_output") {
const execution = parsedResult.execution as
| {
outputs?: Record<string, unknown[]>;
}
| null
| undefined;
const outputs = execution?.outputs || {};
const message = parsedResult.message as string | undefined;
return (
<div className={cn("space-y-4 px-4 py-2", className)}>
<div className="flex items-center gap-2">
<WrenchIcon
size={14}
weight="bold"
className="flex-shrink-0 text-neutral-500"
/>
<Text variant="small" className="text-neutral-500">
{getToolActionPhrase(toolName)}
</Text>
</div>
{message && (
<div className="rounded border p-4">
<Text variant="small" className="text-neutral-600">
{message}
</Text>
</div>
)}
{Object.keys(outputs).length > 0 && (
<div className="space-y-4">
{Object.entries(outputs).map(([outputName, values]) =>
values.map((value, index) => {
const renderer = globalRegistry.getRenderer(value);
if (renderer) {
return (
<OutputItem
key={`${outputName}-${index}`}
value={value}
renderer={renderer}
label={outputName}
/>
);
}
return (
<div
key={`${outputName}-${index}`}
className="rounded border p-4"
>
<Text variant="large-medium" className="mb-2 capitalize">
{outputName}
</Text>
<pre className="overflow-auto text-sm">
{JSON.stringify(value, null, 2)}
</pre>
</div>
);
}),
)}
</div>
)}
</div>
);
}
if (responseType === "block_output" && parsedResult.outputs) {
const outputs = parsedResult.outputs as Record<string, unknown[]>;
return (
<div className={cn("space-y-4 px-4 py-2", className)}>
<div className="flex items-center gap-2">
<WrenchIcon
size={14}
weight="bold"
className="flex-shrink-0 text-neutral-500"
/>
<Text variant="small" className="text-neutral-500">
{getToolActionPhrase(toolName)}
</Text>
</div>
<div className="space-y-4">
{Object.entries(outputs).map(([outputName, values]) =>
values.map((value, index) => {
const renderer = globalRegistry.getRenderer(value);
if (renderer) {
return (
<OutputItem
key={`${outputName}-${index}`}
value={value}
renderer={renderer}
label={outputName}
/>
);
}
return (
<div
key={`${outputName}-${index}`}
className="rounded border p-4"
>
<Text variant="large-medium" className="mb-2 capitalize">
{outputName}
</Text>
<pre className="overflow-auto text-sm">
{JSON.stringify(value, null, 2)}
</pre>
</div>
);
}),
)}
</div>
</div>
);
}
// Handle other response types with a message field (e.g., understanding_updated)
if (parsedResult.message && typeof parsedResult.message === "string") {
// Format tool name from snake_case to Title Case
const formattedToolName = toolName
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
// Clean up message - remove incomplete user_name references
let cleanedMessage = parsedResult.message;
// Remove "Updated understanding with: user_name" pattern if user_name is just a placeholder
cleanedMessage = cleanedMessage.replace(
/Updated understanding with:\s*user_name\.?\s*/gi,
"",
);
// Remove standalone user_name references
cleanedMessage = cleanedMessage.replace(/\buser_name\b\.?\s*/gi, "");
cleanedMessage = cleanedMessage.trim();
// Only show message if it has content after cleaning
if (!cleanedMessage) {
return (
<div
className={cn(
"flex items-center justify-center gap-2 px-4 py-2",
className,
)}
>
<WrenchIcon
size={14}
weight="bold"
className="flex-shrink-0 text-neutral-500"
/>
<Text variant="small" className="text-neutral-500">
{formattedToolName}
</Text>
</div>
);
}
return (
<div className={cn("space-y-2 px-4 py-2", className)}>
<div className="flex items-center justify-center gap-2">
<WrenchIcon
size={14}
weight="bold"
className="flex-shrink-0 text-neutral-500"
/>
<Text variant="small" className="text-neutral-500">
{formattedToolName}
</Text>
</div>
<div className="rounded border p-4">
<Text variant="small" className="text-neutral-600">
{cleanedMessage}
</Text>
</div>
</div>
);
}
}
const renderer = globalRegistry.getRenderer(result);
if (renderer) {
return (
<div className={cn("px-4 py-2", className)}>
<div className="mb-2 flex items-center gap-2">
<WrenchIcon
size={14}
weight="bold"
className="flex-shrink-0 text-neutral-500"
/>
<Text variant="small" className="text-neutral-500">
{getToolActionPhrase(toolName)}
</Text>
</div>
<OutputItem value={result} renderer={renderer} />
</div>
);
}
return (
<div className={cn("flex items-center justify-center gap-2", className)}>
<WrenchIcon
size={14}
weight="bold"
className="flex-shrink-0 text-neutral-500"
/>
<Text variant="small" className="text-neutral-500">
{getToolActionPhrase(toolName)}...
</Text>
</div>
);
}

View File

@@ -0,0 +1,66 @@
/**
* Maps internal tool names to user-friendly display names with emojis.
* @deprecated Use getToolActionPhrase or getToolCompletionPhrase for status messages
*
* @param toolName - The internal tool name from the backend
* @returns A user-friendly display name with an emoji prefix
*/
export function getToolDisplayName(toolName: string): string {
const toolDisplayNames: Record<string, string> = {
find_agent: "🔍 Search Marketplace",
get_agent_details: "📋 Get Agent Details",
check_credentials: "🔑 Check Credentials",
setup_agent: "⚙️ Setup Agent",
run_agent: "▶️ Run Agent",
get_required_setup_info: "📝 Get Setup Requirements",
};
return toolDisplayNames[toolName] || toolName;
}
/**
* Maps internal tool names to human-friendly action phrases (present continuous).
* Used for tool call messages to indicate what action is currently happening.
*
* @param toolName - The internal tool name from the backend
* @returns A human-friendly action phrase in present continuous tense
*/
export function getToolActionPhrase(toolName: string): string {
const toolActionPhrases: Record<string, string> = {
find_agent: "Looking for agents in the marketplace",
agent_carousel: "Looking for agents in the marketplace",
get_agent_details: "Learning about the agent",
check_credentials: "Checking your credentials",
setup_agent: "Setting up the agent",
execution_started: "Running the agent",
run_agent: "Running the agent",
get_required_setup_info: "Getting setup requirements",
schedule_agent: "Scheduling the agent to run",
};
// Return mapped phrase or generate human-friendly fallback
return toolActionPhrases[toolName] || toolName;
}
/**
* Maps internal tool names to human-friendly completion phrases (past tense).
* Used for tool response messages to indicate what action was completed.
*
* @param toolName - The internal tool name from the backend
* @returns A human-friendly completion phrase in past tense
*/
export function getToolCompletionPhrase(toolName: string): string {
const toolCompletionPhrases: Record<string, string> = {
find_agent: "Finished searching the marketplace",
get_agent_details: "Got agent details",
check_credentials: "Checked credentials",
setup_agent: "Agent setup complete",
run_agent: "Agent execution started",
get_required_setup_info: "Got setup requirements",
};
// Return mapped phrase or generate human-friendly fallback
return (
toolCompletionPhrases[toolName] ||
`Finished ${toolName.replace(/_/g, " ").replace("...", "")}`
);
}

View File

@@ -1,20 +1,17 @@
"use client"; "use client";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useChatSession } from "./useChatSession"; import { useChatSession } from "./useChatSession";
import { useChatStream } from "./useChatStream"; import { useChatStream } from "./useChatStream";
interface UseChatArgs { export function useChat() {
urlSessionId?: string | null; const hasCreatedSessionRef = useRef(false);
}
export function useChat({ urlSessionId }: UseChatArgs = {}) {
const hasClaimedSessionRef = useRef(false); const hasClaimedSessionRef = useRef(false);
const { user } = useSupabase(); const { user } = useSupabase();
const { sendMessage: sendStreamMessage } = useChatStream(); const { sendMessage: sendStreamMessage } = useChatStream();
const [showLoader, setShowLoader] = useState(false);
const { const {
session, session,
sessionId: sessionIdFromHook, sessionId: sessionIdFromHook,
@@ -22,16 +19,27 @@ export function useChat({ urlSessionId }: UseChatArgs = {}) {
isLoading, isLoading,
isCreating, isCreating,
error, error,
isSessionNotFound,
createSession, createSession,
claimSession, claimSession,
clearSession: clearSessionBase, clearSession: clearSessionBase,
loadSession, loadSession,
} = useChatSession({ } = useChatSession({
urlSessionId, urlSessionId: null,
autoCreate: false, autoCreate: false,
}); });
useEffect(
function autoCreateSession() {
if (!hasCreatedSessionRef.current && !isCreating && !sessionIdFromHook) {
hasCreatedSessionRef.current = true;
createSession().catch((_err) => {
hasCreatedSessionRef.current = false;
});
}
},
[isCreating, sessionIdFromHook, createSession],
);
useEffect( useEffect(
function autoClaimSession() { function autoClaimSession() {
if ( if (
@@ -67,17 +75,6 @@ export function useChat({ urlSessionId }: UseChatArgs = {}) {
], ],
); );
useEffect(() => {
if (isLoading || isCreating) {
const timer = setTimeout(() => {
setShowLoader(true);
}, 300);
return () => clearTimeout(timer);
} else {
setShowLoader(false);
}
}, [isLoading, isCreating]);
useEffect(function monitorNetworkStatus() { useEffect(function monitorNetworkStatus() {
function handleOnline() { function handleOnline() {
toast.success("Connection restored", { toast.success("Connection restored", {
@@ -102,6 +99,7 @@ export function useChat({ urlSessionId }: UseChatArgs = {}) {
function clearSession() { function clearSession() {
clearSessionBase(); clearSessionBase();
hasCreatedSessionRef.current = false;
hasClaimedSessionRef.current = false; hasClaimedSessionRef.current = false;
} }
@@ -111,11 +109,9 @@ export function useChat({ urlSessionId }: UseChatArgs = {}) {
isLoading, isLoading,
isCreating, isCreating,
error, error,
isSessionNotFound,
createSession, createSession,
clearSession, clearSession,
loadSession, loadSession,
sessionId: sessionIdFromHook, sessionId: sessionIdFromHook,
showLoader,
}; };
} }

Some files were not shown because too many files have changed in this diff Show More