mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-21 04:57:58 -05:00
Compare commits
23 Commits
testing-cl
...
fix/fronte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b4776dcf5 | ||
|
|
03853062b7 | ||
|
|
fa0b7029dd | ||
|
|
c20ca47bb0 | ||
|
|
96222bc3a1 | ||
|
|
318626b886 | ||
|
|
cd004a8492 | ||
|
|
4696766b52 | ||
|
|
451191c73c | ||
|
|
f51e50fe10 | ||
|
|
2fc3516473 | ||
|
|
6789ff3057 | ||
|
|
3d41c0ae07 | ||
|
|
36568d358e | ||
|
|
484321376c | ||
|
|
93651621e6 | ||
|
|
1bdaa0a8c8 | ||
|
|
e7c5294b24 | ||
|
|
82ae0303cf | ||
|
|
a37f7efbdf | ||
|
|
e2ae6086c9 | ||
|
|
1108f74359 | ||
|
|
9b98b2df40 |
14
AGENTS.md
14
AGENTS.md
@@ -16,6 +16,20 @@ See `docs/content/platform/getting-started.md` for setup instructions.
|
|||||||
- Format Python code with `poetry run format`.
|
- Format Python code with `poetry run format`.
|
||||||
- Format frontend code using `pnpm format`.
|
- Format frontend code using `pnpm format`.
|
||||||
|
|
||||||
|
## Frontend-specific guidelines
|
||||||
|
|
||||||
|
**When working on files in `autogpt_platform/frontend/`, always read and follow the conventions in `autogpt_platform/frontend/CONTRIBUTING.md`.**
|
||||||
|
|
||||||
|
Key frontend conventions:
|
||||||
|
- Component props should be `interface Props { ... }` (not exported) unless the interface needs to be used outside the component
|
||||||
|
- Separate render logic from business logic (component.tsx + useComponent.ts + helpers.ts)
|
||||||
|
- Colocate state when possible and avoid create large components, use sub-components ( local `/components` folder next to the parent component ) when sensible
|
||||||
|
- Avoid large hooks, abstract logic into `helpers.ts` files when sensible
|
||||||
|
- Use function declarations for components and handlers, arrow functions only for small inline callbacks
|
||||||
|
- No barrel files or `index.ts` re-exports
|
||||||
|
|
||||||
|
See `autogpt_platform/frontend/CONTRIBUTING.md` for complete frontend architecture, patterns, and conventions.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Backend: `poetry run test` (runs pytest with a docker based postgres + prisma).
|
- Backend: `poetry run test` (runs pytest with a docker based postgres + prisma).
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ async def create_session(
|
|||||||
async def get_session(
|
async def get_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
user_id: Annotated[str | None, Depends(auth.get_user_id)],
|
user_id: Annotated[str | None, Depends(auth.get_user_id)],
|
||||||
) -> SessionDetailResponse:
|
) -> SessionDetailResponse | None:
|
||||||
"""
|
"""
|
||||||
Retrieve the details of a specific chat session.
|
Retrieve the details of a specific chat session.
|
||||||
|
|
||||||
@@ -172,12 +172,12 @@ async def get_session(
|
|||||||
user_id: The optional authenticated user ID, or None for anonymous access.
|
user_id: The optional authenticated user ID, or None for anonymous access.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
SessionDetailResponse: Details for the requested session; raises NotFoundError if not found.
|
SessionDetailResponse: Details for the requested session, or None if not found.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
session = await get_chat_session(session_id, user_id)
|
session = await get_chat_session(session_id, user_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise NotFoundError(f"Session {session_id} not found")
|
return None
|
||||||
|
|
||||||
messages = [message.model_dump() for message in session.messages]
|
messages = [message.model_dump() for message in session.messages]
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ async def stream_chat_completion(
|
|||||||
content="",
|
content="",
|
||||||
)
|
)
|
||||||
accumulated_tool_calls: list[dict[str, Any]] = []
|
accumulated_tool_calls: list[dict[str, Any]] = []
|
||||||
|
has_saved_assistant_message = False
|
||||||
|
|
||||||
# Wrap main logic in try/finally to ensure Langfuse observations are always ended
|
# Wrap main logic in try/finally to ensure Langfuse observations are always ended
|
||||||
has_yielded_end = False
|
has_yielded_end = False
|
||||||
@@ -390,6 +391,33 @@ async def stream_chat_completion(
|
|||||||
if has_received_text and not text_streaming_ended:
|
if has_received_text and not text_streaming_ended:
|
||||||
yield StreamTextEnd(id=text_block_id)
|
yield StreamTextEnd(id=text_block_id)
|
||||||
text_streaming_ended = True
|
text_streaming_ended = True
|
||||||
|
|
||||||
|
# Save assistant message before yielding finish to ensure it's persisted
|
||||||
|
# even if client disconnects immediately after receiving StreamFinish
|
||||||
|
if not has_saved_assistant_message:
|
||||||
|
messages_to_save_early: list[ChatMessage] = []
|
||||||
|
if accumulated_tool_calls:
|
||||||
|
assistant_response.tool_calls = (
|
||||||
|
accumulated_tool_calls
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
assistant_response.content
|
||||||
|
or assistant_response.tool_calls
|
||||||
|
):
|
||||||
|
messages_to_save_early.append(assistant_response)
|
||||||
|
messages_to_save_early.extend(tool_response_messages)
|
||||||
|
|
||||||
|
if messages_to_save_early:
|
||||||
|
session.messages.extend(messages_to_save_early)
|
||||||
|
logger.info(
|
||||||
|
f"Saving assistant message before StreamFinish: "
|
||||||
|
f"content_len={len(assistant_response.content or '')}, "
|
||||||
|
f"tool_calls={len(assistant_response.tool_calls or [])}, "
|
||||||
|
f"tool_responses={len(tool_response_messages)}"
|
||||||
|
)
|
||||||
|
await upsert_chat_session(session)
|
||||||
|
has_saved_assistant_message = True
|
||||||
|
|
||||||
has_yielded_end = True
|
has_yielded_end = True
|
||||||
yield chunk
|
yield chunk
|
||||||
elif isinstance(chunk, StreamError):
|
elif isinstance(chunk, StreamError):
|
||||||
@@ -472,38 +500,46 @@ async def stream_chat_completion(
|
|||||||
return # Exit after retry to avoid double-saving in finally block
|
return # Exit after retry to avoid double-saving in finally block
|
||||||
|
|
||||||
# Normal completion path - save session and handle tool call continuation
|
# Normal completion path - save session and handle tool call continuation
|
||||||
logger.info(
|
# Only save if we haven't already saved when StreamFinish was received
|
||||||
f"Normal completion path: session={session.session_id}, "
|
if not has_saved_assistant_message:
|
||||||
f"current message_count={len(session.messages)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build the messages list in the correct order
|
|
||||||
messages_to_save: list[ChatMessage] = []
|
|
||||||
|
|
||||||
# Add assistant message with tool_calls if any
|
|
||||||
if accumulated_tool_calls:
|
|
||||||
assistant_response.tool_calls = accumulated_tool_calls
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
|
f"Normal completion path: session={session.session_id}, "
|
||||||
)
|
f"current message_count={len(session.messages)}"
|
||||||
if assistant_response.content or assistant_response.tool_calls:
|
|
||||||
messages_to_save.append(assistant_response)
|
|
||||||
logger.info(
|
|
||||||
f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add tool response messages after assistant message
|
# Build the messages list in the correct order
|
||||||
messages_to_save.extend(tool_response_messages)
|
messages_to_save: list[ChatMessage] = []
|
||||||
logger.info(
|
|
||||||
f"Saving {len(tool_response_messages)} tool response messages, "
|
|
||||||
f"total_to_save={len(messages_to_save)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.messages.extend(messages_to_save)
|
# Add assistant message with tool_calls if any
|
||||||
logger.info(
|
if accumulated_tool_calls:
|
||||||
f"Extended session messages, new message_count={len(session.messages)}"
|
assistant_response.tool_calls = accumulated_tool_calls
|
||||||
)
|
logger.info(
|
||||||
await upsert_chat_session(session)
|
f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
|
||||||
|
)
|
||||||
|
if assistant_response.content or assistant_response.tool_calls:
|
||||||
|
messages_to_save.append(assistant_response)
|
||||||
|
logger.info(
|
||||||
|
f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add tool response messages after assistant message
|
||||||
|
messages_to_save.extend(tool_response_messages)
|
||||||
|
logger.info(
|
||||||
|
f"Saving {len(tool_response_messages)} tool response messages, "
|
||||||
|
f"total_to_save={len(messages_to_save)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if messages_to_save:
|
||||||
|
session.messages.extend(messages_to_save)
|
||||||
|
logger.info(
|
||||||
|
f"Extended session messages, new message_count={len(session.messages)}"
|
||||||
|
)
|
||||||
|
await upsert_chat_session(session)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Assistant message already saved when StreamFinish was received, "
|
||||||
|
"skipping duplicate save"
|
||||||
|
)
|
||||||
|
|
||||||
# If we did a tool call, stream the chat completion again to get the next response
|
# If we did a tool call, stream the chat completion again to get the next response
|
||||||
if has_done_tool_call:
|
if has_done_tool_call:
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from .models import (
|
|||||||
UserReadiness,
|
UserReadiness,
|
||||||
)
|
)
|
||||||
from .utils import (
|
from .utils import (
|
||||||
check_user_has_required_credentials,
|
build_missing_credentials_from_graph,
|
||||||
extract_credentials_from_schema,
|
extract_credentials_from_schema,
|
||||||
fetch_graph_from_store_slug,
|
fetch_graph_from_store_slug,
|
||||||
get_or_create_library_agent,
|
get_or_create_library_agent,
|
||||||
@@ -237,15 +237,13 @@ class RunAgentTool(BaseTool):
|
|||||||
# Return credentials needed response with input data info
|
# Return credentials needed response with input data info
|
||||||
# The UI handles credential setup automatically, so the message
|
# The UI handles credential setup automatically, so the message
|
||||||
# focuses on asking about input data
|
# focuses on asking about input data
|
||||||
credentials = extract_credentials_from_schema(
|
requirements_creds_dict = build_missing_credentials_from_graph(
|
||||||
graph.credentials_input_schema
|
graph, None
|
||||||
)
|
)
|
||||||
missing_creds_check = await check_user_has_required_credentials(
|
missing_credentials_dict = build_missing_credentials_from_graph(
|
||||||
user_id, credentials
|
graph, graph_credentials
|
||||||
)
|
)
|
||||||
missing_credentials_dict = {
|
requirements_creds_list = list(requirements_creds_dict.values())
|
||||||
c.id: c.model_dump() for c in missing_creds_check
|
|
||||||
}
|
|
||||||
|
|
||||||
return SetupRequirementsResponse(
|
return SetupRequirementsResponse(
|
||||||
message=self._build_inputs_message(graph, MSG_WHAT_VALUES_TO_USE),
|
message=self._build_inputs_message(graph, MSG_WHAT_VALUES_TO_USE),
|
||||||
@@ -259,7 +257,7 @@ class RunAgentTool(BaseTool):
|
|||||||
ready_to_run=False,
|
ready_to_run=False,
|
||||||
),
|
),
|
||||||
requirements={
|
requirements={
|
||||||
"credentials": [c.model_dump() for c in credentials],
|
"credentials": requirements_creds_list,
|
||||||
"inputs": self._get_inputs_list(graph.input_schema),
|
"inputs": self._get_inputs_list(graph.input_schema),
|
||||||
"execution_modes": self._get_execution_modes(graph),
|
"execution_modes": self._get_execution_modes(graph),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .models import (
|
|||||||
ToolResponseBase,
|
ToolResponseBase,
|
||||||
UserReadiness,
|
UserReadiness,
|
||||||
)
|
)
|
||||||
|
from .utils import build_missing_credentials_from_field_info
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -189,7 +190,11 @@ class RunBlockTool(BaseTool):
|
|||||||
|
|
||||||
if missing_credentials:
|
if missing_credentials:
|
||||||
# Return setup requirements response with missing credentials
|
# Return setup requirements response with missing credentials
|
||||||
missing_creds_dict = {c.id: c.model_dump() for c in missing_credentials}
|
credentials_fields_info = block.input_schema.get_credentials_fields_info()
|
||||||
|
missing_creds_dict = build_missing_credentials_from_field_info(
|
||||||
|
credentials_fields_info, set(matched_credentials.keys())
|
||||||
|
)
|
||||||
|
missing_creds_list = list(missing_creds_dict.values())
|
||||||
|
|
||||||
return SetupRequirementsResponse(
|
return SetupRequirementsResponse(
|
||||||
message=(
|
message=(
|
||||||
@@ -206,7 +211,7 @@ class RunBlockTool(BaseTool):
|
|||||||
ready_to_run=False,
|
ready_to_run=False,
|
||||||
),
|
),
|
||||||
requirements={
|
requirements={
|
||||||
"credentials": [c.model_dump() for c in missing_credentials],
|
"credentials": missing_creds_list,
|
||||||
"inputs": self._get_inputs_list(block),
|
"inputs": self._get_inputs_list(block),
|
||||||
"execution_modes": ["immediate"],
|
"execution_modes": ["immediate"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from backend.api.features.library import model as library_model
|
|||||||
from backend.api.features.store import db as store_db
|
from backend.api.features.store import db as store_db
|
||||||
from backend.data import graph as graph_db
|
from backend.data import graph as graph_db
|
||||||
from backend.data.graph import GraphModel
|
from backend.data.graph import GraphModel
|
||||||
from backend.data.model import CredentialsMetaInput
|
from backend.data.model import CredentialsFieldInfo, CredentialsMetaInput
|
||||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||||
from backend.util.exceptions import NotFoundError
|
from backend.util.exceptions import NotFoundError
|
||||||
|
|
||||||
@@ -89,6 +89,59 @@ def extract_credentials_from_schema(
|
|||||||
return credentials
|
return credentials
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_missing_credential(
|
||||||
|
field_key: str, field_info: CredentialsFieldInfo
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert credential field info into a serializable dict that preserves all supported
|
||||||
|
credential types (e.g., api_key + oauth2) so the UI can offer multiple options.
|
||||||
|
"""
|
||||||
|
supported_types = sorted(field_info.supported_types)
|
||||||
|
provider = next(iter(field_info.provider), "unknown")
|
||||||
|
scopes = sorted(field_info.required_scopes or [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": field_key,
|
||||||
|
"title": field_key.replace("_", " ").title(),
|
||||||
|
"provider": provider,
|
||||||
|
"provider_name": provider.replace("_", " ").title(),
|
||||||
|
"type": supported_types[0] if supported_types else "api_key",
|
||||||
|
"types": supported_types,
|
||||||
|
"scopes": scopes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_missing_credentials_from_graph(
|
||||||
|
graph: GraphModel, matched_credentials: dict[str, CredentialsMetaInput] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build a missing_credentials mapping from a graph's aggregated credentials inputs,
|
||||||
|
preserving all supported credential types for each field.
|
||||||
|
"""
|
||||||
|
matched_keys = set(matched_credentials.keys()) if matched_credentials else set()
|
||||||
|
aggregated_fields = graph.aggregate_credentials_inputs()
|
||||||
|
|
||||||
|
return {
|
||||||
|
field_key: _serialize_missing_credential(field_key, field_info)
|
||||||
|
for field_key, (field_info, _node_fields) in aggregated_fields.items()
|
||||||
|
if field_key not in matched_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_missing_credentials_from_field_info(
|
||||||
|
credential_fields: dict[str, CredentialsFieldInfo],
|
||||||
|
matched_keys: set[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build missing_credentials mapping from a simple credentials field info dictionary.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
field_key: _serialize_missing_credential(field_key, field_info)
|
||||||
|
for field_key, field_info in credential_fields.items()
|
||||||
|
if field_key not in matched_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def extract_credentials_as_dict(
|
def extract_credentials_as_dict(
|
||||||
credentials_input_schema: dict[str, Any] | None,
|
credentials_input_schema: dict[str, Any] | None,
|
||||||
) -> dict[str, CredentialsMetaInput]:
|
) -> dict[str, CredentialsMetaInput]:
|
||||||
|
|||||||
@@ -366,12 +366,12 @@ def generate_block_markdown(
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# What it is (full description)
|
# What it is (full description)
|
||||||
lines.append(f"### What it is")
|
lines.append("### What it is")
|
||||||
lines.append(block.description or "No description available.")
|
lines.append(block.description or "No description available.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# How it works (manual section)
|
# How it works (manual section)
|
||||||
lines.append(f"### How it works")
|
lines.append("### How it works")
|
||||||
how_it_works = manual_content.get(
|
how_it_works = manual_content.get(
|
||||||
"how_it_works", "_Add technical explanation here._"
|
"how_it_works", "_Add technical explanation here._"
|
||||||
)
|
)
|
||||||
@@ -383,7 +383,7 @@ def generate_block_markdown(
|
|||||||
# Inputs table (auto-generated)
|
# Inputs table (auto-generated)
|
||||||
visible_inputs = [f for f in block.inputs if not f.hidden]
|
visible_inputs = [f for f in block.inputs if not f.hidden]
|
||||||
if visible_inputs:
|
if visible_inputs:
|
||||||
lines.append(f"### Inputs")
|
lines.append("### Inputs")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("| Input | Description | Type | Required |")
|
lines.append("| Input | Description | Type | Required |")
|
||||||
lines.append("|-------|-------------|------|----------|")
|
lines.append("|-------|-------------|------|----------|")
|
||||||
@@ -400,7 +400,7 @@ def generate_block_markdown(
|
|||||||
# Outputs table (auto-generated)
|
# Outputs table (auto-generated)
|
||||||
visible_outputs = [f for f in block.outputs if not f.hidden]
|
visible_outputs = [f for f in block.outputs if not f.hidden]
|
||||||
if visible_outputs:
|
if visible_outputs:
|
||||||
lines.append(f"### Outputs")
|
lines.append("### Outputs")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("| Output | Description | Type |")
|
lines.append("| Output | Description | Type |")
|
||||||
lines.append("|--------|-------------|------|")
|
lines.append("|--------|-------------|------|")
|
||||||
@@ -414,7 +414,7 @@ def generate_block_markdown(
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Possible use case (manual section)
|
# Possible use case (manual section)
|
||||||
lines.append(f"### Possible use case")
|
lines.append("### Possible use case")
|
||||||
use_case = manual_content.get("use_case", "_Add practical use case examples here._")
|
use_case = manual_content.get("use_case", "_Add practical use case examples here._")
|
||||||
lines.append("<!-- MANUAL: use_case -->")
|
lines.append("<!-- MANUAL: use_case -->")
|
||||||
lines.append(use_case)
|
lines.append(use_case)
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ While server components and actions are cool and cutting-edge, they introduce a
|
|||||||
|
|
||||||
- Prefer [React Query](https://tanstack.com/query/latest/docs/framework/react/overview) for server state, colocated near consumers (see [state colocation](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster))
|
- Prefer [React Query](https://tanstack.com/query/latest/docs/framework/react/overview) for server state, colocated near consumers (see [state colocation](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster))
|
||||||
- Co-locate UI state inside components/hooks; keep global state minimal
|
- Co-locate UI state inside components/hooks; keep global state minimal
|
||||||
|
- Avoid `useMemo` and `useCallback` unless you have a measured performance issue
|
||||||
|
- Do not abuse `useEffect`; prefer state colocation and derive values directly when possible
|
||||||
|
|
||||||
### Styling and components
|
### Styling and components
|
||||||
|
|
||||||
@@ -549,9 +551,48 @@ Files:
|
|||||||
Types:
|
Types:
|
||||||
|
|
||||||
- Prefer `interface` for object shapes
|
- Prefer `interface` for object shapes
|
||||||
- Component props should be `interface Props { ... }`
|
- Component props should be `interface Props { ... }` (not exported)
|
||||||
|
- Only use specific exported names (e.g., `export interface MyComponentProps`) when the interface needs to be used outside the component
|
||||||
|
- Keep type definitions inline with the component - do not create separate `types.ts` files unless types are shared across multiple files
|
||||||
- Use precise types; avoid `any` and unsafe casts
|
- Use precise types; avoid `any` and unsafe casts
|
||||||
|
|
||||||
|
**Props naming examples:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ Good - internal props, not exported
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ title, onClose }: Props) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good - exported when needed externally
|
||||||
|
export interface ModalProps {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ title, onClose }: ModalProps) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad - unnecessarily specific name for internal use
|
||||||
|
interface ModalComponentProps {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad - separate types.ts file for single component
|
||||||
|
// types.ts
|
||||||
|
export interface ModalProps { ... }
|
||||||
|
|
||||||
|
// Modal.tsx
|
||||||
|
import type { ModalProps } from './types';
|
||||||
|
```
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
|
||||||
- If more than one parameter is needed, pass a single `Args` object for clarity
|
- If more than one parameter is needed, pass a single `Args` object for clarity
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const LOGOUT_REDIRECT_DELAY_MS = 400;
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise(function resolveAfterDelay(resolve) {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogoutPage() {
|
||||||
|
const { logOut } = useSupabase();
|
||||||
|
const router = useRouter();
|
||||||
|
const hasStartedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(function handleLogoutEffect() {
|
||||||
|
if (hasStartedRef.current) return;
|
||||||
|
hasStartedRef.current = true;
|
||||||
|
|
||||||
|
async function runLogout() {
|
||||||
|
await logOut();
|
||||||
|
await wait(LOGOUT_REDIRECT_DELAY_MS);
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
void runLogout();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center px-4">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
<Text variant="body" className="text-center">
|
||||||
|
Logging you out...
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams, origin } = new URL(request.url);
|
const { searchParams, origin } = new URL(request.url);
|
||||||
const code = searchParams.get("code");
|
const code = searchParams.get("code");
|
||||||
|
|
||||||
let next = "/marketplace";
|
let next = "/";
|
||||||
|
|
||||||
if (code) {
|
if (code) {
|
||||||
const supabase = await getServerSupabase();
|
const supabase = await getServerSupabase();
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||||
import { PlayIcon, StopIcon } from "@phosphor-icons/react";
|
import { CircleNotchIcon, PlayIcon, StopIcon } from "@phosphor-icons/react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||||
import { useRunGraph } from "./useRunGraph";
|
import { useRunGraph } from "./useRunGraph";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
||||||
const {
|
const {
|
||||||
@@ -24,6 +25,31 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
|||||||
useShallow((state) => state.isGraphRunning),
|
useShallow((state) => state.isGraphRunning),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isLoading = isExecutingGraph || isTerminatingGraph || isSaving;
|
||||||
|
|
||||||
|
// Determine which icon to show with proper animation
|
||||||
|
const renderIcon = () => {
|
||||||
|
const iconClass = cn(
|
||||||
|
"size-4 transition-transform duration-200 ease-out",
|
||||||
|
!isLoading && "group-hover:scale-110",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<CircleNotchIcon
|
||||||
|
className={cn(iconClass, "animate-spin")}
|
||||||
|
weight="bold"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGraphRunning) {
|
||||||
|
return <StopIcon className={iconClass} weight="fill" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PlayIcon className={iconClass} weight="fill" />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -33,18 +59,18 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
|||||||
variant={isGraphRunning ? "destructive" : "primary"}
|
variant={isGraphRunning ? "destructive" : "primary"}
|
||||||
data-id={isGraphRunning ? "stop-graph-button" : "run-graph-button"}
|
data-id={isGraphRunning ? "stop-graph-button" : "run-graph-button"}
|
||||||
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
|
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
|
||||||
disabled={!flowID || isExecutingGraph || isTerminatingGraph}
|
disabled={!flowID || isLoading}
|
||||||
loading={isExecutingGraph || isTerminatingGraph || isSaving}
|
className="group"
|
||||||
>
|
>
|
||||||
{!isGraphRunning ? (
|
{renderIcon()}
|
||||||
<PlayIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<StopIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{isGraphRunning ? "Stop agent" : "Run agent"}
|
{isLoading
|
||||||
|
? "Processing..."
|
||||||
|
: isGraphRunning
|
||||||
|
? "Stop agent"
|
||||||
|
: "Run agent"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<RunInputDialog
|
<RunInputDialog
|
||||||
|
|||||||
@@ -61,63 +61,67 @@ export const RunInputDialog = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
set: setIsOpen,
|
set: setIsOpen,
|
||||||
}}
|
}}
|
||||||
styling={{ maxWidth: "600px", minWidth: "600px" }}
|
styling={{ maxWidth: "700px", minWidth: "700px" }}
|
||||||
>
|
>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<div className="space-y-6 p-1" data-id="run-input-dialog-content">
|
<div
|
||||||
{/* Credentials Section */}
|
className="grid grid-cols-[1fr_auto] gap-10 p-1"
|
||||||
{hasCredentials() && credentialFields.length > 0 && (
|
data-id="run-input-dialog-content"
|
||||||
<div data-id="run-input-credentials-section">
|
>
|
||||||
<div className="mb-4">
|
<div className="space-y-6">
|
||||||
<Text variant="h4" className="text-gray-900">
|
{/* Credentials Section */}
|
||||||
Credentials
|
{hasCredentials() && credentialFields.length > 0 && (
|
||||||
</Text>
|
<div data-id="run-input-credentials-section">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Text variant="h4" className="text-gray-900">
|
||||||
|
Credentials
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="px-2" data-id="run-input-credentials-form">
|
||||||
|
<CredentialsGroupedView
|
||||||
|
credentialFields={credentialFields}
|
||||||
|
requiredCredentials={requiredCredentials}
|
||||||
|
inputCredentials={credentialValues}
|
||||||
|
inputValues={inputValues}
|
||||||
|
onCredentialChange={handleCredentialFieldChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2" data-id="run-input-credentials-form">
|
)}
|
||||||
<CredentialsGroupedView
|
|
||||||
credentialFields={credentialFields}
|
|
||||||
requiredCredentials={requiredCredentials}
|
|
||||||
inputCredentials={credentialValues}
|
|
||||||
inputValues={inputValues}
|
|
||||||
onCredentialChange={handleCredentialFieldChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inputs Section */}
|
{/* Inputs Section */}
|
||||||
{hasInputs() && (
|
{hasInputs() && (
|
||||||
<div data-id="run-input-inputs-section">
|
<div data-id="run-input-inputs-section">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Text variant="h4" className="text-gray-900">
|
<Text variant="h4" className="text-gray-900">
|
||||||
Inputs
|
Inputs
|
||||||
</Text>
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div data-id="run-input-inputs-form">
|
||||||
|
<FormRenderer
|
||||||
|
jsonSchema={inputSchema as RJSFSchema}
|
||||||
|
handleChange={(v) => handleInputChange(v.formData)}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
initialValues={{}}
|
||||||
|
formContext={{
|
||||||
|
showHandles: false,
|
||||||
|
size: "large",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-id="run-input-inputs-form">
|
)}
|
||||||
<FormRenderer
|
</div>
|
||||||
jsonSchema={inputSchema as RJSFSchema}
|
|
||||||
handleChange={(v) => handleInputChange(v.formData)}
|
|
||||||
uiSchema={uiSchema}
|
|
||||||
initialValues={{}}
|
|
||||||
formContext={{
|
|
||||||
showHandles: false,
|
|
||||||
size: "large",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<div
|
<div
|
||||||
className="flex justify-end pt-2"
|
className="flex flex-col items-end justify-start"
|
||||||
data-id="run-input-actions-section"
|
data-id="run-input-actions-section"
|
||||||
>
|
>
|
||||||
{purpose === "run" && (
|
{purpose === "run" && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="large"
|
size="large"
|
||||||
className="group h-fit min-w-0 gap-2"
|
className="group h-fit min-w-0 gap-2 px-10"
|
||||||
onClick={handleManualRun}
|
onClick={handleManualRun}
|
||||||
loading={isExecutingGraph}
|
loading={isExecutingGraph}
|
||||||
data-id="run-input-manual-run-button"
|
data-id="run-input-manual-run-button"
|
||||||
@@ -132,7 +136,7 @@ export const RunInputDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="large"
|
size="large"
|
||||||
className="group h-fit min-w-0 gap-2"
|
className="group h-fit min-w-0 gap-2 px-10"
|
||||||
onClick={() => setOpenCronSchedulerDialog(true)}
|
onClick={() => setOpenCronSchedulerDialog(true)}
|
||||||
data-id="run-input-schedule-button"
|
data-id="run-input-schedule-button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -53,14 +53,14 @@ export const CustomControls = memo(
|
|||||||
const controls = [
|
const controls = [
|
||||||
{
|
{
|
||||||
id: "zoom-in-button",
|
id: "zoom-in-button",
|
||||||
icon: <PlusIcon className="size-4" />,
|
icon: <PlusIcon className="size-3.5 text-zinc-600" />,
|
||||||
label: "Zoom In",
|
label: "Zoom In",
|
||||||
onClick: () => zoomIn(),
|
onClick: () => zoomIn(),
|
||||||
className: "h-10 w-10 border-none",
|
className: "h-10 w-10 border-none",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "zoom-out-button",
|
id: "zoom-out-button",
|
||||||
icon: <MinusIcon className="size-4" />,
|
icon: <MinusIcon className="size-3.5 text-zinc-600" />,
|
||||||
label: "Zoom Out",
|
label: "Zoom Out",
|
||||||
onClick: () => zoomOut(),
|
onClick: () => zoomOut(),
|
||||||
className: "h-10 w-10 border-none",
|
className: "h-10 w-10 border-none",
|
||||||
@@ -68,9 +68,9 @@ export const CustomControls = memo(
|
|||||||
{
|
{
|
||||||
id: "tutorial-button",
|
id: "tutorial-button",
|
||||||
icon: isTutorialLoading ? (
|
icon: isTutorialLoading ? (
|
||||||
<CircleNotchIcon className="size-4 animate-spin" />
|
<CircleNotchIcon className="size-3.5 animate-spin text-zinc-600" />
|
||||||
) : (
|
) : (
|
||||||
<ChalkboardIcon className="size-4" />
|
<ChalkboardIcon className="size-3.5 text-zinc-600" />
|
||||||
),
|
),
|
||||||
label: isTutorialLoading ? "Loading Tutorial..." : "Start Tutorial",
|
label: isTutorialLoading ? "Loading Tutorial..." : "Start Tutorial",
|
||||||
onClick: handleTutorialClick,
|
onClick: handleTutorialClick,
|
||||||
@@ -79,7 +79,7 @@ export const CustomControls = memo(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "fit-view-button",
|
id: "fit-view-button",
|
||||||
icon: <FrameCornersIcon className="size-4" />,
|
icon: <FrameCornersIcon className="size-3.5 text-zinc-600" />,
|
||||||
label: "Fit View",
|
label: "Fit View",
|
||||||
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
|
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
|
||||||
className: "h-10 w-10 border-none",
|
className: "h-10 w-10 border-none",
|
||||||
@@ -87,9 +87,9 @@ export const CustomControls = memo(
|
|||||||
{
|
{
|
||||||
id: "lock-button",
|
id: "lock-button",
|
||||||
icon: !isLocked ? (
|
icon: !isLocked ? (
|
||||||
<LockOpenIcon className="size-4" />
|
<LockOpenIcon className="size-3.5 text-zinc-600" />
|
||||||
) : (
|
) : (
|
||||||
<LockIcon className="size-4" />
|
<LockIcon className="size-3.5 text-zinc-600" />
|
||||||
),
|
),
|
||||||
label: "Toggle Lock",
|
label: "Toggle Lock",
|
||||||
onClick: () => setIsLocked(!isLocked),
|
onClick: () => setIsLocked(!isLocked),
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export type CustomEdgeData = {
|
|||||||
beadUp?: number;
|
beadUp?: number;
|
||||||
beadDown?: number;
|
beadDown?: number;
|
||||||
beadData?: Map<string, NodeExecutionResult["status"]>;
|
beadData?: Map<string, NodeExecutionResult["status"]>;
|
||||||
|
edgeColorClass?: string;
|
||||||
|
edgeHexColor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomEdge = XYEdge<CustomEdgeData, "custom">;
|
export type CustomEdge = XYEdge<CustomEdgeData, "custom">;
|
||||||
@@ -36,7 +38,6 @@ const CustomEdge = ({
|
|||||||
selected,
|
selected,
|
||||||
}: EdgeProps<CustomEdge>) => {
|
}: EdgeProps<CustomEdge>) => {
|
||||||
const removeConnection = useEdgeStore((state) => state.removeEdge);
|
const removeConnection = useEdgeStore((state) => state.removeEdge);
|
||||||
// Subscribe to the brokenEdgeIDs map and check if this edge is broken across any node
|
|
||||||
const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
|
const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ const CustomEdge = ({
|
|||||||
const isStatic = data?.isStatic ?? false;
|
const isStatic = data?.isStatic ?? false;
|
||||||
const beadUp = data?.beadUp ?? 0;
|
const beadUp = data?.beadUp ?? 0;
|
||||||
const beadDown = data?.beadDown ?? 0;
|
const beadDown = data?.beadDown ?? 0;
|
||||||
|
const edgeColorClass = data?.edgeColorClass;
|
||||||
|
|
||||||
const handleRemoveEdge = () => {
|
const handleRemoveEdge = () => {
|
||||||
removeConnection(id);
|
removeConnection(id);
|
||||||
@@ -70,7 +72,9 @@ const CustomEdge = ({
|
|||||||
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
|
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
|
||||||
: selected
|
: selected
|
||||||
? "stroke-zinc-800"
|
? "stroke-zinc-800"
|
||||||
: "stroke-zinc-500/50 hover:stroke-zinc-500",
|
: edgeColorClass
|
||||||
|
? cn(edgeColorClass, "opacity-70 hover:opacity-100")
|
||||||
|
: "stroke-zinc-500/50 hover:stroke-zinc-500",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<JSBeads
|
<JSBeads
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useCallback } from "react";
|
|||||||
import { useNodeStore } from "../../../stores/nodeStore";
|
import { useNodeStore } from "../../../stores/nodeStore";
|
||||||
import { useHistoryStore } from "../../../stores/historyStore";
|
import { useHistoryStore } from "../../../stores/historyStore";
|
||||||
import { CustomEdge } from "./CustomEdge";
|
import { CustomEdge } from "./CustomEdge";
|
||||||
|
import { getEdgeColorFromOutputType } from "../nodes/helpers";
|
||||||
|
|
||||||
export const useCustomEdge = () => {
|
export const useCustomEdge = () => {
|
||||||
const edges = useEdgeStore((s) => s.edges);
|
const edges = useEdgeStore((s) => s.edges);
|
||||||
@@ -34,8 +35,13 @@ export const useCustomEdge = () => {
|
|||||||
if (exists) return;
|
if (exists) return;
|
||||||
|
|
||||||
const nodes = useNodeStore.getState().nodes;
|
const nodes = useNodeStore.getState().nodes;
|
||||||
const isStatic = nodes.find((n) => n.id === conn.source)?.data
|
const sourceNode = nodes.find((n) => n.id === conn.source);
|
||||||
?.staticOutput;
|
const isStatic = sourceNode?.data?.staticOutput;
|
||||||
|
|
||||||
|
const { colorClass, hexColor } = getEdgeColorFromOutputType(
|
||||||
|
sourceNode?.data?.outputSchema,
|
||||||
|
conn.sourceHandle,
|
||||||
|
);
|
||||||
|
|
||||||
addEdge({
|
addEdge({
|
||||||
source: conn.source,
|
source: conn.source,
|
||||||
@@ -44,6 +50,8 @@ export const useCustomEdge = () => {
|
|||||||
targetHandle: conn.targetHandle,
|
targetHandle: conn.targetHandle,
|
||||||
data: {
|
data: {
|
||||||
isStatic,
|
isStatic,
|
||||||
|
edgeColorClass: colorClass,
|
||||||
|
edgeHexColor: hexColor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -187,3 +187,38 @@ export const getTypeDisplayInfo = (schema: any) => {
|
|||||||
hexColor,
|
hexColor,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getEdgeColorFromOutputType(
|
||||||
|
outputSchema: RJSFSchema | undefined,
|
||||||
|
sourceHandle: string,
|
||||||
|
): { colorClass: string; hexColor: string } {
|
||||||
|
const defaultColor = {
|
||||||
|
colorClass: "stroke-zinc-500/50",
|
||||||
|
hexColor: "#6b7280",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!outputSchema?.properties) return defaultColor;
|
||||||
|
|
||||||
|
const properties = outputSchema.properties as Record<string, unknown>;
|
||||||
|
const handleParts = sourceHandle.split("_#_");
|
||||||
|
let currentSchema: Record<string, unknown> = properties;
|
||||||
|
|
||||||
|
for (let i = 0; i < handleParts.length; i++) {
|
||||||
|
const part = handleParts[i];
|
||||||
|
const fieldSchema = currentSchema[part] as Record<string, unknown>;
|
||||||
|
if (!fieldSchema) return defaultColor;
|
||||||
|
|
||||||
|
if (i === handleParts.length - 1) {
|
||||||
|
const { hexColor, colorClass } = getTypeDisplayInfo(fieldSchema);
|
||||||
|
return { colorClass: colorClass.replace("!text-", "stroke-"), hexColor };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldSchema.properties) {
|
||||||
|
currentSchema = fieldSchema.properties as Record<string, unknown>;
|
||||||
|
} else {
|
||||||
|
return defaultColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultColor;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
// These are SVG Phosphor icons
|
type IconOptions = {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SIZE = 16;
|
||||||
|
const DEFAULT_COLOR = "#52525b"; // zinc-600
|
||||||
|
|
||||||
|
const iconPaths = {
|
||||||
|
ClickIcon: `M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z`,
|
||||||
|
Keyboard: `M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z`,
|
||||||
|
Drag: `M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createIcon(path: string, options: IconOptions = {}): string {
|
||||||
|
const size = options.size ?? DEFAULT_SIZE;
|
||||||
|
const color = options.color ?? DEFAULT_COLOR;
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" fill="${color}" viewBox="0 0 256 256"><path d="${path}"></path></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
export const ICONS = {
|
export const ICONS = {
|
||||||
ClickIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z"></path></svg>`,
|
ClickIcon: createIcon(iconPaths.ClickIcon),
|
||||||
Keyboard: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z"></path></svg>`,
|
Keyboard: createIcon(iconPaths.Keyboard),
|
||||||
Drag: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z"></path></svg>`,
|
Drag: createIcon(iconPaths.Drag),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getIcon(
|
||||||
|
name: keyof typeof iconPaths,
|
||||||
|
options?: IconOptions,
|
||||||
|
): string {
|
||||||
|
return createIcon(iconPaths[name], options);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
import { useNodeStore } from "../../../stores/nodeStore";
|
import { useNodeStore } from "../../../stores/nodeStore";
|
||||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||||
|
import { useTutorialStore } from "../../../stores/tutorialStore";
|
||||||
|
|
||||||
let isTutorialLoading = false;
|
let isTutorialLoading = false;
|
||||||
let tutorialLoadingCallback: ((loading: boolean) => void) | null = null;
|
let tutorialLoadingCallback: ((loading: boolean) => void) | null = null;
|
||||||
@@ -60,12 +61,14 @@ export const startTutorial = async () => {
|
|||||||
handleTutorialComplete();
|
handleTutorialComplete();
|
||||||
removeTutorialStyles();
|
removeTutorialStyles();
|
||||||
clearPrefetchedBlocks();
|
clearPrefetchedBlocks();
|
||||||
|
useTutorialStore.getState().setIsTutorialRunning(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
tour.on("cancel", () => {
|
tour.on("cancel", () => {
|
||||||
handleTutorialCancel(tour);
|
handleTutorialCancel(tour);
|
||||||
removeTutorialStyles();
|
removeTutorialStyles();
|
||||||
clearPrefetchedBlocks();
|
clearPrefetchedBlocks();
|
||||||
|
useTutorialStore.getState().setIsTutorialRunning(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const step of tour.steps) {
|
for (const step of tour.steps) {
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { List } from "@phosphor-icons/react";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
|
||||||
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
|
|
||||||
import { ChatLoadingState } from "./components/ChatLoadingState/ChatLoadingState";
|
|
||||||
import { SessionsDrawer } from "./components/SessionsDrawer/SessionsDrawer";
|
|
||||||
import { useChat } from "./useChat";
|
|
||||||
|
|
||||||
export interface ChatProps {
|
|
||||||
className?: string;
|
|
||||||
headerTitle?: React.ReactNode;
|
|
||||||
showHeader?: boolean;
|
|
||||||
showSessionInfo?: boolean;
|
|
||||||
showNewChatButton?: boolean;
|
|
||||||
onNewChat?: () => void;
|
|
||||||
headerActions?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Chat({
|
|
||||||
className,
|
|
||||||
headerTitle = "AutoGPT Copilot",
|
|
||||||
showHeader = true,
|
|
||||||
showSessionInfo = true,
|
|
||||||
showNewChatButton = true,
|
|
||||||
onNewChat,
|
|
||||||
headerActions,
|
|
||||||
}: ChatProps) {
|
|
||||||
const {
|
|
||||||
messages,
|
|
||||||
isLoading,
|
|
||||||
isCreating,
|
|
||||||
error,
|
|
||||||
sessionId,
|
|
||||||
createSession,
|
|
||||||
clearSession,
|
|
||||||
loadSession,
|
|
||||||
} = useChat();
|
|
||||||
|
|
||||||
const [isSessionsDrawerOpen, setIsSessionsDrawerOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleNewChat = () => {
|
|
||||||
clearSession();
|
|
||||||
onNewChat?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectSession = async (sessionId: string) => {
|
|
||||||
try {
|
|
||||||
await loadSession(sessionId);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load session:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex h-full flex-col", className)}>
|
|
||||||
{/* Header */}
|
|
||||||
{showHeader && (
|
|
||||||
<header className="shrink-0 border-t border-zinc-200 bg-white p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
aria-label="View sessions"
|
|
||||||
onClick={() => setIsSessionsDrawerOpen(true)}
|
|
||||||
className="flex size-8 items-center justify-center rounded hover:bg-zinc-100"
|
|
||||||
>
|
|
||||||
<List width="1.25rem" height="1.25rem" />
|
|
||||||
</button>
|
|
||||||
{typeof headerTitle === "string" ? (
|
|
||||||
<Text variant="h2" className="text-lg font-semibold">
|
|
||||||
{headerTitle}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
headerTitle
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{showSessionInfo && sessionId && (
|
|
||||||
<>
|
|
||||||
{showNewChatButton && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
onClick={handleNewChat}
|
|
||||||
>
|
|
||||||
New Chat
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{headerActions}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
||||||
{/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */}
|
|
||||||
{(isLoading || isCreating || (!sessionId && !error)) && (
|
|
||||||
<ChatLoadingState
|
|
||||||
message={isCreating ? "Creating session..." : "Loading..."}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error State */}
|
|
||||||
{error && !isLoading && (
|
|
||||||
<ChatErrorState error={error} onRetry={createSession} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Session Content */}
|
|
||||||
{sessionId && !isLoading && !error && (
|
|
||||||
<ChatContainer
|
|
||||||
sessionId={sessionId}
|
|
||||||
initialMessages={messages}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Sessions Drawer */}
|
|
||||||
<SessionsDrawer
|
|
||||||
isOpen={isSessionsDrawerOpen}
|
|
||||||
onClose={() => setIsSessionsDrawerOpen(false)}
|
|
||||||
onSelectSession={handleSelectSession}
|
|
||||||
currentSessionId={sessionId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Input } from "@/components/atoms/Input/Input";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ArrowUpIcon } from "@phosphor-icons/react";
|
|
||||||
import { useChatInput } from "./useChatInput";
|
|
||||||
|
|
||||||
export interface ChatInputProps {
|
|
||||||
onSend: (message: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatInput({
|
|
||||||
onSend,
|
|
||||||
disabled = false,
|
|
||||||
placeholder = "Type your message...",
|
|
||||||
className,
|
|
||||||
}: ChatInputProps) {
|
|
||||||
const inputId = "chat-input";
|
|
||||||
const { value, setValue, handleKeyDown, handleSend } = useChatInput({
|
|
||||||
onSend,
|
|
||||||
disabled,
|
|
||||||
maxRows: 5,
|
|
||||||
inputId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("relative flex-1", className)}>
|
|
||||||
<Input
|
|
||||||
id={inputId}
|
|
||||||
label="Chat message input"
|
|
||||||
hideLabel
|
|
||||||
type="textarea"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
rows={1}
|
|
||||||
wrapperClassName="mb-0 relative"
|
|
||||||
className="pr-12"
|
|
||||||
/>
|
|
||||||
<span id="chat-input-hint" className="sr-only">
|
|
||||||
Press Enter to send, Shift+Enter for new line
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={disabled || !value.trim()}
|
|
||||||
className={cn(
|
|
||||||
"absolute right-3 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full",
|
|
||||||
"border border-zinc-800 bg-zinc-800 text-white",
|
|
||||||
"hover:border-zinc-900 hover:bg-zinc-900",
|
|
||||||
"disabled:border-zinc-200 disabled:bg-zinc-200 disabled:text-white disabled:opacity-50",
|
|
||||||
"transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950",
|
|
||||||
"disabled:pointer-events-none",
|
|
||||||
)}
|
|
||||||
aria-label="Send message"
|
|
||||||
>
|
|
||||||
<ArrowUpIcon className="h-3 w-3" weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { KeyboardEvent, useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface UseChatInputArgs {
|
|
||||||
onSend: (message: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
maxRows?: number;
|
|
||||||
inputId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatInput({
|
|
||||||
onSend,
|
|
||||||
disabled = false,
|
|
||||||
maxRows = 5,
|
|
||||||
inputId = "chat-input",
|
|
||||||
}: UseChatInputArgs) {
|
|
||||||
const [value, setValue] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
|
||||||
if (!textarea) return;
|
|
||||||
textarea.style.height = "auto";
|
|
||||||
const lineHeight = parseInt(
|
|
||||||
window.getComputedStyle(textarea).lineHeight,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const maxHeight = lineHeight * maxRows;
|
|
||||||
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
|
|
||||||
textarea.style.height = `${newHeight}px`;
|
|
||||||
textarea.style.overflowY =
|
|
||||||
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
|
||||||
}, [value, maxRows, inputId]);
|
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
|
||||||
if (disabled || !value.trim()) return;
|
|
||||||
onSend(value.trim());
|
|
||||||
setValue("");
|
|
||||||
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
|
||||||
if (textarea) {
|
|
||||||
textarea.style.height = "auto";
|
|
||||||
}
|
|
||||||
}, [value, onSend, disabled, inputId]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
// Shift+Enter allows default behavior (new line) - no need to handle explicitly
|
|
||||||
},
|
|
||||||
[handleSend],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
value,
|
|
||||||
setValue,
|
|
||||||
handleKeyDown,
|
|
||||||
handleSend,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ChatMessage } from "../ChatMessage/ChatMessage";
|
|
||||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
|
||||||
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
|
|
||||||
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
|
|
||||||
import { useMessageList } from "./useMessageList";
|
|
||||||
|
|
||||||
export interface MessageListProps {
|
|
||||||
messages: ChatMessageData[];
|
|
||||||
streamingChunks?: string[];
|
|
||||||
isStreaming?: boolean;
|
|
||||||
className?: string;
|
|
||||||
onStreamComplete?: () => void;
|
|
||||||
onSendMessage?: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageList({
|
|
||||||
messages,
|
|
||||||
streamingChunks = [],
|
|
||||||
isStreaming = false,
|
|
||||||
className,
|
|
||||||
onStreamComplete,
|
|
||||||
onSendMessage,
|
|
||||||
}: MessageListProps) {
|
|
||||||
const { messagesEndRef, messagesContainerRef } = useMessageList({
|
|
||||||
messageCount: messages.length,
|
|
||||||
isStreaming,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={messagesContainerRef}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 overflow-y-auto",
|
|
||||||
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="mx-auto flex max-w-3xl flex-col py-4">
|
|
||||||
{/* Render all persisted messages */}
|
|
||||||
{messages.map((message, index) => {
|
|
||||||
// Check if current message is an agent_output tool_response
|
|
||||||
// and if previous message is an assistant message
|
|
||||||
let agentOutput: ChatMessageData | undefined;
|
|
||||||
|
|
||||||
if (message.type === "tool_response" && message.result) {
|
|
||||||
let parsedResult: Record<string, unknown> | null = null;
|
|
||||||
try {
|
|
||||||
parsedResult =
|
|
||||||
typeof message.result === "string"
|
|
||||||
? JSON.parse(message.result)
|
|
||||||
: (message.result as Record<string, unknown>);
|
|
||||||
} catch {
|
|
||||||
parsedResult = null;
|
|
||||||
}
|
|
||||||
if (parsedResult?.type === "agent_output") {
|
|
||||||
const prevMessage = messages[index - 1];
|
|
||||||
if (
|
|
||||||
prevMessage &&
|
|
||||||
prevMessage.type === "message" &&
|
|
||||||
prevMessage.role === "assistant"
|
|
||||||
) {
|
|
||||||
// This agent output will be rendered inside the previous assistant message
|
|
||||||
// Skip rendering this message separately
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if next message is an agent_output tool_response to include in current assistant message
|
|
||||||
if (message.type === "message" && message.role === "assistant") {
|
|
||||||
const nextMessage = messages[index + 1];
|
|
||||||
if (
|
|
||||||
nextMessage &&
|
|
||||||
nextMessage.type === "tool_response" &&
|
|
||||||
nextMessage.result
|
|
||||||
) {
|
|
||||||
let parsedResult: Record<string, unknown> | null = null;
|
|
||||||
try {
|
|
||||||
parsedResult =
|
|
||||||
typeof nextMessage.result === "string"
|
|
||||||
? JSON.parse(nextMessage.result)
|
|
||||||
: (nextMessage.result as Record<string, unknown>);
|
|
||||||
} catch {
|
|
||||||
parsedResult = null;
|
|
||||||
}
|
|
||||||
if (parsedResult?.type === "agent_output") {
|
|
||||||
agentOutput = nextMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatMessage
|
|
||||||
key={index}
|
|
||||||
message={message}
|
|
||||||
onSendMessage={onSendMessage}
|
|
||||||
agentOutput={agentOutput}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Render thinking message when streaming but no chunks yet */}
|
|
||||||
{isStreaming && streamingChunks.length === 0 && <ThinkingMessage />}
|
|
||||||
|
|
||||||
{/* Render streaming message if active */}
|
|
||||||
{isStreaming && streamingChunks.length > 0 && (
|
|
||||||
<StreamingMessage
|
|
||||||
chunks={streamingChunks}
|
|
||||||
onComplete={onStreamComplete}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Invisible div to scroll to */}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { WrenchIcon } from "@phosphor-icons/react";
|
|
||||||
import { getToolActionPhrase } from "../../helpers";
|
|
||||||
|
|
||||||
export interface ToolCallMessageProps {
|
|
||||||
toolName: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToolCallMessage({ toolName, className }: ToolCallMessageProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
|
||||||
<WrenchIcon
|
|
||||||
size={14}
|
|
||||||
weight="bold"
|
|
||||||
className="flex-shrink-0 text-neutral-500"
|
|
||||||
/>
|
|
||||||
<Text variant="small" className="text-neutral-500">
|
|
||||||
{getToolActionPhrase(toolName)}...
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import "@/components/contextual/OutputRenderers";
|
|
||||||
import {
|
|
||||||
globalRegistry,
|
|
||||||
OutputItem,
|
|
||||||
} from "@/components/contextual/OutputRenderers";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { ToolResult } from "@/types/chat";
|
|
||||||
import { WrenchIcon } from "@phosphor-icons/react";
|
|
||||||
import { getToolActionPhrase } from "../../helpers";
|
|
||||||
|
|
||||||
export interface ToolResponseMessageProps {
|
|
||||||
toolName: string;
|
|
||||||
result?: ToolResult;
|
|
||||||
success?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToolResponseMessage({
|
|
||||||
toolName,
|
|
||||||
result,
|
|
||||||
success: _success = true,
|
|
||||||
className,
|
|
||||||
}: ToolResponseMessageProps) {
|
|
||||||
if (!result) {
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
|
||||||
<WrenchIcon
|
|
||||||
size={14}
|
|
||||||
weight="bold"
|
|
||||||
className="flex-shrink-0 text-neutral-500"
|
|
||||||
/>
|
|
||||||
<Text variant="small" className="text-neutral-500">
|
|
||||||
{getToolActionPhrase(toolName)}...
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedResult: Record<string, unknown> | null = null;
|
|
||||||
try {
|
|
||||||
parsedResult =
|
|
||||||
typeof result === "string"
|
|
||||||
? JSON.parse(result)
|
|
||||||
: (result as Record<string, unknown>);
|
|
||||||
} catch {
|
|
||||||
parsedResult = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedResult && typeof parsedResult === "object") {
|
|
||||||
const responseType = parsedResult.type as string | undefined;
|
|
||||||
|
|
||||||
if (responseType === "agent_output") {
|
|
||||||
const execution = parsedResult.execution as
|
|
||||||
| {
|
|
||||||
outputs?: Record<string, unknown[]>;
|
|
||||||
}
|
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
const outputs = execution?.outputs || {};
|
|
||||||
const message = parsedResult.message as string | undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("space-y-4 px-4 py-2", className)}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<WrenchIcon
|
|
||||||
size={14}
|
|
||||||
weight="bold"
|
|
||||||
className="flex-shrink-0 text-neutral-500"
|
|
||||||
/>
|
|
||||||
<Text variant="small" className="text-neutral-500">
|
|
||||||
{getToolActionPhrase(toolName)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
{message && (
|
|
||||||
<div className="rounded border p-4">
|
|
||||||
<Text variant="small" className="text-neutral-600">
|
|
||||||
{message}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{Object.keys(outputs).length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{Object.entries(outputs).map(([outputName, values]) =>
|
|
||||||
values.map((value, index) => {
|
|
||||||
const renderer = globalRegistry.getRenderer(value);
|
|
||||||
if (renderer) {
|
|
||||||
return (
|
|
||||||
<OutputItem
|
|
||||||
key={`${outputName}-${index}`}
|
|
||||||
value={value}
|
|
||||||
renderer={renderer}
|
|
||||||
label={outputName}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${outputName}-${index}`}
|
|
||||||
className="rounded border p-4"
|
|
||||||
>
|
|
||||||
<Text variant="large-medium" className="mb-2 capitalize">
|
|
||||||
{outputName}
|
|
||||||
</Text>
|
|
||||||
<pre className="overflow-auto text-sm">
|
|
||||||
{JSON.stringify(value, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responseType === "block_output" && parsedResult.outputs) {
|
|
||||||
const outputs = parsedResult.outputs as Record<string, unknown[]>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("space-y-4 px-4 py-2", className)}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<WrenchIcon
|
|
||||||
size={14}
|
|
||||||
weight="bold"
|
|
||||||
className="flex-shrink-0 text-neutral-500"
|
|
||||||
/>
|
|
||||||
<Text variant="small" className="text-neutral-500">
|
|
||||||
{getToolActionPhrase(toolName)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{Object.entries(outputs).map(([outputName, values]) =>
|
|
||||||
values.map((value, index) => {
|
|
||||||
const renderer = globalRegistry.getRenderer(value);
|
|
||||||
if (renderer) {
|
|
||||||
return (
|
|
||||||
<OutputItem
|
|
||||||
key={`${outputName}-${index}`}
|
|
||||||
value={value}
|
|
||||||
renderer={renderer}
|
|
||||||
label={outputName}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${outputName}-${index}`}
|
|
||||||
className="rounded border p-4"
|
|
||||||
>
|
|
||||||
<Text variant="large-medium" className="mb-2 capitalize">
|
|
||||||
{outputName}
|
|
||||||
</Text>
|
|
||||||
<pre className="overflow-auto text-sm">
|
|
||||||
{JSON.stringify(value, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle other response types with a message field (e.g., understanding_updated)
|
|
||||||
if (parsedResult.message && typeof parsedResult.message === "string") {
|
|
||||||
// Format tool name from snake_case to Title Case
|
|
||||||
const formattedToolName = toolName
|
|
||||||
.split("_")
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
// Clean up message - remove incomplete user_name references
|
|
||||||
let cleanedMessage = parsedResult.message;
|
|
||||||
// Remove "Updated understanding with: user_name" pattern if user_name is just a placeholder
|
|
||||||
cleanedMessage = cleanedMessage.replace(
|
|
||||||
/Updated understanding with:\s*user_name\.?\s*/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
// Remove standalone user_name references
|
|
||||||
cleanedMessage = cleanedMessage.replace(/\buser_name\b\.?\s*/gi, "");
|
|
||||||
cleanedMessage = cleanedMessage.trim();
|
|
||||||
|
|
||||||
// Only show message if it has content after cleaning
|
|
||||||
if (!cleanedMessage) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center gap-2 px-4 py-2",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<WrenchIcon
|
|
||||||
size={14}
|
|
||||||
weight="bold"
|
|
||||||
className="flex-shrink-0 text-neutral-500"
|
|
||||||
/>
|
|
||||||
<Text variant="small" className="text-neutral-500">
|
|
||||||
{formattedToolName}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("space-y-2 px-4 py-2", className)}>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<WrenchIcon
|
|
||||||
size={14}
|
|
||||||
weight="bold"
|
|
||||||
className="flex-shrink-0 text-neutral-500"
|
|
||||||
/>
|
|
||||||
<Text variant="small" className="text-neutral-500">
|
|
||||||
{formattedToolName}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div className="rounded border p-4">
|
|
||||||
<Text variant="small" className="text-neutral-600">
|
|
||||||
{cleanedMessage}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderer = globalRegistry.getRenderer(result);
|
|
||||||
if (renderer) {
|
|
||||||
return (
|
|
||||||
<div className={cn("px-4 py-2", className)}>
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<WrenchIcon
|
|
||||||
size={14}
|
|
||||||
weight="bold"
|
|
||||||
className="flex-shrink-0 text-neutral-500"
|
|
||||||
/>
|
|
||||||
<Text variant="small" className="text-neutral-500">
|
|
||||||
{getToolActionPhrase(toolName)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<OutputItem value={result} renderer={renderer} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
|
||||||
<WrenchIcon
|
|
||||||
size={14}
|
|
||||||
weight="bold"
|
|
||||||
className="flex-shrink-0 text-neutral-500"
|
|
||||||
/>
|
|
||||||
<Text variant="small" className="text-neutral-500">
|
|
||||||
{getToolActionPhrase(toolName)}...
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
/**
|
|
||||||
* Maps internal tool names to user-friendly display names with emojis.
|
|
||||||
* @deprecated Use getToolActionPhrase or getToolCompletionPhrase for status messages
|
|
||||||
*
|
|
||||||
* @param toolName - The internal tool name from the backend
|
|
||||||
* @returns A user-friendly display name with an emoji prefix
|
|
||||||
*/
|
|
||||||
export function getToolDisplayName(toolName: string): string {
|
|
||||||
const toolDisplayNames: Record<string, string> = {
|
|
||||||
find_agent: "🔍 Search Marketplace",
|
|
||||||
get_agent_details: "📋 Get Agent Details",
|
|
||||||
check_credentials: "🔑 Check Credentials",
|
|
||||||
setup_agent: "⚙️ Setup Agent",
|
|
||||||
run_agent: "▶️ Run Agent",
|
|
||||||
get_required_setup_info: "📝 Get Setup Requirements",
|
|
||||||
};
|
|
||||||
return toolDisplayNames[toolName] || toolName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps internal tool names to human-friendly action phrases (present continuous).
|
|
||||||
* Used for tool call messages to indicate what action is currently happening.
|
|
||||||
*
|
|
||||||
* @param toolName - The internal tool name from the backend
|
|
||||||
* @returns A human-friendly action phrase in present continuous tense
|
|
||||||
*/
|
|
||||||
export function getToolActionPhrase(toolName: string): string {
|
|
||||||
const toolActionPhrases: Record<string, string> = {
|
|
||||||
find_agent: "Looking for agents in the marketplace",
|
|
||||||
agent_carousel: "Looking for agents in the marketplace",
|
|
||||||
get_agent_details: "Learning about the agent",
|
|
||||||
check_credentials: "Checking your credentials",
|
|
||||||
setup_agent: "Setting up the agent",
|
|
||||||
execution_started: "Running the agent",
|
|
||||||
run_agent: "Running the agent",
|
|
||||||
get_required_setup_info: "Getting setup requirements",
|
|
||||||
schedule_agent: "Scheduling the agent to run",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Return mapped phrase or generate human-friendly fallback
|
|
||||||
return toolActionPhrases[toolName] || toolName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps internal tool names to human-friendly completion phrases (past tense).
|
|
||||||
* Used for tool response messages to indicate what action was completed.
|
|
||||||
*
|
|
||||||
* @param toolName - The internal tool name from the backend
|
|
||||||
* @returns A human-friendly completion phrase in past tense
|
|
||||||
*/
|
|
||||||
export function getToolCompletionPhrase(toolName: string): string {
|
|
||||||
const toolCompletionPhrases: Record<string, string> = {
|
|
||||||
find_agent: "Finished searching the marketplace",
|
|
||||||
get_agent_details: "Got agent details",
|
|
||||||
check_credentials: "Checked credentials",
|
|
||||||
setup_agent: "Agent setup complete",
|
|
||||||
run_agent: "Agent execution started",
|
|
||||||
get_required_setup_info: "Got setup requirements",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Return mapped phrase or generate human-friendly fallback
|
|
||||||
return (
|
|
||||||
toolCompletionPhrases[toolName] ||
|
|
||||||
`Finished ${toolName.replace(/_/g, " ").replace("...", "")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import {
|
|
||||||
getGetV2GetSessionQueryKey,
|
|
||||||
getGetV2GetSessionQueryOptions,
|
|
||||||
postV2CreateSession,
|
|
||||||
useGetV2GetSession,
|
|
||||||
usePatchV2SessionAssignUser,
|
|
||||||
usePostV2CreateSession,
|
|
||||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
|
||||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
|
||||||
import { okData } from "@/app/api/helpers";
|
|
||||||
import { isValidUUID } from "@/lib/utils";
|
|
||||||
import { Key, storage } from "@/services/storage/local-storage";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface UseChatSessionArgs {
|
|
||||||
urlSessionId?: string | null;
|
|
||||||
autoCreate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatSession({
|
|
||||||
urlSessionId,
|
|
||||||
autoCreate = false,
|
|
||||||
}: UseChatSessionArgs = {}) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const justCreatedSessionIdRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (urlSessionId) {
|
|
||||||
if (!isValidUUID(urlSessionId)) {
|
|
||||||
console.error("Invalid session ID format:", urlSessionId);
|
|
||||||
toast.error("Invalid session ID", {
|
|
||||||
description:
|
|
||||||
"The session ID in the URL is not valid. Starting a new session...",
|
|
||||||
});
|
|
||||||
setSessionId(null);
|
|
||||||
storage.clean(Key.CHAT_SESSION_ID);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSessionId(urlSessionId);
|
|
||||||
storage.set(Key.CHAT_SESSION_ID, urlSessionId);
|
|
||||||
} else {
|
|
||||||
const storedSessionId = storage.get(Key.CHAT_SESSION_ID);
|
|
||||||
if (storedSessionId) {
|
|
||||||
if (!isValidUUID(storedSessionId)) {
|
|
||||||
console.error("Invalid stored session ID:", storedSessionId);
|
|
||||||
storage.clean(Key.CHAT_SESSION_ID);
|
|
||||||
setSessionId(null);
|
|
||||||
} else {
|
|
||||||
setSessionId(storedSessionId);
|
|
||||||
}
|
|
||||||
} else if (autoCreate) {
|
|
||||||
setSessionId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [urlSessionId, autoCreate]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
mutateAsync: createSessionMutation,
|
|
||||||
isPending: isCreating,
|
|
||||||
error: createError,
|
|
||||||
} = usePostV2CreateSession();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: sessionData,
|
|
||||||
isLoading: isLoadingSession,
|
|
||||||
error: loadError,
|
|
||||||
refetch,
|
|
||||||
} = useGetV2GetSession(sessionId || "", {
|
|
||||||
query: {
|
|
||||||
enabled: !!sessionId,
|
|
||||||
select: okData,
|
|
||||||
staleTime: Infinity, // Never mark as stale
|
|
||||||
refetchOnMount: false, // Don't refetch on component mount
|
|
||||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
|
||||||
refetchOnReconnect: false, // Don't refetch when network reconnects
|
|
||||||
retry: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: claimSessionMutation } = usePatchV2SessionAssignUser();
|
|
||||||
|
|
||||||
const session = useMemo(() => {
|
|
||||||
if (sessionData) return sessionData;
|
|
||||||
|
|
||||||
if (sessionId && justCreatedSessionIdRef.current === sessionId) {
|
|
||||||
return {
|
|
||||||
id: sessionId,
|
|
||||||
user_id: null,
|
|
||||||
messages: [],
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
} as SessionDetailResponse;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [sessionData, sessionId]);
|
|
||||||
|
|
||||||
const messages = session?.messages || [];
|
|
||||||
const isLoading = isCreating || isLoadingSession;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (createError) {
|
|
||||||
setError(
|
|
||||||
createError instanceof Error
|
|
||||||
? createError
|
|
||||||
: new Error("Failed to create session"),
|
|
||||||
);
|
|
||||||
} else if (loadError) {
|
|
||||||
setError(
|
|
||||||
loadError instanceof Error
|
|
||||||
? loadError
|
|
||||||
: new Error("Failed to load session"),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}, [createError, loadError]);
|
|
||||||
|
|
||||||
const createSession = useCallback(
|
|
||||||
async function createSession() {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
const response = await postV2CreateSession({
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error("Failed to create session");
|
|
||||||
}
|
|
||||||
const newSessionId = response.data.id;
|
|
||||||
setSessionId(newSessionId);
|
|
||||||
storage.set(Key.CHAT_SESSION_ID, newSessionId);
|
|
||||||
justCreatedSessionIdRef.current = newSessionId;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (justCreatedSessionIdRef.current === newSessionId) {
|
|
||||||
justCreatedSessionIdRef.current = null;
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
return newSessionId;
|
|
||||||
} catch (err) {
|
|
||||||
const error =
|
|
||||||
err instanceof Error ? err : new Error("Failed to create session");
|
|
||||||
setError(error);
|
|
||||||
toast.error("Failed to create chat session", {
|
|
||||||
description: error.message,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[createSessionMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadSession = useCallback(
|
|
||||||
async function loadSession(id: string) {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
// Invalidate the query cache for this session to force a fresh fetch
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2GetSessionQueryKey(id),
|
|
||||||
});
|
|
||||||
// Set sessionId after invalidation to ensure the hook refetches
|
|
||||||
setSessionId(id);
|
|
||||||
storage.set(Key.CHAT_SESSION_ID, id);
|
|
||||||
// Force fetch with fresh data (bypass cache)
|
|
||||||
const queryOptions = getGetV2GetSessionQueryOptions(id, {
|
|
||||||
query: {
|
|
||||||
staleTime: 0, // Force fresh fetch
|
|
||||||
retry: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const result = await queryClient.fetchQuery(queryOptions);
|
|
||||||
if (!result || ("status" in result && result.status !== 200)) {
|
|
||||||
console.warn("Session not found on server, clearing local state");
|
|
||||||
storage.clean(Key.CHAT_SESSION_ID);
|
|
||||||
setSessionId(null);
|
|
||||||
throw new Error("Session not found");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const error =
|
|
||||||
err instanceof Error ? err : new Error("Failed to load session");
|
|
||||||
setError(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshSession = useCallback(
|
|
||||||
async function refreshSession() {
|
|
||||||
if (!sessionId) {
|
|
||||||
console.log("[refreshSession] Skipping - no session ID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
await refetch();
|
|
||||||
} catch (err) {
|
|
||||||
const error =
|
|
||||||
err instanceof Error ? err : new Error("Failed to refresh session");
|
|
||||||
setError(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sessionId, refetch],
|
|
||||||
);
|
|
||||||
|
|
||||||
const claimSession = useCallback(
|
|
||||||
async function claimSession(id: string) {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
await claimSessionMutation({ sessionId: id });
|
|
||||||
if (justCreatedSessionIdRef.current === id) {
|
|
||||||
justCreatedSessionIdRef.current = null;
|
|
||||||
}
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2GetSessionQueryKey(id),
|
|
||||||
});
|
|
||||||
await refetch();
|
|
||||||
toast.success("Session claimed successfully", {
|
|
||||||
description: "Your chat history has been saved to your account",
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const error =
|
|
||||||
err instanceof Error ? err : new Error("Failed to claim session");
|
|
||||||
const is404 =
|
|
||||||
(typeof err === "object" &&
|
|
||||||
err !== null &&
|
|
||||||
"status" in err &&
|
|
||||||
err.status === 404) ||
|
|
||||||
(typeof err === "object" &&
|
|
||||||
err !== null &&
|
|
||||||
"response" in err &&
|
|
||||||
typeof err.response === "object" &&
|
|
||||||
err.response !== null &&
|
|
||||||
"status" in err.response &&
|
|
||||||
err.response.status === 404);
|
|
||||||
if (!is404) {
|
|
||||||
setError(error);
|
|
||||||
toast.error("Failed to claim session", {
|
|
||||||
description: error.message || "Unable to claim session",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[claimSessionMutation, queryClient, refetch],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearSession = useCallback(function clearSession() {
|
|
||||||
setSessionId(null);
|
|
||||||
setError(null);
|
|
||||||
storage.clean(Key.CHAT_SESSION_ID);
|
|
||||||
justCreatedSessionIdRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
session,
|
|
||||||
sessionId,
|
|
||||||
messages,
|
|
||||||
isLoading,
|
|
||||||
isCreating,
|
|
||||||
error,
|
|
||||||
createSession,
|
|
||||||
loadSession,
|
|
||||||
refreshSession,
|
|
||||||
claimSession,
|
|
||||||
clearSession,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Chat } from "./components/Chat/Chat";
|
|
||||||
|
|
||||||
export default function ChatPage() {
|
|
||||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isChatEnabled === false) {
|
|
||||||
router.push("/marketplace");
|
|
||||||
}
|
|
||||||
}, [isChatEnabled, router]);
|
|
||||||
|
|
||||||
if (isChatEnabled === null || isChatEnabled === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<Chat className="flex-1" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
|
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar";
|
||||||
|
import { LoadingState } from "./components/LoadingState/LoadingState";
|
||||||
|
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
|
||||||
|
import { MobileHeader } from "./components/MobileHeader/MobileHeader";
|
||||||
|
import { useCopilotShell } from "./useCopilotShell";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopilotShell({ children }: Props) {
|
||||||
|
const {
|
||||||
|
isMobile,
|
||||||
|
isDrawerOpen,
|
||||||
|
isLoading,
|
||||||
|
isLoggedIn,
|
||||||
|
hasActiveSession,
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
handleSelectSession,
|
||||||
|
handleOpenDrawer,
|
||||||
|
handleCloseDrawer,
|
||||||
|
handleDrawerOpenChange,
|
||||||
|
handleNewChat,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isReadyToShowContent,
|
||||||
|
} = useCopilotShell();
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex overflow-hidden bg-[#EFEFF0]"
|
||||||
|
style={{ height: `calc(100vh - ${NAVBAR_HEIGHT_PX}px)` }}
|
||||||
|
>
|
||||||
|
{!isMobile && (
|
||||||
|
<DesktopSidebar
|
||||||
|
sessions={sessions}
|
||||||
|
currentSessionId={currentSessionId}
|
||||||
|
isLoading={isLoading}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
onSelectSession={handleSelectSession}
|
||||||
|
onFetchNextPage={fetchNextPage}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
hasActiveSession={Boolean(hasActiveSession)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
|
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
{isReadyToShowContent ? children : <LoadingState />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<MobileDrawer
|
||||||
|
isOpen={isDrawerOpen}
|
||||||
|
sessions={sessions}
|
||||||
|
currentSessionId={currentSessionId}
|
||||||
|
isLoading={isLoading}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
onSelectSession={handleSelectSession}
|
||||||
|
onFetchNextPage={fetchNextPage}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
onClose={handleCloseDrawer}
|
||||||
|
onOpenChange={handleDrawerOpenChange}
|
||||||
|
hasActiveSession={Boolean(hasActiveSession)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Plus } from "@phosphor-icons/react";
|
||||||
|
import { SessionsList } from "../SessionsList/SessionsList";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessions: SessionSummaryResponse[];
|
||||||
|
currentSessionId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
onSelectSession: (sessionId: string) => void;
|
||||||
|
onFetchNextPage: () => void;
|
||||||
|
onNewChat: () => void;
|
||||||
|
hasActiveSession: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopSidebar({
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
isLoading,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
onSelectSession,
|
||||||
|
onFetchNextPage,
|
||||||
|
onNewChat,
|
||||||
|
hasActiveSession,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<aside className="flex h-full w-80 flex-col border-r border-zinc-100 bg-zinc-50">
|
||||||
|
<div className="shrink-0 px-6 py-4">
|
||||||
|
<Text variant="h3" size="body-medium">
|
||||||
|
Your chats
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3",
|
||||||
|
scrollbarStyles,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SessionsList
|
||||||
|
sessions={sessions}
|
||||||
|
currentSessionId={currentSessionId}
|
||||||
|
isLoading={isLoading}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
onSelectSession={onSelectSession}
|
||||||
|
onFetchNextPage={onFetchNextPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasActiveSession && (
|
||||||
|
<div className="shrink-0 bg-zinc-50 p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={onNewChat}
|
||||||
|
className="w-full"
|
||||||
|
leftIcon={<Plus width="1rem" height="1rem" />}
|
||||||
|
>
|
||||||
|
New Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader";
|
||||||
|
|
||||||
|
export function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<ChatLoader />
|
||||||
|
<Text variant="body" className="text-zinc-500">
|
||||||
|
Loading your chats...
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PlusIcon, X } from "@phosphor-icons/react";
|
||||||
|
import { Drawer } from "vaul";
|
||||||
|
import { SessionsList } from "../SessionsList/SessionsList";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
sessions: SessionSummaryResponse[];
|
||||||
|
currentSessionId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
onSelectSession: (sessionId: string) => void;
|
||||||
|
onFetchNextPage: () => void;
|
||||||
|
onNewChat: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
hasActiveSession: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileDrawer({
|
||||||
|
isOpen,
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
isLoading,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
onSelectSession,
|
||||||
|
onFetchNextPage,
|
||||||
|
onNewChat,
|
||||||
|
onClose,
|
||||||
|
onOpenChange,
|
||||||
|
hasActiveSession,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Drawer.Root open={isOpen} onOpenChange={onOpenChange} direction="left">
|
||||||
|
<Drawer.Portal>
|
||||||
|
<Drawer.Overlay className="fixed inset-0 z-[60] bg-black/10 backdrop-blur-sm" />
|
||||||
|
<Drawer.Content className="fixed left-0 top-0 z-[70] flex h-full w-80 flex-col border-r border-zinc-200 bg-zinc-50">
|
||||||
|
<div className="shrink-0 border-b border-zinc-200 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Drawer.Title className="text-lg font-semibold text-zinc-800">
|
||||||
|
Your tasks
|
||||||
|
</Drawer.Title>
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Close sessions"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X width="1.25rem" height="1.25rem" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3",
|
||||||
|
scrollbarStyles,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SessionsList
|
||||||
|
sessions={sessions}
|
||||||
|
currentSessionId={currentSessionId}
|
||||||
|
isLoading={isLoading}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
onSelectSession={onSelectSession}
|
||||||
|
onFetchNextPage={onFetchNextPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasActiveSession && (
|
||||||
|
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={onNewChat}
|
||||||
|
className="w-full"
|
||||||
|
leftIcon={<PlusIcon width="1rem" height="1rem" />}
|
||||||
|
>
|
||||||
|
New Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer.Portal>
|
||||||
|
</Drawer.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function useMobileDrawer() {
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
function handleOpenDrawer() {
|
||||||
|
setIsDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseDrawer() {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrawerOpenChange(open: boolean) {
|
||||||
|
setIsDrawerOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDrawerOpen,
|
||||||
|
handleOpenDrawer,
|
||||||
|
handleCloseDrawer,
|
||||||
|
handleDrawerOpenChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
||||||
|
import { ListIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onOpenDrawer: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileHeader({ onOpenDrawer }: Props) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Open sessions"
|
||||||
|
onClick={onOpenDrawer}
|
||||||
|
className="fixed z-50 bg-white shadow-md"
|
||||||
|
style={{ left: "1rem", top: `${NAVBAR_HEIGHT_PX + 20}px` }}
|
||||||
|
>
|
||||||
|
<ListIcon width="1.25rem" height="1.25rem" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||||
|
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { InfiniteList } from "@/components/molecules/InfiniteList/InfiniteList";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getSessionTitle } from "../../helpers";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessions: SessionSummaryResponse[];
|
||||||
|
currentSessionId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
onSelectSession: (sessionId: string) => void;
|
||||||
|
onFetchNextPage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionsList({
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
isLoading,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
onSelectSession,
|
||||||
|
onFetchNextPage,
|
||||||
|
}: Props) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-lg px-3 py-2.5">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Text variant="body" className="text-zinc-500">
|
||||||
|
You don't have previous chats
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfiniteList
|
||||||
|
items={sessions}
|
||||||
|
hasMore={hasNextPage}
|
||||||
|
isFetchingMore={isFetchingNextPage}
|
||||||
|
onEndReached={onFetchNextPage}
|
||||||
|
className="space-y-1"
|
||||||
|
renderItem={(session) => {
|
||||||
|
const isActive = session.id === currentSessionId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-lg px-3 py-2.5 text-left transition-colors",
|
||||||
|
isActive ? "bg-zinc-100" : "hover:bg-zinc-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="body"
|
||||||
|
className={cn(
|
||||||
|
"font-normal",
|
||||||
|
isActive ? "text-zinc-600" : "text-zinc-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getSessionTitle(session)}
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||||
|
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||||
|
import { okData } from "@/app/api/helpers";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export interface UseSessionsPaginationArgs {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionsPagination({ enabled }: UseSessionsPaginationArgs) {
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [accumulatedSessions, setAccumulatedSessions] = useState<
|
||||||
|
SessionSummaryResponse[]
|
||||||
|
>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, isError } = useGetV2ListSessions(
|
||||||
|
{ limit: PAGE_SIZE, offset },
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
enabled: enabled && offset >= 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const responseData = okData(data);
|
||||||
|
if (responseData) {
|
||||||
|
const newSessions = responseData.sessions;
|
||||||
|
const total = responseData.total;
|
||||||
|
setTotalCount(total);
|
||||||
|
|
||||||
|
if (offset === 0) {
|
||||||
|
setAccumulatedSessions(newSessions);
|
||||||
|
} else {
|
||||||
|
setAccumulatedSessions((prev) => [...prev, ...newSessions]);
|
||||||
|
}
|
||||||
|
} else if (!enabled) {
|
||||||
|
setAccumulatedSessions([]);
|
||||||
|
setTotalCount(null);
|
||||||
|
}
|
||||||
|
}, [data, offset, enabled]);
|
||||||
|
|
||||||
|
const hasNextPage = useMemo(() => {
|
||||||
|
if (totalCount === null) return false;
|
||||||
|
return accumulatedSessions.length < totalCount;
|
||||||
|
}, [accumulatedSessions.length, totalCount]);
|
||||||
|
|
||||||
|
const areAllSessionsLoaded = useMemo(() => {
|
||||||
|
if (totalCount === null) return false;
|
||||||
|
return (
|
||||||
|
accumulatedSessions.length >= totalCount && !isFetching && !isLoading
|
||||||
|
);
|
||||||
|
}, [accumulatedSessions.length, totalCount, isFetching, isLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
hasNextPage &&
|
||||||
|
!isFetching &&
|
||||||
|
!isLoading &&
|
||||||
|
!isError &&
|
||||||
|
totalCount !== null
|
||||||
|
) {
|
||||||
|
setOffset((prev) => prev + PAGE_SIZE);
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetching, isLoading, isError, totalCount]);
|
||||||
|
|
||||||
|
function fetchNextPage() {
|
||||||
|
if (hasNextPage && !isFetching) {
|
||||||
|
setOffset((prev) => prev + PAGE_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setOffset(0);
|
||||||
|
setAccumulatedSessions([]);
|
||||||
|
setTotalCount(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: accumulatedSessions,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
hasNextPage,
|
||||||
|
areAllSessionsLoaded,
|
||||||
|
totalCount,
|
||||||
|
fetchNextPage,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||||
|
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||||
|
import { format, formatDistanceToNow, isToday } from "date-fns";
|
||||||
|
|
||||||
|
export function convertSessionDetailToSummary(
|
||||||
|
session: SessionDetailResponse,
|
||||||
|
): SessionSummaryResponse {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
created_at: session.created_at,
|
||||||
|
updated_at: session.updated_at,
|
||||||
|
title: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterVisibleSessions(
|
||||||
|
sessions: SessionSummaryResponse[],
|
||||||
|
): SessionSummaryResponse[] {
|
||||||
|
return sessions.filter(
|
||||||
|
(session) => session.updated_at !== session.created_at,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionTitle(session: SessionSummaryResponse): string {
|
||||||
|
if (session.title) return session.title;
|
||||||
|
const isNewSession = session.updated_at === session.created_at;
|
||||||
|
if (isNewSession) {
|
||||||
|
const createdDate = new Date(session.created_at);
|
||||||
|
if (isToday(createdDate)) {
|
||||||
|
return "Today";
|
||||||
|
}
|
||||||
|
return format(createdDate, "MMM d, yyyy");
|
||||||
|
}
|
||||||
|
return "Untitled Chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionUpdatedLabel(
|
||||||
|
session: SessionSummaryResponse,
|
||||||
|
): string {
|
||||||
|
if (!session.updated_at) return "";
|
||||||
|
return formatDistanceToNow(new Date(session.updated_at), { addSuffix: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCurrentSessionIntoList(
|
||||||
|
accumulatedSessions: SessionSummaryResponse[],
|
||||||
|
currentSessionId: string | null,
|
||||||
|
currentSessionData: SessionDetailResponse | null | undefined,
|
||||||
|
): SessionSummaryResponse[] {
|
||||||
|
const filteredSessions: SessionSummaryResponse[] = [];
|
||||||
|
|
||||||
|
if (accumulatedSessions.length > 0) {
|
||||||
|
const visibleSessions = filterVisibleSessions(accumulatedSessions);
|
||||||
|
|
||||||
|
if (currentSessionId) {
|
||||||
|
const currentInAll = accumulatedSessions.find(
|
||||||
|
(s) => s.id === currentSessionId,
|
||||||
|
);
|
||||||
|
if (currentInAll) {
|
||||||
|
const isInVisible = visibleSessions.some(
|
||||||
|
(s) => s.id === currentSessionId,
|
||||||
|
);
|
||||||
|
if (!isInVisible) {
|
||||||
|
filteredSessions.push(currentInAll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredSessions.push(...visibleSessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSessionId && currentSessionData) {
|
||||||
|
const isCurrentInList = filteredSessions.some(
|
||||||
|
(s) => s.id === currentSessionId,
|
||||||
|
);
|
||||||
|
if (!isCurrentInList) {
|
||||||
|
const summarySession = convertSessionDetailToSummary(currentSessionData);
|
||||||
|
filteredSessions.unshift(summarySession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentSessionId(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): string | null {
|
||||||
|
return searchParams.get("sessionId");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAutoSelectSession(
|
||||||
|
areAllSessionsLoaded: boolean,
|
||||||
|
hasAutoSelectedSession: boolean,
|
||||||
|
paramSessionId: string | null,
|
||||||
|
visibleSessions: SessionSummaryResponse[],
|
||||||
|
accumulatedSessions: SessionSummaryResponse[],
|
||||||
|
isLoading: boolean,
|
||||||
|
totalCount: number | null,
|
||||||
|
): {
|
||||||
|
shouldSelect: boolean;
|
||||||
|
sessionIdToSelect: string | null;
|
||||||
|
shouldCreate: boolean;
|
||||||
|
} {
|
||||||
|
if (!areAllSessionsLoaded || hasAutoSelectedSession) {
|
||||||
|
return {
|
||||||
|
shouldSelect: false,
|
||||||
|
sessionIdToSelect: null,
|
||||||
|
shouldCreate: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paramSessionId) {
|
||||||
|
return {
|
||||||
|
shouldSelect: false,
|
||||||
|
sessionIdToSelect: null,
|
||||||
|
shouldCreate: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleSessions.length > 0) {
|
||||||
|
return {
|
||||||
|
shouldSelect: true,
|
||||||
|
sessionIdToSelect: visibleSessions[0].id,
|
||||||
|
shouldCreate: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accumulatedSessions.length === 0 && !isLoading && totalCount === 0) {
|
||||||
|
return { shouldSelect: false, sessionIdToSelect: null, shouldCreate: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCount === 0) {
|
||||||
|
return {
|
||||||
|
shouldSelect: false,
|
||||||
|
sessionIdToSelect: null,
|
||||||
|
shouldCreate: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shouldSelect: false, sessionIdToSelect: null, shouldCreate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkReadyToShowContent(
|
||||||
|
areAllSessionsLoaded: boolean,
|
||||||
|
paramSessionId: string | null,
|
||||||
|
accumulatedSessions: SessionSummaryResponse[],
|
||||||
|
isCurrentSessionLoading: boolean,
|
||||||
|
currentSessionData: SessionDetailResponse | null | undefined,
|
||||||
|
hasAutoSelectedSession: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (!areAllSessionsLoaded) return false;
|
||||||
|
|
||||||
|
if (paramSessionId) {
|
||||||
|
const sessionFound = accumulatedSessions.some(
|
||||||
|
(s) => s.id === paramSessionId,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
sessionFound ||
|
||||||
|
(!isCurrentSessionLoading &&
|
||||||
|
currentSessionData !== undefined &&
|
||||||
|
currentSessionData !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasAutoSelectedSession;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getGetV2ListSessionsQueryKey,
|
||||||
|
useGetV2GetSession,
|
||||||
|
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||||
|
import { okData } from "@/app/api/helpers";
|
||||||
|
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||||
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer";
|
||||||
|
import { useSessionsPagination } from "./components/SessionsList/useSessionsPagination";
|
||||||
|
import {
|
||||||
|
checkReadyToShowContent,
|
||||||
|
filterVisibleSessions,
|
||||||
|
getCurrentSessionId,
|
||||||
|
mergeCurrentSessionIntoList,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
export function useCopilotShell() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const breakpoint = useBreakpoint();
|
||||||
|
const { isLoggedIn } = useSupabase();
|
||||||
|
const isMobile =
|
||||||
|
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
|
||||||
|
|
||||||
|
const isOnHomepage = pathname === "/copilot";
|
||||||
|
const paramSessionId = searchParams.get("sessionId");
|
||||||
|
|
||||||
|
const {
|
||||||
|
isDrawerOpen,
|
||||||
|
handleOpenDrawer,
|
||||||
|
handleCloseDrawer,
|
||||||
|
handleDrawerOpenChange,
|
||||||
|
} = useMobileDrawer();
|
||||||
|
|
||||||
|
const paginationEnabled = !isMobile || isDrawerOpen || !!paramSessionId;
|
||||||
|
|
||||||
|
const {
|
||||||
|
sessions: accumulatedSessions,
|
||||||
|
isLoading: isSessionsLoading,
|
||||||
|
isFetching: isSessionsFetching,
|
||||||
|
hasNextPage,
|
||||||
|
areAllSessionsLoaded,
|
||||||
|
fetchNextPage,
|
||||||
|
reset: resetPagination,
|
||||||
|
} = useSessionsPagination({
|
||||||
|
enabled: paginationEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentSessionId = getCurrentSessionId(searchParams);
|
||||||
|
|
||||||
|
const { data: currentSessionData, isLoading: isCurrentSessionLoading } =
|
||||||
|
useGetV2GetSession(currentSessionId || "", {
|
||||||
|
query: {
|
||||||
|
enabled: !!currentSessionId,
|
||||||
|
select: okData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [hasAutoSelectedSession, setHasAutoSelectedSession] = useState(false);
|
||||||
|
const hasAutoSelectedRef = useRef(false);
|
||||||
|
|
||||||
|
// Mark as auto-selected when sessionId is in URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (paramSessionId && !hasAutoSelectedRef.current) {
|
||||||
|
hasAutoSelectedRef.current = true;
|
||||||
|
setHasAutoSelectedSession(true);
|
||||||
|
}
|
||||||
|
}, [paramSessionId]);
|
||||||
|
|
||||||
|
// On homepage without sessionId, mark as ready immediately
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOnHomepage && !paramSessionId && !hasAutoSelectedRef.current) {
|
||||||
|
hasAutoSelectedRef.current = true;
|
||||||
|
setHasAutoSelectedSession(true);
|
||||||
|
}
|
||||||
|
}, [isOnHomepage, paramSessionId]);
|
||||||
|
|
||||||
|
// Invalidate sessions list when navigating to homepage (to show newly created sessions)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOnHomepage && !paramSessionId) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getGetV2ListSessionsQueryKey(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOnHomepage, paramSessionId, queryClient]);
|
||||||
|
|
||||||
|
// Reset pagination when query becomes disabled
|
||||||
|
const prevPaginationEnabledRef = useRef(paginationEnabled);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPaginationEnabledRef.current && !paginationEnabled) {
|
||||||
|
resetPagination();
|
||||||
|
resetAutoSelect();
|
||||||
|
}
|
||||||
|
prevPaginationEnabledRef.current = paginationEnabled;
|
||||||
|
}, [paginationEnabled, resetPagination]);
|
||||||
|
|
||||||
|
const sessions = mergeCurrentSessionIntoList(
|
||||||
|
accumulatedSessions,
|
||||||
|
currentSessionId,
|
||||||
|
currentSessionData,
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleSessions = filterVisibleSessions(sessions);
|
||||||
|
|
||||||
|
const sidebarSelectedSessionId =
|
||||||
|
isOnHomepage && !paramSessionId ? null : currentSessionId;
|
||||||
|
|
||||||
|
const isReadyToShowContent = isOnHomepage
|
||||||
|
? true
|
||||||
|
: checkReadyToShowContent(
|
||||||
|
areAllSessionsLoaded,
|
||||||
|
paramSessionId,
|
||||||
|
accumulatedSessions,
|
||||||
|
isCurrentSessionLoading,
|
||||||
|
currentSessionData,
|
||||||
|
hasAutoSelectedSession,
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSelectSession(sessionId: string) {
|
||||||
|
// Navigate using replaceState to avoid full page reload
|
||||||
|
window.history.replaceState(null, "", `/copilot?sessionId=${sessionId}`);
|
||||||
|
// Force a re-render by updating the URL through router
|
||||||
|
router.replace(`/copilot?sessionId=${sessionId}`);
|
||||||
|
if (isMobile) handleCloseDrawer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNewChat() {
|
||||||
|
resetAutoSelect();
|
||||||
|
resetPagination();
|
||||||
|
// Invalidate and refetch sessions list to ensure newly created sessions appear
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getGetV2ListSessionsQueryKey(),
|
||||||
|
});
|
||||||
|
window.history.replaceState(null, "", "/copilot");
|
||||||
|
router.replace("/copilot");
|
||||||
|
if (isMobile) handleCloseDrawer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAutoSelect() {
|
||||||
|
hasAutoSelectedRef.current = false;
|
||||||
|
setHasAutoSelectedSession(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMobile,
|
||||||
|
isDrawerOpen,
|
||||||
|
isLoggedIn,
|
||||||
|
hasActiveSession:
|
||||||
|
Boolean(currentSessionId) && (!isOnHomepage || Boolean(paramSessionId)),
|
||||||
|
isLoading: isSessionsLoading || !areAllSessionsLoaded,
|
||||||
|
sessions: visibleSessions,
|
||||||
|
currentSessionId: sidebarSelectedSessionId,
|
||||||
|
handleSelectSession,
|
||||||
|
handleOpenDrawer,
|
||||||
|
handleCloseDrawer,
|
||||||
|
handleDrawerOpenChange,
|
||||||
|
handleNewChat,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage: isSessionsFetching,
|
||||||
|
fetchNextPage,
|
||||||
|
isReadyToShowContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { User } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
export function getGreetingName(user?: User | null): string {
|
||||||
|
if (!user) return "there";
|
||||||
|
const metadata = user.user_metadata as Record<string, unknown> | undefined;
|
||||||
|
const fullName = metadata?.full_name;
|
||||||
|
const name = metadata?.name;
|
||||||
|
if (typeof fullName === "string" && fullName.trim()) {
|
||||||
|
return fullName.split(" ")[0];
|
||||||
|
}
|
||||||
|
if (typeof name === "string" && name.trim()) {
|
||||||
|
return name.split(" ")[0];
|
||||||
|
}
|
||||||
|
if (user.email) {
|
||||||
|
return user.email.split("@")[0];
|
||||||
|
}
|
||||||
|
return "there";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCopilotChatUrl(prompt: string): string {
|
||||||
|
const trimmed = prompt.trim();
|
||||||
|
if (!trimmed) return "/copilot/chat";
|
||||||
|
const encoded = encodeURIComponent(trimmed);
|
||||||
|
return `/copilot/chat?prompt=${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuickActions(): string[] {
|
||||||
|
return [
|
||||||
|
"Show me what I can automate",
|
||||||
|
"Design a custom workflow",
|
||||||
|
"Help me with content creation",
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { CopilotShell } from "./components/CopilotShell/CopilotShell";
|
||||||
|
|
||||||
|
export default function CopilotLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <CopilotShell>{children}</CopilotShell>;
|
||||||
|
}
|
||||||
223
autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx
Normal file
223
autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||||
|
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { Chat } from "@/components/contextual/Chat/Chat";
|
||||||
|
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||||
|
import { getHomepageRoute } from "@/lib/constants";
|
||||||
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
|
import {
|
||||||
|
Flag,
|
||||||
|
type FlagValues,
|
||||||
|
useGetFlag,
|
||||||
|
} from "@/services/feature-flags/use-get-flag";
|
||||||
|
import { useFlags } from "launchdarkly-react-client-sdk";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { getGreetingName, getQuickActions } from "./helpers";
|
||||||
|
|
||||||
|
type PageState =
|
||||||
|
| { type: "welcome" }
|
||||||
|
| { type: "creating"; prompt: string }
|
||||||
|
| { type: "chat"; sessionId: string; initialPrompt?: string };
|
||||||
|
|
||||||
|
export default function CopilotPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { user, isLoggedIn, isUserLoading } = useSupabase();
|
||||||
|
|
||||||
|
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||||
|
const flags = useFlags<FlagValues>();
|
||||||
|
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||||
|
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
|
||||||
|
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
|
||||||
|
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
|
||||||
|
const isFlagReady =
|
||||||
|
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
|
||||||
|
|
||||||
|
const [pageState, setPageState] = useState<PageState>({ type: "welcome" });
|
||||||
|
const initialPromptRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
const urlSessionId = searchParams.get("sessionId");
|
||||||
|
|
||||||
|
// Sync with URL sessionId (preserve initialPrompt from ref)
|
||||||
|
useEffect(
|
||||||
|
function syncSessionFromUrl() {
|
||||||
|
if (urlSessionId) {
|
||||||
|
// If we're already in chat state with this sessionId, don't overwrite
|
||||||
|
if (pageState.type === "chat" && pageState.sessionId === urlSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Get initialPrompt from ref or current state
|
||||||
|
const storedInitialPrompt = initialPromptRef.current.get(urlSessionId);
|
||||||
|
const currentInitialPrompt =
|
||||||
|
storedInitialPrompt ||
|
||||||
|
(pageState.type === "creating"
|
||||||
|
? pageState.prompt
|
||||||
|
: pageState.type === "chat"
|
||||||
|
? pageState.initialPrompt
|
||||||
|
: undefined);
|
||||||
|
if (currentInitialPrompt) {
|
||||||
|
initialPromptRef.current.set(urlSessionId, currentInitialPrompt);
|
||||||
|
}
|
||||||
|
setPageState({
|
||||||
|
type: "chat",
|
||||||
|
sessionId: urlSessionId,
|
||||||
|
initialPrompt: currentInitialPrompt,
|
||||||
|
});
|
||||||
|
} else if (pageState.type === "chat") {
|
||||||
|
setPageState({ type: "welcome" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[urlSessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function ensureAccess() {
|
||||||
|
if (!isFlagReady) return;
|
||||||
|
if (isChatEnabled === false) {
|
||||||
|
router.replace(homepageRoute);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[homepageRoute, isChatEnabled, isFlagReady, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const greetingName = useMemo(
|
||||||
|
function getName() {
|
||||||
|
return getGreetingName(user);
|
||||||
|
},
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
|
const quickActions = useMemo(function getActions() {
|
||||||
|
return getQuickActions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function startChatWithPrompt(prompt: string) {
|
||||||
|
if (!prompt?.trim()) return;
|
||||||
|
if (pageState.type === "creating") return;
|
||||||
|
|
||||||
|
const trimmedPrompt = prompt.trim();
|
||||||
|
setPageState({ type: "creating", prompt: trimmedPrompt });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create session
|
||||||
|
const sessionResponse = await postV2CreateSession({
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionResponse.status !== 200 || !sessionResponse.data?.id) {
|
||||||
|
throw new Error("Failed to create session");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = sessionResponse.data.id;
|
||||||
|
|
||||||
|
// Store initialPrompt in ref so it persists across re-renders
|
||||||
|
initialPromptRef.current.set(sessionId, trimmedPrompt);
|
||||||
|
|
||||||
|
// Update URL and show Chat with initial prompt
|
||||||
|
// Chat will handle sending the message and streaming
|
||||||
|
window.history.replaceState(null, "", `/copilot?sessionId=${sessionId}`);
|
||||||
|
setPageState({ type: "chat", sessionId, initialPrompt: trimmedPrompt });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CopilotPage] Failed to start chat:", error);
|
||||||
|
setPageState({ type: "welcome" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuickAction(action: string) {
|
||||||
|
startChatWithPrompt(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFlagReady || isChatEnabled === false || !isLoggedIn) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show Chat when we have an active session
|
||||||
|
if (pageState.type === "chat") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<Chat
|
||||||
|
key={pageState.sessionId ?? "welcome"}
|
||||||
|
className="flex-1"
|
||||||
|
urlSessionId={pageState.sessionId}
|
||||||
|
initialPrompt={pageState.initialPrompt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state while creating session and sending first message
|
||||||
|
if (pageState.type === "creating") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-1 flex-col items-center justify-center bg-[#f8f8f9] px-6 py-10">
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
<Text variant="body" className="mt-4 text-zinc-500">
|
||||||
|
Starting your chat...
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show Welcome screen
|
||||||
|
const isLoading = isUserLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-6 py-10">
|
||||||
|
<div className="w-full text-center">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<Skeleton className="mx-auto mb-3 h-8 w-64" />
|
||||||
|
<Skeleton className="mx-auto mb-8 h-6 w-80" />
|
||||||
|
<div className="mb-8">
|
||||||
|
<Skeleton className="mx-auto h-14 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-9 w-48 rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<Text
|
||||||
|
variant="h3"
|
||||||
|
className="mb-3 !text-[1.375rem] text-zinc-700"
|
||||||
|
>
|
||||||
|
Hey, <span className="text-violet-600">{greetingName}</span>
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" className="mb-8 !font-normal">
|
||||||
|
What do you want to automate?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<ChatInput
|
||||||
|
onSend={startChatWithPrompt}
|
||||||
|
placeholder='You can search or just ask - e.g. "create a blog post outline"'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-nowrap 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={() => handleQuickAction(action)}
|
||||||
|
className="h-auto shrink-0 border-zinc-600 !px-4 !py-2 text-[1rem] text-zinc-600"
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||||
|
import { getHomepageRoute } from "@/lib/constants";
|
||||||
|
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { getErrorDetails } from "./helpers";
|
import { getErrorDetails } from "./helpers";
|
||||||
@@ -9,6 +11,8 @@ function ErrorPageContent() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const errorMessage = searchParams.get("message");
|
const errorMessage = searchParams.get("message");
|
||||||
const errorDetails = getErrorDetails(errorMessage);
|
const errorDetails = getErrorDetails(errorMessage);
|
||||||
|
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||||
|
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||||
|
|
||||||
function handleRetry() {
|
function handleRetry() {
|
||||||
// Auth-related errors should redirect to login
|
// Auth-related errors should redirect to login
|
||||||
@@ -25,8 +29,8 @@ function ErrorPageContent() {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
// For server/network errors, go to marketplace
|
// For server/network errors, go to home
|
||||||
window.location.href = "/marketplace";
|
window.location.href = homepageRoute;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function RunAgentModal({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{hasAnySetupFields ? (
|
{hasAnySetupFields ? (
|
||||||
<div className="mt-10 pb-32">
|
<div className="mt-4 pb-10">
|
||||||
<RunAgentModalContextProvider
|
<RunAgentModalContextProvider
|
||||||
value={{
|
value={{
|
||||||
agent,
|
agent,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function ModalHeader({ agent }: ModalHeaderProps) {
|
|||||||
<ShowMoreText
|
<ShowMoreText
|
||||||
previewLimit={400}
|
previewLimit={400}
|
||||||
variant="small"
|
variant="small"
|
||||||
className="mt-4 !text-zinc-700"
|
className="mb-2 mt-4 !text-zinc-700"
|
||||||
>
|
>
|
||||||
{agent.description}
|
{agent.description}
|
||||||
</ShowMoreText>
|
</ShowMoreText>
|
||||||
@@ -40,6 +40,8 @@ export function ModalHeader({ agent }: ModalHeaderProps) {
|
|||||||
<Text variant="lead-semibold" className="text-blue-600">
|
<Text variant="lead-semibold" className="text-blue-600">
|
||||||
Tip
|
Tip
|
||||||
</Text>
|
</Text>
|
||||||
|
<div className="h-px w-full bg-blue-100" />
|
||||||
|
|
||||||
<Text variant="body">
|
<Text variant="body">
|
||||||
For best results, run this agent{" "}
|
For best results, run this agent{" "}
|
||||||
{humanizeCronExpression(
|
{humanizeCronExpression(
|
||||||
@@ -50,7 +52,7 @@ export function ModalHeader({ agent }: ModalHeaderProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{agent.instructions ? (
|
{agent.instructions ? (
|
||||||
<div className="flex flex-col gap-4 rounded-medium border border-purple-100 bg-[#F1EBFE/5] p-4">
|
<div className="mt-4 flex flex-col gap-4 rounded-medium border border-purple-100 bg-[#f1ebfe80] p-4">
|
||||||
<Text variant="lead-semibold" className="text-purple-600">
|
<Text variant="lead-semibold" className="text-purple-600">
|
||||||
Instructions
|
Instructions
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/
|
|||||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||||
import { okData } from "@/app/api/helpers";
|
import { okData } from "@/app/api/helpers";
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||||
|
import { isLogoutInProgress } from "@/lib/autogpt-server-api/helpers";
|
||||||
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
import { updateFavoriteInQueries } from "./helpers";
|
import { updateFavoriteInQueries } from "./helpers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,10 +25,14 @@ export function useLibraryAgentCard({ agent }: Props) {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
const { mutateAsync: updateLibraryAgent } = usePatchV2UpdateLibraryAgent();
|
const { mutateAsync: updateLibraryAgent } = usePatchV2UpdateLibraryAgent();
|
||||||
|
const { user, isLoggedIn } = useSupabase();
|
||||||
|
const logoutInProgress = isLogoutInProgress();
|
||||||
|
|
||||||
const { data: profile } = useGetV2GetUserProfile({
|
const { data: profile } = useGetV2GetUserProfile({
|
||||||
query: {
|
query: {
|
||||||
select: okData,
|
select: okData,
|
||||||
|
enabled: isLoggedIn && !!user && !logoutInProgress,
|
||||||
|
queryKey: ["/api/store/profile", user?.id],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Button } from "@/components/atoms/Button/Button";
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
import { FileInput } from "@/components/atoms/FileInput/FileInput";
|
import { FileInput } from "@/components/atoms/FileInput/FileInput";
|
||||||
import { Input } from "@/components/atoms/Input/Input";
|
import { Input } from "@/components/atoms/Input/Input";
|
||||||
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -120,7 +121,7 @@ export default function LibraryUploadAgentDialog() {
|
|||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-t-2 border-white"></div>
|
<LoadingSpinner size="small" className="text-white" />
|
||||||
<span>Uploading...</span>
|
<span>Uploading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||||
|
import { getHomepageRoute } from "@/lib/constants";
|
||||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
import { environment } from "@/services/environment";
|
import { environment } from "@/services/environment";
|
||||||
|
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||||
import { loginFormSchema, LoginProvider } from "@/types/auth";
|
import { loginFormSchema, LoginProvider } from "@/types/auth";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
@@ -20,15 +22,17 @@ export function useLoginPage() {
|
|||||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||||
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
|
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
|
||||||
const isCloudEnv = environment.isCloud();
|
const isCloudEnv = environment.isCloud();
|
||||||
|
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||||
|
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||||
|
|
||||||
// Get redirect destination from 'next' query parameter
|
// Get redirect destination from 'next' query parameter
|
||||||
const nextUrl = searchParams.get("next");
|
const nextUrl = searchParams.get("next");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn && !isLoggingIn) {
|
if (isLoggedIn && !isLoggingIn) {
|
||||||
router.push(nextUrl || "/marketplace");
|
router.push(nextUrl || homepageRoute);
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, isLoggingIn, nextUrl, router]);
|
}, [homepageRoute, isLoggedIn, isLoggingIn, nextUrl, router]);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof loginFormSchema>>({
|
const form = useForm<z.infer<typeof loginFormSchema>>({
|
||||||
resolver: zodResolver(loginFormSchema),
|
resolver: zodResolver(loginFormSchema),
|
||||||
@@ -98,7 +102,7 @@ export function useLoginPage() {
|
|||||||
} else if (result.onboarding) {
|
} else if (result.onboarding) {
|
||||||
router.replace("/onboarding");
|
router.replace("/onboarding");
|
||||||
} else {
|
} else {
|
||||||
router.replace("/marketplace");
|
router.replace(homepageRoute);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
||||||
import { ProfileInfoForm } from "@/components/__legacy__/ProfileInfoForm";
|
import { ProfileInfoForm } from "@/components/__legacy__/ProfileInfoForm";
|
||||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||||
|
import { isLogoutInProgress } from "@/lib/autogpt-server-api/helpers";
|
||||||
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
|
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
|
||||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
import { ProfileLoading } from "./ProfileLoading";
|
import { ProfileLoading } from "./ProfileLoading";
|
||||||
|
|
||||||
export default function UserProfilePage() {
|
export default function UserProfilePage() {
|
||||||
const { user } = useSupabase();
|
const { user } = useSupabase();
|
||||||
|
const logoutInProgress = isLogoutInProgress();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: profile,
|
data: profile,
|
||||||
@@ -18,7 +20,7 @@ export default function UserProfilePage() {
|
|||||||
refetch,
|
refetch,
|
||||||
} = useGetV2GetUserProfile<ProfileDetails | null>({
|
} = useGetV2GetUserProfile<ProfileDetails | null>({
|
||||||
query: {
|
query: {
|
||||||
enabled: !!user,
|
enabled: !!user && !logoutInProgress,
|
||||||
select: (res) => {
|
select: (res) => {
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { getHomepageRoute } from "@/lib/constants";
|
||||||
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||||
import { signupFormSchema } from "@/types/auth";
|
import { signupFormSchema } from "@/types/auth";
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
@@ -58,7 +59,7 @@ export async function signup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOnboardingEnabled = await shouldShowOnboarding();
|
const isOnboardingEnabled = await shouldShowOnboarding();
|
||||||
const next = isOnboardingEnabled ? "/onboarding" : "/";
|
const next = isOnboardingEnabled ? "/onboarding" : getHomepageRoute();
|
||||||
|
|
||||||
return { success: true, next };
|
return { success: true, next };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||||
|
import { getHomepageRoute } from "@/lib/constants";
|
||||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
import { environment } from "@/services/environment";
|
import { environment } from "@/services/environment";
|
||||||
|
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||||
import { LoginProvider, signupFormSchema } from "@/types/auth";
|
import { LoginProvider, signupFormSchema } from "@/types/auth";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
@@ -20,15 +22,17 @@ export function useSignupPage() {
|
|||||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||||
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
|
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
|
||||||
const isCloudEnv = environment.isCloud();
|
const isCloudEnv = environment.isCloud();
|
||||||
|
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||||
|
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||||
|
|
||||||
// Get redirect destination from 'next' query parameter
|
// Get redirect destination from 'next' query parameter
|
||||||
const nextUrl = searchParams.get("next");
|
const nextUrl = searchParams.get("next");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn && !isSigningUp) {
|
if (isLoggedIn && !isSigningUp) {
|
||||||
router.push(nextUrl || "/marketplace");
|
router.push(nextUrl || homepageRoute);
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, isSigningUp, nextUrl, router]);
|
}, [homepageRoute, isLoggedIn, isSigningUp, nextUrl, router]);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof signupFormSchema>>({
|
const form = useForm<z.infer<typeof signupFormSchema>>({
|
||||||
resolver: zodResolver(signupFormSchema),
|
resolver: zodResolver(signupFormSchema),
|
||||||
@@ -129,7 +133,7 @@ export function useSignupPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prefer the URL's next parameter, then result.next (for onboarding), then default
|
// Prefer the URL's next parameter, then result.next (for onboarding), then default
|
||||||
const redirectTo = nextUrl || result.next || "/";
|
const redirectTo = nextUrl || result.next || homepageRoute;
|
||||||
router.replace(redirectTo);
|
router.replace(redirectTo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import {
|
|||||||
getServerAuthToken,
|
getServerAuthToken,
|
||||||
} from "@/lib/autogpt-server-api/helpers";
|
} from "@/lib/autogpt-server-api/helpers";
|
||||||
|
|
||||||
import { transformDates } from "./date-transformer";
|
|
||||||
import { environment } from "@/services/environment";
|
|
||||||
import {
|
import {
|
||||||
IMPERSONATION_HEADER_NAME,
|
IMPERSONATION_HEADER_NAME,
|
||||||
IMPERSONATION_STORAGE_KEY,
|
IMPERSONATION_STORAGE_KEY,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
|
import { environment } from "@/services/environment";
|
||||||
|
import { transformDates } from "./date-transformer";
|
||||||
|
|
||||||
const FRONTEND_BASE_URL =
|
const FRONTEND_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_FRONTEND_BASE_URL || "http://localhost:3000";
|
process.env.NEXT_PUBLIC_FRONTEND_BASE_URL || "http://localhost:3000";
|
||||||
|
|||||||
@@ -1022,7 +1022,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"tags": ["v2", "chat", "chat"],
|
"tags": ["v2", "chat", "chat"],
|
||||||
"summary": "Get Session",
|
"summary": "Get Session",
|
||||||
"description": "Retrieve the details of a specific chat session.\n\nLooks up a chat session by ID for the given user (if authenticated) and returns all session data including messages.\n\nArgs:\n session_id: The unique identifier for the desired chat session.\n user_id: The optional authenticated user ID, or None for anonymous access.\n\nReturns:\n SessionDetailResponse: Details for the requested session; raises NotFoundError if not found.",
|
"description": "Retrieve the details of a specific chat session.\n\nLooks up a chat session by ID for the given user (if authenticated) and returns all session data including messages.\n\nArgs:\n session_id: The unique identifier for the desired chat session.\n user_id: The optional authenticated user ID, or None for anonymous access.\n\nReturns:\n SessionDetailResponse: Details for the requested session, or None if not found.",
|
||||||
"operationId": "getV2GetSession",
|
"operationId": "getV2GetSession",
|
||||||
"security": [{ "HTTPBearerJWT": [] }],
|
"security": [{ "HTTPBearerJWT": [] }],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1039,7 +1039,11 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/SessionDetailResponse"
|
"anyOf": [
|
||||||
|
{ "$ref": "#/components/schemas/SessionDetailResponse" },
|
||||||
|
{ "type": "null" }
|
||||||
|
],
|
||||||
|
"title": "Response Getv2Getsession"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@
|
|||||||
.loader {
|
.loader {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 70px;
|
height: 70px;
|
||||||
border: 5px solid rgb(241 245 249);
|
border: 5px solid transparent;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background:
|
background:
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
import { redirect } from "next/navigation";
|
"use client";
|
||||||
|
|
||||||
|
import { getHomepageRoute } from "@/lib/constants";
|
||||||
|
import {
|
||||||
|
Flag,
|
||||||
|
type FlagValues,
|
||||||
|
useGetFlag,
|
||||||
|
} from "@/services/feature-flags/use-get-flag";
|
||||||
|
import { useFlags } from "launchdarkly-react-client-sdk";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
redirect("/marketplace");
|
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||||
|
const flags = useFlags<FlagValues>();
|
||||||
|
const router = useRouter();
|
||||||
|
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||||
|
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
|
||||||
|
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
|
||||||
|
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
|
||||||
|
const isFlagReady =
|
||||||
|
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function redirectToHomepage() {
|
||||||
|
if (!isFlagReady) return;
|
||||||
|
router.replace(homepageRoute);
|
||||||
|
},
|
||||||
|
[homepageRoute, isFlagReady, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
||||||
|
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
|
||||||
|
import { ChatLoader } from "./components/ChatLoader/ChatLoader";
|
||||||
|
import { useChat } from "./useChat";
|
||||||
|
|
||||||
|
export interface ChatProps {
|
||||||
|
className?: string;
|
||||||
|
urlSessionId?: string | null;
|
||||||
|
initialPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chat({ className, urlSessionId, initialPrompt }: ChatProps) {
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
isLoading,
|
||||||
|
isCreating,
|
||||||
|
error,
|
||||||
|
sessionId,
|
||||||
|
createSession,
|
||||||
|
showLoader,
|
||||||
|
} = useChat({ urlSessionId });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex h-full flex-col", className)}>
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[#f8f8f9]">
|
||||||
|
{/* Loading State */}
|
||||||
|
{showLoader && (isLoading || isCreating) && (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<ChatLoader />
|
||||||
|
<Text variant="body" className="text-zinc-500">
|
||||||
|
Loading your chats...
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && !isLoading && (
|
||||||
|
<ChatErrorState error={error} onRetry={createSession} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Session Content */}
|
||||||
|
{sessionId && !isLoading && !error && (
|
||||||
|
<ChatContainer
|
||||||
|
sessionId={sessionId}
|
||||||
|
initialMessages={messages}
|
||||||
|
initialPrompt={initialPrompt}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface AIChatBubbleProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIChatBubble({ children, className }: AIChatBubbleProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("text-left text-[1rem] leading-relaxed", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export function AuthPromptWidget({
|
|||||||
message,
|
message,
|
||||||
sessionId,
|
sessionId,
|
||||||
agentInfo,
|
agentInfo,
|
||||||
returnUrl = "/chat",
|
returnUrl = "/copilot/chat",
|
||||||
className,
|
className,
|
||||||
}: AuthPromptWidgetProps) {
|
}: AuthPromptWidgetProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -1,31 +1,37 @@
|
|||||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||||
|
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { usePageContext } from "../../usePageContext";
|
import { usePageContext } from "../../usePageContext";
|
||||||
import { ChatInput } from "../ChatInput/ChatInput";
|
import { ChatInput } from "../ChatInput/ChatInput";
|
||||||
import { MessageList } from "../MessageList/MessageList";
|
import { MessageList } from "../MessageList/MessageList";
|
||||||
import { QuickActionsWelcome } from "../QuickActionsWelcome/QuickActionsWelcome";
|
|
||||||
import { useChatContainer } from "./useChatContainer";
|
import { useChatContainer } from "./useChatContainer";
|
||||||
|
|
||||||
export interface ChatContainerProps {
|
export interface ChatContainerProps {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
initialMessages: SessionDetailResponse["messages"];
|
initialMessages: SessionDetailResponse["messages"];
|
||||||
|
initialPrompt?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatContainer({
|
export function ChatContainer({
|
||||||
sessionId,
|
sessionId,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
|
initialPrompt,
|
||||||
className,
|
className,
|
||||||
}: ChatContainerProps) {
|
}: ChatContainerProps) {
|
||||||
const { messages, streamingChunks, isStreaming, sendMessage } =
|
const { messages, streamingChunks, isStreaming, sendMessage, stopStreaming } =
|
||||||
useChatContainer({
|
useChatContainer({
|
||||||
sessionId,
|
sessionId,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
|
initialPrompt,
|
||||||
});
|
});
|
||||||
const { capturePageContext } = usePageContext();
|
|
||||||
|
|
||||||
// Wrap sendMessage to automatically capture page context
|
const { capturePageContext } = usePageContext();
|
||||||
|
const breakpoint = useBreakpoint();
|
||||||
|
const isMobile =
|
||||||
|
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
|
||||||
|
|
||||||
const sendMessageWithContext = useCallback(
|
const sendMessageWithContext = useCallback(
|
||||||
async (content: string, isUserMessage: boolean = true) => {
|
async (content: string, isUserMessage: boolean = true) => {
|
||||||
const context = capturePageContext();
|
const context = capturePageContext();
|
||||||
@@ -34,35 +40,16 @@ export function ChatContainer({
|
|||||||
[sendMessage, capturePageContext],
|
[sendMessage, capturePageContext],
|
||||||
);
|
);
|
||||||
|
|
||||||
const quickActions = [
|
|
||||||
"Find agents for social media management",
|
|
||||||
"Show me agents for content creation",
|
|
||||||
"Help me automate my business",
|
|
||||||
"What can you help me with?",
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex h-full min-h-0 flex-col", className)}
|
className={cn(
|
||||||
style={{
|
"mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col bg-[#f8f8f9]",
|
||||||
backgroundColor: "#ffffff",
|
className,
|
||||||
backgroundImage:
|
)}
|
||||||
"radial-gradient(#e5e5e5 0.5px, transparent 0.5px), radial-gradient(#e5e5e5 0.5px, #ffffff 0.5px)",
|
|
||||||
backgroundSize: "20px 20px",
|
|
||||||
backgroundPosition: "0 0, 10px 10px",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Messages or Welcome Screen */}
|
{/* Messages - Scrollable */}
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pb-24">
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
{messages.length === 0 ? (
|
<div className="flex min-h-full flex-col justify-end">
|
||||||
<QuickActionsWelcome
|
|
||||||
title="Welcome to AutoGPT Copilot"
|
|
||||||
description="Start a conversation to discover and run AI agents."
|
|
||||||
actions={quickActions}
|
|
||||||
onActionClick={sendMessageWithContext}
|
|
||||||
disabled={isStreaming || !sessionId}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<MessageList
|
<MessageList
|
||||||
messages={messages}
|
messages={messages}
|
||||||
streamingChunks={streamingChunks}
|
streamingChunks={streamingChunks}
|
||||||
@@ -70,16 +57,21 @@ export function ChatContainer({
|
|||||||
onSendMessage={sendMessageWithContext}
|
onSendMessage={sendMessageWithContext}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input - Always visible */}
|
{/* Input - Fixed at bottom */}
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 border-t border-zinc-200 bg-white p-4">
|
<div className="relative px-3 pb-6 pt-2">
|
||||||
|
<div className="pointer-events-none absolute top-[-18px] z-10 h-6 w-full bg-gradient-to-b from-transparent to-[#f8f8f9]" />
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={sendMessageWithContext}
|
onSend={sendMessageWithContext}
|
||||||
disabled={isStreaming || !sessionId}
|
disabled={isStreaming || !sessionId}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onStop={stopStreaming}
|
||||||
placeholder={
|
placeholder={
|
||||||
sessionId ? "Type your message..." : "Creating session..."
|
isMobile
|
||||||
|
? "You can search or just ask"
|
||||||
|
: 'You can search or just ask — e.g. "create a blog post outline"'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,6 +1,33 @@
|
|||||||
|
import { SessionKey, sessionStorage } from "@/services/storage/session-storage";
|
||||||
import type { ToolResult } from "@/types/chat";
|
import type { ToolResult } from "@/types/chat";
|
||||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||||
|
|
||||||
|
export function hasSentInitialPrompt(sessionId: string): boolean {
|
||||||
|
try {
|
||||||
|
const sent = JSON.parse(
|
||||||
|
sessionStorage.get(SessionKey.CHAT_SENT_INITIAL_PROMPTS) || "{}",
|
||||||
|
);
|
||||||
|
return sent[sessionId] === true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markInitialPromptSent(sessionId: string): void {
|
||||||
|
try {
|
||||||
|
const sent = JSON.parse(
|
||||||
|
sessionStorage.get(SessionKey.CHAT_SENT_INITIAL_PROMPTS) || "{}",
|
||||||
|
);
|
||||||
|
sent[sessionId] = true;
|
||||||
|
sessionStorage.set(
|
||||||
|
SessionKey.CHAT_SENT_INITIAL_PROMPTS,
|
||||||
|
JSON.stringify(sent),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function removePageContext(content: string): string {
|
export function removePageContext(content: string): string {
|
||||||
// Remove "Page URL: ..." pattern at start of line (case insensitive, handles various formats)
|
// Remove "Page URL: ..." pattern at start of line (case insensitive, handles various formats)
|
||||||
let cleaned = content.replace(/^\s*Page URL:\s*[^\n\r]*/gim, "");
|
let cleaned = content.replace(/^\s*Page URL:\s*[^\n\r]*/gim, "");
|
||||||
@@ -207,12 +234,22 @@ export function parseToolResponse(
|
|||||||
if (responseType === "setup_requirements") {
|
if (responseType === "setup_requirements") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (responseType === "understanding_updated") {
|
||||||
|
return {
|
||||||
|
type: "tool_response",
|
||||||
|
toolId,
|
||||||
|
toolName,
|
||||||
|
result: (parsedResult || result) as ToolResult,
|
||||||
|
success: true,
|
||||||
|
timestamp: timestamp || new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: "tool_response",
|
type: "tool_response",
|
||||||
toolId,
|
toolId,
|
||||||
toolName,
|
toolName,
|
||||||
result,
|
result: parsedResult ? (parsedResult as ToolResult) : result,
|
||||||
success: true,
|
success: true,
|
||||||
timestamp: timestamp || new Date(),
|
timestamp: timestamp || new Date(),
|
||||||
};
|
};
|
||||||
@@ -267,23 +304,34 @@ export function extractCredentialsNeeded(
|
|||||||
| undefined;
|
| undefined;
|
||||||
if (missingCreds && Object.keys(missingCreds).length > 0) {
|
if (missingCreds && Object.keys(missingCreds).length > 0) {
|
||||||
const agentName = (setupInfo?.agent_name as string) || "this block";
|
const agentName = (setupInfo?.agent_name as string) || "this block";
|
||||||
const credentials = Object.values(missingCreds).map((credInfo) => ({
|
const credentials = Object.values(missingCreds).map((credInfo) => {
|
||||||
provider: (credInfo.provider as string) || "unknown",
|
// Normalize to array at boundary - prefer 'types' array, fall back to single 'type'
|
||||||
providerName:
|
const typesArray = credInfo.types as
|
||||||
(credInfo.provider_name as string) ||
|
| Array<"api_key" | "oauth2" | "user_password" | "host_scoped">
|
||||||
(credInfo.provider as string) ||
|
| undefined;
|
||||||
"Unknown Provider",
|
const singleType =
|
||||||
credentialType:
|
|
||||||
(credInfo.type as
|
(credInfo.type as
|
||||||
| "api_key"
|
| "api_key"
|
||||||
| "oauth2"
|
| "oauth2"
|
||||||
| "user_password"
|
| "user_password"
|
||||||
| "host_scoped") || "api_key",
|
| "host_scoped"
|
||||||
title:
|
| undefined) || "api_key";
|
||||||
(credInfo.title as string) ||
|
const credentialTypes =
|
||||||
`${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
|
typesArray && typesArray.length > 0 ? typesArray : [singleType];
|
||||||
scopes: credInfo.scopes as string[] | undefined,
|
|
||||||
}));
|
return {
|
||||||
|
provider: (credInfo.provider as string) || "unknown",
|
||||||
|
providerName:
|
||||||
|
(credInfo.provider_name as string) ||
|
||||||
|
(credInfo.provider as string) ||
|
||||||
|
"Unknown Provider",
|
||||||
|
credentialTypes,
|
||||||
|
title:
|
||||||
|
(credInfo.title as string) ||
|
||||||
|
`${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
|
||||||
|
scopes: credInfo.scopes as string[] | undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
type: "credentials_needed",
|
type: "credentials_needed",
|
||||||
toolName,
|
toolName,
|
||||||
@@ -358,11 +406,14 @@ export function extractInputsNeeded(
|
|||||||
credentials.forEach((cred) => {
|
credentials.forEach((cred) => {
|
||||||
const id = cred.id as string;
|
const id = cred.id as string;
|
||||||
if (id) {
|
if (id) {
|
||||||
|
const credentialTypes = Array.isArray(cred.types)
|
||||||
|
? cred.types
|
||||||
|
: [(cred.type as string) || "api_key"];
|
||||||
credentialsSchema[id] = {
|
credentialsSchema[id] = {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {},
|
properties: {},
|
||||||
credentials_provider: [cred.provider as string],
|
credentials_provider: [cred.provider as string],
|
||||||
credentials_types: [(cred.type as string) || "api_key"],
|
credentials_types: credentialTypes,
|
||||||
credentials_scopes: cred.scopes as string[] | undefined,
|
credentials_scopes: cred.scopes as string[] | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -30,16 +30,17 @@ export function handleTextEnded(
|
|||||||
_chunk: StreamChunk,
|
_chunk: StreamChunk,
|
||||||
deps: HandlerDependencies,
|
deps: HandlerDependencies,
|
||||||
) {
|
) {
|
||||||
console.log("[Text Ended] Saving streamed text as assistant message");
|
|
||||||
const completedText = deps.streamingChunksRef.current.join("");
|
const completedText = deps.streamingChunksRef.current.join("");
|
||||||
if (completedText.trim()) {
|
if (completedText.trim()) {
|
||||||
const assistantMessage: ChatMessageData = {
|
deps.setMessages((prev) => {
|
||||||
type: "message",
|
const assistantMessage: ChatMessageData = {
|
||||||
role: "assistant",
|
type: "message",
|
||||||
content: completedText,
|
role: "assistant",
|
||||||
timestamp: new Date(),
|
content: completedText,
|
||||||
};
|
timestamp: new Date(),
|
||||||
deps.setMessages((prev) => [...prev, assistantMessage]);
|
};
|
||||||
|
return [...prev, assistantMessage];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
deps.setStreamingChunks([]);
|
deps.setStreamingChunks([]);
|
||||||
deps.streamingChunksRef.current = [];
|
deps.streamingChunksRef.current = [];
|
||||||
@@ -58,22 +59,12 @@ export function handleToolCallStart(
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
deps.setMessages((prev) => [...prev, toolCallMessage]);
|
deps.setMessages((prev) => [...prev, toolCallMessage]);
|
||||||
console.log("[Tool Call Start]", {
|
|
||||||
toolId: toolCallMessage.toolId,
|
|
||||||
toolName: toolCallMessage.toolName,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleToolResponse(
|
export function handleToolResponse(
|
||||||
chunk: StreamChunk,
|
chunk: StreamChunk,
|
||||||
deps: HandlerDependencies,
|
deps: HandlerDependencies,
|
||||||
) {
|
) {
|
||||||
console.log("[Tool Response] Received:", {
|
|
||||||
toolId: chunk.tool_id,
|
|
||||||
toolName: chunk.tool_name,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
let toolName = chunk.tool_name || "unknown";
|
let toolName = chunk.tool_name || "unknown";
|
||||||
if (!chunk.tool_name || chunk.tool_name === "unknown") {
|
if (!chunk.tool_name || chunk.tool_name === "unknown") {
|
||||||
deps.setMessages((prev) => {
|
deps.setMessages((prev) => {
|
||||||
@@ -130,19 +121,8 @@ export function handleToolResponse(
|
|||||||
if (toolCallIndex !== -1) {
|
if (toolCallIndex !== -1) {
|
||||||
const newMessages = [...prev];
|
const newMessages = [...prev];
|
||||||
newMessages[toolCallIndex] = responseMessage;
|
newMessages[toolCallIndex] = responseMessage;
|
||||||
console.log(
|
|
||||||
"[Tool Response] Replaced tool_call with matching tool_id:",
|
|
||||||
chunk.tool_id,
|
|
||||||
"at index:",
|
|
||||||
toolCallIndex,
|
|
||||||
);
|
|
||||||
return newMessages;
|
return newMessages;
|
||||||
}
|
}
|
||||||
console.warn(
|
|
||||||
"[Tool Response] No tool_call found with tool_id:",
|
|
||||||
chunk.tool_id,
|
|
||||||
"appending instead",
|
|
||||||
);
|
|
||||||
return [...prev, responseMessage];
|
return [...prev, responseMessage];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -167,50 +147,19 @@ export function handleStreamEnd(
|
|||||||
deps: HandlerDependencies,
|
deps: HandlerDependencies,
|
||||||
) {
|
) {
|
||||||
const completedContent = deps.streamingChunksRef.current.join("");
|
const completedContent = deps.streamingChunksRef.current.join("");
|
||||||
// Only save message if there are uncommitted chunks
|
|
||||||
// (text_ended already saved if there were tool calls)
|
|
||||||
if (completedContent.trim()) {
|
if (completedContent.trim()) {
|
||||||
console.log(
|
|
||||||
"[Stream End] Saving remaining streamed text as assistant message",
|
|
||||||
);
|
|
||||||
const assistantMessage: ChatMessageData = {
|
const assistantMessage: ChatMessageData = {
|
||||||
type: "message",
|
type: "message",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: completedContent,
|
content: completedContent,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
deps.setMessages((prev) => {
|
deps.setMessages((prev) => [...prev, assistantMessage]);
|
||||||
const updated = [...prev, assistantMessage];
|
|
||||||
console.log("[Stream End] Final state:", {
|
|
||||||
localMessages: updated.map((m) => ({
|
|
||||||
type: m.type,
|
|
||||||
...(m.type === "message" && {
|
|
||||||
role: m.role,
|
|
||||||
contentLength: m.content.length,
|
|
||||||
}),
|
|
||||||
...(m.type === "tool_call" && {
|
|
||||||
toolId: m.toolId,
|
|
||||||
toolName: m.toolName,
|
|
||||||
}),
|
|
||||||
...(m.type === "tool_response" && {
|
|
||||||
toolId: m.toolId,
|
|
||||||
toolName: m.toolName,
|
|
||||||
success: m.success,
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
streamingChunks: deps.streamingChunksRef.current,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log("[Stream End] No uncommitted chunks, message already saved");
|
|
||||||
}
|
}
|
||||||
deps.setStreamingChunks([]);
|
deps.setStreamingChunks([]);
|
||||||
deps.streamingChunksRef.current = [];
|
deps.streamingChunksRef.current = [];
|
||||||
deps.setHasTextChunks(false);
|
deps.setHasTextChunks(false);
|
||||||
deps.setIsStreamingInitiated(false);
|
deps.setIsStreamingInitiated(false);
|
||||||
console.log("[Stream End] Stream complete, messages in local state");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleError(chunk: StreamChunk, deps: HandlerDependencies) {
|
export function handleError(chunk: StreamChunk, deps: HandlerDependencies) {
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useChatStream } from "../../useChatStream";
|
import { useChatStream } from "../../useChatStream";
|
||||||
|
import { usePageContext } from "../../usePageContext";
|
||||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||||
import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
|
import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
|
||||||
import {
|
import {
|
||||||
createUserMessage,
|
createUserMessage,
|
||||||
filterAuthMessages,
|
filterAuthMessages,
|
||||||
|
hasSentInitialPrompt,
|
||||||
isToolCallArray,
|
isToolCallArray,
|
||||||
isValidMessage,
|
isValidMessage,
|
||||||
|
markInitialPromptSent,
|
||||||
parseToolResponse,
|
parseToolResponse,
|
||||||
removePageContext,
|
removePageContext,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
@@ -16,20 +19,40 @@ import {
|
|||||||
interface Args {
|
interface Args {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
initialMessages: SessionDetailResponse["messages"];
|
initialMessages: SessionDetailResponse["messages"];
|
||||||
|
initialPrompt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChatContainer({ sessionId, initialMessages }: Args) {
|
export function useChatContainer({
|
||||||
|
sessionId,
|
||||||
|
initialMessages,
|
||||||
|
initialPrompt,
|
||||||
|
}: Args) {
|
||||||
const [messages, setMessages] = useState<ChatMessageData[]>([]);
|
const [messages, setMessages] = useState<ChatMessageData[]>([]);
|
||||||
const [streamingChunks, setStreamingChunks] = useState<string[]>([]);
|
const [streamingChunks, setStreamingChunks] = useState<string[]>([]);
|
||||||
const [hasTextChunks, setHasTextChunks] = useState(false);
|
const [hasTextChunks, setHasTextChunks] = useState(false);
|
||||||
const [isStreamingInitiated, setIsStreamingInitiated] = useState(false);
|
const [isStreamingInitiated, setIsStreamingInitiated] = useState(false);
|
||||||
const streamingChunksRef = useRef<string[]>([]);
|
const streamingChunksRef = useRef<string[]>([]);
|
||||||
const { error, sendMessage: sendStreamMessage } = useChatStream();
|
const previousSessionIdRef = useRef<string | null>(null);
|
||||||
|
const {
|
||||||
|
error,
|
||||||
|
sendMessage: sendStreamMessage,
|
||||||
|
stopStreaming,
|
||||||
|
} = useChatStream();
|
||||||
const isStreaming = isStreamingInitiated || hasTextChunks;
|
const isStreaming = isStreamingInitiated || hasTextChunks;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionId !== previousSessionIdRef.current) {
|
||||||
|
previousSessionIdRef.current = sessionId;
|
||||||
|
setMessages([]);
|
||||||
|
setStreamingChunks([]);
|
||||||
|
streamingChunksRef.current = [];
|
||||||
|
setHasTextChunks(false);
|
||||||
|
setIsStreamingInitiated(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
const allMessages = useMemo(() => {
|
const allMessages = useMemo(() => {
|
||||||
const processedInitialMessages: ChatMessageData[] = [];
|
const processedInitialMessages: ChatMessageData[] = [];
|
||||||
// Map to track tool calls by their ID so we can look up tool names for tool responses
|
|
||||||
const toolCallMap = new Map<string, string>();
|
const toolCallMap = new Map<string, string>();
|
||||||
|
|
||||||
for (const msg of initialMessages) {
|
for (const msg of initialMessages) {
|
||||||
@@ -45,13 +68,9 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
? new Date(msg.timestamp as string)
|
? new Date(msg.timestamp as string)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Remove page context from user messages when loading existing sessions
|
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
content = removePageContext(content);
|
content = removePageContext(content);
|
||||||
// Skip user messages that become empty after removing page context
|
if (!content.trim()) continue;
|
||||||
if (!content.trim()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
processedInitialMessages.push({
|
processedInitialMessages.push({
|
||||||
type: "message",
|
type: "message",
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -61,19 +80,15 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle assistant messages first (before tool messages) to build tool call map
|
|
||||||
if (role === "assistant") {
|
if (role === "assistant") {
|
||||||
// Strip <thinking> tags from content
|
|
||||||
content = content
|
content = content
|
||||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// If assistant has tool calls, create tool_call messages for each
|
|
||||||
if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) {
|
if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) {
|
||||||
for (const toolCall of toolCalls) {
|
for (const toolCall of toolCalls) {
|
||||||
const toolName = toolCall.function.name;
|
const toolName = toolCall.function.name;
|
||||||
const toolId = toolCall.id;
|
const toolId = toolCall.id;
|
||||||
// Store tool name for later lookup
|
|
||||||
toolCallMap.set(toolId, toolName);
|
toolCallMap.set(toolId, toolName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -96,7 +111,6 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Only add assistant message if there's content after stripping thinking tags
|
|
||||||
if (content.trim()) {
|
if (content.trim()) {
|
||||||
processedInitialMessages.push({
|
processedInitialMessages.push({
|
||||||
type: "message",
|
type: "message",
|
||||||
@@ -106,7 +120,6 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (content.trim()) {
|
} else if (content.trim()) {
|
||||||
// Assistant message without tool calls, but with content
|
|
||||||
processedInitialMessages.push({
|
processedInitialMessages.push({
|
||||||
type: "message",
|
type: "message",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -117,7 +130,6 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tool messages - look up tool name from tool call map
|
|
||||||
if (role === "tool") {
|
if (role === "tool") {
|
||||||
const toolCallId = (msg.tool_call_id as string) || "";
|
const toolCallId = (msg.tool_call_id as string) || "";
|
||||||
const toolName = toolCallMap.get(toolCallId) || "unknown";
|
const toolName = toolCallMap.get(toolCallId) || "unknown";
|
||||||
@@ -133,7 +145,6 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle other message types (system, etc.)
|
|
||||||
if (content.trim()) {
|
if (content.trim()) {
|
||||||
processedInitialMessages.push({
|
processedInitialMessages.push({
|
||||||
type: "message",
|
type: "message",
|
||||||
@@ -154,7 +165,7 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
context?: { url: string; content: string },
|
context?: { url: string; content: string },
|
||||||
) {
|
) {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
console.error("Cannot send message: no session ID");
|
console.error("[useChatContainer] Cannot send message: no session ID");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isUserMessage) {
|
if (isUserMessage) {
|
||||||
@@ -167,6 +178,7 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
streamingChunksRef.current = [];
|
streamingChunksRef.current = [];
|
||||||
setHasTextChunks(false);
|
setHasTextChunks(false);
|
||||||
setIsStreamingInitiated(true);
|
setIsStreamingInitiated(true);
|
||||||
|
|
||||||
const dispatcher = createStreamEventDispatcher({
|
const dispatcher = createStreamEventDispatcher({
|
||||||
setHasTextChunks,
|
setHasTextChunks,
|
||||||
setStreamingChunks,
|
setStreamingChunks,
|
||||||
@@ -175,6 +187,7 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
sessionId,
|
sessionId,
|
||||||
setIsStreamingInitiated,
|
setIsStreamingInitiated,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendStreamMessage(
|
await sendStreamMessage(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -184,8 +197,12 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to send message:", err);
|
console.error("[useChatContainer] Failed to send message:", err);
|
||||||
setIsStreamingInitiated(false);
|
setIsStreamingInitiated(false);
|
||||||
|
|
||||||
|
// Don't show error toast for AbortError (expected during cleanup)
|
||||||
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err instanceof Error ? err.message : "Failed to send message";
|
err instanceof Error ? err.message : "Failed to send message";
|
||||||
toast.error("Failed to send message", {
|
toast.error("Failed to send message", {
|
||||||
@@ -196,11 +213,42 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
|
|||||||
[sessionId, sendStreamMessage],
|
[sessionId, sendStreamMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleStopStreaming = useCallback(() => {
|
||||||
|
stopStreaming();
|
||||||
|
setStreamingChunks([]);
|
||||||
|
streamingChunksRef.current = [];
|
||||||
|
setHasTextChunks(false);
|
||||||
|
setIsStreamingInitiated(false);
|
||||||
|
}, [stopStreaming]);
|
||||||
|
|
||||||
|
const { capturePageContext } = usePageContext();
|
||||||
|
|
||||||
|
// Send initial prompt if provided (for new sessions from homepage)
|
||||||
|
useEffect(
|
||||||
|
function handleInitialPrompt() {
|
||||||
|
if (!initialPrompt || !sessionId) return;
|
||||||
|
if (initialMessages.length > 0) return;
|
||||||
|
if (hasSentInitialPrompt(sessionId)) return;
|
||||||
|
|
||||||
|
markInitialPromptSent(sessionId);
|
||||||
|
const context = capturePageContext();
|
||||||
|
sendMessage(initialPrompt, true, context);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
initialPrompt,
|
||||||
|
sessionId,
|
||||||
|
initialMessages.length,
|
||||||
|
sendMessage,
|
||||||
|
capturePageContext,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: allMessages,
|
messages: allMessages,
|
||||||
streamingChunks,
|
streamingChunks,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
error,
|
error,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
stopStreaming: handleStopStreaming,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,9 @@ import { useChatCredentialsSetup } from "./useChatCredentialsSetup";
|
|||||||
export interface CredentialInfo {
|
export interface CredentialInfo {
|
||||||
provider: string;
|
provider: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
|
credentialTypes: Array<
|
||||||
|
"api_key" | "oauth2" | "user_password" | "host_scoped"
|
||||||
|
>;
|
||||||
title: string;
|
title: string;
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
}
|
}
|
||||||
@@ -30,7 +32,7 @@ function createSchemaFromCredentialInfo(
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {},
|
properties: {},
|
||||||
credentials_provider: [credential.provider],
|
credentials_provider: [credential.provider],
|
||||||
credentials_types: [credential.credentialType],
|
credentials_types: credential.credentialTypes,
|
||||||
credentials_scopes: credential.scopes,
|
credentials_scopes: credential.scopes,
|
||||||
discriminator: undefined,
|
discriminator: undefined,
|
||||||
discriminator_mapping: undefined,
|
discriminator_mapping: undefined,
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ArrowUpIcon, StopIcon } from "@phosphor-icons/react";
|
||||||
|
import { useChatInput } from "./useChatInput";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
onSend: (message: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
onStop?: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInput({
|
||||||
|
onSend,
|
||||||
|
disabled = false,
|
||||||
|
isStreaming = false,
|
||||||
|
onStop,
|
||||||
|
placeholder = "Type your message...",
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const inputId = "chat-input";
|
||||||
|
const { value, setValue, handleKeyDown, handleSend, hasMultipleLines } =
|
||||||
|
useChatInput({
|
||||||
|
onSend,
|
||||||
|
disabled: disabled || isStreaming,
|
||||||
|
maxRows: 4,
|
||||||
|
inputId,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||||
|
setValue(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className={cn("relative flex-1", className)}>
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
id={`${inputId}-wrapper`}
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden border border-neutral-200 bg-white shadow-sm",
|
||||||
|
"focus-within:border-zinc-400 focus-within:ring-1 focus-within:ring-zinc-400",
|
||||||
|
hasMultipleLines ? "rounded-xlarge" : "rounded-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id={inputId}
|
||||||
|
aria-label="Chat message input"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled || isStreaming}
|
||||||
|
rows={1}
|
||||||
|
className={cn(
|
||||||
|
"w-full resize-none overflow-y-auto border-0 bg-transparent text-[1rem] leading-6 text-black",
|
||||||
|
"placeholder:text-zinc-400",
|
||||||
|
"focus:outline-none focus:ring-0",
|
||||||
|
"disabled:text-zinc-500",
|
||||||
|
hasMultipleLines ? "pb-6 pl-4 pr-4 pt-2" : "pb-4 pl-4 pr-14 pt-4",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span id="chat-input-hint" className="sr-only">
|
||||||
|
Press Enter to send, Shift+Enter for new line
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isStreaming ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="icon"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Stop generating"
|
||||||
|
onClick={onStop}
|
||||||
|
className="absolute bottom-[7px] right-2 border-red-600 bg-red-600 text-white hover:border-red-800 hover:bg-red-800"
|
||||||
|
>
|
||||||
|
<StopIcon className="h-4 w-4" weight="bold" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="icon"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Send message"
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-[7px] right-2 border-zinc-800 bg-zinc-800 text-white hover:border-zinc-900 hover:bg-zinc-900",
|
||||||
|
(disabled || !value.trim()) && "opacity-20",
|
||||||
|
)}
|
||||||
|
disabled={disabled || !value.trim()}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="h-4 w-4" weight="bold" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { KeyboardEvent, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface UseChatInputArgs {
|
||||||
|
onSend: (message: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
maxRows?: number;
|
||||||
|
inputId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatInput({
|
||||||
|
onSend,
|
||||||
|
disabled = false,
|
||||||
|
maxRows = 5,
|
||||||
|
inputId = "chat-input",
|
||||||
|
}: UseChatInputArgs) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [hasMultipleLines, setHasMultipleLines] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
||||||
|
const wrapper = document.getElementById(
|
||||||
|
`${inputId}-wrapper`,
|
||||||
|
) as HTMLDivElement;
|
||||||
|
if (!textarea || !wrapper) return;
|
||||||
|
|
||||||
|
const isEmpty = !value.trim();
|
||||||
|
const lines = value.split("\n").length;
|
||||||
|
const hasExplicitNewlines = lines > 1;
|
||||||
|
|
||||||
|
const computedStyle = window.getComputedStyle(textarea);
|
||||||
|
const lineHeight = parseInt(computedStyle.lineHeight, 10);
|
||||||
|
const paddingTop = parseInt(computedStyle.paddingTop, 10);
|
||||||
|
const paddingBottom = parseInt(computedStyle.paddingBottom, 10);
|
||||||
|
|
||||||
|
const singleLinePadding = paddingTop + paddingBottom;
|
||||||
|
|
||||||
|
textarea.style.height = "auto";
|
||||||
|
const scrollHeight = textarea.scrollHeight;
|
||||||
|
|
||||||
|
const singleLineHeight = lineHeight + singleLinePadding;
|
||||||
|
const isMultiLine =
|
||||||
|
hasExplicitNewlines || scrollHeight > singleLineHeight + 2;
|
||||||
|
setHasMultipleLines(isMultiLine);
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
wrapper.style.height = `${singleLineHeight}px`;
|
||||||
|
wrapper.style.maxHeight = "";
|
||||||
|
textarea.style.height = `${singleLineHeight}px`;
|
||||||
|
textarea.style.maxHeight = "";
|
||||||
|
textarea.style.overflowY = "hidden";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMultiLine) {
|
||||||
|
const wrapperMaxHeight = 196;
|
||||||
|
const currentMultilinePadding = paddingTop + paddingBottom;
|
||||||
|
const contentMaxHeight = wrapperMaxHeight - currentMultilinePadding;
|
||||||
|
const minMultiLineHeight = lineHeight * 2 + currentMultilinePadding;
|
||||||
|
const contentHeight = scrollHeight;
|
||||||
|
const targetWrapperHeight = Math.min(
|
||||||
|
Math.max(contentHeight + currentMultilinePadding, minMultiLineHeight),
|
||||||
|
wrapperMaxHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
wrapper.style.height = `${targetWrapperHeight}px`;
|
||||||
|
wrapper.style.maxHeight = `${wrapperMaxHeight}px`;
|
||||||
|
textarea.style.height = `${contentHeight}px`;
|
||||||
|
textarea.style.maxHeight = `${contentMaxHeight}px`;
|
||||||
|
textarea.style.overflowY =
|
||||||
|
contentHeight > contentMaxHeight ? "auto" : "hidden";
|
||||||
|
} else {
|
||||||
|
wrapper.style.height = `${singleLineHeight}px`;
|
||||||
|
wrapper.style.maxHeight = "";
|
||||||
|
textarea.style.height = `${singleLineHeight}px`;
|
||||||
|
textarea.style.maxHeight = "";
|
||||||
|
textarea.style.overflowY = "hidden";
|
||||||
|
}
|
||||||
|
}, [value, maxRows, inputId]);
|
||||||
|
|
||||||
|
const handleSend = useCallback(() => {
|
||||||
|
if (disabled || !value.trim()) return;
|
||||||
|
onSend(value.trim());
|
||||||
|
setValue("");
|
||||||
|
setHasMultipleLines(false);
|
||||||
|
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
||||||
|
const wrapper = document.getElementById(
|
||||||
|
`${inputId}-wrapper`,
|
||||||
|
) as HTMLDivElement;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = "auto";
|
||||||
|
}
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.style.height = "";
|
||||||
|
wrapper.style.maxHeight = "";
|
||||||
|
}
|
||||||
|
}, [value, onSend, disabled, inputId]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSend],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
handleKeyDown,
|
||||||
|
handleSend,
|
||||||
|
hasMultipleLines,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.loader {
|
||||||
|
width: 20px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #000;
|
||||||
|
box-shadow: 0 0 0 0 #0004;
|
||||||
|
animation: l1 1s infinite;
|
||||||
|
}
|
||||||
|
@keyframes l1 {
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 30px #0000;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import styles from "./ChatLoader.module.css";
|
||||||
|
|
||||||
|
export function ChatLoader() {
|
||||||
|
return <div className={styles.loader} />;
|
||||||
|
}
|
||||||
@@ -1,48 +1,50 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
|
||||||
import Avatar, {
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@/components/atoms/Avatar/Avatar";
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ArrowClockwise,
|
ArrowsClockwiseIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
RobotIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { getToolActionPhrase } from "../../helpers";
|
|
||||||
import { AgentCarouselMessage } from "../AgentCarouselMessage/AgentCarouselMessage";
|
import { AgentCarouselMessage } from "../AgentCarouselMessage/AgentCarouselMessage";
|
||||||
|
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||||
import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget";
|
import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget";
|
||||||
import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup";
|
import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup";
|
||||||
import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage";
|
import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage";
|
||||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
|
||||||
import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage";
|
import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage";
|
||||||
import { ToolCallMessage } from "../ToolCallMessage/ToolCallMessage";
|
import { ToolCallMessage } from "../ToolCallMessage/ToolCallMessage";
|
||||||
import { ToolResponseMessage } from "../ToolResponseMessage/ToolResponseMessage";
|
import { ToolResponseMessage } from "../ToolResponseMessage/ToolResponseMessage";
|
||||||
|
import { UserChatBubble } from "../UserChatBubble/UserChatBubble";
|
||||||
import { useChatMessage, type ChatMessageData } from "./useChatMessage";
|
import { useChatMessage, type ChatMessageData } from "./useChatMessage";
|
||||||
export interface ChatMessageProps {
|
export interface ChatMessageProps {
|
||||||
message: ChatMessageData;
|
message: ChatMessageData;
|
||||||
|
messages?: ChatMessageData[];
|
||||||
|
index?: number;
|
||||||
|
isStreaming?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
onDismissLogin?: () => void;
|
onDismissLogin?: () => void;
|
||||||
onDismissCredentials?: () => void;
|
onDismissCredentials?: () => void;
|
||||||
onSendMessage?: (content: string, isUserMessage?: boolean) => void;
|
onSendMessage?: (content: string, isUserMessage?: boolean) => void;
|
||||||
agentOutput?: ChatMessageData;
|
agentOutput?: ChatMessageData;
|
||||||
|
isFinalMessage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessage({
|
export function ChatMessage({
|
||||||
message,
|
message,
|
||||||
|
messages = [],
|
||||||
|
index = -1,
|
||||||
|
isStreaming = false,
|
||||||
className,
|
className,
|
||||||
onDismissCredentials,
|
onDismissCredentials,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
agentOutput,
|
agentOutput,
|
||||||
|
isFinalMessage = true,
|
||||||
}: ChatMessageProps) {
|
}: ChatMessageProps) {
|
||||||
const { user } = useSupabase();
|
const { user } = useSupabase();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -55,14 +57,6 @@ export function ChatMessage({
|
|||||||
isCredentialsNeeded,
|
isCredentialsNeeded,
|
||||||
} = useChatMessage(message);
|
} = useChatMessage(message);
|
||||||
|
|
||||||
const { data: profile } = useGetV2GetUserProfile({
|
|
||||||
query: {
|
|
||||||
select: (res) => (res.status === 200 ? res.data : null),
|
|
||||||
enabled: isUser && !!user,
|
|
||||||
queryKey: ["/api/store/profile", user?.id],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAllCredentialsComplete = useCallback(
|
const handleAllCredentialsComplete = useCallback(
|
||||||
function handleAllCredentialsComplete() {
|
function handleAllCredentialsComplete() {
|
||||||
// Send a user message that explicitly asks to retry the setup
|
// Send a user message that explicitly asks to retry the setup
|
||||||
@@ -169,9 +163,45 @@ export function ChatMessage({
|
|||||||
|
|
||||||
// Render tool call messages
|
// Render tool call messages
|
||||||
if (isToolCall && message.type === "tool_call") {
|
if (isToolCall && message.type === "tool_call") {
|
||||||
|
// Check if this tool call is currently streaming
|
||||||
|
// A tool call is streaming if:
|
||||||
|
// 1. isStreaming is true
|
||||||
|
// 2. This is the last tool_call message
|
||||||
|
// 3. There's no tool_response for this tool call yet
|
||||||
|
const isToolCallStreaming =
|
||||||
|
isStreaming &&
|
||||||
|
index >= 0 &&
|
||||||
|
(() => {
|
||||||
|
// Find the last tool_call index
|
||||||
|
let lastToolCallIndex = -1;
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].type === "tool_call") {
|
||||||
|
lastToolCallIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if this is the last tool_call and there's no response yet
|
||||||
|
if (index === lastToolCallIndex) {
|
||||||
|
// Check if there's a tool_response for this tool call
|
||||||
|
const hasResponse = messages
|
||||||
|
.slice(index + 1)
|
||||||
|
.some(
|
||||||
|
(msg) =>
|
||||||
|
msg.type === "tool_response" && msg.toolId === message.toolId,
|
||||||
|
);
|
||||||
|
return !hasResponse;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("px-4 py-2", className)}>
|
<div className={cn("px-4 py-2", className)}>
|
||||||
<ToolCallMessage toolName={message.toolName} />
|
<ToolCallMessage
|
||||||
|
toolId={message.toolId}
|
||||||
|
toolName={message.toolName}
|
||||||
|
arguments={message.arguments}
|
||||||
|
isStreaming={isToolCallStreaming}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -218,27 +248,11 @@ export function ChatMessage({
|
|||||||
|
|
||||||
// Render tool response messages (but skip agent_output if it's being rendered inside assistant message)
|
// Render tool response messages (but skip agent_output if it's being rendered inside assistant message)
|
||||||
if (isToolResponse && message.type === "tool_response") {
|
if (isToolResponse && message.type === "tool_response") {
|
||||||
// Check if this is an agent_output that should be rendered inside assistant message
|
|
||||||
if (message.result) {
|
|
||||||
let parsedResult: Record<string, unknown> | null = null;
|
|
||||||
try {
|
|
||||||
parsedResult =
|
|
||||||
typeof message.result === "string"
|
|
||||||
? JSON.parse(message.result)
|
|
||||||
: (message.result as Record<string, unknown>);
|
|
||||||
} catch {
|
|
||||||
parsedResult = null;
|
|
||||||
}
|
|
||||||
if (parsedResult?.type === "agent_output") {
|
|
||||||
// Skip rendering - this will be rendered inside the assistant message
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("px-4 py-2", className)}>
|
<div className={cn("px-4 py-2", className)}>
|
||||||
<ToolResponseMessage
|
<ToolResponseMessage
|
||||||
toolName={getToolActionPhrase(message.toolName)}
|
toolId={message.toolId}
|
||||||
|
toolName={message.toolName}
|
||||||
result={message.result}
|
result={message.result}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,40 +270,33 @@ export function ChatMessage({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full max-w-3xl gap-3">
|
<div className="flex w-full max-w-3xl gap-3">
|
||||||
{!isUser && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
|
|
||||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-0 flex-1 flex-col",
|
"flex min-w-0 flex-1 flex-col",
|
||||||
isUser && "items-end",
|
isUser && "items-end",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageBubble variant={isUser ? "user" : "assistant"}>
|
{isUser ? (
|
||||||
<MarkdownContent content={message.content} />
|
<UserChatBubble>
|
||||||
{agentOutput &&
|
<MarkdownContent content={message.content} />
|
||||||
agentOutput.type === "tool_response" &&
|
</UserChatBubble>
|
||||||
!isUser && (
|
) : (
|
||||||
|
<AIChatBubble>
|
||||||
|
<MarkdownContent content={message.content} />
|
||||||
|
{agentOutput && agentOutput.type === "tool_response" && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<ToolResponseMessage
|
<ToolResponseMessage
|
||||||
toolName={
|
toolId={agentOutput.toolId}
|
||||||
agentOutput.toolName
|
toolName={agentOutput.toolName || "Agent Output"}
|
||||||
? getToolActionPhrase(agentOutput.toolName)
|
|
||||||
: "Agent Output"
|
|
||||||
}
|
|
||||||
result={agentOutput.result}
|
result={agentOutput.result}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</MessageBubble>
|
</AIChatBubble>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 flex gap-1",
|
"flex gap-0",
|
||||||
isUser ? "justify-end" : "justify-start",
|
isUser ? "justify-end" : "justify-start",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -300,37 +307,25 @@ export function ChatMessage({
|
|||||||
onClick={handleTryAgain}
|
onClick={handleTryAgain}
|
||||||
aria-label="Try again"
|
aria-label="Try again"
|
||||||
>
|
>
|
||||||
<ArrowClockwise className="size-3 text-neutral-500" />
|
<ArrowsClockwiseIcon className="size-4 text-zinc-600" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(isUser || isFinalMessage) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label="Copy message"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="size-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="size-4 text-zinc-600" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleCopy}
|
|
||||||
aria-label="Copy message"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<CheckIcon className="size-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<CopyIcon className="size-3 text-neutral-500" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isUser && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Avatar className="h-7 w-7">
|
|
||||||
<AvatarImage
|
|
||||||
src={profile?.avatar_url ?? ""}
|
|
||||||
alt={profile?.username ?? "User"}
|
|
||||||
/>
|
|
||||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-neutral-600">
|
|
||||||
{profile?.username?.charAt(0)?.toUpperCase() || "U"}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -41,7 +41,9 @@ export type ChatMessageData =
|
|||||||
credentials: Array<{
|
credentials: Array<{
|
||||||
provider: string;
|
provider: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
|
credentialTypes: Array<
|
||||||
|
"api_key" | "oauth2" | "user_password" | "host_scoped"
|
||||||
|
>;
|
||||||
title: string;
|
title: string;
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
}>;
|
}>;
|
||||||
@@ -13,10 +13,9 @@ export function MessageBubble({
|
|||||||
className,
|
className,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const userTheme = {
|
const userTheme = {
|
||||||
bg: "bg-slate-900",
|
bg: "bg-purple-100",
|
||||||
border: "border-slate-800",
|
border: "border-purple-100",
|
||||||
gradient: "from-slate-900/30 via-slate-800/20 to-transparent",
|
text: "text-slate-900",
|
||||||
text: "text-slate-50",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const assistantTheme = {
|
const assistantTheme = {
|
||||||
@@ -40,9 +39,7 @@ export function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Gradient flare background */}
|
{/* Gradient flare background */}
|
||||||
<div
|
<div className={cn("absolute inset-0 bg-gradient-to-br")} />
|
||||||
className={cn("absolute inset-0 bg-gradient-to-br", theme.gradient)}
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-10 transition-all duration-500 ease-in-out",
|
"relative z-10 transition-all duration-500 ease-in-out",
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||||
|
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
|
||||||
|
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
|
||||||
|
import { LastToolResponse } from "./components/LastToolResponse/LastToolResponse";
|
||||||
|
import { MessageItem } from "./components/MessageItem/MessageItem";
|
||||||
|
import { findLastMessageIndex, shouldSkipAgentOutput } from "./helpers";
|
||||||
|
import { useMessageList } from "./useMessageList";
|
||||||
|
|
||||||
|
export interface MessageListProps {
|
||||||
|
messages: ChatMessageData[];
|
||||||
|
streamingChunks?: string[];
|
||||||
|
isStreaming?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onStreamComplete?: () => void;
|
||||||
|
onSendMessage?: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({
|
||||||
|
messages,
|
||||||
|
streamingChunks = [],
|
||||||
|
isStreaming = false,
|
||||||
|
className,
|
||||||
|
onStreamComplete,
|
||||||
|
onSendMessage,
|
||||||
|
}: MessageListProps) {
|
||||||
|
const { messagesEndRef, messagesContainerRef } = useMessageList({
|
||||||
|
messageCount: messages.length,
|
||||||
|
isStreaming,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps this for debugging purposes 💆🏽
|
||||||
|
*/
|
||||||
|
console.log(messages);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
|
{/* Top fade shadow */}
|
||||||
|
<div className="pointer-events-none absolute top-0 z-10 h-8 w-full bg-gradient-to-b from-[#f8f8f9] to-transparent" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={messagesContainerRef}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 overflow-y-auto overflow-x-hidden",
|
||||||
|
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex min-w-0 flex-col hyphens-auto break-words py-4">
|
||||||
|
{/* Render all persisted messages */}
|
||||||
|
{(() => {
|
||||||
|
const lastAssistantMessageIndex = findLastMessageIndex(
|
||||||
|
messages,
|
||||||
|
(msg) => msg.type === "message" && msg.role === "assistant",
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastToolResponseIndex = findLastMessageIndex(
|
||||||
|
messages,
|
||||||
|
(msg) => msg.type === "tool_response",
|
||||||
|
);
|
||||||
|
|
||||||
|
return messages.map((message, index) => {
|
||||||
|
// Skip agent_output tool_responses that should be rendered inside assistant messages
|
||||||
|
if (shouldSkipAgentOutput(message, messages[index - 1])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render last tool_response as AIChatBubble
|
||||||
|
if (
|
||||||
|
message.type === "tool_response" &&
|
||||||
|
index === lastToolResponseIndex
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<LastToolResponse
|
||||||
|
key={index}
|
||||||
|
message={message}
|
||||||
|
prevMessage={messages[index - 1]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageItem
|
||||||
|
key={index}
|
||||||
|
message={message}
|
||||||
|
messages={messages}
|
||||||
|
index={index}
|
||||||
|
lastAssistantMessageIndex={lastAssistantMessageIndex}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onSendMessage={onSendMessage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Render thinking message when streaming but no chunks yet */}
|
||||||
|
{isStreaming && streamingChunks.length === 0 && <ThinkingMessage />}
|
||||||
|
|
||||||
|
{/* Render streaming message if active */}
|
||||||
|
{isStreaming && streamingChunks.length > 0 && (
|
||||||
|
<StreamingMessage
|
||||||
|
chunks={streamingChunks}
|
||||||
|
onComplete={onStreamComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invisible div to scroll to */}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom fade shadow */}
|
||||||
|
<div className="pointer-events-none absolute bottom-0 z-10 h-8 w-full bg-gradient-to-t from-[#f8f8f9] to-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { AIChatBubble } from "../../../AIChatBubble/AIChatBubble";
|
||||||
|
import type { ChatMessageData } from "../../../ChatMessage/useChatMessage";
|
||||||
|
import { MarkdownContent } from "../../../MarkdownContent/MarkdownContent";
|
||||||
|
import { formatToolResponse } from "../../../ToolResponseMessage/helpers";
|
||||||
|
import { shouldSkipAgentOutput } from "../../helpers";
|
||||||
|
|
||||||
|
export interface LastToolResponseProps {
|
||||||
|
message: ChatMessageData;
|
||||||
|
prevMessage: ChatMessageData | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LastToolResponse({
|
||||||
|
message,
|
||||||
|
prevMessage,
|
||||||
|
}: LastToolResponseProps) {
|
||||||
|
if (message.type !== "tool_response") return null;
|
||||||
|
|
||||||
|
// Skip if this is an agent_output that should be rendered inside assistant message
|
||||||
|
if (shouldSkipAgentOutput(message, prevMessage)) return null;
|
||||||
|
|
||||||
|
const formattedText = formatToolResponse(message.result, message.toolName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 overflow-x-hidden hyphens-auto break-words px-4 py-2">
|
||||||
|
<AIChatBubble>
|
||||||
|
<MarkdownContent content={formattedText} />
|
||||||
|
</AIChatBubble>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ChatMessage } from "../../../ChatMessage/ChatMessage";
|
||||||
|
import type { ChatMessageData } from "../../../ChatMessage/useChatMessage";
|
||||||
|
import { useMessageItem } from "./useMessageItem";
|
||||||
|
|
||||||
|
export interface MessageItemProps {
|
||||||
|
message: ChatMessageData;
|
||||||
|
messages: ChatMessageData[];
|
||||||
|
index: number;
|
||||||
|
lastAssistantMessageIndex: number;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
onSendMessage?: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageItem({
|
||||||
|
message,
|
||||||
|
messages,
|
||||||
|
index,
|
||||||
|
lastAssistantMessageIndex,
|
||||||
|
isStreaming = false,
|
||||||
|
onSendMessage,
|
||||||
|
}: MessageItemProps) {
|
||||||
|
const { messageToRender, agentOutput, isFinalMessage } = useMessageItem({
|
||||||
|
message,
|
||||||
|
messages,
|
||||||
|
index,
|
||||||
|
lastAssistantMessageIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatMessage
|
||||||
|
message={messageToRender}
|
||||||
|
messages={messages}
|
||||||
|
index={index}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onSendMessage={onSendMessage}
|
||||||
|
agentOutput={agentOutput}
|
||||||
|
isFinalMessage={isFinalMessage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ChatMessageData } from "../../../ChatMessage/useChatMessage";
|
||||||
|
import { isAgentOutputResult, isToolOutputPattern } from "../../helpers";
|
||||||
|
|
||||||
|
export interface UseMessageItemArgs {
|
||||||
|
message: ChatMessageData;
|
||||||
|
messages: ChatMessageData[];
|
||||||
|
index: number;
|
||||||
|
lastAssistantMessageIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMessageItem({
|
||||||
|
message,
|
||||||
|
messages,
|
||||||
|
index,
|
||||||
|
lastAssistantMessageIndex,
|
||||||
|
}: UseMessageItemArgs) {
|
||||||
|
let agentOutput: ChatMessageData | undefined;
|
||||||
|
let messageToRender: ChatMessageData = message;
|
||||||
|
|
||||||
|
// Check if assistant message follows a tool_call and looks like a tool output
|
||||||
|
if (message.type === "message" && message.role === "assistant") {
|
||||||
|
const prevMessage = messages[index - 1];
|
||||||
|
|
||||||
|
// Check if next message is an agent_output tool_response to include in current assistant message
|
||||||
|
const nextMessage = messages[index + 1];
|
||||||
|
if (
|
||||||
|
nextMessage &&
|
||||||
|
nextMessage.type === "tool_response" &&
|
||||||
|
nextMessage.result
|
||||||
|
) {
|
||||||
|
if (isAgentOutputResult(nextMessage.result)) {
|
||||||
|
agentOutput = nextMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only convert to tool_response if it follows a tool_call AND looks like a tool output
|
||||||
|
if (prevMessage && prevMessage.type === "tool_call") {
|
||||||
|
if (isToolOutputPattern(message.content)) {
|
||||||
|
// Convert this message to a tool_response format for rendering
|
||||||
|
messageToRender = {
|
||||||
|
type: "tool_response",
|
||||||
|
toolId: prevMessage.toolId,
|
||||||
|
toolName: prevMessage.toolName,
|
||||||
|
result: message.content,
|
||||||
|
success: true,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
} as ChatMessageData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFinalMessage =
|
||||||
|
messageToRender.type !== "message" ||
|
||||||
|
messageToRender.role !== "assistant" ||
|
||||||
|
index === lastAssistantMessageIndex;
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageToRender,
|
||||||
|
agentOutput,
|
||||||
|
isFinalMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||||
|
|
||||||
|
export function parseToolResult(
|
||||||
|
result: unknown,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
return typeof result === "string"
|
||||||
|
? JSON.parse(result)
|
||||||
|
: (result as Record<string, unknown>);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgentOutputResult(result: unknown): boolean {
|
||||||
|
const parsed = parseToolResult(result);
|
||||||
|
return parsed?.type === "agent_output";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isToolOutputPattern(content: string): boolean {
|
||||||
|
const normalizedContent = content.toLowerCase().trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedContent.startsWith("no agents found") ||
|
||||||
|
normalizedContent.startsWith("no results found") ||
|
||||||
|
normalizedContent.includes("no agents found matching") ||
|
||||||
|
!!normalizedContent.match(/^no \w+ found/i) ||
|
||||||
|
(content.length < 150 && normalizedContent.includes("try different")) ||
|
||||||
|
(content.length < 200 &&
|
||||||
|
!normalizedContent.includes("i'll") &&
|
||||||
|
!normalizedContent.includes("let me") &&
|
||||||
|
!normalizedContent.includes("i can") &&
|
||||||
|
!normalizedContent.includes("i will"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolResultValue(result: unknown): string {
|
||||||
|
return typeof result === "string"
|
||||||
|
? result
|
||||||
|
: result
|
||||||
|
? JSON.stringify(result, null, 2)
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLastMessageIndex(
|
||||||
|
messages: ChatMessageData[],
|
||||||
|
predicate: (msg: ChatMessageData) => boolean,
|
||||||
|
): number {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (predicate(messages[i])) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSkipAgentOutput(
|
||||||
|
message: ChatMessageData,
|
||||||
|
prevMessage: ChatMessageData | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (message.type !== "tool_response" || !message.result) return false;
|
||||||
|
|
||||||
|
const isAgentOutput = isAgentOutputResult(message.result);
|
||||||
|
return (
|
||||||
|
isAgentOutput &&
|
||||||
|
!!prevMessage &&
|
||||||
|
prevMessage.type === "message" &&
|
||||||
|
prevMessage.role === "assistant"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -81,9 +81,9 @@ export function SessionsDrawer({
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
) : sessions.length === 0 ? (
|
) : sessions.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Text variant="body" className="text-zinc-500">
|
<Text variant="body" className="text-zinc-500">
|
||||||
No sessions found
|
You don't have previously started chats
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { RobotIcon } from "@phosphor-icons/react";
|
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
|
||||||
import { useStreamingMessage } from "./useStreamingMessage";
|
import { useStreamingMessage } from "./useStreamingMessage";
|
||||||
|
|
||||||
export interface StreamingMessageProps {
|
export interface StreamingMessageProps {
|
||||||
@@ -25,16 +24,10 @@ export function StreamingMessage({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full max-w-3xl gap-3">
|
<div className="flex w-full max-w-3xl gap-3">
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-600">
|
|
||||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<MessageBubble variant="assistant">
|
<AIChatBubble>
|
||||||
<MarkdownContent content={displayText} />
|
<MarkdownContent content={displayText} />
|
||||||
</MessageBubble>
|
</AIChatBubble>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { RobotIcon } from "@phosphor-icons/react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||||
|
|
||||||
export interface ThinkingMessageProps {
|
export interface ThinkingMessageProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -34,14 +33,8 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full max-w-3xl gap-3">
|
<div className="flex w-full max-w-3xl gap-3">
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
|
|
||||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<MessageBubble variant="assistant">
|
<AIChatBubble>
|
||||||
<div className="transition-all duration-500 ease-in-out">
|
<div className="transition-all duration-500 ease-in-out">
|
||||||
{showSlowLoader ? (
|
{showSlowLoader ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-2">
|
<div className="flex flex-col items-center gap-3 py-2">
|
||||||
@@ -62,7 +55,7 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</MessageBubble>
|
</AIChatBubble>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ToolArguments } from "@/types/chat";
|
||||||
|
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||||
|
import {
|
||||||
|
formatToolArguments,
|
||||||
|
getToolActionPhrase,
|
||||||
|
getToolIcon,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
export interface ToolCallMessageProps {
|
||||||
|
toolId?: string;
|
||||||
|
toolName: string;
|
||||||
|
arguments?: ToolArguments;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCallMessage({
|
||||||
|
toolName,
|
||||||
|
arguments: toolArguments,
|
||||||
|
isStreaming = false,
|
||||||
|
className,
|
||||||
|
}: ToolCallMessageProps) {
|
||||||
|
const actionPhrase = getToolActionPhrase(toolName);
|
||||||
|
const argumentsText = formatToolArguments(toolName, toolArguments);
|
||||||
|
const displayText = `${actionPhrase}${argumentsText}`;
|
||||||
|
const IconComponent = getToolIcon(toolName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIChatBubble className={className}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconComponent
|
||||||
|
size={14}
|
||||||
|
weight={isStreaming ? "regular" : "regular"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
|
isStreaming ? "text-neutral-500" : "text-neutral-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
isStreaming
|
||||||
|
? "bg-gradient-to-r from-neutral-600 via-neutral-500 to-neutral-600 bg-[length:200%_100%] bg-clip-text text-transparent [animation:shimmer_2s_ease-in-out_infinite]"
|
||||||
|
: "text-neutral-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</AIChatBubble>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { ToolArguments } from "@/types/chat";
|
||||||
|
import {
|
||||||
|
BrainIcon,
|
||||||
|
EyeIcon,
|
||||||
|
FileMagnifyingGlassIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
PackageIcon,
|
||||||
|
PencilLineIcon,
|
||||||
|
PlayIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SquaresFourIcon,
|
||||||
|
type Icon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps internal tool names to human-friendly action phrases (present continuous).
|
||||||
|
* Used for tool call messages to indicate what action is currently happening.
|
||||||
|
*
|
||||||
|
* @param toolName - The internal tool name from the backend
|
||||||
|
* @returns A human-friendly action phrase in present continuous tense
|
||||||
|
*/
|
||||||
|
export function getToolActionPhrase(toolName: string): string {
|
||||||
|
const toolActionPhrases: Record<string, string> = {
|
||||||
|
add_understanding: "Updating your business information",
|
||||||
|
create_agent: "Creating a new agent",
|
||||||
|
edit_agent: "Editing the agent",
|
||||||
|
find_agent: "Looking for agents in the marketplace",
|
||||||
|
find_block: "Searching for blocks",
|
||||||
|
find_library_agent: "Looking for library agents",
|
||||||
|
run_agent: "Running the agent",
|
||||||
|
run_block: "Running the block",
|
||||||
|
view_agent_output: "Retrieving agent output",
|
||||||
|
search_docs: "Searching documentation",
|
||||||
|
get_doc_page: "Loading documentation page",
|
||||||
|
agent_carousel: "Looking for agents in the marketplace",
|
||||||
|
execution_started: "Running the agent",
|
||||||
|
get_required_setup_info: "Getting setup requirements",
|
||||||
|
schedule_agent: "Scheduling the agent to run",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return mapped phrase or generate human-friendly fallback
|
||||||
|
return (
|
||||||
|
toolActionPhrases[toolName] ||
|
||||||
|
toolName
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (l) => l.toUpperCase())
|
||||||
|
.replace(/^/, "Executing ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats tool call arguments into user-friendly text.
|
||||||
|
* Handles different tool types and formats their arguments nicely.
|
||||||
|
*
|
||||||
|
* @param toolName - The tool name
|
||||||
|
* @param args - The tool arguments
|
||||||
|
* @returns Formatted user-friendly text to append to action phrase
|
||||||
|
*/
|
||||||
|
export function formatToolArguments(
|
||||||
|
toolName: string,
|
||||||
|
args: ToolArguments | undefined,
|
||||||
|
): string {
|
||||||
|
if (!args || Object.keys(args).length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (toolName) {
|
||||||
|
case "find_agent":
|
||||||
|
case "find_library_agent":
|
||||||
|
case "agent_carousel":
|
||||||
|
if (args.query) {
|
||||||
|
return ` matching "${args.query as string}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "find_block":
|
||||||
|
if (args.query) {
|
||||||
|
return ` matching "${args.query as string}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "search_docs":
|
||||||
|
if (args.query) {
|
||||||
|
return ` for "${args.query as string}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "get_doc_page":
|
||||||
|
if (args.path) {
|
||||||
|
return ` "${args.path as string}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "run_agent":
|
||||||
|
if (args.username_agent_slug) {
|
||||||
|
return ` "${args.username_agent_slug as string}"`;
|
||||||
|
}
|
||||||
|
if (args.library_agent_id) {
|
||||||
|
return ` (library agent)`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "run_block":
|
||||||
|
if (args.block_id) {
|
||||||
|
return ` "${args.block_id as string}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "view_agent_output":
|
||||||
|
if (args.library_agent_id) {
|
||||||
|
return ` (library agent)`;
|
||||||
|
}
|
||||||
|
if (args.username_agent_slug) {
|
||||||
|
return ` "${args.username_agent_slug as string}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "create_agent":
|
||||||
|
case "edit_agent":
|
||||||
|
if (args.name) {
|
||||||
|
return ` "${args.name as string}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "add_understanding":
|
||||||
|
const understandingFields = Object.entries(args)
|
||||||
|
.filter(
|
||||||
|
([_, value]) => value !== null && value !== undefined && value !== "",
|
||||||
|
)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
if (key === "user_name" && typeof value === "string") {
|
||||||
|
return `for ${value}`;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return `${key}: ${value}`;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
return `${key}: ${value.slice(0, 2).join(", ")}${value.length > 2 ? ` (+${value.length - 2} more)` : ""}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
if (understandingFields.length > 0) {
|
||||||
|
return ` ${understandingFields[0]}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps tool names to their corresponding Phosphor icon components.
|
||||||
|
*
|
||||||
|
* @param toolName - The tool name from the backend
|
||||||
|
* @returns The Icon component for the tool
|
||||||
|
*/
|
||||||
|
export function getToolIcon(toolName: string): Icon {
|
||||||
|
const iconMap: Record<string, Icon> = {
|
||||||
|
add_understanding: BrainIcon,
|
||||||
|
create_agent: PlusIcon,
|
||||||
|
edit_agent: PencilLineIcon,
|
||||||
|
find_agent: SquaresFourIcon,
|
||||||
|
find_library_agent: MagnifyingGlassIcon,
|
||||||
|
find_block: PackageIcon,
|
||||||
|
run_agent: PlayIcon,
|
||||||
|
run_block: PlayIcon,
|
||||||
|
view_agent_output: EyeIcon,
|
||||||
|
search_docs: FileMagnifyingGlassIcon,
|
||||||
|
get_doc_page: FileTextIcon,
|
||||||
|
agent_carousel: MagnifyingGlassIcon,
|
||||||
|
execution_started: PlayIcon,
|
||||||
|
get_required_setup_info: SquaresFourIcon,
|
||||||
|
schedule_agent: PlayIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
return iconMap[toolName] || SquaresFourIcon;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ToolResult } from "@/types/chat";
|
||||||
|
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||||
|
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||||
|
import { formatToolResponse } from "./helpers";
|
||||||
|
|
||||||
|
export interface ToolResponseMessageProps {
|
||||||
|
toolId?: string;
|
||||||
|
toolName: string;
|
||||||
|
result?: ToolResult;
|
||||||
|
success?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolResponseMessage({
|
||||||
|
toolId: _toolId,
|
||||||
|
toolName,
|
||||||
|
result,
|
||||||
|
success: _success,
|
||||||
|
className,
|
||||||
|
}: ToolResponseMessageProps) {
|
||||||
|
const formattedText = formatToolResponse(result, toolName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIChatBubble className={className}>
|
||||||
|
<MarkdownContent content={formattedText} />
|
||||||
|
</AIChatBubble>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
function getToolCompletionPhrase(toolName: string): string {
|
||||||
|
const toolCompletionPhrases: Record<string, string> = {
|
||||||
|
add_understanding: "Updated your business information",
|
||||||
|
create_agent: "Created the agent",
|
||||||
|
edit_agent: "Updated the agent",
|
||||||
|
find_agent: "Found agents in the marketplace",
|
||||||
|
find_block: "Found blocks",
|
||||||
|
find_library_agent: "Found library agents",
|
||||||
|
run_agent: "Started agent execution",
|
||||||
|
run_block: "Executed the block",
|
||||||
|
view_agent_output: "Retrieved agent output",
|
||||||
|
search_docs: "Found documentation",
|
||||||
|
get_doc_page: "Loaded documentation page",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return mapped phrase or generate human-friendly fallback
|
||||||
|
return (
|
||||||
|
toolCompletionPhrases[toolName] ||
|
||||||
|
`Completed ${toolName.replace(/_/g, " ").replace("...", "")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolResponse(result: unknown, toolName: string): string {
|
||||||
|
if (typeof result === "string") {
|
||||||
|
const trimmed = result.trim();
|
||||||
|
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
return formatToolResponse(parsed, toolName);
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof result !== "object" || result === null) {
|
||||||
|
return String(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = result as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Handle different response types
|
||||||
|
const responseType = response.type as string | undefined;
|
||||||
|
|
||||||
|
if (!responseType) {
|
||||||
|
if (response.message) {
|
||||||
|
return String(response.message);
|
||||||
|
}
|
||||||
|
return getToolCompletionPhrase(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (responseType) {
|
||||||
|
case "agents_found":
|
||||||
|
const agents = (response.agents as Array<{ name: string }>) || [];
|
||||||
|
const count = (response.count as number) || 0;
|
||||||
|
if (count === 0) {
|
||||||
|
return "No agents found matching your search.";
|
||||||
|
}
|
||||||
|
return `Found ${count} agent${count === 1 ? "" : "s"}: ${agents
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((a) => a.name)
|
||||||
|
.join(", ")}${count > 3 ? ` and ${count - 3} more` : ""}`;
|
||||||
|
|
||||||
|
case "agent_details":
|
||||||
|
const agent = response.agent as { name: string; description?: string };
|
||||||
|
if (agent) {
|
||||||
|
return `Agent: ${agent.name}${agent.description ? `\n\n${agent.description}` : ""}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "block_list":
|
||||||
|
const blocks = (response.blocks as Array<{ name: string }>) || [];
|
||||||
|
const blockCount = (response.count as number) || 0;
|
||||||
|
if (blockCount === 0) {
|
||||||
|
return "No blocks found matching your search.";
|
||||||
|
}
|
||||||
|
return `Found ${blockCount} block${blockCount === 1 ? "" : "s"}: ${blocks
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((b) => b.name)
|
||||||
|
.join(", ")}${blockCount > 3 ? ` and ${blockCount - 3} more` : ""}`;
|
||||||
|
|
||||||
|
case "block_output":
|
||||||
|
const blockName = (response.block_name as string) || "Block";
|
||||||
|
const outputs = response.outputs as Record<string, unknown> | undefined;
|
||||||
|
if (outputs && Object.keys(outputs).length > 0) {
|
||||||
|
const outputKeys = Object.keys(outputs);
|
||||||
|
return `${blockName} executed successfully. Outputs: ${outputKeys.join(", ")}`;
|
||||||
|
}
|
||||||
|
return `${blockName} executed successfully.`;
|
||||||
|
|
||||||
|
case "doc_search_results":
|
||||||
|
const docResults = (response.results as Array<{ title: string }>) || [];
|
||||||
|
const docCount = (response.count as number) || 0;
|
||||||
|
if (docCount === 0) {
|
||||||
|
return "No documentation found matching your search.";
|
||||||
|
}
|
||||||
|
return `Found ${docCount} documentation result${docCount === 1 ? "" : "s"}: ${docResults
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((r) => r.title)
|
||||||
|
.join(", ")}${docCount > 3 ? ` and ${docCount - 3} more` : ""}`;
|
||||||
|
|
||||||
|
case "doc_page":
|
||||||
|
const title = (response.title as string) || "Documentation";
|
||||||
|
const content = (response.content as string) || "";
|
||||||
|
if (content) {
|
||||||
|
const preview = content.substring(0, 200).trim();
|
||||||
|
return `${title}\n\n${preview}${content.length > 200 ? "..." : ""}`;
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
|
||||||
|
case "understanding_updated":
|
||||||
|
const currentUnderstanding = response.current_understanding as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const fields = (response.updated_fields as string[]) || [];
|
||||||
|
|
||||||
|
if (response.message && typeof response.message === "string") {
|
||||||
|
let message = response.message;
|
||||||
|
if (currentUnderstanding) {
|
||||||
|
for (const [key, value] of Object.entries(currentUnderstanding)) {
|
||||||
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
const placeholder = key;
|
||||||
|
const actualValue = String(value);
|
||||||
|
message = message.replace(
|
||||||
|
new RegExp(`\\b${placeholder}\\b`, "g"),
|
||||||
|
actualValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentUnderstanding &&
|
||||||
|
Object.keys(currentUnderstanding).length > 0
|
||||||
|
) {
|
||||||
|
const understandingEntries = Object.entries(currentUnderstanding)
|
||||||
|
.filter(
|
||||||
|
([_, value]) =>
|
||||||
|
value !== null && value !== undefined && value !== "",
|
||||||
|
)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
if (key === "user_name" && typeof value === "string") {
|
||||||
|
return `Updated information for ${value}`;
|
||||||
|
}
|
||||||
|
return `${key}: ${String(value)}`;
|
||||||
|
});
|
||||||
|
if (understandingEntries.length > 0) {
|
||||||
|
return understandingEntries[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fields.length > 0) {
|
||||||
|
return `Updated business information: ${fields.join(", ")}`;
|
||||||
|
}
|
||||||
|
return "Updated your business information.";
|
||||||
|
|
||||||
|
case "agent_saved":
|
||||||
|
const agentName = (response.agent_name as string) || "Agent";
|
||||||
|
return `Successfully saved "${agentName}" to your library.`;
|
||||||
|
|
||||||
|
case "agent_preview":
|
||||||
|
const previewAgentName = (response.agent_name as string) || "Agent";
|
||||||
|
const nodeCount = (response.node_count as number) || 0;
|
||||||
|
const linkCount = (response.link_count as number) || 0;
|
||||||
|
const description = (response.description as string) || "";
|
||||||
|
let previewText = `Preview: "${previewAgentName}"`;
|
||||||
|
if (description) {
|
||||||
|
previewText += `\n\n${description}`;
|
||||||
|
}
|
||||||
|
previewText += `\n\n${nodeCount} node${nodeCount === 1 ? "" : "s"}, ${linkCount} link${linkCount === 1 ? "" : "s"}`;
|
||||||
|
return previewText;
|
||||||
|
|
||||||
|
case "clarification_needed":
|
||||||
|
const questions =
|
||||||
|
(response.questions as Array<{ question: string }>) || [];
|
||||||
|
if (questions.length === 0) {
|
||||||
|
return response.message
|
||||||
|
? String(response.message)
|
||||||
|
: "I need more information to proceed.";
|
||||||
|
}
|
||||||
|
if (questions.length === 1) {
|
||||||
|
return questions[0].question;
|
||||||
|
}
|
||||||
|
return `I need clarification on ${questions.length} points:\n\n${questions
|
||||||
|
.map((q, i) => `${i + 1}. ${q.question}`)
|
||||||
|
.join("\n")}`;
|
||||||
|
|
||||||
|
case "agent_output":
|
||||||
|
if (response.message) {
|
||||||
|
return String(response.message);
|
||||||
|
}
|
||||||
|
const outputAgentName = (response.agent_name as string) || "Agent";
|
||||||
|
const execution = response.execution as
|
||||||
|
| {
|
||||||
|
status?: string;
|
||||||
|
outputs?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (execution) {
|
||||||
|
const status = execution.status || "completed";
|
||||||
|
const outputs = execution.outputs || {};
|
||||||
|
const outputKeys = Object.keys(outputs);
|
||||||
|
if (outputKeys.length > 0) {
|
||||||
|
return `${outputAgentName} execution ${status}. Outputs: ${outputKeys.join(", ")}`;
|
||||||
|
}
|
||||||
|
return `${outputAgentName} execution ${status}.`;
|
||||||
|
}
|
||||||
|
return `${outputAgentName} output retrieved.`;
|
||||||
|
|
||||||
|
case "execution_started":
|
||||||
|
const execAgentName = (response.graph_name as string) || "Agent";
|
||||||
|
if (response.message) {
|
||||||
|
return String(response.message);
|
||||||
|
}
|
||||||
|
return `Started execution of "${execAgentName}".`;
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
const errorMsg =
|
||||||
|
(response.error as string) || response.message || "An error occurred";
|
||||||
|
return `Error: ${errorMsg}`;
|
||||||
|
|
||||||
|
case "no_results":
|
||||||
|
const suggestions = (response.suggestions as string[]) || [];
|
||||||
|
let noResultsText = (response.message as string) || "No results found.";
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
noResultsText += `\n\nSuggestions: ${suggestions.join(", ")}`;
|
||||||
|
}
|
||||||
|
return noResultsText;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Try to extract a message field
|
||||||
|
if (response.message) {
|
||||||
|
return String(response.message);
|
||||||
|
}
|
||||||
|
// Fallback: try to stringify nicely
|
||||||
|
if (Object.keys(response).length === 0) {
|
||||||
|
return getToolCompletionPhrase(toolName);
|
||||||
|
}
|
||||||
|
// If we have a response object but no recognized type, try to format it nicely
|
||||||
|
// Don't return raw JSON - return a completion phrase instead
|
||||||
|
return getToolCompletionPhrase(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getToolCompletionPhrase(toolName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface UserChatBubbleProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserChatBubble({ children, className }: UserChatBubbleProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative min-w-20 overflow-hidden rounded-xl bg-purple-100 px-3 text-right text-[1rem] leading-relaxed transition-all duration-500 ease-in-out",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative z-10 text-slate-900 transition-all duration-500 ease-in-out">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useChatSession } from "./useChatSession";
|
import { useChatSession } from "./useChatSession";
|
||||||
import { useChatStream } from "./useChatStream";
|
import { useChatStream } from "./useChatStream";
|
||||||
|
|
||||||
export function useChat() {
|
interface UseChatArgs {
|
||||||
const hasCreatedSessionRef = useRef(false);
|
urlSessionId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChat({ urlSessionId }: UseChatArgs = {}) {
|
||||||
const hasClaimedSessionRef = useRef(false);
|
const hasClaimedSessionRef = useRef(false);
|
||||||
const { user } = useSupabase();
|
const { user } = useSupabase();
|
||||||
const { sendMessage: sendStreamMessage } = useChatStream();
|
const { sendMessage: sendStreamMessage } = useChatStream();
|
||||||
|
const [showLoader, setShowLoader] = useState(false);
|
||||||
const {
|
const {
|
||||||
session,
|
session,
|
||||||
sessionId: sessionIdFromHook,
|
sessionId: sessionIdFromHook,
|
||||||
@@ -24,22 +27,10 @@ export function useChat() {
|
|||||||
clearSession: clearSessionBase,
|
clearSession: clearSessionBase,
|
||||||
loadSession,
|
loadSession,
|
||||||
} = useChatSession({
|
} = useChatSession({
|
||||||
urlSessionId: null,
|
urlSessionId,
|
||||||
autoCreate: false,
|
autoCreate: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(
|
|
||||||
function autoCreateSession() {
|
|
||||||
if (!hasCreatedSessionRef.current && !isCreating && !sessionIdFromHook) {
|
|
||||||
hasCreatedSessionRef.current = true;
|
|
||||||
createSession().catch((_err) => {
|
|
||||||
hasCreatedSessionRef.current = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isCreating, sessionIdFromHook, createSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
function autoClaimSession() {
|
function autoClaimSession() {
|
||||||
if (
|
if (
|
||||||
@@ -75,6 +66,17 @@ export function useChat() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || isCreating) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowLoader(true);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else {
|
||||||
|
setShowLoader(false);
|
||||||
|
}
|
||||||
|
}, [isLoading, isCreating]);
|
||||||
|
|
||||||
useEffect(function monitorNetworkStatus() {
|
useEffect(function monitorNetworkStatus() {
|
||||||
function handleOnline() {
|
function handleOnline() {
|
||||||
toast.success("Connection restored", {
|
toast.success("Connection restored", {
|
||||||
@@ -99,7 +101,6 @@ export function useChat() {
|
|||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
clearSessionBase();
|
clearSessionBase();
|
||||||
hasCreatedSessionRef.current = false;
|
|
||||||
hasClaimedSessionRef.current = false;
|
hasClaimedSessionRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,5 +114,6 @@ export function useChat() {
|
|||||||
clearSession,
|
clearSession,
|
||||||
loadSession,
|
loadSession,
|
||||||
sessionId: sessionIdFromHook,
|
sessionId: sessionIdFromHook,
|
||||||
|
showLoader,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import {
|
||||||
|
getGetV2GetSessionQueryKey,
|
||||||
|
getGetV2GetSessionQueryOptions,
|
||||||
|
postV2CreateSession,
|
||||||
|
useGetV2GetSession,
|
||||||
|
usePatchV2SessionAssignUser,
|
||||||
|
usePostV2CreateSession,
|
||||||
|
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||||
|
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||||
|
import { okData } from "@/app/api/helpers";
|
||||||
|
import { isValidUUID } from "@/lib/utils";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface UseChatSessionArgs {
|
||||||
|
urlSessionId?: string | null;
|
||||||
|
autoCreate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatSession({
|
||||||
|
urlSessionId,
|
||||||
|
autoCreate = false,
|
||||||
|
}: UseChatSessionArgs = {}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const justCreatedSessionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlSessionId) {
|
||||||
|
if (!isValidUUID(urlSessionId)) {
|
||||||
|
console.error("Invalid session ID format:", urlSessionId);
|
||||||
|
toast.error("Invalid session ID", {
|
||||||
|
description:
|
||||||
|
"The session ID in the URL is not valid. Starting a new session...",
|
||||||
|
});
|
||||||
|
setSessionId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSessionId(urlSessionId);
|
||||||
|
} else if (autoCreate) {
|
||||||
|
setSessionId(null);
|
||||||
|
} else {
|
||||||
|
setSessionId(null);
|
||||||
|
}
|
||||||
|
}, [urlSessionId, autoCreate]);
|
||||||
|
|
||||||
|
const { isPending: isCreating, error: createError } =
|
||||||
|
usePostV2CreateSession();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: sessionData,
|
||||||
|
isLoading: isLoadingSession,
|
||||||
|
error: loadError,
|
||||||
|
refetch,
|
||||||
|
} = useGetV2GetSession(sessionId || "", {
|
||||||
|
query: {
|
||||||
|
enabled: !!sessionId,
|
||||||
|
select: okData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: claimSessionMutation } = usePatchV2SessionAssignUser();
|
||||||
|
|
||||||
|
const session = useMemo(() => {
|
||||||
|
if (sessionData) return sessionData;
|
||||||
|
|
||||||
|
if (sessionId && justCreatedSessionIdRef.current === sessionId) {
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
user_id: null,
|
||||||
|
messages: [],
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
} as SessionDetailResponse;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [sessionData, sessionId]);
|
||||||
|
|
||||||
|
const messages = session?.messages || [];
|
||||||
|
const isLoading = isCreating || isLoadingSession;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (createError) {
|
||||||
|
setError(
|
||||||
|
createError instanceof Error
|
||||||
|
? createError
|
||||||
|
: new Error("Failed to create session"),
|
||||||
|
);
|
||||||
|
} else if (loadError) {
|
||||||
|
setError(
|
||||||
|
loadError instanceof Error
|
||||||
|
? loadError
|
||||||
|
: new Error("Failed to load session"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [createError, loadError]);
|
||||||
|
|
||||||
|
async function createSession() {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const response = await postV2CreateSession({
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error("Failed to create session");
|
||||||
|
}
|
||||||
|
const newSessionId = response.data.id;
|
||||||
|
setSessionId(newSessionId);
|
||||||
|
justCreatedSessionIdRef.current = newSessionId;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (justCreatedSessionIdRef.current === newSessionId) {
|
||||||
|
justCreatedSessionIdRef.current = null;
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
return newSessionId;
|
||||||
|
} catch (err) {
|
||||||
|
const error =
|
||||||
|
err instanceof Error ? err : new Error("Failed to create session");
|
||||||
|
setError(error);
|
||||||
|
toast.error("Failed to create chat session", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSession(id: string) {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
// Invalidate the query cache for this session to force a fresh fetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: getGetV2GetSessionQueryKey(id),
|
||||||
|
});
|
||||||
|
// Set sessionId after invalidation to ensure the hook refetches
|
||||||
|
setSessionId(id);
|
||||||
|
// Force fetch with fresh data (bypass cache)
|
||||||
|
const queryOptions = getGetV2GetSessionQueryOptions(id, {
|
||||||
|
query: {
|
||||||
|
staleTime: 0, // Force fresh fetch
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await queryClient.fetchQuery(queryOptions);
|
||||||
|
if (!result || ("status" in result && result.status !== 200)) {
|
||||||
|
console.warn("Session not found on server");
|
||||||
|
setSessionId(null);
|
||||||
|
throw new Error("Session not found");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error =
|
||||||
|
err instanceof Error ? err : new Error("Failed to load session");
|
||||||
|
setError(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSession() {
|
||||||
|
if (!sessionId) return;
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await refetch();
|
||||||
|
} catch (err) {
|
||||||
|
const error =
|
||||||
|
err instanceof Error ? err : new Error("Failed to refresh session");
|
||||||
|
setError(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimSession(id: string) {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await claimSessionMutation({ sessionId: id });
|
||||||
|
if (justCreatedSessionIdRef.current === id) {
|
||||||
|
justCreatedSessionIdRef.current = null;
|
||||||
|
}
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: getGetV2GetSessionQueryKey(id),
|
||||||
|
});
|
||||||
|
await refetch();
|
||||||
|
toast.success("Session claimed successfully", {
|
||||||
|
description: "Your chat history has been saved to your account",
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error =
|
||||||
|
err instanceof Error ? err : new Error("Failed to claim session");
|
||||||
|
const is404 =
|
||||||
|
(typeof err === "object" &&
|
||||||
|
err !== null &&
|
||||||
|
"status" in err &&
|
||||||
|
err.status === 404) ||
|
||||||
|
(typeof err === "object" &&
|
||||||
|
err !== null &&
|
||||||
|
"response" in err &&
|
||||||
|
typeof err.response === "object" &&
|
||||||
|
err.response !== null &&
|
||||||
|
"status" in err.response &&
|
||||||
|
err.response.status === 404);
|
||||||
|
if (!is404) {
|
||||||
|
setError(error);
|
||||||
|
toast.error("Failed to claim session", {
|
||||||
|
description: error.message || "Unable to claim session",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSession() {
|
||||||
|
setSessionId(null);
|
||||||
|
setError(null);
|
||||||
|
justCreatedSessionIdRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
sessionId,
|
||||||
|
messages,
|
||||||
|
isLoading,
|
||||||
|
isCreating,
|
||||||
|
error,
|
||||||
|
createSession,
|
||||||
|
loadSession,
|
||||||
|
refreshSession,
|
||||||
|
claimSession,
|
||||||
|
clearSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -149,22 +149,103 @@ export function useChatStream() {
|
|||||||
const retryCountRef = useRef<number>(0);
|
const retryCountRef = useRef<number>(0);
|
||||||
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const currentSessionIdRef = useRef<string | null>(null);
|
||||||
|
const requestStartTimeRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const stopStreaming = useCallback(() => {
|
const stopStreaming = useCallback(
|
||||||
if (abortControllerRef.current) {
|
(sessionId?: string, force: boolean = false) => {
|
||||||
abortControllerRef.current.abort();
|
console.log("[useChatStream] stopStreaming called", {
|
||||||
abortControllerRef.current = null;
|
hasAbortController: !!abortControllerRef.current,
|
||||||
}
|
isAborted: abortControllerRef.current?.signal.aborted,
|
||||||
if (retryTimeoutRef.current) {
|
currentSessionId: currentSessionIdRef.current,
|
||||||
clearTimeout(retryTimeoutRef.current);
|
requestedSessionId: sessionId,
|
||||||
retryTimeoutRef.current = null;
|
requestStartTime: requestStartTimeRef.current,
|
||||||
}
|
timeSinceStart: requestStartTimeRef.current
|
||||||
setIsStreaming(false);
|
? Date.now() - requestStartTimeRef.current
|
||||||
}, []);
|
: null,
|
||||||
|
force,
|
||||||
|
stack: new Error().stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
sessionId &&
|
||||||
|
currentSessionIdRef.current &&
|
||||||
|
currentSessionIdRef.current !== sessionId
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"[useChatStream] Session changed, aborting previous stream",
|
||||||
|
{
|
||||||
|
oldSessionId: currentSessionIdRef.current,
|
||||||
|
newSessionId: sessionId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = abortControllerRef.current;
|
||||||
|
if (controller) {
|
||||||
|
const timeSinceStart = requestStartTimeRef.current
|
||||||
|
? Date.now() - requestStartTimeRef.current
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!force && timeSinceStart !== null && timeSinceStart < 100) {
|
||||||
|
console.log(
|
||||||
|
"[useChatStream] Request just started (<100ms), skipping abort to prevent race condition",
|
||||||
|
{
|
||||||
|
timeSinceStart,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
|
if (
|
||||||
|
signal &&
|
||||||
|
typeof signal.aborted === "boolean" &&
|
||||||
|
!signal.aborted
|
||||||
|
) {
|
||||||
|
console.log("[useChatStream] Aborting stream");
|
||||||
|
controller.abort();
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"[useChatStream] Stream already aborted or signal invalid",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
console.log(
|
||||||
|
"[useChatStream] AbortError caught (expected during cleanup)",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn("[useChatStream] Error aborting stream:", error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
requestStartTimeRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (retryTimeoutRef.current) {
|
||||||
|
clearTimeout(retryTimeoutRef.current);
|
||||||
|
retryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setIsStreaming(false);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log("[useChatStream] Component mounted");
|
||||||
return () => {
|
return () => {
|
||||||
stopStreaming();
|
const sessionIdAtUnmount = currentSessionIdRef.current;
|
||||||
|
console.log(
|
||||||
|
"[useChatStream] Component unmounting, calling stopStreaming",
|
||||||
|
{
|
||||||
|
sessionIdAtUnmount,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
stopStreaming(undefined, false);
|
||||||
|
currentSessionIdRef.current = null;
|
||||||
};
|
};
|
||||||
}, [stopStreaming]);
|
}, [stopStreaming]);
|
||||||
|
|
||||||
@@ -177,12 +258,32 @@ export function useChatStream() {
|
|||||||
context?: { url: string; content: string },
|
context?: { url: string; content: string },
|
||||||
isRetry: boolean = false,
|
isRetry: boolean = false,
|
||||||
) => {
|
) => {
|
||||||
stopStreaming();
|
console.log("[useChatStream] sendMessage called", {
|
||||||
|
sessionId,
|
||||||
|
message: message.substring(0, 50),
|
||||||
|
isUserMessage,
|
||||||
|
isRetry,
|
||||||
|
stack: new Error().stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousSessionId = currentSessionIdRef.current;
|
||||||
|
stopStreaming(sessionId, true);
|
||||||
|
currentSessionIdRef.current = sessionId;
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
abortControllerRef.current = abortController;
|
abortControllerRef.current = abortController;
|
||||||
|
requestStartTimeRef.current = Date.now();
|
||||||
|
console.log("[useChatStream] Created new AbortController", {
|
||||||
|
sessionId,
|
||||||
|
previousSessionId,
|
||||||
|
requestStartTime: requestStartTimeRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
|
console.warn(
|
||||||
|
"[useChatStream] AbortController was aborted before request started",
|
||||||
|
);
|
||||||
|
requestStartTimeRef.current = null;
|
||||||
return Promise.reject(new Error("Request aborted"));
|
return Promise.reject(new Error("Request aborted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +408,9 @@ export function useChatStream() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
dispatchStreamEnd();
|
||||||
|
stopStreaming();
|
||||||
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +456,10 @@ export function useChatStream() {
|
|||||||
readStream();
|
readStream();
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
setIsStreaming(false);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
const streamError =
|
const streamError =
|
||||||
err instanceof Error ? err : new Error("Failed to start stream");
|
err instanceof Error ? err : new Error("Failed to start stream");
|
||||||
setError(streamError);
|
setError(streamError);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useGetV2GetMyAgents } from "@/app/api/__generated__/endpoints/store/store";
|
import { useGetV2GetMyAgents } from "@/app/api/__generated__/endpoints/store/store";
|
||||||
import { okData } from "@/app/api/helpers";
|
import { okData } from "@/app/api/helpers";
|
||||||
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -36,6 +37,7 @@ export function useAgentSelectStep({
|
|||||||
const [selectedAgentVersion, setSelectedAgentVersion] = React.useState<
|
const [selectedAgentVersion, setSelectedAgentVersion] = React.useState<
|
||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const { isLoggedIn } = useSupabase();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: _myAgents,
|
data: _myAgents,
|
||||||
@@ -43,6 +45,7 @@ export function useAgentSelectStep({
|
|||||||
error,
|
error,
|
||||||
} = useGetV2GetMyAgents(undefined, {
|
} = useGetV2GetMyAgents(undefined, {
|
||||||
query: {
|
query: {
|
||||||
|
enabled: isLoggedIn,
|
||||||
select: (res) =>
|
select: (res) =>
|
||||||
okData(res)
|
okData(res)
|
||||||
?.agents.map(
|
?.agents.map(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user