diff --git a/apps/backend/shared/ai/schemas/CcStructuredContent.ts b/apps/backend/shared/ai/schemas/CcStructuredContent.ts index 310a69572..508283012 100644 --- a/apps/backend/shared/ai/schemas/CcStructuredContent.ts +++ b/apps/backend/shared/ai/schemas/CcStructuredContent.ts @@ -1,4 +1,5 @@ import type { AiContext } from '@tailor-cms/interfaces/ai.ts'; +import type { Activity } from '@tailor-cms/interfaces/activity.ts'; import { getSchema as getMetaInputSchema, } from '@tailor-cms/meta-element-collection/schema.js'; @@ -36,6 +37,7 @@ type SubcontainerConfigs = Record; interface ParsedConfig { subcontainers: SubcontainerConfigs; + defaultSubcontainers: Pick[]; ai?: { definition?: string; outputRules?: { prompt?: string }; @@ -97,21 +99,32 @@ const toMetaFields = (meta: any[]): MetaField[] => }), })); +// Container-level options – not subcontainer type definitions. +const CONTAINER_OPTIONS = [ + 'isCollapsible', + 'collapsedPreviewKey', + 'defaultSubcontainers', +]; + +const EMPTY_CONFIG: ParsedConfig = { + subcontainers: {}, + defaultSubcontainers: [], +}; + // Parse container schema config into per-subcontainer configs. // Resolves element types and meta field schemas for each // subcontainer type defined in the container config. const getConfigs = (context: AiContext): ParsedConfig => { - const empty: ParsedConfig = { subcontainers: {} }; const { outlineActivityType, containerType } = context.repository; - if (!outlineActivityType || !containerType) return empty; + if (!outlineActivityType || !containerType) return EMPTY_CONFIG; const containers = schemaAPI.getSupportedContainers( outlineActivityType, ); const container = containers.find( (c: any) => c.type === containerType, ); - if (!container?.config) return empty; + if (!container?.config) return EMPTY_CONFIG; // Container-level element types as default fallback const defaultElementTypes = getElementTypeIds( container.contentElementConfig, @@ -120,6 +133,7 @@ const getConfigs = (context: AiContext): ParsedConfig => { for (const [type, val] of Object.entries( container.config as Record, )) { + if (CONTAINER_OPTIONS.includes(type)) continue; // Subcontainer config overrides container-level const elementTypes = val.contentElementConfig ? getElementTypeIds(val.contentElementConfig) @@ -130,14 +144,22 @@ const getConfigs = (context: AiContext): ParsedConfig => { metaInputs: toMetaFields(getMetaDefinitions(val)), }; } - return { subcontainers, ai: container.ai }; + return { + subcontainers, + defaultSubcontainers: + container.config.defaultSubcontainers || [], + ai: container.ai, + }; }; // Build JSON schema for a single subcontainer type: // discriminated by type enum, with per-type elements and data. +// When defaultData is provided, matching meta fields are +// pinned to exact values via JSON Schema `const`. const buildSubcontainerSchema = ( type: string, config: SubcontainerConfig, + defaultData?: Record, ) => { const { metaInputs, elementTypes } = config; const props: Record = { @@ -149,10 +171,14 @@ const buildSubcontainerSchema = ( const dataRequired: string[] = []; for (const field of metaInputs) { if (!field.schema) continue; - const values = field.options?.map((o) => o.value); - dataProps[field.key] = values?.length - ? { ...field.schema, enum: values } - : field.schema; + if (defaultData?.[field.key] != null) { + dataProps[field.key] = { ...field.schema, enum: [defaultData[field.key]] }; + } else { + const values = field.options?.map((o) => o.value); + dataProps[field.key] = values?.length + ? { ...field.schema, enum: values } + : field.schema; + } dataRequired.push(field.key); } if (Object.keys(dataProps).length) { @@ -165,8 +191,12 @@ const buildSubcontainerSchema = ( // Build OpenAI structured output schema from container config. // Each subcontainer type becomes a discriminated union variant // with its own allowed element types and metadata fields. +// When defaultSubcontainers are defined, uses a tuple to +// enforce exact count, types, and data at each position. export const Schema = (context: AiContext): OpenAISchema => { - const { subcontainers } = getConfigs(context); + const { + subcontainers, defaultSubcontainers, + } = getConfigs(context); // Default to generic section when no config is defined const entries = Object.entries(subcontainers); if (!entries.length) { @@ -174,18 +204,32 @@ export const Schema = (context: AiContext): OpenAISchema => { label: 'Section', metaInputs: [], elementTypes: [], }]); } - // Build per-subcontainer schema with type discriminator - const schemas = entries.map(([type, config]) => - buildSubcontainerSchema(type, config), - ); - const subcontainerSchema = schemas.length === 1 - ? schemas[0] - : { anyOf: schemas }; + let subcontainersSchema: Record; + if (defaultSubcontainers.length) { + const schemas = entries.map(([type, config]) => + buildSubcontainerSchema(type, config)); + const itemSchema = schemas.length === 1 + ? schemas[0] + : { anyOf: schemas }; + subcontainersSchema = { + type: 'array', + items: itemSchema, + minItems: defaultSubcontainers.length, + maxItems: defaultSubcontainers.length, + }; + } else { + const schemas = entries.map(([type, config]) => + buildSubcontainerSchema(type, config)); + const itemSchema = schemas.length === 1 + ? schemas[0] + : { anyOf: schemas }; + subcontainersSchema = { type: 'array', items: itemSchema }; + } return { type: 'json_schema', name: 'cc_structured_content', schema: obj( - { subcontainers: { type: 'array', items: subcontainerSchema } }, + { subcontainers: subcontainersSchema }, ['subcontainers'], ), }; @@ -221,10 +265,25 @@ const describeSubcontainerTypes = (configs: SubcontainerConfigs): string => { .join('\n'); }; +const describeDefaultSubcontainers = ( + defaults: Pick[], + configs: SubcontainerConfigs, +): string => + defaults + .map((defaultSub) => { + const label = + configs[defaultSub.type]?.label || defaultSub.type; + const title = defaultSub.data?.title; + return title + ? ` - "${title}" (${label})` + : ` - ${label}`; + }) + .join('\n'); + // Build prompt with available element types, subcontainer types, // metadata fields, and container-level AI instructions. export const getPrompt = (context: AiContext) => { - const { subcontainers, ai } = getConfigs(context); + const { subcontainers, defaultSubcontainers, ai } = getConfigs(context); // Collect all unique element types across subcontainers const allElementTypes = [ ...new Set(Object.values(subcontainers).flatMap((c) => c.elementTypes)), @@ -248,6 +307,15 @@ export const getPrompt = (context: AiContext) => { '- Do not invent information not present in the documents', ); } + if (defaultSubcontainers.length) { + guidelines.push( + '- Generate exactly these subcontainers in this exact order,' + + ' one per entry, each with a unique title as listed:\n' + + describeDefaultSubcontainers( + defaultSubcontainers, subcontainers, + ), + ); + } if (ai?.outputRules?.prompt) { guidelines.push(ai.outputRules.prompt.trim()); } diff --git a/apps/frontend/app/components/editor/ActivityContent/ContainerList.vue b/apps/frontend/app/components/editor/ActivityContent/ContainerList.vue index d950fe0ad..0841a26ca 100644 --- a/apps/frontend/app/components/editor/ActivityContent/ContainerList.vue +++ b/apps/frontend/app/components/editor/ActivityContent/ContainerList.vue @@ -210,9 +210,11 @@ const requestDeletion = ( const requestContainerDeletion = ( container: Activity, - name: string = 'container', + opts: string | { force?: boolean } = 'container', ) => { const action = (val: Activity) => activityStore.remove(val.id); + if (typeof opts === 'object' && opts.force) return action(container); + const name = typeof opts === 'string' ? opts : 'container'; requestDeletion(container, action, name); }; diff --git a/config/src/schemas/partner-training-v2.schema.ts b/config/src/schemas/partner-training-v2.schema.ts index c5d05af60..30538af42 100644 --- a/config/src/schemas/partner-training-v2.schema.ts +++ b/config/src/schemas/partner-training-v2.schema.ts @@ -137,6 +137,13 @@ const SectionContainer: ContentContainerConfig = { required: true, displayHeading: false, config: { + isCollapsible: true, + defaultSubcontainers: [ + { type: ActivityType.Section, data: { title: 'Intro' } }, + { type: ActivityType.Section, data: { title: 'Key Concepts' } }, + { type: ActivityType.Section, data: { title: 'Top Advisors' } }, + { type: ActivityType.Section, data: { title: 'Talking Points' } }, + ], [ActivityType.Section]: { label: 'Section', meta: () => [...sectionMeta], diff --git a/packages/core-extensions/content-containers/structured-content/src/edit/StructuredSubcontainer.vue b/packages/core-extensions/content-containers/structured-content/src/edit/StructuredSubcontainer.vue index a96530fd6..d70e85f58 100644 --- a/packages/core-extensions/content-containers/structured-content/src/edit/StructuredSubcontainer.vue +++ b/packages/core-extensions/content-containers/structured-content/src/edit/StructuredSubcontainer.vue @@ -1,52 +1,86 @@ @@ -71,6 +105,9 @@ const props = defineProps<{ layout?: boolean; contentElementConfig?: Array; disableContentElementList?: boolean; + isCollapsible?: boolean; + expandAll?: boolean; + collapsedPreviewKey?: string | null; }>(); const emit = defineEmits([ @@ -81,6 +118,30 @@ const emit = defineEmits([ 'delete:element', ]); +const elementCount = computed(() => { + return Object.values(props.elements).filter( + (el) => el.activityId === props.container.id, + ).length; +}); + +const isExpanded = ref(true); + +const collapsedPreviewText = computed(() => { + const key = props.collapsedPreviewKey || props.meta?.[0]?.key; + return key ? props.container?.data?.[key] || '' : ''; +}); + +const toggleExpanded = () => { + isExpanded.value = !isExpanded.value; +}; + +watch( + () => props.expandAll, + (expanded) => { + if (props.isCollapsible) isExpanded.value = !!expanded; + }, +); + const containerData = ref({ ...props.container?.data }) as any; const processedMeta = computed(() => @@ -102,4 +163,9 @@ watch(containerData, save, { deep: true }); .meta-container :deep(.v-messages) { text-align: left; } + +.subcontainer-header-collapsible { + cursor: pointer; + user-select: none; +} diff --git a/packages/core-extensions/content-containers/structured-content/src/edit/config.js b/packages/core-extensions/content-containers/structured-content/src/edit/config.js deleted file mode 100644 index b0d116e06..000000000 --- a/packages/core-extensions/content-containers/structured-content/src/edit/config.js +++ /dev/null @@ -1,36 +0,0 @@ -import { capitalize, reduce, words } from 'lodash-es'; - -const DefaultSubcontainers = { - Section: 'SECTION', -}; - -const DEFAULT_CONFIG = { - [DefaultSubcontainers.Section]: { - label: 'Section', - icon: 'mdi-text-box-outline', - layout: true, - meta: [], - }, -}; - -export const parseConfig = (repository, outlineActivity, container, config) => { - if (!config) return DEFAULT_CONFIG; - return reduce( - config, - (acc, val, key) => { - acc[key] = { - ...val, - icon: val.icon || 'mdi-text', - label: val.label || words(capitalize(key)), - meta: val?.meta?.(repository, outlineActivity, container, val) ?? [], - initMeta: () => - val?.initMeta?.(repository, outlineActivity, container, val) ?? {}, - contentElementConfig: val.contentElementConfig, - disableContentElementList: !!val.disableContentElementList, - disableAi: !!val.disableAi, - }; - return acc; - }, - {}, - ); -}; diff --git a/packages/core-extensions/content-containers/structured-content/src/edit/config.ts b/packages/core-extensions/content-containers/structured-content/src/edit/config.ts new file mode 100644 index 000000000..05f476a02 --- /dev/null +++ b/packages/core-extensions/content-containers/structured-content/src/edit/config.ts @@ -0,0 +1,67 @@ +import { capitalize, reduce, words } from 'lodash-es'; + +// Container-level options that are not subcontainer type definitions: +// - isCollapsible: enable collapse/expand for subcontainers +// - collapsedPreviewKey: meta key shown in collapsed header +// - defaultSubcontainers: subcontainers to create on mount +const CONTAINER_OPTIONS = [ + 'isCollapsible', + 'collapsedPreviewKey', + 'defaultSubcontainers', +]; + +const DefaultSubcontainerType = { + Section: 'SECTION', +}; + +const DEFAULT_SUBCONTAINERS = { + [DefaultSubcontainerType.Section]: { + label: 'Section', + icon: 'mdi-text-box-outline', + layout: true, + meta: [], + }, +}; + +const DEFAULT_RESULT = { + subcontainers: DEFAULT_SUBCONTAINERS, + defaultSubcontainers: [] as any[], + isCollapsible: false, +}; + +export const parseConfig = ( + repository: any, + outlineActivity: any, + container: any, + config: any, +) => { + if (!config) return DEFAULT_RESULT; + const { + isCollapsible = false, + collapsedPreviewKey = null, + defaultSubcontainers = [], + } = config; + const subcontainers = reduce( + config, + (acc: Record, val: any, key: string) => { + if (CONTAINER_OPTIONS.includes(key)) return acc; + acc[key] = { + ...val, + icon: val.icon || 'mdi-text', + label: val.label || words(capitalize(key)), + meta: val?.meta?.(repository, outlineActivity, container, val) ?? [], + initMeta: () => + val?.initMeta?.(repository, outlineActivity, container, val) ?? {}, + contentElementConfig: val.contentElementConfig, + disableContentElementList: !!val.disableContentElementList, + disableAi: !!val.disableAi, + isCollapsible: val.isCollapsible ?? isCollapsible, + collapsedPreviewKey: + val.collapsedPreviewKey || collapsedPreviewKey, + }; + return acc; + }, + {}, + ); + return { subcontainers, defaultSubcontainers, isCollapsible }; +}; diff --git a/packages/core-extensions/content-containers/structured-content/src/edit/index.vue b/packages/core-extensions/content-containers/structured-content/src/edit/index.vue index 88cdaf1e8..84b28e914 100644 --- a/packages/core-extensions/content-containers/structured-content/src/edit/index.vue +++ b/packages/core-extensions/content-containers/structured-content/src/edit/index.vue @@ -1,22 +1,45 @@