Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into feat/admin-rate-limit-management

This commit is contained in:
Zamil Majdy
2026-03-26 07:31:53 +07:00
33 changed files with 1271 additions and 295 deletions

View File

@@ -61,6 +61,7 @@ from backend.copilot.tools.models import (
)
from backend.copilot.tracking import track_user_message
from backend.data.redis_client import get_redis_async
from backend.data.understanding import get_business_understanding
from backend.data.workspace import get_or_create_workspace
from backend.util.exceptions import NotFoundError
@@ -903,6 +904,47 @@ async def session_assign_user(
return {"status": "ok"}
# ========== Suggested Prompts ==========
class SuggestedTheme(BaseModel):
"""A themed group of suggested prompts."""
name: str
prompts: list[str]
class SuggestedPromptsResponse(BaseModel):
"""Response model for user-specific suggested prompts grouped by theme."""
themes: list[SuggestedTheme]
@router.get(
"/suggested-prompts",
dependencies=[Security(auth.requires_user)],
)
async def get_suggested_prompts(
user_id: Annotated[str, Security(auth.get_user_id)],
) -> SuggestedPromptsResponse:
"""
Get LLM-generated suggested prompts grouped by theme.
Returns personalized quick-action prompts based on the user's
business understanding. Returns empty themes list if no custom
prompts are available.
"""
understanding = await get_business_understanding(user_id)
if understanding is None or not understanding.suggested_prompts:
return SuggestedPromptsResponse(themes=[])
themes = [
SuggestedTheme(name=name, prompts=prompts)
for name, prompts in understanding.suggested_prompts.items()
]
return SuggestedPromptsResponse(themes=themes)
# ========== Configuration ==========

View File

@@ -1,7 +1,7 @@
"""Tests for chat API routes: session title update, file attachment validation, usage, and rate limiting."""
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock
import fastapi
import fastapi.testclient
@@ -400,3 +400,69 @@ def test_usage_rejects_unauthenticated_request() -> None:
response = unauthenticated_client.get("/usage")
assert response.status_code == 401
# ─── Suggested prompts endpoint ──────────────────────────────────────
def _mock_get_business_understanding(
mocker: pytest_mock.MockerFixture,
*,
return_value=None,
):
"""Mock get_business_understanding."""
return mocker.patch(
"backend.api.features.chat.routes.get_business_understanding",
new_callable=AsyncMock,
return_value=return_value,
)
def test_suggested_prompts_returns_themes(
mocker: pytest_mock.MockerFixture,
test_user_id: str,
) -> None:
"""User with themed prompts gets them back as themes list."""
mock_understanding = MagicMock()
mock_understanding.suggested_prompts = {
"Learn": ["L1", "L2"],
"Create": ["C1"],
}
_mock_get_business_understanding(mocker, return_value=mock_understanding)
response = client.get("/suggested-prompts")
assert response.status_code == 200
data = response.json()
assert "themes" in data
themes_by_name = {t["name"]: t["prompts"] for t in data["themes"]}
assert themes_by_name["Learn"] == ["L1", "L2"]
assert themes_by_name["Create"] == ["C1"]
def test_suggested_prompts_no_understanding(
mocker: pytest_mock.MockerFixture,
test_user_id: str,
) -> None:
"""User with no understanding gets empty themes list."""
_mock_get_business_understanding(mocker, return_value=None)
response = client.get("/suggested-prompts")
assert response.status_code == 200
assert response.json() == {"themes": []}
def test_suggested_prompts_empty_prompts(
mocker: pytest_mock.MockerFixture,
test_user_id: str,
) -> None:
"""User with understanding but empty prompts gets empty themes list."""
mock_understanding = MagicMock()
mock_understanding.suggested_prompts = {}
_mock_get_business_understanding(mocker, return_value=mock_understanding)
response = client.get("/suggested-prompts")
assert response.status_code == 200
assert response.json() == {"themes": []}

View File

@@ -73,7 +73,7 @@ class ReadDiscordMessagesBlock(Block):
id="df06086a-d5ac-4abb-9996-2ad0acb2eff7",
input_schema=ReadDiscordMessagesBlock.Input, # Assign input schema
output_schema=ReadDiscordMessagesBlock.Output, # Assign output schema
description="Reads messages from a Discord channel using a bot token.",
description="Reads new messages from a Discord channel using a bot token and triggers when a new message is posted",
categories={BlockCategory.SOCIAL},
test_input={
"continuous_read": False,

View File

@@ -205,9 +205,10 @@ Important files (code, configs, outputs) should be saved to workspace to ensure
### SDK tool-result files
When tool outputs are large, the SDK truncates them and saves the full output to
a local file under `~/.claude/projects/.../tool-results/`. To read these files,
always use `read_file` or `Read` (NOT `read_workspace_file`).
`read_workspace_file` reads from cloud workspace storage, where SDK
tool-results are NOT stored.
always use `Read` (NOT `bash_exec`, NOT `read_workspace_file`).
These files are on the host filesystem — `bash_exec` runs in the sandbox and
CANNOT access them. `read_workspace_file` reads from cloud workspace storage,
where SDK tool-results are NOT stored.
{_SHARED_TOOL_NOTES}{extra_notes}"""

View File

@@ -4,6 +4,8 @@ import logging
import re
from typing import Any
from backend.data.dynamic_fields import DICT_SPLIT
from .helpers import (
AGENT_EXECUTOR_BLOCK_ID,
MCP_TOOL_BLOCK_ID,
@@ -1536,8 +1538,8 @@ class AgentFixer:
for link in links:
sink_name = link.get("sink_name", "")
if "_#_" in sink_name:
parent, child = sink_name.split("_#_", 1)
if DICT_SPLIT in sink_name:
parent, child = sink_name.split(DICT_SPLIT, 1)
# Check if child is a numeric index (invalid for _#_ notation)
if child.isdigit():

View File

@@ -4,6 +4,8 @@ import re
import uuid
from typing import Any
from backend.data.dynamic_fields import DICT_SPLIT
from .blocks import get_blocks_as_dicts
__all__ = [
@@ -51,8 +53,8 @@ def generate_uuid() -> str:
def get_defined_property_type(schema: dict[str, Any], name: str) -> str | None:
"""Get property type from a schema, handling nested `_#_` notation."""
if "_#_" in name:
parent, child = name.split("_#_", 1)
if DICT_SPLIT in name:
parent, child = name.split(DICT_SPLIT, 1)
parent_schema = schema.get(parent, {})
if "properties" in parent_schema and isinstance(
parent_schema["properties"], dict

View File

@@ -5,6 +5,8 @@ import logging
import re
from typing import Any
from backend.data.dynamic_fields import DICT_SPLIT
from .helpers import (
AGENT_EXECUTOR_BLOCK_ID,
AGENT_INPUT_BLOCK_ID,
@@ -256,95 +258,6 @@ class AgentValidator:
return valid
def validate_nested_sink_links(
self,
agent: AgentDict,
blocks: list[dict[str, Any]],
node_lookup: dict[str, dict[str, Any]] | None = None,
) -> bool:
"""
Validate nested sink links (links with _#_ notation).
Returns True if all nested links are valid, False otherwise.
"""
valid = True
block_input_schemas = {
block.get("id", ""): block.get("inputSchema", {}).get("properties", {})
for block in blocks
}
block_names = {
block.get("id", ""): block.get("name", "Unknown Block") for block in blocks
}
if node_lookup is None:
node_lookup = self._build_node_lookup(agent)
for link in agent.get("links", []):
sink_name = link.get("sink_name", "")
sink_id = link.get("sink_id")
if not sink_name or not sink_id:
continue
if "_#_" in sink_name:
parent, child = sink_name.split("_#_", 1)
sink_node = node_lookup.get(sink_id)
if not sink_node:
continue
block_id = sink_node.get("block_id")
input_props = block_input_schemas.get(block_id, {})
parent_schema = input_props.get(parent)
if not parent_schema:
block_name = block_names.get(block_id, "Unknown Block")
self.add_error(
f"Invalid nested sink link '{sink_name}' for "
f"node '{sink_id}' (block "
f"'{block_name}' - {block_id}): Parent property "
f"'{parent}' does not exist in the block's "
f"input schema."
)
valid = False
continue
# Check if additionalProperties is allowed either directly
# or via anyOf
allows_additional_properties = parent_schema.get(
"additionalProperties", False
)
# Check anyOf for additionalProperties
if not allows_additional_properties and "anyOf" in parent_schema:
any_of_schemas = parent_schema.get("anyOf", [])
if isinstance(any_of_schemas, list):
for schema_option in any_of_schemas:
if isinstance(schema_option, dict) and schema_option.get(
"additionalProperties"
):
allows_additional_properties = True
break
if not allows_additional_properties:
if not (
isinstance(parent_schema, dict)
and "properties" in parent_schema
and isinstance(parent_schema["properties"], dict)
and child in parent_schema["properties"]
):
block_name = block_names.get(block_id, "Unknown Block")
self.add_error(
f"Invalid nested sink link '{sink_name}' "
f"for node '{link.get('sink_id', '')}' (block "
f"'{block_name}' - {block_id}): Child "
f"property '{child}' does not exist in "
f"parent '{parent}' schema. Available "
f"properties: "
f"{list(parent_schema.get('properties', {}).keys())}"
)
valid = False
return valid
def validate_prompt_double_curly_braces_spaces(self, agent: AgentDict) -> bool:
"""
Validate that prompt parameters do not contain spaces in double curly
@@ -471,8 +384,8 @@ class AgentValidator:
output_props = block_output_schemas.get(block_id, {})
# Handle nested source names (with _#_ notation)
if "_#_" in source_name:
parent, child = source_name.split("_#_", 1)
if DICT_SPLIT in source_name:
parent, child = source_name.split(DICT_SPLIT, 1)
parent_schema = output_props.get(parent)
if not parent_schema:
@@ -553,6 +466,195 @@ class AgentValidator:
return valid
def validate_sink_input_existence(
self,
agent: AgentDict,
blocks: list[dict[str, Any]],
node_lookup: dict[str, dict[str, Any]] | None = None,
) -> bool:
"""
Validate that all sink_names in links and input_default keys in nodes
exist in the corresponding block's input schema.
Checks that for each link the sink_name references a valid input
property in the sink block's inputSchema, and that every key in a
node's input_default is a recognised input property. Also handles
nested inputs with _#_ notation and dynamic schemas for
AgentExecutorBlock.
Args:
agent: The agent dictionary to validate
blocks: List of available blocks with their schemas
node_lookup: Optional pre-built node-id → node dict
Returns:
True if all sink input fields exist, False otherwise
"""
valid = True
block_input_schemas = {
block.get("id", ""): block.get("inputSchema", {}).get("properties", {})
for block in blocks
}
block_names = {
block.get("id", ""): block.get("name", "Unknown Block") for block in blocks
}
if node_lookup is None:
node_lookup = self._build_node_lookup(agent)
def get_input_props(node: dict[str, Any]) -> dict[str, Any]:
block_id = node.get("block_id", "")
if block_id == AGENT_EXECUTOR_BLOCK_ID:
input_default = node.get("input_default", {})
dynamic_input_schema = input_default.get("input_schema", {})
if not isinstance(dynamic_input_schema, dict):
dynamic_input_schema = {}
dynamic_props = dynamic_input_schema.get("properties", {})
if not isinstance(dynamic_props, dict):
dynamic_props = {}
static_props = block_input_schemas.get(block_id, {})
return {**static_props, **dynamic_props}
return block_input_schemas.get(block_id, {})
def check_nested_input(
input_props: dict[str, Any],
field_name: str,
context: str,
block_name: str,
block_id: str,
) -> bool:
parent, child = field_name.split(DICT_SPLIT, 1)
parent_schema = input_props.get(parent)
if not parent_schema:
self.add_error(
f"{context}: Parent property '{parent}' does not "
f"exist in block '{block_name}' ({block_id}) input "
f"schema."
)
return False
allows_additional = parent_schema.get("additionalProperties", False)
# Only anyOf is checked here because Pydantic's JSON schema
# emits optional/union fields via anyOf. allOf and oneOf are
# not currently used by any block's dict-typed inputs, so
# false positives from them are not a concern in practice.
if not allows_additional and "anyOf" in parent_schema:
for schema_option in parent_schema.get("anyOf", []):
if not isinstance(schema_option, dict):
continue
if schema_option.get("additionalProperties"):
allows_additional = True
break
items_schema = schema_option.get("items")
if isinstance(items_schema, dict) and items_schema.get(
"additionalProperties"
):
allows_additional = True
break
if not allows_additional:
if not (
isinstance(parent_schema, dict)
and "properties" in parent_schema
and isinstance(parent_schema["properties"], dict)
and child in parent_schema["properties"]
):
available = (
list(parent_schema.get("properties", {}).keys())
if isinstance(parent_schema, dict)
else []
)
self.add_error(
f"{context}: Child property '{child}' does not "
f"exist in parent '{parent}' of block "
f"'{block_name}' ({block_id}) input schema. "
f"Available properties: {available}"
)
return False
return True
for link in agent.get("links", []):
sink_id = link.get("sink_id")
sink_name = link.get("sink_name", "")
link_id = link.get("id", "Unknown")
if not sink_name:
# Missing sink_name is caught by validate_data_type_compatibility
continue
sink_node = node_lookup.get(sink_id)
if not sink_node:
# Already caught by validate_link_node_references
continue
block_id = sink_node.get("block_id", "")
block_name = block_names.get(block_id, "Unknown Block")
input_props = get_input_props(sink_node)
context = (
f"Invalid sink input field '{sink_name}' in link "
f"'{link_id}' to node '{sink_id}'"
)
if DICT_SPLIT in sink_name:
if not check_nested_input(
input_props, sink_name, context, block_name, block_id
):
valid = False
else:
if sink_name not in input_props:
available_inputs = list(input_props.keys())
self.add_error(
f"{context} (block '{block_name}' - {block_id}): "
f"Input property '{sink_name}' does not exist in "
f"the block's input schema. "
f"Available inputs: {available_inputs}"
)
valid = False
for node in agent.get("nodes", []):
node_id = node.get("id")
block_id = node.get("block_id", "")
block_name = block_names.get(block_id, "Unknown Block")
input_default = node.get("input_default", {})
if not isinstance(input_default, dict) or not input_default:
continue
if (
block_id not in block_input_schemas
and block_id != AGENT_EXECUTOR_BLOCK_ID
):
continue
input_props = get_input_props(node)
for key in input_default:
if key == "credentials":
continue
context = (
f"Node '{node_id}' (block '{block_name}' - {block_id}) "
f"has unknown input_default key '{key}'"
)
if DICT_SPLIT in key:
if not check_nested_input(
input_props, key, context, block_name, block_id
):
valid = False
else:
if key not in input_props:
available_inputs = list(input_props.keys())
self.add_error(
f"{context} which does not exist in the "
f"block's input schema. "
f"Available inputs: {available_inputs}"
)
valid = False
return valid
def validate_io_blocks(self, agent: AgentDict) -> bool:
"""
Validate that the agent has at least one AgentInputBlock and one
@@ -998,14 +1100,14 @@ class AgentValidator:
"Data type compatibility",
self.validate_data_type_compatibility(agent, blocks, node_lookup),
),
(
"Nested sink links",
self.validate_nested_sink_links(agent, blocks, node_lookup),
),
(
"Source output existence",
self.validate_source_output_existence(agent, blocks, node_lookup),
),
(
"Sink input existence",
self.validate_sink_input_existence(agent, blocks, node_lookup),
),
(
"Prompt double curly braces spaces",
self.validate_prompt_double_curly_braces_spaces(agent),

View File

@@ -331,43 +331,6 @@ class TestValidatePromptDoubleCurlyBracesSpaces:
assert any("spaces" in e for e in v.errors)
# ============================================================================
# validate_nested_sink_links
# ============================================================================
class TestValidateNestedSinkLinks:
def test_valid_nested_link_passes(self):
v = AgentValidator()
block = _make_block(
block_id="b1",
input_schema={
"properties": {
"config": {
"type": "object",
"properties": {"key": {"type": "string"}},
}
},
"required": [],
},
)
node = _make_node(node_id="n1", block_id="b1")
link = _make_link(sink_id="n1", sink_name="config_#_key", source_id="n2")
agent = _make_agent(nodes=[node], links=[link])
assert v.validate_nested_sink_links(agent, [block]) is True
def test_invalid_parent_fails(self):
v = AgentValidator()
block = _make_block(block_id="b1")
node = _make_node(node_id="n1", block_id="b1")
link = _make_link(sink_id="n1", sink_name="nonexistent_#_key", source_id="n2")
agent = _make_agent(nodes=[node], links=[link])
assert v.validate_nested_sink_links(agent, [block]) is False
assert any("does not exist" in e for e in v.errors)
# ============================================================================
# validate_agent_executor_block_schemas
# ============================================================================
@@ -595,11 +558,28 @@ class TestValidate:
input_block = _make_block(
block_id=AGENT_INPUT_BLOCK_ID,
name="AgentInputBlock",
input_schema={
"properties": {
"name": {"type": "string"},
"title": {"type": "string"},
"value": {},
"description": {"type": "string"},
},
"required": ["name"],
},
output_schema={"properties": {"result": {}}},
)
output_block = _make_block(
block_id=AGENT_OUTPUT_BLOCK_ID,
name="AgentOutputBlock",
input_schema={
"properties": {
"name": {"type": "string"},
"title": {"type": "string"},
"value": {},
},
"required": ["name"],
},
)
input_node = _make_node(
node_id="n-in",
@@ -650,6 +630,201 @@ class TestValidate:
assert "AgentOutputBlock" in error_message
class TestValidateSinkInputExistence:
"""Tests for validate_sink_input_existence."""
def test_valid_sink_name_passes(self):
v = AgentValidator()
block = _make_block(
block_id="b1",
input_schema={"properties": {"url": {"type": "string"}}, "required": []},
)
node = _make_node(node_id="n1", block_id="b1")
link = _make_link(
source_id="src", source_name="out", sink_id="n1", sink_name="url"
)
agent = _make_agent(nodes=[node], links=[link])
assert v.validate_sink_input_existence(agent, [block]) is True
def test_invalid_sink_name_fails(self):
v = AgentValidator()
block = _make_block(
block_id="b1",
input_schema={"properties": {"url": {"type": "string"}}, "required": []},
)
node = _make_node(node_id="n1", block_id="b1")
link = _make_link(
source_id="src", source_name="out", sink_id="n1", sink_name="nonexistent"
)
agent = _make_agent(nodes=[node], links=[link])
assert v.validate_sink_input_existence(agent, [block]) is False
assert any("nonexistent" in e for e in v.errors)
def test_valid_nested_link_passes(self):
v = AgentValidator()
block = _make_block(
block_id="b1",
input_schema={
"properties": {
"config": {
"type": "object",
"properties": {"key": {"type": "string"}},
}
},
"required": [],
},
)
node = _make_node(node_id="n1", block_id="b1")
link = _make_link(
source_id="src",
source_name="out",
sink_id="n1",
sink_name="config_#_key",
)
agent = _make_agent(nodes=[node], links=[link])
assert v.validate_sink_input_existence(agent, [block]) is True
def test_invalid_nested_child_fails(self):
v = AgentValidator()
block = _make_block(
block_id="b1",
input_schema={
"properties": {
"config": {
"type": "object",
"properties": {"key": {"type": "string"}},
}
},
"required": [],
},
)
node = _make_node(node_id="n1", block_id="b1")
link = _make_link(
source_id="src",
source_name="out",
sink_id="n1",
sink_name="config_#_missing",
)
agent = _make_agent(nodes=[node], links=[link])
assert v.validate_sink_input_existence(agent, [block]) is False
def test_unknown_input_default_key_fails(self):
v = AgentValidator()
block = _make_block(
block_id="b1",
input_schema={"properties": {"url": {"type": "string"}}, "required": []},
)
node = _make_node(
node_id="n1", block_id="b1", input_default={"nonexistent_key": "value"}
)
agent = _make_agent(nodes=[node])
assert v.validate_sink_input_existence(agent, [block]) is False
assert any("nonexistent_key" in e for e in v.errors)
def test_credentials_key_skipped(self):
v = AgentValidator()
block = _make_block(
block_id="b1",
input_schema={"properties": {"url": {"type": "string"}}, "required": []},
)
node = _make_node(
node_id="n1",
block_id="b1",
input_default={
"url": "http://example.com",
"credentials": {"api_key": "x"},
},
)
agent = _make_agent(nodes=[node])
assert v.validate_sink_input_existence(agent, [block]) is True
def test_agent_executor_dynamic_schema_passes(self):
v = AgentValidator()
block = _make_block(
block_id=AGENT_EXECUTOR_BLOCK_ID,
input_schema={
"properties": {
"graph_id": {"type": "string"},
"input_schema": {"type": "object"},
},
"required": ["graph_id"],
},
)
node = _make_node(
node_id="n1",
block_id=AGENT_EXECUTOR_BLOCK_ID,
input_default={
"graph_id": "abc",
"input_schema": {
"properties": {"query": {"type": "string"}},
"required": [],
},
},
)
link = _make_link(
source_id="src",
source_name="out",
sink_id="n1",
sink_name="query",
)
agent = _make_agent(nodes=[node], links=[link])
assert v.validate_sink_input_existence(agent, [block]) is True
def test_input_default_nested_invalid_child_fails(self):
v = AgentValidator()
block = _make_block(
block_id="b1",
input_schema={
"properties": {
"config": {
"type": "object",
"properties": {"key": {"type": "string"}},
}
},
"required": [],
},
)
node = _make_node(
node_id="n1",
block_id="b1",
input_default={"config_#_invalid_child": "value"},
)
agent = _make_agent(nodes=[node])
assert v.validate_sink_input_existence(agent, [block]) is False
assert any("invalid_child" in e for e in v.errors)
def test_input_default_nested_valid_child_passes(self):
v = AgentValidator()
block = _make_block(
block_id="b1",
input_schema={
"properties": {
"config": {
"type": "object",
"properties": {"key": {"type": "string"}},
}
},
"required": [],
},
)
node = _make_node(
node_id="n1",
block_id="b1",
input_default={"config_#_key": "value"},
)
agent = _make_agent(nodes=[node])
assert v.validate_sink_input_existence(agent, [block]) is True
class TestValidateMCPToolBlocks:
"""Tests for validate_mcp_tool_blocks."""

View File

@@ -342,6 +342,7 @@ class GraphExecution(GraphExecutionMeta):
if (
(block := get_block(exec.block_id))
and block.block_type == BlockType.INPUT
and "name" in exec.input_data
)
}
),
@@ -360,8 +361,10 @@ class GraphExecution(GraphExecutionMeta):
outputs: CompletedBlockOutput = defaultdict(list)
for exec in complete_node_executions:
if (
block := get_block(exec.block_id)
) and block.block_type == BlockType.OUTPUT:
(block := get_block(exec.block_id))
and block.block_type == BlockType.OUTPUT
and "name" in exec.input_data
):
outputs[exec.input_data["name"]].append(exec.input_data.get("value"))
return GraphExecution(

View File

@@ -40,6 +40,9 @@ _MAX_PAGES = 100
# LLM extraction timeout (seconds)
_LLM_TIMEOUT = 30
SUGGESTION_THEMES = ["Learn", "Create", "Automate", "Organize"]
PROMPTS_PER_THEME = 5
def _mask_email(email: str) -> str:
"""Mask an email for safe logging: 'alice@example.com' -> 'a***e@example.com'."""
@@ -332,6 +335,11 @@ Fields:
- current_software (list of strings): software/tools currently used
- existing_automation (list of strings): existing automations
- additional_notes (string): any additional context
- suggested_prompts (object with keys "Learn", "Create", "Automate", "Organize"): for each key, \
provide a list of 5 short action prompts (each under 20 words) that would help this person. \
"Learn" = questions about AutoGPT features; "Create" = content/document generation tasks; \
"Automate" = recurring workflow automation ideas; "Organize" = structuring/prioritizing tasks. \
Should be specific to their industry, role, and pain points; actionable and conversational in tone.
Form data:
"""
@@ -378,6 +386,29 @@ async def extract_business_understanding(
# Filter out null values before constructing
cleaned = {k: v for k, v in data.items() if v is not None}
# Validate suggested_prompts: themed dict, filter >20 words, cap at 5 per theme
raw_prompts = cleaned.get("suggested_prompts", {})
if isinstance(raw_prompts, dict):
themed: dict[str, list[str]] = {}
for theme in SUGGESTION_THEMES:
theme_prompts = raw_prompts.get(theme, [])
if not isinstance(theme_prompts, list):
continue
valid = [
s
for p in theme_prompts
if isinstance(p, str) and (s := p.strip()) and len(s.split()) <= 20
]
if valid:
themed[theme] = valid[:PROMPTS_PER_THEME]
if themed:
cleaned["suggested_prompts"] = themed
else:
cleaned.pop("suggested_prompts", None)
else:
cleaned.pop("suggested_prompts", None)
return BusinessUnderstandingInput(**cleaned)

View File

@@ -284,6 +284,7 @@ async def test_populate_understanding_full_flow():
],
}
mock_input = MagicMock()
mock_input.suggested_prompts = {"Learn": ["P1"], "Create": ["P2"]}
with (
patch(
@@ -397,15 +398,25 @@ def test_extraction_prompt_no_format_placeholders():
@pytest.mark.asyncio
async def test_extract_business_understanding_success():
"""Happy path: LLM returns valid JSON that maps to BusinessUnderstandingInput."""
async def test_extract_business_understanding_themed_prompts():
"""Happy path: LLM returns themed prompts as dict."""
mock_choice = MagicMock()
mock_choice.message.content = json.dumps(
{
"user_name": "Alice",
"business_name": "Acme Corp",
"industry": "Technology",
"pain_points": ["manual reporting"],
"suggested_prompts": {
"Learn": ["Learn 1", "Learn 2", "Learn 3", "Learn 4", "Learn 5"],
"Create": [
"Create 1",
"Create 2",
"Create 3",
"Create 4",
"Create 5",
],
"Automate": ["Auto 1", "Auto 2", "Auto 3", "Auto 4", "Auto 5"],
"Organize": ["Org 1", "Org 2", "Org 3", "Org 4", "Org 5"],
},
}
)
mock_response = MagicMock()
@@ -418,9 +429,42 @@ async def test_extract_business_understanding_success():
result = await extract_business_understanding("Q: Name?\nA: Alice")
assert result.user_name == "Alice"
assert result.business_name == "Acme Corp"
assert result.industry == "Technology"
assert result.pain_points == ["manual reporting"]
assert result.suggested_prompts is not None
assert len(result.suggested_prompts) == 4
assert len(result.suggested_prompts["Learn"]) == 5
@pytest.mark.asyncio
async def test_extract_themed_prompts_filters_long_and_unknown_keys():
"""Long prompts are filtered, unknown keys are dropped, each theme capped at 5."""
long_prompt = " ".join(["word"] * 21)
mock_choice = MagicMock()
mock_choice.message.content = json.dumps(
{
"user_name": "Alice",
"suggested_prompts": {
"Learn": [long_prompt, "Valid learn 1", "Valid learn 2"],
"UnknownTheme": ["Should be dropped"],
"Automate": ["A1", "A2", "A3", "A4", "A5", "A6"],
},
}
)
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_client = AsyncMock()
mock_client.chat.completions.create.return_value = mock_response
with patch("backend.data.tally.AsyncOpenAI", return_value=mock_client):
result = await extract_business_understanding("Q: Name?\nA: Alice")
assert result.suggested_prompts is not None
# Unknown key dropped
assert "UnknownTheme" not in result.suggested_prompts
# Long prompt filtered
assert result.suggested_prompts["Learn"] == ["Valid learn 1", "Valid learn 2"]
# Capped at 5
assert result.suggested_prompts["Automate"] == ["A1", "A2", "A3", "A4", "A5"]
@pytest.mark.asyncio

View File

@@ -49,6 +49,25 @@ def _json_to_list(value: Any) -> list[str]:
return []
def _json_to_themed_prompts(value: Any) -> dict[str, list[str]]:
"""Convert Json field to themed prompts dict.
Handles both the new ``dict[str, list[str]]`` format and the legacy
``list[str]`` format. Legacy rows are placed under a ``"General"`` key so
existing personalised prompts remain readable until a backfill regenerates
them into the proper themed shape.
"""
if isinstance(value, dict):
return {
k: [i for i in v if isinstance(i, str)]
for k, v in value.items()
if isinstance(k, str) and isinstance(v, list)
}
if isinstance(value, list) and value:
return {"General": [str(p) for p in value if isinstance(p, str)]}
return {}
class BusinessUnderstandingInput(pydantic.BaseModel):
"""Input model for updating business understanding - all fields optional for incremental updates."""
@@ -104,6 +123,11 @@ class BusinessUnderstandingInput(pydantic.BaseModel):
None, description="Any additional context"
)
# Suggested prompts (UI-only, not included in system prompt)
suggested_prompts: Optional[dict[str, list[str]]] = pydantic.Field(
None, description="LLM-generated suggested prompts grouped by theme"
)
class BusinessUnderstanding(pydantic.BaseModel):
"""Full business understanding model returned from database."""
@@ -140,6 +164,9 @@ class BusinessUnderstanding(pydantic.BaseModel):
# Additional context
additional_notes: Optional[str] = None
# Suggested prompts (UI-only, not included in system prompt)
suggested_prompts: dict[str, list[str]] = pydantic.Field(default_factory=dict)
@classmethod
def from_db(cls, db_record: CoPilotUnderstanding) -> "BusinessUnderstanding":
"""Convert database record to Pydantic model."""
@@ -167,6 +194,7 @@ class BusinessUnderstanding(pydantic.BaseModel):
current_software=_json_to_list(business.get("current_software")),
existing_automation=_json_to_list(business.get("existing_automation")),
additional_notes=business.get("additional_notes"),
suggested_prompts=_json_to_themed_prompts(data.get("suggested_prompts")),
)
@@ -246,33 +274,22 @@ async def get_business_understanding(
return understanding
async def upsert_business_understanding(
user_id: str,
def merge_business_understanding_data(
existing_data: dict[str, Any],
input_data: BusinessUnderstandingInput,
) -> BusinessUnderstanding:
"""
Create or update business understanding with incremental merge strategy.
) -> dict[str, Any]:
"""Merge new input into existing data dict using incremental strategy.
- String fields: new value overwrites if provided (not None)
- List fields: new items are appended to existing (deduplicated)
- suggested_prompts: fully replaced if provided (not None)
Data is stored as: {name: ..., business: {version: 1, ...}}
Returns the merged data dict (mutates and returns *existing_data*).
"""
# Get existing record for merge
existing = await CoPilotUnderstanding.prisma().find_unique(
where={"userId": user_id}
)
# Get existing data structure or start fresh
existing_data: dict[str, Any] = {}
if existing and isinstance(existing.data, dict):
existing_data = dict(existing.data)
existing_business: dict[str, Any] = {}
if isinstance(existing_data.get("business"), dict):
existing_business = dict(existing_data["business"])
# Business fields (stored inside business object)
business_string_fields = [
"job_title",
"business_name",
@@ -310,16 +327,48 @@ async def upsert_business_understanding(
merged = _merge_lists(existing_list, value)
existing_business[field] = merged
# Suggested prompts - fully replace if provided
if input_data.suggested_prompts is not None:
existing_data["suggested_prompts"] = input_data.suggested_prompts
# Set version and nest business data
existing_business["version"] = 1
existing_data["business"] = existing_business
return existing_data
async def upsert_business_understanding(
user_id: str,
input_data: BusinessUnderstandingInput,
) -> BusinessUnderstanding:
"""
Create or update business understanding with incremental merge strategy.
- String fields: new value overwrites if provided (not None)
- List fields: new items are appended to existing (deduplicated)
- suggested_prompts: fully replaced if provided (not None)
Data is stored as: {name: ..., business: {version: 1, ...}}
"""
# Get existing record for merge
existing = await CoPilotUnderstanding.prisma().find_unique(
where={"userId": user_id}
)
# Get existing data structure or start fresh
existing_data: dict[str, Any] = {}
if existing and isinstance(existing.data, dict):
existing_data = dict(existing.data)
merged_data = merge_business_understanding_data(existing_data, input_data)
# Upsert with the merged data
record = await CoPilotUnderstanding.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id, "data": SafeJson(existing_data)},
"update": {"data": SafeJson(existing_data)},
"create": {"userId": user_id, "data": SafeJson(merged_data)},
"update": {"data": SafeJson(merged_data)},
},
)

View File

@@ -0,0 +1,148 @@
"""Tests for business understanding merge and format logic."""
from datetime import datetime, timezone
from typing import Any
from unittest.mock import MagicMock
from backend.data.understanding import (
BusinessUnderstanding,
BusinessUnderstandingInput,
_json_to_themed_prompts,
format_understanding_for_prompt,
merge_business_understanding_data,
)
def _make_input(**kwargs: Any) -> BusinessUnderstandingInput:
"""Create a BusinessUnderstandingInput with only the specified fields."""
return BusinessUnderstandingInput.model_validate(kwargs)
# ─── merge_business_understanding_data: themed prompts ─────────────────
def test_merge_themed_prompts_overwrites_existing():
"""New themed prompts should fully replace existing ones (not merge)."""
existing = {
"name": "Alice",
"business": {"industry": "Tech", "version": 1},
"suggested_prompts": {
"Learn": ["Old learn prompt"],
"Create": ["Old create prompt"],
},
}
new_prompts = {
"Automate": ["Schedule daily reports", "Set up email alerts"],
"Organize": ["Sort inbox by priority"],
}
input_data = _make_input(suggested_prompts=new_prompts)
result = merge_business_understanding_data(existing, input_data)
assert result["suggested_prompts"] == new_prompts
def test_merge_themed_prompts_none_preserves_existing():
"""When input has suggested_prompts=None, existing themed prompts are preserved."""
existing_prompts = {
"Learn": ["How to automate?"],
"Create": ["Build a chatbot"],
}
existing = {
"name": "Alice",
"business": {"industry": "Tech", "version": 1},
"suggested_prompts": existing_prompts,
}
input_data = _make_input(industry="Finance")
result = merge_business_understanding_data(existing, input_data)
assert result["suggested_prompts"] == existing_prompts
assert result["business"]["industry"] == "Finance"
# ─── from_db: themed prompts deserialization ───────────────────────────
def test_from_db_themed_prompts():
"""from_db correctly deserializes a themed dict for suggested_prompts."""
themed = {
"Learn": ["What can I automate?"],
"Create": ["Build a workflow"],
}
db_record = MagicMock()
db_record.id = "test-id"
db_record.userId = "user-1"
db_record.createdAt = datetime.now(tz=timezone.utc)
db_record.updatedAt = datetime.now(tz=timezone.utc)
db_record.data = {
"name": "Alice",
"business": {"industry": "Tech", "version": 1},
"suggested_prompts": themed,
}
result = BusinessUnderstanding.from_db(db_record)
assert result.suggested_prompts == themed
def test_from_db_legacy_list_prompts_preserved_under_general():
"""from_db preserves legacy list[str] prompts under a 'General' key."""
db_record = MagicMock()
db_record.id = "test-id"
db_record.userId = "user-1"
db_record.createdAt = datetime.now(tz=timezone.utc)
db_record.updatedAt = datetime.now(tz=timezone.utc)
db_record.data = {
"name": "Alice",
"business": {"industry": "Tech", "version": 1},
"suggested_prompts": ["Old prompt 1", "Old prompt 2"],
}
result = BusinessUnderstanding.from_db(db_record)
assert result.suggested_prompts == {"General": ["Old prompt 1", "Old prompt 2"]}
# ─── _json_to_themed_prompts helper ───────────────────────────────────
def test_json_to_themed_prompts_with_dict():
value = {"Learn": ["a", "b"], "Create": ["c"]}
assert _json_to_themed_prompts(value) == {"Learn": ["a", "b"], "Create": ["c"]}
def test_json_to_themed_prompts_with_list_returns_general():
assert _json_to_themed_prompts(["a", "b"]) == {"General": ["a", "b"]}
def test_json_to_themed_prompts_with_none_returns_empty():
assert _json_to_themed_prompts(None) == {}
# ─── format_understanding_for_prompt: excludes themed prompts ──────────
def test_format_understanding_excludes_themed_prompts():
"""Themed suggested_prompts are UI-only and must NOT appear in the system prompt."""
understanding = BusinessUnderstanding(
id="test-id",
user_id="user-1",
created_at=datetime.now(tz=timezone.utc),
updated_at=datetime.now(tz=timezone.utc),
user_name="Alice",
industry="Technology",
suggested_prompts={
"Learn": ["Automate reports"],
"Create": ["Set up alerts", "Track KPIs"],
},
)
formatted = format_understanding_for_prompt(understanding)
assert "Alice" in formatted
assert "Technology" in formatted
assert "suggested_prompts" not in formatted
assert "Automate reports" not in formatted
assert "Set up alerts" not in formatted
assert "Track KPIs" not in formatted

View File

@@ -526,7 +526,12 @@ class TestValidateOrchestratorBlocks:
"id": AGENT_INPUT_BLOCK_ID,
"name": "AgentInputBlock",
"inputSchema": {
"properties": {"name": {"type": "string"}},
"properties": {
"name": {"type": "string"},
"title": {"type": "string"},
"value": {},
"description": {"type": "string"},
},
"required": ["name"],
},
"outputSchema": {"properties": {"result": {}}},
@@ -537,6 +542,7 @@ class TestValidateOrchestratorBlocks:
"inputSchema": {
"properties": {
"name": {"type": "string"},
"title": {"type": "string"},
"value": {},
},
"required": ["name"],
@@ -683,7 +689,12 @@ class TestOrchestratorE2EPipeline:
"id": AGENT_INPUT_BLOCK_ID,
"name": "AgentInputBlock",
"inputSchema": {
"properties": {"name": {"type": "string"}},
"properties": {
"name": {"type": "string"},
"title": {"type": "string"},
"value": {},
"description": {"type": "string"},
},
"required": ["name"],
},
"outputSchema": {"properties": {"result": {}}},
@@ -694,6 +705,7 @@ class TestOrchestratorE2EPipeline:
"inputSchema": {
"properties": {
"name": {"type": "string"},
"title": {"type": "string"},
"value": {},
},
"required": ["name"],

View File

@@ -65,6 +65,7 @@ export function CopilotPage() {
error,
stop,
isReconnecting,
isSyncing,
createSession,
onSend,
isLoadingSession,
@@ -135,6 +136,7 @@ export function CopilotPage() {
isSessionError={isSessionError}
isCreatingSession={isCreatingSession}
isReconnecting={isReconnecting}
isSyncing={isSyncing}
onCreateSession={createSession}
onSend={onSend}
onStop={stop}

View File

@@ -17,6 +17,8 @@ export interface ChatContainerProps {
isCreatingSession: boolean;
/** True when backend has an active stream but we haven't reconnected yet. */
isReconnecting?: boolean;
/** True while re-syncing session state after device wake. */
isSyncing?: boolean;
onCreateSession: () => void | Promise<string>;
onSend: (message: string, files?: File[]) => void | Promise<void>;
onStop: () => void;
@@ -35,6 +37,7 @@ export const ChatContainer = ({
isSessionError,
isCreatingSession,
isReconnecting,
isSyncing,
onCreateSession,
onSend,
onStop,
@@ -46,6 +49,7 @@ export const ChatContainer = ({
status === "streaming" ||
status === "submitted" ||
!!isReconnecting ||
!!isSyncing ||
isLoadingSession ||
!!isSessionError;
const inputLayoutId = "copilot-2-chat-input";

View File

@@ -25,6 +25,7 @@ import {
import { cn } from "@/lib/utils";
import {
CheckCircle,
CircleNotch,
DotsThree,
PlusCircleIcon,
PlusIcon,
@@ -36,7 +37,6 @@ import { useEffect, useRef, useState } from "react";
import { useCopilotUIStore } from "../../store";
import { NotificationToggle } from "./components/NotificationToggle/NotificationToggle";
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
import { PulseLoader } from "../PulseLoader/PulseLoader";
import { UsageLimits } from "../UsageLimits/UsageLimits";
export function ChatSidebar() {
@@ -367,7 +367,10 @@ export function ChatSidebar() {
{session.is_processing &&
session.id !== sessionId &&
!completedSessionIDs.has(session.id) && (
<PulseLoader size={16} className="shrink-0" />
<CircleNotch
className="h-4 w-4 shrink-0 animate-spin text-zinc-400"
weight="bold"
/>
)}
{completedSessionIDs.has(session.id) &&
session.id !== sessionId && (

View File

@@ -1,17 +1,18 @@
"use client";
import { ChatInput } from "@/app/(platform)/copilot/components/ChatInput/ChatInput";
import { Button } from "@/components/atoms/Button/Button";
import { useGetV2GetSuggestedPrompts } from "@/app/api/__generated__/endpoints/chat/chat";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
import { Text } from "@/components/atoms/Text/Text";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { SpinnerGapIcon } from "@phosphor-icons/react";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import {
getGreetingName,
getInputPlaceholder,
getQuickActions,
getSuggestionThemes,
} from "./helpers";
import { SuggestionThemes } from "./components/SuggestionThemes/SuggestionThemes";
interface Props {
inputLayoutId: string;
@@ -33,25 +34,35 @@ export function EmptySession({
}: Props) {
const { user } = useSupabase();
const greetingName = getGreetingName(user);
const quickActions = getQuickActions();
const [loadingAction, setLoadingAction] = useState<string | null>(null);
const { data: suggestedPromptsResponse, isLoading: isLoadingPrompts } =
useGetV2GetSuggestedPrompts({
query: { staleTime: Infinity, gcTime: Infinity, refetchOnMount: false },
});
const themes = getSuggestionThemes(
suggestedPromptsResponse?.status === 200
? suggestedPromptsResponse.data.themes
: undefined,
);
const [inputPlaceholder, setInputPlaceholder] = useState(
getInputPlaceholder(),
);
useEffect(() => {
setInputPlaceholder(getInputPlaceholder(window.innerWidth));
}, [window.innerWidth]);
async function handleQuickActionClick(action: string) {
if (isCreatingSession || loadingAction !== null) return;
setLoadingAction(action);
try {
await onSend(action);
} finally {
setLoadingAction(null);
function handleResize() {
setInputPlaceholder(getInputPlaceholder(window.innerWidth));
}
}
handleResize();
const mql = window.matchMedia("(max-width: 500px)");
mql.addEventListener("change", handleResize);
const mql2 = window.matchMedia("(max-width: 1080px)");
mql2.addEventListener("change", handleResize);
return () => {
mql.removeEventListener("change", handleResize);
mql2.removeEventListener("change", handleResize);
};
}, []);
return (
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-0 py-5 md:px-6 md:py-10">
@@ -89,30 +100,19 @@ export function EmptySession({
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{quickActions.map((action) => (
<Button
key={action}
type="button"
variant="outline"
size="small"
onClick={() => void handleQuickActionClick(action)}
disabled={isCreatingSession || loadingAction !== null}
aria-busy={loadingAction === action}
leftIcon={
loadingAction === action ? (
<SpinnerGapIcon
className="h-4 w-4 animate-spin"
weight="bold"
/>
) : null
}
className="h-auto shrink-0 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
>
{action}
</Button>
))}
</div>
{isLoadingPrompts ? (
<div className="flex flex-wrap items-center justify-center gap-3">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-10 w-28 shrink-0 rounded-full" />
))}
</div>
) : (
<SuggestionThemes
themes={themes}
onSend={onSend}
disabled={isCreatingSession}
/>
)}
</motion.div>
</div>
);

View File

@@ -0,0 +1,100 @@
"use client";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/molecules/Popover/Popover";
import { Button } from "@/components/atoms/Button/Button";
import {
BookOpenIcon,
PaintBrushIcon,
LightningIcon,
ListChecksIcon,
SpinnerGapIcon,
} from "@phosphor-icons/react";
import { useState } from "react";
import type { SuggestionTheme } from "../../helpers";
const THEME_ICONS: Record<string, typeof BookOpenIcon> = {
Learn: BookOpenIcon,
Create: PaintBrushIcon,
Automate: LightningIcon,
Organize: ListChecksIcon,
};
interface Props {
themes: SuggestionTheme[];
onSend: (prompt: string) => void | Promise<void>;
disabled?: boolean;
}
export function SuggestionThemes({ themes, onSend, disabled }: Props) {
const [openTheme, setOpenTheme] = useState<string | null>(null);
const [loadingPrompt, setLoadingPrompt] = useState<string | null>(null);
async function handlePromptClick(theme: string, prompt: string) {
if (disabled || loadingPrompt) return;
setLoadingPrompt(`${theme}:${prompt}`);
try {
await onSend(prompt);
} finally {
setLoadingPrompt(null);
setOpenTheme(null);
}
}
return (
<div className="flex flex-wrap items-center justify-center gap-3">
{themes.map((theme) => {
const Icon = THEME_ICONS[theme.name];
return (
<Popover
key={theme.name}
open={openTheme === theme.name}
onOpenChange={(open) => setOpenTheme(open ? theme.name : null)}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="small"
disabled={disabled || loadingPrompt !== null}
className="shrink-0 gap-2 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
>
{Icon && <Icon size={16} weight="regular" />}
{theme.name}
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="w-80 p-2">
<ul className="grid gap-0.5">
{theme.prompts.map((prompt) => (
<li key={prompt}>
<button
type="button"
disabled={disabled || loadingPrompt !== null}
onClick={() => void handlePromptClick(theme.name, prompt)}
className="w-full rounded-md px-3 py-2 text-left text-sm text-zinc-700 transition-colors hover:bg-zinc-100 disabled:opacity-50"
>
{loadingPrompt === `${theme.name}:${prompt}` ? (
<span className="flex items-center gap-2">
<SpinnerGapIcon
className="h-4 w-4 animate-spin"
weight="bold"
/>
{prompt}
</span>
) : (
prompt
)}
</button>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
})}
</div>
);
}

View File

@@ -12,12 +12,87 @@ export function getInputPlaceholder(width?: number) {
return "What's your role and what eats up most of your day? e.g. 'I'm a recruiter and I hate...'";
}
export function getQuickActions() {
return [
"I don't know where to start, just ask me stuff",
"I do the same thing every week and it's killing me",
"Help me find where I'm wasting my time",
];
export interface SuggestionTheme {
name: string;
prompts: string[];
}
export const DEFAULT_THEMES: SuggestionTheme[] = [
{
name: "Learn",
prompts: [
"What can AutoGPT do for me?",
"Show me how agents work",
"What integrations are available?",
"How do I schedule an agent?",
"What are the most popular agents?",
],
},
{
name: "Create",
prompts: [
"Draft a weekly status report",
"Generate social media posts for my business",
"Create a competitive analysis summary",
"Write onboarding emails for new hires",
"Build a content calendar for next month",
],
},
{
name: "Automate",
prompts: [
"Monitor relevant websites for changes",
"Send me a daily news digest on my industry",
"Auto-reply to common customer questions",
"Track price changes on products I sell",
"Summarize my emails every morning",
],
},
{
name: "Organize",
prompts: [
"Sort my bookmarks into categories",
"Create a project timeline from my notes",
"Prioritize my task list by urgency",
"Build a decision matrix for vendor selection",
"Organize my meeting notes into action items",
],
},
];
export function getSuggestionThemes(
apiThemes?: SuggestionTheme[],
): SuggestionTheme[] {
if (!apiThemes?.length) {
return DEFAULT_THEMES;
}
const promptsByTheme = new Map(
apiThemes.map((theme) => [theme.name, theme.prompts] as const),
);
// Legacy users have prompts under "General" — distribute them across themes
const generalPrompts = (promptsByTheme.get("General") ?? []).filter(
(p) => p.trim().length > 0,
);
return DEFAULT_THEMES.map((theme, idx) => {
const personalized = (promptsByTheme.get(theme.name) ?? []).filter(
(p) => p.trim().length > 0,
);
// Spread legacy "General" prompts round-robin across themes
const legacySlice = generalPrompts.filter(
(_, i) => i % DEFAULT_THEMES.length === idx,
);
return {
name: theme.name,
prompts: Array.from(
new Set([...personalized, ...legacySlice, ...theme.prompts]),
).slice(0, theme.prompts.length),
};
});
}
export function getGreetingName(user?: User | null) {

View File

@@ -5,6 +5,7 @@ import { scrollbarStyles } from "@/components/styles/scrollbars";
import { cn } from "@/lib/utils";
import {
CheckCircle,
CircleNotch,
PlusIcon,
SpeakerHigh,
SpeakerSlash,
@@ -13,7 +14,6 @@ import {
} from "@phosphor-icons/react";
import { Drawer } from "vaul";
import { useCopilotUIStore } from "../../store";
import { PulseLoader } from "../PulseLoader/PulseLoader";
interface Props {
isOpen: boolean;
@@ -165,7 +165,10 @@ export function MobileDrawer({
{session.is_processing &&
!completedSessionIDs.has(session.id) &&
session.id !== currentSessionId && (
<PulseLoader size={8} className="shrink-0" />
<CircleNotch
className="h-4 w-4 shrink-0 animate-spin text-zinc-400"
weight="bold"
/>
)}
{completedSessionIDs.has(session.id) &&
session.id !== currentSessionId && (

View File

@@ -1,39 +0,0 @@
.loader {
position: relative;
display: inline-block;
flex-shrink: 0;
}
.loader::before,
.loader::after {
content: "";
box-sizing: border-box;
width: 100%;
height: 100%;
border-radius: 50%;
background: currentColor;
position: absolute;
left: 0;
top: 0;
transform: scale(0);
opacity: 0;
animation: ripple 2s linear infinite;
}
.loader::after {
animation-delay: 1s;
}
@keyframes ripple {
0% {
transform: scale(0);
opacity: 0.6;
}
50% {
opacity: 0.3;
}
100% {
transform: scale(1);
opacity: 0;
}
}

View File

@@ -1,16 +0,0 @@
import { cn } from "@/lib/utils";
import styles from "./PulseLoader.module.css";
interface Props {
size?: number;
className?: string;
}
export function PulseLoader({ size = 24, className }: Props) {
return (
<div
className={cn(styles.loader, className)}
style={{ width: size, height: size }}
/>
);
}

View File

@@ -1,5 +1,24 @@
import type { UIMessage } from "ai";
/**
* Check whether a refetchSession result indicates the backend still has an
* active SSE stream for this session.
*/
export function hasActiveBackendStream(result: { data?: unknown }): boolean {
const d = result.data;
return (
d != null &&
typeof d === "object" &&
"status" in d &&
d.status === 200 &&
"data" in d &&
d.data != null &&
typeof d.data === "object" &&
"active_stream" in d.data &&
!!d.data.active_stream
);
}
/** Mark any in-progress tool parts as completed/errored so spinners stop. */
export function resolveInProgressTools(
messages: UIMessage[],

View File

@@ -54,6 +54,7 @@ export function useCopilotPage() {
status,
error,
isReconnecting,
isSyncing,
isUserStoppingRef,
} = useCopilotStream({
sessionId,
@@ -349,6 +350,7 @@ export function useCopilotPage() {
error,
stop,
isReconnecting,
isSyncing,
isLoadingSession,
isSessionError,
isCreatingSession,

View File

@@ -11,11 +11,18 @@ import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport } from "ai";
import type { FileUIPart, UIMessage } from "ai";
import { useEffect, useMemo, useRef, useState } from "react";
import { deduplicateMessages, resolveInProgressTools } from "./helpers";
import {
deduplicateMessages,
hasActiveBackendStream,
resolveInProgressTools,
} from "./helpers";
const RECONNECT_BASE_DELAY_MS = 1_000;
const RECONNECT_MAX_ATTEMPTS = 3;
/** Minimum time the page must have been hidden to trigger a wake re-sync. */
const WAKE_RESYNC_THRESHOLD_MS = 30_000;
/** Fetch a fresh JWT for direct backend requests (same pattern as WebSocket). */
async function getAuthHeaders(): Promise<Record<string, string>> {
const { token, error } = await getWebSocketToken();
@@ -98,6 +105,10 @@ export function useCopilotStream({
// Must be state (not ref) so that setting it triggers a re-render and
// recomputes `isReconnecting`.
const [reconnectExhausted, setReconnectExhausted] = useState(false);
// True while performing a wake re-sync (blocks chat input).
const [isSyncing, setIsSyncing] = useState(false);
// Tracks the last time the page was hidden — used to detect sleep/wake gaps.
const lastHiddenAtRef = useRef(Date.now());
function handleReconnect(sid: string) {
if (isReconnectScheduledRef.current || !sid) return;
@@ -159,19 +170,7 @@ export function useCopilotStream({
// unnecessary reconnect cycles.
await new Promise((r) => setTimeout(r, 500));
const result = await refetchSession();
const d = result.data;
const backendActive =
d != null &&
typeof d === "object" &&
"status" in d &&
d.status === 200 &&
"data" in d &&
d.data != null &&
typeof d.data === "object" &&
"active_stream" in d.data &&
!!d.data.active_stream;
if (backendActive) {
if (hasActiveBackendStream(result)) {
handleReconnect(sessionId);
}
},
@@ -298,6 +297,67 @@ export function useCopilotStream({
}
}
// Keep a ref to sessionId so the async wake handler can detect staleness.
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
// ---------------------------------------------------------------------------
// Wake detection: when the page becomes visible after being hidden for >30s
// (device sleep, tab backgrounded for a long time), refetch the session to
// pick up any messages the backend produced while the SSE was dead.
// ---------------------------------------------------------------------------
useEffect(() => {
async function handleWakeResync() {
const sid = sessionIdRef.current;
if (!sid) return;
const elapsed = Date.now() - lastHiddenAtRef.current;
lastHiddenAtRef.current = Date.now();
if (document.visibilityState !== "visible") return;
if (elapsed < WAKE_RESYNC_THRESHOLD_MS) return;
setIsSyncing(true);
try {
const result = await refetchSession();
// Bail out if the session changed while the refetch was in flight.
if (sessionIdRef.current !== sid) return;
if (hasActiveBackendStream(result)) {
// Stream is still running — resume SSE to pick up live chunks.
// Remove stale in-progress assistant message first (backend replays
// from "0-0").
setMessages((prev) => {
if (prev.length > 0 && prev[prev.length - 1].role === "assistant") {
return prev.slice(0, -1);
}
return prev;
});
await resumeStream();
}
// If !backendActive, the refetch will update hydratedMessages via
// React Query, and the hydration effect below will merge them in.
} catch (err) {
console.warn("[copilot] wake re-sync failed", err);
} finally {
setIsSyncing(false);
}
}
function onVisibilityChange() {
if (document.visibilityState === "hidden") {
lastHiddenAtRef.current = Date.now();
} else {
handleWakeResync();
}
}
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [refetchSession, setMessages, resumeStream]);
// Hydrate messages from REST API when not actively streaming
useEffect(() => {
if (!hydratedMessages || hydratedMessages.length === 0) return;
@@ -322,6 +382,7 @@ export function useCopilotStream({
hasShownDisconnectToast.current = false;
isUserStoppingRef.current = false;
setReconnectExhausted(false);
setIsSyncing(false);
hasResumedRef.current.clear();
return () => {
clearTimeout(reconnectTimerRef.current);
@@ -424,6 +485,7 @@ export function useCopilotStream({
status,
error: isReconnecting || isUserStoppingRef.current ? undefined : error,
isReconnecting,
isSyncing,
isUserStoppingRef,
};
}

View File

@@ -79,7 +79,10 @@ export function StoreCard({
/>
</>
) : (
<div className="absolute inset-0 rounded-xl bg-violet-50" />
<div
className="absolute inset-0 rounded-xl"
style={{ backgroundColor: "rgb(216, 208, 255)" }}
/>
)}
</div>
@@ -113,7 +116,7 @@ export function StoreCard({
{/* Third Section: Description */}
<div className="mt-2.5 flex w-full flex-col">
<Text variant="body" className="line-clamp-2 leading-normal">
<Text variant="body" className="line-clamp-3 leading-normal">
{description}
</Text>
</div>

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v7.13.0 🍺
* Do not edit manually.
* AutoGPT Agent Server
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
import type { SuggestedTheme } from "./suggestedTheme";
/**
* Response model for user-specific suggested prompts grouped by theme.
*/
export interface SuggestedPromptsResponse {
themes: SuggestedTheme[];
}

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v7.13.0 🍺
* Do not edit manually.
* AutoGPT Agent Server
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
/**
* A themed group of suggested prompts.
*/
export interface SuggestedTheme {
name: string;
prompts: string[];
}

View File

@@ -1358,6 +1358,30 @@
}
}
},
"/api/chat/suggested-prompts": {
"get": {
"tags": ["v2", "chat", "chat"],
"summary": "Get Suggested Prompts",
"description": "Get LLM-generated suggested prompts grouped by theme.\n\nReturns personalized quick-action prompts based on the user's\nbusiness understanding. Returns empty themes list if no custom\nprompts are available.",
"operationId": "getV2GetSuggestedPrompts",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SuggestedPromptsResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/chat/usage": {
"get": {
"tags": ["v2", "chat", "chat"],
@@ -12842,6 +12866,33 @@
"title": "SuggestedGoalResponse",
"description": "Response when the goal needs refinement with a suggested alternative."
},
"SuggestedPromptsResponse": {
"properties": {
"themes": {
"items": { "$ref": "#/components/schemas/SuggestedTheme" },
"type": "array",
"title": "Themes"
}
},
"type": "object",
"required": ["themes"],
"title": "SuggestedPromptsResponse",
"description": "Response model for user-specific suggested prompts grouped by theme."
},
"SuggestedTheme": {
"properties": {
"name": { "type": "string", "title": "Name" },
"prompts": {
"items": { "type": "string" },
"type": "array",
"title": "Prompts"
}
},
"type": "object",
"required": ["name", "prompts"],
"title": "SuggestedTheme",
"description": "A themed group of suggested prompts."
},
"SuggestionsResponse": {
"properties": {
"recent_searches": {

View File

@@ -342,7 +342,7 @@ Below is a comprehensive list of all available blocks, categorized by their prim
| [Post To X](block-integrations/ayrshare/post_to_x.md#post-to-x) | Post to X / Twitter using Ayrshare |
| [Post To YouTube](block-integrations/ayrshare/post_to_youtube.md#post-to-youtube) | Post to YouTube using Ayrshare |
| [Publish To Medium](block-integrations/misc.md#publish-to-medium) | Publishes a post to Medium |
| [Read Discord Messages](block-integrations/discord/bot_blocks.md#read-discord-messages) | Reads messages from a Discord channel using a bot token |
| [Read Discord Messages](block-integrations/discord/bot_blocks.md#read-discord-messages) | Reads new messages from a Discord channel using a bot token and triggers when a new message is posted |
| [Reddit Get My Posts](block-integrations/misc.md#reddit-get-my-posts) | Fetch posts created by the authenticated Reddit user (you) |
| [Reply To Discord Message](block-integrations/discord/bot_blocks.md#reply-to-discord-message) | Replies to a specific Discord message |
| [Reply To Reddit Comment](block-integrations/misc.md#reply-to-reddit-comment) | Reply to a specific Reddit comment |

View File

@@ -1,7 +1,7 @@
## Read Discord Messages
### What it is
A block that reads messages from a Discord channel using a bot token.
A block that reads new messages from a Discord channel using a bot token and triggers when a new message is posted.
### What it does
This block connects to Discord using a bot token and retrieves messages from a specified channel. It can operate continuously or retrieve a single message.

View File

@@ -132,7 +132,7 @@ The user must be visible to your bot (share a server with your bot).
## Read Discord Messages
### What it is
Reads messages from a Discord channel using a bot token.
Reads new messages from a Discord channel using a bot token and triggers when a new message is posted
### How it works
<!-- MANUAL: how_it_works -->