From c58e2157eade300e7b518e087cf64b3420f17a5b Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:37:54 +0700 Subject: [PATCH] feat(frontend): display skill ready for v1 conversations (#11815) --- .../create-skill-ready-event.ts | 56 +++++++++ .../get-event-content.tsx | 15 ++- .../get-skill-ready-content.ts | 108 ++++++++++++++++ .../generic-event-message-wrapper.tsx | 33 +++-- .../src/components/v1/chat/event-message.tsx | 115 +++++++++++++++++- frontend/src/i18n/declaration.ts | 1 + frontend/src/i18n/translation.json | 16 +++ 7 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/v1/chat/event-content-helpers/create-skill-ready-event.ts create mode 100644 frontend/src/components/v1/chat/event-content-helpers/get-skill-ready-content.ts diff --git a/frontend/src/components/v1/chat/event-content-helpers/create-skill-ready-event.ts b/frontend/src/components/v1/chat/event-content-helpers/create-skill-ready-event.ts new file mode 100644 index 0000000000..4682b8a90f --- /dev/null +++ b/frontend/src/components/v1/chat/event-content-helpers/create-skill-ready-event.ts @@ -0,0 +1,56 @@ +import { MessageEvent } from "#/types/v1/core"; +import { BaseEvent } from "#/types/v1/core/base/event"; +import { getSkillReadyContent } from "./get-skill-ready-content"; + +/** + * Synthetic event type for Skill Ready events. + * This extends BaseEvent and includes a marker to identify it as a skill ready event. + */ +export interface SkillReadyEvent extends BaseEvent { + _isSkillReadyEvent: true; + _skillReadyContent: string; +} + +/** + * Type guard for Skill Ready events. + */ +export const isSkillReadyEvent = (event: unknown): event is SkillReadyEvent => + typeof event === "object" && + event !== null && + "_isSkillReadyEvent" in event && + event._isSkillReadyEvent === true; + +/** + * Creates a synthetic "Skill Ready" event from a user MessageEvent. + * This event appears as originating from the agent and contains formatted + * information about activated skills and extended content. + */ +export const createSkillReadyEvent = ( + userEvent: MessageEvent, +): SkillReadyEvent => { + // Support both activated_skills and activated_microagents field names + const activatedSkills = + (userEvent as unknown as { activated_skills?: string[] }) + .activated_skills || + userEvent.activated_microagents || + []; + + const extendedContent = userEvent.extended_content || []; + + // Only create event if we have skills or extended content + if (activatedSkills.length === 0 && extendedContent.length === 0) { + throw new Error( + "Cannot create skill ready event without activated skills or extended content", + ); + } + + const content = getSkillReadyContent(activatedSkills, extendedContent); + + return { + id: `${userEvent.id}-skill-ready`, + timestamp: userEvent.timestamp, + source: "agent", + _isSkillReadyEvent: true, + _skillReadyContent: content, + }; +}; diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx index 7eab7df1a7..5c93b11be5 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx +++ b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx @@ -8,6 +8,7 @@ import { getActionContent } from "./get-action-content"; import { getObservationContent } from "./get-observation-content"; import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content"; import { TaskTrackerObservation } from "#/types/v1/core/base/observation"; +import { SkillReadyEvent, isSkillReadyEvent } from "./create-skill-ready-event"; import i18n from "#/i18n"; const trimText = (text: string, maxLength: number): string => { @@ -159,11 +160,21 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => { return observationType; }; -export const getEventContent = (event: OpenHandsEvent) => { +export const getEventContent = (event: OpenHandsEvent | SkillReadyEvent) => { let title: React.ReactNode = ""; let details: string | React.ReactNode = ""; - if (isActionEvent(event)) { + // Handle Skill Ready events first + if (isSkillReadyEvent(event)) { + // Use translation key if available, otherwise use "SKILL READY" + const skillReadyKey = "OBSERVATION_MESSAGE$SKILL_READY"; + if (i18n.exists(skillReadyKey)) { + title = createTitleFromKey(skillReadyKey, {}); + } else { + title = "Skill Ready"; + } + details = event._skillReadyContent; + } else if (isActionEvent(event)) { title = getActionEventTitle(event); details = getActionContent(event); } else if (isObservationEvent(event)) { diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-skill-ready-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-skill-ready-content.ts new file mode 100644 index 0000000000..5f4b14f846 --- /dev/null +++ b/frontend/src/components/v1/chat/event-content-helpers/get-skill-ready-content.ts @@ -0,0 +1,108 @@ +import { TextContent } from "#/types/v1/core/base/common"; + +/** + * Extracts all text content from an array of TextContent items. + */ +const extractAllText = (extendedContent: TextContent[]): string => + extendedContent + .filter((c) => c.type === "text") + .map((c) => c.text) + .join(""); + +/** + * Extracts all blocks from the given text. + * Returns an array of content strings (without the wrapper tags). + */ +const extractExtraInfoBlocks = (text: string): string[] => { + const blocks: string[] = []; + const blockRegex = /([\s\S]*?)<\/EXTRA_INFO>/gi; + let match = blockRegex.exec(text); + + while (match !== null) { + const blockContent = match[1].trim(); + if (blockContent.length > 0) { + blocks.push(blockContent); + } + match = blockRegex.exec(text); + } + + return blocks; +}; + +/** + * Formats a single skill with its corresponding content block. + */ +const formatSkillWithContent = ( + skill: string, + contentBlock: string | undefined, +): string => { + let formatted = `\n\n- **${skill}**`; + + if (contentBlock && contentBlock.trim().length > 0) { + formatted += `\n\n${contentBlock}`; + } + + return formatted; +}; + +/** + * Formats skills paired with their corresponding extended content blocks. + */ +const formatSkillKnowledge = ( + activatedSkills: string[], + extraInfoBlocks: string[], +): string => { + let content = `\n\n**Triggered Skill Knowledge:**`; + + activatedSkills.forEach((skill, index) => { + const contentBlock = + index < extraInfoBlocks.length ? extraInfoBlocks[index] : undefined; + content += formatSkillWithContent(skill, contentBlock); + }); + + return content; +}; + +/** + * Formats extended content blocks when no skills are present. + */ +const formatExtendedContentOnly = (extraInfoBlocks: string[]): string => { + let content = `\n\n**Extended Content:**`; + + extraInfoBlocks.forEach((block) => { + if (block.trim().length > 0) { + content += `\n\n${block}`; + } + }); + + return content; +}; + +/** + * Formats activated skills and extended content into markdown for display. + * Similar to how v0 formats microagent knowledge in recall observations. + * + * Each skill is paired with its corresponding block by index. + */ +export const getSkillReadyContent = ( + activatedSkills: string[], + extendedContent: TextContent[], +): string => { + // Extract all blocks from extended_content + const extraInfoBlocks: string[] = []; + if (extendedContent && extendedContent.length > 0) { + const allText = extractAllText(extendedContent); + extraInfoBlocks.push(...extractExtraInfoBlocks(allText)); + } + + // Format output based on what we have + if (activatedSkills && activatedSkills.length > 0) { + return formatSkillKnowledge(activatedSkills, extraInfoBlocks); + } + + if (extraInfoBlocks.length > 0) { + return formatExtendedContentOnly(extraInfoBlocks); + } + + return ""; +}; 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 c4c53f7f1a..94f35aec66 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 @@ -3,10 +3,15 @@ import { GenericEventMessage } from "../../../features/chat/generic-event-messag import { getEventContent } from "../event-content-helpers/get-event-content"; import { getObservationResult } from "../event-content-helpers/get-observation-result"; import { isObservationEvent } from "#/types/v1/type-guards"; +import { + SkillReadyEvent, + isSkillReadyEvent, +} 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"; interface GenericEventMessageWrapperProps { - event: OpenHandsEvent; + event: OpenHandsEvent | SkillReadyEvent; isLastMessage: boolean; } @@ -16,11 +21,23 @@ export function GenericEventMessageWrapper({ }: GenericEventMessageWrapperProps) { const { title, details } = getEventContent(event); - if ( - isObservationEvent(event) && - event.observation.kind === "TaskTrackerObservation" - ) { - return
{details}
; + // SkillReadyEvent is not an observation event, so skip the observation checks + if (!isSkillReadyEvent(event)) { + if ( + isObservationEvent(event) && + event.observation.kind === "TaskTrackerObservation" + ) { + return
{details}
; + } + } + + // Determine success status + let success: ObservationResultStatus | undefined; + if (isSkillReadyEvent(event)) { + // Skill Ready events should show success indicator, same as v0 recall observations + success = "success"; + } else if (isObservationEvent(event)) { + success = getObservationResult(event); } return ( @@ -28,9 +45,7 @@ export function GenericEventMessageWrapper({ {isLastMessage && } diff --git a/frontend/src/components/v1/chat/event-message.tsx b/frontend/src/components/v1/chat/event-message.tsx index 637a0063c5..ce0380c9c8 100644 --- a/frontend/src/components/v1/chat/event-message.tsx +++ b/frontend/src/components/v1/chat/event-message.tsx @@ -5,6 +5,7 @@ import { isActionEvent, isObservationEvent, isAgentErrorEvent, + isUserMessageEvent, } from "#/types/v1/type-guards"; import { MicroagentStatus } from "#/types/microagent-status"; import { useConfig } from "#/hooks/query/use-config"; @@ -17,6 +18,7 @@ import { GenericEventMessageWrapper, ThoughtEventMessage, } from "./event-message-components"; +import { createSkillReadyEvent } from "./event-content-helpers/create-skill-ready-event"; interface EventMessageProps { event: OpenHandsEvent & { isFromPlanningAgent?: boolean }; @@ -33,6 +35,104 @@ interface EventMessageProps { isInLast10Actions: boolean; } +/** + * Extracts activated skills from a MessageEvent, supporting both + * activated_skills and activated_microagents field names. + */ +const getActivatedSkills = (event: MessageEvent): string[] => + (event as unknown as { activated_skills?: string[] }).activated_skills || + event.activated_microagents || + []; + +/** + * Checks if extended content contains valid text content. + */ +const hasValidExtendedContent = ( + extendedContent: MessageEvent["extended_content"], +): boolean => { + if (!extendedContent || extendedContent.length === 0) { + return false; + } + + return extendedContent.some( + (content) => content.type === "text" && content.text.trim().length > 0, + ); +}; + +/** + * Determines if a Skill Ready event should be displayed for the given message event. + */ +const shouldShowSkillReadyEvent = (messageEvent: MessageEvent): boolean => { + const activatedSkills = getActivatedSkills(messageEvent); + const hasActivatedSkills = activatedSkills.length > 0; + const hasExtendedContent = hasValidExtendedContent( + messageEvent.extended_content, + ); + + return hasActivatedSkills && hasExtendedContent; +}; + +interface CommonProps { + microagentStatus?: MicroagentStatus | null; + microagentConversationId?: string; + microagentPRUrl?: string; + actions?: Array<{ + icon: React.ReactNode; + onClick: () => void; + tooltip?: string; + }>; + isLastMessage: boolean; + isInLast10Actions: boolean; + config: unknown; + isCheckingFeedback: boolean; + feedbackData: { exists: boolean }; + isFromPlanningAgent: boolean; +} + +/** + * Renders a user message with its corresponding Skill Ready event. + */ +const renderUserMessageWithSkillReady = ( + messageEvent: MessageEvent, + commonProps: CommonProps, + isLastMessage: boolean, +): React.ReactElement => { + try { + const skillReadyEvent = createSkillReadyEvent(messageEvent); + return ( + <> + + + + ); + } catch (error) { + // If skill ready event creation fails, just render the user message + console.error("Failed to create skill ready event:", error); + return ( + + ); + } +}; + /* eslint-disable react/jsx-props-no-spreading */ export function EventMessage({ event, @@ -118,10 +218,21 @@ export function EventMessage({ // Message events (user and assistant messages) if (!isActionEvent(event) && !isObservationEvent(event)) { - // This is a MessageEvent + const messageEvent = event as MessageEvent; + + // Check if this is a user message that should display a Skill Ready event + if (isUserMessageEvent(event) && shouldShowSkillReadyEvent(messageEvent)) { + return renderUserMessageWithSkillReady( + messageEvent, + commonProps, + isLastMessage, + ); + } + + // Render normal message event (user or assistant) return ( diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index b62dd9c1d9..420709ef9b 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -954,4 +954,5 @@ export enum I18nKey { COMMON$CODE_AGENT_DESCRIPTION = "COMMON$CODE_AGENT_DESCRIPTION", COMMON$PLAN_AGENT_DESCRIPTION = "COMMON$PLAN_AGENT_DESCRIPTION", PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED = "PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED", + OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index c43bb8dc07..2278092e8e 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -15262,5 +15262,21 @@ "tr": "Planlama ajanı başlatıldı", "de": "Planungsagent wurde initialisiert", "uk": "Агент планування ініціалізовано" + }, + "OBSERVATION_MESSAGE$SKILL_READY": { + "en": "Skill Ready", + "ja": "スキル準備完了", + "zh-CN": "技能已就绪", + "zh-TW": "技能已就緒", + "ko-KR": "스킬 준비 완료", + "no": "Ferdighet klar", + "it": "Abilità pronta", + "pt": "Habilidade pronta", + "es": "Habilidad lista", + "ar": "المهارة جاهزة", + "fr": "Compétence prête", + "tr": "Yetenek hazır", + "de": "Fähigkeit bereit", + "uk": "Навичка готова" } }