Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor): add ai experiments #4348

Draft
wants to merge 16 commits into
base: staging
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
FixedInstanceData,
featureI18nForServerOnly,
} from '@/helper/feature-i18n-for-server-only'
import { isProduction } from '@/helper/is-production'
import { triggerSentry } from '@/helper/trigger-sentry'

export interface FrontendClientBaseProps {
Expand Down Expand Up @@ -126,7 +127,9 @@ export function FrontendClientBase({
</Head>
) : null}
<AuthProvider unauthenticatedAuthorizationPayload={authorization}>
<SerloOnlyFeaturesContext.Provider value={{ isSerlo: true }}>
<SerloOnlyFeaturesContext.Provider
value={{ isSerlo: true, isProduction }}
>
<StaticStringsProvider
value={
instanceData.lang === 'de'
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/serlo-editor-integration/create-plugins.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PluginsWithData } from '@editor/plugin/helpers/editor-plugins'
import { aiGenerationPlugin } from '@editor/plugins/ai-generation'
import { anchorPlugin } from '@editor/plugins/anchor'
import { articlePlugin } from '@editor/plugins/article'
import { audioPlugin } from '@editor/plugins/audio'
Expand Down Expand Up @@ -50,6 +51,7 @@ import { imagePlugin } from '@/serlo-editor-integration/image-with-serlo-config'

export function createPlugins({ lang }: { lang: Instance }): PluginsWithData {
const plugins = [
EditorPluginType.AiGeneration,
EditorPluginType.Anchor,
EditorPluginType.Article,
EditorPluginType.Audio,
Expand Down Expand Up @@ -124,6 +126,11 @@ export function createPlugins({ lang }: { lang: Instance }): PluginsWithData {
...(isProduction
? []
: [{ type: EditorPluginType.Audio, plugin: audioPlugin }]),

// TODO: Hide behind experimental flag
...(isProduction
? []
: [{ type: EditorPluginType.AiGeneration, plugin: aiGenerationPlugin }]),
...(isProduction
? []
: [
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/serlo-editor-integration/serlo-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createRenderers } from './create-renderers'
import { useSerloHandleLearnerEvent } from './use-handle-learner-event'
import { useAuthentication } from '@/auth/use-authentication'
import { useInstanceData } from '@/contexts/instance-context'
import { isProduction } from '@/helper/is-production'
import type { SetEntityMutationData } from '@/mutations/use-set-entity-mutation/types'

const Editor = dynamic(() => import('@editor/core').then((mod) => mod.Editor), {
Expand Down Expand Up @@ -64,7 +65,12 @@ export function SerloEditor({
value={{ editorVariant: 'serlo-org', userId: String(auth?.id) }}
>
<SerloOnlyFeaturesContext.Provider
value={{ isSerlo: true, licenses, ArticleAddModal }}
value={{
isSerlo: true,
licenses,
ArticleAddModal,
isProduction,
}}
>
<Editor initialState={initialState}>
<SaveButton onSave={onSave} isInTestArea={isInTestArea} />
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEditStrings } from '@editor/i18n/edit-strings-provider'
import { AiChangePluginTool } from '@editor/plugins/ai-generation/plugin-change-tool/ai-change-plugin-tool'
import {
insertPluginChildAfter,
removePluginChild,
Expand All @@ -9,8 +10,9 @@ import {
useAppDispatch,
} from '@editor/store'
import { EditorPluginType } from '@editor/types/editor-plugin-type'
import { SerloOnlyFeaturesContext } from '@editor/utils/serlo-extra-context'
import { faClone, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
import { useCallback, useMemo } from 'react'
import { useCallback, useContext, useMemo } from 'react'

import { AnchorLinkCopyTool } from './anchor-link-copy-tool'
import { DropdownButton } from './dropdown-button'
Expand All @@ -33,6 +35,9 @@ export function PluginDefaultTools({ pluginId }: PluginDefaultToolsProps) {
[pluginId, store]
)

const serloContext = useContext(SerloOnlyFeaturesContext)
const showAiTools = serloContext.isSerlo && !serloContext.isProduction

const handleDuplicatePlugin = useCallback(() => {
const parent = selectChildTreeOfParent(store.getState(), pluginId)
if (!parent) return
Expand Down Expand Up @@ -78,7 +83,9 @@ export function PluginDefaultTools({ pluginId }: PluginDefaultToolsProps) {
<>
{hasRowsParent ? (
<>
{showAiTools ? <AiChangePluginTool pluginId={pluginId} /> : null}
<DropdownButton
separatorTop={showAiTools}
onClick={handleDuplicatePlugin}
label={pluginStrings.rows.duplicate}
icon={faClone}
Expand Down
11 changes: 11 additions & 0 deletions packages/editor/src/i18n/strings/de/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export const editStrings = {
},
},
plugins: {
aiGeneration: {
title: 'Inhalt per AI generieren',
modalTitle: 'Was soll generiert werden?',
placeholder:
'z.B. "Wiederhole den Satz des Pythagoras mit einer interaktiven Verständnisfrage am Ende',
buttonText: 'Inhalt generieren',
menuText: 'Aktuelles Plugin per AI überarbeiten',
},
anchor: {
title: 'Sprungmarke',
description: 'Füge eine Sprungmarke innerhalb deines Inhalts hinzu.',
Expand Down Expand Up @@ -572,4 +580,7 @@ export const editStrings = {
createdAt: 'Zeitstempel',
ready: 'Bereit zum Speichern?',
},
aiFeatures: {
pluginToolReworkWithAi: 'Aktuelles Plugin per AI überarbeiten',
},
}
11 changes: 11 additions & 0 deletions packages/editor/src/i18n/strings/en/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export const editStrings = {
},
},
plugins: {
aiGeneration: {
title: 'Create content via AI',
modalTitle: 'What should be generated?',
placeholder:
'Describe what you want to generate. E.g. "Create an explanation for the quadratic formula with an exercise at the end."',
buttonText: 'Generate content',
menuText: 'Revise current plugin via AI',
},
anchor: {
title: 'Anchor',
description: 'Insert an anchor.',
Expand Down Expand Up @@ -554,4 +562,7 @@ export const editStrings = {
createdAt: 'when?',
ready: 'Ready to save?',
},
aiFeatures: {
pluginToolReworkWithAi: 'Revise current plugin via AI',
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEditStrings } from '@editor/i18n/edit-strings-provider'
elbotho marked this conversation as resolved.
Show resolved Hide resolved
import { cn } from '@editor/utils/cn'
import { useState } from 'react'

export function PromtForm({
onSubmit,
}: {
onSubmit: (prompt: string) => void
}) {
const aiStrings = useEditStrings().plugins.aiGeneration
const [prompt, setPrompt] = useState('')

return (
<form>
<textarea
className={cn(`ml-side w-[calc(100%-32px)] rounded-xl border-2 border-editor-primary-100
bg-editor-primary-100 px-2.5 py-2 text-almost-black
focus:border-editor-primary focus:outline-none`)}
value={prompt}
rows={5}
placeholder={aiStrings.placeholder}
onChange={(e) => setPrompt(e.target.value)}
/>
<button
type="submit"
className="serlo-button-edit-primary mx-side mt-4 px-4"
onClick={(e) => {
e.preventDefault()
onSubmit(prompt)
setPrompt('')
}}
>
{aiStrings.buttonText}
</button>
</form>
)
}
31 changes: 31 additions & 0 deletions packages/editor/src/plugins/ai-generation/decoder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { EditorPluginType } from '@editor/types/editor-plugin-type'
import * as t from 'io-ts'

/*
* Simple validation of editor state
*/
export const StateDecoder = t.strict({
plugin: t.literal(EditorPluginType.Rows),
state: t.array(
t.strict({
plugin: t.union([
t.literal(EditorPluginType.Article),
t.literal(EditorPluginType.ArticleIntroduction),
t.literal(EditorPluginType.Geogebra),
t.literal(EditorPluginType.Anchor),
t.literal(EditorPluginType.Video),
t.literal(EditorPluginType.Audio),
t.literal(EditorPluginType.SerloTable),
t.literal(EditorPluginType.Highlight),
t.literal(EditorPluginType.Injection),
t.literal(EditorPluginType.Multimedia),
t.literal(EditorPluginType.Spoiler),
t.literal(EditorPluginType.Box),
t.literal(EditorPluginType.Image),
t.literal(EditorPluginType.Text),
t.literal(EditorPluginType.Equations),
]),
state: t.unknown,
})
),
})
85 changes: 85 additions & 0 deletions packages/editor/src/plugins/ai-generation/editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { EditorModal } from '@editor/editor-ui/editor-modal'
import { showToastNotice } from '@editor/editor-ui/show-toast-notice'
import { useEditStrings } from '@editor/i18n/edit-strings-provider'
import { EditorPluginType } from '@editor/package'
import {
insertPluginChildBefore,
removePluginChild,
selectChildTreeOfParent,
selectStaticDocument,
useAppDispatch,
useStore,
} from '@editor/store'
import { either as E } from 'fp-ts'

import { type AiGenerationPluginProps } from '.'
import { PromtForm } from './components/promt-form'
import { StateDecoder } from './decoder'
import { mocked } from './mocked'

export function AiGenerationEditor(props: AiGenerationPluginProps) {
const aiStrings = useEditStrings().plugins.aiGeneration

const store = useStore()
const dispatch = useAppDispatch()

// TODO: i18n
function throwError(error?: unknown) {
showToastNotice('⚠️ Sorry, something is wrong with the data.', 'warning')
// eslint-disable-next-line no-console
console.error(error)
throw new Error(
'JSON input data is not a valid editor-state or contains unsupported plugins'
)
}

function handleSubmit(prompt: string) {
console.log(prompt)

// TODO: fetch, validate, loading states etc.

const decoded = StateDecoder.decode(mocked)

if (E.isLeft(decoded)) return throwError()

const content = decoded.right

const parentPlugin = selectChildTreeOfParent(store.getState(), props.id)

// for now make sure we only use it in rows plugin until we provide a list of allowed plugins
if (
parentPlugin === null ||
selectStaticDocument(store.getState(), parentPlugin.id)?.plugin !==
EditorPluginType.Rows
) {
const msg = 'Ai generation can only be used inside a rows plugin!'
showToastNotice(msg)
// eslint-disable-next-line no-console
console.error(msg)
return
}

for (const document of content.state) {
dispatch(
insertPluginChildBefore({
parent: parentPlugin.id,
sibling: props.id,
document,
})
)
}
dispatch(removePluginChild({ parent: parentPlugin.id, child: props.id }))
}

return (
<EditorModal
title={aiStrings.modalTitle}
isOpen
setIsOpen={() => {}}
className="top-8 max-w-xl translate-y-0 sm:top-24"
extraTitleClassName="serlo-h3 mt-4"
>
<PromtForm onSubmit={handleSubmit} />
</EditorModal>
)
}
21 changes: 21 additions & 0 deletions packages/editor/src/plugins/ai-generation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {
type EditorPlugin,
type EditorPluginProps,
object,
} from '@editor/plugin'

import { AiGenerationEditor } from './editor'

const aiGenerationState = object({})

export type AiGenerationPluginState = typeof aiGenerationState
export type AiGenerationPluginProps = EditorPluginProps<AiGenerationPluginState>

/*
* Experimental Plugin for generating content with AI
*/
export const aiGenerationPlugin: EditorPlugin<AiGenerationPluginState> = {
Component: AiGenerationEditor,
state: aiGenerationState,
config: {},
}
Loading
Loading