mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-07 05:15:09 -05:00
Implements persistent User Workspace storage for CoPilot, enabling
blocks to save and retrieve files across sessions. Files are stored in
session-scoped virtual paths (`/sessions/{session_id}/`).
Fixes SECRT-1833
### Changes 🏗️
**Database & Storage:**
- Add `UserWorkspace` and `UserWorkspaceFile` Prisma models
- Implement `WorkspaceStorageBackend` abstraction (GCS for cloud, local
filesystem for self-hosted)
- Add `workspace_id` and `session_id` fields to `ExecutionContext`
**Backend API:**
- Add REST endpoints: `GET/POST /api/workspace/files`, `GET/DELETE
/api/workspace/files/{id}`, `GET /api/workspace/files/{id}/download`
- Add CoPilot tools: `list_workspace_files`, `read_workspace_file`,
`write_workspace_file`
- Integrate workspace storage into `store_media_file()` - returns
`workspace://file-id` references
**Block Updates:**
- Refactor all file-handling blocks to use unified `ExecutionContext`
parameter
- Update media-generating blocks to persist outputs to workspace
(AIImageGenerator, AIImageCustomizer, FluxKontext, TalkingHead, FAL
video, Bannerbear, etc.)
**Frontend:**
- Render `workspace://` image references in chat via proxy endpoint
- Add "AI cannot see this image" overlay indicator
**CoPilot Context Mapping:**
- Session = Agent (graph_id) = Run (graph_exec_id)
- Files scoped to `/sessions/{session_id}/`
### Checklist 📋
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [ ] Create CoPilot session, generate image with AIImageGeneratorBlock
- [ ] Verify image returns `workspace://file-id` (not base64)
- [ ] Verify image renders in chat with visibility indicator
- [ ] Verify workspace files persist across sessions
- [ ] Test list/read/write workspace files via CoPilot tools
- [ ] Test local storage backend for self-hosted deployments
#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
🤖 Generated with [Claude Code](https://claude.ai/code)
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Introduces a new persistent file-storage surface area (DB tables,
storage backends, download API, and chat tools) and rewires
`store_media_file()`/block execution context across many blocks, so
regressions could impact file handling, access control, or storage
costs.
>
> **Overview**
> Adds a **persistent per-user Workspace** (new
`UserWorkspace`/`UserWorkspaceFile` models plus `WorkspaceManager` +
`WorkspaceStorageBackend` with GCS/local implementations) and wires it
into the API via a new `/api/workspace/files/{file_id}/download` route
(including header-sanitized `Content-Disposition`) and shutdown
lifecycle hooks.
>
> Extends `ExecutionContext` to carry execution identity +
`workspace_id`/`session_id`, updates executor tooling to clone
node-specific contexts, and updates `run_block` (CoPilot) to create a
session-scoped workspace and synthetic graph/run/node IDs.
>
> Refactors `store_media_file()` to require `execution_context` +
`return_format` and to support `workspace://` references; migrates many
media/file-handling blocks and related tests to the new API and to
persist generated media as `workspace://...` (or fall back to data URIs
outside CoPilot), and adds CoPilot chat tools for
listing/reading/writing/deleting workspace files with safeguards against
context bloat.
>
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
6abc70f793. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
235 lines
8.7 KiB
Python
235 lines
8.7 KiB
Python
import enum
|
|
from typing import Any
|
|
|
|
from backend.data.block import (
|
|
Block,
|
|
BlockCategory,
|
|
BlockOutput,
|
|
BlockSchemaInput,
|
|
BlockSchemaOutput,
|
|
BlockType,
|
|
)
|
|
from backend.data.execution import ExecutionContext
|
|
from backend.data.model import SchemaField
|
|
from backend.util.file import store_media_file
|
|
from backend.util.type import MediaFileType, convert
|
|
|
|
|
|
class FileStoreBlock(Block):
|
|
class Input(BlockSchemaInput):
|
|
file_in: MediaFileType = SchemaField(
|
|
description="The file to download and store. Can be a URL (https://...), data URI, or local path."
|
|
)
|
|
base_64: bool = SchemaField(
|
|
description="Whether to produce output in base64 format (not recommended, you can pass the file reference across blocks).",
|
|
default=False,
|
|
advanced=True,
|
|
title="Produce Base64 Output",
|
|
)
|
|
|
|
class Output(BlockSchemaOutput):
|
|
file_out: MediaFileType = SchemaField(
|
|
description="Reference to the stored file. In CoPilot: workspace:// URI (visible in list_workspace_files). In graphs: data URI for passing to other blocks."
|
|
)
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="cbb50872-625b-42f0-8203-a2ae78242d8a",
|
|
description=(
|
|
"Downloads and stores a file from a URL, data URI, or local path. "
|
|
"Use this to fetch images, documents, or other files for processing. "
|
|
"In CoPilot: saves to workspace (use list_workspace_files to see it). "
|
|
"In graphs: outputs a data URI to pass to other blocks."
|
|
),
|
|
categories={BlockCategory.BASIC, BlockCategory.MULTIMEDIA},
|
|
input_schema=FileStoreBlock.Input,
|
|
output_schema=FileStoreBlock.Output,
|
|
static_output=True,
|
|
)
|
|
|
|
async def run(
|
|
self,
|
|
input_data: Input,
|
|
*,
|
|
execution_context: ExecutionContext,
|
|
**kwargs,
|
|
) -> BlockOutput:
|
|
# Determine return format based on user preference
|
|
# for_external_api: always returns data URI (base64) - honors "Produce Base64 Output"
|
|
# for_block_output: smart format - workspace:// in CoPilot, data URI in graphs
|
|
return_format = "for_external_api" if input_data.base_64 else "for_block_output"
|
|
|
|
yield "file_out", await store_media_file(
|
|
file=input_data.file_in,
|
|
execution_context=execution_context,
|
|
return_format=return_format,
|
|
)
|
|
|
|
|
|
class StoreValueBlock(Block):
|
|
"""
|
|
This block allows you to provide a constant value as a block, in a stateless manner.
|
|
The common use-case is simply pass the `input` data, it will `output` the same data.
|
|
The block output will be static, the output can be consumed multiple times.
|
|
"""
|
|
|
|
class Input(BlockSchemaInput):
|
|
input: Any = SchemaField(
|
|
description="Trigger the block to produce the output. "
|
|
"The value is only used when `data` is None."
|
|
)
|
|
data: Any = SchemaField(
|
|
description="The constant data to be retained in the block. "
|
|
"This value is passed as `output`.",
|
|
default=None,
|
|
)
|
|
|
|
class Output(BlockSchemaOutput):
|
|
output: Any = SchemaField(description="The stored data retained in the block.")
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="1ff065e9-88e8-4358-9d82-8dc91f622ba9",
|
|
description="A basic block that stores and forwards a value throughout workflows, allowing it to be reused without changes across multiple blocks.",
|
|
categories={BlockCategory.BASIC},
|
|
input_schema=StoreValueBlock.Input,
|
|
output_schema=StoreValueBlock.Output,
|
|
test_input=[
|
|
{"input": "Hello, World!"},
|
|
{"input": "Hello, World!", "data": "Existing Data"},
|
|
],
|
|
test_output=[
|
|
("output", "Hello, World!"), # No data provided, so trigger is returned
|
|
("output", "Existing Data"), # Data is provided, so data is returned.
|
|
],
|
|
static_output=True,
|
|
)
|
|
|
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
yield "output", input_data.data or input_data.input
|
|
|
|
|
|
class PrintToConsoleBlock(Block):
|
|
class Input(BlockSchemaInput):
|
|
text: Any = SchemaField(description="The data to print to the console.")
|
|
|
|
class Output(BlockSchemaOutput):
|
|
output: Any = SchemaField(description="The data printed to the console.")
|
|
status: str = SchemaField(description="The status of the print operation.")
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="f3b1c1b2-4c4f-4f0d-8d2f-4c4f0d8d2f4c",
|
|
description="A debugging block that outputs text to the console for monitoring and troubleshooting workflow execution.",
|
|
categories={BlockCategory.BASIC},
|
|
input_schema=PrintToConsoleBlock.Input,
|
|
output_schema=PrintToConsoleBlock.Output,
|
|
test_input={"text": "Hello, World!"},
|
|
is_sensitive_action=True,
|
|
test_output=[
|
|
("output", "Hello, World!"),
|
|
("status", "printed"),
|
|
],
|
|
)
|
|
|
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
yield "output", input_data.text
|
|
yield "status", "printed"
|
|
|
|
|
|
class NoteBlock(Block):
|
|
class Input(BlockSchemaInput):
|
|
text: str = SchemaField(description="The text to display in the sticky note.")
|
|
|
|
class Output(BlockSchemaOutput):
|
|
output: str = SchemaField(description="The text to display in the sticky note.")
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="cc10ff7b-7753-4ff2-9af6-9399b1a7eddc",
|
|
description="A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes.",
|
|
categories={BlockCategory.BASIC},
|
|
input_schema=NoteBlock.Input,
|
|
output_schema=NoteBlock.Output,
|
|
test_input={"text": "Hello, World!"},
|
|
test_output=[
|
|
("output", "Hello, World!"),
|
|
],
|
|
block_type=BlockType.NOTE,
|
|
)
|
|
|
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
yield "output", input_data.text
|
|
|
|
|
|
class TypeOptions(enum.Enum):
|
|
STRING = "string"
|
|
NUMBER = "number"
|
|
BOOLEAN = "boolean"
|
|
LIST = "list"
|
|
DICTIONARY = "dictionary"
|
|
|
|
|
|
class UniversalTypeConverterBlock(Block):
|
|
class Input(BlockSchemaInput):
|
|
value: Any = SchemaField(
|
|
description="The value to convert to a universal type."
|
|
)
|
|
type: TypeOptions = SchemaField(description="The type to convert the value to.")
|
|
|
|
class Output(BlockSchemaOutput):
|
|
value: Any = SchemaField(description="The converted value.")
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="95d1b990-ce13-4d88-9737-ba5c2070c97b",
|
|
description="This block is used to convert a value to a universal type.",
|
|
categories={BlockCategory.BASIC},
|
|
input_schema=UniversalTypeConverterBlock.Input,
|
|
output_schema=UniversalTypeConverterBlock.Output,
|
|
)
|
|
|
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
try:
|
|
converted_value = convert(
|
|
input_data.value,
|
|
{
|
|
TypeOptions.STRING: str,
|
|
TypeOptions.NUMBER: float,
|
|
TypeOptions.BOOLEAN: bool,
|
|
TypeOptions.LIST: list,
|
|
TypeOptions.DICTIONARY: dict,
|
|
}[input_data.type],
|
|
)
|
|
yield "value", converted_value
|
|
except Exception as e:
|
|
yield "error", f"Failed to convert value: {str(e)}"
|
|
|
|
|
|
class ReverseListOrderBlock(Block):
|
|
"""
|
|
A block which takes in a list and returns it in the opposite order.
|
|
"""
|
|
|
|
class Input(BlockSchemaInput):
|
|
input_list: list[Any] = SchemaField(description="The list to reverse")
|
|
|
|
class Output(BlockSchemaOutput):
|
|
reversed_list: list[Any] = SchemaField(description="The list in reversed order")
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="422cb708-3109-4277-bfe3-bc2ae5812777",
|
|
description="Reverses the order of elements in a list",
|
|
categories={BlockCategory.BASIC},
|
|
input_schema=ReverseListOrderBlock.Input,
|
|
output_schema=ReverseListOrderBlock.Output,
|
|
test_input={"input_list": [1, 2, 3, 4, 5]},
|
|
test_output=[("reversed_list", [5, 4, 3, 2, 1])],
|
|
)
|
|
|
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
reversed_list = list(input_data.input_list)
|
|
reversed_list.reverse()
|
|
yield "reversed_list", reversed_list
|