Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions services/cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
"axios": "^1.13.6",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"diff": "^8.0.3",
"dompurify": "^3.3.2",
"express": "^5.2.1",
"http-proxy-middleware": "^3.0.5",
"i18next": "^25.8.13",
Expand Down
157 changes: 80 additions & 77 deletions services/cms/pnpm-lock.yaml

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions services/cms/src/components/editors/GutenbergEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { useTranslation } from "react-i18next"
import useDisableBrowserDefaultDragFileBehavior from "../../hooks/useDisableBrowserDefaultDragFileBehavior"
import useSidebarStartingYCoodrinate from "../../hooks/useSidebarStartingYCoodrinate"
import { MediaUploadProps } from "../../services/backend/media/mediaUpload"
import { registerEditorAiAbilities } from "../../utils/Gutenberg/ai/abilities"
import {
modifyEmbedBlockAttributes,
modifyImageBlockAttributes,
Expand All @@ -61,6 +62,7 @@ import { modifyGutenbergCategories } from "../../utils/Gutenberg/modifyGutenberg
import { registerBlockVariations } from "../../utils/Gutenberg/registerBlockVariations"
import runMigrationsAndValidations from "../../utils/Gutenberg/runMigrationsAndValidations"
import withMentimeterInspector from "../../utils/Gutenberg/withMentimeterInspector"
import withParagraphAiToolbarAction from "../../utils/Gutenberg/withParagraphAiToolbarAction"
import CommonKeyboardShortcuts from "../CommonKeyboardShortcuts"

import SelectField from "@/shared-module/common/components/InputFields/SelectField"
Expand Down Expand Up @@ -203,6 +205,14 @@ const GutenbergEditor: React.FC<React.PropsWithChildren<GutenbergEditorProps>> =
}
}, [])

useEffect(() => {
registerEditorAiAbilities()
addFilter("editor.BlockEdit", "moocfi/cms/paragraphAiToolbar", withParagraphAiToolbarAction)
return () => {
removeFilter("editor.BlockEdit", "moocfi/cms/paragraphAiToolbar")
}
}, [])

// This **should** be the last useEffect as it supposes that Gutenberg is fully set up
// Runs migrations and validations for the blocks
useEffect(() => {
Expand Down
19 changes: 19 additions & 0 deletions services/cms/src/services/backend/ai-suggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { cmsClient } from "./cmsClient"

import type {
ParagraphSuggestionRequest,
ParagraphSuggestionResponse,
} from "@/shared-module/common/bindings"
import { isParagraphSuggestionResponse } from "@/shared-module/common/bindings.guard"
import { validateResponse } from "@/shared-module/common/utils/fetching"

/**
* Sends a ParagraphSuggestionRequest to `/ai-suggestions/paragraph` and returns a validated ParagraphSuggestionResponse.
* Uses `validateResponse` with `isParagraphSuggestionResponse`; throws on request or validation failures.
*/
export async function requestParagraphSuggestions(
payload: ParagraphSuggestionRequest,
): Promise<ParagraphSuggestionResponse> {
const response = await cmsClient.post("/ai-suggestions/paragraph", payload)
return validateResponse(response, isParagraphSuggestionResponse)
}
145 changes: 145 additions & 0 deletions services/cms/src/utils/Gutenberg/ai/abilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { AI_ACTIONS, AI_TONE_SUBMENU, AI_TRANSLATE_SUBMENU } from "./menu"
import { registerAbility } from "./registry"
import type { AbilityDefinition } from "./types"

import { requestParagraphSuggestions } from "@/services/backend/ai-suggestions"
import type {
ParagraphSuggestionContext,
ParagraphSuggestionRequest,
} from "@/shared-module/common/bindings"

export interface ParagraphAbilityInputMeta {
tone?: string
language?: string
settingType?: string
context?: ParagraphSuggestionContext | null
}

export interface ParagraphAbilityInput {
text: string
isHtml?: boolean
meta?: ParagraphAbilityInputMeta
}

const BASE_INPUT_SCHEMA = {
type: "object",
properties: {
text: { type: "string" },
isHtml: { type: "boolean" },
meta: { type: "object" },
},
required: ["text"],
}

const BASE_OUTPUT_SCHEMA = {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
}

const buildParagraphSuggestionMeta = (
meta?: ParagraphAbilityInputMeta,
): ParagraphSuggestionRequest["meta"] => {
if (!meta) {
return null
}

return {
tone: meta.tone ?? null,
language: meta.language ?? null,
setting_type: meta.settingType ?? null,
}
}

export const buildParagraphSuggestionRequest = (
action: string,
input: ParagraphAbilityInput,
): ParagraphSuggestionRequest => ({
action,
content: input.text,
is_html: input.isHtml ?? false,
meta: buildParagraphSuggestionMeta(input.meta),
context: input.meta?.context ?? null,
})

const fixSpellingAbility: AbilityDefinition<
ParagraphAbilityInput,
{ text: string; suggestions: string[] }
> = {
name: "moocfi/fix-spelling",
label: "Fix spelling",
description: "Fix spelling and grammar in the selected text",
category: "ai",
input_schema: BASE_INPUT_SCHEMA,
output_schema: BASE_OUTPUT_SCHEMA,
callback: async (input) => {
const payload = buildParagraphSuggestionRequest("moocfi/fix-spelling", input)
const response = await requestParagraphSuggestions(payload)

const suggestions = response.suggestions ?? []

if (suggestions.length === 0) {
throw new Error("No AI suggestions were returned for fix spelling")
}

return {
text: suggestions[0] ?? input.text,
suggestions,
}
},
}

function createPlaceholderAbility(
abilityName: string,
label: string,
description: string,
): AbilityDefinition<ParagraphAbilityInput, { text: string; suggestions: string[] }> {
return {
name: abilityName,
label,
description,
category: "ai",
input_schema: BASE_INPUT_SCHEMA,
output_schema: BASE_OUTPUT_SCHEMA,
callback: async (input) => {
const payload = buildParagraphSuggestionRequest(abilityName, input)
const response = await requestParagraphSuggestions(payload)

const suggestions = response.suggestions ?? []

if (suggestions.length === 0) {
throw new Error(`No AI suggestions were returned for ability ${abilityName}`)
}

return {
text: suggestions[0] ?? input.text,
suggestions,
}
},
}
}

const allPlaceholderAbilities: AbilityDefinition<
ParagraphAbilityInput,
{ text: string; suggestions: string[] }
>[] = [
...AI_ACTIONS.filter((action) => action.abilityName !== "moocfi/fix-spelling").map((action) =>
createPlaceholderAbility(action.abilityName, action.id, `Placeholder for ${action.id}`),
),
...AI_TONE_SUBMENU.actions.map((action) =>
createPlaceholderAbility(action.abilityName, action.id, `Placeholder for ${action.id}`),
),
...AI_TRANSLATE_SUBMENU.actions.map((action) =>
createPlaceholderAbility(action.abilityName, action.id, `Placeholder for ${action.id}`),
),
]

/** Registers all editor AI abilities (call once when editor boots). */
export function registerEditorAiAbilities(): void {
registerAbility(fixSpellingAbility)
for (const ability of allPlaceholderAbilities) {
registerAbility(ability)
}
}

export { getAbility, executeAbility, registerAbilityCategory } from "./registry"
Loading
Loading