diff --git a/enterprise/integrations/github/github_view.py b/enterprise/integrations/github/github_view.py index 90027f3804..733cec6c2a 100644 --- a/enterprise/integrations/github/github_view.py +++ b/enterprise/integrations/github/github_view.py @@ -97,6 +97,9 @@ class GithubUserContext(UserContext): user_secrets = await self.secrets_store.load() return dict(user_secrets.custom_secrets) if user_secrets else {} + async def get_mcp_api_key(self) -> str | None: + raise NotImplementedError() + async def get_user_proactive_conversation_setting(user_id: str | None) -> bool: """Get the user's proactive conversation setting. diff --git a/enterprise/server/auth/saas_user_auth.py b/enterprise/server/auth/saas_user_auth.py index eafb7c5b74..2f399a74cf 100644 --- a/enterprise/server/auth/saas_user_auth.py +++ b/enterprise/server/auth/saas_user_auth.py @@ -203,6 +203,15 @@ class SaasUserAuth(UserAuth): self.settings_store = settings_store return settings_store + async def get_mcp_api_key(self) -> str: + api_key_store = ApiKeyStore.get_instance() + mcp_api_key = api_key_store.retrieve_mcp_api_key(self.user_id) + if not mcp_api_key: + mcp_api_key = api_key_store.create_api_key( + self.user_id, 'MCP_API_KEY', None + ) + return mcp_api_key + @classmethod async def get_instance(cls, request: Request) -> UserAuth: logger.debug('saas_user_auth_get_instance') @@ -243,7 +252,12 @@ def get_api_key_from_header(request: Request): # This is a temp hack # Streamable HTTP MCP Client works via redirect requests, but drops the Authorization header for reason # We include `X-Session-API-Key` header by default due to nested runtimes, so it used as a drop in replacement here - return request.headers.get('X-Session-API-Key') + session_api_key = request.headers.get('X-Session-API-Key') + if session_api_key: + return session_api_key + + # Fallback to X-Access-Token header as an additional option + return request.headers.get('X-Access-Token') async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None: diff --git a/enterprise/server/routes/auth.py b/enterprise/server/routes/auth.py index c9e92d54f7..ba7aadb883 100644 --- a/enterprise/server/routes/auth.py +++ b/enterprise/server/routes/auth.py @@ -31,7 +31,6 @@ from openhands.server.services.conversation_service import create_provider_token from openhands.server.shared import config from openhands.server.user_auth import get_access_token from openhands.server.user_auth.user_auth import get_user_auth -from openhands.utils.posthog_tracker import track_user_signup_completed with warnings.catch_warnings(): warnings.simplefilter('ignore') @@ -370,12 +369,6 @@ async def accept_tos(request: Request): logger.info(f'User {user_id} accepted TOS') - # Track user signup completion in PostHog - track_user_signup_completed( - user_id=user_id, - signup_timestamp=user_settings.accepted_tos.isoformat(), - ) - response = JSONResponse( status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url} ) diff --git a/enterprise/server/routes/billing.py b/enterprise/server/routes/billing.py index f1c0c5376b..5a8b59e2d7 100644 --- a/enterprise/server/routes/billing.py +++ b/enterprise/server/routes/billing.py @@ -28,7 +28,6 @@ from storage.subscription_access import SubscriptionAccess from openhands.server.user_auth import get_user_id from openhands.utils.http_session import httpx_verify_option -from openhands.utils.posthog_tracker import track_credits_purchased stripe.api_key = STRIPE_API_KEY billing_router = APIRouter(prefix='/api/billing') @@ -458,20 +457,6 @@ async def success_callback(session_id: str, request: Request): ) session.commit() - # Track credits purchased in PostHog - try: - track_credits_purchased( - user_id=billing_session.user_id, - amount_usd=amount_subtotal / 100, # Convert cents to dollars - credits_added=add_credits, - stripe_session_id=session_id, - ) - except Exception as e: - logger.warning( - f'Failed to track credits purchase: {e}', - extra={'user_id': billing_session.user_id, 'error': str(e)}, - ) - return RedirectResponse( f'{request.base_url}settings/billing?checkout=success', status_code=302 ) diff --git a/enterprise/tests/unit/test_saas_user_auth.py b/enterprise/tests/unit/test_saas_user_auth.py index 35672af724..d4ba902677 100644 --- a/enterprise/tests/unit/test_saas_user_auth.py +++ b/enterprise/tests/unit/test_saas_user_auth.py @@ -535,3 +535,115 @@ def test_get_api_key_from_header_with_invalid_authorization_format(): # Assert that None was returned assert api_key is None + + +def test_get_api_key_from_header_with_x_access_token(): + """Test that get_api_key_from_header extracts API key from X-Access-Token header.""" + # Create a mock request with X-Access-Token header + mock_request = MagicMock(spec=Request) + mock_request.headers = {'X-Access-Token': 'access_token_key'} + + # Call the function + api_key = get_api_key_from_header(mock_request) + + # Assert that the API key was correctly extracted + assert api_key == 'access_token_key' + + +def test_get_api_key_from_header_priority_authorization_over_x_access_token(): + """Test that Authorization header takes priority over X-Access-Token header.""" + # Create a mock request with both headers + mock_request = MagicMock(spec=Request) + mock_request.headers = { + 'Authorization': 'Bearer auth_api_key', + 'X-Access-Token': 'access_token_key', + } + + # Call the function + api_key = get_api_key_from_header(mock_request) + + # Assert that the API key from Authorization header was used + assert api_key == 'auth_api_key' + + +def test_get_api_key_from_header_priority_x_session_over_x_access_token(): + """Test that X-Session-API-Key header takes priority over X-Access-Token header.""" + # Create a mock request with both headers + mock_request = MagicMock(spec=Request) + mock_request.headers = { + 'X-Session-API-Key': 'session_api_key', + 'X-Access-Token': 'access_token_key', + } + + # Call the function + api_key = get_api_key_from_header(mock_request) + + # Assert that the API key from X-Session-API-Key header was used + assert api_key == 'session_api_key' + + +def test_get_api_key_from_header_all_three_headers(): + """Test header priority when all three headers are present.""" + # Create a mock request with all three headers + mock_request = MagicMock(spec=Request) + mock_request.headers = { + 'Authorization': 'Bearer auth_api_key', + 'X-Session-API-Key': 'session_api_key', + 'X-Access-Token': 'access_token_key', + } + + # Call the function + api_key = get_api_key_from_header(mock_request) + + # Assert that the API key from Authorization header was used (highest priority) + assert api_key == 'auth_api_key' + + +def test_get_api_key_from_header_invalid_authorization_fallback_to_x_access_token(): + """Test that invalid Authorization header falls back to X-Access-Token.""" + # Create a mock request with invalid Authorization header and X-Access-Token + mock_request = MagicMock(spec=Request) + mock_request.headers = { + 'Authorization': 'InvalidFormat api_key', + 'X-Access-Token': 'access_token_key', + } + + # Call the function + api_key = get_api_key_from_header(mock_request) + + # Assert that the API key from X-Access-Token header was used + assert api_key == 'access_token_key' + + +def test_get_api_key_from_header_empty_headers(): + """Test that empty header values are handled correctly.""" + # Create a mock request with empty header values + mock_request = MagicMock(spec=Request) + mock_request.headers = { + 'Authorization': '', + 'X-Session-API-Key': '', + 'X-Access-Token': 'access_token_key', + } + + # Call the function + api_key = get_api_key_from_header(mock_request) + + # Assert that the API key from X-Access-Token header was used + assert api_key == 'access_token_key' + + +def test_get_api_key_from_header_bearer_with_empty_token(): + """Test that Bearer header with empty token falls back to other headers.""" + # Create a mock request with Bearer header with empty token + mock_request = MagicMock(spec=Request) + mock_request.headers = { + 'Authorization': 'Bearer ', + 'X-Access-Token': 'access_token_key', + } + + # Call the function + api_key = get_api_key_from_header(mock_request) + + # Assert that empty string from Bearer is returned (current behavior) + # This tests the current implementation behavior + assert api_key == '' diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 18ba27b40c..2da833345a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,7 +39,7 @@ "jose": "^6.1.0", "lucide-react": "^0.544.0", "monaco-editor": "^0.53.0", - "posthog-js": "^1.290.0", + "posthog-js": "^1.298.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-highlight": "^0.15.0", @@ -3910,9 +3910,9 @@ "license": "MIT" }, "node_modules/@posthog/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.5.2.tgz", - "integrity": "sha512-iedUP3EnOPPxTA2VaIrsrd29lSZnUV+ZrMnvY56timRVeZAXoYCkmjfIs3KBAsF8OUT5h1GXLSkoQdrV0r31OQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.6.0.tgz", + "integrity": "sha512-Tbh8UACwbb7jFdDC7wwXHtfNzO+4wKh3VbyMHmp2UBe6w1jliJixexTJNfkqdGZm+ht3M10mcKvGGPnoZ2zLBg==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6" @@ -14711,12 +14711,12 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.290.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.290.0.tgz", - "integrity": "sha512-zavBwZkf+3JeiSDVE7ZDXBfzva/iOljicdhdJH+cZoqp0LsxjKxjnNhGOd3KpAhw0wqdwjhd7Lp1aJuI7DXyaw==", + "version": "1.298.1", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.298.1.tgz", + "integrity": "sha512-MynFhC2HO6sg5moUfpkd0s6RzAqcqFX75kjIi4Xrj2Gl0/YQWYvFUgvv8FCpWPKPs2mdvNWYhs+oqJg0BVVHPw==", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@posthog/core": "1.5.2", + "@posthog/core": "1.6.0", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", diff --git a/frontend/package.json b/frontend/package.json index 8925a5d391..8e1998a304 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,7 @@ "jose": "^6.1.0", "lucide-react": "^0.544.0", "monaco-editor": "^0.53.0", - "posthog-js": "^1.290.0", + "posthog-js": "^1.298.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-highlight": "^0.15.0", diff --git a/frontend/src/api/conversation-service/v1-conversation-service.types.ts b/frontend/src/api/conversation-service/v1-conversation-service.types.ts index 789925047d..621283c274 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.types.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.types.ts @@ -3,15 +3,19 @@ import { Provider } from "#/types/settings"; import { V1SandboxStatus } from "../sandbox-service/sandbox-service.types"; // V1 API Types for requests -// Note: This represents the serialized API format, not the internal TextContent/ImageContent types -export interface V1MessageContent { - type: "text" | "image_url"; - text?: string; - image_url?: { - url: string; - }; +// These types match the SDK's TextContent and ImageContent formats +export interface V1TextContent { + type: "text"; + text: string; } +export interface V1ImageContent { + type: "image"; + image_urls: string[]; +} + +export type V1MessageContent = V1TextContent | V1ImageContent; + type V1Role = "user" | "system" | "assistant" | "tool"; export interface V1SendMessageRequest { diff --git a/frontend/src/components/features/chat/change-agent-button.tsx b/frontend/src/components/features/chat/change-agent-button.tsx index 6257587963..68a0bd2699 100644 --- a/frontend/src/components/features/chat/change-agent-button.tsx +++ b/frontend/src/components/features/chat/change-agent-button.tsx @@ -12,20 +12,15 @@ import { USE_PLANNING_AGENT } from "#/utils/feature-flags"; import { useAgentState } from "#/hooks/use-agent-state"; import { AgentState } from "#/types/agent-state"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; -import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; -import { displaySuccessToast } from "#/utils/custom-toast-handlers"; import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status"; import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; +import { useHandlePlanClick } from "#/hooks/use-handle-plan-click"; export function ChangeAgentButton() { const [contextMenuOpen, setContextMenuOpen] = useState(false); - const { - conversationMode, - setConversationMode, - setSubConversationTaskId, - subConversationTaskId, - } = useConversationStore(); + const { conversationMode, setConversationMode, subConversationTaskId } = + useConversationStore(); const webSocketStatus = useUnifiedWebSocketStatus(); @@ -40,8 +35,6 @@ export function ChangeAgentButton() { const isAgentRunning = curAgentState === AgentState.RUNNING; const { data: conversation } = useActiveConversation(); - const { mutate: createConversation, isPending: isCreatingConversation } = - useCreateConversation(); // Poll sub-conversation task and invalidate parent conversation when ready useSubConversationTaskPolling( @@ -49,6 +42,9 @@ export function ChangeAgentButton() { conversation?.conversation_id || null, ); + // Get handlePlanClick and isCreatingConversation from custom hook + const { handlePlanClick, isCreatingConversation } = useHandlePlanClick(); + // Close context menu when agent starts running useEffect(() => { if ((isAgentRunning || !isWebSocketConnected) && contextMenuOpen) { @@ -56,45 +52,6 @@ export function ChangeAgentButton() { } }, [isAgentRunning, contextMenuOpen, isWebSocketConnected]); - const handlePlanClick = ( - event: React.MouseEvent | KeyboardEvent, - ) => { - event.preventDefault(); - event.stopPropagation(); - - // Set conversation mode to "plan" immediately - setConversationMode("plan"); - - // Check if sub_conversation_ids is not empty - if ( - (conversation?.sub_conversation_ids && - conversation.sub_conversation_ids.length > 0) || - !conversation?.conversation_id - ) { - // Do nothing if both conditions are true - return; - } - - // Create a new sub-conversation if we have a current conversation ID - createConversation( - { - parentConversationId: conversation.conversation_id, - agentType: "plan", - }, - { - onSuccess: (data) => { - displaySuccessToast( - t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED), - ); - // Track the task ID to poll for sub-conversation creation - if (data.v1_task_id) { - setSubConversationTaskId(data.v1_task_id); - } - }, - }, - ); - }; - const isButtonDisabled = isAgentRunning || isCreatingConversation || diff --git a/frontend/src/components/features/chat/chat-message.tsx b/frontend/src/components/features/chat/chat-message.tsx index a3dc934475..6f2f388682 100644 --- a/frontend/src/components/features/chat/chat-message.tsx +++ b/frontend/src/components/features/chat/chat-message.tsx @@ -1,15 +1,9 @@ import React from "react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; -import { code } from "../markdown/code"; import { cn } from "#/utils/utils"; -import { ul, ol } from "../markdown/list"; import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button"; -import { anchor } from "../markdown/anchor"; import { OpenHandsSourceType } from "#/types/core/base"; -import { paragraph } from "../markdown/paragraph"; import { TooltipButton } from "#/components/shared/buttons/tooltip-button"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; interface ChatMessageProps { type: OpenHandsSourceType; @@ -116,18 +110,7 @@ export function ChatMessage({ wordBreak: "break-word", }} > - - {message} - + {message} {children} diff --git a/frontend/src/components/features/chat/error-message.tsx b/frontend/src/components/features/chat/error-message.tsx index 8de367a9a2..da40b3786e 100644 --- a/frontend/src/components/features/chat/error-message.tsx +++ b/frontend/src/components/features/chat/error-message.tsx @@ -1,13 +1,9 @@ import React from "react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; import { useTranslation } from "react-i18next"; -import { code } from "../markdown/code"; -import { ol, ul } from "../markdown/list"; import ArrowDown from "#/icons/angle-down-solid.svg?react"; import ArrowUp from "#/icons/angle-up-solid.svg?react"; import i18n from "#/i18n"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; interface ErrorMessageProps { errorId?: string; @@ -40,18 +36,7 @@ export function ErrorMessage({ errorId, defaultMessage }: ErrorMessageProps) { - {showDetails && ( - - {defaultMessage} - - )} + {showDetails && {defaultMessage}} ); } diff --git a/frontend/src/components/features/chat/expandable-message.tsx b/frontend/src/components/features/chat/expandable-message.tsx index 918eafd6b8..cf9ae550d2 100644 --- a/frontend/src/components/features/chat/expandable-message.tsx +++ b/frontend/src/components/features/chat/expandable-message.tsx @@ -1,9 +1,6 @@ import { useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; -import Markdown from "react-markdown"; import { Link } from "react-router"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; import { useConfig } from "#/hooks/query/use-config"; import { I18nKey } from "#/i18n/declaration"; import ArrowDown from "#/icons/angle-down-solid.svg?react"; @@ -13,9 +10,7 @@ import XCircle from "#/icons/x-circle-solid.svg?react"; import { OpenHandsAction } from "#/types/core/actions"; import { OpenHandsObservation } from "#/types/core/observations"; import { cn } from "#/utils/utils"; -import { code } from "../markdown/code"; -import { ol, ul } from "../markdown/list"; -import { paragraph } from "../markdown/paragraph"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; import { MonoComponent } from "./mono-component"; import { PathComponent } from "./path-component"; @@ -192,17 +187,7 @@ export function ExpandableMessage({ {showDetails && (
- - {details} - + {details}
)} diff --git a/frontend/src/components/features/chat/generic-event-message.tsx b/frontend/src/components/features/chat/generic-event-message.tsx index e5124b69fe..ff2ab633b1 100644 --- a/frontend/src/components/features/chat/generic-event-message.tsx +++ b/frontend/src/components/features/chat/generic-event-message.tsx @@ -1,13 +1,9 @@ import React from "react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; -import { code } from "../markdown/code"; -import { ol, ul } from "../markdown/list"; import ArrowDown from "#/icons/angle-down-solid.svg?react"; import ArrowUp from "#/icons/angle-up-solid.svg?react"; import { SuccessIndicator } from "./success-indicator"; import { ObservationResultStatus } from "./event-content-helpers/get-observation-result"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; interface GenericEventMessageProps { title: React.ReactNode; @@ -49,16 +45,7 @@ export function GenericEventMessage({ {showDetails && (typeof details === "string" ? ( - - {details} - + {details} ) : ( details ))} diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx index fb77b58242..f44a2f8141 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx @@ -39,7 +39,7 @@ export function ConversationCardFooter({ {(createdAt ?? lastUpdatedAt) && (

)} diff --git a/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx b/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx index a2f9bb4036..c5339183d5 100644 --- a/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx +++ b/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx @@ -31,7 +31,7 @@ export function StartTaskCardFooter({ {createdAt && (

)} diff --git a/frontend/src/components/features/home/recent-conversations/recent-conversation.tsx b/frontend/src/components/features/home/recent-conversations/recent-conversation.tsx index 7fcabe2f1f..d86bac55bf 100644 --- a/frontend/src/components/features/home/recent-conversations/recent-conversation.tsx +++ b/frontend/src/components/features/home/recent-conversations/recent-conversation.tsx @@ -67,12 +67,14 @@ export function RecentConversation({ conversation }: RecentConversationProps) { ) : null} - - {formatTimeDelta( - new Date(conversation.created_at || conversation.last_updated_at), - )}{" "} - {t(I18nKey.CONVERSATION$AGO)} - + {(conversation.created_at || conversation.last_updated_at) && ( + + {formatTimeDelta( + conversation.created_at || conversation.last_updated_at, + )}{" "} + {t(I18nKey.CONVERSATION$AGO)} + + )} diff --git a/frontend/src/components/features/markdown/markdown-renderer.tsx b/frontend/src/components/features/markdown/markdown-renderer.tsx new file mode 100644 index 0000000000..0cb55498d6 --- /dev/null +++ b/frontend/src/components/features/markdown/markdown-renderer.tsx @@ -0,0 +1,80 @@ +import Markdown, { Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkBreaks from "remark-breaks"; +import { code } from "./code"; +import { ul, ol } from "./list"; +import { paragraph } from "./paragraph"; +import { anchor } from "./anchor"; +import { h1, h2, h3, h4, h5, h6 } from "./headings"; + +interface MarkdownRendererProps { + /** + * The markdown content to render. Can be passed as children (string) or content prop. + */ + children?: string; + content?: string; + /** + * Additional or override components for markdown elements. + * Default components (code, ul, ol) are always included unless overridden. + */ + components?: Partial; + /** + * Whether to include standard components (anchor, paragraph). + * Defaults to false. + */ + includeStandard?: boolean; + /** + * Whether to include heading components (h1-h6). + * Defaults to false. + */ + includeHeadings?: boolean; +} + +/** + * A reusable Markdown renderer component that provides consistent + * markdown rendering across the application. + * + * By default, includes: + * - code, ul, ol components + * - remarkGfm and remarkBreaks plugins + * + * Can be extended with: + * - includeStandard: adds anchor and paragraph components + * - includeHeadings: adds h1-h6 heading components + * - components prop: allows custom overrides or additional components + */ +export function MarkdownRenderer({ + children, + content, + components: customComponents, + includeStandard = false, + includeHeadings = false, +}: MarkdownRendererProps) { + // Build the components object with defaults and optional additions + const components: Components = { + code, + ul, + ol, + ...(includeStandard && { + a: anchor, + p: paragraph, + }), + ...(includeHeadings && { + h1, + h2, + h3, + h4, + h5, + h6, + }), + ...customComponents, // Custom components override defaults + }; + + const markdownContent = content ?? children ?? ""; + + return ( + + {markdownContent} + + ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx index dc5b5fecaa..2994946731 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx @@ -1,16 +1,10 @@ import { useTranslation } from "react-i18next"; import { Spinner } from "@heroui/react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; -import { code } from "../markdown/code"; -import { ul, ol } from "../markdown/list"; -import { paragraph } from "../markdown/paragraph"; -import { anchor } from "../markdown/anchor"; import { useMicroagentManagementStore } from "#/state/microagent-management-store"; import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content"; import { I18nKey } from "#/i18n/declaration"; import { extractRepositoryInfo } from "#/utils/utils"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; export function MicroagentManagementViewMicroagentContent() { const { t } = useTranslation(); @@ -49,18 +43,9 @@ export function MicroagentManagementViewMicroagentContent() { )} {microagentData && !isLoading && !error && ( - + {microagentData.content} - + )} ); diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts index a227e99cfc..35d01b0655 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts @@ -184,7 +184,22 @@ const getFinishObservationContent = ( event: ObservationEvent, ): string => { const { observation } = event; - return observation.message || ""; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = ""; + + if (observation.is_error) { + content += `**Error:**\n${textContent}`; + } else { + content += textContent; + } + + return content; }; export const getObservationContent = (event: ObservationEvent): string => { diff --git a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx index 94f35aec66..95c2652549 100644 --- a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx +++ b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx @@ -9,6 +9,7 @@ import { } from "../event-content-helpers/create-skill-ready-event"; import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons"; import { ObservationResultStatus } from "../../../features/chat/event-content-helpers/get-observation-result"; +import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; interface GenericEventMessageWrapperProps { event: OpenHandsEvent | SkillReadyEvent; @@ -23,11 +24,17 @@ export function GenericEventMessageWrapper({ // SkillReadyEvent is not an observation event, so skip the observation checks if (!isSkillReadyEvent(event)) { - if ( - isObservationEvent(event) && - event.observation.kind === "TaskTrackerObservation" - ) { - return
{details}
; + if (isObservationEvent(event)) { + if (event.observation.kind === "TaskTrackerObservation") { + return
{details}
; + } + if (event.observation.kind === "FinishObservation") { + return ( + + {details as string} + + ); + } } } diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index 1d4d9d61a8..ae115c41a9 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -505,7 +505,6 @@ export function ConversationWebSocketProvider({ }, { onSuccess: (fileContent) => { - console.log("File content:", fileContent); setPlanContent(fileContent); }, onError: (error) => { diff --git a/frontend/src/hooks/use-handle-plan-click.ts b/frontend/src/hooks/use-handle-plan-click.ts new file mode 100644 index 0000000000..9734bab8da --- /dev/null +++ b/frontend/src/hooks/use-handle-plan-click.ts @@ -0,0 +1,71 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { useConversationStore } from "#/state/conversation-store"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { displaySuccessToast } from "#/utils/custom-toast-handlers"; + +/** + * Custom hook that encapsulates the logic for handling plan creation. + * Returns a function that can be called to create a plan conversation and + * the pending state of the conversation creation. + * + * @returns An object containing handlePlanClick function and isCreatingConversation boolean + */ +export const useHandlePlanClick = () => { + const { t } = useTranslation(); + const { setConversationMode, setSubConversationTaskId } = + useConversationStore(); + const { data: conversation } = useActiveConversation(); + const { mutate: createConversation, isPending: isCreatingConversation } = + useCreateConversation(); + + const handlePlanClick = useCallback( + (event?: React.MouseEvent | KeyboardEvent) => { + event?.preventDefault(); + event?.stopPropagation(); + + // Set conversation mode to "plan" immediately + setConversationMode("plan"); + + // Check if sub_conversation_ids is not empty + if ( + (conversation?.sub_conversation_ids && + conversation.sub_conversation_ids.length > 0) || + !conversation?.conversation_id + ) { + // Do nothing if both conditions are true + return; + } + + // Create a new sub-conversation if we have a current conversation ID + createConversation( + { + parentConversationId: conversation.conversation_id, + agentType: "plan", + }, + { + onSuccess: (data) => { + displaySuccessToast( + t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED), + ); + // Track the task ID to poll for sub-conversation creation + if (data.v1_task_id) { + setSubConversationTaskId(data.v1_task_id); + } + }, + }, + ); + }, + [ + conversation, + createConversation, + setConversationMode, + setSubConversationTaskId, + t, + ], + ); + + return { handlePlanClick, isCreatingConversation }; +}; diff --git a/frontend/src/hooks/use-send-message.ts b/frontend/src/hooks/use-send-message.ts index 1e1d627181..c6655b8230 100644 --- a/frontend/src/hooks/use-send-message.ts +++ b/frontend/src/hooks/use-send-message.ts @@ -41,13 +41,11 @@ export function useSendMessage() { }, ]; - // Add images if present + // Add images if present - using SDK's ImageContent format if (args.image_urls && args.image_urls.length > 0) { - args.image_urls.forEach((url) => { - content.push({ - type: "image_url", - image_url: { url }, - }); + content.push({ + type: "image", + image_urls: args.image_urls, }); } diff --git a/frontend/src/routes/billing.tsx b/frontend/src/routes/billing.tsx index c004d93dee..05d23fe276 100644 --- a/frontend/src/routes/billing.tsx +++ b/frontend/src/routes/billing.tsx @@ -30,11 +30,12 @@ function BillingSettingsScreen() { } displaySuccessToast(t(I18nKey.PAYMENT$SUCCESS)); + + setSearchParams({}); } else if (checkoutStatus === "cancel") { displayErrorToast(t(I18nKey.PAYMENT$CANCELLED)); + setSearchParams({}); } - - setSearchParams({}); }, [checkoutStatus, searchParams, setSearchParams, t, trackCreditsPurchased]); return ; diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index ca5163f3dd..d78df48cd9 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -28,6 +28,7 @@ import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { getProviderId } from "#/utils/map-provider"; import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models"; +import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags"; interface OpenHandsApiKeyHelpProps { testId: string; @@ -118,6 +119,9 @@ function LlmSettingsScreen() { const isSaasMode = config?.APP_MODE === "saas"; const shouldUseOpenHandsKey = isOpenHandsProvider && isSaasMode; + // Determine if we should hide the agent dropdown when V1 conversation API feature flag is enabled + const isV1Enabled = USE_V1_CONVERSATION_API(); + React.useEffect(() => { const determineWhetherToToggleAdvancedSettings = () => { if (resources && settings) { @@ -612,21 +616,23 @@ function LlmSettingsScreen() { href="https://tavily.com/" /> - ({ - key: agent, - label: agent, // TODO: Add i18n support for agent names - })) || [] - } - defaultSelectedKey={settings.AGENT} - isClearable={false} - onInputChange={handleAgentIsDirty} - wrapperClassName="w-full max-w-[680px]" - /> + {!isV1Enabled && ( + ({ + key: agent, + label: agent, // TODO: Add i18n support for agent names + })) || [] + } + defaultSelectedKey={settings.AGENT} + isClearable={false} + onInputChange={handleAgentIsDirty} + wrapperClassName="w-full max-w-[680px]" + /> + )} )} diff --git a/frontend/src/routes/planner-tab.tsx b/frontend/src/routes/planner-tab.tsx index 6d74472bad..2e5af229ef 100644 --- a/frontend/src/routes/planner-tab.tsx +++ b/frontend/src/routes/planner-tab.tsx @@ -1,24 +1,11 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; import { I18nKey } from "#/i18n/declaration"; import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; import { useConversationStore } from "#/state/conversation-store"; -import { code } from "#/components/features/markdown/code"; -import { ul, ol } from "#/components/features/markdown/list"; -import { paragraph } from "#/components/features/markdown/paragraph"; -import { anchor } from "#/components/features/markdown/anchor"; -import { - h1, - h2, - h3, - h4, - h5, - h6, -} from "#/components/features/markdown/headings"; import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom"; +import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; +import { useHandlePlanClick } from "#/hooks/use-handle-plan-click"; function PlannerTab() { const { t } = useTranslation(); @@ -26,7 +13,8 @@ function PlannerTab() { React.useRef(null), ); - const { planContent, setConversationMode } = useConversationStore(); + const { planContent } = useConversationStore(); + const { handlePlanClick } = useHandlePlanClick(); if (planContent !== null && planContent !== undefined) { return ( @@ -35,24 +23,9 @@ function PlannerTab() { onScroll={(e) => onChatBodyScroll(e.currentTarget)} className="flex flex-col w-full h-full p-4 overflow-auto" > - + {planContent} - + ); } @@ -65,7 +38,7 @@ function PlannerTab() {