diff --git a/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/config.py b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/config.py index bb8b5b7f..c62cc846 100644 --- a/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/config.py +++ b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/config.py @@ -1,61 +1,13 @@ -import json -from typing import TYPE_CHECKING, Annotated, Any, Dict, List, Type +from typing import Annotated -from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit -from pydantic import BaseModel, Field, create_model +from pydantic import BaseModel, Field from semantic_workbench_assistant.config import UISchema -from . import config_defaults as config_defaults - -if TYPE_CHECKING: - pass - - -# -# region Helpers -# - -# take a full json schema and return a pydantic model, including support for -# nested objects and typed arrays - - -def json_type_to_python_type(json_type: str) -> Type: - # Mapping JSON types to Python types - type_mapping = {"integer": int, "string": str, "number": float, "boolean": bool, "object": dict, "array": list} - return type_mapping.get(json_type, Any) - - -def create_pydantic_model_from_json_schema(schema: Dict[str, Any], model_name="DynamicModel") -> Type[BaseModel]: - # Nested function to parse properties from the schema - def parse_properties(properties: Dict[str, Any]) -> Dict[str, Any]: - fields = {} - for prop_name, prop_attrs in properties.items(): - prop_type = prop_attrs.get("type") - description = prop_attrs.get("description", None) - - if prop_type == "object": - nested_model = create_pydantic_model_from_json_schema(prop_attrs, model_name=prop_name.capitalize()) - fields[prop_name] = (nested_model, Field(..., description=description)) - elif prop_type == "array": - items = prop_attrs.get("items", {}) - if items.get("type") == "object": - nested_model = create_pydantic_model_from_json_schema(items) - fields[prop_name] = (List[nested_model], Field(..., description=description)) - else: - nested_type = json_type_to_python_type(items.get("type")) - fields[prop_name] = (List[nested_type], Field(..., description=description)) - else: - python_type = json_type_to_python_type(prop_type) - fields[prop_name] = (python_type, Field(..., description=description)) - return fields - - properties = schema.get("properties", {}) - fields = parse_properties(properties) - return create_model(model_name, **fields) - - -# endregion - +from .definition import GuidedConversationDefinition +from .definitions.er_triage import er_triage +from .definitions.interview import interview +from .definitions.patient_intake import patient_intake +from .definitions.poem_feedback import poem_feedback # # region Models @@ -63,78 +15,25 @@ def parse_properties(properties: Dict[str, Any]) -> Dict[str, Any]: class GuidedConversationAgentConfigModel(BaseModel): - artifact: Annotated[ - str, - Field( - title="Artifact", - description="The artifact that the agent will manage.", - ), - UISchema(widget="baseModelEditor"), - ] = json.dumps(config_defaults.ArtifactModel.model_json_schema(), indent=2) - - rules: Annotated[ - list[str], - Field(title="Rules", description="Do's and don'ts that the agent should attempt to follow"), - UISchema(schema={"items": {"ui:widget": "textarea", "ui:options": {"rows": 2}}}), - ] = config_defaults.rules - - conversation_flow: Annotated[ - str, - Field( - title="Conversation Flow", - description="A loose natural language description of the steps of the conversation", - ), - UISchema(widget="textarea", schema={"ui:options": {"rows": 10}}, placeholder="[optional]"), - ] = config_defaults.conversation_flow.strip() - - context: Annotated[ - str, + definition: Annotated[ + GuidedConversationDefinition, Field( - title="Context", - description="General background context for the conversation.", + title="Definition", + description="The definition of the guided conversation agent.", ), - UISchema(widget="textarea", placeholder="[optional]"), - ] = config_defaults.context.strip() - - class ResourceConstraint(ResourceConstraint): - mode: Annotated[ - ResourceConstraintMode, - Field( - title="Resource Mode", - description=( - 'If "exact", the agents will try to pace the conversation to use exactly the resource quantity. If' - ' "maximum", the agents will try to pace the conversation to use at most the resource quantity.' - ), - ), - ] = config_defaults.resource_constraint.mode - - unit: Annotated[ - ResourceConstraintUnit, - Field( - title="Resource Unit", - description="The unit for the resource constraint.", - ), - ] = config_defaults.resource_constraint.unit - - quantity: Annotated[ - float, - Field( - title="Resource Quantity", - description="The quantity for the resource constraint. If <=0, the resource constraint is disabled.", - ), - ] = config_defaults.resource_constraint.quantity - - resource_constraint: Annotated[ - ResourceConstraint, - Field( - title="Resource Constraint", + UISchema( + schema={ + "ui:options": { + "configurations": { + "Poem Feedback": poem_feedback.model_dump(mode="json"), + "Interview": interview.model_dump(mode="json"), + "Patient Intake": patient_intake.model_dump(mode="json"), + "ER Triage": er_triage.model_dump(mode="json"), + }, + }, + }, ), - UISchema(schema={"quantity": {"ui:widget": "updown"}}), - ] = ResourceConstraint() - - def get_artifact_model(self) -> Type[BaseModel]: - schema = json.loads(self.artifact) - return create_pydantic_model_from_json_schema(schema) + ] = poem_feedback # endregion diff --git a/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definition.py b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definition.py new file mode 100644 index 00000000..5deac38e --- /dev/null +++ b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definition.py @@ -0,0 +1,192 @@ +import json +from typing import TYPE_CHECKING, Annotated, Any, Dict, List, Optional, Type, Union + +from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit +from pydantic import BaseModel, Field, create_model +from semantic_workbench_assistant.config import UISchema + +if TYPE_CHECKING: + pass + + +# +# region Helpers +# + +# take a full json schema and return a pydantic model, including support for +# nested objects and typed arrays + + +def parse_json_schema_type(schema_type: Union[str, List[str]]) -> Any: + """Map JSON schema types to Python (Pydantic) types.""" + if isinstance(schema_type, list): + if "null" in schema_type: + schema_type = [t for t in schema_type if t != "null"] + return Optional[parse_json_schema_type(schema_type[0])] + + if schema_type == "string": + return str + elif schema_type == "integer": + return int + elif schema_type == "number": + return float + elif schema_type == "boolean": + return bool + elif schema_type == "array": + return List[Any] + elif schema_type == "object": + return Dict[str, Any] + + return Any + + +def resolve_ref(ref: str, schema: Dict[str, Any], definitions: Dict[str, Any] | None) -> Dict[str, Any]: + """ + Resolves a $ref to the corresponding definition in the schema or definitions. + """ + if definitions is None: + raise ValueError("Definitions must be provided to resolve $ref") + + ref_path = ref.split("/") # Ref paths are typically '#/$defs/SomeType' + if ref_path[0] == "#": # Local reference + ref_path = ref_path[1:] # Strip the '#' + + current = schema # Start from the root schema + for part in ref_path: + if part == "$defs" and part in definitions: + current = definitions # Switch to definitions when we hit $defs + else: + current = current[part] # Walk down the path + + return current + + +def create_pydantic_model_from_json_schema( + schema: Dict[str, Any], model_name: str = "GeneratedModel", definitions: Dict[str, Any] | None = None +) -> Type[BaseModel]: + """ + Recursively converts a JSON schema to a Pydantic BaseModel. + Handles $defs for local definitions and $ref for references. + """ + if definitions is None: + definitions = schema.get("$defs", {}) # Gather $defs if they exist + + fields = {} + + if "properties" in schema: + for field_name, field_schema in schema["properties"].items(): + if "$ref" in field_schema: # Resolve $ref + ref_schema = resolve_ref(field_schema["$ref"], schema, definitions) + field_type = create_pydantic_model_from_json_schema( + ref_schema, model_name=field_name.capitalize(), definitions=definitions + ) + + else: + field_type = parse_json_schema_type(field_schema.get("type", "any")) + + if "items" in field_schema: # If array, handle item type + item_type = parse_json_schema_type(field_schema["items"].get("type", "any")) + field_type = List[item_type] + + if "properties" in field_schema: # If object, generate sub-model + sub_model = create_pydantic_model_from_json_schema( + field_schema, model_name=field_name.capitalize(), definitions=definitions + ) + field_type = sub_model + + # Check if field is required + is_required = field_name in schema.get("required", []) + if is_required: + fields[field_name] = (field_type, ...) + else: + fields[field_name] = (Optional[field_type], None) + + # Dynamically create the Pydantic model + return create_model(model_name, **fields) + + +# endregion + + +# +# region Models +# + + +class GuidedConversationDefinition(BaseModel): + artifact: Annotated[ + str, + Field( + title="Artifact", + description="The artifact that the agent will manage.", + ), + UISchema(widget="baseModelEditor"), + ] + + rules: Annotated[ + list[str], + Field(title="Rules", description="Do's and don'ts that the agent should attempt to follow"), + UISchema(schema={"items": {"ui:widget": "textarea", "ui:options": {"rows": 2}}}), + ] = [] + + conversation_flow: Annotated[ + str, + Field( + title="Conversation Flow", + description="A loose natural language description of the steps of the conversation", + ), + UISchema(widget="textarea", schema={"ui:options": {"rows": 10}}, placeholder="[optional]"), + ] = "" + + context: Annotated[ + str, + Field( + title="Context", + description="General background context for the conversation.", + ), + UISchema(widget="textarea", placeholder="[optional]"), + ] = "" + + # override the default resource constraint to add annotations + class ResourceConstraint(ResourceConstraint): + mode: Annotated[ + ResourceConstraintMode, + Field( + title="Resource Mode", + description=( + 'If "exact", the agents will try to pace the conversation to use exactly the resource quantity. If' + ' "maximum", the agents will try to pace the conversation to use at most the resource quantity.' + ), + ), + ] + + unit: Annotated[ + ResourceConstraintUnit, + Field( + title="Resource Unit", + description="The unit for the resource constraint.", + ), + ] + + quantity: Annotated[ + float, + Field( + title="Resource Quantity", + description="The quantity for the resource constraint. If <=0, the resource constraint is disabled.", + ), + ] + + resource_constraint: Annotated[ + ResourceConstraint, + Field( + title="Resource Constraint", + ), + UISchema(schema={"quantity": {"ui:widget": "updown"}}), + ] + + def get_artifact_model(self) -> Type[BaseModel]: + schema = json.loads(self.artifact) + return create_pydantic_model_from_json_schema(schema) + + +# endregion diff --git a/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/er_triage.py b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/er_triage.py new file mode 100644 index 00000000..69335f23 --- /dev/null +++ b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/er_triage.py @@ -0,0 +1,78 @@ +import json + +from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit +from pydantic import BaseModel, Field + +from ..definition import GuidedConversationDefinition + + +# Define nested models for emergency room triage +class PersonalInformation(BaseModel): + name: str = Field(description="The full name of the patient in 'First Last' format.") + sex: str = Field(description="Sex of the patient (M for male, F for female).") + date_of_birth: str = Field(description="The patient's date of birth in 'MM-DD-YYYY' format.") + phone: str = Field(description="The patient's primary phone number in 'XXX-XXX-XXXX' format.") + + +class Artifact(BaseModel): + personal_information: PersonalInformation = Field( + description="The patient's personal information, including name, sex, date of birth, and phone." + ) + chief_complaint: str = Field(description="The main reason the patient is seeking medical attention.") + symptoms: list[str] = Field(description="List of symptoms the patient is currently experiencing.") + medications: list[str] = Field(description="List of medications the patient is currently taking.") + medical_history: list[str] = Field(description="Relevant medical history including diagnoses, surgeries, etc.") + esi_level: int = Field(description="The Emergency Severity Index (ESI) level, an integer between 1 and 5.") + resource_needs: list[str] = Field(description="A list of resources or interventions needed.") + + +# Rules - Guidelines for triage conversations +rules = [ + "DO NOT provide medical advice.", + "Terminate the conversation if inappropriate content is requested.", + "Begin by collecting basic information such as name and date of birth to quickly identify the patient.", + "Prioritize collecting the chief complaint and symptoms to assess the immediate urgency.", + "Gather relevant medical history and current medications that might affect the patient's condition.", + "If time permits, inquire about additional resource needs for patient care.", + "Maintain a calm and reassuring demeanor to help put patients at ease during questioning.", + "Focus questions to ensure the critical information needed for ESI assignment is collected first.", + "Move urgently but efficiently through questions to minimize patient wait time during triage.", + "Ensure confidentiality and handle all patient information securely.", +] + +# Conversation Flow - Steps for the triage process +conversation_flow = """ +1. Greet the patient and explain the purpose of collecting medical information for triage, quickly begin by collecting basic identifying information such as name and date of birth. +2. Ask about the chief complaint to understand the primary reason for the visit. +3. Inquire about current symptoms the patient is experiencing. +4. Gather relevant medical history, including past diagnoses, surgeries, and hospitalizations. +5. Ask the patient about any medications they are currently taking. +6. Determine if there are any specific resources or interventions needed immediately. +7. Evaluate the collected information to determine the Emergency Severity Index (ESI) level. +8. Reassure the patient and inform them of the next steps in their care as quickly as possible. +""" + +# Context - Additional information for the triage process +context = """ +Assisting patients in providing essential information during emergency room triage in a medical setting. +""" + +# Resource Constraints - Defines the constraints like time for the conversation +resource_constraint = ResourceConstraint( + quantity=10, + unit=ResourceConstraintUnit.MINUTES, + mode=ResourceConstraintMode.MAXIMUM, +) + +# Create instance of the GuidedConversationDefinition model with the above configuration. +er_triage = GuidedConversationDefinition( + artifact=json.dumps(Artifact.model_json_schema(), indent=2), + rules=rules, + conversation_flow=conversation_flow, + context=context, + resource_constraint=GuidedConversationDefinition.ResourceConstraint( + quantity=10, + unit=ResourceConstraintUnit.MINUTES, + mode=ResourceConstraintMode.MAXIMUM, + ), +) diff --git a/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/interview.py b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/interview.py new file mode 100644 index 00000000..8a5911ec --- /dev/null +++ b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/interview.py @@ -0,0 +1,69 @@ +import json + +from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit +from pydantic import BaseModel, Field + +from ..definition import GuidedConversationDefinition + + +# Define models for candidate evaluation +class Artifact(BaseModel): + customer_service_orientation: str = Field(description="A rating of the candidate's customer service orientation.") + communication: str = Field(description="A rating of the candidate's communication skills.") + problem_solving: str = Field(description="A rating of the candidate's problem-solving abilities.") + stress_management: str = Field(description="A rating of the candidate's stress management skills.") + overall_recommendation: str = Field(description="An overall recommendation for hiring the candidate.") + additional_comments: str = Field(description="Additional comments or observations.") + + +# Rules - Guidelines for the conversation +rules = [ + "DO NOT ask inappropriate personal questions.", + "Terminate conversation if inappropriate content is requested.", + "Ask all questions objectively and consistently for each candidate.", + "Avoid leading questions that may influence the candidate's responses.", + "Maintain a professional and neutral demeanor throughout the interview.", + "Allow candidates time to think and respond to questions thoroughly.", + "Record observations accurately without personal bias.", + "Ensure feedback focuses on professional skills and competencies.", + "Respect confidentiality and handle candidate information securely.", +] + +# Conversation Flow - Steps for interviewing candidates +conversation_flow = """ +1. Begin with a brief introduction and explain the interview process. +2. Discuss the candidate's understanding of customer service practices and evaluate their orientation. +3. Assess the candidate's communication skills by asking about their experience in clear, effective communication. +4. Present a scenario to gauge the candidate's problem-solving abilities and ask for their approach. +5. Explore the candidate's stress management techniques through situational questions. +6. Ask for any additional information or comments the candidate would like to share. +7. Conclude the interview by expressing appreciation for their time and informing them that they will be contacted within one week with a decision or further steps. +""" + +# Context - Additional information for the conversation +context = """ +You are an AI assistant that runs part of a structured job interview process aimed at evaluating candidates for a customer service role. +The focus is on assessing key competencies such as customer service orientation, communication, problem-solving, and stress management. +The interaction should be conducted in a fair and professional manner, ensuring candidates have the opportunity to demonstrate their skills. +Feedback and observations will be used to make informed hiring decisions. +""" + +# Resource Constraints - Defines time limits for the conversation +resource_constraint = ResourceConstraint( + quantity=30, + unit=ResourceConstraintUnit.MINUTES, + mode=ResourceConstraintMode.MAXIMUM, +) + +# Create instance of the GuidedConversationDefinition model with the above configuration. +interview = GuidedConversationDefinition( + artifact=json.dumps(Artifact.model_json_schema(), indent=2), + rules=rules, + conversation_flow=conversation_flow, + context=context, + resource_constraint=GuidedConversationDefinition.ResourceConstraint( + quantity=30, + unit=ResourceConstraintUnit.MINUTES, + mode=ResourceConstraintMode.MAXIMUM, + ), +) diff --git a/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/patient_intake.py b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/patient_intake.py new file mode 100644 index 00000000..a02d0165 --- /dev/null +++ b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/patient_intake.py @@ -0,0 +1,74 @@ +import json + +from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit +from pydantic import BaseModel, Field + +from ..definition import GuidedConversationDefinition + + +# Artifact - The artifact is like a form that the agent must complete throughout the conversation. +# It can also be thought of as a working memory for the agent. +# We allow any valid Pydantic BaseModel class to be used. +# Define nested models for personal information +class PersonalInformation(BaseModel): + name: str = Field(description="The full name of the patient.") + date_of_birth: str = Field( + description="The patient's date of birth in 'MM-DD-YYYY' format.", + ) + phone_number: str = Field( + description="The patient's phone number in 'XXX-XXX-XXXX' format.", + ) + email: str = Field(description="The patient's email address.") + + +class PatientIntakeArtifact(BaseModel): + personal_information: PersonalInformation = Field( + description="The patient's personal information, including name, date of birth, phone number, and email." + ) + list_of_symptoms: list[dict] = Field(description="List of symptoms with details and affected area.") + list_of_medications: list[dict] = Field(description="List of medications with name, dosage, and frequency.") + + +# Rules - These are the do's and don'ts that the agent should follow during the conversation. +rules = ["DO NOT provide medical advice.", "Terminate conversation if inappropriate content is requested."] + +# Conversation Flow (optional) - This defines in natural language the steps of the conversation. +conversation_flow = """ +1. Inform the patient that the information collected will be shared with their doctor. +2. Collect the patient's personal information, including their full name, date of birth, phone number, and email address. +3. Ask the patient about any symptoms they are experiencing and record the details along with the affected area. +4. Inquire about any medications, including the name, dosage, and frequency, that the patient is currently taking. +5. Confirm with the patient that all symptoms and medications have been reported. +6. Advise the patient to wait for their doctor for any further consultation or questions. +""" + +# Context (optional) - This is any additional information or the circumstances the agent is in that it should be aware of. +# It can also include the high level goal of the conversation if needed. +context = """ +You are an AI assistant that runs the new patient intake process at a doctor's office. +The purpose is to collect comprehensive information about the patient's symptoms, medications, and personal details. +This data will be shared with the doctor to facilitate a thorough consultation. The interaction is conducted in a respectful +and confidential manner to ensure patient comfort and compliance. +""" + +# Resource Constraints (optional) - This defines the constraints on the conversation such as time or turns. +# It can also help with pacing the conversation, +# For example, here we have set an exact time limit of 10 turns which the agent will try to fill. +resource_constraint = ResourceConstraint( + quantity=15, + unit=ResourceConstraintUnit.MINUTES, + mode=ResourceConstraintMode.MAXIMUM, +) + +# Create instance of the GuidedConversationDefinition model with the above configuration. +patient_intake = GuidedConversationDefinition( + artifact=json.dumps(PatientIntakeArtifact.model_json_schema(), indent=2), + rules=rules, + conversation_flow=conversation_flow.strip(), + context=context.strip(), + resource_constraint=GuidedConversationDefinition.ResourceConstraint( + quantity=15, + unit=ResourceConstraintUnit.MINUTES, + mode=ResourceConstraintMode.MAXIMUM, + ), +) diff --git a/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/config_defaults.py b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/poem_feedback.py similarity index 84% rename from assistants/guided-conversation-assistant/assistant/agents/guided_conversation/config_defaults.py rename to assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/poem_feedback.py index 7994acac..d8822ee0 100644 --- a/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/config_defaults.py +++ b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation/definitions/poem_feedback.py @@ -1,10 +1,9 @@ -from typing import TYPE_CHECKING +import json from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit from pydantic import BaseModel, Field -if TYPE_CHECKING: - pass +from ..definition import GuidedConversationDefinition # Artifact - The artifact is like a form that the agent must complete throughout the conversation. @@ -64,10 +63,15 @@ class ArtifactModel(BaseModel): mode=ResourceConstraintMode.EXACT, ) -__all__ = [ - "ArtifactModel", - "rules", - "conversation_flow", - "context", - "resource_constraint", -] +# Create instance of the GuidedConversationDefinition model with the above configuration. +poem_feedback = GuidedConversationDefinition( + artifact=json.dumps(ArtifactModel.model_json_schema(), indent=2), + rules=rules, + conversation_flow=conversation_flow.strip(), + context=context.strip(), + resource_constraint=GuidedConversationDefinition.ResourceConstraint( + quantity=10, + unit=ResourceConstraintUnit.TURNS, + mode=ResourceConstraintMode.EXACT, + ), +) diff --git a/assistants/guided-conversation-assistant/assistant/agents/guided_conversation_agent.py b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation_agent.py index 19de6399..498a9c91 100644 --- a/assistants/guided-conversation-assistant/assistant/agents/guided_conversation_agent.py +++ b/assistants/guided-conversation-assistant/assistant/agents/guided_conversation_agent.py @@ -49,11 +49,11 @@ async def step_conversation( Step the conversation to the next turn. """ - rules = agent_config.rules - conversation_flow = agent_config.conversation_flow - context = agent_config.context - resource_constraint = agent_config.resource_constraint - artifact = agent_config.get_artifact_model() + rules = agent_config.definition.rules + conversation_flow = agent_config.definition.conversation_flow + context = agent_config.definition.context + resource_constraint = agent_config.definition.resource_constraint + artifact = agent_config.definition.get_artifact_model() kernel = Kernel() service_id = "gc_main" diff --git a/workbench-app/src/components/App/FormWidgets/BaseModelEditorWidget.tsx b/workbench-app/src/components/App/FormWidgets/BaseModelEditorWidget.tsx index 17a78d12..4f320420 100644 --- a/workbench-app/src/components/App/FormWidgets/BaseModelEditorWidget.tsx +++ b/workbench-app/src/components/App/FormWidgets/BaseModelEditorWidget.tsx @@ -17,7 +17,7 @@ import { tokens, } from '@fluentui/react-components'; import { Add16Regular, Delete16Regular, Edit16Regular } from '@fluentui/react-icons'; -import { WidgetProps } from '@rjsf/utils'; +import { findSchemaDefinition, WidgetProps } from '@rjsf/utils'; import React, { useRef } from 'react'; const useClasses = makeStyles({ @@ -63,6 +63,47 @@ interface ModelSchema { }; } +const valueToModelSchema = (value: any): ModelSchema => { + // if value is a string, parse it as JSON to get the schema + // traverse the schema and replace all $refs with the actual definition + const schema = JSON.parse(value); + const traverse = (obj: any) => { + if (obj && typeof obj === 'object') { + if (obj.$ref) { + return findSchemaDefinition(obj.$ref, schema); + } + Object.entries(obj).forEach(([key, value]) => { + obj[key] = traverse(value); + }); + } + return obj; + }; + return traverse(schema); +}; + +const modelSchemaToValue = (modelSchema: ModelSchema): string => { + // convert the model schema back to a string + // traverse the schema and replace all definitions with $refs + const refs = new Map(); + const traverse = (obj: any) => { + if (obj && typeof obj === 'object') { + Object.entries(obj).forEach(([key, value]) => { + obj[key] = traverse(value); + }); + } + if (obj && obj.$id) { + refs.set(obj.$id, obj); + return { $ref: obj.$id }; + } + return obj; + }; + const schema = traverse(modelSchema); + refs.forEach((value, key) => { + schema[key] = value; + }); + return JSON.stringify(schema); +}; + export const BaseModelEditorWidget: React.FC = (props) => { const { label, value, onChange } = props; const classes = useClasses(); @@ -70,15 +111,13 @@ export const BaseModelEditorWidget: React.FC = (props) => { const [openItems, setOpenItems] = React.useState([]); // Define the schema type - const [modelSchema, setModelSchema] = React.useState(() => { - return typeof value === 'string' ? JSON.parse(value) : value || {}; - }); + const [modelSchema, setModelSchema] = React.useState(() => valueToModelSchema(value)); const [editingKey, setEditingKey] = React.useState<{ oldKey: string; newKey: string } | null>(null); // Update the modelSchema when the value changes React.useEffect(() => { - setModelSchema(typeof value === 'string' ? JSON.parse(value) : value || {}); + setModelSchema(valueToModelSchema(value)); }, [value]); // Helper function to update the modelSchema @@ -104,7 +143,7 @@ export const BaseModelEditorWidget: React.FC = (props) => { } setModelSchema(updatedModelSchema); - onChange(JSON.stringify(updatedModelSchema)); + onChange(modelSchemaToValue(updatedModelSchema)); }; // Helper function to update the property key diff --git a/workbench-app/src/components/App/FormWidgets/CustomizedFieldTemplate.tsx b/workbench-app/src/components/App/FormWidgets/CustomizedFieldTemplate.tsx new file mode 100644 index 00000000..74cd9126 --- /dev/null +++ b/workbench-app/src/components/App/FormWidgets/CustomizedFieldTemplate.tsx @@ -0,0 +1,128 @@ +import { Dropdown, Field, Option, Text, tokens } from '@fluentui/react-components'; +import { + FieldTemplateProps, + FormContextType, + getTemplate, + getUiOptions, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import React from 'react'; + +/** The `FieldTemplate` component is the template used by `SchemaField` to render any field. It renders the field + * content, (label, description, children, errors and help) inside of a `WrapIfAdditional` component. + * + * @param props - The `FieldTemplateProps` for this component + */ +export default function CustomizedFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: FieldTemplateProps) { + const { + id, + children, + classNames, + style, + disabled, + displayLabel, + formData, + hidden, + label, + onChange, + onDropPropertyClick, + onKeyChange, + readonly, + required, + rawErrors = [], + errors, + help, + description, + rawDescription, + schema, + uiSchema, + registry, + } = props; + const uiOptions = getUiOptions(uiSchema); + const WrapIfAdditionalTemplate = getTemplate<'WrapIfAdditionalTemplate', T, S, F>( + 'WrapIfAdditionalTemplate', + registry, + uiOptions, + ); + + // If uiSchema includes ui:options for this field, check if it has configurations + // These are used to provide a dropdown to select a configuration for the field + // that will update the formData value and allow users to switch between configurations + // If the user modifies the field value, the configuration dropdown will be reset + const configurationsComponent = React.useMemo(() => { + if (uiOptions && uiOptions['configurations'] && typeof uiOptions['configurations'] === 'object') { + // handle as record + const configurations = uiOptions['configurations'] as Record; + + // Handle selection change for dropdown + const handleSelectionChange = (_: React.SyntheticEvent, option: any) => { + const selectedConfig = configurations[option.optionValue]; + onChange(selectedConfig); + }; + + const selectedKey = + Object.keys(configurations).find( + (key) => JSON.stringify(configurations[key]) === JSON.stringify(formData), + ) || ''; + + const selectedOptions = selectedKey ? [selectedKey] : []; + + return ( + +
+ + {Object.entries(configurations).map(([key]) => ( + + ))} + +
+
+ ); + } + return null; + }, [formData, label, onChange, uiOptions]); + + if (hidden) { + return
{children}
; + } + return ( + + + {configurationsComponent} + {children} + {displayLabel && rawDescription ? ( + + {description} + + ) : null} + {errors} + {help} + + + ); +} diff --git a/workbench-app/src/components/Assistants/AssistantEdit.tsx b/workbench-app/src/components/Assistants/AssistantEdit.tsx index 039a7c6f..495248d6 100644 --- a/workbench-app/src/components/Assistants/AssistantEdit.tsx +++ b/workbench-app/src/components/Assistants/AssistantEdit.tsx @@ -14,6 +14,7 @@ import { useGetConfigQuery, useUpdateConfigMutation } from '../../services/workb import { ConfirmLeave } from '../App/ConfirmLeave'; import { BaseModelEditorWidget } from '../App/FormWidgets/BaseModelEditorWidget'; import { CustomizedArrayFieldTemplate } from '../App/FormWidgets/CustomizedArrayFieldTemplate'; +import CustomizedFieldTemplate from '../App/FormWidgets/CustomizedFieldTemplate'; import { CustomizedObjectFieldTemplate } from '../App/FormWidgets/CustomizedObjectFieldTemplate'; import { InspectableWidget } from '../App/FormWidgets/InspectableWidget'; import { Loading } from '../App/Loading'; @@ -130,6 +131,7 @@ export const AssistantEdit: React.FC = (props) => { const templates = { ArrayFieldTemplate: CustomizedArrayFieldTemplate, + FieldTemplate: CustomizedFieldTemplate, ObjectFieldTemplate: CustomizedObjectFieldTemplate, };