mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into feat/admin-rate-limit-management
This commit is contained in:
@@ -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 ==========
|
||||
|
||||
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"""
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
148
autogpt_platform/backend/backend/data/understanding_test.py
Normal file
148
autogpt_platform/backend/backend/data/understanding_test.py
Normal 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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
autogpt_platform/frontend/src/app/api/__generated__/models/suggestedPromptsResponse.ts
generated
Normal file
15
autogpt_platform/frontend/src/app/api/__generated__/models/suggestedPromptsResponse.ts
generated
Normal 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[];
|
||||
}
|
||||
15
autogpt_platform/frontend/src/app/api/__generated__/models/suggestedTheme.ts
generated
Normal file
15
autogpt_platform/frontend/src/app/api/__generated__/models/suggestedTheme.ts
generated
Normal 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[];
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user