Compare commits

..

8 Commits

Author SHA1 Message Date
Bently
905373a712 fix(frontend): use singleton Shiki highlighter for code syntax highlighting (#12144)
## Summary
Addresses SENTRY-1051: Shiki warning about multiple highlighter
instances.

## Problem
The `@streamdown/code` package creates a **new Shiki highlighter for
each language** encountered. When users view AI chat responses with code
blocks in multiple languages (JavaScript, Python, JSON, YAML, etc.),
this creates 10+ highlighter instances, triggering Shiki's warning:

> "10 instances have been created. Shiki is supposed to be used as a
singleton, consider refactoring your code to cache your highlighter
instance"

This causes memory bloat and performance degradation.

## Solution
Introduced a custom code highlighting plugin that properly implements
the singleton pattern:

### New files:
- `src/lib/shiki-highlighter.ts` - Singleton highlighter management
- `src/lib/streamdown-code-plugin.ts` - Drop-in replacement for
`@streamdown/code`

### Key features:
- **Single shared highlighter** - One instance serves all code blocks
- **Preloaded common languages** - JS, TS, Python, JSON, Bash, YAML,
etc.
- **Lazy loading** - Additional languages loaded on demand
- **Result caching** - Avoids re-highlighting identical code blocks

### Changes:
- Added `shiki` as direct dependency
- Updated `message.tsx` to use the new plugin

## Testing
- [ ] Verify code blocks render correctly in AI chat
- [ ] Confirm no Shiki singleton warnings in console
- [ ] Test with multiple languages in same conversation

## Related
- Linear: SENTRY-1051
- Sentry: Multiple Shiki instances warning

<!-- greptile_comment -->

<details><summary><h3>Greptile Summary</h3></summary>

Replaced `@streamdown/code` with a custom singleton-based Shiki
highlighter implementation to resolve memory bloat from creating
multiple highlighter instances per language. The new implementation
creates a single shared highlighter with preloaded common languages (JS,
TS, Python, JSON, etc.) and lazy-loads additional languages on demand.
Results are cached to avoid re-highlighting identical code blocks.

**Key changes:**
- Added `shiki` v3.21.0 as a direct dependency
- Created `shiki-highlighter.ts` with singleton pattern and language
management utilities
- Created `streamdown-code-plugin.ts` as a drop-in replacement for
`@streamdown/code`
- Updated `message.tsx` to import from the new plugin instead of
`@streamdown/code`

The implementation follows React best practices with async highlighting
and callback-based notifications. The cache key uses code length +
prefix/suffix for efficient lookups on large code blocks.
</details>


<details><summary><h3>Confidence Score: 4/5</h3></summary>

- Safe to merge with minor considerations for edge cases
- The implementation is solid with proper singleton pattern, caching,
and async handling. The code is well-structured and addresses the stated
problem. However, there's a subtle potential race condition in the
callback handling where multiple concurrent requests for the same cache
key could trigger duplicate highlight operations before the first
completes. The cache key generation using prefix/suffix could
theoretically cause false cache hits for large files with identical
prefixes and suffixes. Despite these edge cases, the implementation
should work correctly for the vast majority of use cases.
- No files require special attention
</details>


<details><summary><h3>Sequence Diagram</h3></summary>

```mermaid
sequenceDiagram
    participant UI as Streamdown Component
    participant Plugin as Custom Code Plugin
    participant Cache as Token Cache
    participant Singleton as Shiki Highlighter (Singleton)
    participant Callbacks as Pending Callbacks

    UI->>Plugin: highlight(code, lang)
    Plugin->>Cache: Check cache key
    
    alt Cache hit
        Cache-->>Plugin: Return cached result
        Plugin-->>UI: Return highlighted tokens
    else Cache miss
        Plugin->>Callbacks: Register callback
        Plugin->>Singleton: Get highlighter instance
        
        alt First call
            Singleton->>Singleton: Create highlighter with preloaded languages
        end
        
        Singleton-->>Plugin: Return highlighter
        
        alt Language not loaded
            Plugin->>Singleton: Load language dynamically
        end
        
        Plugin->>Singleton: codeToTokens(code, lang, themes)
        Singleton-->>Plugin: Return tokens
        Plugin->>Cache: Store result
        Plugin->>Callbacks: Notify all waiting callbacks
        Callbacks-->>UI: Async callback with result
    end
```
</details>


<sub>Last reviewed commit: 96c793b</sub>

<!-- greptile_other_comments_section -->

<!-- /greptile_comment -->
2026-02-17 12:15:53 +00:00
Otto
ee9d39bc0f refactor(copilot): Replace legacy delete dialog with molecules/Dialog (#12136)
## Summary
Updates the session delete confirmation in CoPilot to use the new
`Dialog` component from `molecules/Dialog` instead of the legacy
`DeleteConfirmDialog`.

## Changes
- **ChatSidebar**: Use Dialog component for delete confirmation
(desktop)
- **CopilotPage**: Use Dialog component for delete confirmation (mobile)

## Behavior
- Dialog stays **open** during deletion with loading state on button
- Cancel button **disabled** while delete is in progress
- Delete button shows **loading spinner** during deletion
- Dialog only closes on successful delete or when cancel is clicked (if
not deleting)

## Screenshots
*Dialog uses the same styling as other molecules/Dialog instances in the
app*

## Requested by
@0ubbe

<!-- greptile_comment -->

<details><summary><h3>Greptile Summary</h3></summary>

Replaces the legacy `DeleteConfirmDialog` component with the new
`molecules/Dialog` component for session delete confirmations in both
desktop (ChatSidebar) and mobile (CopilotPage) views. The new
implementation maintains the same behavior: dialog stays open during
deletion with a loading state on the delete button and disabled cancel
button, closing only on successful deletion or cancel click.
</details>


<details><summary><h3>Confidence Score: 5/5</h3></summary>

- This PR is safe to merge with minimal risk
- This is a straightforward component replacement that maintains the
same behavior and UX. The Dialog component API is properly used with
controlled state, the loading states are correctly implemented, and both
mobile and desktop views are handled consistently. The changes are
well-tested patterns used elsewhere in the codebase.
- No files require special attention
</details>


<details><summary><h3>Flowchart</h3></summary>

```mermaid
flowchart TD
    A[User clicks delete button] --> B{isMobile?}
    B -->|Yes| C[CopilotPage Dialog]
    B -->|No| D[ChatSidebar Dialog]
    
    C --> E[Set sessionToDelete state]
    D --> E
    
    E --> F[Dialog opens with controlled.isOpen]
    F --> G{User action?}
    
    G -->|Cancel| H{isDeleting?}
    H -->|No| I[handleCancelDelete: setSessionToDelete null]
    H -->|Yes| J[Cancel button disabled]
    
    G -->|Confirm Delete| K[handleConfirmDelete called]
    K --> L[deleteSession mutation]
    L --> M[isDeleting = true]
    M --> N[Button shows loading spinner]
    M --> O[Cancel button disabled]
    
    L --> P{Mutation result?}
    P -->|Success| Q[Invalidate sessions query]
    Q --> R[Clear sessionId if current]
    R --> S[setSessionToDelete null]
    S --> T[Dialog closes]
    
    P -->|Error| U[Show toast error]
    U --> V[setSessionToDelete null]
    V --> W[Dialog closes]
```
</details>


<sub>Last reviewed commit: 275950c</sub>

<!-- greptile_other_comments_section -->

<!-- /greptile_comment -->

---------

Co-authored-by: Lluis Agusti <hi@llu.lu>
Co-authored-by: Ubbe <hi@ubbe.dev>
2026-02-17 19:12:27 +07:00
Swifty
05aaf7a85e fix(backend): Rename LINEAR_API_KEY to COPILOT_LINEAR_API_KEY to prevent global access (#12143)
The `LINEAR_API_KEY` environment variable name is too generic — it
matches the key name used by integrations/blocks, meaning that if set
globally, it could inadvertently grant all users access to Linear
through the blocks system rather than restricting it to the copilot
feature-request tool.

This renames the setting to `COPILOT_LINEAR_API_KEY` to make it clear
this key is scoped exclusively to the copilot's feature-request
functionality, preventing it from being picked up as a general-purpose
Linear credential.

### Changes 🏗️

- Renamed `linear_api_key` → `copilot_linear_api_key` in `Secrets`
settings model (`backend/util/settings.py`)
- Updated all references in the copilot feature-request tool
(`backend/api/features/chat/tools/feature_requests.py`)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Verified the rename is consistent across all references (settings
+ feature_requests tool)
  - [x] No other files reference the old `linear_api_key` setting name

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

> **Note:** The env var changes from `LINEAR_API_KEY` to
`COPILOT_LINEAR_API_KEY`. Any deployment using the old name will need to
update accordingly.

<!-- greptile_comment -->

<details><summary><h3>Greptile Summary</h3></summary>

Renamed `LINEAR_API_KEY` to `COPILOT_LINEAR_API_KEY` in settings and the
copilot feature-request tool to prevent unintended access through Linear
blocks.

**Key changes:**
- Updated `Secrets.linear_api_key` → `Secrets.copilot_linear_api_key` in
`backend/util/settings.py`
- Updated all references in
`backend/api/features/chat/tools/feature_requests.py`
- The rename prevents the copilot Linear key from being picked up by the
Linear blocks integration (which uses `LINEAR_API_KEY` via
`ProviderBuilder` in `backend/blocks/linear/_config.py`)

**Issues found:**
- `.env.default` still references `LINEAR_API_KEY` instead of
`COPILOT_LINEAR_API_KEY`
- Frontend styleguide has a hardcoded error message with the old
variable name
</details>


<details><summary><h3>Confidence Score: 3/5</h3></summary>

- Generally safe but requires fixing `.env.default` before deployment
- The code changes are correct and achieve the intended security
improvement by preventing scope leakage. However, the PR is incomplete -
`.env.default` wasn't updated (critical for deployment) and a frontend
error message reference was missed. These issues will cause
configuration problems for anyone deploying with the new variable name.
- Check `autogpt_platform/backend/.env.default` and
`autogpt_platform/frontend/src/app/(platform)/copilot/styleguide/page.tsx`
- both need updates to match the renamed variable
</details>


<details><summary><h3>Flowchart</h3></summary>

```mermaid
flowchart TD
    A[".env file<br/>COPILOT_LINEAR_API_KEY"] --> B["Secrets model<br/>copilot_linear_api_key"]
    B --> C["feature_requests.py<br/>_get_linear_config()"]
    C --> D["Creates APIKeyCredentials<br/>for copilot feature requests"]
    
    E[".env file<br/>LINEAR_API_KEY"] --> F["ProviderBuilder<br/>in blocks/linear/_config.py"]
    F --> G["Linear blocks integration<br/>for user workflows"]
    
    style A fill:#90EE90
    style B fill:#90EE90
    style C fill:#90EE90
    style D fill:#90EE90
    style E fill:#FFD700
    style F fill:#FFD700
    style G fill:#FFD700
```
</details>


<sub>Last reviewed commit: 86dc57a</sub>

<!-- greptile_other_comments_section -->

<!-- /greptile_comment -->
2026-02-17 11:16:43 +01:00
Reinier van der Leer
9d4dcbd9e0 fix(backend/docker): Make server last (= default) build stage
Without specifying an explicit build target it would build the `migrate` stage because it is the last stage in the Dockerfile. This caused deployment failures.

- Follow-up to #12124 and 074be7ae
2026-02-16 14:49:30 +01:00
Reinier van der Leer
074be7aea6 fix(backend/docker): Update run commands to match deployment
- Follow-up to #12124

Changes:
- Update `run` commands for all backend services in `docker-compose.platform.yml` to match the deployment commands used in production
- Add trigger on `docker-compose(.platform)?.yml` changes to the Frontend CI workflow
2026-02-16 14:23:29 +01:00
Otto
39d28b24fc ci(backend): Upgrade RabbitMQ from 3.12 (EOL) to 4.1.4 (#12118)
## Summary
Upgrades RabbitMQ from the end-of-life `rabbitmq:3.12-management` to
`rabbitmq:4.1.4`, aligning CI, local dev, and e2e testing with
production.

## Changes

### CI Workflow (`.github/workflows/platform-backend-ci.yml`)
- **Image:** `rabbitmq:3.12-management` → `rabbitmq:4.1.4`
- **Port:** Removed 15672 (management UI) — not used
- **Health check:** Added to prevent flaky tests from race conditions
during startup

### Docker Compose (`docker-compose.platform.yml`,
`docker-compose.test.yaml`)
- **Image:** `rabbitmq:management` → `rabbitmq:4.1.4`
- **Port:** Removed 15672 (management UI) — not used

## Why
- RabbitMQ 3.12 is EOL
- We don't use the management interface, so `-management` variant is
unnecessary
- CI and local dev/e2e should match production (4.1.4)

## Testing
CI validates that backend tests pass against RabbitMQ 4.1.4 on Python
3.11, 3.12, and 3.13.

---
Closes SECRT-1703
2026-02-16 12:45:39 +00:00
Reinier van der Leer
bf79a7748a fix(backend/build): Update stale Poetry usage in Dockerfile (#12124)
[SECRT-2006: Dev deployment failing: poetry not found in container
PATH](https://linear.app/autogpt/issue/SECRT-2006)

- Follow-up to #12090

### Changes 🏗️

- Remove now-broken Poetry path config values
- Remove usage of now-broken `poetry run` in container run command
- Add trigger on `backend/Dockerfile` changes to Frontend CI workflow

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - If it works, CI will pass
2026-02-16 13:54:20 +01:00
Otto
649d4ab7f5 feat(chat): Add delete chat session endpoint and UI (#12112)
## Summary

Adds the ability to delete chat sessions from the CoPilot interface.

## Changes

### Backend
- Add `DELETE /api/chat/sessions/{session_id}` endpoint in `routes.py`
- Returns 204 on success, 404 if not found or not owned by user
- Reuses existing `delete_chat_session` function from `model.py`

### Frontend
- Add delete button (trash icon) that appears on hover for each chat
session
- Add confirmation dialog before deletion using existing
`DeleteConfirmDialog` component
- Refresh session list after successful delete
- Clear current session selection if the deleted session was active
- Update OpenAPI spec with new endpoint

## Testing

1. Hover over a chat session in sidebar → trash icon appears
2. Click trash icon → confirmation dialog
3. Confirm deletion → session removed, list refreshes
4. If deleted session was active, selection is cleared

## Screenshots

Delete button appears on hover, confirmation dialog on click.

## Related Issues

Closes SECRT-1928

<!-- greptile_comment -->

<h2>Greptile Overview</h2>

<details><summary><h3>Greptile Summary</h3></summary>

Adds the ability to delete chat sessions from the CoPilot interface — a
new `DELETE /api/chat/sessions/{session_id}` backend endpoint and a
corresponding delete button with confirmation dialog in the
`ChatSidebar` frontend component.

- **Backend route** (`routes.py`): Clean implementation reusing the
existing `delete_chat_session` model function with proper auth guards
and 204/404 responses. No issues.
- **Frontend** (`ChatSidebar.tsx`): Adds hover-visible trash icon per
session, confirmation dialog, mutation with cache invalidation, and
active session clearing on delete. However, it uses a `__legacy__`
component (`DeleteConfirmDialog`) which violates the project's style
guide — new code should use the modern design system components. Error
handling only logs to console without user-facing feedback (project
convention is to use toast notifications for mutation errors).
`isDeleting` is destructured but unused.
- **OpenAPI spec** updated correctly.
- **Unrelated file included**:
`notes/plan-SECRT-1959-graph-edge-desync.md` is a planning document for
a different ticket and should be removed from this PR. The `notes/`
directory is newly introduced and both plan files should be reconsidered
for inclusion.
</details>


<details><summary><h3>Confidence Score: 3/5</h3></summary>

- Functionally correct but has style guide violations and includes
unrelated files that should be addressed before merge.
- The core feature implementation (backend DELETE endpoint and frontend
mutation logic) is sound and follows existing patterns. Score is lowered
because: (1) the frontend uses a legacy component explicitly prohibited
by the project's style guide, (2) mutation errors are not surfaced to
the user, and (3) the PR includes an unrelated planning document for a
different ticket.
- Pay close attention to `ChatSidebar.tsx` for the legacy component
import and error handling, and
`notes/plan-SECRT-1959-graph-edge-desync.md` which should be removed.
</details>


<details><summary><h3>Sequence Diagram</h3></summary>

```mermaid
sequenceDiagram
    participant User
    participant ChatSidebar as ChatSidebar (Frontend)
    participant ReactQuery as React Query
    participant API as DELETE /api/chat/sessions/{id}
    participant Model as model.delete_chat_session
    participant DB as db.delete_chat_session (Prisma)
    participant Redis as Redis Cache

    User->>ChatSidebar: Click trash icon on session
    ChatSidebar->>ChatSidebar: Show DeleteConfirmDialog
    User->>ChatSidebar: Confirm deletion
    ChatSidebar->>ReactQuery: deleteSession({ sessionId })
    ReactQuery->>API: DELETE /api/chat/sessions/{session_id}
    API->>Model: delete_chat_session(session_id, user_id)
    Model->>DB: delete_many(where: {id, userId})
    DB-->>Model: bool (deleted count > 0)
    Model->>Redis: Delete session cache key
    Model->>Model: Clean up session lock
    Model-->>API: True
    API-->>ReactQuery: 204 No Content
    ReactQuery->>ChatSidebar: onSuccess callback
    ChatSidebar->>ReactQuery: invalidateQueries(sessions list)
    ChatSidebar->>ChatSidebar: Clear sessionId if deleted was active
```
</details>


<sub>Last reviewed commit: 44a92c6</sub>

<!-- greptile_other_comments_section -->

<details><summary><h4>Context used (3)</h4></summary>

- Context from `dashboard` - autogpt_platform/frontend/CLAUDE.md
([source](https://app.greptile.com/review/custom-context?memory=39861924-d320-41ba-a1a7-a8bff44f780a))
- Context from `dashboard` - autogpt_platform/frontend/CONTRIBUTING.md
([source](https://app.greptile.com/review/custom-context?memory=cc4f1b17-cb5c-4b63-b218-c772b48e20ee))
- Context from `dashboard` - autogpt_platform/CLAUDE.md
([source](https://app.greptile.com/review/custom-context?memory=6e9dc5dc-8942-47df-8677-e60062ec8c3a))
</details>


<!-- /greptile_comment -->

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
2026-02-16 12:19:18 +00:00
25 changed files with 815 additions and 240 deletions

View File

@@ -41,13 +41,18 @@ jobs:
ports: ports:
- 6379:6379 - 6379:6379
rabbitmq: rabbitmq:
image: rabbitmq:3.12-management image: rabbitmq:4.1.4
ports: ports:
- 5672:5672 - 5672:5672
- 15672:15672
env: env:
RABBITMQ_DEFAULT_USER: ${{ env.RABBITMQ_DEFAULT_USER }} RABBITMQ_DEFAULT_USER: ${{ env.RABBITMQ_DEFAULT_USER }}
RABBITMQ_DEFAULT_PASS: ${{ env.RABBITMQ_DEFAULT_PASS }} RABBITMQ_DEFAULT_PASS: ${{ env.RABBITMQ_DEFAULT_PASS }}
options: >-
--health-cmd "rabbitmq-diagnostics -q ping"
--health-interval 30s
--health-timeout 10s
--health-retries 5
--health-start-period 10s
clamav: clamav:
image: clamav/clamav-debian:latest image: clamav/clamav-debian:latest
ports: ports:

View File

@@ -6,10 +6,16 @@ on:
paths: paths:
- ".github/workflows/platform-frontend-ci.yml" - ".github/workflows/platform-frontend-ci.yml"
- "autogpt_platform/frontend/**" - "autogpt_platform/frontend/**"
- "autogpt_platform/backend/Dockerfile"
- "autogpt_platform/docker-compose.yml"
- "autogpt_platform/docker-compose.platform.yml"
pull_request: pull_request:
paths: paths:
- ".github/workflows/platform-frontend-ci.yml" - ".github/workflows/platform-frontend-ci.yml"
- "autogpt_platform/frontend/**" - "autogpt_platform/frontend/**"
- "autogpt_platform/backend/Dockerfile"
- "autogpt_platform/docker-compose.yml"
- "autogpt_platform/docker-compose.platform.yml"
merge_group: merge_group:
workflow_dispatch: workflow_dispatch:

View File

@@ -53,63 +53,6 @@ COPY autogpt_platform/backend/backend/data/partial_types.py ./backend/data/parti
COPY autogpt_platform/backend/gen_prisma_types_stub.py ./ COPY autogpt_platform/backend/gen_prisma_types_stub.py ./
RUN poetry run prisma generate && poetry run gen-prisma-stub RUN poetry run prisma generate && poetry run gen-prisma-stub
# ============================== BACKEND SERVER ============================== #
FROM debian:13-slim AS server
WORKDIR /app
ENV POETRY_HOME=/opt/poetry \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=true \
POETRY_VIRTUALENVS_IN_PROJECT=true \
DEBIAN_FRONTEND=noninteractive
ENV PATH=/opt/poetry/bin:$PATH
# Install Python, FFmpeg, ImageMagick, and CLI tools for agent use.
# bubblewrap provides OS-level sandbox (whitelist-only FS + no network)
# for the bash_exec MCP tool.
# Using --no-install-recommends saves ~650MB by skipping unnecessary deps like llvm, mesa, etc.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.13 \
python3-pip \
ffmpeg \
imagemagick \
jq \
ripgrep \
tree \
bubblewrap \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/lib/python3* /usr/local/lib/python3*
COPY --from=builder /usr/local/bin/poetry /usr/local/bin/poetry
# Copy Node.js installation for Prisma
COPY --from=builder /usr/bin/node /usr/bin/node
COPY --from=builder /usr/lib/node_modules /usr/lib/node_modules
COPY --from=builder /usr/bin/npm /usr/bin/npm
COPY --from=builder /usr/bin/npx /usr/bin/npx
COPY --from=builder /root/.cache/prisma-python/binaries /root/.cache/prisma-python/binaries
WORKDIR /app/autogpt_platform/backend
# Copy only the .venv from builder (not the entire /app directory)
# The .venv includes the generated Prisma client
COPY --from=builder /app/autogpt_platform/backend/.venv ./.venv
ENV PATH="/app/autogpt_platform/backend/.venv/bin:$PATH"
# Copy dependency files + autogpt_libs (path dependency)
COPY autogpt_platform/autogpt_libs /app/autogpt_platform/autogpt_libs
COPY autogpt_platform/backend/poetry.lock autogpt_platform/backend/pyproject.toml ./
# Copy backend code + docs (for Copilot docs search)
COPY autogpt_platform/backend ./
COPY docs /app/docs
RUN poetry install --no-ansi --only-root
ENV PORT=8000
CMD ["poetry", "run", "rest"]
# =============================== DB MIGRATOR =============================== # # =============================== DB MIGRATOR =============================== #
# Lightweight migrate stage - only needs Prisma CLI, not full Python environment # Lightweight migrate stage - only needs Prisma CLI, not full Python environment
@@ -141,3 +84,59 @@ COPY autogpt_platform/backend/schema.prisma ./
COPY autogpt_platform/backend/backend/data/partial_types.py ./backend/data/partial_types.py COPY autogpt_platform/backend/backend/data/partial_types.py ./backend/data/partial_types.py
COPY autogpt_platform/backend/gen_prisma_types_stub.py ./ COPY autogpt_platform/backend/gen_prisma_types_stub.py ./
COPY autogpt_platform/backend/migrations ./migrations COPY autogpt_platform/backend/migrations ./migrations
# ============================== BACKEND SERVER ============================== #
FROM debian:13-slim AS server
WORKDIR /app
ENV DEBIAN_FRONTEND=noninteractive
# Install Python, FFmpeg, ImageMagick, and CLI tools for agent use.
# bubblewrap provides OS-level sandbox (whitelist-only FS + no network)
# for the bash_exec MCP tool.
# Using --no-install-recommends saves ~650MB by skipping unnecessary deps like llvm, mesa, etc.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.13 \
python3-pip \
ffmpeg \
imagemagick \
jq \
ripgrep \
tree \
bubblewrap \
&& rm -rf /var/lib/apt/lists/*
# Copy poetry (build-time only, for `poetry install --only-root` to create entry points)
COPY --from=builder /usr/local/lib/python3* /usr/local/lib/python3*
COPY --from=builder /usr/local/bin/poetry /usr/local/bin/poetry
# Copy Node.js installation for Prisma
COPY --from=builder /usr/bin/node /usr/bin/node
COPY --from=builder /usr/lib/node_modules /usr/lib/node_modules
COPY --from=builder /usr/bin/npm /usr/bin/npm
COPY --from=builder /usr/bin/npx /usr/bin/npx
COPY --from=builder /root/.cache/prisma-python/binaries /root/.cache/prisma-python/binaries
WORKDIR /app/autogpt_platform/backend
# Copy only the .venv from builder (not the entire /app directory)
# The .venv includes the generated Prisma client
COPY --from=builder /app/autogpt_platform/backend/.venv ./.venv
ENV PATH="/app/autogpt_platform/backend/.venv/bin:$PATH"
# Copy dependency files + autogpt_libs (path dependency)
COPY autogpt_platform/autogpt_libs /app/autogpt_platform/autogpt_libs
COPY autogpt_platform/backend/poetry.lock autogpt_platform/backend/pyproject.toml ./
# Copy backend code + docs (for Copilot docs search)
COPY autogpt_platform/backend ./
COPY docs /app/docs
# Install the project package to create entry point scripts in .venv/bin/
# (e.g., rest, executor, ws, db, scheduler, notification - see [tool.poetry.scripts])
RUN POETRY_VIRTUALENVS_CREATE=true POETRY_VIRTUALENVS_IN_PROJECT=true \
poetry install --no-ansi --only-root
ENV PORT=8000
CMD ["rest"]

View File

@@ -23,6 +23,7 @@ from .model import (
ChatSession, ChatSession,
append_and_save_message, append_and_save_message,
create_chat_session, create_chat_session,
delete_chat_session,
get_chat_session, get_chat_session,
get_user_sessions, get_user_sessions,
) )
@@ -211,6 +212,43 @@ async def create_session(
) )
@router.delete(
"/sessions/{session_id}",
dependencies=[Security(auth.requires_user)],
status_code=204,
responses={404: {"description": "Session not found or access denied"}},
)
async def delete_session(
session_id: str,
user_id: Annotated[str, Security(auth.get_user_id)],
) -> Response:
"""
Delete a chat session.
Permanently removes a chat session and all its messages.
Only the owner can delete their sessions.
Args:
session_id: The session ID to delete.
user_id: The authenticated user's ID.
Returns:
204 No Content on success.
Raises:
HTTPException: 404 if session not found or not owned by user.
"""
deleted = await delete_chat_session(session_id, user_id)
if not deleted:
raise HTTPException(
status_code=404,
detail=f"Session {session_id} not found or access denied",
)
return Response(status_code=204)
@router.get( @router.get(
"/sessions/{session_id}", "/sessions/{session_id}",
) )

View File

@@ -104,8 +104,8 @@ def _get_linear_config() -> tuple[LinearClient, str, str]:
Raises RuntimeError if any required setting is missing. Raises RuntimeError if any required setting is missing.
""" """
secrets = _get_settings().secrets secrets = _get_settings().secrets
if not secrets.linear_api_key: if not secrets.copilot_linear_api_key:
raise RuntimeError("LINEAR_API_KEY is not configured") raise RuntimeError("COPILOT_LINEAR_API_KEY is not configured")
if not secrets.linear_feature_request_project_id: if not secrets.linear_feature_request_project_id:
raise RuntimeError("LINEAR_FEATURE_REQUEST_PROJECT_ID is not configured") raise RuntimeError("LINEAR_FEATURE_REQUEST_PROJECT_ID is not configured")
if not secrets.linear_feature_request_team_id: if not secrets.linear_feature_request_team_id:
@@ -114,7 +114,7 @@ def _get_linear_config() -> tuple[LinearClient, str, str]:
credentials = APIKeyCredentials( credentials = APIKeyCredentials(
id="system-linear", id="system-linear",
provider="linear", provider="linear",
api_key=SecretStr(secrets.linear_api_key), api_key=SecretStr(secrets.copilot_linear_api_key),
title="System Linear API Key", title="System Linear API Key",
) )
client = LinearClient(credentials=credentials) client = LinearClient(credentials=credentials)

View File

@@ -662,7 +662,7 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
mem0_api_key: str = Field(default="", description="Mem0 API key") mem0_api_key: str = Field(default="", description="Mem0 API key")
elevenlabs_api_key: str = Field(default="", description="ElevenLabs API key") elevenlabs_api_key: str = Field(default="", description="ElevenLabs API key")
linear_api_key: str = Field( copilot_linear_api_key: str = Field(
default="", description="Linear API key for system-level operations" default="", description="Linear API key for system-level operations"
) )
linear_feature_request_project_id: str = Field( linear_feature_request_project_id: str = Field(

View File

@@ -53,7 +53,7 @@ services:
rabbitmq: rabbitmq:
<<: *agpt-services <<: *agpt-services
image: rabbitmq:management image: rabbitmq:4.1.4
container_name: rabbitmq container_name: rabbitmq
healthcheck: healthcheck:
test: rabbitmq-diagnostics -q ping test: rabbitmq-diagnostics -q ping
@@ -66,7 +66,6 @@ services:
- RABBITMQ_DEFAULT_PASS=k0VMxyIJF9S35f3x2uaw5IWAl6Y536O7 - RABBITMQ_DEFAULT_PASS=k0VMxyIJF9S35f3x2uaw5IWAl6Y536O7
ports: ports:
- "5672:5672" - "5672:5672"
- "15672:15672"
clamav: clamav:
image: clamav/clamav-debian:latest image: clamav/clamav-debian:latest
ports: ports:

View File

@@ -75,7 +75,7 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
rabbitmq: rabbitmq:
image: rabbitmq:management image: rabbitmq:4.1.4
container_name: rabbitmq container_name: rabbitmq
healthcheck: healthcheck:
test: rabbitmq-diagnostics -q ping test: rabbitmq-diagnostics -q ping
@@ -88,14 +88,13 @@ services:
<<: *backend-env <<: *backend-env
ports: ports:
- "5672:5672" - "5672:5672"
- "15672:15672"
rest_server: rest_server:
build: build:
context: ../ context: ../
dockerfile: autogpt_platform/backend/Dockerfile dockerfile: autogpt_platform/backend/Dockerfile
target: server target: server
command: ["python", "-m", "backend.rest"] command: ["rest"] # points to entry in [tool.poetry.scripts] in pyproject.toml
develop: develop:
watch: watch:
- path: ./ - path: ./
@@ -128,7 +127,7 @@ services:
context: ../ context: ../
dockerfile: autogpt_platform/backend/Dockerfile dockerfile: autogpt_platform/backend/Dockerfile
target: server target: server
command: ["python", "-m", "backend.exec"] command: ["executor"] # points to entry in [tool.poetry.scripts] in pyproject.toml
develop: develop:
watch: watch:
- path: ./ - path: ./
@@ -163,7 +162,7 @@ services:
context: ../ context: ../
dockerfile: autogpt_platform/backend/Dockerfile dockerfile: autogpt_platform/backend/Dockerfile
target: server target: server
command: ["python", "-m", "backend.ws"] command: ["ws"] # points to entry in [tool.poetry.scripts] in pyproject.toml
develop: develop:
watch: watch:
- path: ./ - path: ./
@@ -196,7 +195,7 @@ services:
context: ../ context: ../
dockerfile: autogpt_platform/backend/Dockerfile dockerfile: autogpt_platform/backend/Dockerfile
target: server target: server
command: ["python", "-m", "backend.db"] command: ["db"] # points to entry in [tool.poetry.scripts] in pyproject.toml
develop: develop:
watch: watch:
- path: ./ - path: ./
@@ -225,7 +224,7 @@ services:
context: ../ context: ../
dockerfile: autogpt_platform/backend/Dockerfile dockerfile: autogpt_platform/backend/Dockerfile
target: server target: server
command: ["python", "-m", "backend.scheduler"] command: ["scheduler"] # points to entry in [tool.poetry.scripts] in pyproject.toml
develop: develop:
watch: watch:
- path: ./ - path: ./
@@ -273,7 +272,7 @@ services:
context: ../ context: ../
dockerfile: autogpt_platform/backend/Dockerfile dockerfile: autogpt_platform/backend/Dockerfile
target: server target: server
command: ["python", "-m", "backend.notification"] command: ["notification"] # points to entry in [tool.poetry.scripts] in pyproject.toml
develop: develop:
watch: watch:
- path: ./ - path: ./

View File

@@ -62,7 +62,6 @@
"@rjsf/validator-ajv8": "6.1.2", "@rjsf/validator-ajv8": "6.1.2",
"@sentry/nextjs": "10.27.0", "@sentry/nextjs": "10.27.0",
"@streamdown/cjk": "1.0.1", "@streamdown/cjk": "1.0.1",
"@streamdown/code": "1.0.1",
"@streamdown/math": "1.0.1", "@streamdown/math": "1.0.1",
"@streamdown/mermaid": "1.0.1", "@streamdown/mermaid": "1.0.1",
"@supabase/ssr": "0.7.0", "@supabase/ssr": "0.7.0",
@@ -116,6 +115,7 @@
"remark-gfm": "4.0.1", "remark-gfm": "4.0.1",
"remark-math": "6.0.0", "remark-math": "6.0.0",
"shepherd.js": "14.5.1", "shepherd.js": "14.5.1",
"shiki": "^3.21.0",
"sonner": "2.0.7", "sonner": "2.0.7",
"streamdown": "2.1.0", "streamdown": "2.1.0",
"tailwind-merge": "2.6.0", "tailwind-merge": "2.6.0",

View File

@@ -108,9 +108,6 @@ importers:
'@streamdown/cjk': '@streamdown/cjk':
specifier: 1.0.1 specifier: 1.0.1
version: 1.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@18.3.1)(unified@11.0.5) version: 1.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@18.3.1)(unified@11.0.5)
'@streamdown/code':
specifier: 1.0.1
version: 1.0.1(react@18.3.1)
'@streamdown/math': '@streamdown/math':
specifier: 1.0.1 specifier: 1.0.1
version: 1.0.1(react@18.3.1) version: 1.0.1(react@18.3.1)
@@ -270,6 +267,9 @@ importers:
shepherd.js: shepherd.js:
specifier: 14.5.1 specifier: 14.5.1
version: 14.5.1 version: 14.5.1
shiki:
specifier: ^3.21.0
version: 3.21.0
sonner: sonner:
specifier: 2.0.7 specifier: 2.0.7
version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -3307,11 +3307,6 @@ packages:
peerDependencies: peerDependencies:
react: ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0
'@streamdown/code@1.0.1':
resolution: {integrity: sha512-U9LITfQ28tZYAoY922jdtw1ryg4kgRBdURopqK9hph7G2fBUwPeHthjH7SvaV0fvFv7EqjqCzARJuWUljLe9Ag==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
'@streamdown/math@1.0.1': '@streamdown/math@1.0.1':
resolution: {integrity: sha512-R9WdHbpERiRU7WeO7oT1aIbnLJ/jraDr89F7X9x2OM//Y8G8UMATRnLD/RUwg4VLr8Nu7QSIJ0Pa8lXd2meM4Q==} resolution: {integrity: sha512-R9WdHbpERiRU7WeO7oT1aIbnLJ/jraDr89F7X9x2OM//Y8G8UMATRnLD/RUwg4VLr8Nu7QSIJ0Pa8lXd2meM4Q==}
peerDependencies: peerDependencies:
@@ -11907,11 +11902,6 @@ snapshots:
- micromark-util-types - micromark-util-types
- unified - unified
'@streamdown/code@1.0.1(react@18.3.1)':
dependencies:
react: 18.3.1
shiki: 3.21.0
'@streamdown/math@1.0.1(react@18.3.1)': '@streamdown/math@1.0.1(react@18.3.1)':
dependencies: dependencies:
katex: 0.16.28 katex: 0.16.28

View File

@@ -1,8 +1,16 @@
"use client"; "use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { SidebarProvider } from "@/components/ui/sidebar"; import { SidebarProvider } from "@/components/ui/sidebar";
import { DotsThree } from "@phosphor-icons/react";
import { ChatContainer } from "./components/ChatContainer/ChatContainer"; import { ChatContainer } from "./components/ChatContainer/ChatContainer";
import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar"; import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar";
import { DeleteChatDialog } from "./components/DeleteChatDialog/DeleteChatDialog";
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer"; import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
import { MobileHeader } from "./components/MobileHeader/MobileHeader"; import { MobileHeader } from "./components/MobileHeader/MobileHeader";
import { ScaleLoader } from "./components/ScaleLoader/ScaleLoader"; import { ScaleLoader } from "./components/ScaleLoader/ScaleLoader";
@@ -31,6 +39,12 @@ export function CopilotPage() {
handleDrawerOpenChange, handleDrawerOpenChange,
handleSelectSession, handleSelectSession,
handleNewChat, handleNewChat,
// Delete functionality
sessionToDelete,
isDeleting,
handleDeleteClick,
handleConfirmDelete,
handleCancelDelete,
} = useCopilotPage(); } = useCopilotPage();
if (isUserLoading || !isLoggedIn) { if (isUserLoading || !isLoggedIn) {
@@ -60,6 +74,38 @@ export function CopilotPage() {
onCreateSession={createSession} onCreateSession={createSession}
onSend={onSend} onSend={onSend}
onStop={stop} onStop={stop}
headerSlot={
isMobile && sessionId ? (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="rounded p-1.5 hover:bg-neutral-100"
aria-label="More actions"
>
<DotsThree className="h-5 w-5 text-neutral-600" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
const session = sessions.find(
(s) => s.id === sessionId,
);
if (session) {
handleDeleteClick(session.id, session.title);
}
}}
disabled={isDeleting}
className="text-red-600 focus:bg-red-50 focus:text-red-600"
>
Delete chat
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : undefined
}
/> />
</div> </div>
</div> </div>
@@ -75,6 +121,15 @@ export function CopilotPage() {
onOpenChange={handleDrawerOpenChange} onOpenChange={handleDrawerOpenChange}
/> />
)} )}
{/* Delete confirmation dialog - rendered at top level for proper z-index on mobile */}
{isMobile && (
<DeleteChatDialog
session={sessionToDelete}
isDeleting={isDeleting}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
)}
</SidebarProvider> </SidebarProvider>
); );
} }

View File

@@ -2,6 +2,7 @@
import { ChatInput } from "@/app/(platform)/copilot/components/ChatInput/ChatInput"; import { ChatInput } from "@/app/(platform)/copilot/components/ChatInput/ChatInput";
import { UIDataTypes, UIMessage, UITools } from "ai"; import { UIDataTypes, UIMessage, UITools } from "ai";
import { LayoutGroup, motion } from "framer-motion"; import { LayoutGroup, motion } from "framer-motion";
import { ReactNode } from "react";
import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer"; import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer";
import { CopilotChatActionsProvider } from "../CopilotChatActionsProvider/CopilotChatActionsProvider"; import { CopilotChatActionsProvider } from "../CopilotChatActionsProvider/CopilotChatActionsProvider";
import { EmptySession } from "../EmptySession/EmptySession"; import { EmptySession } from "../EmptySession/EmptySession";
@@ -16,6 +17,7 @@ export interface ChatContainerProps {
onCreateSession: () => void | Promise<string>; onCreateSession: () => void | Promise<string>;
onSend: (message: string) => void | Promise<void>; onSend: (message: string) => void | Promise<void>;
onStop: () => void; onStop: () => void;
headerSlot?: ReactNode;
} }
export const ChatContainer = ({ export const ChatContainer = ({
messages, messages,
@@ -27,6 +29,7 @@ export const ChatContainer = ({
onCreateSession, onCreateSession,
onSend, onSend,
onStop, onStop,
headerSlot,
}: ChatContainerProps) => { }: ChatContainerProps) => {
const inputLayoutId = "copilot-2-chat-input"; const inputLayoutId = "copilot-2-chat-input";
@@ -41,6 +44,7 @@ export const ChatContainer = ({
status={status} status={status}
error={error} error={error}
isLoading={isLoadingSession} isLoading={isLoadingSession}
headerSlot={headerSlot}
/> />
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}

View File

@@ -118,6 +118,7 @@ interface ChatMessagesContainerProps {
status: string; status: string;
error: Error | undefined; error: Error | undefined;
isLoading: boolean; isLoading: boolean;
headerSlot?: React.ReactNode;
} }
export const ChatMessagesContainer = ({ export const ChatMessagesContainer = ({
@@ -125,6 +126,7 @@ export const ChatMessagesContainer = ({
status, status,
error, error,
isLoading, isLoading,
headerSlot,
}: ChatMessagesContainerProps) => { }: ChatMessagesContainerProps) => {
const [thinkingPhrase, setThinkingPhrase] = useState(getRandomPhrase); const [thinkingPhrase, setThinkingPhrase] = useState(getRandomPhrase);
const lastToastTimeRef = useRef(0); const lastToastTimeRef = useRef(0);
@@ -165,6 +167,7 @@ export const ChatMessagesContainer = ({
return ( return (
<Conversation className="min-h-0 flex-1"> <Conversation className="min-h-0 flex-1">
<ConversationContent className="flex flex-1 flex-col gap-6 px-3 py-6"> <ConversationContent className="flex flex-1 flex-col gap-6 px-3 py-6">
{headerSlot}
{isLoading && messages.length === 0 && ( {isLoading && messages.length === 0 && (
<div className="flex min-h-full flex-1 items-center justify-center"> <div className="flex min-h-full flex-1 items-center justify-center">
<LoadingSpinner className="text-neutral-600" /> <LoadingSpinner className="text-neutral-600" />

View File

@@ -1,8 +1,19 @@
"use client"; "use client";
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat"; import {
getGetV2ListSessionsQueryKey,
useDeleteV2DeleteSession,
useGetV2ListSessions,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { Button } from "@/components/atoms/Button/Button"; import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text"; import { Text } from "@/components/atoms/Text/Text";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { toast } from "@/components/molecules/Toast/use-toast";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -12,18 +23,53 @@ import {
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PlusCircleIcon, PlusIcon } from "@phosphor-icons/react"; import { DotsThree, PlusCircleIcon, PlusIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { parseAsString, useQueryState } from "nuqs"; import { parseAsString, useQueryState } from "nuqs";
import { useState } from "react";
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
export function ChatSidebar() { export function ChatSidebar() {
const { state } = useSidebar(); const { state } = useSidebar();
const isCollapsed = state === "collapsed"; const isCollapsed = state === "collapsed";
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString); const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
const [sessionToDelete, setSessionToDelete] = useState<{
id: string;
title: string | null | undefined;
} | null>(null);
const queryClient = useQueryClient();
const { data: sessionsResponse, isLoading: isLoadingSessions } = const { data: sessionsResponse, isLoading: isLoadingSessions } =
useGetV2ListSessions({ limit: 50 }); useGetV2ListSessions({ limit: 50 });
const { mutate: deleteSession, isPending: isDeleting } =
useDeleteV2DeleteSession({
mutation: {
onSuccess: () => {
// Invalidate sessions list to refetch
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
// If we deleted the current session, clear selection
if (sessionToDelete?.id === sessionId) {
setSessionId(null);
}
setSessionToDelete(null);
},
onError: (error) => {
toast({
title: "Failed to delete chat",
description:
error instanceof Error ? error.message : "An error occurred",
variant: "destructive",
});
setSessionToDelete(null);
},
},
});
const sessions = const sessions =
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : []; sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
@@ -35,6 +81,28 @@ export function ChatSidebar() {
setSessionId(id); setSessionId(id);
} }
function handleDeleteClick(
e: React.MouseEvent,
id: string,
title: string | null | undefined,
) {
e.stopPropagation(); // Prevent session selection
if (isDeleting) return; // Prevent double-click during deletion
setSessionToDelete({ id, title });
}
function handleConfirmDelete() {
if (sessionToDelete) {
deleteSession({ sessionId: sessionToDelete.id });
}
}
function handleCancelDelete() {
if (!isDeleting) {
setSessionToDelete(null);
}
}
function formatDate(dateString: string) { function formatDate(dateString: string) {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
@@ -61,6 +129,7 @@ export function ChatSidebar() {
} }
return ( return (
<>
<Sidebar <Sidebar
variant="inset" variant="inset"
collapsible="icon" collapsible="icon"
@@ -130,15 +199,18 @@ export function ChatSidebar() {
</p> </p>
) : ( ) : (
sessions.map((session) => ( sessions.map((session) => (
<button <div
key={session.id} key={session.id}
onClick={() => handleSelectSession(session.id)}
className={cn( className={cn(
"w-full rounded-lg px-3 py-2.5 text-left transition-colors", "group relative w-full rounded-lg transition-colors",
session.id === sessionId session.id === sessionId
? "bg-zinc-100" ? "bg-zinc-100"
: "hover:bg-zinc-50", : "hover:bg-zinc-50",
)} )}
>
<button
onClick={() => handleSelectSession(session.id)}
className="w-full px-3 py-2.5 pr-10 text-left"
> >
<div className="flex min-w-0 max-w-full flex-col overflow-hidden"> <div className="flex min-w-0 max-w-full flex-col overflow-hidden">
<div className="min-w-0 max-w-full"> <div className="min-w-0 max-w-full">
@@ -159,6 +231,29 @@ export function ChatSidebar() {
</Text> </Text>
</div> </div>
</button> </button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1.5 text-zinc-600 transition-all hover:bg-neutral-100"
aria-label="More actions"
>
<DotsThree className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) =>
handleDeleteClick(e, session.id, session.title)
}
disabled={isDeleting}
className="text-red-600 focus:bg-red-50 focus:text-red-600"
>
Delete chat
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)) ))
)} )}
</motion.div> </motion.div>
@@ -184,5 +279,13 @@ export function ChatSidebar() {
</SidebarFooter> </SidebarFooter>
)} )}
</Sidebar> </Sidebar>
<DeleteChatDialog
session={sessionToDelete}
isDeleting={isDeleting}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
); );
} }

View File

@@ -0,0 +1,57 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
interface Props {
session: { id: string; title: string | null | undefined } | null;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function DeleteChatDialog({
session,
isDeleting,
onConfirm,
onCancel,
}: Props) {
return (
<Dialog
title="Delete chat"
styling={{ maxWidth: "30rem", minWidth: "auto" }}
controlled={{
isOpen: !!session,
set: async (open) => {
if (!open && !isDeleting) {
onCancel();
}
},
}}
onClose={isDeleting ? undefined : onCancel}
>
<Dialog.Content>
<Text variant="body">
Are you sure you want to delete{" "}
<Text variant="body-medium" as="span">
&quot;{session?.title || "Untitled chat"}&quot;
</Text>
? This action cannot be undone.
</Text>
<Dialog.Footer>
<Button variant="secondary" onClick={onCancel} disabled={isDeleting}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
loading={isDeleting}
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -8,15 +8,19 @@ interface Props {
export function MobileHeader({ onOpenDrawer }: Props) { export function MobileHeader({ onOpenDrawer }: Props) {
return ( return (
<div
className="fixed z-50 flex gap-2"
style={{ left: "1rem", top: `${NAVBAR_HEIGHT_PX + 20}px` }}
>
<Button <Button
variant="icon" variant="icon"
size="icon" size="icon"
aria-label="Open sessions" aria-label="Open sessions"
onClick={onOpenDrawer} onClick={onOpenDrawer}
className="fixed z-50 bg-white shadow-md" className="bg-white shadow-md"
style={{ left: "1rem", top: `${NAVBAR_HEIGHT_PX + 20}px` }}
> >
<ListIcon width="1.25rem" height="1.25rem" /> <ListIcon width="1.25rem" height="1.25rem" />
</Button> </Button>
</div>
); );
} }

View File

@@ -1,10 +1,15 @@
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat"; import {
getGetV2ListSessionsQueryKey,
useDeleteV2DeleteSession,
useGetV2ListSessions,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { toast } from "@/components/molecules/Toast/use-toast"; import { toast } from "@/components/molecules/Toast/use-toast";
import { useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChatSession } from "./useChatSession"; import { useChatSession } from "./useChatSession";
import { useLongRunningToolPolling } from "./hooks/useLongRunningToolPolling"; import { useLongRunningToolPolling } from "./hooks/useLongRunningToolPolling";
@@ -14,6 +19,11 @@ export function useCopilotPage() {
const { isUserLoading, isLoggedIn } = useSupabase(); const { isUserLoading, isLoggedIn } = useSupabase();
const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [pendingMessage, setPendingMessage] = useState<string | null>(null); const [pendingMessage, setPendingMessage] = useState<string | null>(null);
const [sessionToDelete, setSessionToDelete] = useState<{
id: string;
title: string | null | undefined;
} | null>(null);
const queryClient = useQueryClient();
const { const {
sessionId, sessionId,
@@ -24,6 +34,30 @@ export function useCopilotPage() {
isCreatingSession, isCreatingSession,
} = useChatSession(); } = useChatSession();
const { mutate: deleteSessionMutation, isPending: isDeleting } =
useDeleteV2DeleteSession({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
if (sessionToDelete?.id === sessionId) {
setSessionId(null);
}
setSessionToDelete(null);
},
onError: (error) => {
toast({
title: "Failed to delete chat",
description:
error instanceof Error ? error.message : "An error occurred",
variant: "destructive",
});
setSessionToDelete(null);
},
},
});
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();
const isMobile = const isMobile =
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md"; breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
@@ -143,6 +177,26 @@ export function useCopilotPage() {
if (isMobile) setIsDrawerOpen(false); if (isMobile) setIsDrawerOpen(false);
} }
const handleDeleteClick = useCallback(
(id: string, title: string | null | undefined) => {
if (isDeleting) return;
setSessionToDelete({ id, title });
},
[isDeleting],
);
const handleConfirmDelete = useCallback(() => {
if (sessionToDelete) {
deleteSessionMutation({ sessionId: sessionToDelete.id });
}
}, [sessionToDelete, deleteSessionMutation]);
const handleCancelDelete = useCallback(() => {
if (!isDeleting) {
setSessionToDelete(null);
}
}, [isDeleting]);
return { return {
sessionId, sessionId,
messages, messages,
@@ -165,5 +219,11 @@ export function useCopilotPage() {
handleDrawerOpenChange, handleDrawerOpenChange,
handleSelectSession, handleSelectSession,
handleNewChat, handleNewChat,
// Delete functionality
sessionToDelete,
isDeleting,
handleDeleteClick,
handleConfirmDelete,
handleCancelDelete,
}; };
} }

View File

@@ -1151,6 +1151,36 @@
} }
}, },
"/api/chat/sessions/{session_id}": { "/api/chat/sessions/{session_id}": {
"delete": {
"tags": ["v2", "chat", "chat"],
"summary": "Delete Session",
"description": "Delete a chat session.\n\nPermanently removes a chat session and all its messages.\nOnly the owner can delete their sessions.\n\nArgs:\n session_id: The session ID to delete.\n user_id: The authenticated user's ID.\n\nReturns:\n 204 No Content on success.\n\nRaises:\n HTTPException: 404 if session not found or not owned by user.",
"operationId": "deleteV2DeleteSession",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "session_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Session Id" }
}
],
"responses": {
"204": { "description": "Successful Response" },
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Session not found or access denied" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
},
"get": { "get": {
"tags": ["v2", "chat", "chat"], "tags": ["v2", "chat", "chat"],
"summary": "Get Session", "summary": "Get Session",

View File

@@ -115,7 +115,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className, className,
)} )}
{...props} {...props}

View File

@@ -10,7 +10,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk"; import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code"; import { code } from "@/lib/streamdown-code-plugin";
import { math } from "@streamdown/math"; import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid"; import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";

View File

@@ -25,7 +25,7 @@ export function BaseFooter({
</div> </div>
) : ( ) : (
<div <div
className={`flex w-full items-end justify-between gap-4 pt-6 ${className}`} className={`flex w-full items-end justify-end gap-4 pt-6 ${className}`}
data-testid={testId} data-testid={testId}
> >
{children} {children}

View File

@@ -0,0 +1,70 @@
import {
bundledLanguages,
bundledLanguagesInfo,
createHighlighter,
type BundledLanguage,
type BundledTheme,
type HighlighterGeneric,
} from "shiki";
export type { BundledLanguage, BundledTheme };
const LANGUAGE_ALIASES: Record<string, string> = Object.fromEntries(
bundledLanguagesInfo.flatMap((lang) =>
(lang.aliases ?? []).map((alias) => [alias, lang.id]),
),
);
const SUPPORTED_LANGUAGES = new Set(Object.keys(bundledLanguages));
const PRELOAD_LANGUAGES: BundledLanguage[] = [
"javascript",
"typescript",
"python",
"json",
"bash",
"yaml",
"markdown",
"html",
"css",
"sql",
"tsx",
"jsx",
];
export const SHIKI_THEMES: [BundledTheme, BundledTheme] = [
"github-light",
"github-dark",
];
let highlighterPromise: Promise<
HighlighterGeneric<BundledLanguage, BundledTheme>
> | null = null;
export function getShikiHighlighter(): Promise<
HighlighterGeneric<BundledLanguage, BundledTheme>
> {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: SHIKI_THEMES,
langs: PRELOAD_LANGUAGES,
}).catch((err) => {
highlighterPromise = null;
throw err;
});
}
return highlighterPromise;
}
export function resolveLanguage(lang: string): string {
const normalized = lang.trim().toLowerCase();
return LANGUAGE_ALIASES[normalized] ?? normalized;
}
export function isLanguageSupported(lang: string): boolean {
return SUPPORTED_LANGUAGES.has(resolveLanguage(lang));
}
export function getSupportedLanguages(): BundledLanguage[] {
return Array.from(SUPPORTED_LANGUAGES) as BundledLanguage[];
}

View File

@@ -0,0 +1,159 @@
import type { CodeHighlighterPlugin } from "streamdown";
import {
type BundledLanguage,
type BundledTheme,
getShikiHighlighter,
getSupportedLanguages,
isLanguageSupported,
resolveLanguage,
SHIKI_THEMES,
} from "./shiki-highlighter";
interface HighlightResult {
tokens: {
content: string;
color?: string;
htmlStyle?: Record<string, string>;
}[][];
fg?: string;
bg?: string;
}
type HighlightCallback = (result: HighlightResult) => void;
const MAX_CACHE_SIZE = 500;
const tokenCache = new Map<string, HighlightResult>();
const pendingCallbacks = new Map<string, Set<HighlightCallback>>();
const inFlightLanguageLoads = new Map<string, Promise<void>>();
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return hash.toString(36);
}
function getCacheKey(
code: string,
lang: string,
themes: readonly string[],
): string {
return `${lang}:${themes.join(",")}:${simpleHash(code)}`;
}
function evictOldestIfNeeded(): void {
if (tokenCache.size > MAX_CACHE_SIZE) {
const oldestKey = tokenCache.keys().next().value;
if (oldestKey) {
tokenCache.delete(oldestKey);
}
}
}
export function createSingletonCodePlugin(): CodeHighlighterPlugin {
return {
name: "shiki",
type: "code-highlighter",
supportsLanguage(lang: BundledLanguage): boolean {
return isLanguageSupported(lang);
},
getSupportedLanguages(): BundledLanguage[] {
return getSupportedLanguages();
},
getThemes(): [BundledTheme, BundledTheme] {
return SHIKI_THEMES;
},
highlight({ code, language, themes }, callback) {
const lang = resolveLanguage(language);
const cacheKey = getCacheKey(code, lang, themes);
if (tokenCache.has(cacheKey)) {
return tokenCache.get(cacheKey)!;
}
if (callback) {
if (!pendingCallbacks.has(cacheKey)) {
pendingCallbacks.set(cacheKey, new Set());
}
pendingCallbacks.get(cacheKey)!.add(callback);
}
getShikiHighlighter()
.then(async (highlighter) => {
const loadedLanguages = highlighter.getLoadedLanguages();
if (!loadedLanguages.includes(lang) && isLanguageSupported(lang)) {
let loadPromise = inFlightLanguageLoads.get(lang);
if (!loadPromise) {
loadPromise = highlighter
.loadLanguage(lang as BundledLanguage)
.finally(() => {
inFlightLanguageLoads.delete(lang);
});
inFlightLanguageLoads.set(lang, loadPromise);
}
await loadPromise;
}
const finalLang = (
highlighter.getLoadedLanguages().includes(lang) ? lang : "text"
) as BundledLanguage;
const shikiResult = highlighter.codeToTokens(code, {
lang: finalLang,
themes: { light: themes[0], dark: themes[1] },
});
const result: HighlightResult = {
tokens: shikiResult.tokens.map((line) =>
line.map((token) => ({
content: token.content,
color: token.color,
htmlStyle: token.htmlStyle,
})),
),
fg: shikiResult.fg,
bg: shikiResult.bg,
};
evictOldestIfNeeded();
tokenCache.set(cacheKey, result);
const callbacks = pendingCallbacks.get(cacheKey);
if (callbacks) {
callbacks.forEach((cb) => {
cb(result);
});
pendingCallbacks.delete(cacheKey);
}
})
.catch((error) => {
console.error("[Shiki] Failed to highlight code:", error);
const fallback: HighlightResult = {
tokens: code.split("\n").map((line) => [{ content: line }]),
};
const callbacks = pendingCallbacks.get(cacheKey);
if (callbacks) {
callbacks.forEach((cb) => {
cb(fallback);
});
pendingCallbacks.delete(cacheKey);
}
});
return null;
},
};
}
export const code = createSingletonCodePlugin();

View File

@@ -465,9 +465,13 @@ export async function navigateToAgentByName(
export async function clickRunButton(page: Page): Promise<void> { export async function clickRunButton(page: Page): Promise<void> {
const { getId } = getSelectors(page); const { getId } = getSelectors(page);
// Wait for page to stabilize and buttons to render // Wait for sidebar loading to complete before detecting buttons.
// The NewAgentLibraryView shows either "Setup your task" (empty state) // During sidebar loading, the "New task" button appears transiently
// or "New task" (with items) button // even for agents with no items, then switches to "Setup your task"
// once loading finishes. Waiting for network idle ensures the page
// has settled into its final state.
await page.waitForLoadState("networkidle");
const setupTaskButton = page.getByRole("button", { const setupTaskButton = page.getByRole("button", {
name: /Setup your task/i, name: /Setup your task/i,
}); });
@@ -475,8 +479,7 @@ export async function clickRunButton(page: Page): Promise<void> {
const runButton = getId("agent-run-button"); const runButton = getId("agent-run-button");
const runAgainButton = getId("run-again-button"); const runAgainButton = getId("run-again-button");
// Use Promise.race with waitFor to wait for any of the buttons to appear // Wait for any of the buttons to appear
// This handles the async rendering in CI environments
try { try {
await Promise.race([ await Promise.race([
setupTaskButton.waitFor({ state: "visible", timeout: 15000 }), setupTaskButton.waitFor({ state: "visible", timeout: 15000 }),
@@ -490,7 +493,7 @@ export async function clickRunButton(page: Page): Promise<void> {
); );
} }
// Now check which button is visible and click it // Check which button is visible and click it
if (await setupTaskButton.isVisible()) { if (await setupTaskButton.isVisible()) {
await setupTaskButton.click(); await setupTaskButton.click();
const startTaskButton = page const startTaskButton = page
@@ -534,7 +537,9 @@ export async function runAgent(page: Page): Promise<void> {
export async function waitForAgentPageLoad(page: Page): Promise<void> { export async function waitForAgentPageLoad(page: Page): Promise<void> {
await page.waitForURL(/.*\/library\/agents\/[^/]+/); await page.waitForURL(/.*\/library\/agents\/[^/]+/);
await page.getByTestId("Run actions").isVisible({ timeout: 10000 }); // Wait for sidebar data to finish loading so the page settles
// into its final state (empty view vs sidebar view)
await page.waitForLoadState("networkidle");
} }
export async function getAgentName(page: Page): Promise<string> { export async function getAgentName(page: Page): Promise<string> {

View File

@@ -218,17 +218,6 @@ If you initially installed Docker with Hyper-V, you **dont need to reinstall*
For more details, refer to [Docker's official documentation](https://docs.docker.com/desktop/windows/wsl/). For more details, refer to [Docker's official documentation](https://docs.docker.com/desktop/windows/wsl/).
### ⚠️ Podman Not Supported
AutoGPT requires **Docker** (Docker Desktop or Docker Engine). **Podman and podman-compose are not supported** and may cause path resolution issues, particularly on Windows.
If you see errors like:
```text
Error: the specified Containerfile or Dockerfile does not exist, ..\..\autogpt_platform\backend\Dockerfile
```
This indicates you're using Podman instead of Docker. Please install [Docker Desktop](https://docs.docker.com/desktop/) and use `docker compose` instead of `podman-compose`.
## Development ## Development