mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): display skill ready for v1 conversations (#11815)
This commit is contained in:
parent
9cc8687271
commit
c58e2157ea
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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)) {
|
||||
|
||||
@ -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 "";
|
||||
};
|
||||
@ -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 />}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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": "Навичка готова"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user