feat(frontend): display skill ready for v1 conversations (#11815)

This commit is contained in:
Hiep Le 2025-11-25 23:37:54 +07:00 committed by GitHub
parent 9cc8687271
commit c58e2157ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 331 additions and 13 deletions

View File

@ -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,
};
};

View File

@ -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)) {

View File

@ -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 <EXTRA_INFO> 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 = /<EXTRA_INFO>([\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 <EXTRA_INFO> block by index.
*/
export const getSkillReadyContent = (
activatedSkills: string[],
extendedContent: TextContent[],
): string => {
// Extract all <EXTRA_INFO> 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 "";
};

View File

@ -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,21 +21,31 @@ export function GenericEventMessageWrapper({
}: GenericEventMessageWrapperProps) {
const { title, details } = getEventContent(event);
// SkillReadyEvent is not an observation event, so skip the observation checks
if (!isSkillReadyEvent(event)) {
if (
isObservationEvent(event) &&
event.observation.kind === "TaskTrackerObservation"
) {
return <div>{details}</div>;
}
}
// 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 (
<div>
<GenericEventMessage
title={title}
details={details}
success={
isObservationEvent(event) ? getObservationResult(event) : undefined
}
success={success}
initiallyExpanded={false}
/>
{isLastMessage && <V1ConfirmationButtons />}

View File

@ -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 (
<>
<UserAssistantEventMessage
event={messageEvent}
microagentStatus={commonProps.microagentStatus}
microagentConversationId={commonProps.microagentConversationId}
microagentPRUrl={commonProps.microagentPRUrl}
actions={commonProps.actions}
isLastMessage={false}
isFromPlanningAgent={commonProps.isFromPlanningAgent}
/>
<GenericEventMessageWrapper
event={skillReadyEvent}
isLastMessage={isLastMessage}
/>
</>
);
} catch (error) {
// If skill ready event creation fails, just render the user message
console.error("Failed to create skill ready event:", error);
return (
<UserAssistantEventMessage
event={messageEvent}
microagentStatus={commonProps.microagentStatus}
microagentConversationId={commonProps.microagentConversationId}
microagentPRUrl={commonProps.microagentPRUrl}
actions={commonProps.actions}
isLastMessage={isLastMessage}
isFromPlanningAgent={commonProps.isFromPlanningAgent}
/>
);
}
};
/* 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 (
<UserAssistantEventMessage
event={event as MessageEvent}
event={messageEvent}
{...commonProps}
isLastMessage={isLastMessage}
/>

View File

@ -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",
}

View File

@ -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": "Навичка готова"
}
}